openmausbot 0.1.95 → 0.1.96

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.
Files changed (140) hide show
  1. package/dist/assets/{DesktopViewer-BwWpc4RK.js → DesktopViewer-DsnA23KM.js} +1 -1
  2. package/dist/assets/{abnfDiagram-O67JEVCF-jsQkIqTn.js → abnfDiagram-O67JEVCF-Dkbd2hcj.js} +1 -1
  3. package/dist/assets/{arc-7kei4Hsu.js → arc-Duj5Coy2.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-CRWDFDMq.js → architectureDiagram-NJMV4G6O-uYUsDawP.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-CTKW_IKs.js → blockDiagram-BEXU5L5S-CWDlTpXe.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-BBTW7-6M.js → c4Diagram-YGBWAQC7-B0t15PDc.js} +1 -1
  7. package/dist/assets/channel-BZxwC-Gw.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-M_Ox-MkD.js → chunk-2Q5K7J3B-BJSCL-HV.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-D2YiN-Ud.js → chunk-3FUC2YCW-D3_ILccd.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-BmVYJ0f8.js → chunk-5VM5RSS4-D3dmwT_S.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-BtiLVVnZ.js → chunk-GTNCS2PH-Br73HFPD.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-DFYSb6J6.js → chunk-GWA4HPMP-uSsjxubC.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-mknKBzgV.js → chunk-JWPE2WC7-C40VWFyr.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-DgSYGiOu.js → chunk-NETBCI7D-D6WNNYwU.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-C7ZvuguH.js → chunk-WEXAMYUT-DOEhq64P.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-CPqxINpA.js → chunk-XXDRQBXY-BqJEi6Pi.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-Cd6xfJaC.js → classDiagram-v2-NBCMYWYE-ByU_GnxT.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-BSXY5qRh.js → cose-bilkent-JH36ORCC-D-Uglxqq.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-D_lhEMj1.js → cynefin-OW5HDTMX-CZWO7aYj.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-Da9FrFwk.js → cynefinDiagram-VND7K2PF-C70-w0la.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-T77jMOXe.js → dagre-6A5THRUB-CnhjhExB.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-Dkj96X5E.js → diagram-22UHCM2B-bnjWCNb4.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-CMi9zlD_.js → diagram-3UASUU5V-A4uTWoFV.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-CXK6YUfS.js → diagram-ATOU4E4O-Zi6m3NUl.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-BE7B1u9Q.js → diagram-CDSNMT55-DtCcmd5k.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-CCMQphKr.js → diagram-MLGK6HIB-DofuETS3.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-iHJ_-tGH.js → diagram-MPIPVDR6-Dw2T9PuU.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-DONsCWQ7.js → ebnfDiagram-ZINNZB2B-DbrwPiem.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-CU_WOaXk.js → elk-276RUBZZ-BvCt1dCb.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-BeYwM04G.js → erDiagram-OPXOYQCR-Y-QKqPnU.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-Bdw9DqEl.js → flowDiagram-KWPJA3E3-CDkL1B5w.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-C9iwq3qm.js → ganttDiagram-FUAMR5RP-CKV5D2F6.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-Dx-VWzUg.js → gitGraphDiagram-X574FWY7-nL9iFTCv.js} +1 -1
  34. package/dist/assets/{index-KygHRE3E.js → index-DreDnjOu.js} +1 -1
  35. package/dist/assets/index-Du7o6Dsf.js +610 -0
  36. package/dist/assets/index-KxG4bWSr.css +1 -0
  37. package/dist/assets/{infoDiagram-VRGFBTTK-BG7uaEd4.js → infoDiagram-VRGFBTTK-C7aHjzsf.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-D72WAXnM.js → ishikawaDiagram-OU5B5YK6-KUZsNBTh.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-DKH1gjYT.js → journeyDiagram-ZHPQQLJL-CGUuodFl.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-3upm9JUT.js → kanban-definition-PNTS6WVX-Cy6r868Z.js} +1 -1
  41. package/dist/assets/{linear-CcR4pc0-.js → linear-Bl669148.js} +1 -1
  42. package/dist/assets/{mermaid.core-C6yU5FSS.js → mermaid.core-86Xqz5Mr.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-DOI2xEuv.js → mindmap-definition-NLK3R4M7-G4DzHLrd.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-BVWJSgLd.js → pegDiagram-GJSIUBJH-K4eruzY0.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-jtbYezhq.js → pieDiagram-5QR66LMP-DYdR9PPq.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-g0u8mtPX.js → quadrantDiagram-O4NWA36T-BCAh0nEy.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-k8689Wny.js → railroadDiagram-XR7U4H2S-p0dGRc4g.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-DsJ22PDt.js → requirementDiagram-PLB6GJNP-tLeBG0aL.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-CneWBRgG.js → sankeyDiagram-IPEJSGJF-QRuN16rV.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-DWsFCWap.js → sequenceDiagram-PO4LG4MO-ueT1nVoa.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-Bvf0YE3S.js → sizeCapture-INFHLROL-wRS2zWg2.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-Bxb3Iei8.js → stateDiagram-v2-GCMORJYK-CYzA_hE1.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-DLkk7W4A.js → swimlanes-2SLR337P-kDnv6LMc.js} +1 -1
  54. package/dist/assets/swimlanesDiagram-TC7HE7FX-C7voMDle.js +8 -0
  55. package/dist/assets/{timeline-definition-EJHVYXUP-KR9bIHYC.js → timeline-definition-EJHVYXUP-D1y2UbbH.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-DfsOrj8t.js → usecaseDiagram-POWQR4AR-IqB4v9HX.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-DRSTzG3h.js → vennDiagram-UO4OBE2U-BEi7KYVF.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-DtEdhPgT.js → wardleyDiagram-VNRHLVJA-Dkybo-Yf.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-B-XhTxjG.js → xychartDiagram-PMCCYNJV-BAPFJLpg.js} +1 -1
  60. package/dist/index.html +2 -2
  61. package/dist-server/cloud-home-start.js +25 -12
  62. package/dist-server/companion/src/listener.js +39 -18
  63. package/dist-server/container-mcp.js +14 -27
  64. package/dist-server/drivers/agents-proxy.js +103 -44
  65. package/dist-server/harness-mcp-proxy.js +1 -2
  66. package/dist-server/index.js +2633 -3396
  67. package/dist-server/openmausbot.js +606 -463
  68. package/dist-server/pair-cli.js +606 -463
  69. package/dist-server/server/admin-activity.js +1 -4
  70. package/dist-server/server/auto-approve.js +19 -4
  71. package/dist-server/server/boat.js +4 -4
  72. package/dist-server/server/checkpoints.js +90 -148
  73. package/dist-server/server/chief-of-staff.js +7 -6
  74. package/dist-server/server/cli.js +33 -26
  75. package/dist-server/server/cloud-home-start.js +12 -15
  76. package/dist-server/server/cloud-home.js +0 -4
  77. package/dist-server/server/cloud-move-http.js +44 -30
  78. package/dist-server/server/cloud-move.js +20 -18
  79. package/dist-server/server/composio.js +10 -3
  80. package/dist-server/server/config.js +92 -64
  81. package/dist-server/server/container-computer.js +16 -5
  82. package/dist-server/server/contracts.js +0 -9
  83. package/dist-server/server/decider/jev.js +1 -4
  84. package/dist-server/server/decider/log.js +1 -3
  85. package/dist-server/server/decision-log.js +1 -4
  86. package/dist-server/server/delegations.js +3 -2
  87. package/dist-server/server/drivers/acp/core.js +4 -3
  88. package/dist-server/server/drivers/acp/opencode-go.js +17 -16
  89. package/dist-server/server/drivers/agents-call.js +46 -63
  90. package/dist-server/server/drivers/agents-catalog.js +66 -0
  91. package/dist-server/server/drivers/agents-proxy.js +2 -1
  92. package/dist-server/server/drivers/claude-login-auth.js +2 -11
  93. package/dist-server/server/drivers/codex-device-auth.js +3 -1
  94. package/dist-server/server/drivers/codex.js +5 -4
  95. package/dist-server/server/drivers/grok.js +1 -1
  96. package/dist-server/server/drivers/openai-chat-protocol.js +4 -2
  97. package/dist-server/server/drivers/openai-chat.js +15 -5
  98. package/dist-server/server/drivers/prompt-split.js +4 -4
  99. package/dist-server/server/harness/bus.js +49 -0
  100. package/dist-server/server/harness-mcp-proxy.js +1 -1
  101. package/dist-server/server/index.js +448 -762
  102. package/dist-server/server/mcp-registry.js +35 -0
  103. package/dist-server/server/message-file.js +4 -4
  104. package/dist-server/server/peer-delivery.js +4 -19
  105. package/dist-server/server/peer-roster.js +30 -4
  106. package/dist-server/server/profile-versions.js +0 -31
  107. package/dist-server/server/provider-key-check.js +2 -1
  108. package/dist-server/server/restart.js +40 -0
  109. package/dist-server/server/room-handoffs.js +12 -0
  110. package/dist-server/server/routes/bot-memory.js +0 -24
  111. package/dist-server/server/routine-requests.js +1 -4
  112. package/dist-server/server/routines.js +5 -4
  113. package/dist-server/server/server-launcher.js +53 -0
  114. package/dist-server/server/store.js +1 -1
  115. package/dist-server/server/surface.js +23 -27
  116. package/dist-server/server/system-prompt.js +13 -3
  117. package/dist-server/server/team-manifest.js +1 -6
  118. package/dist-server/server/tunnel.js +4 -1
  119. package/dist-server/server/turn-images.js +1 -21
  120. package/dist-server/server/turn-resources.js +19 -150
  121. package/dist-server/server/usage-ledger.js +3 -1
  122. package/dist-server/server/workspace.js +2 -3
  123. package/dist-server/server-launcher.js +72 -0
  124. package/dist-server/shared/ask-question.js +1 -1
  125. package/dist-server/shared/cloud-computer.js +12 -0
  126. package/dist-server/shared/failed-turn.js +25 -0
  127. package/dist-server/shared/live-approval.js +2 -2
  128. package/dist-server/shared/package-format.js +3 -3
  129. package/dist-server/shared/pairing-link.js +144 -0
  130. package/dist-server/vps-container-mcp.js +14 -27
  131. package/dist-server/workspace-backup.worker.js +14 -27
  132. package/package.json +1 -1
  133. package/dist/assets/channel-DS2NbT5x.js +0 -1
  134. package/dist/assets/index-NF7XCHAy.css +0 -1
  135. package/dist/assets/index-X_5ydXIE.js +0 -610
  136. package/dist/assets/swimlanesDiagram-TC7HE7FX-C6bZtBXk.js +0 -8
  137. package/dist-server/server/claim-idle.js +0 -33
  138. package/dist-server/server/cloud-overflow.js +0 -167
  139. package/dist-server/server/tightening-requests.js +0 -435
  140. package/dist-server/shared/tightening-request.js +0 -87
@@ -8,7 +8,7 @@
8
8
  // forwards with X-Forwarded-*, so request-auth.ts never grants a remote
9
9
  // request loopback trust. If either child exits, both stop and the machine
10
10
  // restarts; the one exception is the server asking to be started again
11
- // after a restore.
11
+ // after a restore (server/restart.ts).
12
12
  //
13
13
  // The machine's secrets (the signing secret, the relay tokens) arrive as
14
14
  // this process's environment. No child's environment ever carries them:
@@ -24,7 +24,8 @@ import { spawn } from "node:child_process";
24
24
  import { chownSync, readFileSync, statSync } from "node:fs";
25
25
  import { dirname, join, posix, resolve } from "node:path";
26
26
  import { fileURLToPath, pathToFileURL } from "node:url";
27
- import { CLOUD_HOME_RESTART_EXIT_CODE, CLOUD_SECRETS_FD_ENV, cloudHomeConfiguration, cloudHomeHost, cloudHomeSecrets, prepareCloudHomeVolume, withoutCloudSecrets, withoutIgnoredCloudKeys, } from "./cloud-home.js";
27
+ import { CLOUD_SECRETS_FD_ENV, cloudHomeConfiguration, cloudHomeHost, cloudHomeSecrets, prepareCloudHomeVolume, withoutCloudSecrets, withoutIgnoredCloudKeys, } from "./cloud-home.js";
28
+ import { restartPolicy } from "./restart.js";
28
29
  const SERVICE_USER = "maus";
29
30
  /** The descriptor the server reads its secrets from. */
30
31
  const SECRETS_FD = 3;
@@ -112,11 +113,6 @@ export function spawnWithSecrets(command, args, env, secrets, ids) {
112
113
  pipe?.end(JSON.stringify(secrets));
113
114
  return child;
114
115
  }
115
- /** What the launcher does when the server child exits: start it again only
116
- * when it asked to (a committed restore), and only a few times in a row. */
117
- export function serverExitAction(code, stopping, restarts) {
118
- return !stopping && code === CLOUD_HOME_RESTART_EXIT_CODE && restarts < 5 ? "restart" : "stop";
119
- }
120
116
  export function startCloudHome(env = process.env) {
121
117
  process.umask(0o077);
122
118
  const config = cloudHomeConfiguration(env);
@@ -189,14 +185,15 @@ export function startCloudHome(env = process.env) {
189
185
  stop(true);
190
186
  });
191
187
  };
192
- let restarts = 0;
193
- const runServer = () => watch(spawnWithSecrets(process.execPath, [join(here, "index.js")], server, secrets, ids), (code) => {
194
- if (serverExitAction(code, stopping, restarts) !== "restart")
195
- return false;
196
- restarts++;
197
- runServer();
198
- return true;
199
- });
188
+ const policy = restartPolicy();
189
+ const runServer = () => {
190
+ watch(spawnWithSecrets(process.execPath, [join(here, "index.js")], server, secrets, ids), (code) => {
191
+ if (!policy.again(code, stopping))
192
+ return false;
193
+ runServer();
194
+ return true;
195
+ });
196
+ };
200
197
  runServer();
201
198
  watch(spawn(edgeBin, ["run", "--config", edgeConfig, "--adapter", "caddyfile"], { env: edge, stdio: "inherit", ...(ids ? { uid: ids.uid, gid: ids.gid } : {}) }));
202
199
  process.once("SIGTERM", () => stop(false));
@@ -45,10 +45,6 @@ export const CLOUD_PAIRING_SKEW_S = 300;
45
45
  export const CLOUD_PAIRING_NONCE_MS = 10 * 60_000;
46
46
  const MAX_NONCES = 10_000;
47
47
  export const CLOUD_HOME_MARKER = ".omb-cloud-home.json";
48
- /** The server exits with this after a restore commits (Move to Cloud): the
49
- * launcher then starts it again, and startup installs the restore. Any other
50
- * exit stops the machine for Fly to restart. */
51
- export const CLOUD_HOME_RESTART_EXIT_CODE = 75;
52
48
  export function cloudHomeConfigured(env = process.env) {
53
49
  return CLOUD_HOME_KEYS.some((key) => env[key] !== undefined);
54
50
  }
@@ -1,9 +1,18 @@
1
1
  import { z } from "zod";
2
+ import { sharedSignIn } from "./admin-activity.js";
2
3
  import { backupsBytes, beginUpload, CLOUD_MOVE_MAX_BYTES, CLOUD_MOVE_MAX_PART_BYTES, CLOUD_MOVE_PART_BYTES, completedUpload, discardNextPreviousCloud, discardUpload, enabledRoutineCount, forgetMoveRestore, freeVolumeBytes, isEmptyWorkspace, moveSpaceNeeded, noteMoveRestore, prepareNextPreviousCloud, previousCloud, previousCloudArchiveBytes, removeMoveFiles, stagePreviousCloud, tidyCloudMoveStorage, totalVolumeBytes, uploadStatus, validUploadDeclaration, workspaceContents, workspaceMoveSize, writeUploadPart, } from "./cloud-move.js";
3
4
  import { commitPendingWorkspaceRestore, createWorkspaceBackup, stageWorkspaceBackup } from "./workspace-backup.js";
4
5
  export const CLOUD_MOVE_PREFIX = "/api/cloud-move";
5
6
  const failure = (message, status) => Object.assign(new Error(message), { status });
6
7
  const HELD_CACHE_MS = 30_000;
8
+ /** Shared with other people, so one person's copy must never replace it: a
9
+ * hosted organisation workspace, or (not on a Cloud home, whose sign-in is its
10
+ * owner's OMB Cloud account) an email sign-in list that lets someone else in
11
+ * (sharedSignIn: a member, a second admin, or a whole @domain). The owner's
12
+ * own address alone (`openmausbot access add you@example.com`) is not. */
13
+ export function workspaceShared(input) {
14
+ return input.hosted || (!input.cloudHome && sharedSignIn(input.signIn));
15
+ }
7
16
  export function createCloudMoveRoutes(options) {
8
17
  const freeBytes = options.freeBytes ?? freeVolumeBytes;
9
18
  const volumeBytes = (path) => { try {
@@ -12,16 +21,17 @@ export function createCloudMoveRoutes(options) {
12
21
  catch {
13
22
  return null;
14
23
  } };
15
- // A move's restore that startup has installed keeps no safety copy or
16
- // staged files; an abandoned upload goes after a day.
17
- if (options.cloudHome) {
18
- try {
19
- tidyCloudMoveStorage(options.dataDir, options.restored);
20
- }
21
- catch (error) {
22
- console.warn(`cloud move: could not tidy backup storage (${error instanceof Error ? error.message : "unknown"})`);
23
- }
24
+ // A copy's restore that startup has installed keeps no safety copy or
25
+ // staged files, and the workspace it replaced becomes the one to swap back
26
+ // to; an abandoned upload goes after a day. Only what a copy recorded is
27
+ // touched, so on a server that never received one this does nothing.
28
+ try {
29
+ tidyCloudMoveStorage(options.dataDir, options.restored);
30
+ }
31
+ catch (error) {
32
+ console.warn(`workspace copy: could not tidy backup storage (${error instanceof Error ? error.message : "unknown"})`);
24
33
  }
34
+ const identity = { appVersion: options.appVersion, environmentId: options.environmentId };
25
35
  // Staged here by a preview; restore accepts nothing else.
26
36
  const staged = new Set();
27
37
  let job = null;
@@ -33,16 +43,16 @@ export function createCloudMoveRoutes(options) {
33
43
  };
34
44
  const check = (req, auth) => {
35
45
  if (!options.authorized(req, auth))
36
- throw failure("Your session changed. Start the move again.", 403);
46
+ throw failure("Your session changed. Start the copy again.", 403);
37
47
  };
38
48
  const ready = () => {
39
49
  if (job?.state === "running" || writing)
40
- throw failure("Another move step is running on your Cloud. Wait for it to finish.", 409);
50
+ throw failure("Another copy step is running on this server. Wait for it to finish.", 409);
41
51
  const status = options.status();
42
52
  if (status.pendingRestore)
43
- throw failure("Your Cloud is restarting to finish a restore. Try again in a minute.", 409);
53
+ throw failure("This server is restarting to finish a restore. Try again in a minute.", 409);
44
54
  if (status.busy)
45
- throw failure("Your Cloud is busy with a backup. Try again when it finishes.", 409);
55
+ throw failure("This server is busy with a backup. Try again when it finishes.", 409);
46
56
  };
47
57
  /** Delete what earlier previews staged and no restore took. */
48
58
  const dropStaged = () => {
@@ -63,7 +73,7 @@ export function createCloudMoveRoutes(options) {
63
73
  after?.(result);
64
74
  }, (error) => {
65
75
  if (job === current)
66
- job = { kind, state: "failed", error: error instanceof Error ? error.message : "The move could not finish." };
76
+ job = { kind, state: "failed", error: error instanceof Error ? error.message : "The copy could not finish." };
67
77
  });
68
78
  }
69
79
  // A person's own page can hold the workspace gate for a moment (an ordinary
@@ -106,7 +116,7 @@ export function createCloudMoveRoutes(options) {
106
116
  const roomFor = (archive, already = 0) => {
107
117
  const needed = moveSpaceNeeded(archive, workspaceMoveSize(options.dataDir).bytes, true) - already;
108
118
  const free = freeBytes(options.dataDir);
109
- return free >= needed ? null : { error: "Your Cloud does not have enough free space for this.", freeBytes: free, neededBytes: needed };
119
+ return free >= needed ? null : { error: "This server does not have enough free space for this.", freeBytes: free, neededBytes: needed };
110
120
  };
111
121
  // The answer that the restore is ready goes out first; then the launcher
112
122
  // starts the server again, and startup installs the restore before anything else loads.
@@ -117,15 +127,16 @@ export function createCloudMoveRoutes(options) {
117
127
  const method = req.method ?? "GET";
118
128
  try {
119
129
  if (!auth.scopes.includes("admin"))
120
- throw failure("Only the owner can move a workspace.", 403);
130
+ throw failure("Only the owner can copy a workspace.", 403);
121
131
  if (method === "GET" && path === `${CLOUD_MOVE_PREFIX}/estimate`) {
122
- json(res, 200, { ...workspaceContents(options.dataDir), ...workspaceMoveSize(options.dataDir), routines: enabledRoutineCount(options.dataDir), maxBytes: CLOUD_MOVE_MAX_BYTES });
132
+ json(res, 200, { ...workspaceContents(options.dataDir), ...workspaceMoveSize(options.dataDir), routines: enabledRoutineCount(options.dataDir), maxBytes: CLOUD_MOVE_MAX_BYTES, ...identity });
123
133
  return true;
124
134
  }
125
- if (!options.cloudHome)
126
- throw failure("Only an OMB Cloud home receives a moved workspace.", 404);
127
135
  if (auth.kind !== "session")
128
- throw failure("Move to Cloud needs the owner's signed-in app.", 403);
136
+ throw failure("Copying a workspace needs the owner's signed-in app.", 403);
137
+ if (options.sharedWorkspace()) {
138
+ throw Object.assign(failure("This server is shared with other people, so it can't receive one computer's bots and chats. Copy to a server only you use.", 403), { code: "shared_workspace" });
139
+ }
129
140
  check(req, auth);
130
141
  if (method === "GET" && path === CLOUD_MOVE_PREFIX) {
131
142
  const contents = workspaceContents(options.dataDir);
@@ -134,8 +145,10 @@ export function createCloudMoveRoutes(options) {
134
145
  json(res, 200, {
135
146
  contents, empty: isEmptyWorkspace(contents), freeBytes: freeBytes(options.dataDir), volumeBytes: volumeBytes(options.dataDir), maxBytes: CLOUD_MOVE_MAX_BYTES,
136
147
  partBytes: CLOUD_MOVE_PART_BYTES, upload: uploadStatus(options.dataDir), previous: previousCloud(options.dataDir), job,
137
- // What backups and the previous Cloud hold on the volume.
148
+ // What backups and the previous workspace hold on the volume.
138
149
  heldBytes: held.bytes,
150
+ // The app refuses a server older than itself, or its own, before exporting.
151
+ ...identity,
139
152
  ...options.status(),
140
153
  lastRestoreId: options.restored.restored ? options.restored.id ?? null : null,
141
154
  rolledBackId: options.restored.rolledBack ? options.restored.id ?? null : null,
@@ -191,7 +204,7 @@ export function createCloudMoveRoutes(options) {
191
204
  }
192
205
  if (discardPreview) {
193
206
  removeMoveFiles(options.dataDir, result.id);
194
- throw failure("The move was stopped; what it staged was removed.", 409);
207
+ throw failure("The copy was stopped; what it staged was removed.", 409);
195
208
  }
196
209
  try {
197
210
  check(req, auth);
@@ -213,7 +226,7 @@ export function createCloudMoveRoutes(options) {
213
226
  if (method === "POST" && path === `${CLOUD_MOVE_PREFIX}/discard`) {
214
227
  await options.readBody(req, 4096);
215
228
  if (job?.state === "running" && job.kind !== "preview")
216
- throw failure("Your Cloud is already replacing its workspace.", 409);
229
+ throw failure("This server is already replacing its workspace.", 409);
217
230
  // A preview still running removes what it stages when it ends.
218
231
  if (job?.state === "running")
219
232
  discardPreview = true;
@@ -226,7 +239,7 @@ export function createCloudMoveRoutes(options) {
226
239
  const body = z.object({ id: z.string().uuid() }).parse(await options.readBody(req, 4096));
227
240
  ready();
228
241
  if (!staged.has(body.id))
229
- throw failure("This move is no longer ready on your Cloud. Start it again.", 404);
242
+ throw failure("This copy is no longer ready on this server. Start it again.", 404);
230
243
  start("restore", async () => {
231
244
  const { committed, previous } = await replaceWith(body.id, req, auth);
232
245
  return { kind: "restore", state: "done", id: committed.id, previous };
@@ -238,15 +251,15 @@ export function createCloudMoveRoutes(options) {
238
251
  await options.readBody(req, 4096);
239
252
  ready();
240
253
  if (!previousCloud(options.dataDir))
241
- throw failure("There is no previous Cloud to restore.", 404);
254
+ throw failure("There is nothing on this server to swap back to.", 404);
242
255
  const refused = roomFor(previousCloudArchiveBytes(options.dataDir));
243
256
  if (refused) {
244
257
  json(res, 507, refused);
245
258
  return true;
246
259
  }
247
260
  dropStaged();
248
- // A swap: what the Cloud has now becomes the previous Cloud, so this
249
- // can be undone the same way.
261
+ // A swap: what this server has now becomes the previous workspace, so
262
+ // this can be undone the same way.
250
263
  start("undo", async () => {
251
264
  const prepared = await stagePreviousCloud(options.dataDir, options.appVersion);
252
265
  const { committed, previous } = await replaceWith(prepared.id, req, auth);
@@ -255,7 +268,7 @@ export function createCloudMoveRoutes(options) {
255
268
  json(res, 202, { job });
256
269
  return true;
257
270
  }
258
- json(res, 404, { error: "Unknown move operation." });
271
+ json(res, 404, { error: "Unknown copy operation." });
259
272
  }
260
273
  catch (error) {
261
274
  if (res.headersSent) {
@@ -263,10 +276,11 @@ export function createCloudMoveRoutes(options) {
263
276
  return true;
264
277
  }
265
278
  const status = error instanceof z.ZodError ? 400 : error.status ?? 400;
266
- const received = error.received;
279
+ const received = error.received, code = error.code;
267
280
  json(res, status, {
268
- error: error instanceof z.ZodError ? "Invalid move request." : error instanceof Error ? error.message : "The move failed.",
281
+ error: error instanceof z.ZodError ? "Invalid copy request." : error instanceof Error ? error.message : "The copy failed.",
269
282
  ...(Number.isSafeInteger(received) ? { received } : {}),
283
+ ...(code === "shared_workspace" ? { code } : {}),
270
284
  });
271
285
  }
272
286
  return true;
@@ -1,16 +1,18 @@
1
- // Move to Cloud (docs/cloud-pro.md): a person's one-time copy of their local
2
- // workspace onto their OMB Cloud home. The desktop exports the ordinary
3
- // encrypted workspace backup (workspace-backup.ts) and uploads it here in
4
- // parts; this machine then previews and restores it like any other backup,
5
- // so everything that backup policy keeps out (credentials, sign-ins, pairing
6
- // and sessions, runtime state) stays where it is on both sides.
1
+ // Copy this computer here (docs/copy-workspace.md): a person's copy of their
2
+ // desktop's workspace onto a server they own and added in the desktop app, an
3
+ // OMB Cloud home included. The desktop exports the ordinary encrypted
4
+ // workspace backup (workspace-backup.ts) and uploads it here in parts; this
5
+ // server then previews and restores it like any other backup, so everything
6
+ // that backup policy keeps out (credentials, sign-ins, pairing and sessions,
7
+ // runtime state) stays where it is on both sides.
7
8
  //
8
9
  // What this file adds: the workspace's size and contents, a resumable upload
9
- // slot, the space check for the machine's fixed-size volume, a backup of the
10
- // Cloud's own workspace taken before it is replaced ("Restore previous Cloud"
11
- // swaps it back), and the tidying that keeps that backup the only copy left
12
- // behind. Everything lives under `.backups`, which no snapshot includes and
13
- // no restore replaces.
10
+ // slot, the space check for the server's volume, a backup of this server's
11
+ // own workspace taken before it is replaced (Swap back puts it back), and the
12
+ // tidying that keeps that backup the only copy left behind. Everything lives
13
+ // under `.backups`, which no snapshot includes and no restore replaces. The
14
+ // file and folder names still say "cloud" (`cloud-move`, `cloud-previous`):
15
+ // renaming them would orphan the swap back on servers already running.
14
16
  import { createHash, randomBytes } from "node:crypto";
15
17
  import { createReadStream, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statfsSync, writeFileSync } from "node:fs";
16
18
  import { open } from "node:fs/promises";
@@ -167,9 +169,9 @@ export function validUploadDeclaration(input) {
167
169
  if (!Number.isSafeInteger(input.bytes) || input.bytes < MIN_BYTES)
168
170
  throw fail("This is not a workspace backup.", 400);
169
171
  if (input.bytes > CLOUD_MOVE_MAX_BYTES)
170
- throw fail("This workspace is larger than the 10 GB a move can carry.", 413);
172
+ throw fail("This workspace is larger than the 10 GB a copy can carry.", 413);
171
173
  if (input.files !== undefined && (!Number.isSafeInteger(input.files) || input.files < 0 || input.files > CLOUD_MOVE_MAX_FILES)) {
172
- throw fail("This workspace has more than the 100,000 files a move can carry.", 413);
174
+ throw fail("This workspace has more than the 100,000 files a copy can carry.", 413);
173
175
  }
174
176
  return { sha256: input.sha256, bytes: input.bytes };
175
177
  }
@@ -193,7 +195,7 @@ export function beginUpload(dataDir, declared, now = Date.now()) {
193
195
  export async function writeUploadPart(dataDir, sha256, offset, length, body, now = Date.now()) {
194
196
  const meta = readUploadMeta(dataDir);
195
197
  if (!meta || meta.sha256 !== sha256)
196
- throw fail("This upload is not in progress on your Cloud. Start the move again.", 404);
198
+ throw fail("This upload is not in progress on this server. Start the copy again.", 404);
197
199
  if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(length) || length <= 0 || length > CLOUD_MOVE_MAX_PART_BYTES) {
198
200
  throw fail("Invalid upload part.", 400);
199
201
  }
@@ -238,7 +240,7 @@ export async function writeUploadPart(dataDir, sha256, offset, length, body, now
238
240
  export async function completedUpload(dataDir, sha256) {
239
241
  const status = uploadStatus(dataDir);
240
242
  if (!status || status.sha256 !== sha256)
241
- throw fail("This upload is not in progress on your Cloud. Start the move again.", 404);
243
+ throw fail("This upload is not in progress on this server. Start the copy again.", 404);
242
244
  if (status.received !== status.bytes)
243
245
  throw fail("The upload is not complete yet.", 409, { received: status.received });
244
246
  const hash = createHash("sha256");
@@ -246,7 +248,7 @@ export async function completedUpload(dataDir, sha256) {
246
248
  hash.update(chunk);
247
249
  if (hash.digest("hex") !== sha256) {
248
250
  discardUpload(dataDir);
249
- throw fail("The uploaded file was damaged on the way. Start the move again.", 400);
251
+ throw fail("The uploaded file was damaged on the way. Start the copy again.", 400);
250
252
  }
251
253
  return uploadPartPath(dataDir);
252
254
  }
@@ -312,7 +314,7 @@ export function discardNextPreviousCloud(dataDir) {
312
314
  export async function stagePreviousCloud(dataDir, appVersion) {
313
315
  const previous = readPrevious(previousFolder(dataDir));
314
316
  if (!previous || isEmptyWorkspace(previous.contents))
315
- throw fail("There is no previous Cloud to restore.", 404);
317
+ throw fail("There is nothing on this server to swap back to.", 404);
316
318
  const archive = join(previousFolder(dataDir), "workspace.ombbackup");
317
319
  const staged = await stageWorkspaceBackup(dataDir, archive, { password: previous.password, currentAppVersion: appVersion });
318
320
  return { ...staged, bytes: lstatSync(archive).size };
@@ -373,7 +375,7 @@ function pendingRestoreId(dataDir) {
373
375
  return null;
374
376
  }
375
377
  }
376
- /** At a Cloud home's startup, after any pending restore was applied:
378
+ /** At every server's startup, after any pending restore was applied:
377
379
  * - a next previous Cloud becomes the previous Cloud once its restore is
378
380
  * installed, and is dropped otherwise;
379
381
  * - moves' and swaps' restores, once settled, keep no safety copy or staged
@@ -1106,7 +1106,7 @@ export async function removeAccount(cfg, slug, accountId) {
1106
1106
  }
1107
1107
  /** Mint a browser auth link for one service. Returns { url } or throws. */
1108
1108
  export async function authorizeService(cfg, slug, requestedAlias) {
1109
- const alias = normalizeAccountAlias(requestedAlias);
1109
+ let alias = normalizeAccountAlias(requestedAlias);
1110
1110
  const toolkit = canonicalToolkitSlug(slug);
1111
1111
  const mode = connectionMode(cfg);
1112
1112
  const unavailable = managedConnectorUnavailableReason(mode, toolkit);
@@ -1136,8 +1136,15 @@ export async function authorizeService(cfg, slug, requestedAlias) {
1136
1136
  if (usableAccounts.length >= MULTI_ACCOUNT_CONFIG.max_accounts_per_toolkit) {
1137
1137
  throw inputError(`${toolkit} already has the maximum of ${MULTI_ACCOUNT_CONFIG.max_accounts_per_toolkit} accounts`, 409);
1138
1138
  }
1139
- if (usableAccounts.length > 0 && !alias) {
1140
- throw inputError("Add an account alias so the existing connection is not replaced");
1139
+ if (serviceAccounts.length > 0 && !alias) {
1140
+ if (serviceAccounts.every((account) => /^(initializing|initiated|expired)$/i.test(account.status ?? ""))) {
1141
+ // Retry without replacing a named account or revoking a former grant:
1142
+ // EXPIRED can describe either an abandoned flow or an expired credential.
1143
+ alias = `omb-retry-${randomUUID()}`;
1144
+ }
1145
+ else {
1146
+ throw inputError("Add an account alias so the existing connection is not replaced");
1147
+ }
1141
1148
  }
1142
1149
  if (alias && serviceAccounts.some((account) => account.alias?.trim().toLowerCase() === alias.toLowerCase())) {
1143
1150
  throw inputError(`Account alias "${alias}" is already in use for ${toolkit}`, 409);
@@ -13,7 +13,6 @@ 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";
17
16
  const optionalText = z.string().optional();
18
17
  const SSH_ALIAS = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
19
18
  const LEGACY_BROWSER_PROFILE_ID = /^[A-Za-z0-9_-]{1,40}$/;
@@ -292,17 +291,6 @@ const featureConfigSchema = z.object({
292
291
  * conversations that share words with the message ride into the turn.
293
292
  * Read-only; on unless explicitly switched off — see autoRecallEnabled. */
294
293
  autoRecall: z.boolean().optional(),
295
- /** Idle release for computer claims (#1653): a desktop seat that stays
296
- * screen-quiet for 90 seconds is released to waiting turns while its
297
- * holder's turn still lives; the previous holder re-claims directly
298
- * for 10 minutes and yields to an occupied seat. Off until baked; see
299
- * computerClaimIdleReleaseEnabled for how to enable it by hand. */
300
- computerClaimIdleRelease: z.boolean().optional(),
301
- /** Consented cloud overflow for local computer waits (#1655): a wait
302
- * may offer a per-second-billed cloud seat, but only through an explicit
303
- * consent card or a configured allowlist — never a silent Auto default.
304
- * Off until baked; see cloudOverflowEnabled for how to enable it by hand. */
305
- cloudOverflow: z.boolean().optional(),
306
294
  /** Opt-in shared skills library: one store at the data dir that bots
307
295
  * read by assignment instead of per-workspace copies. Off until
308
296
  * explicitly enabled — see skillsLibraryEnabled. */
@@ -436,8 +424,12 @@ const appConfigSchema = z.object({
436
424
  * provider is Boat now and the key is kept for compatibility. */
437
425
  box: z.object({ token: optionalText }).optional(),
438
426
  vps: vpsConfigSchema.optional(),
439
- /** Optional OpenCode key; persisted write-only and passed only to its child. */
440
- opencodeGo: z.object({ apiKey: optionalText }).optional(),
427
+ /** Optional OpenCode key; persisted write-only and passed only to its child.
428
+ * `providerKeys`: keys for OpenCode's other providers (Venice, Groq…) by the
429
+ * environment name OpenCode reads, also write-only and only for its child.
430
+ * Settings changes go through mergeOpenCodeProviderKeys; the stored copy
431
+ * is read loosely (storedAppConfigSchema). */
432
+ opencodeGo: z.object({ apiKey: optionalText, providerKeys: z.record(z.string(), z.string()).optional() }).optional(),
441
433
  /** Voice settings and the selected voice id. `provider` picks the
442
434
  * engine: "elevenlabs" (default; needs `key`), "fish" (needs its own
443
435
  * `fishKey`; `fishModel` picks its speech model), "system" (the Mac's
@@ -526,15 +518,6 @@ const appConfigSchema = z.object({
526
518
  /** The authorization decision log (server/decision-log.ts): days of month
527
519
  * files kept, at least; OMB_DECISION_RETENTION_DAYS wins when set. */
528
520
  decisions: z.object({ retentionDays: z.number().int().min(1).max(3650).optional() }).strict().optional(),
529
- /** #1655 cloud-overflow settings. perSecondCostUsd is the operator's own
530
- * verified rate: with no price configured the feature stays inert rather
531
- * than show an invented one. allowlistedThreads carries standing consent
532
- * for exact thread ids. */
533
- cloudOverflow: z.object({
534
- perSecondCostUsd: z.number().positive().max(10).optional(),
535
- idleStopMs: z.number().int().positive().max(24 * 60 * 60_000).optional(),
536
- allowlistedThreads: z.array(z.string().min(1)).max(1000).optional(),
537
- }).strict().optional(),
538
521
  localVm: localVmConfigSchema.optional(),
539
522
  features: featureConfigSchema.optional(),
540
523
  onboarding: onboardingConfigSchema.optional(),
@@ -550,6 +533,15 @@ const appConfigSchema = z.object({
550
533
  });
551
534
  const storedAppConfigSchema = appConfigSchema.extend({
552
535
  browserProfiles: storedBrowserProfilesSchema.optional(),
536
+ /** Read loosely, like mcpServers: a hand-edited provider key that isn't
537
+ * text is dropped here, and a bad name or key later
538
+ * (openCodeProviderKeys), never the whole file. */
539
+ opencodeGo: z.object({
540
+ apiKey: optionalText,
541
+ providerKeys: z.record(z.string(), z.unknown()).catch({})
542
+ .transform((keys) => Object.fromEntries(Object.entries(keys).filter((entry) => typeof entry[1] === "string")))
543
+ .optional(),
544
+ }).optional(),
553
545
  });
554
546
  const appConfigPatchSchema = appConfigSchema.omit({ instances: true, mcpServers: true, cliStartup: true, customDomain: true })
555
547
  .extend({ threads: threadsPatchSchema.optional(), newBots: newBotsPatchSchema.optional() });
@@ -749,43 +741,6 @@ export function claudeUserMcpEnabled(cfg) {
749
741
  export function llmThreadTitlesEnabled(cfg) {
750
742
  return cfg.features?.llmThreadTitles === true;
751
743
  }
752
- /** Idle release for computer claims (#1653): a whole-turn desktop hold
753
- * ends after a screen-quiet window (default 90 seconds, screen-poller
754
- * frames excluded) instead of at turn settle, and the previous holder
755
- * re-claims directly inside a reclaim window (default 10 minutes) while
756
- * yielding to a seat another turn already holds. Off unless an explicit
757
- * `true` — bake it as a maintainer-only flag first, exactly like
758
- * sharedComputers: enable by hand in ~/.openmausbot/config.json
759
- * (`{"features": {"computerClaimIdleRelease": true}}`) and restart. */
760
- export function computerClaimIdleReleaseEnabled(cfg) {
761
- return cfg.features?.computerClaimIdleRelease === true;
762
- }
763
- /** Consented cloud overflow for local computer waits (#1655): while a turn
764
- * waits for a local desktop, it may offer overflow to a per-second-billed
765
- * cloud seat — never silently: the card names the cost, and only an
766
- * explicit per-conversation consent or a configured allowlist thread may
767
- * start the machine, which then hard-stops after an idle window. Off
768
- * unless an explicit `true`, like computerClaimIdleRelease: enable by
769
- * hand in ~/.openmausbot/config.json
770
- * (`{"features": {"cloudOverflow": true}, "cloudOverflow": {"perSecondCostUsd": 0.0004}}`)
771
- * and restart. */
772
- export function cloudOverflowEnabled(cfg) {
773
- return cfg.features?.cloudOverflow === true;
774
- }
775
- /** The per-second rate shown on the card, or null when the operator has
776
- * not set one: the offer fails closed rather than guess a price. */
777
- export function cloudOverflowPerSecondCostUsd(cfg) {
778
- const cost = cfg.cloudOverflow?.perSecondCostUsd;
779
- return typeof cost === "number" && Number.isFinite(cost) && cost > 0 ? cost : null;
780
- }
781
- /** How long a cloud seat may stay screen-quiet before it is stopped. */
782
- export function cloudOverflowIdleStopMs(cfg) {
783
- return cfg.cloudOverflow?.idleStopMs ?? CLOUD_SEAT_IDLE_STOP_MS;
784
- }
785
- /** Threads with standing consent, by exact thread id. */
786
- export function cloudOverflowAllowlistedThreads(cfg) {
787
- return new Set(cfg.cloudOverflow?.allowlistedThreads ?? []);
788
- }
789
744
  /** Opt-in shared skills library (skills lane S1): one store at the data dir
790
745
  * that bots reference by assignment instead of per-workspace copies. Off
791
746
  * unless enabled by hand in ~/.openmausbot/config.json
@@ -815,7 +770,6 @@ export const FLEET_NEUTRAL_KEYS = new Set([
815
770
  "memory",
816
771
  "localVm",
817
772
  "features",
818
- "cloudOverflow",
819
773
  "browserProfiles",
820
774
  "onboarding",
821
775
  ]);
@@ -1151,6 +1105,75 @@ export const PROVIDER_CREDENTIAL_ENV = [
1151
1105
  "CURSOR_API_KEY",
1152
1106
  "CURSOR_AUTH_TOKEN",
1153
1107
  ];
1108
+ /** Provider keys OpenCode reads from its environment, as it does in a
1109
+ * terminal: with ANTHROPIC_API_KEY set, `opencode` lists Anthropic's models.
1110
+ * Keys OpenMaus saves for another engine (xAI, Mistral, the workspace
1111
+ * Anthropic key) are workspace credentials under other names and never
1112
+ * ride along. */
1113
+ export const OPENCODE_PROVIDER_ENV = [
1114
+ "ANTHROPIC_API_KEY",
1115
+ "OPENAI_API_KEY",
1116
+ "GEMINI_API_KEY",
1117
+ "GOOGLE_API_KEY",
1118
+ "KIMI_API_KEY",
1119
+ "MOONSHOT_API_KEY",
1120
+ "MINIMAX_API_KEY",
1121
+ ];
1122
+ /** Keys the owner saves in Settings for OpenCode's other providers (Venice,
1123
+ * Groq, DeepSeek…), each under the environment name OpenCode reads. A Cloud
1124
+ * has no terminal to export them in. They go to OpenCode's process only,
1125
+ * never to this server's own environment. */
1126
+ export const OPENCODE_PROVIDER_KEY_LIMIT = 20;
1127
+ const OPENCODE_PROVIDER_KEY_NAME = /^[A-Z][A-Z0-9_]{0,59}_(?:API_KEY|KEY|TOKEN)$/;
1128
+ /** Printable, no spaces: no provider key has any, and a pasted sentence is not a key. */
1129
+ const OPENCODE_PROVIDER_KEY_VALUE = /^[\x21-\x7e]{1,4096}$/;
1130
+ /** Why OpenCode can't be given a key under this name, or undefined. A name
1131
+ * OpenMaus keeps for itself or another engine would never reach OpenCode:
1132
+ * every engine keeps only the credential names it reads (credentialEnv). */
1133
+ function openCodeProviderKeyNameRefusal(name) {
1134
+ if (!OPENCODE_PROVIDER_KEY_NAME.test(name)) {
1135
+ return "Use the name the provider reads its key from: capitals, ending in _API_KEY, _KEY or _TOKEN, such as VENICE_API_KEY.";
1136
+ }
1137
+ if (name === "OPENCODE_API_KEY")
1138
+ return "Save the OpenCode key in the OpenCode API key box instead.";
1139
+ const reserved = name.startsWith("OPENCODE_") || name.startsWith("OMB_")
1140
+ || WORKSPACE_CREDENTIAL_ENV.includes(name)
1141
+ || (PROVIDER_CREDENTIAL_ENV.includes(name) && !OPENCODE_PROVIDER_ENV.includes(name));
1142
+ return reserved ? `OpenMaus keeps ${name} for itself, so OpenCode can't be given it here. Put this key in opencode.json instead.` : undefined;
1143
+ }
1144
+ /** The saved OpenCode provider keys that can be used, by name. A hand-edited
1145
+ * entry OpenCode could not be given, or whose value is not a key, is
1146
+ * skipped; so is anything past the limit. */
1147
+ export function openCodeProviderKeys(cfg) {
1148
+ const saved = cfg.opencodeGo?.providerKeys ?? {};
1149
+ const usable = Object.keys(saved).sort()
1150
+ .filter((name) => !openCodeProviderKeyNameRefusal(name) && OPENCODE_PROVIDER_KEY_VALUE.test(saved[name] ?? ""))
1151
+ .slice(0, OPENCODE_PROVIDER_KEY_LIMIT);
1152
+ return Object.fromEntries(usable.map((name) => [name, saved[name]]));
1153
+ }
1154
+ /** A Settings change to the saved OpenCode provider keys: a key saves or
1155
+ * replaces its name, "" removes it, and every other saved key stays. Returns
1156
+ * the whole new set to store, or why it was refused. */
1157
+ export function mergeOpenCodeProviderKeys(cfg, change) {
1158
+ const keys = new Map(Object.entries(openCodeProviderKeys(cfg)));
1159
+ for (const [name, raw] of Object.entries(change)) {
1160
+ const value = raw.trim();
1161
+ if (!value) {
1162
+ keys.delete(name);
1163
+ continue;
1164
+ }
1165
+ const refusal = openCodeProviderKeyNameRefusal(name);
1166
+ if (refusal)
1167
+ return { ok: false, error: refusal };
1168
+ if (!OPENCODE_PROVIDER_KEY_VALUE.test(value))
1169
+ return { ok: false, error: "That doesn't look like a key. Paste only the key, with no spaces." };
1170
+ keys.set(name, value);
1171
+ }
1172
+ if (keys.size > OPENCODE_PROVIDER_KEY_LIMIT) {
1173
+ return { ok: false, error: `OpenCode can hold up to ${OPENCODE_PROVIDER_KEY_LIMIT} provider keys. Remove one you no longer use, then add this one.` };
1174
+ }
1175
+ return { ok: true, keys: Object.fromEntries([...keys].sort(([a], [b]) => (a < b ? -1 : 1))) };
1176
+ }
1154
1177
  const configSaveListeners = new Set();
1155
1178
  /** Told, synchronously, what each saveConfig wrote: the file before and
1156
1179
  * after. The admin activity log (server/admin-activity.ts) records the
@@ -1180,7 +1203,7 @@ export function saveConfig(patch, options = {}) {
1180
1203
  const storedProfiles = storedBrowserProfilesSchema.safeParse(disk.browserProfiles);
1181
1204
  if (storedProfiles.success)
1182
1205
  disk.browserProfiles = storedProfiles.data;
1183
- for (const key of ["xai", "anthropic", "mistral", "cerebras", "openai", "openrouter", "openaiCompat", "composio", "box", "opencodeGo", "tts", "decider", "imageGen", "live", "profile", "rooms", "threads", "context", "memory", "localVm", "features", "cloudOverflow", "budgets", "billing", "decisions", "onboarding", "browserEngine", "newBots"]) {
1206
+ for (const key of ["xai", "anthropic", "mistral", "cerebras", "openai", "openrouter", "openaiCompat", "composio", "box", "opencodeGo", "tts", "decider", "imageGen", "live", "profile", "rooms", "threads", "context", "memory", "localVm", "features", "budgets", "billing", "decisions", "onboarding", "browserEngine", "newBots"]) {
1184
1207
  const section = checkedPatch[key];
1185
1208
  if (!section)
1186
1209
  continue;
@@ -1467,8 +1490,13 @@ function injectedEnvironment(cfg, instanceId, driver) {
1467
1490
  // to Cloud Pro's included token, which never enters an environment map.
1468
1491
  if (driver === "boxAgent" && cfg.box?.token)
1469
1492
  environment.set("BOX_TOKEN", cfg.box.token);
1470
- if (driver === "opencodeGo" && cfg.opencodeGo?.apiKey)
1471
- environment.set("OPENCODE_API_KEY", cfg.opencodeGo.apiKey);
1493
+ if (driver === "opencodeGo") {
1494
+ // Keys for OpenCode's other providers, under the names OpenCode reads.
1495
+ for (const [name, key] of Object.entries(openCodeProviderKeys(cfg)))
1496
+ environment.set(name, key);
1497
+ if (cfg.opencodeGo?.apiKey)
1498
+ environment.set("OPENCODE_API_KEY", cfg.opencodeGo.apiKey);
1499
+ }
1472
1500
  return environment;
1473
1501
  }
1474
1502
  /** One instance per pasted provider key, each running only on its own key.