// Send to My Cloud: this computer's bots, their skills, logins, saved secrets, 1Password, settings and chats go to the person's own // Cloud. One direction; added and updated, never deleted; one action for every entry point. Chats go by row // (server/cloud-send-chats.ts): each conversation's rows after the last one My Cloud took, so its history goes once. A // Cloud too old for that gets Bring newer chats' copy, only from a send the person asked for. // // The move ("move") is the same send run to carry everything this computer has: its apps and keys first // (cloud-connections.mjs), then every bot and its chats whatever was once kept here, over an owner session that counts // as someone using My Cloud so it stays awake; a sign-in that refreshes itself goes only in the final pass, and this // computer lets go of its own copy once My Cloud has it. `check` then reads both sides to say what did not arrive // (cloud-send-check.mjs). // // What each side has comes from both servers' GET /api/cloud-send; what goes is decided part by part (planSend in // cloud-send-parts.mjs) and recorded item by item, so a send cut short goes on from the item it reached. Every write is // one of My Cloud's own routes, over one owner session per send: the Admin's one-time code for the Cloud it verified, // redeemed at that Cloud alone, signed out after, as Copy does. A secret reaches main only over the server's private // port (server/login-handoff.ts) and goes only to that session. import { createHash } from "node:crypto"; import { AUTOMATION_KINDS, BOT_PARTS, EMPTY, MOVE_BOT_PARTS, MOVE_KINDS, PICTURE_PART, differenceSignature, failedSummary, itemKey, parseSendSummary, pendingRows, planSend, resultKey } from "./cloud-send-parts.mjs"; import { collectConnections, releaseConnections, sendConnections } from "./cloud-connections.mjs"; import { createMoveKinds, mergeRoutines } from "./cloud-send-kinds.mjs"; import { botsOnCloud } from "./cloud-logins.mjs"; import { moveCheck, patientSession, rowIdChunks, unanswered } from "./cloud-send-check.mjs"; import nativeText from "./native-text.cjs"; const LOCAL_ROUTE = /^\/api\/(?:cloud-send|cloud-send\?move=1|cloud-send\/manifest|cloud-send\/chats|cloud-send\/chats\?attachments=1|cloud-send\/kind\?key=[\w%.-]{1,300}|cloud-send\/memory|cloud-send\/memory\?bot=[\w-]{1,128}|cloud-send\/files|cloud-send\/files\?path=[\w%.()~!*'-]{1,12000}&offset=\d{1,16}&length=\d{1,9}|cloud-send\/chats\/rows\?thread=[\w-]{1,200}&after=\d{1,16}&id=[\w%.-]{0,600}|cloud-send\/chats\/ids\?thread=[\w-]{1,200}&from=\d{1,9}|cloud-send\/chats\/attachment\/[A-Za-z0-9-]{1,80}\.[A-Za-z0-9]{1,10}|cloud-connections|connectors\/connected|config|bots\?messages=0|bots\/[\w-]{1,128}\/(?:skills|skills\/[a-z0-9-]{1,64}|memory|memory\/file\?path=[\w%.-]{1,200})|attachments\/[A-Za-z0-9-]{1,80}\.(?:png|jpg|gif|webp))$/u; /** One request's chats: a few conversations' rows, well under My Cloud's body limit. */ const CHATS_BATCH_BYTES = 4 * 1024 * 1024; const CHATS_BATCH_PAGES = 40; /** My Cloud checks at most this many attachment names a request. */ const ATTACHMENT_NAMES = 2000; /** My Cloud takes at most this many row ids, of at most this many conversations, a request. */ const ROW_IDS = 10_000; const ROW_ID_CONVERSATIONS = 200; const MEMORY_FILES = 100; const SCOPED_READ = /^\/api\/cloud-send(?:\?move=1|\/manifest|\/chats(?:\?attachments=1)?|\/files)$/u; const PICTURE_TYPES = { png: "image/png", jpg: "image/jpeg", gif: "image/gif", webp: "image/webp" }; const VAULT_ID = /^[\w-]{1,128}$/u; /** Whole sends, which carry the chats too; a send of some items (Use this computer's, one login) never does. */ const CHAT_REASONS = new Set(["button", "my-cloud", "menu", "move"]); /** Bring newer chats' copy of the whole workspace, for a Cloud too old to take chats by row: never a move, which such a * Cloud refuses. */ const OUTDATED_FOR_MOVE = "My Cloud is updating. Your bots move there by themselves when it's done."; const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value); const sentence = (value) => (typeof value === "string" && value.trim() ? value.trim().replace(/\s+/g, " ").slice(0, 300) : null); export class CloudSendError extends Error { constructor(code, message, options) { super(message, options); this.name = "CloudSendError"; this.code = code; } } export { unanswered }; /** Security: only a `serverError.*` key passes, so a server cannot make the page show any other catalog string. */ export function namedRefusal(answer) { if (answer.status !== 401 && answer.status !== 403) return null; const key = answer.body?.errorKey, message = sentence(answer.body?.error); if (typeof key !== "string" || !key.startsWith("serverError.") || !message) return null; const params = record(answer.body.errorParams) ? Object.fromEntries(Object.entries(answer.body.errorParams).filter(([, value]) => typeof value === "string" || typeof value === "number")) : {}; return { message, errorKey: key, errorParams: params }; } /** One plain sentence for a failed write, from what My Cloud answered. */ function refusal(answer, fallback) { if (answer.status === 401 || answer.status === 403) return namedRefusal(answer)?.message ?? "My Cloud did not let this app make the change. Open My Cloud in this app once, then try again."; if (answer.status === 409 && /limited to \d+ bots/u.test(answer.body?.error ?? "")) return sentence(answer.body.error); return sentence(answer.body?.error) ? `My Cloud said: ${sentence(answer.body.error)}` : fallback; } function refusedError(code, answer, fallback) { const named = namedRefusal(answer); return Object.assign(new CloudSendError(code, refusal(answer, fallback)), named ? { errorKey: named.errorKey, errorParams: named.errorParams } : {}); } /** My Cloud's own 1Password refusal speaks to someone pasting a token on My Cloud's page; here it was this computer's. * 1Password refusing the token is final: sending it again can't help until 1Password is connected again here. */ function onePasswordRefusal(answer) { const said = sentence(answer.body?.error); if (answer.status === 400 && said && /did not accept the token|not a whole 1Password|service account token/iu.test(said)) { return Object.assign(new CloudSendError("refused", "My Cloud's 1Password did not accept this computer's token: it may have been revoked, expired, or lost access to its vault. Connect 1Password again in this computer's Settings → Logins, and it goes to My Cloud by itself."), { final: true }); } return refusedError("refused", answer, "My Cloud could not connect 1Password."); } const pick = (source, names) => Object.fromEntries(names.filter((name) => source[name] !== undefined && source[name] !== null).map((name) => [name, source[name]])); /** A bot's fields for some parts, as My Cloud's PATCH takes them: never Custom approval, which only this computer's own * window can grant. */ export function botPatch(bot, parts) { const body = {}; for (const part of parts) { const names = BOT_PARTS[part]; if (names) Object.assign(body, pick(bot, names)); } if (body.approvalMode === "custom") delete body.approvalMode; if (record(body.modelSelection)) body.modelSelection = pick(body.modelSelection, ["instanceId", "model", "effort", "variant"]); if (parts.includes("model") && record(body.modelSelection)) body.requireAvailableModel = true; return body; } const outcomeOf = (value) => record(value) && Number.isSafeInteger(value.at) && record(value.result) ? value : null; const COUNTED = ["bots", "updatedBots", "skills", "logins", "settings"]; /** What reached My Cloud from this computer, all sends together. */ function countsOf(value) { const chats = record(value?.chats) ? value.chats : {}; const whole = (count) => Number.isSafeInteger(count) && count > 0 ? count : 0; return { ...Object.fromEntries(COUNTED.map((key) => [key, whole(value?.[key])])), onePassword: value?.onePassword === true, chats: { conversations: whole(chats.conversations), messages: whole(chats.messages) } }; } const addCounts = (total, sent) => countsOf({ ...Object.fromEntries(COUNTED.map((key) => [key, total[key] + sent[key]])), onePassword: total.onePassword || sent.onePassword, chats: { conversations: total.chats.conversations + (sent.chats?.conversations ?? 0), messages: total.chats.messages + (sent.chats?.messages ?? 0) } }); /** The state main keeps in its own folder: per Cloud what was sent, what the person kept here, the bots a send made * there whose fields still wait to be recorded (`created`), the bots a send asked My Cloud to make and has not heard back * about (`creating`), the sign-ins My Cloud took that this computer still holds * (`release`), the bot files a move put there (`files`, path to sha256), whether each automation a move carried was * on here (`on`), the conversations a move's check found whole (`verified`: this computer's newest row and row count * then, and how many rows My Cloud's manifest said it had), and those whose earlier rows My Cloud made its owner's * (`stamped`). */ export function parseSendState(value) { const clouds = {}; if (record(value?.clouds)) { for (const [origin, entry] of Object.entries(value.clouds)) { if (!/^https?:\/\/[^/\s]+$/u.test(origin) || !record(entry)) continue; clouds[origin] = { sent: record(entry.sent) ? entry.sent : {}, off: record(entry.off) ? Object.fromEntries(Object.entries(entry.off).filter(([, why]) => why === "you" || why === "removed")) : {}, dismissed: typeof entry.dismissed === "string" ? entry.dismissed : "", last: outcomeOf(entry.last), whole: outcomeOf(entry.whole), arrived: countsOf(entry.arrived), chats: record(entry.chats) ? Object.fromEntries(Object.entries(entry.chats).filter(([threadId, cursor]) => /^[\w-]{1,200}$/u.test(threadId) && (cursor === "removed" || (record(cursor) && Number.isSafeInteger(cursor.after) && typeof cursor.id === "string")))) : {}, created: record(entry.created) ? Object.fromEntries(Object.entries(entry.created).filter(([key, on]) => key.startsWith("bot:") && on === true)) : {}, creating: record(entry.creating) ? Object.fromEntries(Object.entries(entry.creating).filter(([key, on]) => key.startsWith("bot:") && on === true)) : {}, release: Array.isArray(entry.release) ? entry.release.filter((held) => record(held) && typeof held.id === "string" && typeof held.digest === "string").slice(0, 200) : [], files: record(entry.files) ? Object.fromEntries(Object.entries(entry.files).filter(([path, sha]) => typeof sha === "string" && /^[a-f0-9]{64}$/u.test(sha) && path.length <= 4096)) : {}, on: record(entry.on) ? Object.fromEntries(Object.entries(entry.on).filter(([key, on]) => /^(?:routine|call|webhook|app-event):[\w-]{1,128}$/u.test(key) && typeof on === "boolean")) : {}, verified: record(entry.verified) ? Object.fromEntries(Object.entries(entry.verified).filter(([threadId, seen]) => /^[\w-]{1,200}$/u.test(threadId) && record(seen) && typeof seen.newest === "string" && Number.isSafeInteger(seen.rows) && Number.isSafeInteger(seen.there))) : {}, stamped: record(entry.stamped) ? Object.fromEntries(Object.entries(entry.stamped).filter(([threadId, on]) => /^[\w-]{1,200}$/u.test(threadId) && on === true)) : {}, }; } } return { v: 1, clouds }; } /** What My Cloud's own page is told: the names of this computer's bots that wait, and counts. */ export function sendNotice(snapshot) { const rows = pendingRows({ rows: snapshot.rows ?? [] }); const count = (kind) => rows.filter((row) => row.kind === kind).length; return { ready: snapshot.cloud === "ready", bots: rows.filter((row) => row.kind === "bot" && row.state === "only-here").map((row) => row.label), updatedBots: rows.filter((row) => row.kind === "bot" && row.state === "to-update").map((row) => row.label), skills: count("skill"), logins: count("login"), onePassword: count("1password") > 0, settings: count("setting"), signature: snapshot.signature ?? "", dismissed: Boolean(snapshot.signature) && snapshot.signature === snapshot.dismissed, running: Boolean(snapshot.running), ...(snapshot.running ? { runningReason: snapshot.running.reason, ...(snapshot.running.progress ? { progress: snapshot.running.progress } : {}), ...(snapshot.running.phase ? { phase: snapshot.running.phase } : {}) } : {}), last: snapshot.last ? told(snapshot.last, snapshot.dismissed) : null, whole: snapshot.whole ? told(snapshot.whole, snapshot.dismissed) : null, arrived: countsOf(snapshot.arrived), }; } /** A send's outcome as My Cloud's page is told it: counts and reasons only. */ function told(outcome, dismissed) { return { at: outcome.at, reason: outcome.reason, failed: outcome.result.failed.length, sent: sentCounts(outcome.result), // Every failure is one trying again can't fix (1Password refused the token): no Try again. final: !outcome.error && outcome.result.failed.length > 0 && outcome.result.failed.every((entry) => entry.final === true), seen: dismissed === resultKey(outcome), ...failedSummary(outcome) }; } /** What a send got across, in counts only: My Cloud's page names nothing of this computer's but its bots. */ function sentCounts(result) { return { bots: result.bots.length, updatedBots: result.updatedBots.length, skills: result.skills, logins: result.logins, ...(result.secrets ? { secrets: result.secrets } : {}), onePassword: result.onePassword, settings: result.settings.length, chats: result.chats ? { conversations: result.chats.conversations, messages: result.chats.messages } : null }; } /** What a send did, in counts and the names a person recognises. `connections`: apps and keys a move carried. */ function emptyResult() { return { bots: [], updatedBots: [], skills: 0, logins: 0, onePassword: false, settings: [], connections: 0, chats: null, failed: [], notes: [] }; } /** What a move carried beyond a send: automations and skill extras, memory merged, files, conversations' settings. */ const movedResult = () => ({ moved: 0, memory: 0, files: { sent: 0, kept: 0 }, conversationSettings: 0 }); /** * @param {{ * local: (route: string) => Promise, * cloud: { * origin(): string | null, * asleep(): boolean, * read(route: string): Promise, * open(options?: { move?: boolean }): Promise<{ call(method: string, route: string, body?: unknown): Promise<{ status: number, ok: boolean, body: any }>, * upload(route: string, bytes: Uint8Array, type: string): Promise<{ status: number, ok: boolean, body: any }>, close(): Promise }>, * }, * handoff: (what: { kind: string, [key: string]: unknown }) => Promise, * store: { read(): unknown, write(value: unknown): void }, * onChange?: (snapshot: unknown) => void, * now?: () => number, * engineWaitMs?: number, * space?: { disk(): unknown, grow(sizeGb: number): Promise }, * team?: { bot: string, name: string, label: string }, * patience?: { stallMs?: number, waits?: number[], blips?: number[] }, * }} deps `patience`: how long a move waits for a Cloud that does not answer (cloud-send-check.mjs). `open({ move: true })`: the move's session, which counts as someone using My Cloud. `engineWaitMs`: how long a * move waits for My Cloud to run the engines whose keys it just took. `local(route, { method })`: a change on this * computer's server (an app event My Cloud now collects). `space`: My Cloud's plan disk and the Admin's grow. `team`: a * move of this one bot into a team (team-move.mjs): only the bot, its skills, files, chats and routines go, under the * name the team gave it; never apps, keys, logins, settings or its notes about the person. `local` is then the bot's * own Cloud, and `label` names the person beside a file or bot of the same name. */ export function createCloudSend({ local, cloud, handoff, store, onChange = () => {}, now = Date.now, sleep, patience = {}, engineWaitMs, space, team = null, t = nativeText.englishText.t }) { let saved = parseSendState(safeRead(store)); let view = { cloud: "none", plan: null, local: null, theirs: null, chats: null }; let running = null, progress = null, checking = null, localScope = null; const only = team?.bot ?? null; /** A route as a move of one bot asks it of either side: kept to that bot. */ const scoped = (route) => (only ? `${route}${route.includes("?") ? "&" : "?"}bot=${only}` : route); function safeRead(source) { try { return source.read(); } catch { return null; } } function persist() { try { store.write(saved); } catch {} } const entry = (origin) => (saved.clouds[origin] ??= { sent: {}, off: {}, dismissed: "", last: null, whole: null, arrived: countsOf(null), chats: {}, created: {}, creating: {}, release: [], files: {}, on: {}, verified: {}, stamped: {} }); /** What a move ignores of what the person kept here: everything goes but what was removed on My Cloud. */ const removedOnly = (off) => Object.fromEntries(Object.entries(off).filter(([, why]) => why === "removed")); const connectionLocal = { json: (route) => localJson(route), handoff: (what) => handoff(what) }; function snapshot() { const origin = cloud.origin(); const mine = origin ? entry(origin) : null; const plan = view.plan; return { cloud: origin ? view.cloud : "none", running: running ? { reason: running.reason, ...(progress ? { progress } : {}), ...(running.chats ? { phase: "chats" } : {}) } : null, rows: plan?.rows ?? [], cloudOnly: plan?.cloudOnly ?? [], pending: plan ? pendingRows(plan).length : 0, chats: view.chats ?? null, signature: plan && pendingRows(plan).length ? createHash("sha256").update(differenceSignature(plan)).digest("hex").slice(0, 32) : "", dismissed: mine?.dismissed ?? "", last: mine?.last ?? null, whole: mine?.whole ?? null, arrived: mine?.arrived ?? countsOf(null), cloudVersion: view.theirs?.appVersion ?? null, localVersion: view.local?.appVersion ?? null, checking, }; } const publish = () => { const value = snapshot(); try { onChange(value); } catch {} return value; }; const allowed = (route) => LOCAL_ROUTE.test(route.replace(/[?&]bot=[\w-]{1,128}$/u, "")); /** What a move of some bots reads of this computer's server names them. */ const chosenOnly = (route) => (localScope && SCOPED_READ.test(route) ? `${route}${route.includes("?") ? "&" : "?"}scope=${localScope}` : route); async function localJson(route, options) { if (!allowed(route)) throw new CloudSendError("invalid", "Unsupported local request."); const response = await local(chosenOnly(route), options); if (!response.ok) throw new CloudSendError("local", "OpenMausBot on this computer did not answer. Try again in a moment."); return response.json(); } async function localBytes(route) { if (!allowed(route)) throw new CloudSendError("invalid", "Unsupported local request."); const response = await local(route); if (!response.ok) return null; return new Uint8Array(await response.arrayBuffer()); } async function localSend(method, route) { if (!LOCAL_ROUTE.test(route) || method !== "DELETE") throw new CloudSendError("invalid", "Unsupported local request."); const response = await local(route, { method }); await response.body?.cancel(); return { ok: response.ok, status: response.status }; } const kinds = createMoveKinds({ localJson, localBytes, localSend, entry, persist, refusal, space, scoped, progress: (value) => { progress = value; publish(); } }); async function readCloud() { let response; try { response = await cloud.read("/api/cloud-send"); } catch { return { state: cloud.asleep() ? "asleep" : "unreachable" }; } if (response.status === 404) return { state: "outdated" }; if (!response.ok) return { state: response.status === 401 || response.status === 403 ? "signed-out" : "unreachable" }; const summary = parseSendSummary(await response.json().catch(() => null)); return summary ? { state: "ready", summary } : { state: "unreachable" }; } /** `everything`: a move's plan, which goes past what the person once kept here. A bot a send made on My Cloud whose * fields were not recorded yet has them all sent again. */ function plan(origin, mine, theirs, force = [], { everything = false } = {}) { const kept = entry(origin); // A Cloud too old to keep a secret by its id would make a new one every send. const going = theirs?.vault ? mine : { ...mine, items: (mine?.items ?? []).filter((item) => item.kind !== "secret") }; const planned = planSend({ local: going, cloud: theirs, sent: kept.sent, off: everything ? removedOnly(kept.off) : kept.off, force: [...force, ...Object.keys(kept.created)] }); if (everything && !team) mergeRoutines(planned.rows); let changed = false; for (const [key, parts] of Object.entries(planned.baseline)) { kept.sent[key] = { ...kept.sent[key], ...parts }; changed = true; } for (const key of planned.removed) { kept.off[key] = "removed"; delete kept.sent[key]; delete kept.created[key]; changed = true; } if (everything) { // A move has nobody to ask: a part both sides hold otherwise is My Cloud's (§2.2), and recorded as settled, so the // move's check (manifestGaps) agrees with this plan instead of calling it missing on every pass. const here = new Map((mine?.items ?? []).map((item) => [item.key, item])), there = new Map((theirs?.items ?? []).map((item) => [item.key, item])); for (const row of planned.rows) { // A routine's runs are history My Cloud adds by id and never drops: when they are all that differs, it goes. if (row.kind === "routine" && row.differs?.includes("runs") && !row.differs.includes("def")) { row.differs = row.differs.filter((part) => part !== "runs"); row.parts = [...(row.parts ?? []), "runs"]; row.state = "to-update"; if (!row.differs.length) delete row.differs; } for (const part of row.differs ?? []) { kept.sent[row.key] = { ...kept.sent[row.key], [part]: [here.get(row.key).parts[part], there.get(row.key)?.parts[part] ?? EMPTY] }; changed = true; } } } if (changed) persist(); return planned; } /** The conversations still to go, for Settings and the page; what was known before when this computer did not answer. */ const chatsLeft = (origin, theirs) => theirs?.chats ? chatsWaiting(origin, theirs).then(({ ready, busy }) => ({ waiting: ready.length + busy })).catch(() => view.chats ?? null) : Promise.resolve(null); /** Reads both sides and plans; never writes to My Cloud. */ async function compare() { const origin = cloud.origin(); if (!origin) { view = { cloud: "none", plan: null, local: null, theirs: null, chats: null }; return publish(); } if (cloud.asleep()) { view = { ...view, cloud: "asleep" }; return publish(); } const [mine, theirs] = await Promise.all([localJson("/api/cloud-send").then(parseSendSummary).catch(() => null), readCloud()]); if (!mine) { view = { ...view, cloud: "local-unavailable" }; return publish(); } if (theirs.state !== "ready") { view = { cloud: theirs.state, plan: null, local: mine, theirs: null, chats: null }; return publish(); } view = { cloud: "ready", plan: plan(origin, mine, theirs.summary), local: mine, theirs: theirs.summary, chats: await chatsLeft(origin, theirs.summary) }; return publish(); } /** One send at a time; one asked while another runs waits for it, then runs as it was asked. A move (`reason: "move"`) * carries everything; its final pass (`final`) carries the sign-ins that refresh themselves too, and this computer lets * go of each once My Cloud has it. */ async function send({ keys = null, force = [], reason = "button", final = false, scope = null } = {}) { while (running) await running.promise.catch(() => {}); const origin = cloud.origin(); if (!origin) throw new CloudSendError("no_cloud", "This computer has no My Cloud yet."); const job = { reason, final: reason === "move" && final === true, scope, promise: null, chats: false }; running = job; progress = null; localScope = scope; publish(); job.promise = (async () => { try { return await sendTo(origin, job, { keys, force: Array.isArray(force) ? force.filter((key) => typeof key === "string") : [], reason }); } catch (error) { // Kept as this send's outcome, so the strip and Settings say why, instead of an earlier send's result. const kept = entry(origin); kept.last = { at: now(), reason, result: emptyResult(), error: sentence(error?.message) ?? "Nothing could be sent to My Cloud. Try again." }; if (!keys && CHAT_REASONS.has(reason)) kept.whole = kept.last; persist(); if (unanswered(error)) view = { ...view, cloud: "unreachable" }; throw error; } finally { running = null; progress = null; localScope = null; publish(); } })(); return job.promise; } async function sendTo(origin, job, { keys, force, reason }) { const result = { ...emptyResult(), ...(reason === "move" ? movedResult() : {}) }; const mine = parseSendSummary(await localJson(reason === "move" ? scoped("/api/cloud-send?move=1") : "/api/cloud-send")); if (!mine) throw new CloudSendError("local", "OpenMausBot on this computer did not answer. Try again in a moment."); const withChats = !keys && CHAT_REASONS.has(reason); const latest = await sendItems(origin, mine, result, { keys, force, job, withChats }); const outcome = { at: now(), reason, result }; const kept = entry(origin); kept.last = outcome; if (withChats) kept.whole = outcome; kept.arrived = addCounts(kept.arrived, sentCounts(result)); persist(); view = { cloud: "ready", plan: plan(origin, mine, latest), local: mine, theirs: latest, chats: await chatsLeft(origin, latest) }; return outcome; } /** A move goes only to a Cloud that says it takes one: an older one would lose the owner's lines and the checks. */ async function assertTakesMove(session) { const described = await session.call("GET", "/.well-known/openmausbot/environment"); if (described.status >= 500) throw Object.assign(new CloudSendError("unreachable", "My Cloud did not answer. Try again."), { status: described.status }); if (!(Number(described.body?.capabilities?.move) >= 1)) throw new CloudSendError("outdated", OUTDATED_FOR_MOVE); } /** Every item that waits, and then the chats, over one owner session, always signed out; what My Cloud holds after. A * move's apps and keys go first, so the bots that follow find their engines; then the skills only a move carries, the * bots, their memory and files, the chats with each conversation's settings, and last the automations, switched off. An * app event goes only in the final pass, when this computer lets go of it. */ async function sendItems(origin, mine, result, { keys, force, job, withChats }) { const move = job.reason === "move"; const summaryRoute = move ? scoped("/api/cloud-send?move=1") : "/api/cloud-send"; const opened = await cloud.open(move ? { move: true } : undefined).catch((error) => { throw new CloudSendError("session", sentence(error?.message) ?? "This app could not sign in to My Cloud. Try again.", { cause: error }); }); const session = move && !team ? patientSession(opened, { ...(sleep ? { sleep } : {}), ...(patience.blips ? { blips: patience.blips } : {}) }) : opened; try { const before = await session.call("GET", summaryRoute); if (before.status === 404) throw new CloudSendError("outdated", move ? OUTDATED_FOR_MOVE : "My Cloud needs an update before it can receive bots from this computer. It updates by itself; try again later."); const theirs = before.ok ? parseSendSummary(before.body) : null; if (!theirs) throw refusedError("unreachable", before, "My Cloud did not answer. Try again."); let computer = team ? { label: team.label } : null; if (move) { await assertTakesMove(session); // A bot moving into a team carries nothing of the person's: their apps and keys stay. // A sign-in that refreshes itself stays with the bots that stay: only one place can hold it. if (!team) computer = await moveConnections(origin, session, result, job.final && !job.scope); } settleCreating(origin, theirs); const planned = plan(origin, mine, theirs, force, { everything: move }); const wanted = keys ? new Set(keys) : null; const rows = pendingRows(planned).filter((row) => !wanted || wanted.has(row.key) || (row.bot && wanted.has(itemKey.bot(row.bot)))) .filter((row) => row.kind !== "app-event" || job.final); const total = rows.length; progress = total ? { done: 0, total } : null; publish(); const cloudBots = new Set(theirs.items.filter((item) => item.kind === "bot" && item.id).map((item) => item.id)); const localItems = new Map(mine.items.map((item) => [item.key, item])); const cloudItems = new Map(theirs.items.map((item) => [item.key, item])); const ctx = { origin, session, result, cloudBots, localItems, cloudItems, planned: new Map(planned.rows.map((row) => [row.key, row])), bots: null, skillLists: new Map(), sentKeys: new Set(), sentParts: {}, recorded: new Set(), move, ownMove: move && !team, summaryRoute, computer: computer?.label ?? null }; const order = { "library-skill": 0, "imported-skill": 0, bot: 1, skill: 2, setting: 3, login: 4, secret: 4, "1password": 5 }; const automations = new Set(AUTOMATION_KINDS); const goRow = async (row) => { try { await sendRow(ctx, row); } catch (error) { result.failed.push({ key: row.key, kind: row.kind, label: row.label, error: sentence(error?.message) ?? "It could not be sent.", ...(error?.final === true ? { final: true } : {}) }); } await recordItem(ctx, row.key).catch(() => {}); if (progress && !job.chats) progress = { done: progress.done + 1, total }; publish(); }; for (const row of rows.filter((entry) => !automations.has(entry.kind)).sort((a, b) => order[a.kind] - order[b.kind])) await goRow(row); const moved = move ? await movedBots(ctx) : []; if (move) { // Its notes about the person stay with them when a bot moves into a team. if (!team) await kinds.moveMemory(ctx, moved); try { await kinds.moveFiles(ctx, moved, computer?.label); } catch (error) { result.failed.push({ key: "files", kind: "files", label: "Files", error: sentence(error?.message) ?? "The files could not be sent." }); } } const after = parseSendSummary((await session.call("GET", summaryRoute)).body); if (after) remember(origin, ctx, after); const latest = after ?? theirs; if (withChats && latest.chats) { job.chats = true; progress = null; publish(); try { await sendChatRows(origin, session, latest, result, { everything: move }); if (move) await stampHeld(origin, session, latest); } catch (error) { result.failed.push({ key: "chats", kind: "chats", label: "Chats", error: sentence(error?.message) ?? "The chats could not be added." }); } } if (move) { try { result.conversationSettings = await kinds.moveSettings(ctx); } catch (error) { result.failed.push({ key: "conversation-settings", kind: "chats", label: "Chats", error: sentence(error?.message) ?? "The chats' settings could not be added." }); } const last = rows.filter((entry) => automations.has(entry.kind)); for (const row of last) await goRow(row); // The pass's closing plan reads My Cloud as it is now: read before the automations went, it would take each one // just carried for one removed there. if (last.length) return parseSendSummary((await session.call("GET", summaryRoute)).body) ?? latest; } return latest; } finally { try { await session.close(); } catch {} } } /** The bots of this computer's that My Cloud has now, with their names. */ async function movedBots(ctx) { const bots = await localBots(ctx); return [...ctx.cloudBots].filter((id) => bots.has(id) && ctx.localItems.has(itemKey.bot(id))).map((id) => ({ id, name: bots.get(id).name })); } /** This computer's apps and keys, MCP servers, mailboxes and connected apps, over the move's session. A sign-in that * refreshes itself goes only in the final pass; this computer then lets go of its copy once My Cloud has it, and one it * could not let go of yet is kept and let go of on the next final pass. */ async function moveConnections(origin, session, result, final) { const kept = entry(origin); const failed = (id, label, error, last) => result.failed.push({ key: id, kind: "connection", label, error: sentence(error) ?? t("native.home.itemDidNotMove"), ...(last ? { final: true } : {}) }); if (final && kept.release.length) { kept.release = await releaseConnections(kept.release, { local: connectionLocal, target: session }).catch(() => kept.release); persist(); } let collected; try { collected = await collectConnections(connectionLocal); } catch (error) { failed("connections", "Apps and keys", error?.message, false); return null; } const moved = await sendConnections(collected, session, { local: connectionLocal, signIns: final, t, ...(engineWaitMs !== undefined ? { waitMs: engineWaitMs } : {}), onProgress: (step) => { progress = { done: step.done, total: step.total }; publish(); } }); result.connections += moved.moved.length + moved.runsHere.length; for (const failure of moved.failed) failed(failure.id, failure.label, failure.message, failure.final); for (const held of moved.held ?? []) result.notes.push({ kind: "held", connection: held.label }); for (const stayed of moved.runsHere) if (!stayed.off) result.notes.push({ kind: "stayed", connection: stayed.label, ...(stayed.needs ? { needs: stayed.needs } : {}) }); if (moved.release.length) { kept.release = [...kept.release.filter((held) => !moved.release.some((next) => next.id === held.id)), ...moved.release]; persist(); } return collected.computer; } /** The conversations whose newest row My Cloud has not taken, not answered in now: of bots My Cloud has and that are * not kept here (`everything`: whatever was kept here), and of rooms, which My Cloud makes once one of their bots is * there. A room nobody wrote in yet goes as its active thread with no rows (`empty`), once, so My Cloud makes it too. */ async function chatsWaiting(origin, theirs, { everything = false } = {}) { const listed = await localJson(scoped("/api/cloud-send/chats")); const kept = entry(origin); const cloudBots = new Set(theirs.items.filter((item) => item.kind === "bot" && item.id).map((item) => item.id)); const id = (value) => typeof value === "string" && /^[\w-]{1,128}$/u.test(value); const thread = (value) => typeof value === "string" && /^[\w-]{1,200}$/u.test(value); const keptHere = (botId) => everything ? kept.off[itemKey.bot(botId)] === "removed" : Boolean(kept.off[itemKey.bot(botId)]); // My Cloud makes the room once one of its bots is there, as it does from a room's rows; one with no bot never goes. const empty = (Array.isArray(listed?.rooms) ? listed.rooms : []).filter((room) => record(room) && id(room.room) && thread(room.threadId) && Array.isArray(room.members) && room.members.length > 0) .map((room) => ({ room: room.room, threadId: room.threadId, last: 0, lastId: "", busy: false, empty: true })); const conversations = [...(Array.isArray(listed?.conversations) ? listed.conversations : []).filter((head) => record(head) && (id(head.bot) !== id(head.room)) && thread(head.threadId) && Number.isSafeInteger(head.last) && typeof head.lastId === "string"), ...empty]; const waiting = conversations.filter((head) => { const cursor = kept.chats[head.threadId]; if (cursor === "removed" || (head.bot && (!cloudBots.has(head.bot) || keptHere(head.bot)))) return false; return !cursor || cursor.after !== head.last || cursor.id !== head.lastId; }); return { dataDir: typeof listed?.dataDir === "string" ? listed.dataDir : "", ready: waiting.filter((head) => !head.busy), busy: waiting.filter((head) => head.busy).length, owner: team && typeof listed?.owner === "string" && /^p_[\w-]{22}$/u.test(listed.owner) ? listed.owner : null }; } /** Each waiting conversation's new rows, page by page, a few conversations per request; the cursor moves only past * what My Cloud took, and once My Cloud did not take a page, the conversation's later pages wait for the next send too. * The attachments those rows name go first, unless My Cloud has them. `result.chats.left`: the conversations that did * not all go (a bot answering in one, or My Cloud not taking a page), for the next send. */ async function sendChatRows(origin, session, theirs, result, { everything = false } = {}) { const { dataDir, ready, busy, owner: from } = await chatsWaiting(origin, theirs, { everything }); const kept = entry(origin); const total = ready.length; const held = new Set(); let batch = [], bytes = 0, done = 0; result.chats = { conversations: 0, messages: 0, left: busy }; progress = total ? { done: 0, total } : null; publish(); const flush = async () => { if (!batch.length) return; const names = [...new Set(batch.flatMap((page) => page.attachments))]; for (let i = 0; i < names.length; i += ATTACHMENT_NAMES) { const asked = names.slice(i, i + ATTACHMENT_NAMES); const checked = await session.call("POST", "/api/cloud-send/chats/attachments", { names: asked }); if (!checked.ok) throw refusedError("refused", checked, "My Cloud could not take the chats' attachments."); for (const name of Array.isArray(checked.body?.missing) ? checked.body.missing.filter((name) => asked.includes(name)) : []) { const file = await localBytes(`/api/cloud-send/chats/attachment/${name}`); if (!file) continue; const stored = await session.upload(`/api/cloud-send/chats/attachment/${name}`, file, "application/octet-stream"); if (stored.status === 413 || stored.status === 507) { result.failed.push({ key: `attachment:${name}`, kind: "chats", label: "Attachment", error: stored.status === 507 ? "My Cloud's room for attachments is full, so an attachment did not go. Its chat went without it." : "An attachment is bigger than My Cloud takes, so it did not go. Its chat went without it.", ...(stored.status === 413 ? { final: true } : {}) }); continue; } if (!stored.ok) throw refusedError("refused", stored, "My Cloud could not take an attachment of the chats."); } } // Into a team: whose lines on My Cloud become the mover's there. const answer = await session.call("POST", "/api/cloud-send/chats", { v: 1, dataDir, ...(from ? { owner: from } : {}), conversations: batch.map(({ owner, threadId, task, rows, leaf, sent }) => ({ ...owner, threadId, task, rows, leaf, sent })) }); if (!answer.ok) throw refusedError("refused", answer, "My Cloud could not add the chats."); const accepted = new Set(Array.isArray(answer.body?.accepted) ? answer.body.accepted : []); const removed = new Set(Array.isArray(answer.body?.removed) ? answer.body.removed : []); for (const page of batch) { if (accepted.has(page.threadId) && typeof page.lastId === "string") kept.chats[page.threadId] = { after: page.last, id: page.lastId }; else { if (!held.has(page.threadId) && !(removed.has(page.threadId) && page.sent)) result.chats.left++; held.add(page.threadId); if (removed.has(page.threadId) && page.sent) kept.chats[page.threadId] = "removed"; } } result.chats.conversations += Number.isSafeInteger(answer.body?.conversations) ? answer.body.conversations : 0; result.chats.messages += Number.isSafeInteger(answer.body?.messages) ? answer.body.messages : 0; persist(); batch = []; bytes = 0; }; for (const head of ready) { let cursor = record(kept.chats[head.threadId]) ? kept.chats[head.threadId] : null; for (let pages = 0; pages < 10_000 && !held.has(head.threadId); pages++) { const page = await localJson(`/api/cloud-send/chats/rows?thread=${head.threadId}&after=${cursor?.after ?? 0}&id=${encodeURIComponent(cursor?.id ?? "")}`); if (!record(page) || !Array.isArray(page.rows)) break; if (page.restarted) delete kept.chats[head.threadId]; // A room nobody wrote in yet: its thread goes with no rows, which My Cloud takes as the room to make. const room = head.empty === true && !page.rows.length && !page.lastId && record(page.roomInfo); if (page.rows.length || room) { const size = Buffer.byteLength(JSON.stringify(page.rows), "utf8"); if (bytes + size > CHATS_BATCH_BYTES || batch.length >= CHATS_BATCH_PAGES) await flush(); if (held.has(head.threadId)) break; const owner = head.room ? { room: head.room, ...(record(page.roomInfo) ? { roomInfo: page.roomInfo } : {}) } : { bot: head.bot }; batch.push({ owner, threadId: head.threadId, task: record(page.task) ? page.task : null, rows: page.rows, leaf: typeof page.leaf === "string" ? page.leaf : null, sent: record(kept.chats[head.threadId]), last: room ? 0 : page.last, lastId: room ? "" : page.lastId, attachments: Array.isArray(page.attachments) ? page.attachments.filter((name) => typeof name === "string" && /^[A-Za-z0-9-]{1,80}\.[A-Za-z0-9]{1,10}$/u.test(name)) : [] }); bytes += size; } else if (page.lastId) kept.chats[head.threadId] = { after: page.last, id: page.lastId }; if (!page.more || !page.lastId) break; cursor = { after: page.last, id: page.lastId }; } progress = { done: ++done, total }; publish(); } await flush(); } /** A move has My Cloud make its owner's lines and answers its owner's among the rows it already held of this computer's * bots and rooms, which an earlier send or a Bring left naming nobody: this computer's row ids go, never a row. Once per * conversation (`stamped`): every row that arrives over the owner's session after it carries the owner already, and a * check that finds one that does not names its conversation again. */ async function stampHeld(origin, session, theirs) { const listed = await localJson(scoped("/api/cloud-send/chats")); const kept = entry(origin); const cloudBots = new Set(theirs.items.filter((item) => item.kind === "bot" && item.id).map((item) => item.id)); const threads = (Array.isArray(listed?.conversations) ? listed.conversations : []).filter((head) => record(head) && (cloudBots.has(head.bot) || typeof head.room === "string") && typeof head.threadId === "string" && /^[\w-]{1,200}$/u.test(head.threadId) && kept.chats[head.threadId] !== "removed" && !kept.stamped[head.threadId]).map((head) => head.threadId); let batch = [], ids = 0, whole = []; const flush = async () => { if (batch.length) { const answer = await session.call("POST", "/api/cloud-send/chats/owner", { v: 1, conversations: batch }); if (!answer.ok) throw refusedError("refused", answer, "My Cloud could not keep the chats' lines as yours."); } for (const threadId of whole) kept.stamped[threadId] = true; if (whole.length) persist(); batch = []; ids = 0; whole = []; }; for (const threadId of threads) { for await (const chunk of rowIdChunks(localJson, threadId)) { if (ids + chunk.ids.length > ROW_IDS || batch.length >= ROW_ID_CONVERSATIONS) await flush(); batch.push({ threadId, ids: chunk.ids }); ids += chunk.ids.length; if (!chunk.more) whole.push(threadId); } } await flush(); } /** What was sent is recorded against what My Cloud holds right after, a part it refused included: that part is tried * again only once it changes here. `keys`: only these. */ function remember(origin, ctx, after, keys = ctx.sentKeys) { const kept = entry(origin); const there = new Map(after.items.map((item) => [item.key, item])); for (const key of keys) { const mine = ctx.localItems.get(key), theirs = there.get(key); if (!mine || !theirs) continue; kept.sent[key] = { ...kept.sent[key] }; for (const part of ctx.sentParts[key] ?? Object.keys(mine.parts)) kept.sent[key][part] = [mine.parts[part], theirs.parts[part] ?? EMPTY]; delete kept.created[key]; ctx.recorded.add(key); } } /** Each item is recorded as soon as it went, from My Cloud's own answer for it and its skills, so a send cut short goes * on from the next item instead of judging what already went as My Cloud's own change. */ async function recordItem(ctx, key) { const keys = [...ctx.sentKeys].filter((sent) => !ctx.recorded.has(sent)); if (!keys.length) return; const answer = await ctx.session.call("GET", scoped(`/api/cloud-send?item=${encodeURIComponent(key)}${ctx.move ? "&move=1" : ""}`)); const after = answer.ok ? parseSendSummary(answer.body) : null; if (!after) return; remember(ctx.origin, ctx, after, keys); persist(); } /** A bot a send asked My Cloud to make and never heard back about: one My Cloud has is that send's own, so all its * fields go (`created`); one it lacks was never made, and goes as new. */ function settleCreating(origin, theirs) { const kept = entry(origin); const waiting = Object.keys(kept.creating ?? {}); if (!waiting.length) return; const there = new Set(theirs.items.map((item) => item.key)); for (const key of waiting) { if (there.has(key)) { kept.sent[key] ??= {}; kept.created[key] = true; } delete kept.creating[key]; } persist(); } const done = (ctx, key, parts = null) => { ctx.sentKeys.add(key); if (parts) ctx.sentParts[key] = parts; }; /** A bot this send made on My Cloud: its fields go whatever My Cloud's defaults are, until they are recorded, and it * counts as sent before, so one removed there before then stays removed. */ function markCreated(ctx, key) { const kept = entry(ctx.origin); kept.sent[key] ??= {}; kept.created[key] = true; delete kept.creating[key]; persist(); } async function localBots(ctx) { if (!ctx.bots) { const listed = await localJson("/api/bots?messages=0"); ctx.bots = new Map((Array.isArray(listed?.bots) ? listed.bots : []).filter((bot) => record(bot) && typeof bot.id === "string").map((bot) => [bot.id, bot])); } return ctx.bots; } async function localSkills(ctx, botId) { if (!ctx.skillLists.has(botId)) { const listed = await localJson(`/api/bots/${botId}/skills`); ctx.skillLists.set(botId, new Map((Array.isArray(listed?.skills) ? listed.skills : []).filter((skill) => record(skill) && typeof skill.name === "string").map((skill) => [skill.name, skill]))); } return ctx.skillLists.get(botId); } async function sendRow(ctx, row) { if (MOVE_KINDS.includes(row.kind)) { await kinds.sendKind(ctx, row); ctx.result.moved++; return done(ctx, row.key); } if (row.kind === "bot") return row.state === "only-here" ? addBot(ctx, row) : updateBot(ctx, row); if (row.kind === "skill") return sendSkill(ctx, row); if (row.kind === "setting") return sendSetting(ctx, row); if (row.kind === "login") return sendLogin(ctx, row); if (row.kind === "secret") return sendSecret(ctx, row); if (row.kind === "1password") return sendOnePassword(ctx, row); } async function patchBot(ctx, bot, parts) { const refused = {}; const whole = await ctx.session.call("PATCH", `/api/bots/${bot.id}`, botPatch(bot, parts)); if (whole.ok) return refused; for (const part of parts) { const body = botPatch(bot, [part]); if (!Object.keys(body).length) continue; const one = await ctx.session.call("PATCH", `/api/bots/${bot.id}`, body); if (!one.ok) refused[part] = refusal(one, "My Cloud refused this setting."); } return refused; } async function sendPicture(ctx, bot) { const name = typeof bot.avatarUrl === "string" ? /^\/api\/attachments\/([A-Za-z0-9-]{1,80}\.(png|jpg|gif|webp))$/u.exec(bot.avatarUrl) : null; if (!name) return null; const bytes = await localBytes(`/api/attachments/${name[1]}`); if (!bytes) return "The picture could not be read on this computer."; const uploaded = await ctx.session.upload("/api/attachments", bytes, PICTURE_TYPES[name[2]]); const stored = typeof uploaded.body?.path === "string" ? uploaded.body.path.replaceAll("\\", "/").split("/").pop() : null; if (!uploaded.ok || !stored) return refusal(uploaded, "My Cloud could not store the picture."); const set = await ctx.session.call("PATCH", `/api/bots/${bot.id}`, { avatarUrl: `/api/attachments/${stored}` }); return set.ok ? null : refusal(set, "My Cloud could not use the picture."); } /** A move keeps both of two different bots of one name: the one arriving is named after this computer. */ async function arrivingName(ctx, bot) { if (team) return team.name; if (!ctx.move || !ctx.computer) return bot.name; ctx.cloudNames ??= ctx.session.call("GET", "/api/bots?messages=0") .then((listed) => (Array.isArray(listed.body?.bots) ? listed.body.bots : []).filter(record), () => []); const same = (name) => typeof name === "string" && name.trim().toLocaleLowerCase() === bot.name.trim().toLocaleLowerCase(); const taken = (await ctx.cloudNames).some((theirs) => theirs.id !== bot.id && same(theirs.name) && !(bot.defaultBot && theirs.defaultBot)); return taken ? `${bot.name} (${ctx.computer})`.slice(0, 80) : bot.name; } async function addBot(ctx, row) { const bot = (await localBots(ctx)).get(row.id); if (!bot) throw new CloudSendError("gone", "This bot is no longer on this computer."); const model = record(bot.modelSelection) ? pick(bot.modelSelection, ["instanceId", "model", "effort", "variant"]) : null; // A bot that moves into a team gets a main chat of the team's own there; its chats with the person arrive as theirs. const base = { id: bot.id, ...(team ? {} : { threadId: bot.threadId }), name: await arrivingName(ctx, bot), useDefaults: false }; // Kept before asking: a send cut short while My Cloud makes the bot still knows the bot is its own (settleCreating). entry(ctx.origin).creating[row.key] = true; persist(); let created = await ctx.session.call("POST", "/api/bots", { ...base, ...(model ? { modelSelection: model, requireAvailableModel: true } : {}) }); let modelKept = Boolean(model) && created.ok; if (!created.ok && model && created.status === 400) { created = await ctx.session.call("POST", "/api/bots", base); modelKept = false; } if (!created.ok) throw refusedError("refused", created, "My Cloud could not add this bot."); ctx.cloudBots.add(bot.id); markCreated(ctx, row.key); const parts = Object.keys(BOT_PARTS).filter((part) => part !== "name" && (part !== "model" || modelKept)); const refused = await patchBot(ctx, bot, parts); if (ctx.move) Object.assign(refused, await kinds.sendBotFields(ctx, bot.id, Object.keys(MOVE_BOT_PARTS))); const picture = await sendPicture(ctx, bot); if (picture) refused[PICTURE_PART] = picture; done(ctx, row.key); ctx.result.bots.push(bot.name); if ((!modelKept && model) || refused.model) ctx.result.notes.push({ kind: "model", bot: bot.name }); if (Object.keys(refused).some((part) => part !== "model")) ctx.result.notes.push({ kind: "settings", bot: bot.name }); for (const [key, item] of ctx.localItems) { if (item.kind !== "skill" || item.bot !== bot.id || ctx.planned.get(key)?.state !== "only-here") continue; try { await putSkill(ctx, bot.id, item.id, false); done(ctx, key); ctx.result.skills++; } catch (error) { ctx.result.failed.push({ key, kind: "skill", label: item.label, error: sentence(error?.message) ?? "It could not be sent." }); } } if (!ctx.move) await sendMemory(ctx, bot.id).catch(() => ctx.result.notes.push({ kind: "memory", bot: bot.name })); } async function updateBot(ctx, row) { const bot = (await localBots(ctx)).get(row.id); if (!bot) throw new CloudSendError("gone", "This bot is no longer on this computer."); const parts = row.parts ?? []; const fields = parts.filter((part) => part !== PICTURE_PART && !MOVE_BOT_PARTS[part]); const refused = fields.length ? await patchBot(ctx, bot, fields) : {}; const moveParts = ctx.move ? parts.filter((part) => MOVE_BOT_PARTS[part]) : []; if (moveParts.length) Object.assign(refused, await kinds.sendBotFields(ctx, bot.id, moveParts)); if (parts.includes(PICTURE_PART)) { const picture = await sendPicture(ctx, bot); if (picture) refused[PICTURE_PART] = picture; } done(ctx, row.key, parts); if (Object.keys(refused).length < parts.length) ctx.result.updatedBots.push(bot.name); if (refused.model) ctx.result.notes.push({ kind: "model", bot: bot.name }); if (Object.keys(refused).some((part) => part !== "model")) ctx.result.notes.push({ kind: "settings", bot: bot.name }); if (entry(ctx.origin).created[row.key] && !ctx.move) await sendMemory(ctx, bot.id).catch(() => ctx.result.notes.push({ kind: "memory", bot: bot.name })); } /** The bot's own skill, by its SKILL.md: My Cloud's copy is replaced when the text changed here. */ async function putSkill(ctx, botId, name, replace) { const skill = (await localSkills(ctx, botId)).get(name); if (!skill) throw new CloudSendError("gone", "This skill is no longer on this computer."); const text = await localJson(`/api/bots/${botId}/skills/${name}`); if (typeof text?.text !== "string") throw new CloudSendError("local", "The skill could not be read on this computer."); const install = (from, body) => ctx.session.call("POST", `/api/bots/${botId}/skill-template`, { name, description: typeof from.description === "string" ? from.description.slice(0, 1024) : "", warnings: [], source: typeof from.source === "string" && from.source.trim() ? from.source.slice(0, 2000) : "this computer", text: body, enabled: from.enabled === true, }); let previous = null; if (replace) { const listed = await ctx.session.call("GET", `/api/bots/${botId}/skills`); const own = await ctx.session.call("GET", `/api/bots/${botId}/skills/${name}`); const entry = Array.isArray(listed.body?.skills) ? listed.body.skills.find((candidate) => candidate?.name === name) : null; if (!own.ok || typeof own.body?.text !== "string" || !entry) throw refusedError("refused", own, "My Cloud's copy of this skill could not be read, so it was left as it is."); previous = { entry, text: own.body.text }; const removed = await ctx.session.call("DELETE", `/api/bots/${botId}/skills/${name}`); if (!removed.ok) throw refusedError("refused", removed, "My Cloud could not replace this skill."); } const added = await install(skill, text.text); if (added.ok) return; if (previous) await install(previous.entry, previous.text).catch(() => null); throw refusedError("refused", added, "My Cloud could not add this skill."); } async function sendSkill(ctx, row) { const there = ctx.cloudItems.get(row.key); const parts = row.parts ?? []; if (there && parts.length === 1 && parts[0] === "on") { const skill = (await localSkills(ctx, row.bot)).get(row.id); const set = await ctx.session.call("PATCH", `/api/bots/${row.bot}/skills/${row.id}`, { enabled: skill?.enabled === true }); if (!set.ok) throw refusedError("refused", set, "My Cloud could not switch this skill."); } else { if (!ctx.cloudBots.has(row.bot)) throw new CloudSendError("no_bot", "Its bot is not on My Cloud."); await putSkill(ctx, row.bot, row.id, Boolean(there)); } done(ctx, row.key, there && parts.length === 1 && parts[0] === "on" ? parts : null); ctx.result.skills++; } /** A new bot's memory notes go with it once; after that each side's bot keeps its own memory. */ async function sendMemory(ctx, botId) { const overview = await localJson(`/api/bots/${botId}/memory`); const files = ["MEMORY.md", ...(Array.isArray(overview?.topics) ? overview.topics.map((topic) => topic?.path).filter((path) => typeof path === "string") : [])]; for (const path of files.slice(0, MEMORY_FILES)) { const doc = await localJson(`/api/bots/${botId}/memory/file?path=${encodeURIComponent(path)}`); if (!doc?.exists || typeof doc.text !== "string" || !doc.text.trim()) continue; const put = await ctx.session.call("PUT", `/api/bots/${botId}/memory/file`, { path, text: doc.text }); if (!put.ok) throw refusedError("refused", put, "My Cloud could not save a memory note."); } } async function sendSetting(ctx, row) { const config = await localJson("/api/config"); const parts = row.parts ?? ["name", "email", "about", "value"]; let body; if (row.key === itemKey.setting("profile")) { const profile = record(config?.profile) ? config.profile : {}; const fields = { name: "name", email: "email", about: "aboutMe" }; body = { profile: Object.fromEntries(parts.filter((part) => fields[part] && typeof profile[fields[part]] === "string").map((part) => [fields[part], profile[fields[part]]])) }; } else if (row.key === itemKey.setting("language")) { if (typeof config?.language !== "string") return; body = { language: config.language }; } else return; const saved = await ctx.session.call("PATCH", "/api/config", body); if (!saved.ok) throw refusedError("refused", saved, "My Cloud could not save this setting."); done(ctx, row.key, parts); ctx.result.settings.push(row.label); } async function sendLogin(ctx, row) { const handed = await handoff({ kind: "login", id: row.id }); const login = handed?.login; if (!record(login)) throw new CloudSendError("local", "This login could not be read on this computer."); const kept = botsOnCloud(login.bots, ctx.cloudBots); const saved = await ctx.session.call("POST", "/api/logins", { site: login.site, username: login.username, password: login.password, ...(login.totp ? { totp: login.totp } : {}), bots: kept, exactHost: login.exactHost === true }); if (!saved.ok) throw refusedError("refused", saved, "My Cloud could not save the login."); if (Array.isArray(login.bots) && kept.length < login.bots.length) ctx.result.notes.push({ kind: "narrowed", login: row.label }); done(ctx, row.key); ctx.result.logins++; } /** A saved Vault secret, under its id here so an MCP server's `vault:` keeps naming it there: never a linked item. */ async function sendSecret(ctx, row) { const secret = (await handoff({ kind: "secret", id: row.id }))?.secret; if (!record(secret)) throw new CloudSendError("local", "This secret could not be read on this computer."); const kept = botsOnCloud(secret.bots, ctx.cloudBots); const saved = await ctx.session.call("POST", "/api/vault/secrets", { id: secret.id, name: secret.name, value: secret.value, bots: kept, source: secret.source, ...(secret.refOnly ? { refOnly: true } : {}) }); if (!saved.ok) throw refusedError("refused", saved, "My Cloud could not save the secret."); if (Array.isArray(secret.bots) && kept.length < secret.bots.length) ctx.result.notes.push({ kind: "narrowed", login: row.label }); done(ctx, row.key); ctx.result.secrets = (ctx.result.secrets ?? 0) + 1; } async function sendOnePassword(ctx, row) { const handed = await handoff({ kind: "1password" }); const onePassword = handed?.onePassword; if (!record(onePassword) || typeof onePassword.token !== "string") throw new CloudSendError("local", "1Password is not connected on this computer."); const parts = row.parts ?? ["token", "vaults"]; if (row.state === "only-here" || parts.includes("token")) { const connected = await ctx.session.call("POST", "/api/logins/1password", { token: onePassword.token }); if (!connected.ok) throw onePasswordRefusal(connected); } for (const [vault, bots] of Object.entries(onePassword.grants ?? {})) { if (!VAULT_ID.test(vault)) continue; const granted = await ctx.session.call("PATCH", `/api/logins/1password/vaults/${encodeURIComponent(vault)}`, { bots: botsOnCloud(bots, ctx.cloudBots) }); if (!granted.ok && granted.status !== 404) throw refusedError("refused", granted, "My Cloud connected 1Password but could not keep its vaults to the same bots."); } done(ctx, row.key); ctx.result.onePassword = true; } /** What did not arrive of what a move carried (cloud-send-check.mjs `moveCheck`), over one owner session that counts * as someone using My Cloud; `snapshot().checking` says how far it is. A check that stood still too long fails as * unanswered, saying whether it got anywhere (`progressed`). `scope`: only the bots a move of some of them carries; its * credentials are copies this computer goes on using, and are not checked. */ async function check({ scope = null, stopped } = {}) { const origin = cloud.origin(); if (!origin) throw new CloudSendError("no_cloud", "This computer has no My Cloud yet."); const session = await cloud.open({ move: true }).catch((error) => { throw new CloudSendError("session", sentence(error?.message) ?? "This app could not sign in to My Cloud. Try again.", { cause: error }); }); localScope = scope; try { return await moveCheck({ session, kept: entry(origin), persist, localJson, handoff: team || scope ? null : handoff, scoped, now, sleep, patience, stopped, takesMove: () => assertTakesMove(session), refuse: (answer, message) => refusedError("unreachable", answer, message), onStep: (value) => { checking = value; publish(); } }); } catch (error) { if (error?.code === "slow") throw Object.assign(new CloudSendError("unreachable", sentence(error.message) ?? "My Cloud did not answer.", { cause: error.cause }), { progressed: error.progressed === true }); throw error; } finally { checking = null; localScope = null; publish(); try { await session.close(); } catch {} } } return { snapshot, compare, send, check, /** The per-item switch. Turning on something removed on My Cloud forgets that it was sent, so it goes again. */ setItem(key, on) { const origin = cloud.origin(); if (!origin || typeof key !== "string") return publish(); const kept = entry(origin); if (on) { if (kept.off[key] === "removed") delete kept.sent[key]; delete kept.off[key]; } else kept.off[key] = "you"; persist(); if (view.local && view.theirs) view = { ...view, plan: plan(origin, view.local, view.theirs) }; return publish(); }, dismiss(signature) { const origin = cloud.origin(); if (origin && typeof signature === "string") { entry(origin).dismissed = signature.slice(0, 20_000); persist(); } return publish(); }, /** Each automation a move carried, by key, and whether it was on here when it went: what the switch turns on there. */ switches() { const origin = cloud.origin(); return origin ? { ...entry(origin).on } : {}; }, /** For the native menu: what is waiting, from the last comparison, without asking anyone. */ waiting() { const plan = view.plan; if (!plan || view.cloud !== "ready") return null; const rows = pendingRows(plan); return { bots: rows.filter((row) => row.kind === "bot" && row.state === "only-here").length, total: rows.length }; }, }; }