openmausbot 0.1.89 → 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/dist/assets/{abnfDiagram-O67JEVCF-BwaMaZEm.js → abnfDiagram-O67JEVCF-DUyXd0kL.js} +1 -1
- package/dist/assets/{arc-Be-8GKxd.js → arc-q73qM-At.js} +1 -1
- package/dist/assets/{architectureDiagram-NJMV4G6O-Dp_U2Kv1.js → architectureDiagram-NJMV4G6O-D33O3Wur.js} +1 -1
- package/dist/assets/{blockDiagram-BEXU5L5S-DQjUjYUk.js → blockDiagram-BEXU5L5S-DLsJeoXC.js} +1 -1
- package/dist/assets/{c4Diagram-YGBWAQC7-Bd3aE4Kc.js → c4Diagram-YGBWAQC7-DnaljZnu.js} +1 -1
- package/dist/assets/channel-BCuyHjFV.js +1 -0
- package/dist/assets/{chunk-2Q5K7J3B-BijjTBeJ.js → chunk-2Q5K7J3B-Da0h2i79.js} +1 -1
- package/dist/assets/{chunk-3FUC2YCW-CMGdwvzW.js → chunk-3FUC2YCW-CfzHi54f.js} +2 -2
- package/dist/assets/{chunk-5VM5RSS4-0G09BfsZ.js → chunk-5VM5RSS4-LjE4d9Jf.js} +1 -1
- package/dist/assets/{chunk-GTNCS2PH-FOp3vSHe.js → chunk-GTNCS2PH-DY-UGmvc.js} +1 -1
- package/dist/assets/{chunk-GWA4HPMP-Dv-20VIn.js → chunk-GWA4HPMP-BqeGhyhP.js} +1 -1
- package/dist/assets/{chunk-JWPE2WC7-CRgrdFcz.js → chunk-JWPE2WC7-BYa9EBAg.js} +1 -1
- package/dist/assets/{chunk-NETBCI7D-3hP4f7QB.js → chunk-NETBCI7D-vTrDtWyb.js} +1 -1
- package/dist/assets/{chunk-WEXAMYUT-CG98IjlG.js → chunk-WEXAMYUT-ic5tXadM.js} +1 -1
- package/dist/assets/{chunk-XXDRQBXY-BrSTC9xG.js → chunk-XXDRQBXY-DvRCY-c6.js} +1 -1
- package/dist/assets/{classDiagram-v2-NBCMYWYE-rpQQQDFX.js → classDiagram-v2-NBCMYWYE-DVbmgz_L.js} +1 -1
- package/dist/assets/{cose-bilkent-JH36ORCC-G6RSafHA.js → cose-bilkent-JH36ORCC-vdUs2n57.js} +1 -1
- package/dist/assets/{cynefin-OW5HDTMX-CXjv-IM2.js → cynefin-OW5HDTMX-BvLGlV3U.js} +1 -1
- package/dist/assets/{cynefinDiagram-VND7K2PF-Dw07MR9f.js → cynefinDiagram-VND7K2PF-C4GcJQDt.js} +1 -1
- package/dist/assets/{dagre-6A5THRUB-BwPnlTg2.js → dagre-6A5THRUB-BMSaHpt_.js} +1 -1
- package/dist/assets/{diagram-22UHCM2B-Cm6xcMiz.js → diagram-22UHCM2B-D5b9UBhC.js} +1 -1
- package/dist/assets/{diagram-3UASUU5V-CDpogPbE.js → diagram-3UASUU5V-CIKLlB7Y.js} +1 -1
- package/dist/assets/{diagram-ATOU4E4O-BV8z3Pnc.js → diagram-ATOU4E4O-BzXb-7k7.js} +1 -1
- package/dist/assets/{diagram-CDSNMT55-CKCQhOOC.js → diagram-CDSNMT55-HsTvJ7uT.js} +1 -1
- package/dist/assets/{diagram-MLGK6HIB-FGvJg69C.js → diagram-MLGK6HIB-Db9ySNbQ.js} +1 -1
- package/dist/assets/{diagram-MPIPVDR6-CwATvgnS.js → diagram-MPIPVDR6-D6BKbpqY.js} +1 -1
- package/dist/assets/{ebnfDiagram-ZINNZB2B-CH_c3SSC.js → ebnfDiagram-ZINNZB2B-Bq067L0t.js} +1 -1
- package/dist/assets/{elk-276RUBZZ-3guZi71R.js → elk-276RUBZZ-DhaD7fNJ.js} +1 -1
- package/dist/assets/{erDiagram-OPXOYQCR-BMHphR2n.js → erDiagram-OPXOYQCR-fwIhfz36.js} +1 -1
- package/dist/assets/{flowDiagram-KWPJA3E3-Vm5DkW5x.js → flowDiagram-KWPJA3E3-DygIyu0V.js} +1 -1
- package/dist/assets/{ganttDiagram-FUAMR5RP-DO-1ct07.js → ganttDiagram-FUAMR5RP-DPYfSO96.js} +1 -1
- package/dist/assets/{gitGraphDiagram-X574FWY7-nxeksEIN.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-Oxk9x45i.js → index-G1PHKGyh.js} +1 -1
- package/dist/assets/{infoDiagram-VRGFBTTK-Dkcvr0p7.js → infoDiagram-VRGFBTTK-DstBLxME.js} +1 -1
- package/dist/assets/{ishikawaDiagram-OU5B5YK6-BwVibkoO.js → ishikawaDiagram-OU5B5YK6-CrIsGgch.js} +1 -1
- package/dist/assets/{journeyDiagram-ZHPQQLJL-ByiBZqgG.js → journeyDiagram-ZHPQQLJL-CYypGDbB.js} +1 -1
- package/dist/assets/{kanban-definition-PNTS6WVX-qqjgXBF3.js → kanban-definition-PNTS6WVX-DGjPt8E_.js} +1 -1
- package/dist/assets/{linear-CtJnKe6A.js → linear-Dwc3Hrru.js} +1 -1
- package/dist/assets/{mermaid.core-BEiDklrY.js → mermaid.core-Dc4OePBL.js} +5 -5
- package/dist/assets/{mindmap-definition-NLK3R4M7-DfIYHia9.js → mindmap-definition-NLK3R4M7-DV-vepj-.js} +1 -1
- package/dist/assets/{pegDiagram-GJSIUBJH-CYyRqa1a.js → pegDiagram-GJSIUBJH-lx5Kj-n5.js} +1 -1
- package/dist/assets/{pieDiagram-5QR66LMP-hOToy_zH.js → pieDiagram-5QR66LMP-Bq7Vmm15.js} +1 -1
- package/dist/assets/{quadrantDiagram-O4NWA36T-DKAsNN9I.js → quadrantDiagram-O4NWA36T-DeqeqOBj.js} +1 -1
- package/dist/assets/{railroadDiagram-XR7U4H2S-BSA-d8JU.js → railroadDiagram-XR7U4H2S-CXwRQzSC.js} +1 -1
- package/dist/assets/{requirementDiagram-PLB6GJNP-CSmlvb-p.js → requirementDiagram-PLB6GJNP-8tAeoB88.js} +1 -1
- package/dist/assets/{sankeyDiagram-IPEJSGJF-CD5F6fnG.js → sankeyDiagram-IPEJSGJF-BG5zjGWU.js} +1 -1
- package/dist/assets/{sequenceDiagram-PO4LG4MO-JUq5sx6d.js → sequenceDiagram-PO4LG4MO-DKF3iLcw.js} +1 -1
- package/dist/assets/{sizeCapture-INFHLROL-BjIS-Stk.js → sizeCapture-INFHLROL-B-y1qUgT.js} +1 -1
- package/dist/assets/{stateDiagram-v2-GCMORJYK-DklkUxOg.js → stateDiagram-v2-GCMORJYK-MkzPgxAc.js} +1 -1
- package/dist/assets/{swimlanes-2SLR337P-CsjIU6be.js → swimlanes-2SLR337P-DnbaOEwH.js} +1 -1
- package/dist/assets/swimlanesDiagram-TC7HE7FX-BZQ07DGE.js +8 -0
- package/dist/assets/{timeline-definition-EJHVYXUP-BkFqK7AB.js → timeline-definition-EJHVYXUP-Bb4xG_Bt.js} +1 -1
- package/dist/assets/{usecaseDiagram-POWQR4AR-70gJB1EU.js → usecaseDiagram-POWQR4AR-JgkR9pFf.js} +1 -1
- package/dist/assets/{vennDiagram-UO4OBE2U-83Tm62dN.js → vennDiagram-UO4OBE2U-CIPIrAYe.js} +1 -1
- package/dist/assets/{wardleyDiagram-VNRHLVJA-tTmseoX9.js → wardleyDiagram-VNRHLVJA-BjCLuUvJ.js} +1 -1
- package/dist/assets/{xychartDiagram-PMCCYNJV-R1LBA55a.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 +52 -4
- package/dist-server/drivers/agents-proxy.js +20 -10
- package/dist-server/index.js +8985 -6461
- package/dist-server/openmausbot.js +327 -145
- package/dist-server/pair-cli.js +327 -145
- 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} +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/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 +115 -6
- package/dist-server/server/container-computer.js +13 -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 +5 -4
- 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} +13 -12
- 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 +15 -4
- package/dist-server/server/drivers/pi.js +3 -3
- package/dist-server/server/hosted-models.js +6 -1
- package/dist-server/server/index.js +1029 -479
- 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 +5 -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/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 +9 -0
- package/dist-server/vps-container-mcp.js +52 -4
- package/dist-server/workspace-backup.worker.js +52 -4
- 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,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)
|
|
@@ -13,6 +13,7 @@ import { isModelVariant } from "./contracts.js";
|
|
|
13
13
|
import { PROVIDER_ICON_PRESETS, providerIconError } from "../shared/provider-icon.js";
|
|
14
14
|
import { isRemoteMcpServer, parseStoredMcpServer } from "./mcp-registry.js";
|
|
15
15
|
import { parseJson, schemaIssue } from "./schema.js";
|
|
16
|
+
import { CLOUD_SEAT_IDLE_STOP_MS } from "./cloud-overflow.js";
|
|
16
17
|
const optionalText = z.string().optional();
|
|
17
18
|
const SSH_ALIAS = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
|
|
18
19
|
const LEGACY_BROWSER_PROFILE_ID = /^[A-Za-z0-9_-]{1,40}$/;
|
|
@@ -87,7 +88,8 @@ const roomConfigSchema = z.object({
|
|
|
87
88
|
.min(MIN_ROOM_TURN_TIMEOUT_MINUTES)
|
|
88
89
|
.max(MAX_ROOM_TURN_TIMEOUT_MINUTES),
|
|
89
90
|
/** Room handoff tree lifetime. Active execution pauses this clock; the
|
|
90
|
-
* hard cap is
|
|
91
|
+
* hard cap is the maximum stall window, measured wall-clock from the
|
|
92
|
+
* tree's last durable progress. */
|
|
91
93
|
handoffLifetimeMinutes: z.number().int().min(1).max(MAX_ROOM_TURN_TIMEOUT_MINUTES).optional(),
|
|
92
94
|
handoffMinRunwayMinutes: z.number().int().min(1).max(MAX_ROOM_TURN_TIMEOUT_MINUTES).optional(),
|
|
93
95
|
handoffHardCapMinutes: z.number().int().min(1).max(7 * MAX_ROOM_TURN_TIMEOUT_MINUTES).optional(),
|
|
@@ -95,8 +97,14 @@ const roomConfigSchema = z.object({
|
|
|
95
97
|
(rooms.handoffLifetimeMinutes ?? DEFAULT_ROOM_HANDOFF_LIFETIME_MINUTES) &&
|
|
96
98
|
(rooms.handoffLifetimeMinutes ?? DEFAULT_ROOM_HANDOFF_LIFETIME_MINUTES) <=
|
|
97
99
|
(rooms.handoffHardCapMinutes ?? DEFAULT_ROOM_HANDOFF_HARD_CAP_MINUTES), { message: "rooms handoff bounds must satisfy handoffMinRunwayMinutes <= handoffLifetimeMinutes <= handoffHardCapMinutes" });
|
|
100
|
+
/** Isolation for bot desktops. Migration note (issue #1654): switching
|
|
101
|
+
* modes changes lease keys and vm-home directories, so desktops cold-start
|
|
102
|
+
* under the new mode — a pool seat lives in vm-homes/pool-N — while the old
|
|
103
|
+
* mode's workspaces stay on disk until removed. maxInstances caps per-bot
|
|
104
|
+
* desktops in per-bot mode, or the pool's seat count in pool mode; it is
|
|
105
|
+
* not a total across modes. Default stays "shared". */
|
|
98
106
|
const localVmConfigSchema = z.object({
|
|
99
|
-
mode: z.enum(["shared", "per-bot"]).optional(),
|
|
107
|
+
mode: z.enum(["shared", "per-bot", "pool"]).optional(),
|
|
100
108
|
maxInstances: z
|
|
101
109
|
.number()
|
|
102
110
|
.int()
|
|
@@ -247,6 +255,10 @@ const featureConfigSchema = z.object({
|
|
|
247
255
|
skillAuthoring: z.boolean().optional(),
|
|
248
256
|
/** Show each tool run in the transcript. Off unless explicitly enabled. */
|
|
249
257
|
showToolCalls: z.boolean().optional(),
|
|
258
|
+
/** Run a routine inside the conversation it reports to, instead of a hidden
|
|
259
|
+
* thread. Off unless explicitly enabled. The run still starts from its own
|
|
260
|
+
* instructions, but the messages stay in that chat. */
|
|
261
|
+
routinesInConversation: z.boolean().optional(),
|
|
250
262
|
/** Experimental built-in browser. Off until explicitly enabled; each bot
|
|
251
263
|
* also has its own switch. */
|
|
252
264
|
browser: z.boolean().optional(),
|
|
@@ -260,8 +272,23 @@ const featureConfigSchema = z.object({
|
|
|
260
272
|
claudeUserMcp: z.boolean().optional(),
|
|
261
273
|
/** LLM-generated titles for new bot threads. Off until explicitly
|
|
262
274
|
* enabled; a one-shot that fails or answers junk leaves the first-message
|
|
263
|
-
|
|
275
|
+
* snippet in place — see llmThreadTitlesEnabled. */
|
|
264
276
|
llmThreadTitles: z.boolean().optional(),
|
|
277
|
+
/** Before each turn, passages from the bot's own memory files and earlier
|
|
278
|
+
* conversations that share words with the message ride into the turn.
|
|
279
|
+
* Read-only; on unless explicitly switched off — see autoRecallEnabled. */
|
|
280
|
+
autoRecall: z.boolean().optional(),
|
|
281
|
+
/** Idle release for computer claims (#1653): a desktop seat that stays
|
|
282
|
+
* screen-quiet for 90 seconds is released to waiting turns while its
|
|
283
|
+
* holder's turn still lives; the previous holder re-claims directly
|
|
284
|
+
* for 10 minutes and yields to an occupied seat. Off until baked; see
|
|
285
|
+
* computerClaimIdleReleaseEnabled for how to enable it by hand. */
|
|
286
|
+
computerClaimIdleRelease: z.boolean().optional(),
|
|
287
|
+
/** Consented cloud overflow for local computer waits (#1655): a wait
|
|
288
|
+
* may offer a per-second-billed cloud seat, but only through an explicit
|
|
289
|
+
* consent card or a configured allowlist — never a silent Auto default.
|
|
290
|
+
* Off until baked; see cloudOverflowEnabled for how to enable it by hand. */
|
|
291
|
+
cloudOverflow: z.boolean().optional(),
|
|
265
292
|
});
|
|
266
293
|
/** First-run progress. Kept in the workspace config rather than a browser so
|
|
267
294
|
* it survives cleared site data and is shared by every paired client. Hint
|
|
@@ -376,6 +403,8 @@ const appConfigSchema = z.object({
|
|
|
376
403
|
/** Project key used for Sessions, catalog and agent tools. userId/sessionId
|
|
377
404
|
* are non-secret local identifiers used to reuse one Composio Session. */
|
|
378
405
|
composio: z.object({ apiKey: optionalText, userId: optionalText, sessionId: optionalText }).optional(),
|
|
406
|
+
/** Historical config section name "box" (the persisted config.json key); the
|
|
407
|
+
* provider is Boat now and the key is kept for compatibility. */
|
|
379
408
|
box: z.object({ token: optionalText }).optional(),
|
|
380
409
|
vps: vpsConfigSchema.optional(),
|
|
381
410
|
/** Optional OpenCode key; persisted write-only and passed only to its child. */
|
|
@@ -429,10 +458,26 @@ const appConfigSchema = z.object({
|
|
|
429
458
|
compactAt: z.number().positive().max(10_000_000).optional(),
|
|
430
459
|
autoCompact: z.boolean().optional(),
|
|
431
460
|
}).optional(),
|
|
461
|
+
/** Memory upkeep timing, for bots with Memory upkeep switched on. */
|
|
462
|
+
memory: z.object({
|
|
463
|
+
/** Quiet time after a turn before its facts are captured (ms). */
|
|
464
|
+
captureQuietMs: z.number().int().min(1_000).max(24 * 60 * 60_000).optional(),
|
|
465
|
+
/** Local hour (0-23) after which the nightly tidy-up runs. */
|
|
466
|
+
tidyHour: z.number().int().min(0).max(23).optional(),
|
|
467
|
+
}).strict().optional(),
|
|
432
468
|
threads: threadsConfigSchema.optional(),
|
|
433
469
|
/** The authorization decision log (server/decision-log.ts): days of month
|
|
434
470
|
* files kept, at least; OMB_DECISION_RETENTION_DAYS wins when set. */
|
|
435
471
|
decisions: z.object({ retentionDays: z.number().int().min(1).max(3650).optional() }).strict().optional(),
|
|
472
|
+
/** #1655 cloud-overflow settings. perSecondCostUsd is the operator's own
|
|
473
|
+
* verified rate: with no price configured the feature stays inert rather
|
|
474
|
+
* than show an invented one. allowlistedThreads carries standing consent
|
|
475
|
+
* for exact thread ids. */
|
|
476
|
+
cloudOverflow: z.object({
|
|
477
|
+
perSecondCostUsd: z.number().positive().max(10).optional(),
|
|
478
|
+
idleStopMs: z.number().int().positive().max(24 * 60 * 60_000).optional(),
|
|
479
|
+
allowlistedThreads: z.array(z.string().min(1)).max(1000).optional(),
|
|
480
|
+
}).strict().optional(),
|
|
436
481
|
localVm: localVmConfigSchema.optional(),
|
|
437
482
|
features: featureConfigSchema.optional(),
|
|
438
483
|
onboarding: onboardingConfigSchema.optional(),
|
|
@@ -541,8 +586,9 @@ export function roomTurnTimeoutMinutes(cfg) {
|
|
|
541
586
|
return cfg.rooms?.turnTimeoutMinutes ?? DEFAULT_ROOM_TURN_TIMEOUT_MINUTES;
|
|
542
587
|
}
|
|
543
588
|
/** Room handoff tree budgets in milliseconds. The tree lifetime pauses
|
|
544
|
-
* while a node is actively executing; the hard cap is
|
|
545
|
-
*
|
|
589
|
+
* while a node is actively executing; the hard cap is the maximum stall
|
|
590
|
+
* window, measured wall-clock from the tree's last durable progress. Read
|
|
591
|
+
* when the server starts. */
|
|
546
592
|
export function roomHandoffLimits(cfg) {
|
|
547
593
|
return {
|
|
548
594
|
lifetimeMs: (cfg.rooms?.handoffLifetimeMinutes ?? DEFAULT_ROOM_HANDOFF_LIFETIME_MINUTES) * 60_000,
|
|
@@ -578,9 +624,26 @@ export function localVmMaxInstances(cfg) {
|
|
|
578
624
|
export function skillAuthoringEnabled(cfg) {
|
|
579
625
|
return cfg.features?.skillAuthoring !== false;
|
|
580
626
|
}
|
|
627
|
+
/** Automatic recall is read-only, so it is on unless switched off. */
|
|
628
|
+
export function autoRecallEnabled(cfg) {
|
|
629
|
+
return cfg.features?.autoRecall !== false;
|
|
630
|
+
}
|
|
631
|
+
export const DEFAULT_CAPTURE_QUIET_MS = 2 * 60_000;
|
|
632
|
+
export const DEFAULT_TIDY_HOUR = 3;
|
|
633
|
+
export function captureQuietMs(cfg) {
|
|
634
|
+
return cfg.memory?.captureQuietMs ?? DEFAULT_CAPTURE_QUIET_MS;
|
|
635
|
+
}
|
|
636
|
+
export function tidyHour(cfg) {
|
|
637
|
+
return cfg.memory?.tidyHour ?? DEFAULT_TIDY_HOUR;
|
|
638
|
+
}
|
|
581
639
|
export function showToolCallsEnabled(cfg) {
|
|
582
640
|
return cfg.features?.showToolCalls === true;
|
|
583
641
|
}
|
|
642
|
+
/** A routine's turns are posted in the conversation that receives its card.
|
|
643
|
+
* Off by default, so scheduled work stays in a hidden thread. */
|
|
644
|
+
export function routinesInConversationEnabled(cfg) {
|
|
645
|
+
return cfg.features?.routinesInConversation === true;
|
|
646
|
+
}
|
|
584
647
|
/** Workspace-level gate for the experimental built-in browser. A bot's own
|
|
585
648
|
* switch sits under it, so either can withhold the browser. */
|
|
586
649
|
export function builtInBrowserEnabled(cfg) {
|
|
@@ -614,6 +677,43 @@ export function claudeUserMcpEnabled(cfg) {
|
|
|
614
677
|
export function llmThreadTitlesEnabled(cfg) {
|
|
615
678
|
return cfg.features?.llmThreadTitles === true;
|
|
616
679
|
}
|
|
680
|
+
/** Idle release for computer claims (#1653): a whole-turn desktop hold
|
|
681
|
+
* ends after a screen-quiet window (default 90 seconds, screen-poller
|
|
682
|
+
* frames excluded) instead of at turn settle, and the previous holder
|
|
683
|
+
* re-claims directly inside a reclaim window (default 10 minutes) while
|
|
684
|
+
* yielding to a seat another turn already holds. Off unless an explicit
|
|
685
|
+
* `true` — bake it as a maintainer-only flag first, exactly like
|
|
686
|
+
* sharedComputers: enable by hand in ~/.openmausbot/config.json
|
|
687
|
+
* (`{"features": {"computerClaimIdleRelease": true}}`) and restart. */
|
|
688
|
+
export function computerClaimIdleReleaseEnabled(cfg) {
|
|
689
|
+
return cfg.features?.computerClaimIdleRelease === true;
|
|
690
|
+
}
|
|
691
|
+
/** Consented cloud overflow for local computer waits (#1655): while a turn
|
|
692
|
+
* waits for a local desktop, it may offer overflow to a per-second-billed
|
|
693
|
+
* cloud seat — never silently: the card names the cost, and only an
|
|
694
|
+
* explicit per-conversation consent or a configured allowlist thread may
|
|
695
|
+
* start the machine, which then hard-stops after an idle window. Off
|
|
696
|
+
* unless an explicit `true`, like computerClaimIdleRelease: enable by
|
|
697
|
+
* hand in ~/.openmausbot/config.json
|
|
698
|
+
* (`{"features": {"cloudOverflow": true}, "cloudOverflow": {"perSecondCostUsd": 0.0004}}`)
|
|
699
|
+
* and restart. */
|
|
700
|
+
export function cloudOverflowEnabled(cfg) {
|
|
701
|
+
return cfg.features?.cloudOverflow === true;
|
|
702
|
+
}
|
|
703
|
+
/** The per-second rate shown on the card, or null when the operator has
|
|
704
|
+
* not set one: the offer fails closed rather than guess a price. */
|
|
705
|
+
export function cloudOverflowPerSecondCostUsd(cfg) {
|
|
706
|
+
const cost = cfg.cloudOverflow?.perSecondCostUsd;
|
|
707
|
+
return typeof cost === "number" && Number.isFinite(cost) && cost > 0 ? cost : null;
|
|
708
|
+
}
|
|
709
|
+
/** How long a cloud seat may stay screen-quiet before it is stopped. */
|
|
710
|
+
export function cloudOverflowIdleStopMs(cfg) {
|
|
711
|
+
return cfg.cloudOverflow?.idleStopMs ?? CLOUD_SEAT_IDLE_STOP_MS;
|
|
712
|
+
}
|
|
713
|
+
/** Threads with standing consent, by exact thread id. */
|
|
714
|
+
export function cloudOverflowAllowlistedThreads(cfg) {
|
|
715
|
+
return new Set(cfg.cloudOverflow?.allowlistedThreads ?? []);
|
|
716
|
+
}
|
|
617
717
|
/** Config sections no provider driver reads. A write that touches only
|
|
618
718
|
* these must not rebuild the fleet: rebuilding disposes every engine child
|
|
619
719
|
* and reloads it, seconds of work that would also interrupt in-flight
|
|
@@ -629,8 +729,10 @@ export const FLEET_NEUTRAL_KEYS = new Set([
|
|
|
629
729
|
"threads",
|
|
630
730
|
"automaticRecovery",
|
|
631
731
|
"context",
|
|
732
|
+
"memory",
|
|
632
733
|
"localVm",
|
|
633
734
|
"features",
|
|
735
|
+
"cloudOverflow",
|
|
634
736
|
"browserProfiles",
|
|
635
737
|
"onboarding",
|
|
636
738
|
]);
|
|
@@ -752,6 +854,7 @@ export function loadConfig() {
|
|
|
752
854
|
cfg.composio = { ...cfg.composio };
|
|
753
855
|
if (process.env.COMPOSIO_API_KEY !== undefined)
|
|
754
856
|
cfg.composio.apiKey = process.env.COMPOSIO_API_KEY;
|
|
857
|
+
// BOX_TOKEN keeps its historical name; the provider is Boat.
|
|
755
858
|
cfg.box = { ...cfg.box };
|
|
756
859
|
if (process.env.BOX_TOKEN !== undefined)
|
|
757
860
|
cfg.box.token = process.env.BOX_TOKEN;
|
|
@@ -761,6 +864,11 @@ export function loadConfig() {
|
|
|
761
864
|
cfg.tts = { ...cfg.tts };
|
|
762
865
|
if (process.env.OMB_TTS_KEY !== undefined)
|
|
763
866
|
cfg.tts.key = process.env.OMB_TTS_KEY;
|
|
867
|
+
// A preset ElevenLabs voice (Cloud Pro sets one) is only a default: a voice or
|
|
868
|
+
// another speech provider the person picked in Settings always wins.
|
|
869
|
+
const presetVoice = process.env.OMB_TTS_DEFAULT_VOICE?.trim();
|
|
870
|
+
if (presetVoice && !cfg.tts.voice?.trim() && (cfg.tts.provider ?? "elevenlabs") === "elevenlabs")
|
|
871
|
+
cfg.tts.voice = presetVoice;
|
|
764
872
|
if (process.env.OMB_FISH_AUDIO_API_KEY !== undefined)
|
|
765
873
|
cfg.tts.fishKey = process.env.OMB_FISH_AUDIO_API_KEY;
|
|
766
874
|
cfg.imageGen = { ...cfg.imageGen };
|
|
@@ -929,7 +1037,7 @@ export function saveConfig(patch, options = {}) {
|
|
|
929
1037
|
const storedProfiles = storedBrowserProfilesSchema.safeParse(disk.browserProfiles);
|
|
930
1038
|
if (storedProfiles.success)
|
|
931
1039
|
disk.browserProfiles = storedProfiles.data;
|
|
932
|
-
for (const key of ["xai", "anthropic", "mistral", "openaiCompat", "composio", "box", "opencodeGo", "tts", "imageGen", "profile", "rooms", "threads", "context", "localVm", "features", "budgets", "billing", "decisions", "onboarding", "browserEngine", "newBots"]) {
|
|
1040
|
+
for (const key of ["xai", "anthropic", "mistral", "openaiCompat", "composio", "box", "opencodeGo", "tts", "imageGen", "profile", "rooms", "threads", "context", "memory", "localVm", "features", "cloudOverflow", "budgets", "billing", "decisions", "onboarding", "browserEngine", "newBots"]) {
|
|
933
1041
|
const section = checkedPatch[key];
|
|
934
1042
|
if (!section)
|
|
935
1043
|
continue;
|
|
@@ -1138,6 +1246,7 @@ function injectedEnvironment(cfg, driver) {
|
|
|
1138
1246
|
environment.set("OPENAI_COMPAT_API_KEY", cfg.openaiCompat.key);
|
|
1139
1247
|
if (driver === "openai-compat" && cfg.openaiCompat?.url)
|
|
1140
1248
|
environment.set("OPENAI_COMPAT_URL", cfg.openaiCompat.url);
|
|
1249
|
+
// driverKind "boxAgent" and env BOX_TOKEN keep their historical names.
|
|
1141
1250
|
if (driver === "boxAgent" && cfg.box?.token)
|
|
1142
1251
|
environment.set("BOX_TOKEN", cfg.box.token);
|
|
1143
1252
|
if (driver === "opencodeGo" && cfg.opencodeGo?.apiKey)
|