openmausbot 0.1.90 → 0.1.92
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/DesktopViewer-Do6lBa4C.js +5 -0
- package/dist/assets/{abnfDiagram-O67JEVCF-DUyXd0kL.js → abnfDiagram-O67JEVCF-Cd3X7Hxc.js} +1 -1
- package/dist/assets/{arc-q73qM-At.js → arc-DIYc7Ymx.js} +1 -1
- package/dist/assets/{architectureDiagram-NJMV4G6O-D33O3Wur.js → architectureDiagram-NJMV4G6O-DBmRGVyP.js} +1 -1
- package/dist/assets/{blockDiagram-BEXU5L5S-DLsJeoXC.js → blockDiagram-BEXU5L5S-CPFePSEE.js} +1 -1
- package/dist/assets/{c4Diagram-YGBWAQC7-DnaljZnu.js → c4Diagram-YGBWAQC7-DD_DO1qy.js} +1 -1
- package/dist/assets/channel-Dtk5Mtic.js +1 -0
- package/dist/assets/{chunk-2Q5K7J3B-Da0h2i79.js → chunk-2Q5K7J3B-BNEeNSjX.js} +1 -1
- package/dist/assets/{chunk-3FUC2YCW-CfzHi54f.js → chunk-3FUC2YCW-BbinLaqH.js} +2 -2
- package/dist/assets/{chunk-5VM5RSS4-LjE4d9Jf.js → chunk-5VM5RSS4-BBgYrgDR.js} +1 -1
- package/dist/assets/{chunk-GTNCS2PH-DY-UGmvc.js → chunk-GTNCS2PH-ezXj-kA-.js} +1 -1
- package/dist/assets/{chunk-GWA4HPMP-BqeGhyhP.js → chunk-GWA4HPMP-9FxZ3diV.js} +1 -1
- package/dist/assets/{chunk-JWPE2WC7-BYa9EBAg.js → chunk-JWPE2WC7-CO-40JpI.js} +1 -1
- package/dist/assets/{chunk-NETBCI7D-vTrDtWyb.js → chunk-NETBCI7D-BEhjslYE.js} +1 -1
- package/dist/assets/{chunk-WEXAMYUT-ic5tXadM.js → chunk-WEXAMYUT-CNeq4n7_.js} +1 -1
- package/dist/assets/{chunk-XXDRQBXY-DvRCY-c6.js → chunk-XXDRQBXY-OsCY7KSi.js} +1 -1
- package/dist/assets/{classDiagram-v2-NBCMYWYE-DVbmgz_L.js → classDiagram-v2-NBCMYWYE-CloRUEtk.js} +1 -1
- package/dist/assets/{cose-bilkent-JH36ORCC-vdUs2n57.js → cose-bilkent-JH36ORCC-D1BGZhp4.js} +1 -1
- package/dist/assets/{cynefin-OW5HDTMX-BvLGlV3U.js → cynefin-OW5HDTMX-CUf4T2CN.js} +1 -1
- package/dist/assets/{cynefinDiagram-VND7K2PF-C4GcJQDt.js → cynefinDiagram-VND7K2PF-DPEZrFGA.js} +1 -1
- package/dist/assets/{dagre-6A5THRUB-BMSaHpt_.js → dagre-6A5THRUB-bDe6GIv5.js} +1 -1
- package/dist/assets/{diagram-22UHCM2B-D5b9UBhC.js → diagram-22UHCM2B-CdcUQz55.js} +1 -1
- package/dist/assets/{diagram-3UASUU5V-CIKLlB7Y.js → diagram-3UASUU5V-BZpqGg7c.js} +1 -1
- package/dist/assets/{diagram-ATOU4E4O-BzXb-7k7.js → diagram-ATOU4E4O-DvGM6ugp.js} +1 -1
- package/dist/assets/{diagram-CDSNMT55-HsTvJ7uT.js → diagram-CDSNMT55-DSTmMm0s.js} +1 -1
- package/dist/assets/{diagram-MLGK6HIB-Db9ySNbQ.js → diagram-MLGK6HIB-15cucnMn.js} +1 -1
- package/dist/assets/{diagram-MPIPVDR6-D6BKbpqY.js → diagram-MPIPVDR6-tMbuaE6n.js} +1 -1
- package/dist/assets/{ebnfDiagram-ZINNZB2B-Bq067L0t.js → ebnfDiagram-ZINNZB2B-BYxYzjHI.js} +1 -1
- package/dist/assets/{elk-276RUBZZ-DhaD7fNJ.js → elk-276RUBZZ-v6bj852W.js} +1 -1
- package/dist/assets/{erDiagram-OPXOYQCR-fwIhfz36.js → erDiagram-OPXOYQCR-5lBBlnd2.js} +1 -1
- package/dist/assets/{flowDiagram-KWPJA3E3-DygIyu0V.js → flowDiagram-KWPJA3E3-CZyl1zIj.js} +1 -1
- package/dist/assets/{ganttDiagram-FUAMR5RP-DPYfSO96.js → ganttDiagram-FUAMR5RP-BiAI-ltZ.js} +1 -1
- package/dist/assets/{gitGraphDiagram-X574FWY7-DhkHeCle.js → gitGraphDiagram-X574FWY7-BAZISkj3.js} +1 -1
- package/dist/assets/index-Cdmjrl28.css +1 -0
- package/dist/assets/{index-G1PHKGyh.js → index-DGBaEXrM.js} +1 -1
- package/dist/assets/index-DR7YPMkW.js +597 -0
- package/dist/assets/{infoDiagram-VRGFBTTK-DstBLxME.js → infoDiagram-VRGFBTTK-B6ooOy-Z.js} +1 -1
- package/dist/assets/{ishikawaDiagram-OU5B5YK6-CrIsGgch.js → ishikawaDiagram-OU5B5YK6-BZ9ksi-B.js} +1 -1
- package/dist/assets/{journeyDiagram-ZHPQQLJL-CYypGDbB.js → journeyDiagram-ZHPQQLJL-DWqlzIEe.js} +1 -1
- package/dist/assets/{kanban-definition-PNTS6WVX-DGjPt8E_.js → kanban-definition-PNTS6WVX-JX6piDwL.js} +1 -1
- package/dist/assets/{linear-Dwc3Hrru.js → linear-CmOWzX0P.js} +1 -1
- package/dist/assets/{mermaid.core-Dc4OePBL.js → mermaid.core-CF1zwoQ-.js} +5 -5
- package/dist/assets/{mindmap-definition-NLK3R4M7-DV-vepj-.js → mindmap-definition-NLK3R4M7-CGh2K4fW.js} +1 -1
- package/dist/assets/{pegDiagram-GJSIUBJH-lx5Kj-n5.js → pegDiagram-GJSIUBJH-CDwYN0s7.js} +1 -1
- package/dist/assets/{pieDiagram-5QR66LMP-Bq7Vmm15.js → pieDiagram-5QR66LMP-1rfjJocB.js} +1 -1
- package/dist/assets/{quadrantDiagram-O4NWA36T-DeqeqOBj.js → quadrantDiagram-O4NWA36T-tKPvPiok.js} +1 -1
- package/dist/assets/{railroadDiagram-XR7U4H2S-CXwRQzSC.js → railroadDiagram-XR7U4H2S-BHLm5vXR.js} +1 -1
- package/dist/assets/{requirementDiagram-PLB6GJNP-8tAeoB88.js → requirementDiagram-PLB6GJNP-CB45STp7.js} +1 -1
- package/dist/assets/{sankeyDiagram-IPEJSGJF-BG5zjGWU.js → sankeyDiagram-IPEJSGJF-55QA_huC.js} +1 -1
- package/dist/assets/{sequenceDiagram-PO4LG4MO-DKF3iLcw.js → sequenceDiagram-PO4LG4MO-BckFmQHn.js} +1 -1
- package/dist/assets/{sizeCapture-INFHLROL-B-y1qUgT.js → sizeCapture-INFHLROL-BFCOJF11.js} +1 -1
- package/dist/assets/{stateDiagram-v2-GCMORJYK-MkzPgxAc.js → stateDiagram-v2-GCMORJYK-CZvmiVVN.js} +1 -1
- package/dist/assets/{swimlanes-2SLR337P-DnbaOEwH.js → swimlanes-2SLR337P-BpDUCEKy.js} +1 -1
- package/dist/assets/swimlanesDiagram-TC7HE7FX-M0GYW9Y3.js +8 -0
- package/dist/assets/{timeline-definition-EJHVYXUP-Bb4xG_Bt.js → timeline-definition-EJHVYXUP-BpxHq6IK.js} +1 -1
- package/dist/assets/{usecaseDiagram-POWQR4AR-JgkR9pFf.js → usecaseDiagram-POWQR4AR-Cj66vm02.js} +1 -1
- package/dist/assets/{vennDiagram-UO4OBE2U-CIPIrAYe.js → vennDiagram-UO4OBE2U-C9nRkWE_.js} +1 -1
- package/dist/assets/{wardleyDiagram-VNRHLVJA-BjCLuUvJ.js → wardleyDiagram-VNRHLVJA-D-me_nWj.js} +1 -1
- package/dist/assets/{xychartDiagram-PMCCYNJV-DKgiaYxT.js → xychartDiagram-PMCCYNJV-Dc_fL4mk.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/novnc-NOTICE.txt +118 -0
- package/dist-server/cloud-home-start.js +128 -15
- package/dist-server/container-mcp.js +277 -45
- package/dist-server/drivers/agents-proxy.js +16 -4
- package/dist-server/enterprise/server/index.js +5 -1
- package/dist-server/index.js +46703 -39955
- package/dist-server/local-computer-proxy.js +128 -7
- package/dist-server/mcp-gate.js +11 -5
- package/dist-server/model-catalog/models-dev.snapshot.json +7299 -0
- package/dist-server/openmausbot.js +4099 -1217
- package/dist-server/pair-cli.js +4099 -1217
- package/dist-server/server/boat.js +67 -22
- package/dist-server/server/bot-overview.js +7 -4
- package/dist-server/server/browser-engine.js +10 -1
- package/dist-server/server/browser-live.js +46 -2
- package/dist-server/server/browser-runtime.js +74 -8
- package/dist-server/server/browser-tool-shape.js +6 -4
- package/dist-server/server/chief-of-staff.js +1 -1
- package/dist-server/server/cloud-home-start.js +136 -26
- package/dist-server/server/cloud-home.js +64 -5
- package/dist-server/server/cloud-lending.js +167 -0
- package/dist-server/server/cloud-move-http.js +268 -0
- package/dist-server/server/cloud-move.js +418 -0
- package/dist-server/server/cloud-owner.js +196 -0
- package/dist-server/server/cloud-secrets-boot.js +6 -0
- package/dist-server/server/cloud-secrets.js +65 -0
- package/dist-server/server/config.js +215 -17
- package/dist-server/server/container-computer.js +3 -2
- package/dist-server/server/decider/index.js +238 -0
- package/dist-server/server/decider/jev.js +183 -0
- package/dist-server/server/decider/log.js +62 -0
- package/dist-server/server/decider/relay.js +52 -0
- package/dist-server/server/decider/room-routing.js +88 -0
- package/dist-server/server/decider/types.js +5 -0
- package/dist-server/server/desktop-viewer-targets.js +26 -0
- package/dist-server/server/drivers/acp/core.js +167 -21
- package/dist-server/server/drivers/acp/opencode-go.js +614 -85
- package/dist-server/server/drivers/agents-catalog.js +22 -6
- package/dist-server/server/drivers/antigravity-acp.js +69 -11
- package/dist-server/server/drivers/antigravity-temp.js +360 -0
- package/dist-server/server/drivers/antigravity.js +21 -0
- package/dist-server/server/drivers/boatagent.js +20 -12
- package/dist-server/server/drivers/chatgpt-plan-auth.js +481 -0
- package/dist-server/server/drivers/claude.js +313 -34
- package/dist-server/server/drivers/codex.js +228 -18
- package/dist-server/server/drivers/grok.js +2 -2
- package/dist-server/server/drivers/mistral.js +4 -3
- package/dist-server/server/drivers/openai-chat-protocol.js +4 -3
- package/dist-server/server/drivers/openai-chat.js +41 -3
- package/dist-server/server/drivers/openai-compat.js +73 -23
- package/dist-server/server/engine-install.js +46 -12
- package/dist-server/server/env-path.js +15 -3
- package/dist-server/server/group-goal-run.js +6 -4
- package/dist-server/server/harness/registry.js +2 -2
- package/dist-server/server/harness-capabilities.js +2 -0
- package/dist-server/server/included-services.js +70 -0
- package/dist-server/server/index.js +1753 -263
- package/dist-server/server/lending-memory.js +479 -0
- package/dist-server/server/local-vm-idle.js +24 -6
- package/dist-server/server/managed-desktop.js +3 -0
- package/dist-server/server/mcp-bridge.js +5 -3
- package/dist-server/server/mcp-http.js +6 -3
- package/dist-server/server/mcp-oauth-discovery.js +133 -0
- package/dist-server/server/mcp-oauth-store.js +70 -0
- package/dist-server/server/mcp-oauth.js +550 -0
- package/dist-server/server/mcp-probe.js +14 -1
- package/dist-server/server/mcp-registry.js +43 -0
- package/dist-server/server/memory-upkeep.js +94 -84
- package/dist-server/server/model-catalog/catalog.js +374 -0
- package/dist-server/server/model-catalog/presets.js +176 -0
- package/dist-server/server/model-catalog/trim.js +219 -0
- package/dist-server/server/model-context-window.js +4 -4
- package/dist-server/server/package-export.js +17 -10
- package/dist-server/server/package-import.js +6 -3
- package/dist-server/server/package-parts.js +3 -1
- package/dist-server/server/provider-key-check.js +4 -2
- package/dist-server/server/recent-work.js +1 -1
- package/dist-server/server/request-auth.js +25 -0
- package/dist-server/server/routes/antigravity-leftovers.js +45 -0
- package/dist-server/server/routes/bot-memory.js +31 -6
- package/dist-server/server/routes/decider.js +19 -0
- package/dist-server/server/routes/desktop-viewer.js +194 -0
- package/dist-server/server/routine-requests.js +1 -1
- package/dist-server/server/routines.js +1 -1
- package/dist-server/server/sessions.js +56 -9
- package/dist-server/server/shared-computers.js +116 -30
- package/dist-server/server/skills.js +25 -10
- package/dist-server/server/store.js +79 -124
- package/dist-server/server/surface.js +13 -0
- package/dist-server/server/system-prompt.js +11 -0
- package/dist-server/server/team-backup.js +6 -2
- package/dist-server/server/team-manifest.js +11 -3
- package/dist-server/server/thread-events.js +2 -0
- package/dist-server/server/thread-starters.js +33 -0
- package/dist-server/server/thread-title.js +46 -0
- package/dist-server/server/tts/elevenlabs.js +11 -6
- package/dist-server/server/tts/fish.js +4 -5
- package/dist-server/server/tts/index.js +20 -11
- package/dist-server/server/vps-computer.js +79 -12
- package/dist-server/server/webhooks.js +18 -1
- package/dist-server/server/workspace-backup-policy.js +25 -3
- package/dist-server/server/workspace-backup.js +28 -8
- package/dist-server/server/workspace.js +65 -15
- package/dist-server/shared/cloud-home.js +4 -0
- package/dist-server/shared/package-format.js +30 -4
- package/dist-server/shared/team-backup.js +20 -3
- package/dist-server/shared/workspace-backup-client.js +1 -0
- package/dist-server/src/lib/citations.js +177 -0
- package/dist-server/src/lib/composer-attachments.js +7 -1
- package/dist-server/vps-container-mcp.js +278 -45
- package/dist-server/workspace-backup.worker.js +184 -38
- package/enterprise/server/index.js +5 -1
- package/package.json +1 -1
- package/dist/assets/channel-BCuyHjFV.js +0 -1
- package/dist/assets/index-CRvo-DS2.css +0 -1
- package/dist/assets/index-CWYGlA63.js +0 -588
- package/dist/assets/swimlanesDiagram-TC7HE7FX-BZQ07DGE.js +0 -8
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
// Move to Cloud (docs/cloud-pro.md): a person's one-time copy of their local
|
|
2
|
+
// workspace onto their OMB Cloud home. The desktop exports the ordinary
|
|
3
|
+
// encrypted workspace backup (workspace-backup.ts) and uploads it here in
|
|
4
|
+
// parts; this machine then previews and restores it like any other backup,
|
|
5
|
+
// so everything that backup policy keeps out (credentials, sign-ins, pairing
|
|
6
|
+
// and sessions, runtime state) stays where it is on both sides.
|
|
7
|
+
//
|
|
8
|
+
// What this file adds: the workspace's size and contents, a resumable upload
|
|
9
|
+
// slot, the space check for the machine's fixed-size volume, a backup of the
|
|
10
|
+
// Cloud's own workspace taken before it is replaced ("Restore previous Cloud"
|
|
11
|
+
// swaps it back), and the tidying that keeps that backup the only copy left
|
|
12
|
+
// behind. Everything lives under `.backups`, which no snapshot includes and
|
|
13
|
+
// no restore replaces.
|
|
14
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
15
|
+
import { createReadStream, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statfsSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { open } from "node:fs/promises";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { DatabaseSync } from "node:sqlite";
|
|
19
|
+
import { writeFileAtomic } from "./atomic.js";
|
|
20
|
+
import { MAX_WORKSPACE_BACKUP_FILES, MAX_WORKSPACE_BACKUP_UPLOAD_BYTES, omittedFromWorkspaceBackup, removeWorkspaceBackupJob, stageWorkspaceBackup, } from "./workspace-backup.js";
|
|
21
|
+
/** The part size the desktop sends; a part may be at most CLOUD_MOVE_MAX_PART_BYTES. */
|
|
22
|
+
export const CLOUD_MOVE_PART_BYTES = 16 * 1024 ** 2;
|
|
23
|
+
export const CLOUD_MOVE_MAX_PART_BYTES = 64 * 1024 ** 2;
|
|
24
|
+
export const CLOUD_MOVE_MAX_BYTES = MAX_WORKSPACE_BACKUP_UPLOAD_BYTES;
|
|
25
|
+
export const CLOUD_MOVE_MAX_FILES = MAX_WORKSPACE_BACKUP_FILES;
|
|
26
|
+
/** Headroom kept free on the volume after a move's own files. */
|
|
27
|
+
export const CLOUD_MOVE_SPACE_MARGIN = 256 * 1024 ** 2;
|
|
28
|
+
// Encrypted header plus tag: anything smaller cannot be a backup.
|
|
29
|
+
const MIN_BYTES = 60;
|
|
30
|
+
const SHA256 = /^[a-f0-9]{64}$/;
|
|
31
|
+
// An upload nobody has touched for a day is abandoned, not resumable.
|
|
32
|
+
const UPLOAD_STALE_MS = 24 * 3600_000;
|
|
33
|
+
const fail = (message, status, extra = {}) => Object.assign(new Error(message), { status, ...extra });
|
|
34
|
+
const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
35
|
+
function rosterLength(path) {
|
|
36
|
+
try {
|
|
37
|
+
const value = JSON.parse(readFileSync(path, "utf8"));
|
|
38
|
+
return Array.isArray(value) ? value.length : 0;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** What the person would call their work: bots, rooms, and the conversations
|
|
45
|
+
* they took part in (a starter bot's greeting is not a chat). */
|
|
46
|
+
export function workspaceContents(dataDir) {
|
|
47
|
+
let chats = 0;
|
|
48
|
+
const database = join(dataDir, "messages.db");
|
|
49
|
+
if (existsSync(database)) {
|
|
50
|
+
const db = new DatabaseSync(database, { timeout: 5_000 });
|
|
51
|
+
try {
|
|
52
|
+
const columns = db.prepare("SELECT name FROM pragma_table_info('messages')").all().map((row) => String(row.name));
|
|
53
|
+
if (columns.length) {
|
|
54
|
+
const who = columns.includes("role") ? "role" : "json_extract(json, '$.role')";
|
|
55
|
+
chats = Number(db.prepare(`SELECT COUNT(DISTINCT thread_id) AS chats FROM messages WHERE ${who} = 'user'`).get()?.chats ?? 0);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
db.close();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { bots: rosterLength(join(dataDir, "bots.json")), rooms: rosterLength(join(dataDir, "groups.json")), chats };
|
|
63
|
+
}
|
|
64
|
+
/** A fresh machine: its one starter bot at most, no rooms, nobody has chatted. */
|
|
65
|
+
export function isEmptyWorkspace(contents) {
|
|
66
|
+
return contents.bots <= 1 && contents.rooms === 0 && contents.chats === 0;
|
|
67
|
+
}
|
|
68
|
+
/** Bytes and files a backup of this workspace would carry, by the export's
|
|
69
|
+
* own rules. An estimate: live files can change before the export. */
|
|
70
|
+
export function workspaceMoveSize(dataDir) {
|
|
71
|
+
let bytes = 0, files = 0;
|
|
72
|
+
const walk = (directory, prefix) => {
|
|
73
|
+
for (const name of readdirSync(directory)) {
|
|
74
|
+
const path = prefix ? `${prefix}/${name}` : name;
|
|
75
|
+
if (omittedFromWorkspaceBackup(path))
|
|
76
|
+
continue;
|
|
77
|
+
const stat = lstatSync(join(directory, name));
|
|
78
|
+
if (stat.isSymbolicLink())
|
|
79
|
+
continue;
|
|
80
|
+
if (stat.isDirectory())
|
|
81
|
+
walk(join(directory, name), path);
|
|
82
|
+
else if (stat.isFile()) {
|
|
83
|
+
bytes += stat.size;
|
|
84
|
+
files++;
|
|
85
|
+
}
|
|
86
|
+
if (files > CLOUD_MOVE_MAX_FILES)
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
if (existsSync(dataDir))
|
|
91
|
+
walk(dataDir, "");
|
|
92
|
+
return { bytes, files };
|
|
93
|
+
}
|
|
94
|
+
export function freeVolumeBytes(path) {
|
|
95
|
+
const disk = statfsSync(path, { bigint: true });
|
|
96
|
+
const free = disk.bavail * disk.bsize;
|
|
97
|
+
return free > BigInt(Number.MAX_SAFE_INTEGER) ? Number.MAX_SAFE_INTEGER : Number(free);
|
|
98
|
+
}
|
|
99
|
+
/** Peak extra space a move of `upload` bytes needs: the upload, its decrypted
|
|
100
|
+
* copy and its staged files exist together while it is checked; the restore
|
|
101
|
+
* then installs from the staged copy. Replacing a workspace first backs it up
|
|
102
|
+
* (a snapshot and its archive, briefly both). */
|
|
103
|
+
export function moveSpaceNeeded(upload, current, replacing) {
|
|
104
|
+
return 3 * upload + (replacing ? 2 * current : 0) + CLOUD_MOVE_SPACE_MARGIN;
|
|
105
|
+
}
|
|
106
|
+
function privateFolder(path) {
|
|
107
|
+
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
108
|
+
const stat = lstatSync(path);
|
|
109
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
110
|
+
throw fail("Backup storage must be a real folder, not a link.", 500);
|
|
111
|
+
return path;
|
|
112
|
+
}
|
|
113
|
+
const backupsRoot = (dataDir) => privateFolder(join(dataDir, ".backups"));
|
|
114
|
+
// Reads never create the folder; beginUpload does.
|
|
115
|
+
const uploadFolder = (dataDir) => join(dataDir, ".backups", "cloud-move");
|
|
116
|
+
const uploadMetaPath = (dataDir) => join(uploadFolder(dataDir), "upload.json");
|
|
117
|
+
const uploadPartPath = (dataDir) => join(uploadFolder(dataDir), "upload.part");
|
|
118
|
+
function readUploadMeta(dataDir) {
|
|
119
|
+
try {
|
|
120
|
+
const value = JSON.parse(readFileSync(uploadMetaPath(dataDir), "utf8"));
|
|
121
|
+
if (record(value) && typeof value.sha256 === "string" && SHA256.test(value.sha256) && Number.isSafeInteger(value.bytes) && Number.isSafeInteger(value.updatedAt)) {
|
|
122
|
+
return value;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch { /* none, or unreadable: a fresh upload replaces it */ }
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
function receivedBytes(dataDir) {
|
|
129
|
+
try {
|
|
130
|
+
const stat = lstatSync(uploadPartPath(dataDir));
|
|
131
|
+
return stat.isFile() ? stat.size : 0;
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
export function uploadStatus(dataDir) {
|
|
138
|
+
const meta = readUploadMeta(dataDir);
|
|
139
|
+
return meta ? { sha256: meta.sha256, bytes: meta.bytes, received: Math.min(receivedBytes(dataDir), meta.bytes) } : null;
|
|
140
|
+
}
|
|
141
|
+
export function discardUpload(dataDir) {
|
|
142
|
+
rmSync(uploadPartPath(dataDir), { force: true });
|
|
143
|
+
rmSync(uploadMetaPath(dataDir), { force: true });
|
|
144
|
+
}
|
|
145
|
+
/** Check a declared upload: its size, file count and SHA-256. */
|
|
146
|
+
export function validUploadDeclaration(input) {
|
|
147
|
+
if (typeof input.sha256 !== "string" || !SHA256.test(input.sha256))
|
|
148
|
+
throw fail("This is not a workspace backup.", 400);
|
|
149
|
+
if (!Number.isSafeInteger(input.bytes) || input.bytes < MIN_BYTES)
|
|
150
|
+
throw fail("This is not a workspace backup.", 400);
|
|
151
|
+
if (input.bytes > CLOUD_MOVE_MAX_BYTES)
|
|
152
|
+
throw fail("This workspace is larger than the 10 GB a move can carry.", 413);
|
|
153
|
+
if (input.files !== undefined && (!Number.isSafeInteger(input.files) || input.files < 0 || input.files > CLOUD_MOVE_MAX_FILES)) {
|
|
154
|
+
throw fail("This workspace has more than the 100,000 files a move can carry.", 413);
|
|
155
|
+
}
|
|
156
|
+
return { sha256: input.sha256, bytes: input.bytes };
|
|
157
|
+
}
|
|
158
|
+
/** Start an upload, or continue the same file's upload where it stopped. */
|
|
159
|
+
export function beginUpload(dataDir, declared, now = Date.now()) {
|
|
160
|
+
const existing = readUploadMeta(dataDir);
|
|
161
|
+
if (existing && existing.sha256 === declared.sha256 && existing.bytes === declared.bytes && existing.updatedAt + UPLOAD_STALE_MS > now && receivedBytes(dataDir) <= declared.bytes) {
|
|
162
|
+
writeFileAtomic(uploadMetaPath(dataDir), JSON.stringify({ ...existing, updatedAt: now }), { mode: 0o600 });
|
|
163
|
+
return { ...declared, received: receivedBytes(dataDir) };
|
|
164
|
+
}
|
|
165
|
+
discardUpload(dataDir);
|
|
166
|
+
backupsRoot(dataDir);
|
|
167
|
+
privateFolder(uploadFolder(dataDir));
|
|
168
|
+
writeFileSync(uploadPartPath(dataDir), "", { mode: 0o600, flag: "wx" });
|
|
169
|
+
writeFileAtomic(uploadMetaPath(dataDir), JSON.stringify({ ...declared, updatedAt: now }), { mode: 0o600 });
|
|
170
|
+
return { ...declared, received: 0 };
|
|
171
|
+
}
|
|
172
|
+
/** Store one part at `offset`. A part already stored is accepted again
|
|
173
|
+
* without writing; any other offset than the next one answers where the
|
|
174
|
+
* upload stands (409, `received`). A failed part is cut back off. */
|
|
175
|
+
export async function writeUploadPart(dataDir, sha256, offset, length, body, now = Date.now()) {
|
|
176
|
+
const meta = readUploadMeta(dataDir);
|
|
177
|
+
if (!meta || meta.sha256 !== sha256)
|
|
178
|
+
throw fail("This upload is not in progress on your Cloud. Start the move again.", 404);
|
|
179
|
+
if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(length) || length <= 0 || length > CLOUD_MOVE_MAX_PART_BYTES) {
|
|
180
|
+
throw fail("Invalid upload part.", 400);
|
|
181
|
+
}
|
|
182
|
+
if (offset + length > meta.bytes)
|
|
183
|
+
throw fail("The upload is larger than the file it declared.", 413);
|
|
184
|
+
const received = receivedBytes(dataDir);
|
|
185
|
+
const drain = async () => { for await (const _chunk of body) { /* already stored */ } };
|
|
186
|
+
if (offset + length <= received) {
|
|
187
|
+
await drain();
|
|
188
|
+
return received;
|
|
189
|
+
}
|
|
190
|
+
if (offset !== received) {
|
|
191
|
+
await drain();
|
|
192
|
+
throw fail("The upload continues from another point.", 409, { received });
|
|
193
|
+
}
|
|
194
|
+
const handle = await open(uploadPartPath(dataDir), "r+");
|
|
195
|
+
let written = 0;
|
|
196
|
+
try {
|
|
197
|
+
for await (const chunk of body) {
|
|
198
|
+
if (written + chunk.length > length)
|
|
199
|
+
throw fail("The upload part is larger than it declared.", 413);
|
|
200
|
+
let done = 0;
|
|
201
|
+
while (done < chunk.length)
|
|
202
|
+
done += (await handle.write(chunk, done, chunk.length - done, offset + written + done)).bytesWritten;
|
|
203
|
+
written += chunk.length;
|
|
204
|
+
}
|
|
205
|
+
if (written !== length)
|
|
206
|
+
throw fail("The upload part was incomplete.", 400);
|
|
207
|
+
await handle.sync();
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
await handle.truncate(received).catch(() => { });
|
|
211
|
+
throw error;
|
|
212
|
+
}
|
|
213
|
+
finally {
|
|
214
|
+
await handle.close();
|
|
215
|
+
}
|
|
216
|
+
writeFileAtomic(uploadMetaPath(dataDir), JSON.stringify({ ...meta, updatedAt: now }), { mode: 0o600 });
|
|
217
|
+
return received + length;
|
|
218
|
+
}
|
|
219
|
+
/** The complete upload's path, once its size and SHA-256 match what was declared. */
|
|
220
|
+
export async function completedUpload(dataDir, sha256) {
|
|
221
|
+
const status = uploadStatus(dataDir);
|
|
222
|
+
if (!status || status.sha256 !== sha256)
|
|
223
|
+
throw fail("This upload is not in progress on your Cloud. Start the move again.", 404);
|
|
224
|
+
if (status.received !== status.bytes)
|
|
225
|
+
throw fail("The upload is not complete yet.", 409, { received: status.received });
|
|
226
|
+
const hash = createHash("sha256");
|
|
227
|
+
for await (const chunk of createReadStream(uploadPartPath(dataDir), { highWaterMark: 1024 * 1024 }))
|
|
228
|
+
hash.update(chunk);
|
|
229
|
+
if (hash.digest("hex") !== sha256) {
|
|
230
|
+
discardUpload(dataDir);
|
|
231
|
+
throw fail("The uploaded file was damaged on the way. Start the move again.", 400);
|
|
232
|
+
}
|
|
233
|
+
return uploadPartPath(dataDir);
|
|
234
|
+
}
|
|
235
|
+
const previousFolder = (dataDir) => join(dataDir, ".backups", "cloud-previous");
|
|
236
|
+
const nextPreviousFolder = (dataDir) => join(dataDir, ".backups", "cloud-previous.next");
|
|
237
|
+
const moveRestoresPath = (dataDir) => join(dataDir, ".backups", "cloud-move-restores.json");
|
|
238
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
239
|
+
function readPrevious(folder) {
|
|
240
|
+
try {
|
|
241
|
+
const value = JSON.parse(readFileSync(join(folder, "previous.json"), "utf8"));
|
|
242
|
+
if (!record(value) || typeof value.password !== "string" || typeof value.createdAt !== "string" || !record(value.contents))
|
|
243
|
+
return null;
|
|
244
|
+
if (!lstatSync(join(folder, "workspace.ombbackup")).isFile())
|
|
245
|
+
return null;
|
|
246
|
+
return value;
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/** The previous Cloud that can be put back, never its password. A fresh
|
|
253
|
+
* Cloud's (its starter bot, nothing else) is kept but not offered. */
|
|
254
|
+
export function previousCloud(dataDir) {
|
|
255
|
+
const previous = readPrevious(previousFolder(dataDir));
|
|
256
|
+
if (!previous || isEmptyWorkspace(previous.contents))
|
|
257
|
+
return null;
|
|
258
|
+
return { createdAt: previous.createdAt, ...previous.contents, bytes: lstatSync(join(previousFolder(dataDir), "workspace.ombbackup")).size };
|
|
259
|
+
}
|
|
260
|
+
/** Back up this workspace as the next previous Cloud, for the restore
|
|
261
|
+
* `restoreId`. `create` is createWorkspaceBackup, run by the caller inside
|
|
262
|
+
* the maintenance gate. It becomes the previous Cloud only once startup has
|
|
263
|
+
* installed that restore. */
|
|
264
|
+
export async function prepareNextPreviousCloud(dataDir, restoreId, create) {
|
|
265
|
+
const password = randomBytes(32).toString("base64url");
|
|
266
|
+
const contents = workspaceContents(dataDir);
|
|
267
|
+
const created = await create(password);
|
|
268
|
+
const next = nextPreviousFolder(dataDir);
|
|
269
|
+
try {
|
|
270
|
+
backupsRoot(dataDir);
|
|
271
|
+
rmSync(next, { recursive: true, force: true });
|
|
272
|
+
privateFolder(next);
|
|
273
|
+
renameSync(created.path, join(next, "workspace.ombbackup"));
|
|
274
|
+
const meta = { password, createdAt: new Date().toISOString(), contents, forRestore: restoreId };
|
|
275
|
+
writeFileAtomic(join(next, "previous.json"), JSON.stringify(meta), { mode: 0o600 });
|
|
276
|
+
return { createdAt: meta.createdAt, ...contents };
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
rmSync(next, { recursive: true, force: true });
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
282
|
+
finally {
|
|
283
|
+
try {
|
|
284
|
+
removeWorkspaceBackupJob(dataDir, created.id);
|
|
285
|
+
}
|
|
286
|
+
catch { /* The export job expires with the others. */ }
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
/** Drop a next previous Cloud whose restore did not commit. */
|
|
290
|
+
export function discardNextPreviousCloud(dataDir) {
|
|
291
|
+
rmSync(nextPreviousFolder(dataDir), { recursive: true, force: true });
|
|
292
|
+
}
|
|
293
|
+
/** Stage the previous Cloud for an ordinary restore; its archive stays. */
|
|
294
|
+
export async function stagePreviousCloud(dataDir, appVersion) {
|
|
295
|
+
const previous = readPrevious(previousFolder(dataDir));
|
|
296
|
+
if (!previous || isEmptyWorkspace(previous.contents))
|
|
297
|
+
throw fail("There is no previous Cloud to restore.", 404);
|
|
298
|
+
const archive = join(previousFolder(dataDir), "workspace.ombbackup");
|
|
299
|
+
const staged = await stageWorkspaceBackup(dataDir, archive, { password: previous.password, currentAppVersion: appVersion });
|
|
300
|
+
return { ...staged, bytes: lstatSync(archive).size };
|
|
301
|
+
}
|
|
302
|
+
/** The archive size of the previous Cloud (0 without one), for space checks. */
|
|
303
|
+
export function previousCloudArchiveBytes(dataDir) {
|
|
304
|
+
try {
|
|
305
|
+
return lstatSync(join(previousFolder(dataDir), "workspace.ombbackup")).size;
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
return 0;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
function readMoveRestores(dataDir) {
|
|
312
|
+
try {
|
|
313
|
+
const value = JSON.parse(readFileSync(moveRestoresPath(dataDir), "utf8"));
|
|
314
|
+
return Array.isArray(value) ? value.filter((id) => typeof id === "string" && UUID.test(id)) : [];
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
return [];
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
function writeMoveRestores(dataDir, ids) {
|
|
321
|
+
backupsRoot(dataDir);
|
|
322
|
+
if (ids.length)
|
|
323
|
+
writeFileAtomic(moveRestoresPath(dataDir), JSON.stringify([...new Set(ids)]), { mode: 0o600 });
|
|
324
|
+
else
|
|
325
|
+
rmSync(moveRestoresPath(dataDir), { force: true });
|
|
326
|
+
}
|
|
327
|
+
/** Remember a restore made by a move or a swap back, before it commits, so
|
|
328
|
+
* its safety copy and staged files are tidied once it is installed. */
|
|
329
|
+
export function noteMoveRestore(dataDir, restoreId) {
|
|
330
|
+
writeMoveRestores(dataDir, [...readMoveRestores(dataDir), restoreId]);
|
|
331
|
+
}
|
|
332
|
+
export function forgetMoveRestore(dataDir, restoreId) {
|
|
333
|
+
writeMoveRestores(dataDir, readMoveRestores(dataDir).filter((id) => id !== restoreId));
|
|
334
|
+
}
|
|
335
|
+
/** Delete a staged job, and its safety copy, by id: guarded paths only. */
|
|
336
|
+
export function removeMoveFiles(dataDir, id) {
|
|
337
|
+
if (!UUID.test(id))
|
|
338
|
+
return;
|
|
339
|
+
for (const name of [id, `safety-${id}`]) {
|
|
340
|
+
const path = join(dataDir, ".backups", name);
|
|
341
|
+
try {
|
|
342
|
+
const stat = lstatSync(path);
|
|
343
|
+
if (stat.isDirectory() && !stat.isSymbolicLink())
|
|
344
|
+
rmSync(path, { recursive: true, force: true });
|
|
345
|
+
}
|
|
346
|
+
catch { /* already gone */ }
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
function pendingRestoreId(dataDir) {
|
|
350
|
+
try {
|
|
351
|
+
const value = JSON.parse(readFileSync(join(dataDir, ".backups", "pending-restore.json"), "utf8"));
|
|
352
|
+
return record(value) && typeof value.id === "string" ? value.id : null;
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
/** At a Cloud home's startup, after any pending restore was applied:
|
|
359
|
+
* - a next previous Cloud becomes the previous Cloud once its restore is
|
|
360
|
+
* installed, and is dropped otherwise;
|
|
361
|
+
* - moves' and swaps' restores, once settled, keep no safety copy or staged
|
|
362
|
+
* files (the previous Cloud is the one undo point);
|
|
363
|
+
* - an upload nobody has touched for a day is deleted.
|
|
364
|
+
* Never touches a restore still waiting to be applied. */
|
|
365
|
+
export function tidyCloudMoveStorage(dataDir, restored, now = Date.now()) {
|
|
366
|
+
if (!existsSync(join(dataDir, ".backups")))
|
|
367
|
+
return;
|
|
368
|
+
const pending = pendingRestoreId(dataDir);
|
|
369
|
+
const next = readPrevious(nextPreviousFolder(dataDir));
|
|
370
|
+
if (next && next.forRestore !== pending) {
|
|
371
|
+
if (restored.restored && restored.id === next.forRestore) {
|
|
372
|
+
rmSync(previousFolder(dataDir), { recursive: true, force: true });
|
|
373
|
+
renameSync(nextPreviousFolder(dataDir), previousFolder(dataDir));
|
|
374
|
+
}
|
|
375
|
+
else
|
|
376
|
+
discardNextPreviousCloud(dataDir);
|
|
377
|
+
}
|
|
378
|
+
else if (!next && existsSync(nextPreviousFolder(dataDir)) && !pending)
|
|
379
|
+
discardNextPreviousCloud(dataDir);
|
|
380
|
+
const settled = readMoveRestores(dataDir).filter((id) => id !== pending);
|
|
381
|
+
for (const id of settled)
|
|
382
|
+
removeMoveFiles(dataDir, id);
|
|
383
|
+
writeMoveRestores(dataDir, readMoveRestores(dataDir).filter((id) => id === pending));
|
|
384
|
+
if (restored.id && settled.includes(restored.id)) {
|
|
385
|
+
// The receipt must not point at a safety copy that is gone.
|
|
386
|
+
delete restored.safetyCopyPath;
|
|
387
|
+
try {
|
|
388
|
+
const receipt = join(dataDir, ".backups", "last-restore.json");
|
|
389
|
+
const value = JSON.parse(readFileSync(receipt, "utf8"));
|
|
390
|
+
if (record(value) && value.id === restored.id && "safetyCopyPath" in value) {
|
|
391
|
+
delete value.safetyCopyPath;
|
|
392
|
+
writeFileAtomic(receipt, JSON.stringify(value), { mode: 0o600 });
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
catch { /* no receipt */ }
|
|
396
|
+
}
|
|
397
|
+
const upload = readUploadMeta(dataDir);
|
|
398
|
+
if ((upload && upload.updatedAt + UPLOAD_STALE_MS <= now) || (!upload && existsSync(uploadPartPath(dataDir))))
|
|
399
|
+
discardUpload(dataDir);
|
|
400
|
+
}
|
|
401
|
+
/** Bytes under `.backups`: what backups, safety copies and moves hold on the volume. */
|
|
402
|
+
export function backupsBytes(dataDir) {
|
|
403
|
+
let bytes = 0;
|
|
404
|
+
const walk = (directory) => {
|
|
405
|
+
for (const name of readdirSync(directory)) {
|
|
406
|
+
const stat = lstatSync(join(directory, name));
|
|
407
|
+
if (stat.isDirectory() && !stat.isSymbolicLink())
|
|
408
|
+
walk(join(directory, name));
|
|
409
|
+
else if (stat.isFile())
|
|
410
|
+
bytes += stat.size;
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
try {
|
|
414
|
+
walk(join(dataDir, ".backups"));
|
|
415
|
+
}
|
|
416
|
+
catch { /* none yet */ }
|
|
417
|
+
return bytes;
|
|
418
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// An OMB Cloud home is personal (docs/cloud-pro.md): only the owner's own
|
|
2
|
+
// devices connect, each with an admin session the Admin's signed pairing
|
|
3
|
+
// gave it. No pairing, sign-in or session without admin scope is minted or
|
|
4
|
+
// redeemed there (server/sessions.ts requireAdmin), and at every boot any
|
|
5
|
+
// that is stored is revoked: what it opened or wrote is nobody's from then
|
|
6
|
+
// on, and its key is recorded first, so no crash can ever make it the
|
|
7
|
+
// owner's.
|
|
8
|
+
//
|
|
9
|
+
// Before, every device carried its own key (v0.1.91), so a device the owner
|
|
10
|
+
// unpaired made the owner's conversations read as someone else's. Once, at
|
|
11
|
+
// the first boot of a personal Cloud home, and again after a restore, the
|
|
12
|
+
// keys named until then are settled, in two tiers:
|
|
13
|
+
// - adopted: every key that opened a conversation, wrote a line or answered
|
|
14
|
+
// a card, except a revoked one. Its conversations are the owner's for who
|
|
15
|
+
// opened them, their approval level and their folder (so the owner's rooms
|
|
16
|
+
// and conversations keep Auto or Full, and their project folder);
|
|
17
|
+
// - proven: only a key with proof it was the owner's, an admin session
|
|
18
|
+
// still paired, or one that answered a card (only the owner's devices
|
|
19
|
+
// could, on a Cloud home). Only those feed lending, memory, recall and the
|
|
20
|
+
// recent-work brief (cloud-lending.ts ownerOnlyConversation): a guest the
|
|
21
|
+
// owner unpaired before is indistinguishable from an owner's old device,
|
|
22
|
+
// so its lines never count as the owner's there.
|
|
23
|
+
// Routines fail closed: a routine's writer is the owner only with proof (the
|
|
24
|
+
// owner's key as its writer, the owner's fingerprint, #2023, a proposal the
|
|
25
|
+
// owner approved on its card, or a restore the owner started whose results
|
|
26
|
+
// conversation is theirs); every other routine is nobody's, and runs
|
|
27
|
+
// confined. What a revoked session opened loses its working folder (its next
|
|
28
|
+
// turn gets a private one), and its routines are paused.
|
|
29
|
+
import { lstatSync, readFileSync } from "node:fs";
|
|
30
|
+
import { z } from "zod";
|
|
31
|
+
import { writeFileAtomic } from "./atomic.js";
|
|
32
|
+
export const CLOUD_PERSONAL_REFUSAL = "Cloud Pro is personal: only your own devices can connect.";
|
|
33
|
+
const KEY = /^p_[\w-]{22}$/;
|
|
34
|
+
const keys = z.array(z.string().regex(KEY)).max(100_000);
|
|
35
|
+
const recordFile = z.object({
|
|
36
|
+
version: z.literal(2),
|
|
37
|
+
machineId: z.string().max(128),
|
|
38
|
+
/** Revoked here, ever: never anyone's but nobody's. */
|
|
39
|
+
revokedKeys: keys,
|
|
40
|
+
adoptedKeys: keys,
|
|
41
|
+
provenKeys: keys,
|
|
42
|
+
/** The earlier keys are settled (and routines named). */
|
|
43
|
+
settled: z.boolean(),
|
|
44
|
+
/** A restore applied here whose data is not settled yet. */
|
|
45
|
+
pendingRestore: z.string().max(128).optional(),
|
|
46
|
+
/** The last restore whose data is settled (a restore applied at a start
|
|
47
|
+
* that ended before recording it is found this way at the next). */
|
|
48
|
+
settledRestore: z.string().max(128).optional(),
|
|
49
|
+
}).strict();
|
|
50
|
+
function readRecord(options) {
|
|
51
|
+
const fresh = { version: 2, machineId: options.machineId, revokedKeys: [], adoptedKeys: [], provenKeys: [], settled: false, fresh: true };
|
|
52
|
+
let stat;
|
|
53
|
+
try {
|
|
54
|
+
stat = lstatSync(options.file, { throwIfNoEntry: false });
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
stat = null;
|
|
58
|
+
}
|
|
59
|
+
if (stat === undefined)
|
|
60
|
+
return fresh;
|
|
61
|
+
try {
|
|
62
|
+
if (!stat || !stat.isFile() || stat.size > 16_000_000)
|
|
63
|
+
throw new Error("not a file");
|
|
64
|
+
const record = recordFile.parse(JSON.parse(readFileSync(options.file, "utf8")));
|
|
65
|
+
// Another machine's (never carried by a backup): nothing in it is ours.
|
|
66
|
+
if (record.machineId !== options.machineId) {
|
|
67
|
+
options.log(`cloud home: ${options.file} is another machine's; settling this one's own`);
|
|
68
|
+
return fresh;
|
|
69
|
+
}
|
|
70
|
+
return record;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// Settled before, and unreadable now: nothing earlier is the owner's, and
|
|
74
|
+
// running the settlement again could make a revoked key theirs.
|
|
75
|
+
options.log(`cloud home: ${options.file} is unreadable, so nothing from before is treated as the owner's`);
|
|
76
|
+
return { ...fresh, settled: true };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function save(options, record) {
|
|
80
|
+
try {
|
|
81
|
+
writeFileAtomic(options.file, JSON.stringify(record), { mode: 0o600 });
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
options.log(`cloud home: could not record who is the owner (${error instanceof Error ? error.message : String(error)}); trying again at the next start`);
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** A step that never stops the server from starting: logged when it fails
|
|
90
|
+
* (`failed` is then called), and done again at the next start (every step
|
|
91
|
+
* can be). */
|
|
92
|
+
function attempt(options, what, step, fallback, failed) {
|
|
93
|
+
try {
|
|
94
|
+
return step();
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
options.log(`cloud home: could not ${what} (${error instanceof Error ? error.message : String(error)})`);
|
|
98
|
+
failed?.();
|
|
99
|
+
return fallback;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** The settled keys, never one revoked since. */
|
|
103
|
+
function settledSets(record) {
|
|
104
|
+
const revoked = new Set(record.revokedKeys);
|
|
105
|
+
return { adopted: new Set(record.adoptedKeys.filter((key) => !revoked.has(key))), proven: new Set(record.provenKeys.filter((key) => !revoked.has(key))) };
|
|
106
|
+
}
|
|
107
|
+
/** Revoke every session that is not one of the owner's devices, and settle
|
|
108
|
+
* the earlier keys (once, and after a restore). Every step is recorded
|
|
109
|
+
* before it is taken, so a crash anywhere, or a failed save, only ever
|
|
110
|
+
* leaves a key less trusted, and the next start carries on. */
|
|
111
|
+
export function settleCloudOwnership(options) {
|
|
112
|
+
const { fresh, ...read } = readRecord(options);
|
|
113
|
+
let record = read;
|
|
114
|
+
const others = options.sessions.list().filter((session) => !session.scopes.includes("admin"));
|
|
115
|
+
const revoked = new Set([...record.revokedKeys, ...others.map((session) => options.personKey(session))]);
|
|
116
|
+
// A restore to settle: one applied at this start, or a later one than the
|
|
117
|
+
// last settled (its start ended before it was recorded). At the very first
|
|
118
|
+
// start, only one applied now: what came before settles the strict way.
|
|
119
|
+
const restore = options.restoredNow ??
|
|
120
|
+
(!fresh && options.lastRestore && options.lastRestore !== record.settledRestore && options.lastRestore !== record.pendingRestore ? options.lastRestore : undefined);
|
|
121
|
+
record = { ...record, revokedKeys: [...revoked], ...(restore ? { pendingRestore: restore } : {}),
|
|
122
|
+
...(fresh && !options.restoredNow && options.lastRestore ? { settledRestore: options.lastRestore } : {}) };
|
|
123
|
+
// 1. The keys about to be revoked are recorded first.
|
|
124
|
+
if (!save(options, record))
|
|
125
|
+
return settledSets(record);
|
|
126
|
+
// 2. Their sessions go.
|
|
127
|
+
let count = 0;
|
|
128
|
+
for (const session of others)
|
|
129
|
+
if (attempt(options, "revoke a session", () => options.sessions.revoke(session.id), false))
|
|
130
|
+
count += 1;
|
|
131
|
+
options.log(`cloud home: revoked ${count} session${count === 1 ? "" : "s"} that ${count === 1 ? "was" : "were"} not the owner's own device${count === 1 ? "" : "s"}`);
|
|
132
|
+
// 3. What any revoked key opened or wrote is nobody's: its conversations
|
|
133
|
+
// lose their working folder (the owner's project, often), its routines stop.
|
|
134
|
+
const orphans = attempt(options, "mark conversations as nobody's", () => options.starters.reassign((person) => revoked.has(person), options.nobodyKey), []);
|
|
135
|
+
if (orphans.length)
|
|
136
|
+
attempt(options, "unpin their folders", () => options.unpin(orphans), undefined);
|
|
137
|
+
const stopped = attempt(options, "mark routines as nobody's", () => options.writers.reassign((person) => revoked.has(person), options.nobodyKey), []);
|
|
138
|
+
if (stopped.length)
|
|
139
|
+
attempt(options, "pause their routines", () => options.routines.pause(stopped), undefined);
|
|
140
|
+
if (fresh) {
|
|
141
|
+
options.log("cloud home: only your own devices can connect now. Review Settings → Remote access → Paired devices, and sign out any device that isn't yours.");
|
|
142
|
+
}
|
|
143
|
+
const restoring = record.pendingRestore !== undefined;
|
|
144
|
+
if (record.settled && !restoring)
|
|
145
|
+
return settledSets(record);
|
|
146
|
+
// 4. The earlier keys, recorded before anything is rewritten.
|
|
147
|
+
const eligible = (person) => KEY.test(person) && person !== options.ownerKey && person !== options.nobodyKey && !revoked.has(person);
|
|
148
|
+
// Settled only once every step below is done; else the next start does it again.
|
|
149
|
+
let complete = true;
|
|
150
|
+
const incomplete = () => { complete = false; };
|
|
151
|
+
const lines = attempt(options, "read who wrote in each conversation", () => options.lines(), { senders: [], answerers: [], approvals: [] }, incomplete);
|
|
152
|
+
const admins = options.sessions.list().filter((session) => session.scopes.includes("admin")).map((session) => options.personKey(session));
|
|
153
|
+
const proven = new Set([...record.provenKeys, ...admins, ...lines.answerers].filter(eligible));
|
|
154
|
+
const adopted = new Set([...record.adoptedKeys, ...proven, ...options.starters.people(), ...lines.senders].filter(eligible));
|
|
155
|
+
record = { ...record, adoptedKeys: [...adopted], provenKeys: [...proven] };
|
|
156
|
+
if (!save(options, record))
|
|
157
|
+
return settledSets(record);
|
|
158
|
+
// 5. Conversations the owner provably opened name the owner; routines are
|
|
159
|
+
// the owner's only with proof, else nobody's.
|
|
160
|
+
const moved = attempt(options, "name the owner on their conversations", () => options.starters.reassign((person) => proven.has(person), options.ownerKey), [], incomplete).length;
|
|
161
|
+
const ownersKey = (person) => person !== undefined && (person === options.ownerKey || proven.has(person));
|
|
162
|
+
// A card the owner allowed is proof of what it showed them: only while the
|
|
163
|
+
// routine still runs exactly that (its instructions, bot, schedule, where
|
|
164
|
+
// it runs and no attachment: anyone could change a routine on v0.1.91
|
|
165
|
+
// without being recorded).
|
|
166
|
+
const approved = new Set();
|
|
167
|
+
for (const [id, person, shape] of lines.approvals) {
|
|
168
|
+
if (ownersKey(person) && options.routines.shape(id) === shape)
|
|
169
|
+
approved.add(id);
|
|
170
|
+
}
|
|
171
|
+
let owners = 0, nobodys = 0;
|
|
172
|
+
attempt(options, "name each routine's writer", () => {
|
|
173
|
+
for (const id of options.routines.ids()) {
|
|
174
|
+
const writer = options.routines.writer(id);
|
|
175
|
+
// A restore is proof only for a routine reporting into a conversation
|
|
176
|
+
// that names nobody yet, or the owner (a backup from before can hold a
|
|
177
|
+
// guest's routine and its conversation).
|
|
178
|
+
const opener = options.routines.resultsOpener(id);
|
|
179
|
+
const owner = writer === options.ownerKey || options.routines.fingerprinted(id) || approved.has(id) ||
|
|
180
|
+
(restoring && writer !== options.nobodyKey && !(writer !== undefined && revoked.has(writer)) && (opener === undefined || ownersKey(opener)));
|
|
181
|
+
if (writer !== (owner ? options.ownerKey : options.nobodyKey))
|
|
182
|
+
options.routines.name(id, owner ? options.ownerKey : options.nobodyKey);
|
|
183
|
+
if (owner)
|
|
184
|
+
owners += 1;
|
|
185
|
+
else
|
|
186
|
+
nobodys += 1;
|
|
187
|
+
}
|
|
188
|
+
}, undefined, incomplete);
|
|
189
|
+
if (!complete)
|
|
190
|
+
return settledSets(record);
|
|
191
|
+
const { pendingRestore: done, ...rest } = record;
|
|
192
|
+
record = { ...rest, settled: true, ...(done ? { settledRestore: done } : {}) };
|
|
193
|
+
save(options, record);
|
|
194
|
+
options.log(`cloud home: settled what came before${restoring ? " (a restore)" : ""}: ${adopted.size} earlier device key${adopted.size === 1 ? "" : "s"} (${proven.size} proven), ${moved} conversation${moved === 1 ? "" : "s"} named the owner's, ${owners} routine${owners === 1 ? "" : "s"} the owner's and ${nobodys} nobody's`);
|
|
195
|
+
return settledSets(record);
|
|
196
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// The server's first import (server/index.ts): on an OMB Cloud home, the
|
|
2
|
+
// secrets the launcher hands over its pipe are read, and the pipe closed,
|
|
3
|
+
// before any other module is loaded, so no process the server starts can
|
|
4
|
+
// inherit it (cloud-secrets.ts, cloud-home-start.ts).
|
|
5
|
+
import { takeCloudSecrets } from "./cloud-secrets.js";
|
|
6
|
+
export const BOOT_CLOUD_SECRETS = takeCloudSecrets();
|