electron/computer-sharing.mjs
Rama main · réplica de lectura del repositorio original.
import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import { randomBytes, randomUUID } from "node:crypto";
import { setTimeout as delay } from "node:timers/promises";
import { allowedServerOrigin } from "./server-address.mjs";
import { createSharedCua, executeSharedOperation, personalSecretPaths, sharedComputerError, validCommandSeconds } from "./shared-computer-access.mjs";
import { createLentMcp, LENT_MCP_ACTIONS, ownPorts, validLentMcpServer } from "./lent-mcp.mjs";
import { sharedCuaConnection } from "./shared-cua-connection.mjs";
export { sharedCuaConnection };
import { createLendingActivity, describeSharedOperation } from "./lending-activity.mjs";
import { cuaUnavailable, SCREEN_LOCKED } from "./lent-screen-failures.mjs";
import { createLentScreenPresence } from "./lent-screen-presence.mjs";
import { BACKGROUND_HEADERS, CloudAsleepError, asleepAnswer, createCloudPowerGate } from "./cloud-power.mjs";
import { linkUrl, runDeviceLink } from "./device-link.mjs";
import { createComputerAsks } from "./computer-asks.mjs";
import { LINK_ASLEEP, linkPath } from "./device-link-protocol.mjs";
import { shareableDrive } from "./shared-drives.mjs";
import nativeText from "./native-text.cjs";
const uuid = value => typeof value === "string" && /^[a-f0-9]{8}-[a-f0-9]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i.test(value);
const DATA_VOLUME = "/System/Volumes/Data";
const identify = async candidate => { const info = await fsp.stat(candidate, { bigint: true }); return `${info.dev}:${info.ino}`; };
/** macOS reaches one home through a firmlink and again under the data volume, with different ancestors: walk both. */
function homeCandidates() {
const home = path.resolve(os.homedir());
const seeds = [home];
if (process.platform === "darwin" && !home.startsWith(`${DATA_VOLUME}${path.sep}`)) seeds.push(path.join(DATA_VOLUME, home));
return seeds;
}
/** {dev, ino}, not text: text misses firmlinks, bind mounts, case-insensitive spellings, Unicode normalization and 8.3/UNC names. */
async function enclosingHomeIdentities() {
const identities = new Set();
const [home, ...aliases] = homeCandidates();
const canonicalHome = await fsp.realpath(home);
const homeIdentity = await identify(canonicalHome);
const roots = [canonicalHome];
for (const alias of aliases) {
try {
const canonical = await fsp.realpath(alias);
if ((await identify(canonical)) === homeIdentity) roots.push(canonical);
} catch (error) {
// Only the optional macOS data-volume spelling may be absent; any other failure must not shrink the home boundary.
if (error.code !== "ENOENT" && error.code !== "ENOTDIR") throw error;
}
}
for (let current of roots) {
for (;;) {
identities.add(await identify(current));
const parent = path.dirname(current);
if (parent === current) break;
current = parent;
}
}
return identities;
}
/** Sharing a drive letter, root or mount point shares a whole volume; unreadable parents fail closed. */
async function volumeRoot(canonical) {
const parent = path.dirname(canonical);
if (parent === canonical) return true;
try { return (await fsp.stat(parent, { bigint: true })).dev !== (await fsp.stat(canonical, { bigint: true })).dev; } catch { return true; }
}
/** Whether `target` is exactly the person's home folder. Only the home folder itself may be lent on request (see
* `home` in validateSharedFolders); the folders above it, and the system disk, never can. */
export async function isHomeFolder(target) {
try {
const [home] = homeCandidates();
return (await identify(await fsp.realpath(target))) === (await identify(await fsp.realpath(home)));
} catch { return false; }
}
const GONE = new Set(["ENOENT", "ENOTDIR"]);
/** The folders that still exist and pass the checks a folder chosen by hand passes. One moved or deleted since is left
* out; any other refusal (the whole home folder, a file) still fails. */
export async function existingFolders(folders, validate) {
const found = await Promise.all(folders.map((folder) => validate([folder]).then((list) => list[0] ?? null, (error) => {
if (GONE.has(error?.code)) return null;
throw error;
})));
return found.filter(Boolean);
}
/** `homeEdits`: the home folder keeps the editing it asks for, which only the person's own Clouds get (lendAutomatically);
* any other server gets it read only. */
export async function validateSharedFolders(folders, { homeEdits = false } = {}) {
if (!Array.isArray(folders) || folders.length > 20) throw Object.assign(new Error("Choose at most 20 shared folders"), { code: "TOO_MANY" });
const result = [];
const enclosing = await enclosingHomeIdentities();
for (const folder of folders) {
if (!uuid(folder?.id) || typeof folder.path !== "string" || !path.isAbsolute(folder.path) || typeof folder.write !== "boolean") throw Object.assign(new Error("Choose folders using the desktop folder picker"), { code: "NOT_PICKED" });
const canonical = await fsp.realpath(folder.path);
if (!(await fsp.stat(canonical)).isDirectory()) throw Object.assign(new Error("Choose a folder, not a file"), { code: "NOT_FOLDER" });
// The home folder, everything above it and the system disk are never shared. A separate drive (an external disk, a data
// disk on a server, a drive letter) may be shared whole; any other mount point is still refused.
// The home folder itself is lent only when asked for by name (`home: true`); anything above it, and the system disk,
// are refused whatever is asked. Security: with editing, a write through it still never reaches its hidden files,
// Library or AppData (insideHomeSettings), where shell start-up files and ~/.ssh live.
const home = folder.home === true && await isHomeFolder(canonical);
if (!home && (enclosing.has(await identify(canonical)) || ((await volumeRoot(canonical)) && !(await shareableDrive(canonical))))) throw Object.assign(new Error("Choose specific folders or a separate drive, not the entire computer or home folder"), { code: "HOME_OR_ROOT" });
if (result.some(entry => entry.id === folder.id || entry.path === canonical)) continue;
result.push({ id: folder.id, path: canonical, name: path.basename(canonical).slice(0, 120), write: home && !homeEdits ? false : folder.write, ...(home ? { home: true } : {}) });
}
return result;
}
const JOB_KEYS = new Set(["computer_id", "action", "folder_id", "path", "content", "encoding", "expected_sha256", "old_text", "new_text", "command", "timeout_seconds", "tool_name", "arguments", "server"]);
const JOB_ACTIONS = new Set(["list_files", "read_file", "write_file", "edit_file", "append_file", "delete_file", "run_command", "computer_tools", "computer_call", ...LENT_MCP_ACTIONS]);
const optionalText = (value, max) => value === undefined || (typeof value === "string" && value.length <= max);
/** Refuses malformed input only; the grant still decides whether the operation is allowed. */
export function validSharedOperation(operation, computerId) {
return Boolean(operation) && typeof operation === "object" && !Array.isArray(operation) &&
Object.keys(operation).every(key => JOB_KEYS.has(key)) &&
operation.computer_id === computerId && JOB_ACTIONS.has(operation.action) &&
(operation.folder_id === undefined || uuid(operation.folder_id)) &&
optionalText(operation.path, 2048) && optionalText(operation.content, 350_000) &&
optionalText(operation.old_text, 350_000) && optionalText(operation.new_text, 350_000) &&
(operation.encoding === undefined || operation.encoding === "utf8" || operation.encoding === "base64") &&
(operation.expected_sha256 === undefined || (typeof operation.expected_sha256 === "string" && /^[a-f0-9]{64}$/.test(operation.expected_sha256))) &&
optionalText(operation.command, 8000) && validCommandSeconds(operation.timeout_seconds) && optionalText(operation.tool_name, 256) &&
(operation.arguments === undefined || (Boolean(operation.arguments) && typeof operation.arguments === "object" && !Array.isArray(operation.arguments))) &&
(LENT_MCP_ACTIONS.has(operation.action) ? validLentMcpServer(operation.server) : operation.server === undefined);
}
export function cloudLendingVerdict(binding, current, env) {
// A saved sign-in still being read at startup is not a sign-out: lending waits for it.
if (!binding || current?.status === "signed-out") return { stop: "signed-out" };
if (current?.status === "restoring") return { pause: "restoring" };
if (current?.accountId && current.accountId !== binding.accountId) return { stop: "account-changed" };
if (env?.origin !== binding.origin || (current?.origin && current.origin !== binding.origin)) return { stop: "machine-changed" };
if (!current?.accountId) return { pause: "unverified" };
if (current.status !== "connected" && current.status !== "unavailable") return { pause: current.status };
return { allow: true };
}
/** A server of the person's own, by this desktop's sign-in there: its admin (owner) pairing on a server nobody else signs
* in to, as the server says (`own`, server/own-home.ts), and no Cloud, team or hosted server. Lent only once the person
* allows it here (lendOwn), without the maintainer flag. Security: never a non-admin pairing, a server others sign in to,
* a team's server or a Cloud (a Cloud is lent as one, by its own rules). */
export function ownServerSession(auth) {
return auth?.kind === "session" && auth.own === true && Array.isArray(auth.scopes) && auth.scopes.includes("admin") && auth.cloudHome !== true && !auth.team && auth.hosted !== true;
}
/** What a Cloud that keeps MCP servers for this computer hears: it runs them, and which installs here it is. */
const lendsMcp = environments => {
const ids = Array.isArray(environments) ? environments.filter(uuid).slice(0, 8) : [];
return { mcpServers: true, ...(ids.length ? { environments: ids } : {}) };
};
/** A Cloud grant lends the internet connection unless it was unticked: one kept without `internet` lends it too. */
const lendsInternet = grant => Boolean(grant?.cloud) && grant.internet !== false;
/** Whether the person said no to lending this Cloud before lending was automatic: their own Off (`stopped.reason` "you",
* or an Off kept from before the reason was) or the internet connection unticked. Lending then starts Paused. */
export function saidNo(record) {
if (!record?.cloud) return false;
if (record.enabled !== true) return record.stopped?.reason === "you" || typeof record.stopped?.reason !== "string";
return record.internet === false;
}
/** Security: the menu's switch lends the terminal to every Cloud of one account, so grants from before it stand for it
* only when one account's grants all lent the terminal, as the person confirmed then, and nobody said no to lending. */
function terminalLentBeforeAutomatic(records) {
const grants = Object.values(records).filter(record => record?.enabled === true && typeof record.cloud?.accountId === "string" && !saidNo(record));
const accounts = new Set(grants.filter(grant => grant.terminal === true).map(grant => grant.cloud.accountId));
if (accounts.size !== 1) return null;
const [account] = accounts;
return grants.every(grant => grant.cloud.accountId !== account || grant.terminal === true) ? account : null;
}
/** What each Cloud of the person's own gets by itself: the internet connection, their Chrome, apps and screen once
* computer control works here (`screen`), and the home folder with editing (`home`, null when it can't be lent), on top
* of the folders lent to it already (`kept`, which Files may keep copies of) and the project folders the move adds. The
* terminal only when the person turned it on (`terminal`). At most 20 folders, the home folder first. */
export function automaticGrant({ kept = [], home = null, screen = false, projects = [], terminal = false }) {
const byPath = new Map();
const keptHome = home ? kept.find(folder => folder.path === home.path) : null;
for (const folder of [...(home ? [keptHome ? { ...keptHome, write: true, home: true } : home] : []), ...kept, ...projects]) {
const known = byPath.get(folder.path);
byPath.set(folder.path, known ? { ...known, write: known.write || folder.write === true } : folder);
}
return { folders: [...byPath.values()].slice(0, 20), screen: screen === true, internet: true, chrome: true, terminal: terminal === true };
}
const lentAs = folder => `${folder.path}\n${folder.write === true}`;
/** The saved grant lends exactly what `wanted` (an automaticGrant) says. */
export function lendsAsWanted(record, wanted) {
if (record?.enabled !== true || !record.cloud || !Array.isArray(record.folders)) return false;
const have = new Set(record.folders.map(lentAs));
return record.internet === true && record.chrome === true && (record.computer === true) === wanted.screen && (record.terminal === true) === wanted.terminal
&& have.size === wanted.folders.length && wanted.folders.every(folder => have.has(lentAs(folder)));
}
class Paused extends Error {}
const UPDATE_SERVER = "Update this server to enable computer sharing";
const LENT_PLATFORMS = new Set(["darwin", "win32", "linux"]);
const RECONNECT_EVERY_MS = 15 * 60_000;
/** Waits before the device link is dialled again, longer after each attempt that never opened. */
const LINK_RETRY_MS = [2_000, 5_000, 15_000, 60_000];
/** Lending that is failing, as the menu and a notification say it; a sleeping Cloud or a Pause is no failure. */
const WHY_NOT_LENT = Object.freeze({ "connect-first": "native.lendTray.whySignIn", "machine-changed": "native.lendTray.whyMoved", "account-changed": "native.lendTray.whyAccount", "not-cloud": "native.lendTray.whyMoved", "not-own": "native.lendTray.whyMoved" });
const whyNotLent = problem => WHY_NOT_LENT[problem] ?? "native.lendTray.whyUnreachable";
/** The link carried bytes this recently: the tray says the computer is in use. */
const IN_USE_MS = 5_000;
/** A site opened through this computer goes into its activity once per outcome in this long, so a page's many
* connections are one line. */
const BROWSE_NOTE_MS = 10 * 60_000;
/** Outbound HTTPS only: no local listening port, and pairing cookies and the connector secret stay in Electron. */
/** `reconnect(origin)`: signs this app back in to the person's own Cloud when its session there has ended, so lending
* resumes by itself; at most every 15 minutes. `linkHeaders(env)`: the device link's sign-in, as the poll's fetch
* carries it (the session cookie). `myCloud()`: the saved server that is the person's own My Cloud, which the move's
* project folders are lent to. `ownClouds()`: the saved servers that are the verified session's own Clouds, never a
* Team Cloud: each is lent this computer by itself (automaticGrant) unless lending is paused. `screen()`: computer
* control's state, whose `lent` says whether apps and screen are lent: computer control
* works, or only macOS's permissions are missing, which `askForScreen(connection)` asks macOS for when a Cloud's bot
* first needs them. `cloudPower(origin)`: the Admin's word on another Cloud of the person's own (the Clouds list's
* `power`), so a sleeping one is never dialled. While this app is open, paused or not, each of those Clouds hears how
* this computer is and asks for it when a bot there needs it: `prompt(env, ask, signal)` shows the ask in this app's
* own prompt, `startScreen()` starts computer control again (its state after), and `confirmTerminal()` turns on
* running commands once the person confirms it here. */
export function createComputerSharing({ file, fetch: fetchImpl, environments, localEnvironments = () => [], cuaConnection, hostControl, screenLocked = () => false, idleSeconds = () => null, protectedPaths = [], enabled = async () => false, home = os.homedir(), activityFile, cloud = () => null, onChange = () => {}, reconnect = null, now = Date.now, power = createCloudPowerGate(),
linkHeaders = () => ({}), WebSocket: linkSocket = globalThis.WebSocket, egress = {}, chrome = {}, serverPorts = () => [], myCloud = () => null, ownClouds = () => [], screen = () => false, askForScreen = () => {}, cloudPower = () => null,
prompt = async () => null, startScreen = async () => null, confirmTerminal = async () => false, log = () => {}, t = nativeText.englishText.t }) {
const protectedRoots = [path.dirname(file), ...protectedPaths.filter(entry => typeof entry === "string" && entry), ...personalSecretPaths(home)];
const activity = createLendingActivity(activityFile ?? path.join(path.dirname(file), "lending-activity.jsonl"));
const presence = createLentScreenPresence({ idleSeconds, now });
/** Computer control's latest state, for the icon's menu; null until it is first read. */
let screenNow = null;
let records = {};
let paused = false;
/** Set while the pause holds only in memory: its save failed, so a restart would lend again. Pause retries the save. */
let pauseError;
/** The Cloud account that confirmed lending the terminal; another account signed in here gets none until it confirms. */
let terminalAccount = null;
try {
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
if (parsed?.version === 1 && parsed.records && typeof parsed.records === "object") records = parsed.records;
paused = parsed?.automatic ? parsed.automatic.paused === true : Object.values(records).some(saidNo);
if (typeof parsed?.automatic?.terminalAccount === "string") terminalAccount = parsed.automatic.terminalAccount;
else if (!parsed?.automatic) terminalAccount = terminalLentBeforeAutomatic(records);
} catch {}
const running = new Map();
const grantVersions = new Map();
const changeGrant = id => { const version = (grantVersions.get(id) ?? 0) + 1; grantVersions.set(id, version); return version; };
const status = new Map();
const reconnected = new Map();
/** The Clouds whose device link is open now. */
const linked = new Set();
const inUse = new Map();
let closed = false;
let maintainerOff = false;
let executing = null;
const changed = () => { if (!closed) try { onChange(summary()); } catch {} };
const summary = () => {
const browsing = [...inUse.keys()].find(id => linked.has(id));
const lent = paused ? [] : environments().filter(env => (records[env.id]?.cloud || records[env.id]?.own) && records[env.id]?.enabled === true);
const using = lent.filter(env => executing?.env === env.id || (inUse.has(env.id) && linked.has(env.id)));
const problems = lendingProblems(lent);
return {
lending: lent.map(env => env.id),
clouds: (paused ? [...ownClouds(), ...environments().filter(env => records[env.id]?.own && records[env.id].enabled === true)] : lent).map(env => env.name),
// Lent, but the Cloud hasn't accepted this computer yet (or lost it): the menu must not say it can use it. A
// sleeping My Cloud still can; it wakes when a bot needs this computer.
waiting: lent.filter(env => !status.get(env.id)?.connected && status.get(env.id)?.problem !== "asleep").map(env => env.name),
...(problems.length ? { problems } : {}),
using: using.map(env => env.name),
paused,
...(pauseError ? { pauseError } : {}),
terminal: terminalAccount !== null && terminalAccount === signedInAccount(),
// The menu's terminal switch is the Clouds' one; a server of the person's own lends the terminal from Settings.
terminalSwitch: ownClouds().length > 0,
busy: executing ?? (browsing ? { env: browsing, action: "internet" } : null),
screen: screenNow,
};
};
/** What is keeping this computer from a Cloud of the person's own, for the menu and one notification: a Cloud it can't
* reach or stay signed in to, lending that ended by itself, and a signed-in My Cloud this app has no saved place for. */
const lendingProblems = lent => {
if (paused) return [];
const failing = lent.flatMap(env => {
const now = status.get(env.id);
return now && !now.connected && now.problem !== "asleep" && now.problem !== "paused" && (now.problem || now.error) ? [{ name: env.name, why: t(whyNotLent(now.problem)) }] : [];
});
const ended = environments().flatMap(env => {
const record = records[env.id], reason = record?.stopped?.reason;
return record?.cloud && record.enabled !== true && (reason === "machine-changed" || reason === "account-changed") && !lent.includes(env) ? [{ name: env.name, why: t(whyNotLent(reason)) }] : [];
});
const signedIn = cloud();
const homeless = signedIn?.status === "connected" && signedIn.origin && !environments().some(env => env.origin === signedIn.origin)
? [{ name: t("native.lendTray.myCloud"), why: t("native.lendTray.whyNoServer") }] : [];
return [...failing, ...ended, ...homeless];
};
/** Whether apps and screen are lent now; the icon's menu follows any change in why not. */
const readScreen = () => Promise.resolve().then(screen).then(status => status, () => null).then(status => {
const next = status && typeof status === "object" ? status : null;
const before = JSON.stringify(screenNow);
screenNow = next;
if (JSON.stringify(next) !== before) changed();
return status === true || next?.lent === true;
});
/** Each site My Cloud's browsers opened through this computer, or that its check refused, as activity. */
const noted = new Map();
const browsed = env => (target, result) => {
if (!result?.socket && !result?.refused) return;
const key = `${env.id}\n${target.chrome === true ? "chrome" : target.host}\n${result.refused ?? "ok"}`;
if (now() - (noted.get(key) ?? -Infinity) < BROWSE_NOTE_MS) return;
if (noted.size >= 1000) for (const [old, at] of noted) if (now() - at >= BROWSE_NOTE_MS) noted.delete(old);
noted.set(key, now());
const viaChrome = target.chrome === true;
activity.record({ env, action: viaChrome ? "chrome" : "internet", detail: viaChrome ? "" : String(target.host), ok: Boolean(result.socket), ...(result.refused ? { error: String(result.refused) } : {}) });
};
const traffic = id => () => {
const was = inUse.get(id);
clearTimeout(was);
const timer = setTimeout(() => { if (inUse.get(id) === timer) { inUse.delete(id); changed(); } }, IN_USE_MS);
timer.unref?.();
inUse.set(id, timer);
if (!was) changed();
};
// A remote workspace's capability is not authority here: recheck the local gate (maintainer grants only).
const requireEnabled = async () => {
const allowed = !closed && !maintainerOff && await enabled().catch(() => false);
if (allowed && !closed && !maintainerOff) return;
maintainerOff = true;
for (const env of environments()) {
if (records[env.id]?.cloud || records[env.id]?.own || !running.has(env.id)) continue;
if (status.get(env.id)?.connected) disconnect(env, records[env.id]); else stop(env.id);
}
throw new Error("Computer sharing is turned off on this computer. Restart the desktop after enabling it.");
};
const endCloud = (env, reason) => {
disconnect(env, records[env.id]);
if (records[env.id]?.enabled) store({ ...records, [env.id]: { ...records[env.id], enabled: false, stopped: { reason, at: now() } } });
status.set(env.id, { connected: false, problem: reason });
changed();
};
const requireGrant = async (env, grant) => {
// A server of the person's own was allowed here: no maintainer flag, but Pause and quitting hold it as they hold Clouds.
if (grant.own === true) { if (closed || paused) throw new Paused("Lending is paused."); return; }
if (!grant.cloud) return requireEnabled();
// Quitting only pauses: a check in flight at close must never switch lending off for the next launch.
if (closed) throw new Paused("This app is closing.");
const verdict = cloudLendingVerdict(grant.cloud, cloud(env), env);
if (verdict.allow) return;
if (verdict.stop) { endCloud(env, verdict.stop); throw new Error("Lending to this Cloud stopped."); }
throw new Paused("Waiting for your MausBot Cloud sign-in.");
};
const store = next => {
fs.mkdirSync(path.dirname(file), { recursive: true });
const temporary = `${file}.tmp`;
fs.writeFileSync(temporary, JSON.stringify({ version: 1, records: next, automatic: { paused, ...(terminalAccount ? { terminalAccount } : {}) } }), { mode: 0o600 });
fs.chmodSync(temporary, 0o600);
fs.renameSync(temporary, file); records = next;
if (paused) pauseError = undefined;
};
const sleepsHere = env => Boolean(records[env.id]?.cloud) || cloud()?.origin === env.origin;
/** My Cloud sleeps by the Admin's word and its own, on the gate this app shares with Files (`power`); another Cloud of
* the person's own by the same two (the Clouds list's `power`, `cloudPower`, and its own "going to sleep" answer) on a
* gate of its own, so one Cloud asleep never holds another's lending, and a sleeping one is not dialled until the Admin
* says it is awake (any request reaching it would wake it). */
const ownGates = new Map();
const sleepGate = env => {
if (env.id === myCloud()?.id || cloud()?.origin === env.origin) return power;
if (!ownGates.has(env.id)) ownGates.set(env.id, createCloudPowerGate({ power: () => cloudPower(env.origin) }));
return ownGates.get(env.id);
};
const request = async (env, route, body, signal, secret) => {
if (!allowedServerOrigin(env.origin)) throw new Error("Computer sharing needs the server's HTTPS address, or one on your own network");
const response = await fetchImpl(`${env.origin}${route}`, {
method: body === undefined ? "GET" : "POST", credentials: "include", redirect: "error", cache: "no-store",
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(35_000)]) : AbortSignal.timeout(10_000),
headers: { origin: env.origin, ...BACKGROUND_HEADERS, ...(body === undefined ? {} : { "content-type": "application/json" }), ...(secret ? { "x-omb-computer-secret": secret } : {}) },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
if (asleepAnswer(response)) {
await response.body?.cancel?.().catch(() => {});
if (sleepsHere(env)) sleepGate(env).hold();
throw new CloudAsleepError();
}
const chunks = []; let bytes = 0;
for await (const chunk of response.body ?? []) {
bytes += chunk.length; if (bytes > 4_000_000) throw new Error("Server response exceeded limit");
chunks.push(Buffer.from(chunk));
}
let json; try { json = JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch { throw new Error("This server does not support computer sharing. Update its OpenMausBot installation."); }
if (!response.ok) throw Object.assign(new Error(response.status === 401 || response.status === 403 ? "Pair this desktop again before sharing computer access." : `Server request failed (${response.status}). Update the server if needed.`), { status: response.status });
return json;
};
const describe = async (env, required = true) => {
const [auth, descriptor] = await Promise.all([request(env, "/api/auth/session"), request(env, "/.well-known/openmausbot/environment")])
.catch(error => { throw error.status === 401 || error.status === 403 ? Object.assign(error, { problem: "connect-first" }) : error; });
if (auth.kind !== "session" || !uuid(auth.id) || !uuid(descriptor.environmentId)) throw Object.assign(new Error("Complete server pairing or sign-in first"), { problem: "connect-first" });
// A server of the person's own says what it takes from them in their own sign-in's answer (`lending`), never before pairing.
const offered = { ...descriptor.capabilities, ...(ownServerSession(auth) && auth.lending && typeof auth.lending === "object" ? auth.lending : {}) };
if (required && offered.sharedComputers !== true) throw new Error(UPDATE_SERVER);
return { supported: offered.sharedComputers === true, auth, info: { sessionId: auth.id, environmentId: descriptor.environmentId }, details: offered.lendingDetails === true, link: offered.deviceLink === true, chrome: offered.myChromeLink === true, timeouts: offered.commandTimeouts === true, long: offered.longCommands === true, edits: offered.fileEdits === true, asks: offered.computerAsks === true, mcp: offered.lentMcp === true };
};
const identity = async env => {
await requireEnabled();
const { info, edits } = await describe(env);
await requireEnabled();
if (edits) supportsEdits.add(env.id); else supportsEdits.delete(env.id);
return info;
};
/** A server of the person's own their bots moved to: lent as their Clouds are, to its one owner's sign-in alone, at the
* server the move chose (lending-target.mjs `own:<environment id>`). */
const ownHomeId = env => { const current = cloud(env); return current?.origin === env.origin && current.accountId?.startsWith("own:") ? current.accountId.slice(4) : null; };
/** What the server says it takes, remembered for its connect; a server of the person's own never gets the device link. */
const takes = (env, { details, link, chrome: takesChrome, timeouts, long, edits, mcp }) => {
for (const [set, on] of [[supportsDetails, details], [supportsLink, link], [supportsChrome, takesChrome], [supportsTimeouts, timeouts], [supportsLong, long], [supportsEdits, edits], [supportsMcp, mcp]]) {
if (on) set.add(env.id); else set.delete(env.id);
}
};
const cloudIdentity = async env => {
const described = await describe(env), { auth, info } = described;
const own = ownHomeId(env);
if (own !== null && (auth.own !== true || info.environmentId !== own)) throw Object.assign(new Error(t("native.lend.homeChanged")), { problem: "not-cloud" });
if ((own === null && auth.cloudHome !== true) || !Array.isArray(auth.scopes) || !auth.scopes.includes("admin")) throw Object.assign(new Error("This server is not My Cloud"), { problem: "not-cloud" });
takes(env, described);
return info;
};
/** This desktop's sign-in on a server of the person's own, checked now (ownServerSession). */
const ownIdentity = async env => {
const described = await describe(env, false);
if (!ownServerSession(described.auth)) throw Object.assign(new Error(t("native.lend.notOwnServer")), { problem: "not-own" });
if (!described.supported) throw new Error(UPDATE_SERVER);
takes(env, { ...described, link: false, chrome: false });
return described.info;
};
/** The Clouds that keep MCP servers for this computer to run. */
const supportsMcp = new Set();
const supportsDetails = new Set();
const supportsLink = new Set();
/** The Clouds that take the person's Chrome over the device link. */
const supportsChrome = new Set();
const supportsTimeouts = new Set();
const supportsLong = new Set();
const supportsEdits = new Set();
/** My Cloud's browsers reach the web through this computer while lending runs; never while My Cloud sleeps, so a
* sleeping Cloud is never dialled. */
const keepLink = async (env, grant, signal) => {
for (let failures = 0; !signal.aborted;) {
if (sleepsHere(env) && sleepGate(env).asleep()) {
await sleepGate(env).wait(signal);
continue;
}
let opened = false;
const signIn = await Promise.resolve().then(() => linkHeaders(env)).catch(() => ({}));
const ended = await runDeviceLink({
url: linkUrl(env.origin, linkPath(grant.id)), signal, WebSocket: linkSocket, egress, traffic: traffic(env.id), onStream: browsed(env), chrome,
lends: () => ({ internet: lendsInternet(grant), chrome: grant.chrome === true && supportsChrome.has(env.id) }),
headers: { ...signIn, origin: env.origin, ...BACKGROUND_HEADERS, "x-omb-computer-secret": grant.secret },
onOpen: () => { opened = true; failures = 0; linked.add(env.id); changed(); },
});
linked.delete(env.id); changed();
if (signal.aborted) return;
if (ended.code === LINK_ASLEEP) { if (sleepsHere(env)) sleepGate(env).hold(); continue; }
if (!opened) failures++;
try { await delay(LINK_RETRY_MS[Math.min(failures, LINK_RETRY_MS.length - 1)], undefined, { signal }); } catch { return; }
}
};
const matches = (grant, info) => grant?.sessionId === info.sessionId && grant?.environmentId === info.environmentId;
const stop = id => {
const live = running.get(id);
if (live) { live.abort.abort(); live.cua?.close(); live.mcp?.close(); running.delete(id); }
status.set(id, { connected: false });
};
const run = (env, grant) => {
stop(env.id);
if (closed || !grant.enabled || (grant.cloud || grant.own ? paused : maintainerOff)) return;
const live = { abort: new AbortController(), cua: null, mcp: null };
running.set(env.id, live);
const signal = live.abort.signal;
const call = (action, body = {}, held = signal) => request(env, `/api/shared-computers/${grant.id}/${action}`, body, held, grant.secret);
const gate = () => requireGrant(env, grant);
const awake = () => { if (sleepsHere(env) && sleepGate(env).asleep()) throw new CloudAsleepError(); };
void (async () => {
while (!signal.aborted) {
try {
await gate();
awake();
if (grant.cloud) {
// A new pairing of this desktop keeps lending; another server behind that address does not.
const info = await cloudIdentity(env);
if (signal.aborted || running.get(env.id) !== live) break;
if (grant.environmentId && grant.environmentId !== info.environmentId) { endCloud(env, "machine-changed"); break; }
if (grant.environmentId !== info.environmentId || grant.sessionId !== info.sessionId) {
grant = { ...grant, ...info };
if (records[env.id]?.id === grant.id) store({ ...records, [env.id]: grant });
}
} else if (grant.own) {
// Pairing this desktop again keeps lending, as an admin of that same server only; any other answer ends it.
const info = await ownIdentity(env);
if (signal.aborted || running.get(env.id) !== live) break;
if (info.environmentId !== grant.environmentId) { endCloud(env, "machine-changed"); break; }
if (info.sessionId !== grant.sessionId) {
grant = { ...grant, sessionId: info.sessionId };
if (records[env.id]?.id === grant.id) store({ ...records, [env.id]: grant });
}
} else if (!matches(grant, await identity(env))) throw new Error("Server sign-in changed. Review computer access again in Settings.");
// A folder deleted since is left out, so the rest (the internet connection too) keeps working; any other refusal
// still stops lending.
const present = new Set((await existingFolders(grant.folders, validateSharedFolders)).map(folder => folder.id));
const folders = grant.folders.filter(folder => present.has(folder.id));
const effectiveGrant = { ...grant, folders, protectedPaths: protectedRoots };
await gate();
awake();
await request(env, "/api/shared-computers/connect", {
id: grant.id, name: os.hostname().slice(0, 120), environmentId: grant.environmentId,
folders: folders.map(({ id, name, write }) => ({ id, name, write })), terminal: grant.terminal, computer: grant.computer,
// So My Cloud's bots say "your PC" on Windows and "your Mac" only on a Mac; only a server that takes it gets it.
...(supportsDetails.has(env.id) && LENT_PLATFORMS.has(process.platform) ? { platform: process.platform } : {}),
...(grant.cloud && supportsLink.has(env.id) ? { internet: lendsInternet(grant) } : {}),
...(grant.cloud && supportsChrome.has(env.id) ? { chrome: grant.chrome === true } : {}),
...(supportsTimeouts.has(env.id) ? { commandTimeouts: true } : {}),
...(supportsLong.has(env.id) ? { longCommands: true } : {}),
...(supportsEdits.has(env.id) ? { fileEdits: true } : {}),
...((grant.cloud || grant.own) && supportsMcp.has(env.id) ? lendsMcp(localEnvironments()) : {}),
}, signal, grant.secret);
if (signal.aborted) break;
status.set(env.id, { connected: true });
changed();
if (supportsLink.has(env.id) && (lendsInternet(grant) || (grant.cloud && grant.chrome === true && supportsChrome.has(env.id)))) live.link ??= keepLink(env, grant, signal).catch(() => {});
while (!signal.aborted) {
await gate();
awake();
const { job } = await call("poll", {}, sleepsHere(env) ? AbortSignal.any([signal, sleepGate(env).signal()]) : signal);
await gate();
if (!job) continue;
if (!uuid(job.id)) throw new Error("Invalid computer request");
if (!validSharedOperation(job.operation, grant.id)) {
activity.record({ env, action: "invalid", detail: "", ok: false, error: "Refused a malformed request" });
await call("result", { jobId: job.id, result: sharedComputerError(new Error("Invalid computer request")) });
continue;
}
if (executing) { await call("result", { jobId: job.id, result: sharedComputerError(new Error("This computer is busy with another server")) }); continue; }
executing = { env: env.id, action: job.operation.action };
changed();
const jobAbort = new AbortController();
const jobSignal = AbortSignal.any([signal, jobAbort.signal]);
let leasing = false, ended = false, renewal = null;
let control;
const lease = async () => {
if (leasing) return;
leasing = true;
try {
await gate();
if (!(await call("lease", { jobId: job.id })).active) jobAbort.abort();
if (!ended) await (renewal = control?.renew());
} catch { jobAbort.abort(); }
finally { leasing = false; renewal = null; }
};
const heartbeat = setInterval(() => void lease(), 1000);
let result;
try {
await lease(); jobSignal.throwIfAborted();
// Listing the tools never touches the screen, so only a call takes this computer's screen lock.
if (grant.computer && job.operation.action === "computer_call") {
if (screenLocked()) throw new Error(SCREEN_LOCKED);
const waits = presence.refusal(job.operation.tool_name, job.operation.arguments);
if (waits) throw new Error(waits);
if (!hostControl) throw new Error("The local computer control gate is unavailable");
control = await hostControl(job.id, jobSignal);
jobSignal.throwIfAborted();
}
result = await executeSharedOperation(effectiveGrant, job.operation, jobSignal, async () => {
if (!live.cua) {
const connection = await cuaConnection();
jobSignal.throwIfAborted();
const mcp = sharedCuaConnection(connection);
if (!mcp) { if (grant.cloud || grant.own) try { askForScreen(connection); } catch {} throw new Error(cuaUnavailable(connection?.reason, process.platform, t)); }
live.cua = createSharedCua(mcp, { t });
}
return live.cua;
}, () => (live.mcp ??= createLentMcp({ ports: () => ownPorts({ serverPorts: serverPorts() }) })));
} catch (error) { result = sharedComputerError(error); live.cua?.close(); live.cua = null; }
finally {
clearInterval(heartbeat); ended = true;
if (control) presence.acted(job.operation.tool_name, job.operation.arguments);
await renewal?.catch(() => {});
await control?.release().catch(() => {});
executing = null; changed();
}
activity.record({
env, action: job.operation.action, detail: describeSharedOperation(grant, job.operation), ok: result?.isError !== true,
error: result?.isError === true ? result?.content?.[0]?.text : undefined,
});
// Never retry an action if the result delivery fails.
await call("result", { jobId: job.id, result });
}
} catch (error) {
if (error?.problem === "not-own" && grant.own) { endCloud(env, "not-own"); break; }
if (error instanceof Paused) {
if (running.get(env.id) === live) disconnect(env, grant, true);
status.set(env.id, { connected: false, problem: "paused" });
changed();
break;
}
if (!signal.aborted && sleepsHere(env) && (error instanceof CloudAsleepError || sleepGate(env).asleep())) {
status.set(env.id, { connected: false, problem: "asleep" });
changed();
await sleepGate(env).wait(signal);
continue;
}
const problem = grant.cloud ? error.problem ?? (error.status === 401 || error.status === 403 ? "connect-first" : "waiting") : undefined;
if (!signal.aborted) status.set(env.id, { connected: false, error: error.message, ...(problem ? { problem } : {}) });
changed();
// Its session on My Cloud ended (unused for a month, or signed out there): sign back in rather than wait for a click.
if (problem === "connect-first" && reconnect && !signal.aborted && now() - (reconnected.get(env.id) ?? -Infinity) >= RECONNECT_EVERY_MS) {
reconnected.set(env.id, now());
if (await Promise.resolve(reconnect(env.origin)).catch(() => false)) continue;
}
}
try { await delay(5000, undefined, { signal }); } catch { break; }
}
})().catch(() => {});
};
/** Lent and connected, it says whether its internet connection is in use yet: "on", "connecting", or "unsupported" by
* a Cloud too old for the device link. */
const internetState = (env, lent) => lendsInternet(lent) && lent.enabled === true && status.get(env.id)?.connected
? { internet: true, link: !supportsLink.has(env.id) ? "unsupported" : linked.has(env.id) ? "on" : "connecting" }
: { internet: lendsInternet(lent) };
/** The same for the person's Chrome: "on", "connecting", or "unsupported" by a Cloud that does not take it. */
const chromeState = (env, lent) => lent?.cloud && lent.chrome === true && lent.enabled === true && status.get(env.id)?.connected
? { chrome: true, chromeLink: !supportsChrome.has(env.id) ? "unsupported" : linked.has(env.id) ? "on" : "connecting" }
: { chrome: lent?.cloud ? lent.enabled === true && lent.chrome === true : false };
const state = id => {
const grant = records[id];
return { enabled: grant?.enabled === true, folders: Array.isArray(grant?.folders) ? grant.folders : [], terminal: grant?.terminal === true, computer: grant?.computer === true,
...(grant?.own ? { own: true, ...(paused ? { paused: true } : {}) } : {}), ...status.get(id) };
};
// `resumes`: lending waits (for the Cloud sign-in, say) and resumes by itself, so the server keeps it as lent, not stopped.
/** Disconnects still waiting for their Cloud's answer. */
const goodbyes = new Set();
const disconnect = (env, grant, resumes = false) => {
stop(env.id);
if (resumes && sleepsHere(env) && sleepGate(env).asleep()) return;
if (!grant?.secret) return;
const told = request(env, `/api/shared-computers/${grant.id}/disconnect`, resumes ? { paused: true } : {}, undefined, grant.secret).catch(() => {}).finally(() => { goodbyes.delete(told); });
goodbyes.add(told);
};
const cloudState = env => {
const grant = env ? records[env.id] : undefined;
const lent = grant?.cloud ? grant : null;
return {
enabled: lent?.enabled === true,
folders: lent && Array.isArray(lent.folders) ? lent.folders : [],
screen: lent?.computer === true,
terminal: lent?.enabled === true && lent.terminal === true,
...internetState(env, lent),
...chromeState(env, lent),
busy: Boolean(env && executing?.env === env.id),
...(env ? status.get(env.id) : {}),
};
};
const validRecord = grant => grant?.enabled === true && uuid(grant.id) && /^[a-f0-9]{64}$/.test(grant.secret) && Array.isArray(grant.folders) &&
(grant.cloud
? typeof grant.cloud.accountId === "string" && typeof grant.cloud.origin === "string" && (grant.environmentId === null || uuid(grant.environmentId))
: uuid(grant.sessionId) && uuid(grant.environmentId));
const cloudGrant = (env, current, info, { folders = [], screen, internet, terminal, chrome }) => ({ ...info, id: randomUUID(), secret: randomBytes(32).toString("hex"), enabled: true, folders,
terminal: terminal === true, computer: screen === true, internet: internet === true, chrome: chrome === true, cloud: { accountId: current.accountId, origin: env.origin } });
const verifiedCloud = env => {
const current = cloud(env);
return current?.status === "connected" && current.accountId && current.origin === env.origin ? current : null;
};
const signedInAccount = () => ownClouds().map(verifiedCloud).find(Boolean)?.accountId ?? null;
/** The Cloud account and its machine must be verified now. A terminal (unrestricted commands as this user) only when
* `input.terminal === true`. */
const saveCloud = async (env, input, version = changeGrant(env.id)) => {
if (closed) throw new Error("Lending is unavailable while the app is closing.");
if (grantVersions.get(env.id) !== version) return cloudState(env);
const current = verifiedCloud(env);
if (!current) throw Object.assign(new Error("Open My Cloud first."), { problem: "connect-first" });
const folders = await validateSharedFolders(input?.folders, { homeEdits: true });
if (!folders.length && input?.screen !== true && input?.internet !== true && input?.chrome !== true && input?.terminal !== true) throw new Error(t("native.lend.chooseCapability"));
let info = { sessionId: null, environmentId: null };
try { info = await cloudIdentity(env); } catch (error) { if (error.problem !== "connect-first") throw error; }
if (grantVersions.get(env.id) !== version) return cloudState(env);
const grant = cloudGrant(env, current, info, { ...input, folders });
disconnect(env, records[env.id]);
store({ ...records, [env.id]: grant }); run(env, grant); changed();
return cloudState(env);
};
const lendable = folders => Promise.all(folders.map(folder => validateSharedFolders([folder], { homeEdits: true }).then(([checked]) => checked ?? null, () => null)))
.then(found => found.filter(Boolean));
/** Calls nothing itself, so a sleeping Cloud is never woken: the grant reaches its Cloud the way any grant does, once that
* Cloud is awake. A grant still bound to this Cloud keeps its binding to that Cloud's machine. */
const lendNow = (env, current, kept, wanted) => {
changeGrant(env.id);
const bound = kept?.enabled === true && kept.cloud?.accountId === current.accountId && kept.cloud.origin === env.origin;
const grant = cloudGrant(env, current, bound ? { sessionId: kept.sessionId ?? null, environmentId: kept.environmentId ?? null } : { sessionId: null, environmentId: null }, wanted);
if (sleepsHere(env) && sleepGate(env).asleep()) stop(env.id); else disconnect(env, kept);
try { store({ ...records, [env.id]: grant }); } catch { return; }
run(env, grant);
changed();
};
/** Moves on each time an account is left: a pass that began before that lends nothing more. */
let accountTurn = 0;
const automaticPass = async (projects, target) => {
if (closed || paused || !ownClouds().some(verifiedCloud)) return;
const turn = accountTurn;
const [[homeFolder = null], screenLent, projectFolders] = await Promise.all([
lendable([{ id: randomUUID(), path: home, write: true, home: true }]),
readScreen(),
lendable(projects.map(where => ({ id: randomUUID(), path: where, write: true }))),
]);
for (const env of ownClouds()) {
const kept = records[env.id], current = verifiedCloud(env);
if (closed || paused || turn !== accountTurn || !current) continue;
const keptFolders = kept?.cloud?.accountId === current.accountId && Array.isArray(kept.folders) ? await lendable(kept.folders) : [];
const wanted = automaticGrant({ kept: keptFolders, home: homeFolder, screen: screenLent, projects: env.id === (target ?? myCloud())?.id ? projectFolders : [],
terminal: terminalAccount === current.accountId });
if (closed || paused || turn !== accountTurn || records[env.id] !== kept) continue;
if (!lendsAsWanted(kept, wanted)) lendNow(env, current, kept, wanted);
else if (!running.has(env.id) && validRecord(kept) && cloudLendingVerdict(kept.cloud, cloud(env), env).allow) run(env, kept);
}
};
/** This computer as the person's own Clouds hear it: paused, and why apps and screen or commands aren't lent. */
const thisComputer = async () => {
const status = await Promise.resolve().then(screen).then(value => value && typeof value === "object" ? value : null, () => null);
return { name: os.hostname().slice(0, 120) || t("rail.thisComputer"), platform: LENT_PLATFORMS.has(process.platform) ? process.platform : null, paused,
screen: { ready: status?.ready === true, reason: status && !status.ready ? status.reason ?? null : null }, terminal: terminalAccount !== null && terminalAccount === signedInAccount() };
};
/** Every Cloud of the person's own may use this computer again. */
const resumeLending = () => {
if (!paused || closed) return;
try {
paused = false;
store(records);
pauseError = undefined;
} catch {
// Not saved: lending stays paused, here and after a restart.
paused = true;
pauseError = t("native.lendTray.pauseUnsaved");
changed();
return;
}
const automatic = lentAutomatically();
for (const env of environments()) {
const grant = records[env.id];
if (!validRecord(grant) || running.has(env.id)) continue;
if (grant.own || (grant.cloud && !automatic.has(env.id) && cloudLendingVerdict(grant.cloud, cloud(env), env).allow)) run(env, grant);
}
changed();
wakeAsking();
return lendAutomatically();
};
/** Turn on, from this app's prompt or the card: what the ask needs, done here. */
const turnOn = async (env, ask) => {
if (paused) await resumeLending();
if (paused) return { ok: false, reason: "paused" };
if (ask.need === "screen") {
let status = await Promise.resolve().then(screen).catch(() => null);
if (status && typeof status === "object" && !status.ready) status = await startScreen();
if (status && typeof status === "object" && !status.ready) return { ok: false, reason: status.reason ?? "not-running" };
}
if (ask.need === "terminal" && terminalAccount !== signedInAccount() && !(await confirmTerminal())) return { declined: true };
return { ok: true };
};
/** Per Cloud that takes asks: what stops its poll for good, and what ends its current poll early. */
const asking = new Map();
/** A Cloud too old to take asks is asked again only after this long. */
const olderClouds = new Map();
const asks = createComputerAsks({ prompt: (env, ask, signal) => prompt(env, ask, signal), turnOn, log, answered: id => asking.get(id)?.wake() });
const keepAsking = async (env, entry) => {
const { signal } = entry.stop;
let heard = -1, failures = 0, supported = null;
while (!signal.aborted && !closed) {
if (sleepsHere(env) && sleepGate(env).asleep()) { await sleepGate(env).wait(signal); continue; }
const early = new AbortController();
entry.wake = () => early.abort();
const answers = [...asks.pending(env.id)];
try {
supported ??= (await describe(env)).asks;
if (!supported) { olderClouds.set(env.id, now()); return; }
const news = await request(env, "/api/shared-computers/asks", { ...await thisComputer(), heard, answers }, AbortSignal.any([signal, early.signal, sleepGate(env).signal()]));
asks.sent(env.id, answers);
failures = 0;
if (Number.isSafeInteger(news?.version)) heard = news.version;
asks.heard(env, Array.isArray(news?.asks) ? news.asks : []);
} catch (error) {
if (signal.aborted) return;
if (early.signal.aborted || error instanceof CloudAsleepError) continue;
if (error?.status === 404) { olderClouds.set(env.id, now()); return; }
failures++;
try { await delay(LINK_RETRY_MS[Math.min(failures, LINK_RETRY_MS.length - 1)], undefined, { signal }); } catch { return; }
}
}
};
/** Each verified own Cloud hears this computer while this app is open; the others don't. */
const syncAsking = () => {
const wanted = closed ? new Set() : new Set(ownClouds().filter(verifiedCloud).map(env => env.id));
for (const [id, entry] of asking) if (!wanted.has(id)) { entry.stop.abort(); asking.delete(id); }
for (const env of ownClouds()) {
if (!wanted.has(env.id) || asking.has(env.id) || now() - (olderClouds.get(env.id) ?? -Infinity) < 30 * 60_000) continue;
const entry = { stop: new AbortController(), wake: () => {} };
asking.set(env.id, entry);
void keepAsking(env, entry).catch(() => {}).finally(() => { if (asking.get(env.id) === entry) asking.delete(env.id); });
}
};
/** This computer changed: each Cloud hears it now, not at its next poll. */
const wakeAsking = () => { for (const entry of asking.values()) entry.wake(); };
/** The Clouds whose grant the automatic pass runs: it runs a kept grant only once it lends what it should. */
const lentAutomatically = () => new Set(ownClouds().map(env => env.id));
/** One pass at a time: a change while one runs asks for one more after it. */
let passing = Promise.resolve(), passQueued = false;
const lendAutomatically = (projects = [], target = null) => {
if (passQueued && !projects.length) return passing;
passQueued = true;
passing = passing.then(() => { passQueued = false; return automaticPass(projects, target); }).catch(() => {}).finally(syncAsking);
return passing;
};
return {
state, identity,
activity(id, limit) {
const env = id === undefined ? null : environments().find(entry => entry.id === id);
const entries = activity.list(env ? 500 : limit);
return (env ? entries.filter(entry => entry.origin === env.origin) : entries).slice(0, limit ?? 100);
},
async observe(env) {
const info = await identity(env);
if (matches(records[env.id], info)) return null;
disconnect(env, records[env.id]);
return info;
},
decline(env, info) { changeGrant(env.id); disconnect(env, records[env.id]); store({ ...records, [env.id]: { ...info, enabled: false, folders: [], terminal: false, computer: false } }); },
async save(env, input, info) {
const version = changeGrant(env.id);
const fresh = await identity(env);
if (!matches(info, fresh)) throw new Error("Server sign-in changed. Review computer access again.");
const folders = await validateSharedFolders(input.folders);
await requireEnabled();
const grant = { ...fresh, id: randomUUID(), secret: randomBytes(32).toString("hex"), enabled: true, folders, terminal: input.terminal === true, computer: input.computer === true };
if (!folders.length && !grant.terminal && !grant.computer) throw new Error("Choose at least one folder or capability to share");
if (grantVersions.get(env.id) !== version) return state(env.id);
disconnect(env, records[env.id]);
store({ ...records, [env.id]: grant }); run(env, grant); changed(); return state(env.id);
},
/** Whether `env` is a server of the person's own, checked now: its identity, or null (not paired as its admin, a team
* or Cloud, too old to take a lent computer, or not answering). */
ownServer: env => ownIdentity(env).catch(() => null),
/** A server of the person's own whose pairing here was never asked about: its identity, for the popup; null once the
* person answered for this pairing (a grant they allowed carries over to a new pairing of that same server); false
* when it is not a server of their own. */
async ownOffer(env) {
const info = await ownIdentity(env).catch(() => null), kept = records[env.id];
if (!info) return false;
if (kept && kept.environmentId === info.environmentId && (kept.sessionId === info.sessionId || (kept.own && kept.enabled === true))) return null;
return info;
},
/** The person allowed a server of their own to use this computer: what `input` lends, checked as every grant is, the
* home folder read only. Security: only while this desktop is that server's admin now; no folder unless chosen. */
async lendOwn(env, input) {
const version = changeGrant(env.id);
if (closed) throw new Error("Lending is unavailable while the app is closing.");
const info = await ownIdentity(env);
const folders = await validateSharedFolders(input?.folders ?? []);
const grant = { ...info, id: randomUUID(), secret: randomBytes(32).toString("hex"), enabled: true, own: true, folders, terminal: input?.terminal === true, computer: input?.computer === true };
if (!folders.length && !grant.terminal && !grant.computer) throw new Error("Choose at least one folder or capability to share");
if (grantVersions.get(env.id) !== version) return state(env.id);
disconnect(env, records[env.id]);
store({ ...records, [env.id]: grant }); run(env, grant); changed(); return state(env.id);
},
/** Not now: nothing is lent, and this pairing is not asked again. */
declineOwn(env, info) { changeGrant(env.id); disconnect(env, records[env.id]); store({ ...records, [env.id]: { ...info, enabled: false, own: true, folders: [], terminal: false, computer: false } }); changed(); },
cloudState,
/** What Files copies to My Cloud (electron/lent-folders.mjs): the folders lent to it, with this grant's id and the
* places no grant reaches, and `paused` while lending is; `{ off: true }` while nothing is lent to it. */
lentToCloud(env) {
const grant = env ? records[env.id] : undefined;
const lent = grant?.cloud ? grant : grant?.enabled === true ? grant : null;
if (lent?.enabled !== true || !Array.isArray(lent.folders)) return { off: true };
return { lendingId: lent.id, folders: lent.folders.map(({ id, path: where, name, write }) => ({ id, path: where, name, write: write === true })), protectedPaths: protectedRoots, ...(paused ? { paused: true } : {}) };
},
/** Lending to My Cloud is running, so it calls My Cloud whenever it is awake. */
lendingToCloud: () => [...running.keys()].some(id => Boolean(records[id]?.cloud)),
/** A server is using this computer now: a job running on it, or its browsing going through this computer. */
busy: () => summary().busy !== null,
saveCloud,
/** The move's project folders, lent to the home it went to (My Cloud unless `place` names a server of the person's own)
* with editing on top of the rest. It throws while lending there can't take them yet (paused, or not signed in there),
* so the move tries again later. */
async lendProjects(paths, place = null) {
const env = place ?? myCloud();
if (paused || !env || !verifiedCloud(env)) throw new Error(t("native.lend.moveWaiting"));
await lendAutomatically(Array.isArray(paths) ? paths.filter(where => typeof where === "string") : [], env);
if (records[env.id]?.enabled !== true) throw new Error(t("native.lend.moveNotStarted"));
},
paused: () => paused,
/** Lending's last requests have been answered or have timed out (10 s at most). Security: a Cloud re-issues its session
* cookie in the answer to any request that carried one, so that answer must land before the window's session is cleared. */
goodbyesDone: () => Promise.allSettled([...goodbyes, passing]),
/** Every Cloud of the person's own stops using this computer, across restarts too, until resume(). Sharing stops
* first; a save that fails sets pauseError, and Pause again retries it. */
pause() {
if (closed || (paused && !pauseError)) return;
paused = true;
for (const env of environments()) {
if (!records[env.id]?.cloud && !records[env.id]?.own) continue;
if (running.has(env.id)) disconnect(env, records[env.id]);
status.set(env.id, { connected: false, problem: "paused" });
}
try { store(records); } catch { pauseError = t("native.lendTray.pauseUnsaved"); }
changed();
wakeAsking();
},
resume: () => resumeLending(),
/** Whether every Cloud of the signed-in account's own may run commands here as this user, across restarts too. */
lendTerminal(on) {
const account = on === true ? signedInAccount() : null;
if (closed || terminalAccount === account) return;
if (on === true && !account) throw new Error(t("native.lend.terminalSignIn"));
const before = terminalAccount;
terminalAccount = account;
try { store(records); } catch (error) { terminalAccount = before; throw error; }
// A Cloud the pass can't verify now keeps its grant until it can, so its commands stop here.
if (!account) for (const [id, grant] of Object.entries(records)) if (grant?.cloud && grant.terminal === true) stop(id);
changed();
wakeAsking();
return lendAutomatically();
},
/** Computer control started or stopped: apps and screen follow. */
screenChanged: () => readScreen().then(() => { wakeAsking(); return lendAutomatically(); }),
revoke(env) {
changeGrant(env.id);
disconnect(env, records[env.id]);
if (records[env.id]) store({ ...records, [env.id]: { ...records[env.id], enabled: false, ...(records[env.id].enabled ? { stopped: { reason: "you", at: now() } } : {}) } });
changed();
return state(env.id);
},
forget(env) { changeGrant(env.id); disconnect(env, records[env.id]); const next = { ...records }; delete next[env.id]; store(next); changed(); },
start({ maintainer = true } = {}) {
const automatic = lentAutomatically();
for (const env of environments()) {
const grant = records[env.id];
if (!validRecord(grant)) continue;
if (grant.cloud) { if (!automatic.has(env.id) && cloudLendingVerdict(grant.cloud, cloud(env), env).allow) run(env, grant); }
else if (grant.own || (maintainer && !maintainerOff)) run(env, grant);
}
changed();
void lendAutomatically();
},
/** Another account takes over: this computer stops being lent to this one's Clouds, as when this app quits. Nothing is
* told to them (one asleep stays asleep) and what was lent is kept, so lending goes on if it comes back. */
leaveAccount(accountId) {
if (!accountId) return;
accountTurn++;
for (const [id, grant] of Object.entries(records)) {
if (grant?.cloud?.accountId !== accountId) continue;
stop(id);
const entry = asking.get(id);
if (entry) { entry.stop.abort(); asking.delete(id); }
}
changed();
},
cloudChanged() {
if (closed) return;
// The Admin's word on each other Cloud may have changed: a lending loop waiting for one to wake goes on.
for (const gate of ownGates.values()) gate.changed();
const automatic = lentAutomatically();
for (const env of environments()) {
const grant = records[env.id];
if (!grant?.cloud || grant.enabled !== true) continue;
const verdict = cloudLendingVerdict(grant.cloud, cloud(env), env);
if (verdict.stop) endCloud(env, verdict.stop);
else if (verdict.pause) { if (running.has(env.id)) { disconnect(env, grant, true); status.set(env.id, { connected: false, problem: "paused" }); changed(); } }
else if (!running.has(env.id) && !automatic.has(env.id) && validRecord(grant)) run(env, grant);
}
void lendAutomatically();
},
close() { closed = true; for (const id of running.keys()) stop(id); syncAsking(); asks.close(); },
};
}