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:`). */ 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(); }, }; }