openmausbot 0.1.88 → 0.1.90
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/README.md +1 -1
- package/dist/assets/{abnfDiagram-O67JEVCF-qd9VD3GV.js → abnfDiagram-O67JEVCF-DUyXd0kL.js} +1 -1
- package/dist/assets/{arc-3RwkUjRe.js → arc-q73qM-At.js} +1 -1
- package/dist/assets/{architectureDiagram-NJMV4G6O-CFKSJS7a.js → architectureDiagram-NJMV4G6O-D33O3Wur.js} +1 -1
- package/dist/assets/{blockDiagram-BEXU5L5S-CG1rEfWD.js → blockDiagram-BEXU5L5S-DLsJeoXC.js} +1 -1
- package/dist/assets/{c4Diagram-YGBWAQC7-CBW9N664.js → c4Diagram-YGBWAQC7-DnaljZnu.js} +1 -1
- package/dist/assets/channel-BCuyHjFV.js +1 -0
- package/dist/assets/{chunk-2Q5K7J3B-2mRfTksB.js → chunk-2Q5K7J3B-Da0h2i79.js} +1 -1
- package/dist/assets/{chunk-3FUC2YCW-Cv1dpErj.js → chunk-3FUC2YCW-CfzHi54f.js} +2 -2
- package/dist/assets/{chunk-5VM5RSS4-B7IACJp-.js → chunk-5VM5RSS4-LjE4d9Jf.js} +1 -1
- package/dist/assets/{chunk-GTNCS2PH-An0hZtYa.js → chunk-GTNCS2PH-DY-UGmvc.js} +1 -1
- package/dist/assets/{chunk-GWA4HPMP-BcJB2nEm.js → chunk-GWA4HPMP-BqeGhyhP.js} +1 -1
- package/dist/assets/{chunk-JWPE2WC7-CV_W7TN7.js → chunk-JWPE2WC7-BYa9EBAg.js} +1 -1
- package/dist/assets/{chunk-NETBCI7D-Bcs_kwnA.js → chunk-NETBCI7D-vTrDtWyb.js} +1 -1
- package/dist/assets/{chunk-WEXAMYUT-lrx_DxFP.js → chunk-WEXAMYUT-ic5tXadM.js} +1 -1
- package/dist/assets/{chunk-XXDRQBXY-DabHlLGM.js → chunk-XXDRQBXY-DvRCY-c6.js} +1 -1
- package/dist/assets/{classDiagram-v2-NBCMYWYE-BnHj6P7d.js → classDiagram-v2-NBCMYWYE-DVbmgz_L.js} +1 -1
- package/dist/assets/{cose-bilkent-JH36ORCC-CfLp6UDi.js → cose-bilkent-JH36ORCC-vdUs2n57.js} +1 -1
- package/dist/assets/{cynefin-OW5HDTMX-Dm4dbVA1.js → cynefin-OW5HDTMX-BvLGlV3U.js} +1 -1
- package/dist/assets/{cynefinDiagram-VND7K2PF-B9mH-PID.js → cynefinDiagram-VND7K2PF-C4GcJQDt.js} +1 -1
- package/dist/assets/{dagre-6A5THRUB-DDc_NVQJ.js → dagre-6A5THRUB-BMSaHpt_.js} +1 -1
- package/dist/assets/{diagram-22UHCM2B-lwr8Ip8-.js → diagram-22UHCM2B-D5b9UBhC.js} +1 -1
- package/dist/assets/{diagram-3UASUU5V-BLLe6dmI.js → diagram-3UASUU5V-CIKLlB7Y.js} +1 -1
- package/dist/assets/{diagram-ATOU4E4O-6eUIq4lz.js → diagram-ATOU4E4O-BzXb-7k7.js} +1 -1
- package/dist/assets/{diagram-CDSNMT55-O-pRN7VQ.js → diagram-CDSNMT55-HsTvJ7uT.js} +1 -1
- package/dist/assets/{diagram-MLGK6HIB-BietvGAI.js → diagram-MLGK6HIB-Db9ySNbQ.js} +1 -1
- package/dist/assets/{diagram-MPIPVDR6-Bd2Cl62X.js → diagram-MPIPVDR6-D6BKbpqY.js} +1 -1
- package/dist/assets/{ebnfDiagram-ZINNZB2B-B6iDy69W.js → ebnfDiagram-ZINNZB2B-Bq067L0t.js} +1 -1
- package/dist/assets/{elk-276RUBZZ-DQVHfKe0.js → elk-276RUBZZ-DhaD7fNJ.js} +1 -1
- package/dist/assets/{erDiagram-OPXOYQCR-BWdVp2tQ.js → erDiagram-OPXOYQCR-fwIhfz36.js} +1 -1
- package/dist/assets/{flowDiagram-KWPJA3E3-WLE6rKB4.js → flowDiagram-KWPJA3E3-DygIyu0V.js} +1 -1
- package/dist/assets/{ganttDiagram-FUAMR5RP-BbjS-4gn.js → ganttDiagram-FUAMR5RP-DPYfSO96.js} +1 -1
- package/dist/assets/{gitGraphDiagram-X574FWY7-Dpy2QSGq.js → gitGraphDiagram-X574FWY7-DhkHeCle.js} +1 -1
- package/dist/assets/index-CRvo-DS2.css +1 -0
- package/dist/assets/index-CWYGlA63.js +588 -0
- package/dist/assets/{index-CIaHn5NS.js → index-G1PHKGyh.js} +1 -1
- package/dist/assets/{infoDiagram-VRGFBTTK-D_jKWlKe.js → infoDiagram-VRGFBTTK-DstBLxME.js} +1 -1
- package/dist/assets/{ishikawaDiagram-OU5B5YK6-DLJohSuK.js → ishikawaDiagram-OU5B5YK6-CrIsGgch.js} +1 -1
- package/dist/assets/{journeyDiagram-ZHPQQLJL-DPOFj0qN.js → journeyDiagram-ZHPQQLJL-CYypGDbB.js} +1 -1
- package/dist/assets/{kanban-definition-PNTS6WVX-BI7v-xpK.js → kanban-definition-PNTS6WVX-DGjPt8E_.js} +1 -1
- package/dist/assets/{linear-C2pWPSxO.js → linear-Dwc3Hrru.js} +1 -1
- package/dist/assets/{mermaid.core-CR-j5WQU.js → mermaid.core-Dc4OePBL.js} +5 -5
- package/dist/assets/{mindmap-definition-NLK3R4M7-lz9GvXRh.js → mindmap-definition-NLK3R4M7-DV-vepj-.js} +1 -1
- package/dist/assets/{pegDiagram-GJSIUBJH-Dl4_ia61.js → pegDiagram-GJSIUBJH-lx5Kj-n5.js} +1 -1
- package/dist/assets/{pieDiagram-5QR66LMP-DWuT3n88.js → pieDiagram-5QR66LMP-Bq7Vmm15.js} +1 -1
- package/dist/assets/{quadrantDiagram-O4NWA36T-DXWe9Jx7.js → quadrantDiagram-O4NWA36T-DeqeqOBj.js} +1 -1
- package/dist/assets/{railroadDiagram-XR7U4H2S-COY1IBVY.js → railroadDiagram-XR7U4H2S-CXwRQzSC.js} +1 -1
- package/dist/assets/{requirementDiagram-PLB6GJNP-DnyVuhsQ.js → requirementDiagram-PLB6GJNP-8tAeoB88.js} +1 -1
- package/dist/assets/{sankeyDiagram-IPEJSGJF-BSc4NdGK.js → sankeyDiagram-IPEJSGJF-BG5zjGWU.js} +1 -1
- package/dist/assets/{sequenceDiagram-PO4LG4MO-kqEU2EGq.js → sequenceDiagram-PO4LG4MO-DKF3iLcw.js} +1 -1
- package/dist/assets/{sizeCapture-INFHLROL-BvB4bkWD.js → sizeCapture-INFHLROL-B-y1qUgT.js} +1 -1
- package/dist/assets/{stateDiagram-v2-GCMORJYK-BIrqNv7y.js → stateDiagram-v2-GCMORJYK-MkzPgxAc.js} +1 -1
- package/dist/assets/{swimlanes-2SLR337P-CFnalQUE.js → swimlanes-2SLR337P-DnbaOEwH.js} +1 -1
- package/dist/assets/swimlanesDiagram-TC7HE7FX-BZQ07DGE.js +8 -0
- package/dist/assets/{timeline-definition-EJHVYXUP-CsSWJnSQ.js → timeline-definition-EJHVYXUP-Bb4xG_Bt.js} +1 -1
- package/dist/assets/{usecaseDiagram-POWQR4AR-DkAmJ1EE.js → usecaseDiagram-POWQR4AR-JgkR9pFf.js} +1 -1
- package/dist/assets/{vennDiagram-UO4OBE2U-C3vd9drL.js → vennDiagram-UO4OBE2U-CIPIrAYe.js} +1 -1
- package/dist/assets/{wardleyDiagram-VNRHLVJA-j26CM-pi.js → wardleyDiagram-VNRHLVJA-BjCLuUvJ.js} +1 -1
- package/dist/assets/{xychartDiagram-PMCCYNJV-BYu1Q_at.js → xychartDiagram-PMCCYNJV-DKgiaYxT.js} +1 -1
- package/dist/index.html +2 -2
- package/dist-server/cloud-home-start.js +14795 -0
- package/dist-server/container-mcp.js +60 -4
- package/dist-server/drivers/agents-proxy.js +20 -10
- package/dist-server/index.js +48924 -46153
- package/dist-server/openmausbot.js +544 -253
- package/dist-server/pair-cli.js +544 -253
- package/dist-server/permission-proxy.js +1 -1
- package/dist-server/server/admission.js +46 -0
- package/dist-server/server/automatic-recovery.js +15 -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} +330 -328
- 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/cli-api-setup.js +0 -1
- package/dist-server/server/cloud-backend.js +5 -5
- package/dist-server/server/cloud-home-start.js +103 -0
- package/dist-server/server/cloud-home.js +218 -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 +124 -6
- package/dist-server/server/container-computer.js +23 -0
- package/dist-server/server/contracts.js +10 -0
- package/dist-server/server/control-client.js +2 -2
- package/dist-server/server/delegations.js +11 -3
- package/dist-server/server/drivers/acp/core.js +129 -21
- package/dist-server/server/drivers/acp/qwen.js +3 -0
- package/dist-server/server/drivers/agents-call.js +5 -1
- package/dist-server/server/drivers/agents-catalog.js +9 -3
- package/dist-server/server/drivers/{boxagent.js → boatagent.js} +14 -13
- 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 +177 -13
- package/dist-server/server/drivers/native.js +1 -1
- package/dist-server/server/drivers/openai-chat.js +19 -4
- package/dist-server/server/drivers/pi.js +3 -3
- package/dist-server/server/harness-capabilities.js +18 -3
- package/dist-server/server/hosted-models.js +6 -1
- package/dist-server/server/index.js +1163 -530
- 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/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/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/routine-requests.js +27 -16
- package/dist-server/server/routines.js +10 -6
- package/dist-server/server/steer-queue.js +41 -10
- package/dist-server/server/store.js +25 -1
- package/dist-server/server/surface.js +1 -1
- package/dist-server/server/system-prompt.js +16 -5
- package/dist-server/server/team-computers.js +2 -2
- 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 +1 -1
- package/dist-server/server/turn-resources.js +87 -11
- package/dist-server/server/vps-computer.js +1 -1
- package/dist-server/server/webhooks.js +21 -3
- 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/credential-request.js +3 -3
- package/dist-server/shared/tool-surface.js +1 -1
- package/dist-server/src/lib/composer-attachments.js +26 -0
- package/dist-server/vps-container-mcp.js +60 -4
- package/dist-server/workspace-backup.worker.js +60 -4
- package/package.json +1 -1
- package/dist/assets/channel-CnRnO3tF.js +0 -1
- package/dist/assets/index-BIMdIbaw.css +0 -1
- package/dist/assets/index-CHnODCr2.js +0 -583
- package/dist/assets/swimlanesDiagram-TC7HE7FX-C4a8qNGj.js +0 -8
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// OMB Cloud Pro home machine: the boot contract, the Admin's signed pairing
|
|
2
|
+
// 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
|
+
export function cloudHomeConfigured(env = process.env) {
|
|
43
|
+
return CLOUD_HOME_KEYS.some((key) => env[key] !== undefined);
|
|
44
|
+
}
|
|
45
|
+
function invalid(why) {
|
|
46
|
+
throw new Error(`Cloud home configuration is invalid: ${why}.`);
|
|
47
|
+
}
|
|
48
|
+
function exactHttpsOrigin(raw, name) {
|
|
49
|
+
let url;
|
|
50
|
+
try {
|
|
51
|
+
if (!raw || raw !== raw.trim())
|
|
52
|
+
throw new Error();
|
|
53
|
+
url = new URL(raw);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return invalid(`${name} must be an https origin`);
|
|
57
|
+
}
|
|
58
|
+
if (url.protocol !== "https:" || url.username || url.password || (raw !== url.origin && raw !== `${url.origin}/`)) {
|
|
59
|
+
invalid(`${name} must be an exact https origin with no path`);
|
|
60
|
+
}
|
|
61
|
+
return url.origin;
|
|
62
|
+
}
|
|
63
|
+
/** Read the boot contract. Null on any machine that is not a Cloud home;
|
|
64
|
+
* throws (so the server refuses to start) on a partial or invalid one. */
|
|
65
|
+
export function cloudHomeConfiguration(env = process.env) {
|
|
66
|
+
if (!cloudHomeConfigured(env))
|
|
67
|
+
return null;
|
|
68
|
+
if (env.OMB_DESKTOP_PARENT === "1")
|
|
69
|
+
invalid("the desktop app cannot run as a Cloud home machine");
|
|
70
|
+
if (hostedWorkspaceConfigured(env))
|
|
71
|
+
invalid("a Cloud home is not a hosted team workspace; remove OMB_ADMIN_URL, OMB_ADMIN_WORKSPACE and OMB_ADMIN_MEMBERSHIP");
|
|
72
|
+
if (env.OMB_CLOUD_ROLE !== "home")
|
|
73
|
+
invalid('OMB_CLOUD_ROLE must be "home"; this image runs the home machine');
|
|
74
|
+
const machineId = env.OMB_CLOUD_MACHINE_ID ?? "";
|
|
75
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{2,127}$/.test(machineId))
|
|
76
|
+
invalid("OMB_CLOUD_MACHINE_ID must be the Admin's machine id (letters, digits, dashes)");
|
|
77
|
+
const adminOrigin = exactHttpsOrigin(env.OMB_CLOUD_ADMIN_URL, "OMB_CLOUD_ADMIN_URL");
|
|
78
|
+
const publicOrigin = exactHttpsOrigin(env.OMB_PUBLIC_URL, "OMB_PUBLIC_URL");
|
|
79
|
+
const bootstrapSecret = env.OMB_CLOUD_BOOTSTRAP_SECRET ?? "";
|
|
80
|
+
if (!/^[A-Za-z0-9_-]{43,128}$/.test(bootstrapSecret))
|
|
81
|
+
invalid("OMB_CLOUD_BOOTSTRAP_SECRET must be at least 256 bits of base64url");
|
|
82
|
+
const warnings = [];
|
|
83
|
+
const ignored = CLOUD_IGNORED_KEYS.filter((key) => env[key] !== undefined);
|
|
84
|
+
if (ignored.length)
|
|
85
|
+
warnings.push(`ignoring ${ignored.join(", ")}: Cloud Pro includes no AI; people sign in with their own Claude or ChatGPT account, or an API key`);
|
|
86
|
+
return { machineId, adminOrigin, publicOrigin, bootstrapSecret, warnings };
|
|
87
|
+
}
|
|
88
|
+
/** The environment without a platform gateway's settings (CLOUD_IGNORED_KEYS). */
|
|
89
|
+
export function withoutIgnoredCloudKeys(env) {
|
|
90
|
+
const kept = { ...env };
|
|
91
|
+
for (const key of CLOUD_IGNORED_KEYS)
|
|
92
|
+
delete kept[key];
|
|
93
|
+
return kept;
|
|
94
|
+
}
|
|
95
|
+
/** The Admin's side of the signature (openmaus-cloud cloudPairingSignature). */
|
|
96
|
+
export function cloudPairingSignature(secret, timestamp, nonce, body) {
|
|
97
|
+
const bodyHash = createHash("sha256").update(body).digest("base64url");
|
|
98
|
+
return createHmac("sha256", secret).update(`v1\n${timestamp}\n${nonce}\nPOST\n${CLOUD_PAIRING_PATH}\n${bodyHash}`).digest("base64url");
|
|
99
|
+
}
|
|
100
|
+
/** The Admin's signed request for one pairing window on this machine. */
|
|
101
|
+
export function createCloudPairing(options) {
|
|
102
|
+
const { secret, sessions } = options;
|
|
103
|
+
const now = options.now ?? Date.now;
|
|
104
|
+
const nonces = new Map();
|
|
105
|
+
const refused = (error) => ({ status: 401, body: { error } });
|
|
106
|
+
return {
|
|
107
|
+
/** Everything is checked before anything is opened: the signature (in
|
|
108
|
+
* constant time; a bad one counts toward the pairing lockout), then the
|
|
109
|
+
* clock, then the nonce, then the body. Nothing here is ever logged. */
|
|
110
|
+
handle(input) {
|
|
111
|
+
const lock = sessions.attemptAllowed(input.source);
|
|
112
|
+
if (!lock.ok)
|
|
113
|
+
return { status: 429, body: { error: "rate_limited", retryAfterSeconds: Math.ceil(lock.retryAfterMs / 1000) } };
|
|
114
|
+
const timestamp = input.timestamp ?? "", nonce = input.nonce ?? "";
|
|
115
|
+
const presented = /^v1=([A-Za-z0-9_-]{43})$/.exec(input.signature ?? "")?.[1];
|
|
116
|
+
const expected = Buffer.from(cloudPairingSignature(secret, timestamp, nonce, input.body), "base64url");
|
|
117
|
+
const given = presented ? Buffer.from(presented, "base64url") : Buffer.alloc(expected.length);
|
|
118
|
+
const matches = given.length === expected.length && timingSafeEqual(given, expected);
|
|
119
|
+
if (!presented || !matches || !/^\d{1,12}$/.test(timestamp) || !/^[A-Za-z0-9_-]{16,128}$/.test(nonce)) {
|
|
120
|
+
sessions.noteFailure(input.source);
|
|
121
|
+
return refused("invalid_signature");
|
|
122
|
+
}
|
|
123
|
+
sessions.clearFailures(input.source);
|
|
124
|
+
const at = now();
|
|
125
|
+
if (Math.abs(at / 1000 - Number(timestamp)) > CLOUD_PAIRING_SKEW_S)
|
|
126
|
+
return refused("stale_request");
|
|
127
|
+
for (const [seen, until] of nonces)
|
|
128
|
+
if (until <= at)
|
|
129
|
+
nonces.delete(seen);
|
|
130
|
+
if (nonces.has(nonce))
|
|
131
|
+
return refused("replayed_request");
|
|
132
|
+
if (nonces.size >= MAX_NONCES)
|
|
133
|
+
return { status: 503, body: { error: "busy" } };
|
|
134
|
+
nonces.set(nonce, at + CLOUD_PAIRING_NONCE_MS);
|
|
135
|
+
let parsed;
|
|
136
|
+
try {
|
|
137
|
+
parsed = input.body.length ? JSON.parse(input.body.toString("utf8")) : {};
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return { status: 400, body: { error: "invalid_body" } };
|
|
141
|
+
}
|
|
142
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
143
|
+
return { status: 400, body: { error: "invalid_body" } };
|
|
144
|
+
const { label, ttlSeconds } = parsed;
|
|
145
|
+
// oxlint-disable-next-line no-control-regex
|
|
146
|
+
if (label !== undefined && (typeof label !== "string" || label.length > 80 || /[\x00-\x1f\x7f]/.test(label)))
|
|
147
|
+
return { status: 400, body: { error: "invalid_label" } };
|
|
148
|
+
if (ttlSeconds !== undefined && (!Number.isSafeInteger(ttlSeconds) || ttlSeconds < 1))
|
|
149
|
+
return { status: 400, body: { error: "invalid_ttl" } };
|
|
150
|
+
const ttl = Math.min(ttlSeconds ?? CLOUD_PAIRING_DEFAULT_TTL_S, CLOUD_PAIRING_MAX_TTL_S);
|
|
151
|
+
const opened = sessions.openPairing({
|
|
152
|
+
scopes: ["admin", "client"],
|
|
153
|
+
label: typeof label === "string" && label.trim() ? label.trim() : "OMB Cloud",
|
|
154
|
+
ttlMs: ttl * 1000,
|
|
155
|
+
});
|
|
156
|
+
return { status: 200, body: { code: formatPairingCode(opened.code), credential: opened.credential, expiresAt: opened.expiresAt } };
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/** The exact bytes the Admin signed, bounded. */
|
|
161
|
+
export function readSignedBody(req, limit = 4096) {
|
|
162
|
+
return new Promise((resolve, reject) => {
|
|
163
|
+
const chunks = [];
|
|
164
|
+
let size = 0, done = false;
|
|
165
|
+
req.on("data", (chunk) => {
|
|
166
|
+
if (done)
|
|
167
|
+
return;
|
|
168
|
+
size += chunk.length;
|
|
169
|
+
if (size > limit) {
|
|
170
|
+
done = true;
|
|
171
|
+
reject(Object.assign(new Error("body too large"), { status: 413 }));
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
chunks.push(chunk);
|
|
175
|
+
});
|
|
176
|
+
req.on("end", () => { if (!done) {
|
|
177
|
+
done = true;
|
|
178
|
+
resolve(Buffer.concat(chunks));
|
|
179
|
+
} });
|
|
180
|
+
req.on("error", (error) => { if (!done) {
|
|
181
|
+
done = true;
|
|
182
|
+
reject(Object.assign(error, { status: 400 }));
|
|
183
|
+
} });
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
// The image's home directory may carry the shell files useradd seeds; a
|
|
187
|
+
// fresh Fly volume carries lost+found. Anything else is someone's data.
|
|
188
|
+
const ADOPTABLE = new Set(["lost+found", ".bash_logout", ".bashrc", ".profile"]);
|
|
189
|
+
/** Bind the volume to this machine on first boot and refuse any other
|
|
190
|
+
* machine's volume afterwards. Never deletes or rewrites existing data. */
|
|
191
|
+
export function prepareCloudHomeVolume(home, machineId) {
|
|
192
|
+
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
193
|
+
const marker = join(home, CLOUD_HOME_MARKER);
|
|
194
|
+
if (existsSync(marker)) {
|
|
195
|
+
const stat = lstatSync(marker);
|
|
196
|
+
let saved;
|
|
197
|
+
try {
|
|
198
|
+
if (!stat.isFile() || stat.size > 4096)
|
|
199
|
+
throw new Error();
|
|
200
|
+
saved = JSON.parse(readFileSync(marker, "utf8"));
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
throw new Error("This volume's Cloud marker is unreadable. No data was changed.");
|
|
204
|
+
}
|
|
205
|
+
if (saved?.machine !== machineId)
|
|
206
|
+
throw new Error("This volume belongs to another Cloud machine. No data was changed.");
|
|
207
|
+
return "existing";
|
|
208
|
+
}
|
|
209
|
+
if (readdirSync(home).some((name) => !ADOPTABLE.has(name))) {
|
|
210
|
+
throw new Error("Refusing to adopt a data volume that was not made for this Cloud machine. No data was changed.");
|
|
211
|
+
}
|
|
212
|
+
writeFileAtomic(marker, JSON.stringify({ version: 1, machine: machineId }) + "\n", { mode: 0o600 });
|
|
213
|
+
return "new";
|
|
214
|
+
}
|
|
215
|
+
/** What the edge proxy may answer for: the machine's own public name. */
|
|
216
|
+
export function cloudHomeHost(config) {
|
|
217
|
+
return new URL(config.publicOrigin).hostname;
|
|
218
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/** Consent, cost, and idle-stop policy for overflowing a local computer
|
|
2
|
+
* wait onto a per-second-billed cloud seat (#1655).
|
|
3
|
+
*
|
|
4
|
+
* Like server/claim-idle.ts this module is deliberately synchronous and
|
|
5
|
+
* clock-injected: the server decides every transition on its single
|
|
6
|
+
* thread and tests pin time exactly. Nothing here starts or stops a
|
|
7
|
+
* machine — the wiring does, and only when the decision says to. Keep
|
|
8
|
+
* this module free of server imports. */
|
|
9
|
+
import { computerWaitDuration } from "./computer-wait.js";
|
|
10
|
+
/** Default idle stop: a cloud seat no real screen work has touched for
|
|
11
|
+
* this long is stopped (archived), because billing runs per second while
|
|
12
|
+
* it runs. "A few idle minutes" per the issue. */
|
|
13
|
+
export const CLOUD_SEAT_IDLE_STOP_MS = 5 * 60_000;
|
|
14
|
+
/** Retry cadence for an idle stop that failed (#1655): the first retry
|
|
15
|
+
* after one minute, doubling per failure up to ten. A failed stop keeps
|
|
16
|
+
* its lease — the seat still bills — so the sweep always retries instead
|
|
17
|
+
* of abandoning a running machine. */
|
|
18
|
+
export const CLOUD_SEAT_SLEEP_RETRY_MS = 60_000;
|
|
19
|
+
export const CLOUD_SEAT_SLEEP_RETRY_MAX_MS = 10 * 60_000;
|
|
20
|
+
export function cloudOverflowAction(situation) {
|
|
21
|
+
if (!situation.featureEnabled || !situation.cloudConfigured || situation.perSecondCostUsd === null)
|
|
22
|
+
return { kind: "off" };
|
|
23
|
+
if (situation.started)
|
|
24
|
+
return { kind: "none" };
|
|
25
|
+
if (situation.consented)
|
|
26
|
+
return { kind: "start" };
|
|
27
|
+
if (situation.offered)
|
|
28
|
+
return { kind: "none" };
|
|
29
|
+
return { kind: "offer" };
|
|
30
|
+
}
|
|
31
|
+
/** Per-conversation consent plus the offer bookkeeping that keeps the
|
|
32
|
+
* card to one per conversation until it is revoked. A thread the operator
|
|
33
|
+
* allowlisted in config carries standing consent and never needs the
|
|
34
|
+
* card. */
|
|
35
|
+
export class CloudOverflowConsent {
|
|
36
|
+
grants = new Map();
|
|
37
|
+
offers = new Map();
|
|
38
|
+
revocations = new Set();
|
|
39
|
+
/** Consent for this conversation. With `perSecondCostUsd`, a card
|
|
40
|
+
* consent counts only at the rate its card showed — an allowlisted
|
|
41
|
+
* thread carries the operator's standing consent, and the operator is
|
|
42
|
+
* also the one who sets the rate. */
|
|
43
|
+
consented(threadId, allowlistedThreads = new Set(), perSecondCostUsd) {
|
|
44
|
+
if (this.revocations.has(threadId))
|
|
45
|
+
return false;
|
|
46
|
+
if (allowlistedThreads.has(threadId))
|
|
47
|
+
return true;
|
|
48
|
+
const grant = this.grants.get(threadId);
|
|
49
|
+
if (!grant)
|
|
50
|
+
return false;
|
|
51
|
+
return perSecondCostUsd === undefined || grant.perSecondCostUsd === perSecondCostUsd;
|
|
52
|
+
}
|
|
53
|
+
/** `perSecondCostUsd` is the rate the answered card showed. */
|
|
54
|
+
grant(threadId, perSecondCostUsd, now = Date.now()) {
|
|
55
|
+
this.revocations.delete(threadId);
|
|
56
|
+
this.grants.set(threadId, { at: now, perSecondCostUsd });
|
|
57
|
+
}
|
|
58
|
+
/** Revoking also clears the offered mark, so a later wait in the same
|
|
59
|
+
* conversation may ask again instead of meeting silence. */
|
|
60
|
+
revoke(threadId) {
|
|
61
|
+
const had = this.grants.delete(threadId);
|
|
62
|
+
this.revocations.add(threadId);
|
|
63
|
+
this.offers.delete(threadId);
|
|
64
|
+
return had;
|
|
65
|
+
}
|
|
66
|
+
/** A card is outstanding for this conversation — at the given rate
|
|
67
|
+
* when one is supplied, so a config change re-offers with the new
|
|
68
|
+
* price instead of meeting silence at the old one. */
|
|
69
|
+
offered(threadId, perSecondCostUsd) {
|
|
70
|
+
const offer = this.offers.get(threadId);
|
|
71
|
+
if (!offer)
|
|
72
|
+
return false;
|
|
73
|
+
return perSecondCostUsd === undefined || offer.perSecondCostUsd === perSecondCostUsd;
|
|
74
|
+
}
|
|
75
|
+
/** The rate the outstanding card showed, or null when no card is out. */
|
|
76
|
+
offeredRate(threadId) {
|
|
77
|
+
return this.offers.get(threadId)?.perSecondCostUsd ?? null;
|
|
78
|
+
}
|
|
79
|
+
markOffered(threadId, perSecondCostUsd, now = Date.now()) {
|
|
80
|
+
this.offers.set(threadId, { at: now, perSecondCostUsd });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** One running cloud seat started by overflow. `touch` is called only
|
|
84
|
+
* for real computer tool completions on that seat — screen-poller frames
|
|
85
|
+
* never reach it, exactly like #1653's activity clock, so preview
|
|
86
|
+
* traffic cannot keep a paid machine awake. */
|
|
87
|
+
export class CloudSeatLease {
|
|
88
|
+
botId;
|
|
89
|
+
threadId;
|
|
90
|
+
/** The claim generation that started the seat: a stop releases the Box
|
|
91
|
+
* claim for exactly this owner, never a newer turn's. */
|
|
92
|
+
generation;
|
|
93
|
+
startedAt;
|
|
94
|
+
idleStopMs;
|
|
95
|
+
lastActivityAt;
|
|
96
|
+
sleepRetryNotBefore = 0;
|
|
97
|
+
sleepRetryMs = CLOUD_SEAT_SLEEP_RETRY_MS;
|
|
98
|
+
constructor(init) {
|
|
99
|
+
const idleStopMs = init.idleStopMs ?? CLOUD_SEAT_IDLE_STOP_MS;
|
|
100
|
+
if (!Number.isFinite(idleStopMs) || idleStopMs <= 0)
|
|
101
|
+
throw new Error("Cloud seat idle stop window must be positive");
|
|
102
|
+
this.botId = init.botId;
|
|
103
|
+
this.threadId = init.threadId;
|
|
104
|
+
this.generation = init.generation;
|
|
105
|
+
this.startedAt = init.now ?? Date.now();
|
|
106
|
+
this.lastActivityAt = this.startedAt;
|
|
107
|
+
this.idleStopMs = idleStopMs;
|
|
108
|
+
}
|
|
109
|
+
touch(now = Date.now()) {
|
|
110
|
+
this.lastActivityAt = now;
|
|
111
|
+
// Real work earns a fresh stop attempt: the backoff only paces
|
|
112
|
+
// retries against an idle seat that keeps failing to sleep.
|
|
113
|
+
this.sleepRetryNotBefore = 0;
|
|
114
|
+
this.sleepRetryMs = CLOUD_SEAT_SLEEP_RETRY_MS;
|
|
115
|
+
}
|
|
116
|
+
/** An idle stop may run now — true until a failure defers it. */
|
|
117
|
+
sleepDue(now = Date.now()) {
|
|
118
|
+
return now >= this.sleepRetryNotBefore;
|
|
119
|
+
}
|
|
120
|
+
/** The idle stop failed: keep the lease and back off, bounded, so the
|
|
121
|
+
* sweep retries gently instead of hammering a sick Box — and never
|
|
122
|
+
* abandons a seat that still bills. */
|
|
123
|
+
deferSleep(now = Date.now()) {
|
|
124
|
+
this.sleepRetryNotBefore = now + this.sleepRetryMs;
|
|
125
|
+
this.sleepRetryMs = Math.min(this.sleepRetryMs * 2, CLOUD_SEAT_SLEEP_RETRY_MAX_MS);
|
|
126
|
+
}
|
|
127
|
+
idleFor(now = Date.now()) {
|
|
128
|
+
return Math.max(0, now - this.lastActivityAt);
|
|
129
|
+
}
|
|
130
|
+
idleElapsed(now = Date.now()) {
|
|
131
|
+
return now - this.lastActivityAt >= this.idleStopMs;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/** Dollars per second at rate-card precision: enough places for small
|
|
135
|
+
* per-second rates to stay visible, trailing zeros trimmed but never
|
|
136
|
+
* past two decimals. */
|
|
137
|
+
export function formatPerSecondUsd(perSecondCostUsd) {
|
|
138
|
+
let text = perSecondCostUsd.toFixed(6).replace(/0+$/, "");
|
|
139
|
+
if (text.endsWith("."))
|
|
140
|
+
text += "00";
|
|
141
|
+
else {
|
|
142
|
+
const decimals = text.length - text.indexOf(".") - 1;
|
|
143
|
+
if (decimals < 2)
|
|
144
|
+
text += "0".repeat(2 - decimals);
|
|
145
|
+
}
|
|
146
|
+
return "$" + text;
|
|
147
|
+
}
|
|
148
|
+
/** The consent card (#1655): the per-second cost sits beside the local
|
|
149
|
+
* wait picture before any choice is made, and nothing starts without an
|
|
150
|
+
* explicit consent the person still has to give. */
|
|
151
|
+
export function cloudOverflowOfferText(opts) {
|
|
152
|
+
const idleStopMs = opts.idleStopMs ?? CLOUD_SEAT_IDLE_STOP_MS;
|
|
153
|
+
const estimate = opts.waitEstimateMs === undefined ? "" : ` Recent local waits here have taken about ${computerWaitDuration(opts.waitEstimateMs)}.`;
|
|
154
|
+
return `This computer is busy. Its work can overflow to a cloud computer for ${formatPerSecondUsd(opts.perSecondCostUsd)} per second.${estimate} Nothing starts without your consent — allow cloud overflow for this conversation to start it; the cloud computer stops after ${computerWaitDuration(idleStopMs)} idle.`;
|
|
155
|
+
}
|
|
156
|
+
export function cloudSeatStartedText(opts) {
|
|
157
|
+
const idleStopMs = opts.idleStopMs ?? CLOUD_SEAT_IDLE_STOP_MS;
|
|
158
|
+
return `Cloud computer started at ${formatPerSecondUsd(opts.perSecondCostUsd)} per second. It stops automatically after ${computerWaitDuration(idleStopMs)} idle, and the local wait continues until this computer is free.`;
|
|
159
|
+
}
|
|
160
|
+
export function cloudSeatStoppedText(idleMs) {
|
|
161
|
+
return `Cloud computer stopped after ${computerWaitDuration(idleMs)} idle — billing pauses while it sleeps.`;
|
|
162
|
+
}
|
|
163
|
+
export function cloudOverflowConsentText(allowed, idleStopMs = CLOUD_SEAT_IDLE_STOP_MS) {
|
|
164
|
+
return allowed
|
|
165
|
+
? `Cloud overflow allowed for this conversation. A waiting turn may start the cloud computer; it stops after ${computerWaitDuration(idleStopMs)} idle.`
|
|
166
|
+
: "Cloud overflow consent revoked. Waits stay local, and no cloud computer starts without a new consent.";
|
|
167
|
+
}
|
|
@@ -196,6 +196,11 @@ export function connectorAvailability(cfg, storeState = process.env.OMB_CREDENTI
|
|
|
196
196
|
return "configured";
|
|
197
197
|
return storeState === "unavailable" ? "unreadable" : "unconfigured";
|
|
198
198
|
}
|
|
199
|
+
export function connectorSetup(cfg, desktopManaged = process.env.OMB_DESKTOP_PARENT === "1") {
|
|
200
|
+
if (configured(cfg))
|
|
201
|
+
return "ready";
|
|
202
|
+
return desktopManaged ? "service-unavailable" : "needs-setup";
|
|
203
|
+
}
|
|
199
204
|
async function brokerRequest(path, init) {
|
|
200
205
|
const broker = brokerAccess();
|
|
201
206
|
if (!broker)
|
|
@@ -99,7 +99,7 @@ export class ObservationCoordinator {
|
|
|
99
99
|
this.metrics.retries += 1;
|
|
100
100
|
}
|
|
101
101
|
/** canonicalFrame must describe the full screenshot, even when the image
|
|
102
|
-
* returned to the model is cropped. A
|
|
102
|
+
* returned to the model is cropped. A boat-provided full-frame hash works. */
|
|
103
103
|
observeFrame(canonicalFrame, crop) {
|
|
104
104
|
this.metrics.screenshotsCaptured += 1;
|
|
105
105
|
const hash = canonicalFrame
|
|
@@ -107,7 +107,7 @@ export class ObservationCoordinator {
|
|
|
107
107
|
: null;
|
|
108
108
|
const view = crop ? `${crop.x},${crop.y},${crop.width},${crop.height}` : "full";
|
|
109
109
|
const signature = hash ? `${hash}:${view}` : null;
|
|
110
|
-
// If the
|
|
110
|
+
// If the boat cannot provide a full-frame hash, fail open and send the
|
|
111
111
|
// valid image. Suppressing a possibly-new crop would be worse.
|
|
112
112
|
const changed = signature === null || signature !== this.lastObservation;
|
|
113
113
|
if (signature)
|