openmausbot 0.1.86 → 0.1.88

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 (216) hide show
  1. package/README.md +4 -3
  2. package/dist/assets/{abnfDiagram-O67JEVCF-DpUZNnFD.js → abnfDiagram-O67JEVCF-qd9VD3GV.js} +1 -1
  3. package/dist/assets/{arc-DW7mrI4p.js → arc-3RwkUjRe.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-CyeZ5WLZ.js → architectureDiagram-NJMV4G6O-CFKSJS7a.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-CBVaNjO7.js → blockDiagram-BEXU5L5S-CG1rEfWD.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-sf8d41uT.js → c4Diagram-YGBWAQC7-CBW9N664.js} +1 -1
  7. package/dist/assets/channel-CnRnO3tF.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-BOGvy7Y_.js → chunk-2Q5K7J3B-2mRfTksB.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-BiF9zyiz.js → chunk-3FUC2YCW-Cv1dpErj.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-EFuZ1SBZ.js → chunk-5VM5RSS4-B7IACJp-.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-pJLNSAT7.js → chunk-GTNCS2PH-An0hZtYa.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-Cb-3_3IT.js → chunk-GWA4HPMP-BcJB2nEm.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-DAf5aogn.js → chunk-JWPE2WC7-CV_W7TN7.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-DR3kiwZz.js → chunk-NETBCI7D-Bcs_kwnA.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-BmU8PeyM.js → chunk-WEXAMYUT-lrx_DxFP.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-Buh8g8OT.js → chunk-XXDRQBXY-DabHlLGM.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-BurfmMWM.js → classDiagram-v2-NBCMYWYE-BnHj6P7d.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-KkGZUEkU.js → cose-bilkent-JH36ORCC-CfLp6UDi.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-C3PpV96d.js → cynefin-OW5HDTMX-Dm4dbVA1.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-Dt8JwJaI.js → cynefinDiagram-VND7K2PF-B9mH-PID.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-DkjN8266.js → dagre-6A5THRUB-DDc_NVQJ.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-DUAJS0JV.js → diagram-22UHCM2B-lwr8Ip8-.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-Dw4KhF1a.js → diagram-3UASUU5V-BLLe6dmI.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-Dd1gmQqf.js → diagram-ATOU4E4O-6eUIq4lz.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-CjwGs0Yw.js → diagram-CDSNMT55-O-pRN7VQ.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-BL-69Zrq.js → diagram-MLGK6HIB-BietvGAI.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-5Zi2N7wo.js → diagram-MPIPVDR6-Bd2Cl62X.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-D9NekWUX.js → ebnfDiagram-ZINNZB2B-B6iDy69W.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-CkaSQjxw.js → elk-276RUBZZ-DQVHfKe0.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-CdAVBV8-.js → erDiagram-OPXOYQCR-BWdVp2tQ.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-Bm_lWWH7.js → flowDiagram-KWPJA3E3-WLE6rKB4.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-FZ8ejYWJ.js → ganttDiagram-FUAMR5RP-BbjS-4gn.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-Dlv9rizZ.js → gitGraphDiagram-X574FWY7-Dpy2QSGq.js} +1 -1
  34. package/dist/assets/index-BIMdIbaw.css +1 -0
  35. package/dist/assets/index-CHnODCr2.js +583 -0
  36. package/dist/assets/{index-h9FFNwTu.js → index-CIaHn5NS.js} +1 -1
  37. package/dist/assets/{infoDiagram-VRGFBTTK-BdQ_gW5B.js → infoDiagram-VRGFBTTK-D_jKWlKe.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-EwssEp2X.js → ishikawaDiagram-OU5B5YK6-DLJohSuK.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-BX1G1vOd.js → journeyDiagram-ZHPQQLJL-DPOFj0qN.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-BB4kAsyC.js → kanban-definition-PNTS6WVX-BI7v-xpK.js} +1 -1
  41. package/dist/assets/{linear-3su2TG93.js → linear-C2pWPSxO.js} +1 -1
  42. package/dist/assets/{mermaid.core-CDD1E0s9.js → mermaid.core-CR-j5WQU.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-Ba50MsNK.js → mindmap-definition-NLK3R4M7-lz9GvXRh.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-DZLkkZlM.js → pegDiagram-GJSIUBJH-Dl4_ia61.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-DaTz77Uq.js → pieDiagram-5QR66LMP-DWuT3n88.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-CpaiidpR.js → quadrantDiagram-O4NWA36T-DXWe9Jx7.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-mN4JrMeI.js → railroadDiagram-XR7U4H2S-COY1IBVY.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-BSNDRpg7.js → requirementDiagram-PLB6GJNP-DnyVuhsQ.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-FngzGNug.js → sankeyDiagram-IPEJSGJF-BSc4NdGK.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-CM4pCC3L.js → sequenceDiagram-PO4LG4MO-kqEU2EGq.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-CRICHXkw.js → sizeCapture-INFHLROL-BvB4bkWD.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-oyTWpxH2.js → stateDiagram-v2-GCMORJYK-BIrqNv7y.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-D9h9mPBq.js → swimlanes-2SLR337P-CFnalQUE.js} +1 -1
  54. package/dist/assets/swimlanesDiagram-TC7HE7FX-C4a8qNGj.js +8 -0
  55. package/dist/assets/{timeline-definition-EJHVYXUP-BJUlN9bW.js → timeline-definition-EJHVYXUP-CsSWJnSQ.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-3uBNkbAb.js → usecaseDiagram-POWQR4AR-DkAmJ1EE.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-D2odalzT.js → vennDiagram-UO4OBE2U-C3vd9drL.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-DHgFaB9S.js → wardleyDiagram-VNRHLVJA-j26CM-pi.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-w5GYM2h0.js → xychartDiagram-PMCCYNJV-BYu1Q_at.js} +1 -1
  60. package/dist/index.html +2 -2
  61. package/dist-server/browser-proxy.js +2 -1
  62. package/dist-server/companion/src/routes.js +8 -1
  63. package/dist-server/connector-proxy.js +105 -1
  64. package/dist-server/container-mcp.js +1032 -165
  65. package/dist-server/drivers/agents-proxy.js +338 -29
  66. package/dist-server/drivers/phone-proxy.js +33 -2
  67. package/dist-server/index.js +40590 -31811
  68. package/dist-server/local-computer-proxy.js +5 -3
  69. package/dist-server/local-computer.js +53 -17
  70. package/dist-server/mcp-gate.js +2 -0
  71. package/dist-server/mcp-server.js +5 -3
  72. package/dist-server/message-search.worker.js +52 -0
  73. package/dist-server/openmausbot.js +4457 -1399
  74. package/dist-server/pair-cli.js +4457 -1399
  75. package/dist-server/permission-proxy.js +29 -9
  76. package/dist-server/server/admission.js +81 -0
  77. package/dist-server/server/aside-queue.js +294 -0
  78. package/dist-server/server/attachments.js +98 -1
  79. package/dist-server/server/auto-approve.js +4 -3
  80. package/dist-server/server/auto-vm-claims.js +1 -0
  81. package/dist-server/server/bot-attachment.js +107 -0
  82. package/dist-server/server/bot-overview.js +19 -0
  83. package/dist-server/server/bot-package.js +18 -292
  84. package/dist-server/server/bot-profile.js +5 -17
  85. package/dist-server/server/box-create-idempotency.js +3 -3
  86. package/dist-server/server/box.js +57 -55
  87. package/dist-server/server/browser-proxy.js +3 -2
  88. package/dist-server/server/browser-runtime.js +37 -1
  89. package/dist-server/server/cli-api-setup.js +1 -0
  90. package/dist-server/server/cli.js +2 -2
  91. package/dist-server/server/command-allowlist.js +154 -0
  92. package/dist-server/server/composio.js +297 -1
  93. package/dist-server/server/computer-wait.js +22 -9
  94. package/dist-server/server/config.js +84 -12
  95. package/dist-server/server/connector-advertisement.js +165 -0
  96. package/dist-server/server/connector-proxy.js +14 -2
  97. package/dist-server/server/connector-verdict.js +168 -0
  98. package/dist-server/server/container-computer.js +61 -1
  99. package/dist-server/server/contracts.js +22 -0
  100. package/dist-server/server/default-model-selection.js +9 -0
  101. package/dist-server/server/delegations.js +85 -34
  102. package/dist-server/server/drivers/acp/core.js +80 -20
  103. package/dist-server/server/drivers/acp/cursor.js +3 -7
  104. package/dist-server/server/drivers/acp/droid.js +4 -7
  105. package/dist-server/server/drivers/acp/gemini.js +4 -4
  106. package/dist-server/server/drivers/acp/grok.js +4 -9
  107. package/dist-server/server/drivers/acp/hermes.js +3 -5
  108. package/dist-server/server/drivers/acp/opencode-go.js +2 -5
  109. package/dist-server/server/drivers/acp/qwen.js +9 -15
  110. package/dist-server/server/drivers/agents-call.js +179 -13
  111. package/dist-server/server/drivers/agents-catalog.js +134 -10
  112. package/dist-server/server/drivers/boxagent.js +160 -36
  113. package/dist-server/server/drivers/builtIn.js +2 -0
  114. package/dist-server/server/drivers/chat-box-tools.js +90 -0
  115. package/dist-server/server/drivers/chat-images.js +54 -0
  116. package/dist-server/server/drivers/chat-mcp-tools.js +38 -8
  117. package/dist-server/server/drivers/chat-tool-approval.js +70 -18
  118. package/dist-server/server/drivers/claude.js +38 -19
  119. package/dist-server/server/drivers/codex-catalog.js +14 -6
  120. package/dist-server/server/drivers/codex-identity.js +2 -1
  121. package/dist-server/server/drivers/codex-instructions.js +48 -6
  122. package/dist-server/server/drivers/codex-release.js +48 -0
  123. package/dist-server/server/drivers/codex-tool-surface.js +20 -0
  124. package/dist-server/server/drivers/codex.js +102 -55
  125. package/dist-server/server/drivers/mistral.js +115 -0
  126. package/dist-server/server/drivers/openai-chat.js +180 -33
  127. package/dist-server/server/drivers/openai-compat.js +1 -0
  128. package/dist-server/server/drivers/permission-command.js +35 -0
  129. package/dist-server/server/drivers/phone-proxy.js +47 -2
  130. package/dist-server/server/drivers/pi.js +306 -28
  131. package/dist-server/server/drivers/prompt-split.js +110 -0
  132. package/dist-server/server/drivers/session-idle.js +29 -0
  133. package/dist-server/server/env-path.js +15 -0
  134. package/dist-server/server/fleet.js +1 -1
  135. package/dist-server/server/group-thread-usage.js +103 -0
  136. package/dist-server/server/guarded-requests.js +2 -2
  137. package/dist-server/server/harness/registry.js +1 -0
  138. package/dist-server/server/harness-capabilities.js +46 -0
  139. package/dist-server/server/hosted-models.js +1 -1
  140. package/dist-server/server/index.js +2396 -505
  141. package/dist-server/server/local-computer.js +72 -23
  142. package/dist-server/server/managed-desktop.js +1 -1
  143. package/dist-server/server/mcp-bridge.js +6 -6
  144. package/dist-server/server/message-db.js +22 -42
  145. package/dist-server/server/message-file.js +9 -1
  146. package/dist-server/server/message-search-query.js +43 -0
  147. package/dist-server/server/message-search-worker.js +80 -0
  148. package/dist-server/server/message-search.worker.js +21 -0
  149. package/dist-server/server/model-requests.js +248 -0
  150. package/dist-server/server/new-bot-defaults.js +115 -0
  151. package/dist-server/server/options-card.js +21 -0
  152. package/dist-server/server/org-library.js +1065 -0
  153. package/dist-server/server/package-export.js +627 -39
  154. package/dist-server/server/package-import.js +458 -0
  155. package/dist-server/server/package-parts.js +87 -0
  156. package/dist-server/server/peer-approval.js +16 -7
  157. package/dist-server/server/peer-delivery.js +60 -0
  158. package/dist-server/server/peer-roster.js +67 -0
  159. package/dist-server/server/presets.js +492 -0
  160. package/dist-server/server/profile-requests.js +66 -39
  161. package/dist-server/server/profile-revision.js +9 -1
  162. package/dist-server/server/profile-versions.js +6 -3
  163. package/dist-server/server/provider-key-check.js +3 -2
  164. package/dist-server/server/published-teams.js +80 -0
  165. package/dist-server/server/room-handoffs.js +19 -3
  166. package/dist-server/server/routes/bot-presets.js +25 -0
  167. package/dist-server/server/routine-requests.js +35 -10
  168. package/dist-server/server/routines.js +41 -1
  169. package/dist-server/server/section-context.js +10 -0
  170. package/dist-server/server/skill-fetch.js +72 -14
  171. package/dist-server/server/skills.js +35 -61
  172. package/dist-server/server/steer-queue.js +3 -2
  173. package/dist-server/server/store.js +291 -17
  174. package/dist-server/server/surface.js +10 -4
  175. package/dist-server/server/system-prompt.js +38 -6
  176. package/dist-server/server/team-backup.js +6 -2
  177. package/dist-server/server/team-computers.js +10 -0
  178. package/dist-server/server/team-library.js +6 -2
  179. package/dist-server/server/team-manifest.js +4 -3
  180. package/dist-server/server/team-setup-requests.js +139 -27
  181. package/dist-server/server/thread-events.js +20 -2
  182. package/dist-server/server/tightening-requests.js +405 -0
  183. package/dist-server/server/tts/chatterbox.js +27 -5
  184. package/dist-server/server/turn-context.js +2 -0
  185. package/dist-server/server/turn-watchdog.js +12 -3
  186. package/dist-server/server/usage-ledger.js +19 -2
  187. package/dist-server/server/vps-computer.js +139 -51
  188. package/dist-server/server/vps-container-mcp.js +7 -0
  189. package/dist-server/server/vps-ssh.js +36 -7
  190. package/dist-server/server/workspace-backup-policy.js +12 -0
  191. package/dist-server/server/workspace-backup.js +42 -5
  192. package/dist-server/server/workspace-backup.worker.js +10 -0
  193. package/dist-server/server/workspace.js +1 -1
  194. package/dist-server/shared/approval-mode.js +1 -1
  195. package/dist-server/shared/ask-question.js +207 -12
  196. package/dist-server/shared/bot-profile.js +15 -0
  197. package/dist-server/shared/command-allowlist.js +1 -0
  198. package/dist-server/shared/credential-request.js +6 -2
  199. package/dist-server/shared/line-diff.js +23 -0
  200. package/dist-server/shared/model-request.js +1 -0
  201. package/dist-server/shared/options-card.js +51 -0
  202. package/dist-server/shared/package-format.js +967 -0
  203. package/dist-server/shared/profile-request.js +5 -4
  204. package/dist-server/shared/skill-md.js +64 -0
  205. package/dist-server/shared/team-backup.js +7 -0
  206. package/dist-server/shared/tightening-request.js +87 -0
  207. package/dist-server/shared/wire.js +4 -0
  208. package/dist-server/src/lib/composer-attachments.js +25 -3
  209. package/dist-server/vps-container-mcp.js +1132 -171
  210. package/dist-server/workspace-backup.worker.js +19474 -0
  211. package/enterprise/README.md +1 -1
  212. package/package.json +1 -1
  213. package/dist/assets/channel-BXFkgus9.js +0 -1
  214. package/dist/assets/index-BGgqlWPJ.css +0 -1
  215. package/dist/assets/index-BtHo8ZKl.js +0 -577
  216. package/dist/assets/swimlanesDiagram-TC7HE7FX-BWrvB2ak.js +0 -8
@@ -6,7 +6,8 @@ import { spawn } from "node:child_process";
6
6
  import { createConnection, createServer } from "node:net";
7
7
  import { BASE_IMAGE, CUA_DRIVER_VERSION, CUA_SOCKET, IMAGE as CUA_IMAGE, cuaExecArgs, dockerSecurityIsHardened, imageLabelsMatch, managedImageDockerfile, wholeScreenshot, BASE_IMAGE_DIGEST, BASE_IMAGE_LABEL, DRIVER_LABEL, IMAGE_LAYER_LABEL, IMAGE_LAYER_VERSION, MANAGED_LABEL, } from "./container-computer.js";
8
8
  import { DATA_DIR, isValidSshAlias, vpsSshAlias } from "./config.js";
9
- import { augmentedPath, resolveCliSpawn } from "./env-path.js";
9
+ import { augmentedPath } from "./env-path.js";
10
+ import { killCliTree, spawnCli } from "./procs.js";
10
11
  import { prepareVpsSsh } from "./vps-ssh.js";
11
12
  import { loadEnvironmentId } from "./environment.js";
12
13
  import { SPAWNED_PROXIES } from "./proxy-paths.js";
@@ -28,7 +29,9 @@ function vpsEnvironmentId() {
28
29
  // SIGTERM must give ssh + docker time to tear down the remote exec before the
29
30
  // SIGKILL escalation; 1s was routinely too short over a WAN round-trip, and an
30
31
  // orphaned remote exec keeps the driver socket busy for the next command.
31
- const COMMAND_TIMEOUT_KILL_GRACE_MS = 5_000;
32
+ const COMMAND_TIMEOUT_TERM_GRACE_MS = 5_000;
33
+ // killCliTree also waits up to one second for the forced group exit.
34
+ const COMMAND_TIMEOUT_KILL_GRACE_MS = COMMAND_TIMEOUT_TERM_GRACE_MS + 1_000;
32
35
  const CONTAINER_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/;
33
36
  const CONTAINER_ID = /^[a-f0-9]{12,64}$/i;
34
37
  const FULL_CONTAINER_ID = /^[a-f0-9]{64}$/i;
@@ -63,20 +66,32 @@ function snapshotVpsConfig(cfg) {
63
66
  export function vpsLifecycleBusy() {
64
67
  return lifecycleLocks.size > 0;
65
68
  }
66
- // A held lock means a lifecycle mutation (worst case: a 10-minute image
67
- // build) is running. Waiting it out would wedge Sleep and the screenshot
68
- // poll behind it, so acquisition fails fast instead.
69
+ // Sleep, remove and preview requests must not queue behind a potentially
70
+ // 10-minute image build. Turn preparation may instead wait through a normal
71
+ // preview, whose bounded duration is accounted for below.
69
72
  const LOCK_ACQUIRE_TIMEOUT_MS = 5_000;
70
73
  // The panel polls status every 4-6s and the screen poller re-checks it before
71
74
  // every frame; each full status is several docker-over-SSH processes. Same
72
75
  // pattern as container-computer's screenshotStatusCache, and the same TTL.
73
76
  const STATUS_CACHE_TTL_MS = 10_000;
74
77
  const statusCache = new Map();
78
+ const pendingStatuses = new Map();
79
+ const pendingProvisions = new Map();
75
80
  const SCREENSHOT_BUDGET_MS = 45_000;
76
81
  const SCREENSHOT_CLEANUP_BUDGET_MS = 10_000;
82
+ // Turn preparation may wait for a normal preview to finish; destructive
83
+ // actions retain the short acquisition limit. Overlapping provisions share
84
+ // one operation below, so they never queue behind their own image build.
85
+ const PREPARATION_LOCK_TIMEOUT_MS = SCREENSHOT_BUDGET_MS + LOCK_ACQUIRE_TIMEOUT_MS;
77
86
  const pendingScreenshots = new Map();
78
87
  const viewerConnections = new Map();
79
88
  const desktopTunnels = new Map();
89
+ function invalidateVpsStatus(key) {
90
+ statusCache.delete(key);
91
+ // Detach old polls as well: an inspection begun before a mutation must
92
+ // neither be joined nor republish its old result after that mutation.
93
+ pendingStatuses.delete(key);
94
+ }
80
95
  /** Whether a turn may prepare or start the VPS container rather than only
81
96
  * reuse a running one. Explicit Cloud always may; Auto only when the person
82
97
  * switched on Start VPS automatically — except for unattended runs. A
@@ -216,11 +231,10 @@ function tailCollector() {
216
231
  }
217
232
  export function defaultRunner(args, options = {}) {
218
233
  return new Promise((resolve, reject) => {
219
- const command = resolveCliSpawn("docker", args);
220
234
  // docker's SSH transport runs the first `ssh` on PATH: the app's shim,
221
235
  // which shares one connection across every command of this VPS.
222
236
  const ssh = prepareVpsSsh(DATA_DIR, augmentedPath());
223
- const child = spawn(command.command, command.args, {
237
+ const child = spawnCli("docker", args, {
224
238
  shell: false,
225
239
  env: { ...process.env, PATH: ssh.path },
226
240
  stdio: ["pipe", "pipe", "pipe"],
@@ -228,31 +242,33 @@ export function defaultRunner(args, options = {}) {
228
242
  const stdout = tailCollector();
229
243
  const stderr = tailCollector();
230
244
  let settled = false;
231
- let timedOut = false;
232
- let killTimer;
245
+ let failure = null;
233
246
  let timeout;
234
247
  const settle = (finish) => {
235
248
  if (settled)
236
249
  return;
237
250
  settled = true;
238
251
  clearTimeout(timeout);
239
- if (killTimer)
240
- clearTimeout(killTimer);
241
252
  finish();
242
253
  };
243
- timeout = setTimeout(() => {
244
- if (settled)
254
+ const fail = (error) => {
255
+ if (settled || failure)
245
256
  return;
246
- timedOut = true;
247
- killTimer = setTimeout(() => {
248
- if (settled)
249
- return;
250
- child.kill("SIGKILL");
251
- settle(() => reject(new Error("Docker-over-SSH command timed out")));
252
- }, COMMAND_TIMEOUT_KILL_GRACE_MS);
253
- killTimer.unref?.();
254
- child.kill("SIGTERM");
255
- }, options.timeoutMs ?? 120_000);
257
+ failure = error;
258
+ clearTimeout(timeout);
259
+ // Docker launches ssh, which can retain these pipes after Docker exits.
260
+ // Only this command's private process group is ours to stop; persistent
261
+ // SSH masters detach from it and may serve other commands. Hold callers'
262
+ // lifecycle ownership until the bounded tree cleanup has completed.
263
+ const cleaned = () => {
264
+ child.stdin.destroy();
265
+ child.stdout.destroy();
266
+ child.stderr.destroy();
267
+ settle(() => reject(error));
268
+ };
269
+ void killCliTree(child, COMMAND_TIMEOUT_TERM_GRACE_MS).then(cleaned, cleaned);
270
+ };
271
+ timeout = setTimeout(() => fail(new Error("Docker-over-SSH command timed out")), options.timeoutMs ?? 120_000);
256
272
  timeout.unref?.();
257
273
  child.stdout.setEncoding("utf8");
258
274
  child.stderr.setEncoding("utf8");
@@ -263,18 +279,14 @@ export function defaultRunner(args, options = {}) {
263
279
  stderr.push(chunk);
264
280
  });
265
281
  child.stdin.on("error", (error) => {
266
- if (timedOut)
267
- return;
268
- settle(() => reject(new Error(`Docker-over-SSH stdin failed: ${error.message}`)));
282
+ fail(new Error(`Docker-over-SSH stdin failed: ${error.message}`));
269
283
  });
270
284
  child.on("error", (error) => {
271
- settle(() => reject(new Error(`Docker-over-SSH could not start: ${error.message}`)));
285
+ fail(new Error(`Docker-over-SSH could not start: ${error.message}`));
272
286
  });
273
287
  child.on("close", (code, signal) => {
274
- if (timedOut) {
275
- settle(() => reject(new Error("Docker-over-SSH command timed out")));
288
+ if (failure)
276
289
  return;
277
- }
278
290
  settle(() => {
279
291
  if (code === 0)
280
292
  return resolve({ stdout: stdout.text(), stderr: stderr.text() });
@@ -286,7 +298,7 @@ export function defaultRunner(args, options = {}) {
286
298
  child.stdin.end(options.input);
287
299
  }
288
300
  catch (error) {
289
- settle(() => reject(new Error(`Docker-over-SSH stdin failed: ${error instanceof Error ? error.message : String(error)}`)));
301
+ fail(new Error(`Docker-over-SSH stdin failed: ${error instanceof Error ? error.message : String(error)}`));
290
302
  }
291
303
  });
292
304
  }
@@ -531,15 +543,30 @@ export async function vpsComputerStatus(cfg, botId, runner = defaultRunner) {
531
543
  if (cacheable) {
532
544
  // A capture already checks this exact target. Let it finish instead of
533
545
  // opening another six SSH connections while its readiness check is cold.
534
- await pendingScreenshots.get(key)?.catch(() => { });
546
+ const capture = pendingScreenshots.get(key);
547
+ if (capture)
548
+ await capture.catch(() => { });
535
549
  const cached = statusCache.get(key);
536
550
  if (cached && cached.expiresAt > Date.now())
537
551
  return cached.status;
552
+ const pending = pendingStatuses.get(key);
553
+ if (pending)
554
+ return pending;
538
555
  }
539
- const status = await computeVpsComputerStatus(cfg, botId, runner);
556
+ const inspection = computeVpsComputerStatus(cfg, botId, runner);
540
557
  if (cacheable)
541
- statusCache.set(key, { status, expiresAt: Date.now() + STATUS_CACHE_TTL_MS });
542
- return status;
558
+ pendingStatuses.set(key, inspection);
559
+ try {
560
+ const status = await inspection;
561
+ if (cacheable && pendingStatuses.get(key) === inspection && !lifecycleLocks.has(key)) {
562
+ statusCache.set(key, { status, expiresAt: Date.now() + STATUS_CACHE_TTL_MS });
563
+ }
564
+ return status;
565
+ }
566
+ finally {
567
+ if (cacheable && pendingStatuses.get(key) === inspection)
568
+ pendingStatuses.delete(key);
569
+ }
543
570
  }
544
571
  const VPS_INVENTORY_LIMIT = 256;
545
572
  const VPS_INVENTORY_STATES = new Set([
@@ -761,14 +788,32 @@ async function prepareVpsImage(alias, runner) {
761
788
  * Backoff doubles 0.5s→4s because each poll is a real SSH connection, and
762
789
  * between polls only ONE cheap exec (`cua-driver status`) asks whether the
763
790
  * driver answers yet — the full multi-invocation status runs again only when
764
- * that predicate flips, and once more at the deadline, so the returned state
765
- * is always a complete inspection. */
791
+ * that predicate flips. Every command, including the diagnostic tail, is
792
+ * clamped to the readiness phase's deadline and transport cleanup allowance. */
766
793
  async function waitForVpsReady(cfg, botId, runner, budgetMs = 60_000) {
767
794
  const alias = vpsSshAlias(cfg);
768
795
  const deadline = Date.now() + budgetMs;
769
- let status = await computeVpsComputerStatus(cfg, botId, runner);
796
+ let budgetExpired = false;
797
+ const boundedRunner = async (args, options = {}) => {
798
+ // Include transport termination in the readiness phase's budget; a
799
+ // final failed probe or diagnostic tail must not keep its lock past it.
800
+ const remaining = deadline - Date.now() - COMMAND_TIMEOUT_KILL_GRACE_MS;
801
+ if (remaining <= 0) {
802
+ budgetExpired = true;
803
+ throw new Error("VPS desktop readiness timed out");
804
+ }
805
+ try {
806
+ return await runner(args, { ...options, timeoutMs: Math.min(options.timeoutMs ?? 120_000, remaining) });
807
+ }
808
+ catch (error) {
809
+ if (Date.now() >= deadline - COMMAND_TIMEOUT_KILL_GRACE_MS)
810
+ budgetExpired = true;
811
+ throw error;
812
+ }
813
+ };
814
+ let status = await computeVpsComputerStatus(cfg, botId, boundedRunner);
770
815
  let delayMs = 500;
771
- while (!status.ready && alias && Date.now() < deadline) {
816
+ while (!status.ready && alias && !budgetExpired && Date.now() < deadline) {
772
817
  if (!status.daemonUp ||
773
818
  !status.image ||
774
819
  !status.imageMatches ||
@@ -785,14 +830,24 @@ async function waitForVpsReady(cfg, botId, runner, budgetMs = 60_000) {
785
830
  });
786
831
  delayMs = Math.min(delayMs * 2, 4_000);
787
832
  const container = status.container_id ?? status.container_name;
788
- const driverAnswers = await runner(vpsDockerArgs(alias, cuaExecArgs(["status", "--socket", CUA_SOCKET], { container })), { timeoutMs: 10_000 }).then(() => true, () => false);
833
+ const driverAnswers = await boundedRunner(vpsDockerArgs(alias, cuaExecArgs(["status", "--socket", CUA_SOCKET], { container })), { timeoutMs: 10_000 }).then(() => true, () => false);
834
+ if (budgetExpired)
835
+ break;
789
836
  if (!driverAnswers && Date.now() < deadline)
790
837
  continue;
791
- status = await computeVpsComputerStatus(cfg, botId, runner);
838
+ status = await computeVpsComputerStatus(cfg, botId, boundedRunner);
792
839
  }
840
+ if (budgetExpired)
841
+ return {
842
+ ...status,
843
+ ready: false,
844
+ desktopReady: false,
845
+ desktop_error: "VPS desktop readiness timed out",
846
+ problem: "The VPS desktop did not become ready in time — retry when the connection recovers",
847
+ };
793
848
  return status;
794
849
  }
795
- async function withVpsLifecycleLock(key, operation) {
850
+ async function withVpsLifecycleLock(key, operation, acquireTimeoutMs = LOCK_ACQUIRE_TIMEOUT_MS) {
796
851
  const previous = lifecycleLocks.get(key);
797
852
  let release;
798
853
  const current = new Promise((resolve) => {
@@ -804,7 +859,7 @@ async function withVpsLifecycleLock(key, operation) {
804
859
  const acquired = await Promise.race([
805
860
  previous.then(() => true),
806
861
  new Promise((resolve) => {
807
- acquireTimer = setTimeout(() => resolve(false), LOCK_ACQUIRE_TIMEOUT_MS);
862
+ acquireTimer = setTimeout(() => resolve(false), acquireTimeoutMs);
808
863
  acquireTimer.unref?.();
809
864
  }),
810
865
  ]);
@@ -849,6 +904,7 @@ export async function removeManagedVpsComputer(cfg, owners, containerName, confi
849
904
  }
850
905
  const key = `${alias}:${containerName}`;
851
906
  return withVpsLifecycleLock(key, async () => {
907
+ invalidateVpsStatus(key);
852
908
  const scan = await scanManagedVpsComputers(cfg, owners, runner);
853
909
  const inventory = scan.inventory;
854
910
  if (!inventory.available) {
@@ -874,13 +930,14 @@ export async function removeManagedVpsComputer(cfg, owners, containerName, confi
874
930
  await runner(vpsDockerArgs(alias, ["rm", "-f", containerId]), { timeoutMs: 2 * 60_000 });
875
931
  }
876
932
  catch (error) {
933
+ invalidateVpsStatus(key);
877
934
  const detail = (error instanceof Error ? error.message : String(error))
878
935
  .replace(/[\r\n\t]+/g, " ")
879
936
  .trim()
880
937
  .slice(0, 200);
881
938
  throw Object.assign(new Error(`The VPS refused to remove this computer${detail ? `: ${detail}` : ""}`), { status: 502 });
882
939
  }
883
- statusCache.delete(key);
940
+ invalidateVpsStatus(key);
884
941
  viewerConnections.delete(key);
885
942
  if (instance.ownerBotId)
886
943
  stopDesktopTunnel(instance.ownerBotId);
@@ -893,14 +950,19 @@ export async function vpsComputerAction(action, cfg, botId, runner = defaultRunn
893
950
  if (!alias)
894
951
  throw Object.assign(new Error("VPS is not configured — add an SSH config alias in App Settings → Connections"), { status: 409 });
895
952
  const key = `${alias}:${vpsContainerName(botId)}`;
953
+ const pending = action === "provision" ? pendingProvisions.get(key) : undefined;
954
+ if (pending)
955
+ return pending;
896
956
  const operation = async () => {
897
957
  // A mutation invalidates every cached poll answer, before and after: the
898
958
  // panel must never keep showing the pre-action world for a TTL.
899
- statusCache.delete(key);
959
+ invalidateVpsStatus(key);
900
960
  try {
901
961
  const before = await computeVpsComputerStatus(cfg, botId, runner);
902
962
  if (!before.daemonUp)
903
963
  throw Object.assign(new Error(before.problem ?? "Docker over SSH is not reachable"), { status: 409 });
964
+ if (action === "provision" && before.ready)
965
+ return before;
904
966
  // A real lifecycle change invalidates the remote endpoint. Provision
905
967
  // is also the turn-start idempotency path, so leave an already-running
906
968
  // viewer alone when no start/replacement will occur.
@@ -946,7 +1008,7 @@ export async function vpsComputerAction(action, cfg, botId, runner = defaultRunn
946
1008
  throw Object.assign(new Error("The VPS container name is occupied by a container OpenMausBot did not create — remove it on the VPS yourself"), { status: 409 });
947
1009
  }
948
1010
  await run(["rm", "-f", containerRef]);
949
- return computeVpsComputerStatus(cfg, botId, runner);
1011
+ return await computeVpsComputerStatus(cfg, botId, runner);
950
1012
  }
951
1013
  else {
952
1014
  if (before.container !== "running")
@@ -954,13 +1016,22 @@ export async function vpsComputerAction(action, cfg, botId, runner = defaultRunn
954
1016
  assertUsableContainer(before);
955
1017
  await run(["stop", containerRef]);
956
1018
  }
957
- return action === "stop" ? computeVpsComputerStatus(cfg, botId, runner) : waitForVpsReady(cfg, botId, runner);
1019
+ return await (action === "stop" ? computeVpsComputerStatus(cfg, botId, runner) : waitForVpsReady(cfg, botId, runner));
958
1020
  }
959
1021
  finally {
960
- statusCache.delete(key);
1022
+ invalidateVpsStatus(key);
961
1023
  }
962
1024
  };
963
- return withVpsLifecycleLock(key, operation);
1025
+ const preparation = withVpsLifecycleLock(key, operation, action === "provision" ? PREPARATION_LOCK_TIMEOUT_MS : LOCK_ACQUIRE_TIMEOUT_MS);
1026
+ if (action === "provision")
1027
+ pendingProvisions.set(key, preparation);
1028
+ try {
1029
+ return await preparation;
1030
+ }
1031
+ finally {
1032
+ if (pendingProvisions.get(key) === preparation)
1033
+ pendingProvisions.delete(key);
1034
+ }
964
1035
  }
965
1036
  /** Auto is intentionally read-only: it can attach only to an existing ready
966
1037
  * container. It recomputes rather than reading the poll cache — routing a
@@ -976,7 +1047,7 @@ export async function inspectVpsForAuto(cfg, botId, runner = defaultRunner) {
976
1047
  cfg = snapshotVpsConfig(cfg);
977
1048
  const key = vpsLockKey(cfg, botId);
978
1049
  return key
979
- ? withVpsLifecycleLock(key, () => computeVpsComputerStatus(cfg, botId, runner))
1050
+ ? withVpsLifecycleLock(key, () => computeVpsComputerStatus(cfg, botId, runner), PREPARATION_LOCK_TIMEOUT_MS)
980
1051
  : computeVpsComputerStatus(cfg, botId, runner);
981
1052
  }
982
1053
  /** Open a temporary noVNC connection through the configured SSH alias. The
@@ -1116,9 +1187,26 @@ export async function vpsComputerScreenshot(cfg, botId, runner = defaultRunner)
1116
1187
  // poller runs every few seconds, and re-verifying the whole container
1117
1188
  // between frames multiplied every frame's SSH cost.
1118
1189
  const cached = cacheable ? statusCache.get(key) : undefined;
1190
+ const pendingStatus = cacheable ? pendingStatuses.get(key) : undefined;
1191
+ // A poll which started first already owns the inspection. Sharing its
1192
+ // result must not extend this preview's own deadline; the poll remains
1193
+ // independently owned if it is still checking when this preview expires.
1194
+ let statusTimer;
1119
1195
  const status = cached && cached.expiresAt > Date.now()
1120
1196
  ? cached.status
1121
- : await computeVpsComputerStatus(cfg, botId, boundedRunner);
1197
+ : pendingStatus
1198
+ ? await Promise.race([
1199
+ pendingStatus,
1200
+ new Promise((_resolve, reject) => {
1201
+ statusTimer = setTimeout(() => {
1202
+ invalidateVpsStatus(key);
1203
+ reject(timeoutError());
1204
+ }, Math.max(0, workDeadline - Date.now()));
1205
+ statusTimer.unref?.();
1206
+ }),
1207
+ ]).finally(() => { if (statusTimer)
1208
+ clearTimeout(statusTimer); })
1209
+ : await computeVpsComputerStatus(cfg, botId, boundedRunner);
1122
1210
  // Status converts transport errors into displayable state. A deadline is
1123
1211
  // still a timeout, not evidence that this container became incompatible.
1124
1212
  if (budgetExpired) {
@@ -5,6 +5,9 @@
5
5
  // mcp-bridge.ts, shared with the Local VM entry point.
6
6
  import { runMcpBridge } from "./mcp-bridge.js";
7
7
  import { vpsContainerMcpArgs, vpsDockerArgs } from "./vps-computer.js";
8
+ import { DATA_DIR } from "./config.js";
9
+ import { augmentedPath } from "./env-path.js";
10
+ import { prepareVpsSsh } from "./vps-ssh.js";
8
11
  const [alias, containerName] = process.argv.slice(2);
9
12
  const sshAlias = alias ?? "";
10
13
  let args;
@@ -19,9 +22,13 @@ catch {
19
22
  // through `ps`, and the token guards a loopback endpoint.
20
23
  const controlUrl = process.env.OMB_CONTROL_URL ?? "";
21
24
  const controlToken = process.env.OMB_CONTROL_TOKEN ?? "";
25
+ const ssh = prepareVpsSsh(DATA_DIR, augmentedPath());
22
26
  runMcpBridge({
23
27
  command: "docker",
24
28
  args,
29
+ // Keep tool calls and the watchdog on the same bounded, shared transport
30
+ // as startup and previews; otherwise only the panel gets the SSH defaults.
31
+ env: { ...process.env, PATH: ssh.path },
25
32
  label: "VPS Cua Driver",
26
33
  // The probe checks the TRANSPORT (SSH + daemon), deliberately not the
27
34
  // driver: a busy desktop mid-tool-call must never look dead, while an
@@ -10,10 +10,39 @@
10
10
  // that includes theirs first (so anything they set still wins) and fills in
11
11
  // connection sharing and fail-fast timeouts, plus an `ssh` shim docker finds
12
12
  // ahead of the real one that points at that config.
13
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
13
+ import { createHash } from "node:crypto";
14
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
14
15
  import { homedir } from "node:os";
15
- import { delimiter, join } from "node:path";
16
+ import { delimiter, join, resolve } from "node:path";
16
17
  export const VPS_SSH_DIR = "ssh";
18
+ function shellQuote(value) {
19
+ return `'${value.replace(/'/g, "'\\''")}'`;
20
+ }
21
+ /** macOS limits Unix socket paths to 104 bytes, including the terminator.
22
+ * OpenSSH also appends a temporary suffix while creating a master socket.
23
+ * A long application/test data path must not disable every SSH command. */
24
+ function controlDirectory(sshDir) {
25
+ if (Buffer.byteLength(join(sshDir, `cm-${"0".repeat(40)}`)) <= 80)
26
+ return sshDir;
27
+ const uid = process.getuid();
28
+ const id = createHash("sha256").update(resolve(sshDir)).digest("hex").slice(0, 12);
29
+ // /tmp is intentionally used instead of tmpdir(): macOS TMPDIR is itself
30
+ // often too long for a control socket. Validate before chmod or use so an
31
+ // existing symlink/other user's directory cannot redirect the socket.
32
+ const dir = `/tmp/omb-ssh-${uid}-${id}`;
33
+ try {
34
+ mkdirSync(dir, { mode: 0o700 });
35
+ }
36
+ catch (error) {
37
+ if (error.code !== "EEXIST")
38
+ throw error;
39
+ }
40
+ const stat = lstatSync(dir);
41
+ if (!stat.isDirectory() || stat.uid !== uid)
42
+ throw new Error("The VPS SSH control directory is not private to this user");
43
+ chmodSync(dir, 0o700);
44
+ return dir;
45
+ }
17
46
  function findExecutable(name, pathValue, exclude) {
18
47
  for (const dir of pathValue.split(delimiter)) {
19
48
  if (!dir || dir === exclude)
@@ -49,15 +78,15 @@ export function vpsSshConfigText(sshDir, userConfig = join(homedir(), ".ssh", "c
49
78
  return [
50
79
  "# Written by OpenMausBot for its VPS computer connections. Do not edit;",
51
80
  "# it is regenerated. Your own ~/.ssh/config is included first and wins.",
52
- ...(existsSync(userConfig) ? [`Include ${userConfig}`] : []),
81
+ ...(existsSync(userConfig) ? [`Include ${JSON.stringify(userConfig)}`] : []),
53
82
  "Host *",
54
83
  " ControlMaster auto",
55
- ` ControlPath ${join(sshDir, "cm-%C")}`,
84
+ ` ControlPath ${JSON.stringify(join(sshDir.replace(/%/g, "%%"), "cm-%C"))}`,
56
85
  " ControlPersist 10m",
57
86
  " ServerAliveInterval 15",
58
87
  " ServerAliveCountMax 3",
59
88
  " ConnectTimeout 10",
60
- ...(existsSync(systemConfig) ? [`Include ${systemConfig}`] : []),
89
+ ...(existsSync(systemConfig) ? [`Include ${JSON.stringify(systemConfig)}`] : []),
61
90
  "",
62
91
  ].join("\n");
63
92
  }
@@ -72,7 +101,7 @@ export function prepareVpsSsh(dataDir, pathValue, platform = process.platform) {
72
101
  mkdirSync(binDir, { recursive: true, mode: 0o700 });
73
102
  chmodSync(sshDir, 0o700);
74
103
  const configPath = join(sshDir, "config");
75
- writeIfChanged(configPath, vpsSshConfigText(sshDir), 0o600);
104
+ writeIfChanged(configPath, vpsSshConfigText(controlDirectory(sshDir)), 0o600);
76
105
  const realSsh = findExecutable("ssh", pathValue, binDir);
77
106
  if (!realSsh)
78
107
  return { configPath, path: pathValue };
@@ -80,7 +109,7 @@ export function prepareVpsSsh(dataDir, pathValue, platform = process.platform) {
80
109
  "#!/bin/sh",
81
110
  "# Written by OpenMausBot. docker's SSH transport finds this ssh first, so",
82
111
  "# every VPS command shares one connection whether or not the alias says so.",
83
- `exec ${JSON.stringify(realSsh)} -F ${JSON.stringify(configPath)} "$@"`,
112
+ `exec ${shellQuote(realSsh)} -F ${shellQuote(configPath)} "$@"`,
84
113
  "",
85
114
  ].join("\n");
86
115
  writeIfChanged(join(binDir, "ssh"), shim, 0o700);
@@ -22,10 +22,22 @@ export function restoredWorkspaceConfig(portable, destination) {
22
22
  }
23
23
  /** Exact app-owned authentication paths, not a scan of user document text. */
24
24
  export function excludedWorkspaceAuthPath(path) {
25
+ // Machine/provider-specific execution grants are not portable template data.
26
+ if (/^command-allowlist\.json(?:$|\.\d+\.[0-9a-f-]+\.tmp$)/.test(path))
27
+ return true;
25
28
  return /^(?:(?:providers|caddy|chrome-profile|\.agent-browser)(?:\/|$)|workspace-credentials\.json$|external-runtimes\.json$|browser-engine-key$)/.test(path) ||
26
29
  /^(?:config\.json|webhooks\.json|workspace-credentials\.json|external-runtimes\.json|browser-engine-key|sessions\.json|tunnel-account\.json)\.\d+(?:\.[0-9a-f-]+)?\.tmp$/.test(path) ||
27
30
  /^(?:vm-home|vm-homes\/[^/]+)\/\.browser-profiles(?:\/|$)/.test(path);
28
31
  }
32
+ /** The Organization library files Electron main downloads and verifies
33
+ * (docs/desktop-library.md): the catalog, a catalog write in progress, and the
34
+ * cached release files. Main fetches them again from Admin, and sign-out
35
+ * deletes them, so a backup never keeps an Organization's package bytes. The
36
+ * runtime's own `org-library/state.json` and `presets.json` are backed up. Like
37
+ * the hook tokens below, an archive that holds them still restores. */
38
+ export function redownloadedOrgLibraryPath(path) {
39
+ return /^org-library\/(?:catalog\.json(?:\.[0-9a-f-]{36}\.tmp)?$|blobs(?:\/|$))/.test(path);
40
+ }
29
41
  /** Per-turn engine hook bearers (`hook-tokens/<digest>.token`, written by the
30
42
  * Claude driver). They are dead once their turn settles, so they are never
31
43
  * exported. Unlike the saved auth paths above they are not refused on import:
@@ -6,12 +6,13 @@ import { isAbsolute, join, parse, posix, relative, resolve, win32 } from "node:p
6
6
  import { homedir } from "node:os";
7
7
  import { backup, DatabaseSync } from "node:sqlite";
8
8
  import { pipeline } from "node:stream/promises";
9
+ import { Worker } from "node:worker_threads";
9
10
  import * as tar from "tar";
10
11
  import { fromMarkdown } from "mdast-util-from-markdown";
11
12
  import { writeFileAtomic } from "./atomic.js";
12
13
  import { escapeAttribute, splitTranscriptAttachments } from "../src/lib/composer-attachments.js";
13
14
  import { WORKSPACE_BACKUP_CLIENT_KEYS } from "../shared/workspace-backup-client.js";
14
- import { ephemeralWorkspaceTokenPath, excludedWorkspaceAuthPath, portableWorkspaceConfig, restoredWorkspaceConfig } from "./workspace-backup-policy.js";
15
+ import { ephemeralWorkspaceTokenPath, excludedWorkspaceAuthPath, portableWorkspaceConfig, redownloadedOrgLibraryPath, restoredWorkspaceConfig } from "./workspace-backup-policy.js";
15
16
  export const MAX_WORKSPACE_BACKUP_BYTES = 10 * 1024 ** 3;
16
17
  export const MAX_WORKSPACE_BACKUP_FILES = 100_000;
17
18
  export const MAX_WORKSPACE_BACKUP_UPLOAD_BYTES = MAX_WORKSPACE_BACKUP_BYTES + 256 * 1024 ** 2;
@@ -77,8 +78,7 @@ function jobPath(dataDir, id) {
77
78
  }
78
79
  return path;
79
80
  }
80
- function newJob(dataDir) {
81
- const id = randomUUID();
81
+ function newJob(dataDir, id = randomUUID()) {
82
82
  const directory = jobPath(dataDir, id);
83
83
  mkdirSync(directory, { mode: 0o700 });
84
84
  return { id, directory };
@@ -265,13 +265,50 @@ function databaseCounts(path) {
265
265
  db.close();
266
266
  }
267
267
  }
268
+ /** The existing maintenance gate owns exclusivity until this worker has exited.
269
+ * Keep synchronous file validation/fsync intact, but off the request thread. */
268
270
  export async function createWorkspaceBackup(dataDir, options) {
271
+ const source = new URL("./workspace-backup.worker.ts", import.meta.url);
272
+ // Assign ownership before the worker can create staging, including crashes
273
+ // too early to send a job-created message back to this process.
274
+ const jobId = randomUUID();
275
+ let worker;
276
+ let completed = false;
277
+ try {
278
+ worker = new Worker(existsSync(source) ? source : new URL("./workspace-backup.worker.js", import.meta.url), {
279
+ workerData: { dataDir, options, jobId }, execArgv: [],
280
+ });
281
+ const result = await new Promise((resolveBackup, reject) => {
282
+ const failed = () => reject(new Error("The backup worker stopped before completing. Try again."));
283
+ worker.once("message", (reply) => {
284
+ if (reply.result)
285
+ resolveBackup(reply.result);
286
+ else if (reply.error)
287
+ reject(new Error(reply.error));
288
+ else
289
+ failed();
290
+ });
291
+ worker.once("error", failed);
292
+ worker.once("exit", failed);
293
+ });
294
+ completed = true;
295
+ return result;
296
+ }
297
+ finally {
298
+ await worker?.terminate();
299
+ // Reuse the guarded deletion path: never sweep other jobs or recovery.
300
+ if (!completed && entryExists(join(dataDir, ".backups", jobId)))
301
+ removeWorkspaceBackupJob(dataDir, jobId);
302
+ }
303
+ }
304
+ /** Worker implementation; application callers use createWorkspaceBackup. */
305
+ export async function createWorkspaceBackupSnapshot(dataDir, options, jobId) {
269
306
  if (Object.hasOwn(options, "credentials"))
270
307
  throw new Error("Workspace backups do not transfer credentials.");
271
308
  assertLocalAuthOutsideSnapshot(dataDir);
272
309
  const salt = randomBytes(16);
273
310
  const key = await passwordKey(options.password, salt);
274
- const job = newJob(dataDir);
311
+ const job = newJob(dataDir, jobId);
275
312
  const snapshot = join(job.directory, "snapshot");
276
313
  folder(join(snapshot, "data"));
277
314
  const entries = [];
@@ -311,7 +348,7 @@ export async function createWorkspaceBackup(dataDir, options) {
311
348
  if (!prefix && excluded(name))
312
349
  continue;
313
350
  const path = prefix ? `${prefix}/${name}` : name;
314
- if (excludedWorkspaceAuthPath(path) || ephemeralWorkspaceTokenPath(path))
351
+ if (excludedWorkspaceAuthPath(path) || ephemeralWorkspaceTokenPath(path) || redownloadedOrgLibraryPath(path))
315
352
  continue;
316
353
  // Do not silently skip noncanonical source spellings: reject them so
317
354
  // a case-sensitive host cannot export auth paths active on Windows/Mac.
@@ -0,0 +1,10 @@
1
+ import { parentPort, workerData } from "node:worker_threads";
2
+ import { createWorkspaceBackupSnapshot } from "./workspace-backup.js";
3
+ const { dataDir, options, jobId } = workerData;
4
+ try {
5
+ // Same snapshot and cleanup implementation; never send password/options back.
6
+ parentPort.postMessage({ result: await createWorkspaceBackupSnapshot(dataDir, options, jobId) });
7
+ }
8
+ catch (error) {
9
+ parentPort.postMessage({ error: error instanceof Error ? error.message : "The workspace backup could not finish." });
10
+ }
@@ -18,7 +18,7 @@ export const WORKSPACES_DIR = join(DATA_DIR, "workspaces");
18
18
  export const TASK_WORKSPACES_DIR = join(DATA_DIR, "task-workspaces");
19
19
  /** MCP support does not imply native filesystem tools or a local working directory. */
20
20
  export function supportsWorkspaceFiles(driverKind) {
21
- return !["grok", "openai-compat", "minimax", "boxAgent"].includes(driverKind);
21
+ return !["grok", "openai-compat", "minimax", "mistral", "boxAgent"].includes(driverKind);
22
22
  }
23
23
  /** Default task files are private to the thread, outside the bot's shared
24
24
  * memory folder. This is directory organization, not a shell sandbox. */
@@ -21,7 +21,7 @@ export function supportsApprovalMode(driverKind, mode) {
21
21
  // engines could never stop asking — not by its own level, and not through
22
22
  // a Chief's delegated Full access either.
23
23
  return ["codex", "claudeAgent", "antigravityAgent", "cursorAgent", "grokAgent", "opencodeGo", "qwenAgent", "geminiAgent",
24
- "openai-compat", "grok", "minimax"].includes(driverKind ?? "");
24
+ "openai-compat", "grok", "minimax", "mistral"].includes(driverKind ?? "");
25
25
  }
26
26
  /** A Full/Custom grant belongs to one provider's tool semantics. Other
27
27
  * modes carry across only when the destination actually implements them.