openmausbot 0.1.89 → 0.1.91
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/{abnfDiagram-O67JEVCF-BwaMaZEm.js → abnfDiagram-O67JEVCF-yP-w8pRq.js} +1 -1
- package/dist/assets/{arc-Be-8GKxd.js → arc-DsivfZEh.js} +1 -1
- package/dist/assets/{architectureDiagram-NJMV4G6O-Dp_U2Kv1.js → architectureDiagram-NJMV4G6O-CILw7lzT.js} +1 -1
- package/dist/assets/{blockDiagram-BEXU5L5S-DQjUjYUk.js → blockDiagram-BEXU5L5S-DEQUV3HF.js} +1 -1
- package/dist/assets/{c4Diagram-YGBWAQC7-Bd3aE4Kc.js → c4Diagram-YGBWAQC7-lSXCZoKA.js} +1 -1
- package/dist/assets/channel-DrfGMu7X.js +1 -0
- package/dist/assets/{chunk-2Q5K7J3B-BijjTBeJ.js → chunk-2Q5K7J3B-QCD5bgWP.js} +1 -1
- package/dist/assets/{chunk-3FUC2YCW-CMGdwvzW.js → chunk-3FUC2YCW-C5-iZJuL.js} +2 -2
- package/dist/assets/{chunk-5VM5RSS4-0G09BfsZ.js → chunk-5VM5RSS4-cxsawMpY.js} +1 -1
- package/dist/assets/{chunk-GTNCS2PH-FOp3vSHe.js → chunk-GTNCS2PH-DV8tV8H7.js} +1 -1
- package/dist/assets/{chunk-GWA4HPMP-Dv-20VIn.js → chunk-GWA4HPMP-CJFZyfbW.js} +1 -1
- package/dist/assets/{chunk-JWPE2WC7-CRgrdFcz.js → chunk-JWPE2WC7-BigbMXco.js} +1 -1
- package/dist/assets/{chunk-NETBCI7D-3hP4f7QB.js → chunk-NETBCI7D-oVxTfpDy.js} +1 -1
- package/dist/assets/{chunk-WEXAMYUT-CG98IjlG.js → chunk-WEXAMYUT-CH4BiroW.js} +1 -1
- package/dist/assets/{chunk-XXDRQBXY-BrSTC9xG.js → chunk-XXDRQBXY-KEDa6gax.js} +1 -1
- package/dist/assets/{classDiagram-v2-NBCMYWYE-rpQQQDFX.js → classDiagram-v2-NBCMYWYE-BiPjfqVv.js} +1 -1
- package/dist/assets/{cose-bilkent-JH36ORCC-G6RSafHA.js → cose-bilkent-JH36ORCC-Caxew3OY.js} +1 -1
- package/dist/assets/{cynefin-OW5HDTMX-CXjv-IM2.js → cynefin-OW5HDTMX-B_Pui369.js} +1 -1
- package/dist/assets/{cynefinDiagram-VND7K2PF-Dw07MR9f.js → cynefinDiagram-VND7K2PF-X64ydIhP.js} +1 -1
- package/dist/assets/{dagre-6A5THRUB-BwPnlTg2.js → dagre-6A5THRUB-TqYCI1E1.js} +1 -1
- package/dist/assets/{diagram-22UHCM2B-Cm6xcMiz.js → diagram-22UHCM2B-TeOt3rAQ.js} +1 -1
- package/dist/assets/{diagram-3UASUU5V-CDpogPbE.js → diagram-3UASUU5V-D70BLhDA.js} +1 -1
- package/dist/assets/{diagram-ATOU4E4O-BV8z3Pnc.js → diagram-ATOU4E4O-BbQDLwUC.js} +1 -1
- package/dist/assets/{diagram-CDSNMT55-CKCQhOOC.js → diagram-CDSNMT55-ChNJZ1u-.js} +1 -1
- package/dist/assets/{diagram-MLGK6HIB-FGvJg69C.js → diagram-MLGK6HIB-DMBl2APa.js} +1 -1
- package/dist/assets/{diagram-MPIPVDR6-CwATvgnS.js → diagram-MPIPVDR6-CEqDchOp.js} +1 -1
- package/dist/assets/{ebnfDiagram-ZINNZB2B-CH_c3SSC.js → ebnfDiagram-ZINNZB2B-BVDKRp_-.js} +1 -1
- package/dist/assets/{elk-276RUBZZ-3guZi71R.js → elk-276RUBZZ-CRkofO9B.js} +1 -1
- package/dist/assets/{erDiagram-OPXOYQCR-BMHphR2n.js → erDiagram-OPXOYQCR-DruOMh5e.js} +1 -1
- package/dist/assets/{flowDiagram-KWPJA3E3-Vm5DkW5x.js → flowDiagram-KWPJA3E3-8GMQeB7t.js} +1 -1
- package/dist/assets/{ganttDiagram-FUAMR5RP-DO-1ct07.js → ganttDiagram-FUAMR5RP-ORE9jcsM.js} +1 -1
- package/dist/assets/{gitGraphDiagram-X574FWY7-nxeksEIN.js → gitGraphDiagram-X574FWY7-BmsedsWh.js} +1 -1
- package/dist/assets/index-BlnCEcU6.js +588 -0
- package/dist/assets/{index-Oxk9x45i.js → index-DKNEM8Q6.js} +1 -1
- package/dist/assets/index-DVxjOvID.css +1 -0
- package/dist/assets/{infoDiagram-VRGFBTTK-Dkcvr0p7.js → infoDiagram-VRGFBTTK-E_p7AooJ.js} +1 -1
- package/dist/assets/{ishikawaDiagram-OU5B5YK6-BwVibkoO.js → ishikawaDiagram-OU5B5YK6-Y-B2mKda.js} +1 -1
- package/dist/assets/{journeyDiagram-ZHPQQLJL-ByiBZqgG.js → journeyDiagram-ZHPQQLJL-eA8RELcK.js} +1 -1
- package/dist/assets/{kanban-definition-PNTS6WVX-qqjgXBF3.js → kanban-definition-PNTS6WVX-DqIL2e8e.js} +1 -1
- package/dist/assets/{linear-CtJnKe6A.js → linear-yC_29R6e.js} +1 -1
- package/dist/assets/{mermaid.core-BEiDklrY.js → mermaid.core-VhEMkSND.js} +5 -5
- package/dist/assets/{mindmap-definition-NLK3R4M7-DfIYHia9.js → mindmap-definition-NLK3R4M7-oJ7kyQZy.js} +1 -1
- package/dist/assets/{pegDiagram-GJSIUBJH-CYyRqa1a.js → pegDiagram-GJSIUBJH-BmfYcY0O.js} +1 -1
- package/dist/assets/{pieDiagram-5QR66LMP-hOToy_zH.js → pieDiagram-5QR66LMP-DKvgP7w9.js} +1 -1
- package/dist/assets/{quadrantDiagram-O4NWA36T-DKAsNN9I.js → quadrantDiagram-O4NWA36T-BEKL3W0L.js} +1 -1
- package/dist/assets/{railroadDiagram-XR7U4H2S-BSA-d8JU.js → railroadDiagram-XR7U4H2S-BEsiqR5s.js} +1 -1
- package/dist/assets/{requirementDiagram-PLB6GJNP-CSmlvb-p.js → requirementDiagram-PLB6GJNP-DWBcWvAP.js} +1 -1
- package/dist/assets/{sankeyDiagram-IPEJSGJF-CD5F6fnG.js → sankeyDiagram-IPEJSGJF-rpZ2_O4s.js} +1 -1
- package/dist/assets/{sequenceDiagram-PO4LG4MO-JUq5sx6d.js → sequenceDiagram-PO4LG4MO-B99HQdJR.js} +1 -1
- package/dist/assets/{sizeCapture-INFHLROL-BjIS-Stk.js → sizeCapture-INFHLROL-BjN0qT5T.js} +1 -1
- package/dist/assets/{stateDiagram-v2-GCMORJYK-DklkUxOg.js → stateDiagram-v2-GCMORJYK-BbsVgGsC.js} +1 -1
- package/dist/assets/{swimlanes-2SLR337P-CsjIU6be.js → swimlanes-2SLR337P-DG3gKkeR.js} +1 -1
- package/dist/assets/swimlanesDiagram-TC7HE7FX-BCZhxl7e.js +8 -0
- package/dist/assets/{timeline-definition-EJHVYXUP-BkFqK7AB.js → timeline-definition-EJHVYXUP-BTHOdtF_.js} +1 -1
- package/dist/assets/{usecaseDiagram-POWQR4AR-70gJB1EU.js → usecaseDiagram-POWQR4AR-acLSLFwc.js} +1 -1
- package/dist/assets/{vennDiagram-UO4OBE2U-83Tm62dN.js → vennDiagram-UO4OBE2U-C5JzNX9G.js} +1 -1
- package/dist/assets/{wardleyDiagram-VNRHLVJA-tTmseoX9.js → wardleyDiagram-VNRHLVJA-DL1-PrR_.js} +1 -1
- package/dist/assets/{xychartDiagram-PMCCYNJV-R1LBA55a.js → xychartDiagram-PMCCYNJV-Bvr1477p.js} +1 -1
- package/dist/index.html +2 -2
- package/dist-server/cloud-home-start.js +14810 -0
- package/dist-server/container-mcp.js +136 -36
- package/dist-server/drivers/agents-proxy.js +31 -10
- package/dist-server/index.js +48251 -44124
- package/dist-server/openmausbot.js +628 -292
- package/dist-server/pair-cli.js +628 -292
- package/dist-server/permission-proxy.js +1 -1
- package/dist-server/server/admission.js +46 -0
- package/dist-server/server/{box-create-idempotency.js → boat-create-idempotency.js} +32 -31
- package/dist-server/server/{box-delete-journal.js → boat-delete-journal.js} +26 -25
- package/dist-server/server/{box.js → boat.js} +381 -334
- package/dist-server/server/bot-overview.js +7 -4
- package/dist-server/server/bot-profile.js +7 -1
- package/dist-server/server/channel-queue.js +58 -18
- package/dist-server/server/checkpoints.js +98 -52
- package/dist-server/server/claim-idle.js +33 -0
- package/dist-server/server/cloud-backend.js +5 -5
- package/dist-server/server/cloud-home-start.js +121 -0
- package/dist-server/server/cloud-home.js +241 -0
- package/dist-server/server/cloud-lending.js +103 -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-overflow.js +167 -0
- package/dist-server/server/composio.js +5 -0
- package/dist-server/server/computer-observation.js +2 -2
- package/dist-server/server/config.js +156 -9
- package/dist-server/server/container-computer.js +13 -0
- package/dist-server/server/control-client.js +2 -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/delegations.js +11 -3
- package/dist-server/server/drivers/acp/core.js +5 -4
- package/dist-server/server/drivers/agents-call.js +5 -1
- package/dist-server/server/drivers/agents-catalog.js +25 -3
- package/dist-server/server/drivers/{boxagent.js → boatagent.js} +30 -21
- package/dist-server/server/drivers/builtIn.js +2 -2
- package/dist-server/server/drivers/{chat-box-tools.js → chat-boat-tools.js} +3 -3
- package/dist-server/server/drivers/chat-mcp-tools.js +2 -2
- package/dist-server/server/drivers/claude.js +178 -13
- package/dist-server/server/drivers/native.js +1 -1
- package/dist-server/server/drivers/openai-chat.js +15 -4
- package/dist-server/server/drivers/pi.js +3 -3
- package/dist-server/server/group-goal-run.js +6 -4
- package/dist-server/server/harness-capabilities.js +2 -0
- package/dist-server/server/hosted-models.js +6 -1
- package/dist-server/server/included-services.js +70 -0
- package/dist-server/server/index.js +1462 -576
- package/dist-server/server/local-vm-inventory.js +11 -1
- package/dist-server/server/local-vm-seat-pool.js +141 -0
- package/dist-server/server/managed-policy.js +4 -1
- package/dist-server/server/memory-capture.js +194 -0
- package/dist-server/server/memory-entries.js +76 -0
- package/dist-server/server/memory-organize.js +102 -0
- package/dist-server/server/memory-tidy.js +144 -0
- package/dist-server/server/memory-topics.js +169 -0
- package/dist-server/server/memory-upkeep.js +452 -0
- package/dist-server/server/message-db.js +49 -5
- 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/permission-proxy.js +3 -3
- package/dist-server/server/profile-learned.js +98 -0
- package/dist-server/server/profile-versions.js +45 -11
- package/dist-server/server/recall.js +172 -0
- package/dist-server/server/redact.js +1 -1
- package/dist-server/server/request-auth.js +2 -0
- package/dist-server/server/room-cwd.js +1 -1
- package/dist-server/server/room-handoffs.js +61 -6
- package/dist-server/server/routes/bot-memory.js +215 -0
- package/dist-server/server/routes/decider.js +19 -0
- package/dist-server/server/routine-requests.js +27 -16
- package/dist-server/server/routines.js +10 -6
- package/dist-server/server/shared-computers.js +116 -30
- package/dist-server/server/steer-queue.js +41 -10
- package/dist-server/server/store.js +42 -3
- package/dist-server/server/surface.js +14 -1
- package/dist-server/server/system-prompt.js +16 -5
- package/dist-server/server/team-backup.js +6 -2
- package/dist-server/server/team-computers.js +2 -2
- package/dist-server/server/team-manifest.js +11 -3
- package/dist-server/server/tightening-requests.js +34 -4
- package/dist-server/server/tool-summary.js +2 -2
- package/dist-server/server/tts/elevenlabs.js +12 -7
- package/dist-server/server/tts/index.js +17 -10
- package/dist-server/server/turn-resources.js +87 -11
- package/dist-server/server/vps-computer.js +1 -1
- package/dist-server/server/workspace-backup-policy.js +22 -3
- package/dist-server/server/workspace-backup.js +25 -8
- package/dist-server/server/workspace.js +57 -22
- package/dist-server/shared/ask-question.js +7 -5
- package/dist-server/shared/bot-avatar.js +19 -0
- package/dist-server/shared/cloud-home.js +4 -0
- package/dist-server/shared/credential-request.js +3 -3
- package/dist-server/shared/package-format.js +30 -4
- package/dist-server/shared/team-backup.js +20 -3
- package/dist-server/shared/tool-surface.js +1 -1
- package/dist-server/src/lib/composer-attachments.js +9 -0
- package/dist-server/vps-container-mcp.js +136 -36
- package/dist-server/workspace-backup.worker.js +165 -35
- package/package.json +1 -1
- package/dist/assets/channel-DqY_gMLg.js +0 -1
- package/dist/assets/index-BIMdIbaw.css +0 -1
- package/dist/assets/index-BVPdbq2r.js +0 -583
- package/dist/assets/swimlanesDiagram-TC7HE7FX-Bk14M0_B.js +0 -8
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// OMB Cloud Pro home machine: the boot contract, the places it offers, the
|
|
2
|
+
// Admin's signed pairing request, and the volume the machine lives on. docs/cloud-pro.md is the
|
|
3
|
+
// contract of record (and openmaus-cloud docs/consumer-cloud.md its Admin
|
|
4
|
+
// half); keep them in step.
|
|
5
|
+
//
|
|
6
|
+
// One Fly app per customer runs this server behind an edge proxy on
|
|
7
|
+
// 0.0.0.0:8080 (server/cloud-home-start.ts). The server itself still binds
|
|
8
|
+
// 127.0.0.1 and every request reaches it with X-Forwarded-* headers, so no
|
|
9
|
+
// request from the network is ever the loopback owner.
|
|
10
|
+
//
|
|
11
|
+
// The Admin never holds a session on the machine. It signs a request with
|
|
12
|
+
// the machine's bootstrap secret (HMAC-SHA256 over the method, path, a
|
|
13
|
+
// timestamp, a nonce and the body's hash); each valid request opens one
|
|
14
|
+
// ordinary pairing window (sessions.ts: single use, at most ten minutes),
|
|
15
|
+
// which the Admin hands to the person's signed-in desktop app.
|
|
16
|
+
//
|
|
17
|
+
// Cloud Pro includes no AI. The person signs in on the machine with their own
|
|
18
|
+
// Claude or ChatGPT account, or an API key, exactly as on any server; nothing
|
|
19
|
+
// on a Cloud home is ever routed to a platform model gateway.
|
|
20
|
+
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
21
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { writeFileAtomic } from "./atomic.js";
|
|
24
|
+
import { hostedWorkspaceConfigured } from "./enterprise.js";
|
|
25
|
+
import { formatPairingCode } from "./sessions.js";
|
|
26
|
+
export const CLOUD_HOME_CONTRACT_VERSION = 1;
|
|
27
|
+
/** Any of these switches the server into Cloud home mode; then all are required. */
|
|
28
|
+
export const CLOUD_HOME_KEYS = ["OMB_CLOUD_ROLE", "OMB_CLOUD_MACHINE_ID", "OMB_CLOUD_ADMIN_URL", "OMB_CLOUD_BOOTSTRAP_SECRET"];
|
|
29
|
+
/** A platform model gateway's settings. A Cloud home never uses them: given
|
|
30
|
+
* any, it logs one warning, and neither the server nor anything it starts
|
|
31
|
+
* ever sees them. */
|
|
32
|
+
export const CLOUD_IGNORED_KEYS = ["OMB_HOSTED_MODEL_URL", "OMB_HOSTED_MODEL_TOKEN", "OMB_HOSTED_MODELS"];
|
|
33
|
+
export const CLOUD_PAIRING_PATH = "/api/cloud/pairing";
|
|
34
|
+
export const CLOUD_PAIRING_DEFAULT_TTL_S = 300;
|
|
35
|
+
export const CLOUD_PAIRING_MAX_TTL_S = 600;
|
|
36
|
+
/** How far a signed request's timestamp may be from this machine's clock. */
|
|
37
|
+
export const CLOUD_PAIRING_SKEW_S = 300;
|
|
38
|
+
/** How long a used nonce is refused. Longer than the whole accepted window. */
|
|
39
|
+
export const CLOUD_PAIRING_NONCE_MS = 10 * 60_000;
|
|
40
|
+
const MAX_NONCES = 10_000;
|
|
41
|
+
export const CLOUD_HOME_MARKER = ".omb-cloud-home.json";
|
|
42
|
+
/** The server exits with this after a restore commits (Move to Cloud): the
|
|
43
|
+
* launcher then starts it again, and startup installs the restore. Any other
|
|
44
|
+
* exit stops the machine for Fly to restart. */
|
|
45
|
+
export const CLOUD_HOME_RESTART_EXIT_CODE = 75;
|
|
46
|
+
export function cloudHomeConfigured(env = process.env) {
|
|
47
|
+
return CLOUD_HOME_KEYS.some((key) => env[key] !== undefined);
|
|
48
|
+
}
|
|
49
|
+
function invalid(why) {
|
|
50
|
+
throw new Error(`Cloud home configuration is invalid: ${why}.`);
|
|
51
|
+
}
|
|
52
|
+
function exactHttpsOrigin(raw, name) {
|
|
53
|
+
let url;
|
|
54
|
+
try {
|
|
55
|
+
if (!raw || raw !== raw.trim())
|
|
56
|
+
throw new Error();
|
|
57
|
+
url = new URL(raw);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return invalid(`${name} must be an https origin`);
|
|
61
|
+
}
|
|
62
|
+
if (url.protocol !== "https:" || url.username || url.password || (raw !== url.origin && raw !== `${url.origin}/`)) {
|
|
63
|
+
invalid(`${name} must be an exact https origin with no path`);
|
|
64
|
+
}
|
|
65
|
+
return url.origin;
|
|
66
|
+
}
|
|
67
|
+
/** Read the boot contract. Null on any machine that is not a Cloud home;
|
|
68
|
+
* throws (so the server refuses to start) on a partial or invalid one. */
|
|
69
|
+
export function cloudHomeConfiguration(env = process.env) {
|
|
70
|
+
if (!cloudHomeConfigured(env))
|
|
71
|
+
return null;
|
|
72
|
+
if (env.OMB_DESKTOP_PARENT === "1")
|
|
73
|
+
invalid("the desktop app cannot run as a Cloud home machine");
|
|
74
|
+
if (hostedWorkspaceConfigured(env))
|
|
75
|
+
invalid("a Cloud home is not a hosted team workspace; remove OMB_ADMIN_URL, OMB_ADMIN_WORKSPACE and OMB_ADMIN_MEMBERSHIP");
|
|
76
|
+
if (env.OMB_CLOUD_ROLE !== "home")
|
|
77
|
+
invalid('OMB_CLOUD_ROLE must be "home"; this image runs the home machine');
|
|
78
|
+
const machineId = env.OMB_CLOUD_MACHINE_ID ?? "";
|
|
79
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{2,127}$/.test(machineId))
|
|
80
|
+
invalid("OMB_CLOUD_MACHINE_ID must be the Admin's machine id (letters, digits, dashes)");
|
|
81
|
+
const adminOrigin = exactHttpsOrigin(env.OMB_CLOUD_ADMIN_URL, "OMB_CLOUD_ADMIN_URL");
|
|
82
|
+
const publicOrigin = exactHttpsOrigin(env.OMB_PUBLIC_URL, "OMB_PUBLIC_URL");
|
|
83
|
+
const bootstrapSecret = env.OMB_CLOUD_BOOTSTRAP_SECRET ?? "";
|
|
84
|
+
if (!/^[A-Za-z0-9_-]{43,128}$/.test(bootstrapSecret))
|
|
85
|
+
invalid("OMB_CLOUD_BOOTSTRAP_SECRET must be at least 256 bits of base64url");
|
|
86
|
+
const warnings = [];
|
|
87
|
+
const ignored = CLOUD_IGNORED_KEYS.filter((key) => env[key] !== undefined);
|
|
88
|
+
if (ignored.length)
|
|
89
|
+
warnings.push(`ignoring ${ignored.join(", ")}: Cloud Pro includes no AI; people sign in with their own Claude or ChatGPT account, or an API key`);
|
|
90
|
+
return { machineId, adminOrigin, publicOrigin, bootstrapSecret, warnings };
|
|
91
|
+
}
|
|
92
|
+
/** The environment without a platform gateway's settings (CLOUD_IGNORED_KEYS). */
|
|
93
|
+
export function withoutIgnoredCloudKeys(env) {
|
|
94
|
+
const kept = { ...env };
|
|
95
|
+
for (const key of CLOUD_IGNORED_KEYS)
|
|
96
|
+
delete kept[key];
|
|
97
|
+
return kept;
|
|
98
|
+
}
|
|
99
|
+
// The places a Cloud home offers live in shared/cloud-home.ts, so the app
|
|
100
|
+
// lists exactly what the server accepts.
|
|
101
|
+
export { cloudHomeOffersPlace } from "../shared/cloud-home.js";
|
|
102
|
+
/** Why a Cloud home refuses a place it never offers (no "this computer" of
|
|
103
|
+
* the person's, no Local VM), in the words the person reads; undefined for a
|
|
104
|
+
* place it offers. A turn's error shows 160 characters, so each fits. */
|
|
105
|
+
export function cloudHomePlaceRefusal(place) {
|
|
106
|
+
if (place === "local")
|
|
107
|
+
return "This computer isn't a place on your OMB Cloud: its bots run in the cloud. Set Works on to Auto, Cloud or Browser, or lend your Mac under Settings → OMB Cloud.";
|
|
108
|
+
if (place === "vm")
|
|
109
|
+
return "Bots on your OMB Cloud can't use a Local VM: the cloud machine has no container runtime. Set Works on to Auto, Cloud or Browser.";
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
/** What a turn is told when Cloud is chosen but no Boat account is set up (no
|
|
113
|
+
* key of the person's and no included Boat). A Cloud home has no Local VM to
|
|
114
|
+
* suggest instead. */
|
|
115
|
+
export function boatNotConfiguredMessage(cloudHome) {
|
|
116
|
+
return `Cloud Boat is not configured — add a Boat API key or choose ${cloudHome ? "Browser" : "Local VM"}`;
|
|
117
|
+
}
|
|
118
|
+
/** The Admin's side of the signature (openmaus-cloud cloudPairingSignature). */
|
|
119
|
+
export function cloudPairingSignature(secret, timestamp, nonce, body) {
|
|
120
|
+
const bodyHash = createHash("sha256").update(body).digest("base64url");
|
|
121
|
+
return createHmac("sha256", secret).update(`v1\n${timestamp}\n${nonce}\nPOST\n${CLOUD_PAIRING_PATH}\n${bodyHash}`).digest("base64url");
|
|
122
|
+
}
|
|
123
|
+
/** The Admin's signed request for one pairing window on this machine. */
|
|
124
|
+
export function createCloudPairing(options) {
|
|
125
|
+
const { secret, sessions } = options;
|
|
126
|
+
const now = options.now ?? Date.now;
|
|
127
|
+
const nonces = new Map();
|
|
128
|
+
const refused = (error) => ({ status: 401, body: { error } });
|
|
129
|
+
return {
|
|
130
|
+
/** Everything is checked before anything is opened: the signature (in
|
|
131
|
+
* constant time; a bad one counts toward the pairing lockout), then the
|
|
132
|
+
* clock, then the nonce, then the body. Nothing here is ever logged. */
|
|
133
|
+
handle(input) {
|
|
134
|
+
const lock = sessions.attemptAllowed(input.source);
|
|
135
|
+
if (!lock.ok)
|
|
136
|
+
return { status: 429, body: { error: "rate_limited", retryAfterSeconds: Math.ceil(lock.retryAfterMs / 1000) } };
|
|
137
|
+
const timestamp = input.timestamp ?? "", nonce = input.nonce ?? "";
|
|
138
|
+
const presented = /^v1=([A-Za-z0-9_-]{43})$/.exec(input.signature ?? "")?.[1];
|
|
139
|
+
const expected = Buffer.from(cloudPairingSignature(secret, timestamp, nonce, input.body), "base64url");
|
|
140
|
+
const given = presented ? Buffer.from(presented, "base64url") : Buffer.alloc(expected.length);
|
|
141
|
+
const matches = given.length === expected.length && timingSafeEqual(given, expected);
|
|
142
|
+
if (!presented || !matches || !/^\d{1,12}$/.test(timestamp) || !/^[A-Za-z0-9_-]{16,128}$/.test(nonce)) {
|
|
143
|
+
sessions.noteFailure(input.source);
|
|
144
|
+
return refused("invalid_signature");
|
|
145
|
+
}
|
|
146
|
+
sessions.clearFailures(input.source);
|
|
147
|
+
const at = now();
|
|
148
|
+
if (Math.abs(at / 1000 - Number(timestamp)) > CLOUD_PAIRING_SKEW_S)
|
|
149
|
+
return refused("stale_request");
|
|
150
|
+
for (const [seen, until] of nonces)
|
|
151
|
+
if (until <= at)
|
|
152
|
+
nonces.delete(seen);
|
|
153
|
+
if (nonces.has(nonce))
|
|
154
|
+
return refused("replayed_request");
|
|
155
|
+
if (nonces.size >= MAX_NONCES)
|
|
156
|
+
return { status: 503, body: { error: "busy" } };
|
|
157
|
+
nonces.set(nonce, at + CLOUD_PAIRING_NONCE_MS);
|
|
158
|
+
let parsed;
|
|
159
|
+
try {
|
|
160
|
+
parsed = input.body.length ? JSON.parse(input.body.toString("utf8")) : {};
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return { status: 400, body: { error: "invalid_body" } };
|
|
164
|
+
}
|
|
165
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
166
|
+
return { status: 400, body: { error: "invalid_body" } };
|
|
167
|
+
const { label, ttlSeconds } = parsed;
|
|
168
|
+
// oxlint-disable-next-line no-control-regex
|
|
169
|
+
if (label !== undefined && (typeof label !== "string" || label.length > 80 || /[\x00-\x1f\x7f]/.test(label)))
|
|
170
|
+
return { status: 400, body: { error: "invalid_label" } };
|
|
171
|
+
if (ttlSeconds !== undefined && (!Number.isSafeInteger(ttlSeconds) || ttlSeconds < 1))
|
|
172
|
+
return { status: 400, body: { error: "invalid_ttl" } };
|
|
173
|
+
const ttl = Math.min(ttlSeconds ?? CLOUD_PAIRING_DEFAULT_TTL_S, CLOUD_PAIRING_MAX_TTL_S);
|
|
174
|
+
const opened = sessions.openPairing({
|
|
175
|
+
scopes: ["admin", "client"],
|
|
176
|
+
label: typeof label === "string" && label.trim() ? label.trim() : "OMB Cloud",
|
|
177
|
+
ttlMs: ttl * 1000,
|
|
178
|
+
});
|
|
179
|
+
return { status: 200, body: { code: formatPairingCode(opened.code), credential: opened.credential, expiresAt: opened.expiresAt } };
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
/** The exact bytes the Admin signed, bounded. */
|
|
184
|
+
export function readSignedBody(req, limit = 4096) {
|
|
185
|
+
return new Promise((resolve, reject) => {
|
|
186
|
+
const chunks = [];
|
|
187
|
+
let size = 0, done = false;
|
|
188
|
+
req.on("data", (chunk) => {
|
|
189
|
+
if (done)
|
|
190
|
+
return;
|
|
191
|
+
size += chunk.length;
|
|
192
|
+
if (size > limit) {
|
|
193
|
+
done = true;
|
|
194
|
+
reject(Object.assign(new Error("body too large"), { status: 413 }));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
chunks.push(chunk);
|
|
198
|
+
});
|
|
199
|
+
req.on("end", () => { if (!done) {
|
|
200
|
+
done = true;
|
|
201
|
+
resolve(Buffer.concat(chunks));
|
|
202
|
+
} });
|
|
203
|
+
req.on("error", (error) => { if (!done) {
|
|
204
|
+
done = true;
|
|
205
|
+
reject(Object.assign(error, { status: 400 }));
|
|
206
|
+
} });
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
// The image's home directory may carry the shell files useradd seeds; a
|
|
210
|
+
// fresh Fly volume carries lost+found. Anything else is someone's data.
|
|
211
|
+
const ADOPTABLE = new Set(["lost+found", ".bash_logout", ".bashrc", ".profile"]);
|
|
212
|
+
/** Bind the volume to this machine on first boot and refuse any other
|
|
213
|
+
* machine's volume afterwards. Never deletes or rewrites existing data. */
|
|
214
|
+
export function prepareCloudHomeVolume(home, machineId) {
|
|
215
|
+
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
216
|
+
const marker = join(home, CLOUD_HOME_MARKER);
|
|
217
|
+
if (existsSync(marker)) {
|
|
218
|
+
const stat = lstatSync(marker);
|
|
219
|
+
let saved;
|
|
220
|
+
try {
|
|
221
|
+
if (!stat.isFile() || stat.size > 4096)
|
|
222
|
+
throw new Error();
|
|
223
|
+
saved = JSON.parse(readFileSync(marker, "utf8"));
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
throw new Error("This volume's Cloud marker is unreadable. No data was changed.");
|
|
227
|
+
}
|
|
228
|
+
if (saved?.machine !== machineId)
|
|
229
|
+
throw new Error("This volume belongs to another Cloud machine. No data was changed.");
|
|
230
|
+
return "existing";
|
|
231
|
+
}
|
|
232
|
+
if (readdirSync(home).some((name) => !ADOPTABLE.has(name))) {
|
|
233
|
+
throw new Error("Refusing to adopt a data volume that was not made for this Cloud machine. No data was changed.");
|
|
234
|
+
}
|
|
235
|
+
writeFileAtomic(marker, JSON.stringify({ version: 1, machine: machineId }) + "\n", { mode: 0o600 });
|
|
236
|
+
return "new";
|
|
237
|
+
}
|
|
238
|
+
/** What the edge proxy may answer for: the machine's own public name. */
|
|
239
|
+
export function cloudHomeHost(config) {
|
|
240
|
+
return new URL(config.publicOrigin).hostname;
|
|
241
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Who may use a Mac lent to an OMB Cloud home (docs/cloud-pro.md, "Let my
|
|
2
|
+
// Cloud use this Mac"). A Cloud home is one person's server, but not every
|
|
3
|
+
// turn on it is theirs: a guest the owner paired, a webhook's payload, or a
|
|
4
|
+
// line someone else slipped into a running turn must never reach the Mac.
|
|
5
|
+
//
|
|
6
|
+
// A turn may use the lent Mac only when the harness can prove it acts for the
|
|
7
|
+
// owner:
|
|
8
|
+
// - a conversation the owner started from one of their own devices (a live
|
|
9
|
+
// session with admin scope), or
|
|
10
|
+
// - a scheduled run of a routine whose instructions the owner wrote (created
|
|
11
|
+
// or edited from one of those devices), or a run the owner started by hand;
|
|
12
|
+
// and the conversation holds nobody else's words, ever: a resumed session
|
|
13
|
+
// carries everything said in it, so one line (or card answer) from a guest,
|
|
14
|
+
// a teammate bot or a local process, before or during the turn, takes that
|
|
15
|
+
// conversation out of lending. The owner starts a new one to lend again.
|
|
16
|
+
// Webhook runs, guests, rooms, a bot's delegated or peer turn, and anything
|
|
17
|
+
// unprovable never can.
|
|
18
|
+
// Everything here is pure; server/index.ts supplies the records.
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
import { existsSync, lstatSync, readFileSync } from "node:fs";
|
|
21
|
+
import { z } from "zod";
|
|
22
|
+
import { writeFileAtomic } from "./atomic.js";
|
|
23
|
+
const personOf = (line) => line?.role === "user" && !line.peerAsk ? line.sender?.id : undefined;
|
|
24
|
+
export function cloudHomeTurnMayLend(turn) {
|
|
25
|
+
return cloudHomeLendingRefusal(turn) === null;
|
|
26
|
+
}
|
|
27
|
+
export function cloudHomeLendingRefusal(turn) {
|
|
28
|
+
const { request } = turn;
|
|
29
|
+
if (!request?.messageId || request.stopped || !request.generations.has(turn.generation))
|
|
30
|
+
return "unproven";
|
|
31
|
+
const index = turn.thread.findIndex(line => line.id === request.messageId);
|
|
32
|
+
if (index < 0)
|
|
33
|
+
return "unproven";
|
|
34
|
+
let owner;
|
|
35
|
+
if (request.automation === undefined) {
|
|
36
|
+
owner = turn.ownerPerson(personOf(turn.thread[index]));
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
const run = turn.routineRun();
|
|
40
|
+
owner = request.automation !== "webhook" && Boolean(run) && run.triggerSource !== "webhook" &&
|
|
41
|
+
run.ownerAuthored && (run.triggerSource !== "manual" || run.ownerStarted);
|
|
42
|
+
}
|
|
43
|
+
if (!owner)
|
|
44
|
+
return "not-owner";
|
|
45
|
+
// Nothing anyone else wrote (a guest, a teammate bot, a local process) may
|
|
46
|
+
// be anywhere in this conversation, before or after the request, as a line
|
|
47
|
+
// (sent, queued, steered or handed in) or as the answer to a card. A
|
|
48
|
+
// routine's own prompt line names nobody; its provenance was proven above.
|
|
49
|
+
const clean = turn.thread.every((line, at) => (line.role !== "user" || (at === index && request.automation !== undefined) || turn.ownerPerson(personOf(line))) &&
|
|
50
|
+
(!answeredByPerson(line.card) || turn.ownerPerson(turn.cardAnswerer(line.card))));
|
|
51
|
+
return clean ? null : "someone-else";
|
|
52
|
+
}
|
|
53
|
+
/** A card someone answered: a person's verdict or words. The harness's own
|
|
54
|
+
* settlements (automatic approvals, a turn that ended) are marked dismissed
|
|
55
|
+
* or unavailable and carry nobody's words. */
|
|
56
|
+
function answeredByPerson(card) {
|
|
57
|
+
if (!card)
|
|
58
|
+
return false;
|
|
59
|
+
return (Boolean(card.answered) && card.answered !== "unavailable" && card.dismissed !== true) || Boolean(card.answeredText);
|
|
60
|
+
}
|
|
61
|
+
/** What a routine run does and when, reduced to what its author decides:
|
|
62
|
+
* the instructions, where and on what it runs, and its schedule. The
|
|
63
|
+
* scheduler never rewrites these (it does rewrite a routine's results thread
|
|
64
|
+
* on each run, so that is guarded by who edits the routine instead: see the
|
|
65
|
+
* routines PATCH route). An edit by anyone else changes the fingerprint. */
|
|
66
|
+
export function routineFingerprint(routine) {
|
|
67
|
+
const attachments = (routine.attachments ?? []).map(attachment => [attachment.id, attachment.path]);
|
|
68
|
+
return createHash("sha256").update(JSON.stringify([
|
|
69
|
+
routine.prompt ?? "", routine.target ?? null, routine.botId ?? "", routine.groupId ?? null, attachments,
|
|
70
|
+
routine.schedule ?? null, routine.runOn ?? null,
|
|
71
|
+
])).digest("hex");
|
|
72
|
+
}
|
|
73
|
+
const authorsFile = z.object({ version: z.literal(1), routines: z.record(z.string().max(128), z.string().regex(/^[a-f0-9]{64}$/)) }).strict();
|
|
74
|
+
/** Which routines the owner wrote, as the fingerprint of what they wrote.
|
|
75
|
+
* Persisted beside the routines (owner-only). A missing or damaged file
|
|
76
|
+
* means none: it never grants anything by being absent. */
|
|
77
|
+
export function createCloudRoutineAuthors(file) {
|
|
78
|
+
let routines = {};
|
|
79
|
+
try {
|
|
80
|
+
if (existsSync(file)) {
|
|
81
|
+
const stat = lstatSync(file);
|
|
82
|
+
if (stat.isFile() && !stat.isSymbolicLink() && stat.size <= 1_000_000)
|
|
83
|
+
routines = authorsFile.parse(JSON.parse(readFileSync(file, "utf8"))).routines;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
routines = {};
|
|
88
|
+
}
|
|
89
|
+
const save = () => writeFileAtomic(file, JSON.stringify({ version: 1, routines }), { mode: 0o600 });
|
|
90
|
+
return {
|
|
91
|
+
/** The owner wrote this routine as it stands now. */
|
|
92
|
+
record(id, routine) { routines = { ...routines, [id]: routineFingerprint(routine) }; save(); },
|
|
93
|
+
forget(id) { if (Object.hasOwn(routines, id)) {
|
|
94
|
+
const next = { ...routines };
|
|
95
|
+
delete next[id];
|
|
96
|
+
routines = next;
|
|
97
|
+
save();
|
|
98
|
+
} },
|
|
99
|
+
/** Whether this routine, as it stands (or as a run snapshotted it), is
|
|
100
|
+
* exactly what the owner wrote. */
|
|
101
|
+
authored(id, routine) { return Object.hasOwn(routines, id) && routines[id] === routineFingerprint(routine); },
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { backupsBytes, beginUpload, CLOUD_MOVE_MAX_BYTES, CLOUD_MOVE_MAX_PART_BYTES, CLOUD_MOVE_PART_BYTES, completedUpload, discardNextPreviousCloud, discardUpload, forgetMoveRestore, freeVolumeBytes, isEmptyWorkspace, moveSpaceNeeded, noteMoveRestore, prepareNextPreviousCloud, previousCloud, previousCloudArchiveBytes, removeMoveFiles, stagePreviousCloud, tidyCloudMoveStorage, uploadStatus, validUploadDeclaration, workspaceContents, workspaceMoveSize, writeUploadPart, } from "./cloud-move.js";
|
|
3
|
+
import { commitPendingWorkspaceRestore, createWorkspaceBackup, stageWorkspaceBackup } from "./workspace-backup.js";
|
|
4
|
+
export const CLOUD_MOVE_PREFIX = "/api/cloud-move";
|
|
5
|
+
const failure = (message, status) => Object.assign(new Error(message), { status });
|
|
6
|
+
const HELD_CACHE_MS = 30_000;
|
|
7
|
+
export function createCloudMoveRoutes(options) {
|
|
8
|
+
const freeBytes = options.freeBytes ?? freeVolumeBytes;
|
|
9
|
+
// A move's restore that startup has installed keeps no safety copy or
|
|
10
|
+
// staged files; an abandoned upload goes after a day.
|
|
11
|
+
if (options.cloudHome) {
|
|
12
|
+
try {
|
|
13
|
+
tidyCloudMoveStorage(options.dataDir, options.restored);
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
console.warn(`cloud move: could not tidy backup storage (${error instanceof Error ? error.message : "unknown"})`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
// Staged here by a preview; restore accepts nothing else.
|
|
20
|
+
const staged = new Set();
|
|
21
|
+
let job = null;
|
|
22
|
+
let writing = false, discardPreview = false;
|
|
23
|
+
let held = null;
|
|
24
|
+
const json = (res, status, body) => {
|
|
25
|
+
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
|
|
26
|
+
res.end(JSON.stringify(body));
|
|
27
|
+
};
|
|
28
|
+
const check = (req, auth) => {
|
|
29
|
+
if (!options.authorized(req, auth))
|
|
30
|
+
throw failure("Your session changed. Start the move again.", 403);
|
|
31
|
+
};
|
|
32
|
+
const ready = () => {
|
|
33
|
+
if (job?.state === "running" || writing)
|
|
34
|
+
throw failure("Another move step is running on your Cloud. Wait for it to finish.", 409);
|
|
35
|
+
const status = options.status();
|
|
36
|
+
if (status.pendingRestore)
|
|
37
|
+
throw failure("Your Cloud is restarting to finish a restore. Try again in a minute.", 409);
|
|
38
|
+
if (status.busy)
|
|
39
|
+
throw failure("Your Cloud is busy with a backup. Try again when it finishes.", 409);
|
|
40
|
+
};
|
|
41
|
+
/** Delete what earlier previews staged and no restore took. */
|
|
42
|
+
const dropStaged = () => {
|
|
43
|
+
for (const id of staged)
|
|
44
|
+
removeMoveFiles(options.dataDir, id);
|
|
45
|
+
staged.clear();
|
|
46
|
+
if (job?.kind === "preview" && job.state === "done")
|
|
47
|
+
job = null;
|
|
48
|
+
};
|
|
49
|
+
function start(kind, work, after) {
|
|
50
|
+
const current = { kind, state: "running" };
|
|
51
|
+
job = current;
|
|
52
|
+
held = null;
|
|
53
|
+
void work().then((result) => {
|
|
54
|
+
if (job !== current)
|
|
55
|
+
return;
|
|
56
|
+
job = result;
|
|
57
|
+
after?.(result);
|
|
58
|
+
}, (error) => {
|
|
59
|
+
if (job === current)
|
|
60
|
+
job = { kind, state: "failed", error: error instanceof Error ? error.message : "The move could not finish." };
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
// A person's own page can hold the workspace gate for a moment (an ordinary
|
|
64
|
+
// request in flight); try the gate a few times before giving up.
|
|
65
|
+
const quietly = async (work) => {
|
|
66
|
+
for (let attempt = 0;; attempt++) {
|
|
67
|
+
try {
|
|
68
|
+
return await options.exclusive(work, true);
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (attempt >= 4 || error.status !== 409)
|
|
72
|
+
throw error;
|
|
73
|
+
await new Promise((resolve) => setTimeout(resolve, options.gateRetryMs ?? 2_000));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
/** Back up the workspace about to be replaced, then commit `id`. On any
|
|
78
|
+
* failure nothing is left behind: not the new backup, not the staged files. */
|
|
79
|
+
async function replaceWith(id, req, auth) {
|
|
80
|
+
noteMoveRestore(options.dataDir, id);
|
|
81
|
+
try {
|
|
82
|
+
return await quietly(async () => {
|
|
83
|
+
check(req, auth);
|
|
84
|
+
const previous = await prepareNextPreviousCloud(options.dataDir, id, (password) => createWorkspaceBackup(options.dataDir, { password, appVersion: options.appVersion }));
|
|
85
|
+
return { committed: commitPendingWorkspaceRestore(options.dataDir, id), previous };
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
discardNextPreviousCloud(options.dataDir);
|
|
90
|
+
forgetMoveRestore(options.dataDir, id);
|
|
91
|
+
removeMoveFiles(options.dataDir, id);
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
staged.delete(id);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** Room for staging `archive` bytes and installing them, plus a backup of the
|
|
99
|
+
* workspace they replace; 507 with both numbers otherwise. */
|
|
100
|
+
const roomFor = (archive, already = 0) => {
|
|
101
|
+
const needed = moveSpaceNeeded(archive, workspaceMoveSize(options.dataDir).bytes, true) - already;
|
|
102
|
+
const free = freeBytes(options.dataDir);
|
|
103
|
+
return free >= needed ? null : { error: "Your Cloud does not have enough free space for this.", freeBytes: free, neededBytes: needed };
|
|
104
|
+
};
|
|
105
|
+
// The answer that the restore is ready goes out first; then the launcher
|
|
106
|
+
// starts the server again, and startup installs the restore before anything else loads.
|
|
107
|
+
const restartSoon = () => { setTimeout(() => options.restart(), options.restartDelayMs ?? 1_500).unref?.(); };
|
|
108
|
+
return async (req, res, path, auth) => {
|
|
109
|
+
if (path !== CLOUD_MOVE_PREFIX && !path.startsWith(`${CLOUD_MOVE_PREFIX}/`))
|
|
110
|
+
return false;
|
|
111
|
+
const method = req.method ?? "GET";
|
|
112
|
+
try {
|
|
113
|
+
if (!auth.scopes.includes("admin"))
|
|
114
|
+
throw failure("Only the owner can move a workspace.", 403);
|
|
115
|
+
if (method === "GET" && path === `${CLOUD_MOVE_PREFIX}/estimate`) {
|
|
116
|
+
json(res, 200, { ...workspaceContents(options.dataDir), ...workspaceMoveSize(options.dataDir), maxBytes: CLOUD_MOVE_MAX_BYTES });
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
if (!options.cloudHome)
|
|
120
|
+
throw failure("Only an OMB Cloud home receives a moved workspace.", 404);
|
|
121
|
+
if (auth.kind !== "session")
|
|
122
|
+
throw failure("Move to Cloud needs the owner's signed-in app.", 403);
|
|
123
|
+
check(req, auth);
|
|
124
|
+
if (method === "GET" && path === CLOUD_MOVE_PREFIX) {
|
|
125
|
+
const contents = workspaceContents(options.dataDir);
|
|
126
|
+
if (!held || held.at + HELD_CACHE_MS < Date.now())
|
|
127
|
+
held = { at: Date.now(), bytes: backupsBytes(options.dataDir) };
|
|
128
|
+
json(res, 200, {
|
|
129
|
+
contents, empty: isEmptyWorkspace(contents), freeBytes: freeBytes(options.dataDir), maxBytes: CLOUD_MOVE_MAX_BYTES,
|
|
130
|
+
partBytes: CLOUD_MOVE_PART_BYTES, upload: uploadStatus(options.dataDir), previous: previousCloud(options.dataDir), job,
|
|
131
|
+
// What backups and the previous Cloud hold on the volume.
|
|
132
|
+
heldBytes: held.bytes,
|
|
133
|
+
...options.status(),
|
|
134
|
+
lastRestoreId: options.restored.restored ? options.restored.id ?? null : null,
|
|
135
|
+
rolledBackId: options.restored.rolledBack ? options.restored.id ?? null : null,
|
|
136
|
+
});
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
if (method === "POST" && path === `${CLOUD_MOVE_PREFIX}/upload`) {
|
|
140
|
+
const declared = validUploadDeclaration(z.object({ sha256: z.unknown(), bytes: z.unknown(), files: z.unknown().optional() }).parse(await options.readBody(req, 4096)));
|
|
141
|
+
ready();
|
|
142
|
+
// A new upload ends any earlier attempt: its staged files go, and a
|
|
143
|
+
// stored part (whichever file it was) counts as space about to be freed.
|
|
144
|
+
dropStaged();
|
|
145
|
+
const refused = roomFor(declared.bytes, uploadStatus(options.dataDir)?.received ?? 0);
|
|
146
|
+
if (refused) {
|
|
147
|
+
json(res, 507, refused);
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
check(req, auth);
|
|
151
|
+
held = null;
|
|
152
|
+
json(res, 200, { ...beginUpload(options.dataDir, declared), partBytes: CLOUD_MOVE_PART_BYTES });
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
const part = /^\/api\/cloud-move\/upload\/([a-f0-9]{64})$/.exec(path);
|
|
156
|
+
if (method === "PUT" && part) {
|
|
157
|
+
const offset = Number(new URL(req.url ?? "/", "http://cloud-move.invalid").searchParams.get("offset"));
|
|
158
|
+
const length = Number(req.headers["content-length"]);
|
|
159
|
+
if (!Number.isSafeInteger(length) || length <= 0 || length > CLOUD_MOVE_MAX_PART_BYTES)
|
|
160
|
+
throw failure("Send each part with its length, at most 64 MB.", 411);
|
|
161
|
+
ready();
|
|
162
|
+
writing = true;
|
|
163
|
+
let received;
|
|
164
|
+
try {
|
|
165
|
+
received = await writeUploadPart(options.dataDir, part[1], offset, length, req.iterator({ destroyOnReturn: false }));
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
writing = false;
|
|
169
|
+
}
|
|
170
|
+
json(res, 200, { received });
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
if (method === "POST" && path === `${CLOUD_MOVE_PREFIX}/preview`) {
|
|
174
|
+
const body = z.object({ sha256: z.string().regex(/^[a-f0-9]{64}$/), password: z.string().min(12).max(1024) }).parse(await options.readBody(req, 8192));
|
|
175
|
+
ready();
|
|
176
|
+
discardPreview = false;
|
|
177
|
+
start("preview", async () => {
|
|
178
|
+
const file = await completedUpload(options.dataDir, body.sha256);
|
|
179
|
+
let result;
|
|
180
|
+
try {
|
|
181
|
+
result = await stageWorkspaceBackup(options.dataDir, file, { password: body.password, currentAppVersion: options.appVersion });
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
discardUpload(options.dataDir);
|
|
185
|
+
}
|
|
186
|
+
if (discardPreview) {
|
|
187
|
+
removeMoveFiles(options.dataDir, result.id);
|
|
188
|
+
throw failure("The move was stopped; what it staged was removed.", 409);
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
check(req, auth);
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
removeMoveFiles(options.dataDir, result.id);
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
197
|
+
staged.add(result.id);
|
|
198
|
+
const { summary } = result;
|
|
199
|
+
return { kind: "preview", state: "done", id: result.id, summary: {
|
|
200
|
+
appVersion: summary.appVersion, files: summary.files, bytes: summary.bytes, bots: summary.bots,
|
|
201
|
+
groups: summary.groups, threads: summary.threads, messages: summary.messages,
|
|
202
|
+
} };
|
|
203
|
+
});
|
|
204
|
+
json(res, 202, { job });
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
if (method === "POST" && path === `${CLOUD_MOVE_PREFIX}/discard`) {
|
|
208
|
+
await options.readBody(req, 4096);
|
|
209
|
+
if (job?.state === "running" && job.kind !== "preview")
|
|
210
|
+
throw failure("Your Cloud is already replacing its workspace.", 409);
|
|
211
|
+
// A preview still running removes what it stages when it ends.
|
|
212
|
+
if (job?.state === "running")
|
|
213
|
+
discardPreview = true;
|
|
214
|
+
dropStaged();
|
|
215
|
+
held = null;
|
|
216
|
+
json(res, 200, { ok: true });
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
if (method === "POST" && path === `${CLOUD_MOVE_PREFIX}/restore`) {
|
|
220
|
+
const body = z.object({ id: z.string().uuid() }).parse(await options.readBody(req, 4096));
|
|
221
|
+
ready();
|
|
222
|
+
if (!staged.has(body.id))
|
|
223
|
+
throw failure("This move is no longer ready on your Cloud. Start it again.", 404);
|
|
224
|
+
start("restore", async () => {
|
|
225
|
+
const { committed, previous } = await replaceWith(body.id, req, auth);
|
|
226
|
+
return { kind: "restore", state: "done", id: committed.id, previous };
|
|
227
|
+
}, restartSoon);
|
|
228
|
+
json(res, 202, { job });
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
if (method === "POST" && path === `${CLOUD_MOVE_PREFIX}/undo`) {
|
|
232
|
+
await options.readBody(req, 4096);
|
|
233
|
+
ready();
|
|
234
|
+
if (!previousCloud(options.dataDir))
|
|
235
|
+
throw failure("There is no previous Cloud to restore.", 404);
|
|
236
|
+
const refused = roomFor(previousCloudArchiveBytes(options.dataDir));
|
|
237
|
+
if (refused) {
|
|
238
|
+
json(res, 507, refused);
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
dropStaged();
|
|
242
|
+
// A swap: what the Cloud has now becomes the previous Cloud, so this
|
|
243
|
+
// can be undone the same way.
|
|
244
|
+
start("undo", async () => {
|
|
245
|
+
const prepared = await stagePreviousCloud(options.dataDir, options.appVersion);
|
|
246
|
+
const { committed, previous } = await replaceWith(prepared.id, req, auth);
|
|
247
|
+
return { kind: "undo", state: "done", id: committed.id, previous };
|
|
248
|
+
}, restartSoon);
|
|
249
|
+
json(res, 202, { job });
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
json(res, 404, { error: "Unknown move operation." });
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
if (res.headersSent) {
|
|
256
|
+
res.destroy();
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
const status = error instanceof z.ZodError ? 400 : error.status ?? 400;
|
|
260
|
+
const received = error.received;
|
|
261
|
+
json(res, status, {
|
|
262
|
+
error: error instanceof z.ZodError ? "Invalid move request." : error instanceof Error ? error.message : "The move failed.",
|
|
263
|
+
...(Number.isSafeInteger(received) ? { received } : {}),
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
return true;
|
|
267
|
+
};
|
|
268
|
+
}
|