electron/companion.mjs
Rama main · réplica de lectura del repositorio original.
// 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();
}