Ctrl K
ComunidadGuías…

electron/companion.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// The companion sidecar is the only part of the app that listens off-machine; the renderer never reaches its control port.
import { app, utilityProcess } from "electron";
import { randomBytes } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { resolveCompanionEntry } from "./companion-entry.mjs";
import { anyRouteOn, COMPANION_SETTINGS_VERSION, countPairedPhones, directRoutesOn, hasTailnetAddress, phoneLinkStartsAtLaunch, readCompanionSettings, readPairedDevices, setRoute } from "./companion-settings.mjs";
import {
  cleanupCompanionOriginEndpoint,
  companionOriginHealth,
  createCompanionOriginEndpoint,
} from "./companion-origin-gateway.mjs";

// Must stay clear of the harness's 8799 and its webhook receiver's 8800.
const CONTROL_PORT = 8811;
const COMPANION_PORT = 8810;

let proc = null;
// New for each sidecar launch and held only by main and the sidecar, so a page served from the control port cannot drive it.
let controlToken = "";
let lastError = null;
let advertisedHostedUrl = null;
let originTarget = null;
let lifecycleListener = () => {};
const expectedStops = new WeakSet();

const entryPoint = (resourcesPath) =>
  resolveCompanionEntry({
    isPackaged: app.isPackaged,
    resourcesPath,
    appPath: app.getAppPath(),
    exists: fs.existsSync,
  });

const settingsFile = () => path.join(app.getPath("userData"), "companion-settings.json");

let migrationFacts = () => ({});

/** Main's part of the one-time update: whether this computer has its managed HTTPS address. */
export function setCompanionMigrationFacts(read) {
  migrationFacts = read;
}

export function companionSettings() {
  let raw = null;
  try {
    raw = JSON.parse(fs.readFileSync(settingsFile(), "utf8"));
  } catch {
  }
  if (raw?.version === COMPANION_SETTINGS_VERSION) return readCompanionSettings(raw);
  const settings = readCompanionSettings(raw, raw?.enabled === true ? { devices: readPairedDevices() ?? [], tailnetAddress: hasTailnetAddress(), ...migrationFacts() } : {});
  if (raw) writeCompanionSettings(settings);
  return settings;
}

/** Any way in turned on: the old single switch reads this. */
export function companionEnabledAtRest() {
  return anyRouteOn(companionSettings());
}

export function companionRouteOn(route) {
  return companionSettings().routes[route]?.on === true;
}

/** The launch rule, with this computer's paired devices read from the companion's store. */
export function companionStartsAtLaunch({ remoteClient, serverReady }) {
  return phoneLinkStartsAtLaunch({ remoteClient, serverReady, settings: companionSettings(), pairedPhones: countPairedPhones() });
}

export function companionKeepAwakeAtRest() {
  return companionSettings().keepAwake;
}

function writeCompanionSettings(next) {
  const file = settingsFile();
  const temporary = `${file}.${process.pid}.tmp`;
  try {
    fs.mkdirSync(path.dirname(file), { recursive: true });
    fs.writeFileSync(temporary, JSON.stringify(next, null, 2));
    fs.renameSync(temporary, file);
  } catch {
    try {
      fs.unlinkSync(temporary);
    } catch {
    }
  }
}

export function rememberCompanionKeepAwake(keepAwake) {
  writeCompanionSettings({ ...companionSettings(), keepAwake: keepAwake === true });
}

/** Turns one way in on or off for good, and tells a running sidecar which direct ways in to listen on. */
export async function rememberCompanionRoute(route, on, extra) {
  const next = setRoute(companionSettings(), route, on, extra);
  writeCompanionSettings(next);
  if (proc && (route === "wifi" || route === "tailscale")) {
    await control("PUT", "/routes", { ...directRoutesOn(next), tailnetName: next.routes.tailscale.name ?? null }).catch(() => {});
  }
  return next;
}

/** The person saw the update's note about a route kept on. */
export function dismissCompanionNotice(route) {
  const settings = companionSettings();
  writeCompanionSettings({ ...settings, notices: settings.notices.filter((notice) => notice !== route) });
}

async function control(method, urlPath, body, { timeoutMs = 4_000 } = {}) {
  const options = {
    method,
    signal: AbortSignal.timeout(timeoutMs),
    headers: { "x-openmausbot-control-token": controlToken },
  };
  if (body !== undefined) {
    options.body = JSON.stringify(body);
    options.headers["content-type"] = "application/json";
  }
  const res = await fetch(`http://127.0.0.1:${CONTROL_PORT}${urlPath}`, options);
  if (!res.ok && res.status !== 404) throw new Error(`companion control ${res.status}`);
  return res.json();
}

export function companionRunning() {
  return proc !== null;
}

/** Public state only: the connector credential never enters this module. */
export function companionAdvertisedHostedUrl() {
  return proc ? advertisedHostedUrl : null;
}

/** Main-process only: must never cross IPC into the renderer. */
export function companionOriginTarget() {
  return proc && originTarget ? { ...originTarget } : null;
}

/** The listener runs synchronously on exit and invalidates the guardian before this module removes the socket path. */
export function setCompanionLifecycleListener(listener = () => {}) {
  lifecycleListener = listener;
}

// Lifecycle transitions run one at a time: every guard below is a check followed by an await.
let transition = Promise.resolve();

const serialize = (work) => {
  const next = transition.then(work, work);
  // The chain must never carry a rejection forward, or one failure poisons every later transition.
  transition = next.then(
    () => {},
    () => {},
  );
  return next;
};

/** Resolves with the panel's state; a failed start is a message, never a thrown error. */
let e2eHooks = { initialMessage: () => null, tokenRejected: () => {}, changed: () => {} };

/** The end-to-end connection's part: its configuration goes to every new sidecar over the port, a refused relay token
 * comes back the same way, and pairing changes may need the relay. */
export function setCompanionE2EHooks(hooks) {
  e2eHooks = { ...e2eHooks, ...hooks };
}

/** In-memory port only. False when no sidecar runs. */
export function postCompanionMessage(message) {
  if (!proc) return false;
  try {
    proc.postMessage(message);
    return true;
  } catch {
    return false;
  }
}

export function startCompanion(options) {
  return serialize(() => start(options));
}

export function stopCompanion() {
  return serialize(() => stop());
}

async function start({ resourcesPath, harnessPort, mutationToken, hostedUrl = null, secretPublicKey = null, log }) {
  if (proc) return companionState();
  lastError = null;
  const resolved = entryPoint(resourcesPath);
  if (!resolved) {
    lastError = app.isPackaged
      ? "the companion is missing from this build"
      : "the companion sources are missing from this checkout";
    return companionState();
  }
  log?.(`companion fork ${resolved.entry}`);

  let allocatedOrigin;
  try {
    allocatedOrigin = createCompanionOriginEndpoint();
  } catch {
    lastError = "the private companion origin could not be created";
    return companionState();
  }
  let cleanedOrigin = false;
  const cleanupOrigin = () => {
    if (cleanedOrigin) return;
    cleanedOrigin = true;
    cleanupCompanionOriginEndpoint(allocatedOrigin);
  };

  // Never inherit an endpoint from the launch environment: main passes one only after verifying the connector.
  const childEnvironment = { ...process.env };
  delete childEnvironment.OMB_COMPANION_HOSTED_URL;
  delete childEnvironment.OMB_COMPANION_INTERNAL_ORIGIN;
  delete childEnvironment.OMB_PHONE_SECRET_PUBLIC_KEY;
  delete childEnvironment.OMB_COMPANION_TAILNET_NAME;
  const settings = companionSettings();
  const direct = directRoutesOn(settings);
  childEnvironment.OMB_COMPANION_ROUTES = Object.keys(direct).filter((route) => direct[route]).join(",");
  if (direct.tailscale && settings.routes.tailscale.name) childEnvironment.OMB_COMPANION_TAILNET_NAME = settings.routes.tailscale.name;
  if (hostedUrl) childEnvironment.OMB_COMPANION_HOSTED_URL = hostedUrl;
  childEnvironment.OMB_COMPANION_INTERNAL_ORIGIN = allocatedOrigin.socketPath;
  if (/^[A-Za-z0-9_-]{87}$/.test(String(secretPublicKey ?? ""))) {
    childEnvironment.OMB_PHONE_SECRET_PUBLIC_KEY = secretPublicKey;
  }

  controlToken = randomBytes(32).toString("base64url");
  let child;
  try {
    child = utilityProcess.fork(resolved.entry, [], {
      env: {
        ...childEnvironment,
        OMB_PORT: String(harnessPort),
        OMB_COMPANION_PORT: String(COMPANION_PORT),
        OMB_CONTROL_PORT: String(CONTROL_PORT),
      },
      execArgv: resolved.execArgv,
      stdio: ["ignore", "pipe", "pipe"],
    });
  } catch {
    cleanupOrigin();
    lastError = "the companion process could not be started";
    return companionState();
  }
  child.once("spawn", () => {
    // Never expose this capability in argv, environment, logs or the renderer.
    try {
      child.postMessage({ type: "openmausbot:companion-mutation-token", token: mutationToken, controlToken });
      const e2e = e2eHooks.initialMessage();
      if (e2e) child.postMessage(e2e);
    } catch {
      log?.("companion authorization could not be initialized");
      child.kill();
    }
  });
  child.on("message", (message) => {
    if (message?.type === "openmausbot:companion-e2e-token-rejected") e2eHooks.tokenRejected();
  });
  child.stdout?.on("data", (d) => log?.(`[companion] ${String(d).trimEnd()}`));
  child.stderr?.on("data", (d) => log?.(`[companion err] ${String(d).trimEnd()}`));

  let exited = false;
  child.once("exit", (code) => {
    exited = true;
    if (proc === child) {
      proc = null;
      advertisedHostedUrl = null;
      originTarget = null;
      lifecycleListener({
        type: "exit",
        expected: expectedStops.has(child),
        pid: child.pid,
      });
    }
    cleanupOrigin();
    log?.(`companion exited code=${code}`);
  });

  for (let i = 0; i < 40; i++) {
    if (exited) {
      lastError = "the companion could not start — check the log";
      return companionState();
    }
    try {
      const state = await control("GET", "/state");
      // A hand-started or leftover sidecar answers the port the same way: match the pid.
      if (state?.pid !== undefined && child.pid !== undefined && state.pid !== child.pid) {
        try {
          child.kill();
        } catch {
        }
        lastError = `port ${CONTROL_PORT} is already serving another companion — stop it and try again`;
        return companionState();
      }
      if (!Number.isSafeInteger(child.pid) || child.pid <= 0) {
        throw new Error("child pid unavailable");
      }
      const target = { pid: child.pid, socketPath: allocatedOrigin.socketPath };
      if (!(await companionOriginHealth(target))) throw new Error("private origin not ready");
      proc = child;
      advertisedHostedUrl = hostedUrl;
      originTarget = Object.freeze(target);
      return companionState();
    } catch {
      await new Promise((r) => setTimeout(r, 150));
    }
  }
  try {
    child.kill();
  } catch {
  }
  lastError = "the companion did not come up in time";
  return companionState();
}

/** stopCompanion's body, run inside the transition queue. */
async function stop() {
  const child = proc;
  proc = null;
  advertisedHostedUrl = null;
  originTarget = null;
  lastError = null;
  if (!child) return companionState();
  expectedStops.add(child);
  try {
    child.kill();
  } catch {
  }
  // kill() only asks: wait, bounded, for the exit so the next start does not race the port.
  await new Promise((resolve) => {
    let done = false;
    const finish = () => {
      if (done) return;
      done = true;
      resolve();
    };
    child.once("exit", finish);
    setTimeout(finish, 5_000).unref?.();
  });
  return companionState();
}

/** Callers publish only after public health verification succeeds. */
export function setCompanionHostedUrl(endpoint) {
  return serialize(async () => {
    if (!proc) return companionState();
    const state = await control("PUT", "/hosted-endpoint", { url: endpoint || null });
    advertisedHostedUrl = endpoint || null;
    return state;
  });
}

/** Paired devices as the sidecar would list them, read from its store while it sleeps. */
function devicesAtRest() {
  return (readPairedDevices() ?? []).map(({ tokenHash: _secret, ...device }) => device);
}

function restState() {
  const settings = companionSettings();
  return { keepAwake: settings.keepAwake, routes: settings.routes, notices: settings.notices };
}

export async function companionState() {
  const rest = restState();
  if (!proc) {
    const state = {
      enabled: false,
      ...rest,
      port: COMPANION_PORT,
      devices: devicesAtRest(),
      connectedDeviceIds: [],
      pairing: null,
    };
    if (lastError) state.error = lastError;
    return state;
  }
  try {
    const state = await control("GET", "/state");
    return { enabled: true, ...rest, ...state };
  } catch {
    return {
      enabled: true,
      ...rest,
      port: COMPANION_PORT,
      devices: [],
      connectedDeviceIds: [],
      pairing: null,
      error: "the companion is not responding",
    };
  }
}

/** A conditional close cannot erase a newer code created after the renderer began cancelling. */
export async function companionPairing(open, expectedToken) {
  if (!proc) return companionState();
  const conditionalClose = !open && expectedToken !== undefined;
  const candidate = String(expectedToken ?? "");
  const token = /^omb_pair_[A-Za-z0-9_-]{43}$/.test(candidate)
    ? candidate
    : "invalid-pairing-token";
  const path = conditionalClose
    ? `/pairing?expectedToken=${encodeURIComponent(token)}`
    : "/pairing";
  try {
    const state = await control(open ? "POST" : "DELETE", path);
    e2eHooks.changed();
    return {
      enabled: true,
      ...restState(),
      ...state,
    };
  } catch {
    const state = await companionState();
    return {
      ...state,
      error: state.error ?? "Phone pairing could not be updated.",
    };
  }
}

export async function companionRevoke(deviceId) {
  if (!proc) return companionState();
  // the id came from the renderer, so it does not get to shape a path
  if (!/^[\w-]{1,64}$/.test(String(deviceId ?? ""))) return companionState();
  await control("DELETE", `/devices/${deviceId}`).catch(() => {});
  e2eHooks.changed();
  return companionState();
}

/** A fresh code while the pairing screen stays open; the previous one keeps its last seconds. */
export async function companionPairingRenew() {
  if (!proc) return companionState();
  try {
    return { enabled: true, ...restState(), ...(await control("POST", "/pairing/renew")) };
  } catch {
    return companionPairing(true);
  }
}

/** "any" or "local" (Wi-Fi and Tailscale only). */
export async function companionDeviceRoutes(deviceId, routes) {
  if (!proc) return companionState();
  if (!/^[\w-]{1,64}$/.test(String(deviceId ?? "")) || (routes !== "any" && routes !== "local")) return companionState();
  await control("PUT", `/devices/${deviceId}/routes`, { routes }).catch(() => {});
  e2eHooks.changed();
  return companionState();
}

export async function companionCloudDesktopAccess(deviceId, allowed) {
  if (!proc) return companionState();
  if (!/^[\w-]{1,64}$/.test(String(deviceId ?? ""))) return companionState();
  await control(allowed ? "POST" : "DELETE", `/devices/${deviceId}/cloud-desktop`).catch(() => {});
  return companionState();
}

/** Browser access is its own per-device grant, never inherited from VM access. */
export async function companionBrowserControlAccess(deviceId, allowed) {
  if (!proc) return companionState();
  if (!/^[\w-]{1,64}$/.test(String(deviceId ?? ""))) return companionState();
  await control(allowed ? "POST" : "DELETE", `/devices/${deviceId}/browser-control`);
  return companionState();
}