// This computer's connections, moved once with the rest of the person's things to their own Cloud: MCP servers with // their secrets and sign-ins, the AI keys bots run on, connected apps and mailboxes. Secrets reach main only over the // server's private port (cloud-logins.mjs) and go only to the move's owner session on My Cloud. A sign-in that // refreshes itself moves: once My Cloud has it, this computer forgets its own copy, so only My Cloud uses it. // // const collected = await collectConnections(local); // const result = await sendConnections(collected, target, { local }); // // `local`: this computer's server, `{ json(route), handoff(what) }`: a loopback read, and the private port's answer. // `target`: the owner session on My Cloud, `{ call(method, route, body?) }` answering `{ ok, status, body }`. import nativeText from "./native-text.cjs"; const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value); const sentence = (value) => (typeof value === "string" && value.trim() ? value.trim().slice(0, 300) : null); const pick = (source, names) => Object.fromEntries(names.filter((name) => source[name] !== undefined && source[name] !== null).map((name) => [name, source[name]])); const ID = /^(?:mcp:[a-z][a-z0-9_-]{0,31}|key:\w{1,40}|composio|email)$/u; const INSTALLATION = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u; const ENVIRONMENT = /^[\w-]{1,128}$/u; const SLUG = /^[a-z0-9_][a-z0-9_-]{0,80}$/u; const ENGINE = /^[\w.:-]{1,128}$/u; const LAUNCHER = /^[a-z0-9._+-]{1,40}$/u; const DIGEST = /^[0-9a-f]{64}$/u; const KINDS = new Set(["mcp", "key", "composio", "email"]); /** Keys first, so the engines they run are ready before anything that follows them; connected apps last. */ const ORDER = { key: 0, mcp: 1, email: 2, composio: 3 }; const MCP_FIELDS = ["command", "args", "env", "type", "url", "headers", "oauth", "resultBudget", "enabled", "runsOn"]; /** How long a move waits for My Cloud to run the engines whose keys just went. */ const ENGINE_WAIT_MS = 10_000; export class ConnectionMoveError extends Error { /** `final`: trying again can't fix it (My Cloud's policy or limits refused it). */ constructor(code, message, final = false) { super(message); this.name = "ConnectionMoveError"; this.code = code; this.final = final; } } /** A server's answer to GET /api/cloud-connections, kept to its known shape; null when it is not one. */ export function parseConnections(value) { if (!record(value) || !Array.isArray(value.connections) || !record(value.computer)) return null; const { environmentId, label } = value.computer; if (typeof environmentId !== "string" || !ENVIRONMENT.test(environmentId) || typeof label !== "string" || !label.trim()) return null; const strings = (list, pattern, max) => (Array.isArray(list) ? [...new Set(list.filter((entry) => typeof entry === "string" && pattern.test(entry)))].slice(0, max) : []); const connections = value.connections.filter((item) => record(item) && typeof item.id === "string" && ID.test(item.id) && KINDS.has(item.kind) && item.id.split(":")[0] === item.kind && typeof item.label === "string" && item.label.trim()) .slice(0, 200).map((item) => ({ id: item.id, kind: item.kind, label: item.label.trim().slice(0, 120), ...(item.runsHere === true ? { runsHere: true } : {}), ...(typeof item.launcher === "string" && LAUNCHER.test(item.launcher) ? { launcher: item.launcher } : {}), ...(Array.isArray(item.for) ? { for: strings(item.for, ENGINE, 20) } : {}), ...(item.signIn === true ? { signIn: true } : {}), ...(item.mode === "managed" || item.mode === "own" ? { mode: item.mode } : {}), ...(typeof item.account === "string" && item.account.length <= 120 ? { account: item.account } : {}), ...(typeof item.installationId === "string" && INSTALLATION.test(item.installationId) ? { installationId: item.installationId } : {}), })); return { computer: { environmentId, label: label.trim().slice(0, 120) }, connections, engines: strings(value.engines, ENGINE, 200), commands: strings(value.commands, LAUNCHER, 40), ...(value.managedApps === false ? { managedApps: false } : {}), ...(value.vaultKeys === true ? { vaultKeys: true } : {}) }; } /** This computer's connections, by name only, and the computer they are on. */ export async function collectConnections(local) { const listed = parseConnections(await local.json("/api/cloud-connections").catch(() => null)); if (!listed) throw new ConnectionMoveError("local", "OpenMausBot on this computer did not list its apps and keys. Try again in a moment."); return { computer: listed.computer, connections: listed.connections }; } function refused(answer, fallback) { if (answer.status === 401) return new ConnectionMoveError("refused", "My Cloud did not let this app make the change. Open My Cloud in this app once, then try again."); const said = sentence(answer.body?.error); return new ConnectionMoveError("refused", said ? `My Cloud said: ${said}` : fallback, answer.status === 400 || answer.status === 403); } async function secrets(local, item) { const answer = await local.handoff({ kind: "connection", id: item.id }).catch(() => null); if (!record(answer?.connection)) throw new ConnectionMoveError("local", `${item.label} could not be read on this computer.`); return { connection: answer.connection, ...(typeof answer.digest === "string" && DIGEST.test(answer.digest) ? { digest: answer.digest } : {}) }; } /** My Cloud's connections once it runs every one of these engines, asked every half second; the last ones read when it * does not get there in time. */ async function runningEngines(target, engines, waitMs, cloud) { for (const started = Date.now(); ; await new Promise((resolve) => setTimeout(resolve, 500))) { const answer = await target.call("GET", "/api/cloud-connections").catch(() => null); const read = answer?.ok ? parseConnections(answer.body) : null; if (read) cloud = read; if ([...engines].every((engine) => cloud.engines.includes(engine)) || Date.now() - started >= waitMs) return cloud; } } const signInRoute = (item) => `/api/cloud-connections/mcp/${item.id.slice("mcp:".length)}/sign-in`; /** This computer's newer copy of a sign-in, in place of the one My Cloud took from it (`onCloud`); its digest, or null. */ async function sendAgain(local, target, item, onCloud) { const fresh = await secrets(local, item).catch(() => null); const signIn = fresh?.connection.mcp?.signIn; if (!record(signIn) || !fresh.digest) return null; const answer = await target.call("PUT", `${signInRoute(item)}?replaces=${onCloud}`, signIn).catch(() => null); return answer?.ok && answer.body?.adopted === true ? fresh.digest : null; } /** This computer lets go of a sign-in My Cloud took (`digest`: what My Cloud has); one that refreshed here meanwhile * goes again first. True once let go of. */ async function letGo(local, target, item, digest) { let onCloud = digest; for (let tries = 0; tries < 3 && onCloud; tries++) { const answer = await local.handoff({ kind: "connection-release", id: item.id, digest: onCloud }).catch(() => null); if (answer?.release?.released === true) return { released: true }; if (answer?.release?.changed !== true) break; onCloud = await sendAgain(local, target, item, onCloud); } return { released: false, digest: onCloud ?? digest }; } /** * Makes My Cloud act as this computer's connected-apps account: with OpenMausBot's service, a one-time code minted for * My Cloud's own installation; with the person's own Composio project, the same key and user. A Cloud whose own apps * are connected keeps them, and says which apps connected here it lacks (`missing`). * `here` and `there`: the "composio" connection of this computer and of My Cloud (null when My Cloud has none yet). */ export async function adoptComposioId(target, { local, here, there, managedApps = true, t = nativeText.englishText.t }) { if (here.account && there?.account === here.account) return { shared: true }; if (here.mode === "managed" && !managedApps) { throw new ConnectionMoveError("no_service", t("native.cloud.connectedAppsStay"), true); } const listed = await local.json("/api/connectors/connected").catch(() => null); const apps = Object.entries(record(listed?.services) ? listed.services : {}).filter(([slug, state]) => SLUG.test(slug) && record(state) && state.connected === true) .map(([slug]) => slug).slice(0, 200); let body; if (here.mode === "managed") { if (!there?.installationId) throw new ConnectionMoveError("not_ready", "My Cloud's connected apps are not ready yet. Try again in a moment."); const code = (await local.handoff({ kind: "composio-share", for: there.installationId }).catch(() => null))?.share?.code; if (typeof code !== "string") throw new ConnectionMoveError("local", "This computer's connected apps could not be shared."); body = { share: code, apps }; } else { const own = (await secrets(local, here)).connection.composio; if (!record(own) || typeof own.apiKey !== "string" || typeof own.userId !== "string") throw new ConnectionMoveError("local", "This computer's connected apps could not be read."); body = { apiKey: own.apiKey, userId: own.userId, apps }; } const answer = await target.call("POST", "/api/cloud-connections/connected-apps", body); if (!answer.ok) throw refused(answer, "My Cloud did not take this computer's connected apps."); if (answer.body?.shared === true) return { shared: true }; return { kept: true, missing: Array.isArray(answer.body?.missing) ? answer.body.missing.filter((slug) => typeof slug === "string" && SLUG.test(slug)) : [] }; } /** * Moves each of this computer's connections to My Cloud, keys first: each over My Cloud's own routes, with its secrets. * An MCP server that needs this computer (its program, files or address are here, or My Cloud lacks its program) goes * marked as running on this computer, which My Cloud never starts. A sign-in that refreshes itself goes too, and this * computer lets go of its copy once My Cloud has it (with `release: false`, the result lists it instead, for * releaseConnections). One connection failing never stops the others. * * With `signIns: false`, a sign-in that refreshes itself stays here for a later pass (`later`), and the rest goes. * * Returns { moved, runsHere, kept, missing, failed, release, later, held }: what My Cloud has now; what went marked as * running here (`needs`: a program My Cloud lacks; `off`: it was switched off here too); what My Cloud kept of its own (its own sign-in, its own connected * apps); the connected apps of this computer's that My Cloud then lacks; what failed (`final`: trying again can't fix * it); the sign-ins My Cloud took that this computer still holds; the sign-ins left for a later pass; and the keys a * trial's Cloud keeps unused while its Claude credit lasts. */ export async function sendConnections(collected, target, { local, release = true, signIns = true, waitMs = ENGINE_WAIT_MS, onProgress = () => {}, t = nativeText.englishText.t } = {}) { const answer = await target.call("GET", "/api/cloud-connections").catch(() => null); if (answer?.status === 404) throw new ConnectionMoveError("outdated", "My Cloud needs an update before it can take this computer's apps and keys. It updates by itself."); let cloud = answer?.ok ? parseConnections(answer.body) : null; if (!cloud) throw new ConnectionMoveError("unreachable", "My Cloud did not answer. Try again in a moment."); const result = { moved: [], runsHere: [], kept: [], missing: [], failed: [], release: [], later: [], held: [] }; const done = (item) => result.moved.push({ id: item.id, label: item.label }); /** A sign-in to My Cloud; one that refreshes itself (`digest`) is then let go of here. My Cloud keeps one of its own. */ async function moveSignIn(item, signIn, digest) { const put = await target.call("PUT", signInRoute(item), signIn); if (!put.ok) throw refused(put, `My Cloud did not take ${item.label}'s sign-in.`); if (put.body?.kept === true) return void result.kept.push({ id: item.id, label: item.label }); if (!digest) return; const outcome = release ? await letGo(local, target, item, digest) : { released: false, digest }; if (!outcome.released) result.release.push({ id: item.id, label: item.label, digest: outcome.digest }); } const steps = { async key(item) { const { connection } = await secrets(local, item); if (!record(connection.config)) throw new ConnectionMoveError("local", `${item.label} could not be read on this computer.`); const holding = await target.call("PUT", "/api/cloud-connections/held-keys", { for: item.for ?? [] }).catch(() => null); const saved = await target.call("PATCH", "/api/config", connection.config); if (!saved.ok) throw refused(saved, `My Cloud did not take the ${item.label} key.`); if (holding?.ok && holding.body?.held === true) result.held.push({ id: item.id, label: item.label }); done(item); return holding?.ok && holding.body?.held === true; }, async mcp(item) { const { connection, digest } = await secrets(local, item); const { name, server, signIn, keys } = record(connection.mcp) ? connection.mcp : {}; if (typeof name !== "string" || !record(server)) throw new ConnectionMoveError("local", `${item.label} could not be read on this computer.`); // A Cloud too old to read a `vault:` key would save the pointer as the key itself: it goes once the Cloud has updated. if (Array.isArray(keys) && keys.length && !cloud.vaultKeys) throw new ConnectionMoveError("outdated", "My Cloud needs an update before it can take this computer's apps and keys. It updates by itself."); const { enabled, runsOn: marked, ...definition } = pick(server, MCP_FIELDS); const missing = item.launcher && !cloud.commands.includes(item.launcher) ? item.launcher : null; const runsOn = record(marked) ? marked : item.runsHere || missing ? collected.computer : null; const keysField = Array.isArray(keys) && keys.length ? { vaultKeys: keys } : {}; let saved; if (cloud.connections.some((theirs) => theirs.id === item.id)) { saved = await target.call("PUT", `/api/mcp/servers/${name}`, { ...definition, enabled: enabled === true, runsOn, ...keysField }); } else { saved = await target.call("POST", "/api/mcp/servers", { name, ...definition, ...(runsOn ? { runsOn } : {}), ...keysField }); if (saved.ok && enabled === true) saved = await target.call("PATCH", `/api/mcp/servers/${name}`, { enabled: true }); } if (!saved.ok) throw refused(saved, `My Cloud did not take ${item.label}.`); if (runsOn) return void result.runsHere.push({ id: item.id, label: item.label, ...(missing && !item.runsHere && !record(marked) ? { needs: missing } : {}), ...(enabled === true ? {} : { off: true }) }); done(item); if (record(signIn)) { if (!digest) throw new ConnectionMoveError("local", `${item.label}'s sign-in could not be read on this computer.`); if (!signIns) return void result.later.push({ id: item.id, label: item.label }); await moveSignIn(item, signIn, digest); } }, async email(item) { const accounts = (await secrets(local, item)).connection.email; if (!Array.isArray(accounts)) throw new ConnectionMoveError("local", t("native.cloud.mailRead")); const answer = await target.call("PUT", "/api/cloud-connections/email", { accounts }); if (answer.status === 404) throw new ConnectionMoveError("outdated", t("native.cloud.mailOutdated"), true); if (!answer.ok) throw refused(answer, t("native.cloud.mailRefused")); done(item); }, async composio(item) { const outcome = await adoptComposioId(target, { local, here: item, there: cloud.connections.find((theirs) => theirs.id === "composio") ?? null, managedApps: cloud.managedApps !== false, t }); if (outcome.kept) { result.kept.push({ id: item.id, label: item.label }); result.missing.push(...outcome.missing.map((slug) => ({ slug }))); } else done(item); }, }; const items = [...collected.connections].sort((a, b) => ORDER[a.kind] - ORDER[b.kind]); const engines = new Set(); let count = 0; for (const item of items) { if (item.kind !== "key" && engines.size) { cloud = await runningEngines(target, engines, waitMs, cloud); engines.clear(); } try { const held = await steps[item.kind](item); if (item.kind === "key" && !held) for (const engine of item.for ?? []) if (!cloud.engines.includes(engine)) engines.add(engine); } catch (error) { result.failed.push({ id: item.id, label: item.label, message: sentence(error?.message) ?? `${item.label} did not move.`, final: error?.final === true }); } onProgress({ done: ++count, total: items.length, label: item.label }); } if (engines.size) await runningEngines(target, engines, waitMs, cloud); return result; } /** Lets go of sign-ins My Cloud took that this computer still holds (`release` of a move's result); the ones it could * not let go of yet. */ export async function releaseConnections(pending, { local, target }) { const held = []; for (const entry of pending) { if (!record(entry) || typeof entry.id !== "string" || !ID.test(entry.id) || typeof entry.digest !== "string" || !DIGEST.test(entry.digest)) continue; const item = { id: entry.id, kind: entry.id.startsWith("mcp:") ? "mcp" : entry.id, label: typeof entry.label === "string" ? entry.label : entry.id }; const outcome = await letGo(local, target, item, entry.digest); if (!outcome.released) held.push({ id: item.id, label: item.label, digest: outcome.digest }); } return held; }