botmux 3.5.0 → 3.6.0

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 (505) hide show
  1. package/README.en.md +56 -396
  2. package/README.md +55 -396
  3. package/dist/.runtime-build-id +1 -0
  4. package/dist/adapters/backend/sandbox.d.ts +99 -279
  5. package/dist/adapters/backend/sandbox.d.ts.map +1 -1
  6. package/dist/adapters/backend/sandbox.js +424 -826
  7. package/dist/adapters/backend/sandbox.js.map +1 -1
  8. package/dist/adapters/cli/claude-code.d.ts +6 -0
  9. package/dist/adapters/cli/claude-code.d.ts.map +1 -1
  10. package/dist/adapters/cli/claude-code.js +197 -11
  11. package/dist/adapters/cli/claude-code.js.map +1 -1
  12. package/dist/adapters/cli/coco.js +4 -4
  13. package/dist/adapters/cli/coco.js.map +1 -1
  14. package/dist/adapters/cli/codex-app.d.ts.map +1 -1
  15. package/dist/adapters/cli/codex-app.js +9 -6
  16. package/dist/adapters/cli/codex-app.js.map +1 -1
  17. package/dist/adapters/cli/codex.d.ts.map +1 -1
  18. package/dist/adapters/cli/codex.js +9 -5
  19. package/dist/adapters/cli/codex.js.map +1 -1
  20. package/dist/adapters/cli/fs-policy.d.ts +244 -0
  21. package/dist/adapters/cli/fs-policy.d.ts.map +1 -0
  22. package/dist/adapters/cli/fs-policy.js +537 -0
  23. package/dist/adapters/cli/fs-policy.js.map +1 -0
  24. package/dist/adapters/cli/grok.d.ts +2 -1
  25. package/dist/adapters/cli/grok.d.ts.map +1 -1
  26. package/dist/adapters/cli/grok.js +2 -1
  27. package/dist/adapters/cli/grok.js.map +1 -1
  28. package/dist/adapters/cli/hermes.d.ts.map +1 -1
  29. package/dist/adapters/cli/hermes.js +8 -4
  30. package/dist/adapters/cli/hermes.js.map +1 -1
  31. package/dist/adapters/cli/mtr.d.ts.map +1 -1
  32. package/dist/adapters/cli/mtr.js +3 -2
  33. package/dist/adapters/cli/mtr.js.map +1 -1
  34. package/dist/adapters/cli/opencode.d.ts.map +1 -1
  35. package/dist/adapters/cli/opencode.js +5 -4
  36. package/dist/adapters/cli/opencode.js.map +1 -1
  37. package/dist/adapters/cli/read-isolation.d.ts +14 -200
  38. package/dist/adapters/cli/read-isolation.d.ts.map +1 -1
  39. package/dist/adapters/cli/read-isolation.js +18 -477
  40. package/dist/adapters/cli/read-isolation.js.map +1 -1
  41. package/dist/adapters/cli/relay.js +2 -2
  42. package/dist/adapters/cli/relay.js.map +1 -1
  43. package/dist/adapters/cli/runner-input.d.ts +2 -2
  44. package/dist/adapters/cli/runner-input.d.ts.map +1 -1
  45. package/dist/adapters/cli/runner-input.js +9 -6
  46. package/dist/adapters/cli/runner-input.js.map +1 -1
  47. package/dist/adapters/cli/seed.d.ts.map +1 -1
  48. package/dist/adapters/cli/seed.js +2 -1
  49. package/dist/adapters/cli/seed.js.map +1 -1
  50. package/dist/adapters/cli/traex.d.ts.map +1 -1
  51. package/dist/adapters/cli/traex.js +3 -2
  52. package/dist/adapters/cli/traex.js.map +1 -1
  53. package/dist/adapters/cli/types.d.ts +25 -18
  54. package/dist/adapters/cli/types.d.ts.map +1 -1
  55. package/dist/bot-registry.d.ts +54 -23
  56. package/dist/bot-registry.d.ts.map +1 -1
  57. package/dist/bot-registry.js +91 -0
  58. package/dist/bot-registry.js.map +1 -1
  59. package/dist/cli.d.ts.map +1 -1
  60. package/dist/cli.js +297 -104
  61. package/dist/cli.js.map +1 -1
  62. package/dist/codex-app-runner.js +85 -192
  63. package/dist/codex-app-runner.js.map +1 -1
  64. package/dist/config.d.ts +0 -16
  65. package/dist/config.d.ts.map +1 -1
  66. package/dist/config.js +0 -30
  67. package/dist/config.js.map +1 -1
  68. package/dist/core/ask-broker.d.ts +12 -0
  69. package/dist/core/ask-broker.d.ts.map +1 -1
  70. package/dist/core/ask-broker.js +41 -0
  71. package/dist/core/ask-broker.js.map +1 -1
  72. package/dist/core/bot-turn-mutation-gate.d.ts +24 -0
  73. package/dist/core/bot-turn-mutation-gate.d.ts.map +1 -0
  74. package/dist/core/bot-turn-mutation-gate.js +369 -0
  75. package/dist/core/bot-turn-mutation-gate.js.map +1 -0
  76. package/dist/core/command-handler.d.ts +5 -0
  77. package/dist/core/command-handler.d.ts.map +1 -1
  78. package/dist/core/command-handler.js +53 -73
  79. package/dist/core/command-handler.js.map +1 -1
  80. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  81. package/dist/core/dashboard-ipc-server.js +356 -84
  82. package/dist/core/dashboard-ipc-server.js.map +1 -1
  83. package/dist/core/dashboard-rows.d.ts +8 -0
  84. package/dist/core/dashboard-rows.d.ts.map +1 -1
  85. package/dist/core/dashboard-rows.js.map +1 -1
  86. package/dist/core/host-overload-alert.d.ts +157 -0
  87. package/dist/core/host-overload-alert.d.ts.map +1 -0
  88. package/dist/core/host-overload-alert.js +251 -0
  89. package/dist/core/host-overload-alert.js.map +1 -0
  90. package/dist/core/passthrough-commands.d.ts +8 -0
  91. package/dist/core/passthrough-commands.d.ts.map +1 -1
  92. package/dist/core/passthrough-commands.js +20 -1
  93. package/dist/core/passthrough-commands.js.map +1 -1
  94. package/dist/core/plugins/mcp/gateway-installer.d.ts.map +1 -1
  95. package/dist/core/plugins/mcp/gateway-installer.js +14 -2
  96. package/dist/core/plugins/mcp/gateway-installer.js.map +1 -1
  97. package/dist/core/restart-coordinator.d.ts +28 -0
  98. package/dist/core/restart-coordinator.d.ts.map +1 -0
  99. package/dist/core/restart-coordinator.js +82 -0
  100. package/dist/core/restart-coordinator.js.map +1 -0
  101. package/dist/core/restart-followup-policy.d.ts +112 -0
  102. package/dist/core/restart-followup-policy.d.ts.map +1 -0
  103. package/dist/core/restart-followup-policy.js +49 -0
  104. package/dist/core/restart-followup-policy.js.map +1 -0
  105. package/dist/core/role-library.d.ts +1 -1
  106. package/dist/core/role-library.js +1 -1
  107. package/dist/core/session-cwd.d.ts.map +1 -1
  108. package/dist/core/session-cwd.js +8 -0
  109. package/dist/core/session-cwd.js.map +1 -1
  110. package/dist/core/session-discovery.d.ts +14 -0
  111. package/dist/core/session-discovery.d.ts.map +1 -1
  112. package/dist/core/session-discovery.js +25 -0
  113. package/dist/core/session-discovery.js.map +1 -1
  114. package/dist/core/session-liveness.d.ts +15 -0
  115. package/dist/core/session-liveness.d.ts.map +1 -0
  116. package/dist/core/session-liveness.js +76 -0
  117. package/dist/core/session-liveness.js.map +1 -0
  118. package/dist/core/session-manager.d.ts.map +1 -1
  119. package/dist/core/session-manager.js +90 -60
  120. package/dist/core/session-manager.js.map +1 -1
  121. package/dist/core/session-row-enrichment.d.ts +15 -0
  122. package/dist/core/session-row-enrichment.d.ts.map +1 -0
  123. package/dist/core/session-row-enrichment.js +93 -0
  124. package/dist/core/session-row-enrichment.js.map +1 -0
  125. package/dist/core/slash-inject.js +1 -1
  126. package/dist/core/slash-inject.js.map +1 -1
  127. package/dist/core/trigger-final-suppression.d.ts +24 -0
  128. package/dist/core/trigger-final-suppression.d.ts.map +1 -0
  129. package/dist/core/trigger-final-suppression.js +97 -0
  130. package/dist/core/trigger-final-suppression.js.map +1 -0
  131. package/dist/core/trigger-session.d.ts.map +1 -1
  132. package/dist/core/trigger-session.js +64 -4
  133. package/dist/core/trigger-session.js.map +1 -1
  134. package/dist/core/types.d.ts +6 -0
  135. package/dist/core/types.d.ts.map +1 -1
  136. package/dist/core/types.js.map +1 -1
  137. package/dist/core/worker-pool.d.ts +44 -0
  138. package/dist/core/worker-pool.d.ts.map +1 -1
  139. package/dist/core/worker-pool.js +333 -38
  140. package/dist/core/worker-pool.js.map +1 -1
  141. package/dist/daemon.d.ts +5 -1
  142. package/dist/daemon.d.ts.map +1 -1
  143. package/dist/daemon.js +526 -35
  144. package/dist/daemon.js.map +1 -1
  145. package/dist/dashboard/aggregator.d.ts +1 -0
  146. package/dist/dashboard/aggregator.d.ts.map +1 -1
  147. package/dist/dashboard/aggregator.js +34 -6
  148. package/dist/dashboard/aggregator.js.map +1 -1
  149. package/dist/dashboard/bot-payload.d.ts +8 -0
  150. package/dist/dashboard/bot-payload.d.ts.map +1 -1
  151. package/dist/dashboard/bot-payload.js +10 -0
  152. package/dist/dashboard/bot-payload.js.map +1 -1
  153. package/dist/dashboard/compat.d.ts +102 -2
  154. package/dist/dashboard/compat.d.ts.map +1 -1
  155. package/dist/dashboard/compat.js +78 -14
  156. package/dist/dashboard/compat.js.map +1 -1
  157. package/dist/dashboard/connector-api.d.ts.map +1 -1
  158. package/dist/dashboard/connector-api.js +1 -0
  159. package/dist/dashboard/connector-api.js.map +1 -1
  160. package/dist/dashboard/desktop-asks.d.ts +21 -0
  161. package/dist/dashboard/desktop-asks.d.ts.map +1 -0
  162. package/dist/dashboard/desktop-asks.js +49 -0
  163. package/dist/dashboard/desktop-asks.js.map +1 -0
  164. package/dist/dashboard/public-redact.d.ts +7 -0
  165. package/dist/dashboard/public-redact.d.ts.map +1 -1
  166. package/dist/dashboard/public-redact.js +35 -3
  167. package/dist/dashboard/public-redact.js.map +1 -1
  168. package/dist/dashboard/session-presentation.d.ts +16 -0
  169. package/dist/dashboard/session-presentation.d.ts.map +1 -0
  170. package/dist/dashboard/session-presentation.js +62 -0
  171. package/dist/dashboard/session-presentation.js.map +1 -0
  172. package/dist/dashboard/settings-write-applier.d.ts +2 -1
  173. package/dist/dashboard/settings-write-applier.d.ts.map +1 -1
  174. package/dist/dashboard/settings-write-applier.js +19 -1
  175. package/dist/dashboard/settings-write-applier.js.map +1 -1
  176. package/dist/dashboard/trigger-api.d.ts.map +1 -1
  177. package/dist/dashboard/trigger-api.js +19 -1
  178. package/dist/dashboard/trigger-api.js.map +1 -1
  179. package/dist/dashboard/vc-consumer-profiles-api.d.ts +7 -2
  180. package/dist/dashboard/vc-consumer-profiles-api.d.ts.map +1 -1
  181. package/dist/dashboard/vc-consumer-profiles-api.js +13 -0
  182. package/dist/dashboard/vc-consumer-profiles-api.js.map +1 -1
  183. package/dist/dashboard/web/app.d.ts.map +1 -1
  184. package/dist/dashboard/web/app.js +18 -5
  185. package/dist/dashboard/web/app.js.map +1 -1
  186. package/dist/dashboard/web/bot-defaults-page.d.ts +16 -0
  187. package/dist/dashboard/web/bot-defaults-page.d.ts.map +1 -1
  188. package/dist/dashboard/web/bot-defaults-page.js +288 -7
  189. package/dist/dashboard/web/bot-defaults-page.js.map +1 -1
  190. package/dist/dashboard/web/bot-defaults.d.ts +10 -0
  191. package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
  192. package/dist/dashboard/web/bot-defaults.js.map +1 -1
  193. package/dist/dashboard/web/client-shell.d.ts +29 -0
  194. package/dist/dashboard/web/client-shell.d.ts.map +1 -0
  195. package/dist/dashboard/web/client-shell.js +89 -0
  196. package/dist/dashboard/web/client-shell.js.map +1 -0
  197. package/dist/dashboard/web/connectors-page.d.ts.map +1 -1
  198. package/dist/dashboard/web/connectors-page.js +5 -1
  199. package/dist/dashboard/web/connectors-page.js.map +1 -1
  200. package/dist/dashboard/web/i18n.d.ts.map +1 -1
  201. package/dist/dashboard/web/i18n.js +130 -22
  202. package/dist/dashboard/web/i18n.js.map +1 -1
  203. package/dist/dashboard/web/preferences.d.ts +7 -0
  204. package/dist/dashboard/web/preferences.d.ts.map +1 -1
  205. package/dist/dashboard/web/preferences.js +32 -0
  206. package/dist/dashboard/web/preferences.js.map +1 -1
  207. package/dist/dashboard/web/sessions-kanban.d.ts +1 -1
  208. package/dist/dashboard/web/sessions-kanban.d.ts.map +1 -1
  209. package/dist/dashboard/web/sessions-kanban.js +1 -1
  210. package/dist/dashboard/web/sessions-kanban.js.map +1 -1
  211. package/dist/dashboard/web/sessions-page.d.ts.map +1 -1
  212. package/dist/dashboard/web/sessions-page.js +78 -67
  213. package/dist/dashboard/web/sessions-page.js.map +1 -1
  214. package/dist/dashboard/web/sessions.d.ts +4 -0
  215. package/dist/dashboard/web/sessions.d.ts.map +1 -1
  216. package/dist/dashboard/web/sessions.js +14 -1
  217. package/dist/dashboard/web/sessions.js.map +1 -1
  218. package/dist/dashboard/web/settings-page.d.ts +6 -0
  219. package/dist/dashboard/web/settings-page.d.ts.map +1 -1
  220. package/dist/dashboard/web/settings-page.js +55 -30
  221. package/dist/dashboard/web/settings-page.js.map +1 -1
  222. package/dist/dashboard/web/store.d.ts +1 -2
  223. package/dist/dashboard/web/store.d.ts.map +1 -1
  224. package/dist/dashboard/web/store.js +69 -20
  225. package/dist/dashboard/web/store.js.map +1 -1
  226. package/dist/dashboard/web/vc-consumer-profiles-section.d.ts.map +1 -1
  227. package/dist/dashboard/web/vc-consumer-profiles-section.js +128 -42
  228. package/dist/dashboard/web/vc-consumer-profiles-section.js.map +1 -1
  229. package/dist/dashboard/webhook-routes.d.ts.map +1 -1
  230. package/dist/dashboard/webhook-routes.js +9 -2
  231. package/dist/dashboard/webhook-routes.js.map +1 -1
  232. package/dist/dashboard-web/app.js +1 -1
  233. package/dist/dashboard-web/chunks/bot-defaults-page-VQMPUJ4Y.js +10 -0
  234. package/dist/dashboard-web/chunks/chunk-AGCPF6MT.js +1 -0
  235. package/dist/dashboard-web/chunks/{chunk-QCPPKCX3.js → chunk-JMRGZQDB.js} +1 -1
  236. package/dist/dashboard-web/chunks/{chunk-EJDYUINZ.js → chunk-NP4JUBGS.js} +1 -1
  237. package/dist/dashboard-web/chunks/{chunk-WXPMNEYV.js → chunk-P327LXOT.js} +1 -1
  238. package/dist/dashboard-web/chunks/chunk-SNKATZWK.js +49 -0
  239. package/dist/dashboard-web/chunks/connectors-page-DOY4J3ZL.js +1 -0
  240. package/dist/dashboard-web/chunks/{groups-page-3P4TLUMF.js → groups-page-74TS2DIH.js} +1 -1
  241. package/dist/dashboard-web/chunks/{insights-page-TDCZV56A.js → insights-page-AYCO65JC.js} +1 -1
  242. package/dist/dashboard-web/chunks/{monitor-room-QWCWL3SX.js → monitor-room-YKYLTSAN.js} +1 -1
  243. package/dist/dashboard-web/chunks/{monitoring-page-IOWFSNVC.js → monitoring-page-QBIUNRBR.js} +1 -1
  244. package/dist/dashboard-web/chunks/{office-page-5AUPEHZP.js → office-page-4PH2A26P.js} +1 -1
  245. package/dist/dashboard-web/chunks/overview-page-H73D5UT4.js +1 -0
  246. package/dist/dashboard-web/chunks/{roles-page-WJVB52ET.js → roles-page-U4BTOQ7F.js} +1 -1
  247. package/dist/dashboard-web/chunks/{schedules-page-RVRWHP4V.js → schedules-page-RHRHX7R7.js} +1 -1
  248. package/dist/dashboard-web/chunks/sessions-page-VSC6AM5G.js +2 -0
  249. package/dist/dashboard-web/chunks/settings-page-LF3RKM5P.js +2 -0
  250. package/dist/dashboard-web/chunks/{skills-page-7YT5TIX5.js → skills-page-SYOIGGXH.js} +1 -1
  251. package/dist/dashboard-web/chunks/{team-federation-page-NJZKTU7A.js → team-federation-page-RMBLASTW.js} +1 -1
  252. package/dist/dashboard-web/chunks/{v3-page-NQ73UA2Q.js → v3-page-XJXKKJRN.js} +1 -1
  253. package/dist/dashboard-web/chunks/{whiteboards-page-DSM3MDMN.js → whiteboards-page-K3YVSTHJ.js} +1 -1
  254. package/dist/dashboard-web/style.css +571 -3
  255. package/dist/dashboard.js +238 -9
  256. package/dist/dashboard.js.map +1 -1
  257. package/dist/global-config.d.ts +11 -0
  258. package/dist/global-config.d.ts.map +1 -1
  259. package/dist/global-config.js +27 -1
  260. package/dist/global-config.js.map +1 -1
  261. package/dist/i18n/en.d.ts.map +1 -1
  262. package/dist/i18n/en.js +6 -19
  263. package/dist/i18n/en.js.map +1 -1
  264. package/dist/i18n/zh.d.ts.map +1 -1
  265. package/dist/i18n/zh.js +6 -19
  266. package/dist/i18n/zh.js.map +1 -1
  267. package/dist/im/lark/card-builder.d.ts +1 -14
  268. package/dist/im/lark/card-builder.d.ts.map +1 -1
  269. package/dist/im/lark/card-builder.js +1 -35
  270. package/dist/im/lark/card-builder.js.map +1 -1
  271. package/dist/im/lark/card-handler.d.ts +10 -0
  272. package/dist/im/lark/card-handler.d.ts.map +1 -1
  273. package/dist/im/lark/card-handler.js +161 -44
  274. package/dist/im/lark/card-handler.js.map +1 -1
  275. package/dist/im/lark/client.d.ts +47 -0
  276. package/dist/im/lark/client.d.ts.map +1 -1
  277. package/dist/im/lark/client.js +127 -18
  278. package/dist/im/lark/client.js.map +1 -1
  279. package/dist/im/lark/event-dispatcher.d.ts +13 -0
  280. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  281. package/dist/im/lark/event-dispatcher.js +48 -3
  282. package/dist/im/lark/event-dispatcher.js.map +1 -1
  283. package/dist/im/lark/identity-cache.d.ts +25 -1
  284. package/dist/im/lark/identity-cache.d.ts.map +1 -1
  285. package/dist/im/lark/identity-cache.js +47 -1
  286. package/dist/im/lark/identity-cache.js.map +1 -1
  287. package/dist/im/lark/overload-nonce.d.ts +19 -0
  288. package/dist/im/lark/overload-nonce.d.ts.map +1 -0
  289. package/dist/im/lark/overload-nonce.js +72 -0
  290. package/dist/im/lark/overload-nonce.js.map +1 -0
  291. package/dist/im/lark/v3-progress-card.d.ts.map +1 -1
  292. package/dist/im/lark/v3-progress-card.js +5 -0
  293. package/dist/im/lark/v3-progress-card.js.map +1 -1
  294. package/dist/services/async-trigger-state.d.ts +82 -0
  295. package/dist/services/async-trigger-state.d.ts.map +1 -0
  296. package/dist/services/async-trigger-state.js +95 -0
  297. package/dist/services/async-trigger-state.js.map +1 -0
  298. package/dist/services/async-trigger-store.d.ts +28 -0
  299. package/dist/services/async-trigger-store.d.ts.map +1 -0
  300. package/dist/services/async-trigger-store.js +120 -0
  301. package/dist/services/async-trigger-store.js.map +1 -0
  302. package/dist/services/bot-config-store.d.ts.map +1 -1
  303. package/dist/services/bot-config-store.js +34 -2
  304. package/dist/services/bot-config-store.js.map +1 -1
  305. package/dist/services/bridge-fallback-gate.d.ts +32 -0
  306. package/dist/services/bridge-fallback-gate.d.ts.map +1 -1
  307. package/dist/services/bridge-fallback-gate.js +39 -0
  308. package/dist/services/bridge-fallback-gate.js.map +1 -1
  309. package/dist/services/bridge-turn-queue.d.ts.map +1 -1
  310. package/dist/services/bridge-turn-queue.js +15 -1
  311. package/dist/services/bridge-turn-queue.js.map +1 -1
  312. package/dist/services/card-prefs-store.d.ts +4 -0
  313. package/dist/services/card-prefs-store.d.ts.map +1 -1
  314. package/dist/services/card-prefs-store.js +8 -0
  315. package/dist/services/card-prefs-store.js.map +1 -1
  316. package/dist/services/claude-transcript.d.ts +23 -0
  317. package/dist/services/claude-transcript.d.ts.map +1 -1
  318. package/dist/services/claude-transcript.js +33 -0
  319. package/dist/services/claude-transcript.js.map +1 -1
  320. package/dist/services/codex-app-runner-protocol.d.ts +61 -0
  321. package/dist/services/codex-app-runner-protocol.d.ts.map +1 -0
  322. package/dist/services/codex-app-runner-protocol.js +170 -0
  323. package/dist/services/codex-app-runner-protocol.js.map +1 -0
  324. package/dist/services/codex-app-turn-controller.d.ts +72 -0
  325. package/dist/services/codex-app-turn-controller.d.ts.map +1 -0
  326. package/dist/services/codex-app-turn-controller.js +524 -0
  327. package/dist/services/codex-app-turn-controller.js.map +1 -0
  328. package/dist/services/codex-runner-freshness.d.ts +39 -0
  329. package/dist/services/codex-runner-freshness.d.ts.map +1 -0
  330. package/dist/services/codex-runner-freshness.js +78 -0
  331. package/dist/services/codex-runner-freshness.js.map +1 -0
  332. package/dist/services/connector-store.d.ts +6 -0
  333. package/dist/services/connector-store.d.ts.map +1 -1
  334. package/dist/services/connector-store.js.map +1 -1
  335. package/dist/services/grant-store.d.ts.map +1 -1
  336. package/dist/services/grant-store.js +16 -0
  337. package/dist/services/grant-store.js.map +1 -1
  338. package/dist/services/hermes-transcript.d.ts +1 -0
  339. package/dist/services/hermes-transcript.d.ts.map +1 -1
  340. package/dist/services/hermes-transcript.js +49 -0
  341. package/dist/services/hermes-transcript.js.map +1 -1
  342. package/dist/services/sandbox-migration.d.ts +14 -0
  343. package/dist/services/sandbox-migration.d.ts.map +1 -0
  344. package/dist/services/sandbox-migration.js +85 -0
  345. package/dist/services/sandbox-migration.js.map +1 -0
  346. package/dist/services/sandbox-store.d.ts +28 -0
  347. package/dist/services/sandbox-store.d.ts.map +1 -1
  348. package/dist/services/sandbox-store.js +82 -0
  349. package/dist/services/sandbox-store.js.map +1 -1
  350. package/dist/services/schedule-split-migration.d.ts +2 -0
  351. package/dist/services/schedule-split-migration.d.ts.map +1 -0
  352. package/dist/services/schedule-split-migration.js +159 -0
  353. package/dist/services/schedule-split-migration.js.map +1 -0
  354. package/dist/services/schedule-store.d.ts +28 -4
  355. package/dist/services/schedule-store.d.ts.map +1 -1
  356. package/dist/services/schedule-store.js +118 -33
  357. package/dist/services/schedule-store.js.map +1 -1
  358. package/dist/services/trigger-types.d.ts +18 -1
  359. package/dist/services/trigger-types.d.ts.map +1 -1
  360. package/dist/services/trigger-types.js +3 -0
  361. package/dist/services/trigger-types.js.map +1 -1
  362. package/dist/services/vc-meeting-consumer-profile-store.d.ts.map +1 -1
  363. package/dist/services/vc-meeting-consumer-profile-store.js +1 -0
  364. package/dist/services/vc-meeting-consumer-profile-store.js.map +1 -1
  365. package/dist/services/vc-meeting-consumer-profile-templates.d.ts +32 -0
  366. package/dist/services/vc-meeting-consumer-profile-templates.d.ts.map +1 -0
  367. package/dist/services/vc-meeting-consumer-profile-templates.js +106 -0
  368. package/dist/services/vc-meeting-consumer-profile-templates.js.map +1 -0
  369. package/dist/services/vc-meeting-delivery-feed.d.ts +1 -1
  370. package/dist/services/vc-meeting-delivery-feed.d.ts.map +1 -1
  371. package/dist/services/vc-meeting-delivery-feed.js +2 -1
  372. package/dist/services/vc-meeting-delivery-feed.js.map +1 -1
  373. package/dist/services/vc-meeting-delivery-hub-store.d.ts +3 -1
  374. package/dist/services/vc-meeting-delivery-hub-store.d.ts.map +1 -1
  375. package/dist/services/vc-meeting-delivery-hub-store.js +11 -0
  376. package/dist/services/vc-meeting-delivery-hub-store.js.map +1 -1
  377. package/dist/services/vc-meeting-delivery-protocol.d.ts +9 -2
  378. package/dist/services/vc-meeting-delivery-protocol.d.ts.map +1 -1
  379. package/dist/services/vc-meeting-delivery-protocol.js +10 -1
  380. package/dist/services/vc-meeting-delivery-protocol.js.map +1 -1
  381. package/dist/services/vc-meeting-delivery-receiver.d.ts.map +1 -1
  382. package/dist/services/vc-meeting-delivery-receiver.js +10 -1
  383. package/dist/services/vc-meeting-delivery-receiver.js.map +1 -1
  384. package/dist/services/vc-meeting-delivery-store.d.ts +12 -2
  385. package/dist/services/vc-meeting-delivery-store.d.ts.map +1 -1
  386. package/dist/services/vc-meeting-delivery-store.js +14 -2
  387. package/dist/services/vc-meeting-delivery-store.js.map +1 -1
  388. package/dist/services/vc-meeting-im-reply.d.ts +2 -1
  389. package/dist/services/vc-meeting-im-reply.d.ts.map +1 -1
  390. package/dist/services/vc-meeting-im-reply.js +19 -3
  391. package/dist/services/vc-meeting-im-reply.js.map +1 -1
  392. package/dist/services/vc-meeting-listener-output-protocol.d.ts +27 -0
  393. package/dist/services/vc-meeting-listener-output-protocol.d.ts.map +1 -0
  394. package/dist/services/vc-meeting-listener-output-protocol.js +58 -0
  395. package/dist/services/vc-meeting-listener-output-protocol.js.map +1 -0
  396. package/dist/services/vc-meeting-listener-topic-store.d.ts +31 -0
  397. package/dist/services/vc-meeting-listener-topic-store.d.ts.map +1 -0
  398. package/dist/services/vc-meeting-listener-topic-store.js +120 -0
  399. package/dist/services/vc-meeting-listener-topic-store.js.map +1 -0
  400. package/dist/services/vc-meeting-runtime-store.d.ts +2 -1
  401. package/dist/services/vc-meeting-runtime-store.d.ts.map +1 -1
  402. package/dist/services/vc-meeting-runtime-store.js +8 -0
  403. package/dist/services/vc-meeting-runtime-store.js.map +1 -1
  404. package/dist/services/vc-meeting-send-policy.d.ts +8 -1
  405. package/dist/services/vc-meeting-send-policy.d.ts.map +1 -1
  406. package/dist/services/vc-meeting-send-policy.js +8 -0
  407. package/dist/services/vc-meeting-send-policy.js.map +1 -1
  408. package/dist/types.d.ts +53 -4
  409. package/dist/types.d.ts.map +1 -1
  410. package/dist/utils/allowed-users-apply.d.ts +76 -0
  411. package/dist/utils/allowed-users-apply.d.ts.map +1 -0
  412. package/dist/utils/allowed-users-apply.js +123 -0
  413. package/dist/utils/allowed-users-apply.js.map +1 -0
  414. package/dist/utils/allowed-users-cache.d.ts +29 -0
  415. package/dist/utils/allowed-users-cache.d.ts.map +1 -0
  416. package/dist/utils/allowed-users-cache.js +82 -0
  417. package/dist/utils/allowed-users-cache.js.map +1 -0
  418. package/dist/utils/child-env.d.ts +4 -3
  419. package/dist/utils/child-env.d.ts.map +1 -1
  420. package/dist/utils/child-env.js +16 -1
  421. package/dist/utils/child-env.js.map +1 -1
  422. package/dist/utils/cli-usage-limit.d.ts +27 -1
  423. package/dist/utils/cli-usage-limit.d.ts.map +1 -1
  424. package/dist/utils/cli-usage-limit.js +92 -20
  425. package/dist/utils/cli-usage-limit.js.map +1 -1
  426. package/dist/utils/input-gate.d.ts +2 -0
  427. package/dist/utils/input-gate.d.ts.map +1 -1
  428. package/dist/utils/input-gate.js +2 -0
  429. package/dist/utils/input-gate.js.map +1 -1
  430. package/dist/utils/runtime-build-id.d.ts +21 -0
  431. package/dist/utils/runtime-build-id.d.ts.map +1 -0
  432. package/dist/utils/runtime-build-id.js +83 -0
  433. package/dist/utils/runtime-build-id.js.map +1 -0
  434. package/dist/utils/terminal-renderer.d.ts +3 -3
  435. package/dist/utils/terminal-renderer.js +3 -3
  436. package/dist/vc-agent/cards.d.ts.map +1 -1
  437. package/dist/vc-agent/cards.js +21 -1
  438. package/dist/vc-agent/cards.js.map +1 -1
  439. package/dist/worker.js +1010 -669
  440. package/dist/worker.js.map +1 -1
  441. package/dist/workflows/events/payloads.d.ts +56 -0
  442. package/dist/workflows/events/payloads.d.ts.map +1 -1
  443. package/dist/workflows/events/payloads.js +9 -0
  444. package/dist/workflows/events/payloads.js.map +1 -1
  445. package/dist/workflows/events/schema.d.ts +86 -0
  446. package/dist/workflows/events/schema.d.ts.map +1 -1
  447. package/dist/workflows/hostExecutors/botmux-schedule.d.ts.map +1 -1
  448. package/dist/workflows/hostExecutors/botmux-schedule.js +13 -1
  449. package/dist/workflows/hostExecutors/botmux-schedule.js.map +1 -1
  450. package/dist/workflows/migration/v2-run-archive-schema.d.ts +20 -20
  451. package/dist/workflows/migration/v2-to-v3.d.ts.map +1 -1
  452. package/dist/workflows/migration/v2-to-v3.js +5 -1
  453. package/dist/workflows/migration/v2-to-v3.js.map +1 -1
  454. package/dist/workflows/shared/sandbox-policy.d.ts +10 -1
  455. package/dist/workflows/shared/sandbox-policy.d.ts.map +1 -1
  456. package/dist/workflows/shared/sandbox-policy.js +12 -0
  457. package/dist/workflows/shared/sandbox-policy.js.map +1 -1
  458. package/dist/workflows/v3/architect.d.ts.map +1 -1
  459. package/dist/workflows/v3/architect.js +1 -0
  460. package/dist/workflows/v3/architect.js.map +1 -1
  461. package/dist/workflows/v3/bot-resolve.d.ts.map +1 -1
  462. package/dist/workflows/v3/bot-resolve.js +36 -0
  463. package/dist/workflows/v3/bot-resolve.js.map +1 -1
  464. package/dist/workflows/v3/cli-run.d.ts +9 -0
  465. package/dist/workflows/v3/cli-run.d.ts.map +1 -1
  466. package/dist/workflows/v3/cli-run.js +17 -2
  467. package/dist/workflows/v3/cli-run.js.map +1 -1
  468. package/dist/workflows/v3/contract.d.ts +8 -0
  469. package/dist/workflows/v3/contract.d.ts.map +1 -1
  470. package/dist/workflows/v3/contract.js.map +1 -1
  471. package/dist/workflows/v3/goal-cli.d.ts +58 -0
  472. package/dist/workflows/v3/goal-cli.d.ts.map +1 -0
  473. package/dist/workflows/v3/goal-cli.js +430 -0
  474. package/dist/workflows/v3/goal-cli.js.map +1 -0
  475. package/dist/workflows/v3/library-materialize.d.ts.map +1 -1
  476. package/dist/workflows/v3/library-materialize.js +5 -1
  477. package/dist/workflows/v3/library-materialize.js.map +1 -1
  478. package/dist/workflows/v3/library-schema.d.ts +21 -0
  479. package/dist/workflows/v3/library-schema.d.ts.map +1 -1
  480. package/dist/workflows/v3/library-schema.js +95 -1
  481. package/dist/workflows/v3/library-schema.js.map +1 -1
  482. package/dist/workflows/v3/progress-projection.d.ts +3 -0
  483. package/dist/workflows/v3/progress-projection.d.ts.map +1 -1
  484. package/dist/workflows/v3/progress-projection.js +23 -1
  485. package/dist/workflows/v3/progress-projection.js.map +1 -1
  486. package/dist/workflows/v3/run-envelope.d.ts +46 -1
  487. package/dist/workflows/v3/run-envelope.d.ts.map +1 -1
  488. package/dist/workflows/v3/run-envelope.js +32 -1
  489. package/dist/workflows/v3/run-envelope.js.map +1 -1
  490. package/package.json +2 -2
  491. package/dist/dashboard-web/chunks/bot-defaults-page-O376WSER.js +0 -5
  492. package/dist/dashboard-web/chunks/chunk-H7P5UV4W.js +0 -49
  493. package/dist/dashboard-web/chunks/chunk-SZOB6VK2.js +0 -1
  494. package/dist/dashboard-web/chunks/connectors-page-IR54AOAI.js +0 -1
  495. package/dist/dashboard-web/chunks/overview-page-QJMI6NLW.js +0 -1
  496. package/dist/dashboard-web/chunks/sessions-page-KWLGBX6G.js +0 -3
  497. package/dist/dashboard-web/chunks/settings-page-TNZEPC7V.js +0 -2
  498. package/dist/services/sandbox-land.d.ts +0 -45
  499. package/dist/services/sandbox-land.d.ts.map +0 -1
  500. package/dist/services/sandbox-land.js +0 -394
  501. package/dist/services/sandbox-land.js.map +0 -1
  502. package/dist/utils/screen-analyzer.d.ts +0 -75
  503. package/dist/utils/screen-analyzer.d.ts.map +0 -1
  504. package/dist/utils/screen-analyzer.js +0 -333
  505. package/dist/utils/screen-analyzer.js.map +0 -1
@@ -1,128 +1,45 @@
1
1
  /**
2
- * File-isolation sandbox (bubblewrap + overlayfs) for oncall bots.
2
+ * Linux file sandbox: bwrap DIRECT mode (fs-policy three-tier whitelist).
3
3
  *
4
- * Model (OVERLAYFS read-all / write-isolated, per product decision 2026-06-10):
5
- * the sandboxed agent READS the entire real filesystem natively the real CLI
6
- * config/auth/env/project, NO scrub, the CLI just works. WRITES are isolated via
7
- * an overlayfs mount: the real lower layer is NEVER modified, only changed files
8
- * copy-up into a per-session UPPER layer (zero-copy reads, only the delta uses
9
- * disk, NO git clone). Landing copies that UPPER changeset back to the real
10
- * project. Privacy masking is per-bot opt-in with NO defaults.
4
+ * Model (2026-07-16 refactor, design doc "botmux 文件沙盒重构方案"): the
5
+ * sandboxed CLI writes the PROJECT DIRECTLY (same behaviour as an unsandboxed
6
+ * run inside the policy's readWrite zones) and sees NOTHING outside the
7
+ * policy's rules a fresh tmpfs root, only the rule paths bound in. This
8
+ * replaced the overlayfs+landing model: no mounts to leak, no landing step,
9
+ * no bridge redirect (the CLI's data dir is a REAL host path).
11
10
  *
12
- * Mechanism (empirically verified runs as root on this 5.15 kernel):
13
- * mount -t overlay overlay -o lowerdir=REAL,upperdir=UPPER,workdir=WORK MERGED
14
- * bwrap 0.8.0 has NO --overlay, so we mount the overlay ON THE HOST then bind the
15
- * merged dir into bwrap. overlayfs forbids upper/work INSIDE lower, so the HOME
16
- * overlay (lower=/root) puts upper/work OUTSIDE /root (under /var/tmp/...). The
17
- * merged mountpoints also live there: putting home-merged below HOME makes the
18
- * rootless FUSE overlay recursively contain itself, and bwrap can block forever
19
- * while resolving later bind destinations. Project upper/work stay under the
20
- * data dir because they are the persistent, landable changeset.
11
+ * The policy is built by the worker (adapters/cli/fs-policy.ts the single
12
+ * source of truth for BOTH platforms) and compiled to bwrap argv here. macOS
13
+ * enforces the SAME policy via Seatbelt (compileToSeatbelt) at the worker's
14
+ * spawn site nothing in this module runs on darwin.
21
15
  *
22
- * Linux-only (overlayfs + bwrap depend on Linux). macOS reuses Anthropic's
23
- * sandbox-exec approach and is handled elsewhere.
16
+ * `botmux send` relay: unchanged from the previous model. The sandboxed CLI's
17
+ * `botmux send` writes a validated request into a per-session outbox; the
18
+ * daemon-side watcher re-executes the send OUTSIDE the sandbox with real
19
+ * credentials. No Feishu credential ever enters the sandbox.
24
20
  */
25
- import { homedir } from 'node:os';
26
- import { mkdirSync, existsSync, writeFileSync, chmodSync, readdirSync, readFileSync, rmSync, statSync, lstatSync, realpathSync, symlinkSync, unlinkSync, openSync, fstatSync, readSync, writeSync, closeSync, constants as fsConstants } from 'node:fs';
21
+ import { mkdirSync, existsSync, writeFileSync, chmodSync, readdirSync, readFileSync, rmSync, rmdirSync, unlinkSync, statSync, lstatSync, readlinkSync, realpathSync, openSync, fstatSync, readSync, writeSync, closeSync, constants as fsConstants } from 'node:fs';
27
22
  import { atomicWriteFileSync } from '../../utils/atomic-write.js';
28
- import { basename, isAbsolute, join, dirname, relative, resolve } from 'node:path';
23
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
29
24
  import { fileURLToPath } from 'node:url';
30
25
  import { spawn, spawnSync } from 'node:child_process';
26
+ import { compileToBwrap } from '../cli/fs-policy.js';
31
27
  import { PROXY_ENV_KEYS } from '../../utils/child-env.js';
32
28
  import { MCP_GATEWAY_REQUIRED_ENV, MCP_GATEWAY_SOCKET_ENV, } from '../../core/plugins/mcp/environment.js';
33
- /** Host root for the HOME overlay's upper/work — MUST be OUTSIDE the home lower
34
- * (overlayfs forbids upper/work inside lower). */
35
- const VARTMP_ROOT = '/var/tmp/botmux-sbx';
36
- // ───────────────────────────── overlay primitives ────────────────────────────
37
- /**
38
- * Mount an overlayfs: reads fall through to `lower` (real, zero copy); writes
39
- * copy-up into `upper` (the landable changeset). `work` is overlayfs scratch on
40
- * the same fs as `upper`. Returns true iff `mount` exited 0.
41
- */
42
- export function mountOverlay(opts) {
43
- for (const d of [opts.upper, opts.work, opts.merged]) {
44
- try {
45
- mkdirSync(d, { recursive: true });
46
- }
47
- catch { /* */ }
48
- }
49
- const optStr = `lowerdir=${opts.lower},upperdir=${opts.upper},workdir=${opts.work}`;
50
- // A privileged (root) daemon uses the kernel overlayfs driver — fastest. A
51
- // non-root daemon CANNOT mount kernel overlayfs even inside bwrap's userns (the
52
- // hardened mount env rejects it on this kernel), so it falls back to
53
- // fuse-overlayfs — a userspace overlay needing no root, only /dev/fuse. Same
54
- // lowerdir/upperdir/workdir semantics → landing, the bridge redirect, and the
55
- // privacy masks all work identically; only the mount mechanism differs.
56
- // BOTMUX_SANDBOX_FUSE=1 forces the userspace path even as root (escape hatch +
57
- // lets a root daemon exercise exactly what unprivileged users hit).
58
- const forceFuse = process.env.BOTMUX_SANDBOX_FUSE === '1';
59
- if (!forceFuse && process.getuid?.() === 0) {
60
- const r = spawnSync('mount', ['-t', 'overlay', 'overlay', '-o', optStr, opts.merged], { stdio: 'pipe' });
61
- if (r.status === 0)
62
- return true;
63
- // root but kernel mount failed (rare) → fall through to fuse-overlayfs
64
- }
65
- const f = spawnSync('fuse-overlayfs', ['-o', optStr, opts.merged], { stdio: 'pipe' });
66
- return f.status === 0;
67
- }
68
- function decodeMountInfoPath(raw) {
69
- return raw.replace(/\\([0-7]{3})/g, (_match, octal) => String.fromCharCode(Number.parseInt(octal, 8)));
70
- }
71
- /** True iff `path` is currently a mountpoint (host-side overlay still mounted).
72
- * Read mountinfo instead of stat'ing the path: a broken recursive FUSE mount can
73
- * leave any path-based `mountpoint` probe blocked in uninterruptible I/O. */
74
- export function isMounted(path) {
75
- const target = resolve(path);
76
- try {
77
- const mountInfo = readFileSync('/proc/self/mountinfo', 'utf8');
78
- return mountInfo.split('\n').some(line => {
79
- const fields = line.split(' ');
80
- return fields.length > 4 && decodeMountInfoPath(fields[4]) === target;
81
- });
82
- }
83
- catch {
84
- return spawnSync('mountpoint', ['-q', target], {
85
- stdio: 'ignore',
86
- timeout: 2_000,
87
- }).status === 0;
88
- }
89
- }
90
- /** Unmount an overlay merged dir. Best-effort: lazy detach if a normal unmount
91
- * fails because a still-draining bwrap child holds the FUSE mount busy. */
92
- export function unmountOverlay(merged) {
93
- if (!isMounted(merged))
94
- return; // not a mountpoint
95
- // kernel overlay → `umount`; fuse-overlayfs → `fusermount -u` (a non-root
96
- // daemon can't `umount` its own fuse mount). `fusermount -uz` is the critical
97
- // rootless busy-mount fallback; plain `umount -l` is usually not permitted.
98
- if (spawnSync('fusermount', ['-u', merged], { stdio: 'ignore' }).status === 0)
99
- return;
100
- if (spawnSync('umount', [merged], { stdio: 'ignore' }).status === 0)
101
- return;
102
- if (spawnSync('fusermount', ['-uz', merged], { stdio: 'ignore' }).status === 0)
103
- return;
104
- spawnSync('umount', ['-l', merged], { stdio: 'ignore' });
105
- }
106
- /** Verify (and best-effort auto-install) the sandbox runtime deps so the user
107
- * needn't pre-install: `bubblewrap` always, `fuse-overlayfs` when the userspace
108
- * overlay path is used (non-root daemon, or BOTMUX_SANDBOX_FUSE=1). Installs via
109
- * the system package manager when the daemon can (root, or passwordless sudo);
110
- * otherwise logs a one-line manual-install hint and returns false so the caller
111
- * fails the spawn (never a silent unsandboxed run). Returns true if all present. */
112
- function ensureSandboxDeps(needFuse) {
29
+ /** Verify (and best-effort auto-install) bubblewrap so the user needn't
30
+ * pre-install. Installs via the system package manager when the daemon can
31
+ * (root, or passwordless sudo); otherwise logs a one-line manual-install hint
32
+ * and returns false so the caller fails the spawn (never a silent
33
+ * unsandboxed run). */
34
+ function ensureSandboxDeps() {
113
35
  const has = (cmd) => spawnSync('sh', ['-c', `command -v ${cmd}`], { stdio: 'ignore' }).status === 0;
114
- const missing = [];
115
- if (!has('bwrap'))
116
- missing.push('bubblewrap');
117
- if (needFuse && !has('fuse-overlayfs'))
118
- missing.push('fuse-overlayfs');
119
- if (!missing.length)
36
+ if (has('bwrap'))
120
37
  return true;
121
- const pm = has('apt-get') ? ['apt-get', 'install', '-y', ...missing] :
122
- has('dnf') ? ['dnf', 'install', '-y', ...missing] :
123
- has('yum') ? ['yum', 'install', '-y', ...missing] :
124
- has('apk') ? ['apk', 'add', ...missing] :
125
- has('pacman') ? ['pacman', '-S', '--noconfirm', ...missing] :
38
+ const pm = has('apt-get') ? ['apt-get', 'install', '-y', 'bubblewrap'] :
39
+ has('dnf') ? ['dnf', 'install', '-y', 'bubblewrap'] :
40
+ has('yum') ? ['yum', 'install', '-y', 'bubblewrap'] :
41
+ has('apk') ? ['apk', 'add', 'bubblewrap'] :
42
+ has('pacman') ? ['pacman', '-S', '--noconfirm', 'bubblewrap'] :
126
43
  null;
127
44
  const isRoot = process.getuid?.() === 0;
128
45
  if (pm) {
@@ -130,86 +47,13 @@ function ensureSandboxDeps(needFuse) {
130
47
  // (never blocks on an interactive prompt).
131
48
  const argv = isRoot ? pm : ['sudo', '-n', ...pm];
132
49
  const r = spawnSync(argv[0], argv.slice(1), { stdio: 'ignore', timeout: 180_000 });
133
- if (r.status === 0 && !missing.some(m => !has(m === 'bubblewrap' ? 'bwrap' : m)))
50
+ if (r.status === 0 && has('bwrap'))
134
51
  return true;
135
52
  }
136
- const guide = pm ? `${isRoot ? '' : 'sudo '}${pm.join(' ')}` : `install: ${missing.join(', ')}`;
137
- console.error(`[sandbox] missing deps (${missing.join(', ')}); auto-install unavailable — install manually then retry: ${guide}`);
53
+ const guide = pm ? `${isRoot ? '' : 'sudo '}${pm.join(' ')}` : 'install bubblewrap';
54
+ console.error(`[sandbox] bwrap missing; auto-install unavailable — install manually then retry: ${guide}`);
138
55
  return false;
139
56
  }
140
- /**
141
- * Build the bwrap argv prefix. Final spawn becomes:
142
- * bwrap <these args> -- <cliBin> <cliArgs...>
143
- *
144
- * Mount order matters (later mounts win): the whole real fs read-only first, then
145
- * the home + project merged overlays bind over it (so writes there are isolated),
146
- * then user readonly inputs, then privacy masks blank specific paths (masks bind
147
- * after user readonly roots so an overlapping readonly entry can never re-expose
148
- * masked content), then trusted runtime roots, then the outbox binds LAST so it
149
- * stays writable even if a mask covers a parent dir.
150
- */
151
- export function buildSandboxArgs(plan) {
152
- const a = [];
153
- // Read the entire real fs (zero scrub — the CLI's config/auth/env just work).
154
- a.push('--ro-bind', '/', '/');
155
- // Fresh kernel/runtime dirs (the ro-bind of / would otherwise carry host /tmp etc.).
156
- a.push('--proc', '/proc', '--dev', '/dev', '--tmpfs', '/tmp', '--tmpfs', '/run', '--tmpfs', '/dev/shm');
157
- if (plan.mcpGatewaySocket) {
158
- a.push('--dir', plan.mcpGatewaySocket.sandboxDir);
159
- a.push('--ro-bind', plan.mcpGatewaySocket.hostDir, plan.mcpGatewaySocket.sandboxDir);
160
- }
161
- // Write-isolated home + project (overlay merged: reads=real lower, writes=upper).
162
- // If cwd IS HOME, two binds to the same destination would make the later
163
- // project bind silently shadow the home overlay. Use the project overlay as
164
- // the single winning layer in that case.
165
- if (plan.projectMount !== plan.home) {
166
- a.push('--bind', plan.homeMerged, plan.home);
167
- }
168
- a.push('--bind', plan.projectMerged, plan.projectMount);
169
- // CLI auth/login dirs kept REAL + writable (bind over the isolated home) so token
170
- // refresh / login persists. Narrow (auth only) keeps session history isolated;
171
- // some CLIs widen to their whole state dir for SQLite locks (CliAdapter.authPaths).
172
- for (const p of plan.authReal ?? [])
173
- a.push('--bind', p, p);
174
- // User-configured read-only inputs — BEFORE the masks so hidePaths always win
175
- // over an overlapping (e.g. ancestor) readonly entry.
176
- for (const root of plan.userReadonlyRoots ?? [])
177
- a.push('--ro-bind', root, root);
178
- // Per-bot privacy masks (opt-in, no defaults).
179
- for (const dir of plan.hideDirs)
180
- a.push('--tmpfs', dir);
181
- for (const f of plan.hideFiles)
182
- a.push('--ro-bind', f.empty, f.path);
183
- // Re-open only daemon-derived private roots (for example this bot's own
184
- // BOT_HOME) after their parent BOTMUX_HOME was blanked.
185
- for (const root of plan.trustedWritableRoots ?? [])
186
- a.push('--bind', root, root);
187
- // A private root can itself contain a credential that the sandbox must not
188
- // recover (notably send-cred.json). Re-deny those after the carve-out.
189
- for (const dir of plan.finalHideDirs ?? [])
190
- a.push('--tmpfs', dir);
191
- for (const f of plan.finalHideFiles ?? [])
192
- a.push('--ro-bind', f.empty, f.path);
193
- // Session-scoped TRUSTED runtime inputs, e.g. generated skill/plugin dirs —
194
- // after the masks so a broad hideDir can't blank skill delivery.
195
- for (const root of plan.readonlyRoots ?? [])
196
- a.push('--ro-bind', root, root);
197
- // A readonly runtime root can contain install-time credentials that the child
198
- // does not need. Re-mask those paths after the root bind so mount order cannot
199
- // re-expose them.
200
- for (const dir of plan.postReadonlyHideDirs ?? [])
201
- a.push('--tmpfs', dir);
202
- for (const f of plan.postReadonlyHideFiles ?? [])
203
- a.push('--ro-bind', f.empty, f.path);
204
- // Outbox LAST so it wins even if a mask covers a parent dir.
205
- a.push('--bind', plan.outbox, plan.outbox);
206
- // Isolate namespaces (keep net unless explicitly disabled).
207
- a.push('--unshare-user', '--unshare-pid', '--unshare-ipc', '--unshare-uts', '--unshare-cgroup-try');
208
- if (plan.net === false)
209
- a.push('--unshare-net');
210
- a.push('--die-with-parent', '--new-session', '--chdir', plan.projectMount);
211
- return a;
212
- }
213
57
  function assertCredentialIsolationPath(path, kind) {
214
58
  const normalized = resolve(path);
215
59
  if (!isAbsolute(path) || normalized !== path || normalized === '/') {
@@ -217,15 +61,8 @@ function assertCredentialIsolationPath(path, kind) {
217
61
  }
218
62
  return normalized;
219
63
  }
220
- /**
221
- * Build the lightweight bwrap wrapper used when full overlay sandboxing is off.
222
- * The host filesystem, including BOTMUX_HOME itself, remains live/read-write.
223
- * Device credentials and their arbitrary future sidecars live below a dedicated
224
- * authority directory which is replaced wholesale with a private tmpfs. The
225
- * few legacy/root authority files are masked individually with read-only
226
- * /dev/null mounts. This preserves root-level atomic writes, newly created
227
- * plugin/skill/run directories, symlinks, and live config replacement.
228
- */
64
+ /** Lightweight bwrap used when only the one-way device credential boundary is
65
+ * required and the full file sandbox is off. */
229
66
  export function buildCredentialOnlySandboxArgs(input) {
230
67
  if (!isAbsolute(input.workingDir) || !isAbsolute(input.cliBin)) {
231
68
  throw new Error('credential isolation requires absolute cwd and CLI binary paths');
@@ -237,17 +74,12 @@ export function buildCredentialOnlySandboxArgs(input) {
237
74
  }
238
75
  const args = [
239
76
  '--bind', '/', '/',
240
- // Do not expose host PIDs: `/proc/<worker-pid>/root/...` would otherwise
241
- // cross back into the worker's unfiltered mount namespace and recover a
242
- // credential despite the private authority mounts below.
243
77
  '--proc', '/proc',
244
78
  ];
245
79
  for (const path of [...new Set(input.readonlyPaths ?? [])].sort()) {
246
80
  const normalized = assertCredentialIsolationPath(path, 'readonly path');
247
81
  args.push('--ro-bind', normalized, normalized);
248
82
  }
249
- // Authority masks are deliberately LAST so no readonly carve-out can
250
- // accidentally re-expose a credential ancestor.
251
83
  for (const directory of hideDirectories.sort())
252
84
  args.push('--tmpfs', directory);
253
85
  for (const file of hideFiles.sort())
@@ -268,15 +100,8 @@ function probeBubblewrapCredentialMasks() {
268
100
  catch { /* spawn probe reports failure below */ }
269
101
  const probe = spawnSync(executable, ['--help'], { encoding: 'utf8' });
270
102
  if (probe.status !== 0) {
271
- return {
272
- supported: false,
273
- mechanism: null,
274
- reason: 'bubblewrap is unavailable',
275
- };
103
+ return { supported: false, mechanism: null, reason: 'bubblewrap is unavailable' };
276
104
  }
277
- // Prove this host can actually create the namespaces/mounts, not merely that
278
- // a binary exists. A private /tmp exercises the directory-mask primitive
279
- // without touching host files.
280
105
  const runtime = spawnSync(executable, [
281
106
  '--bind', '/', '/',
282
107
  '--proc', '/proc',
@@ -300,10 +125,6 @@ function probeBubblewrapCredentialMasks() {
300
125
  : 'bubblewrap cannot establish the required user/mount/PID namespaces',
301
126
  };
302
127
  }
303
- /** Host-side pre-enrollment probe. It accepts no child/session environment or
304
- * adapter input, performs no writes/installation, and therefore can safely run
305
- * before the fixed marker is created. Enrollment uses this to refuse enabling
306
- * device credentials on a host where future workers could not confine them. */
307
128
  export function probeHostCredentialIsolationMechanism() {
308
129
  if (process.platform === 'darwin') {
309
130
  const executable = '/usr/bin/sandbox-exec';
@@ -325,10 +146,8 @@ export function probeHostCredentialIsolationMechanism() {
325
146
  reason: `credential isolation unsupported on ${process.platform}`,
326
147
  };
327
148
  }
328
- /** Probe/install the one lightweight Linux dependency. Kept separate so the
329
- * worker can make the mandatory gate decision before any local CLI is spawned. */
330
149
  export function credentialOnlySandboxAvailable() {
331
- if (process.platform !== 'linux' || !ensureSandboxDeps(false))
150
+ if (process.platform !== 'linux' || !ensureSandboxDeps())
332
151
  return false;
333
152
  const probe = probeBubblewrapCredentialMasks();
334
153
  if (probe.supported)
@@ -337,7 +156,7 @@ export function credentialOnlySandboxAvailable() {
337
156
  return false;
338
157
  }
339
158
  export function prepareCredentialOnlySandbox(input) {
340
- if (process.platform !== 'linux' || !ensureSandboxDeps(false))
159
+ if (process.platform !== 'linux' || !ensureSandboxDeps())
341
160
  return null;
342
161
  const probe = probeBubblewrapCredentialMasks();
343
162
  if (!probe.supported || probe.mechanism !== 'bwrap')
@@ -347,174 +166,30 @@ export function prepareCredentialOnlySandbox(input) {
347
166
  args: buildCredentialOnlySandboxArgs(input),
348
167
  };
349
168
  }
350
- /**
351
- * After `buildSandboxArgs` masks `/run` with a fresh tmpfs, any executable whose
352
- * resolved path lives UNDER `/run` (the common case: fnm/nvm/volta expose the
353
- * active toolchain's bin dir as a per-session symlink farm under
354
- * `/run/user/<uid>/fnm_multishells/<hash>/bin`, and `which codex` / the daemon's
355
- * own `process.execPath` for node land there) would VANISH inside the sandbox →
356
- * bwrap `execvp` fails → the CLI exits instantly → Botmux's crash-loop guard
357
- * trips after 4 retries. This re-exposes each such bin dir read-only at its real
358
- * path so the binary (and the node interpreter its `#!/usr/bin/env node` shebang
359
- * needs, which lives in the same fnm bin dir) survive the tmpfs.
360
- *
361
- * The caller feeds in EVERY path that will be exec'd inside the sandbox, not just
362
- * the direct bwrap target: the cliBin, the daemon's own node (process.execPath),
363
- * AND each adapter-declared SECOND-STAGE executable (CliAdapter.sandboxExtraExecPaths)
364
- * — e.g. the codex-app adapter's resolved `codex` (its resolvedBin is the daemon
365
- * node running the runner, which spawns the real codex later for the app-server,
366
- * so without this the codex path would still be masked). We deliberately do NOT
367
- * scan raw cliArgs: a path arg like `--cwd /run/user/<uid>/proj` would re-bind its
368
- * PARENT `/run/user/<uid>`, shadowing the project overlay mounted there and
369
- * exposing sibling files / IPC sockets — re-exposing must be limited to declared
370
- * executables.
371
- *
372
- * Pure: returns the `--ro-bind-try <dir> <dir>` args (deduped, `/run/`-subpaths
373
- * only — NEVER `/run` itself, which would clobber the tmpfs and the relay shim
374
- * mounted at /run/sbxbin). `-try` so a stale/racing path can't fail the spawn.
375
- * Returns [] for binaries already outside /run (system node, npm/pnpm globals) —
376
- * non-fnm users are unaffected.
377
- */
169
+ /** Re-expose trusted executable directories hidden below the fresh /run tmpfs. */
378
170
  export function reexposeRunBinArgs(binPaths) {
379
171
  const dirs = new Set();
380
- for (const p of binPaths) {
381
- if (!p || typeof p !== 'string')
172
+ for (const path of binPaths) {
173
+ if (!path || typeof path !== 'string')
382
174
  continue;
383
- const d = dirname(p);
384
- if (d.startsWith('/run/'))
385
- dirs.add(d); // startsWith('/run/') excludes '/run' itself
175
+ const dir = dirname(path);
176
+ if (dir.startsWith('/run/'))
177
+ dirs.add(dir);
386
178
  }
387
179
  const out = [];
388
- for (const d of dirs)
389
- out.push('--ro-bind-try', d, d);
390
- return out;
391
- }
392
- /** Expand a leading `~` (bare `~` or `~/…` only — never `~user`) to `home`. */
393
- function expandTilde(raw, home) {
394
- return raw.replace(/^~(?=\/|$)/, home);
395
- }
396
- /** Tilde-expand each entry and keep only paths that exist on the host. */
397
- function resolveExistingPaths(paths, home) {
398
- const out = [];
399
- for (const raw of paths ?? []) {
400
- if (!raw || typeof raw !== 'string')
401
- continue;
402
- const p = expandTilde(raw, home);
403
- try {
404
- if (existsSync(p))
405
- out.push(p);
406
- }
407
- catch { /* */ }
408
- }
180
+ for (const dir of dirs)
181
+ out.push('--ro-bind-try', dir, dir);
409
182
  return out;
410
183
  }
411
- /** Does readonly-binding `p` swallow the overlay root `root` (p === root or an
412
- * ancestor of it)? Later binds win in bwrap, so such a bind would replace the
413
- * whole write-isolated overlay with the real read-only tree. Both args must be
414
- * canonicalized first — a raw `/repo/`, `/repo/../repo`, or a symlink to the
415
- * project would slip past a plain string-prefix check yet still shadow the
416
- * overlay once bwrap normalizes/resolves the mount. */
417
- function coversRoot(p, root) {
418
- if (p === root)
419
- return true;
420
- const prefix = p.endsWith('/') ? p : `${p}/`; // '/' stays '/', '/a' → '/a/'
421
- return root.startsWith(prefix);
422
- }
423
- /** Canonicalize an existing path: resolve symlinks + `.`/`..`/trailing slash.
424
- * Falls back to a lexical resolve if realpath fails (e.g. a racing unlink). */
425
- function canonicalize(p) {
184
+ /** Canonicalize if possible (Seatbelt/bwrap both resolve symlinks). */
185
+ function canonical(p) {
426
186
  try {
427
187
  return realpathSync(p);
428
188
  }
429
189
  catch {
430
- return resolve(p);
190
+ return p;
431
191
  }
432
192
  }
433
- /** Canonicalize through the deepest existing ancestor while preserving a
434
- * possibly-missing tail. `realpathSync('/home-link/x/missing')` cannot resolve
435
- * `/home-link`, so a lexical fallback would leave a bwrap destination under a
436
- * symlink even though related mounts use the canonical home. */
437
- function canonicalizeWithMissingTail(p) {
438
- let cursor = resolve(p);
439
- const tail = [];
440
- while (true) {
441
- try {
442
- return join(realpathSync(cursor), ...tail);
443
- }
444
- catch { /* walk upward */ }
445
- const parent = dirname(cursor);
446
- if (parent === cursor)
447
- return resolve(p);
448
- tail.unshift(basename(cursor));
449
- cursor = parent;
450
- }
451
- }
452
- /** bwrap cannot bind-mount over a symlink mount destination. Some hosts expose
453
- * $HOME through a symlink, so bind overlays — and set the child HOME env — at
454
- * canonical targets so the mount point always exists and $HOME resolves even
455
- * when the symlink's parent is masked inside the sandbox. */
456
- export function resolveSandboxMountPath(p) {
457
- return canonicalize(p);
458
- }
459
- /** Host-side overlay mountpoints must not live below HOME, because HOME is the
460
- * lower layer of the home overlay. The old layout did exactly that and allowed
461
- * fuse-overlayfs to recursively expose its own mountpoint. Keep the legacy
462
- * paths here only so upgrades can tear down pre-fix sessions safely. */
463
- export function sandboxOverlayPaths(dataDir, sessionId) {
464
- const sessionRoot = join(resolveSandboxMountPath(dataDir), 'sandboxes', sessionId);
465
- const runtimeRoot = join(VARTMP_ROOT, sessionId);
466
- return {
467
- sessionRoot,
468
- runtimeRoot,
469
- projectMerged: join(runtimeRoot, 'proj-merged'),
470
- homeMerged: join(runtimeRoot, 'home-merged'),
471
- legacyProjectMerged: join(sessionRoot, 'proj-merged'),
472
- legacyHomeMerged: join(sessionRoot, 'home-merged'),
473
- };
474
- }
475
- function overlayMountCandidates(paths) {
476
- return [
477
- paths.projectMerged,
478
- paths.homeMerged,
479
- paths.legacyProjectMerged,
480
- paths.legacyHomeMerged,
481
- ];
482
- }
483
- function unmountSandboxOverlays(paths) {
484
- for (const merged of overlayMountCandidates(paths))
485
- unmountOverlay(merged);
486
- }
487
- function hasMountedSandboxOverlay(paths) {
488
- return overlayMountCandidates(paths).some(isMounted);
489
- }
490
- /**
491
- * Resolve user-configured sandboxReadonlyPaths: tilde-expand, drop non-existent
492
- * entries, and REJECT entries that (after resolving symlinks + normalizing) are
493
- * equal to or an ancestor of an overlay root (home / projectMount) — those would
494
- * shadow the entire write-isolated overlay with the real read-only tree,
495
- * silently breaking write isolation. The overlap check runs on the CANONICAL
496
- * path so a symlink (`/tmp/ref -> /repo`) or a non-normalized string (`/repo/`,
497
- * `/repo/../repo`) can't alias past the guard. Entries strictly UNDER an overlay
498
- * root stay allowed: that's the documented "reference material, read-only,
499
- * excluded from /land" use case. Returns the tilde-expanded original paths (the
500
- * docs promise "mounted at the same path"); bwrap resolves any symlink source.
501
- * Exported for tests.
502
- */
503
- export function resolveUserReadonlyRoots(paths, home, projectMount) {
504
- const homeReal = canonicalize(home);
505
- const projReal = canonicalize(projectMount);
506
- const out = [];
507
- for (const p of resolveExistingPaths(paths, home)) {
508
- const real = canonicalize(p);
509
- if (coversRoot(real, homeReal) || coversRoot(real, projReal)) {
510
- console.error(`[sandbox] sandboxReadonlyPaths entry ignored (resolves to an overlay root, would shadow write isolation): ${p}`);
511
- continue;
512
- }
513
- out.push(p);
514
- }
515
- return out;
516
- }
517
- // ───────────────────────────── orchestration ─────────────────────────────────
518
193
  /** Absolute path to this build's compiled cli.js (dist/cli.js), derived from
519
194
  * this module's own location (dist/adapters/backend/sandbox.js → ../../cli.js). */
520
195
  function distCliJs() {
@@ -525,394 +200,359 @@ function distCliJs() {
525
200
  // sandbox shim must still execute the built CLI entrypoint.
526
201
  return fileURLToPath(new URL('../../../dist/cli.js', import.meta.url));
527
202
  }
528
- /** Is file-sandbox enabled for this session? Spike gate = env; the real
529
- * per-bot BotConfig.sandbox flag is decided by the caller. */
203
+ /** Is the file sandbox globally forced for this daemon? The real per-bot
204
+ * BotConfig.sandbox flag is decided by the caller. */
530
205
  export function sandboxEnabled() {
531
206
  return process.env.BOTMUX_SANDBOX === '1';
532
207
  }
533
- /** The host path where a sandboxed session actually writes a Claude-family
534
- * data dir. Usually this is the HOME overlay upper. If the project mount is
535
- * HOME (or otherwise contains the data dir), the later project overlay is the
536
- * winning mount and the data lands in proj-upper instead.
208
+ /**
209
+ * Whether a LOCAL sandbox engine applies to this backend at all. riff has NO
210
+ * local CLI process to wrap (execution happens in riff's own remote sandbox);
211
+ * without this bypass the worker's fail-safe "backend not sandboxable" hard
212
+ * error would brick every sandbox-enabled bot the moment it switches to riff.
213
+ * Platform is no longer a factor — fs-policy sandboxes darwin AND linux.
214
+ */
215
+ export function localSandboxApplies(backendType) {
216
+ return backendType !== 'riff';
217
+ }
218
+ /** Top-level dirs that are symlinks on usrmerge distros (/bin → usr/bin …) —
219
+ * replicated inside the tmpfs root so `#!/bin/sh` etc. resolve. */
220
+ const USRMERGE_CANDIDATES = ['/bin', '/sbin', '/lib', '/lib64', '/lib32', '/libx32'];
221
+ /** Basename of the per-session manifest of deny-mask mountpoints the worker
222
+ * had to CREATE on the host (they didn't pre-exist) so bwrap could bind an
223
+ * empty mask over them. Persisted (not just held in an in-memory cleanup
224
+ * closure) because a Linux tmux sandbox can survive a daemon restart — after
225
+ * restart the close path only knows `sessionRoot`, and without this manifest
226
+ * the empty host mountpoints would leak forever. Read + acted on by EVERY
227
+ * teardown path (normal close, reattach-then-close, spawn-failure rollback,
228
+ * stale sweep) BEFORE the sessionRoot is removed.
537
229
  *
538
- * The home overlay is bound (and $HOME set) at the CANONICAL home, so copy-ups
539
- * land relative to that root. Compute the in-home relative path robustly whether
540
- * realDataDir arrives in symlink or canonical form: adapters build it from the
541
- * raw homedir() (so the raw base cancels cleanly in the common case), but a
542
- * canonicalized dataDir under a symlink home would otherwise escape via `..`.
543
- * Data roots outside both HOME and the project are returned unchanged because
544
- * neither overlay owns them (Claude read isolation normally relocates those
545
- * roots into BOT_HOME before spawn). */
546
- export function sandboxedClaudeDataDir(sessionId, realDataDir, context = {}) {
547
- const relativeWithin = (root, target) => {
548
- const rel = relative(root, target);
549
- return rel === '' || (rel !== '..' && !rel.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) && !isAbsolute(rel))
550
- ? rel
551
- : null;
552
- };
553
- const rawHome = homedir();
554
- const home = resolveSandboxMountPath(rawHome);
555
- const rawHomeRel = relativeWithin(rawHome, realDataDir);
556
- const canonicalDataDir = rawHomeRel !== null
557
- ? join(home, rawHomeRel)
558
- : canonicalize(realDataDir);
559
- if (context.sourceWorkingDir && context.dataDir) {
560
- const projectMount = resolveSandboxMountPath(context.sourceWorkingDir);
561
- const projectRel = relativeWithin(projectMount, canonicalDataDir);
562
- if (projectRel !== null) {
563
- return join(resolveSandboxMountPath(context.dataDir), 'sandboxes', sessionId, 'proj-upper', projectRel);
230
+ * TRUST MODEL: the manifest lives inside `sessionRoot`, which the policy makes
231
+ * a MANDATORY deny in-sandbox (only the outbox is a nested RW carve-out), so a
232
+ * sandboxed CLI cannot read or rewrite it. But it can be reached OUT of band
233
+ * (a custom data dir under the project, a future policy hole), so cleanup does
234
+ * NOT trust the self-reported path as a delete authorization: each entry also
235
+ * records the (dev, ino) captured at creation, and cleanup re-`lstat`s and
236
+ * removes ONLY when the on-disk inode still matches. That defeats both a
237
+ * swapped manifest pointing at a victim path AND the "empty mountpoint deleted,
238
+ * a new empty object created at the same path" reuse race. */
239
+ const MASK_MANIFEST_NAME = 'mask-mounts.json';
240
+ /** Atomically persist the created-mountpoint manifest (0600). Returns false on
241
+ * failure so the caller can FAIL CLOSED (roll back + abort spawn) rather than
242
+ * start a session whose pre-created host mountpoints could later leak. */
243
+ function writeMaskManifest(sessionRoot, created) {
244
+ if (!created.length)
245
+ return true;
246
+ try {
247
+ atomicWriteFileSync(join(sessionRoot, MASK_MANIFEST_NAME), JSON.stringify(created), { mode: 0o600 });
248
+ return true;
249
+ }
250
+ catch {
251
+ return false;
252
+ }
253
+ }
254
+ /** Reclaim a list of created-mountpoint entries (the IN-MEMORY truth). Runs on
255
+ * ALL teardown paths — normal close reads the entries back from the manifest,
256
+ * spawn-failure rollback passes the in-memory accumulator directly (the
257
+ * manifest may never have been written). Fail-safe & NON-RECURSIVE:
258
+ * - rejects any entry whose path is non-absolute, contains `..`, or is a
259
+ * symlink on disk (a swapped entry can't trick us into deleting elsewhere);
260
+ * - IDENTITY-BINDS: removes only when the on-disk (dev, ino) still matches what
261
+ * WE recorded at creation — so a tampered manifest pointing at a pre-existing
262
+ * victim, or a path whose empty object was replaced after we created ours,
263
+ * is left untouched;
264
+ * - a dir is removed with `rmdir` only (throws ENOTEMPTY if the host/a
265
+ * concurrent process wrote into it → content preserved, never rm -rf);
266
+ * - a file is `unlink`ed only when it is a regular, zero-byte file.
267
+ * Entries are processed in order; the caller records them deepest-first so a
268
+ * child is removed before its now-empty parent. */
269
+ function reclaimMaskEntries(entries) {
270
+ if (!Array.isArray(entries))
271
+ return;
272
+ for (const e of entries) {
273
+ if (!e || typeof e !== 'object')
274
+ continue;
275
+ const { path, kind, dev, ino } = e;
276
+ if (typeof path !== 'string' || !isAbsolute(path))
277
+ continue;
278
+ if (path.split('/').includes('..'))
279
+ continue;
280
+ if (kind !== 'dir' && kind !== 'file')
281
+ continue;
282
+ if (typeof dev !== 'number' || typeof ino !== 'number')
283
+ continue; // pre-identity / forged → refuse
284
+ let st;
285
+ try {
286
+ st = lstatSync(path);
564
287
  }
288
+ catch {
289
+ continue;
290
+ } // already gone
291
+ if (st.isSymbolicLink())
292
+ continue; // never follow a swapped symlink
293
+ if (st.dev !== dev || st.ino !== ino)
294
+ continue; // not the object WE created → leave it
295
+ try {
296
+ if (kind === 'dir' && st.isDirectory()) {
297
+ rmdirSync(path); // rmdir — throws ENOTEMPTY if host wrote into it → kept
298
+ }
299
+ else if (kind === 'file' && st.isFile() && st.size === 0) {
300
+ unlinkSync(path); // unlink a still-empty placeholder only
301
+ }
302
+ }
303
+ catch { /* non-empty / concurrent write / perms → leave it, never recurse */ }
565
304
  }
566
- const homeRel = rawHomeRel ?? relativeWithin(home, canonicalDataDir);
567
- if (homeRel !== null) {
568
- return join(VARTMP_ROOT, sessionId, 'home-upper', homeRel);
305
+ }
306
+ /** Reclaim the empty deny-mask mountpoints recorded in the persisted manifest,
307
+ * then (implicitly) the manifest goes with the sessionRoot. Used by the NORMAL
308
+ * teardown paths (close, reattach-close, stale sweep) — where the manifest was
309
+ * written successfully. Spawn-failure rollback does NOT use this (it can't
310
+ * trust a manifest that may never have been written); it passes the in-memory
311
+ * accumulator to reclaimMaskEntries directly. MUST run BEFORE removing
312
+ * sessionRoot (which holds the manifest). */
313
+ function reclaimMaskMounts(sessionRoot) {
314
+ const manifestPath = join(sessionRoot, MASK_MANIFEST_NAME);
315
+ let raw;
316
+ try {
317
+ raw = readFileSync(manifestPath, 'utf8');
569
318
  }
570
- return canonicalDataDir;
319
+ catch {
320
+ return;
321
+ } // none created / already gone
322
+ let entries;
323
+ try {
324
+ entries = JSON.parse(raw);
325
+ }
326
+ catch {
327
+ return;
328
+ }
329
+ reclaimMaskEntries(entries);
571
330
  }
572
- /** Credential roots that must never be readable inside the file sandbox.
573
- * Relay mode is the only credentialed send path; otherwise an agent could
574
- * unset BOTMUX_SEND_RELAY and execute an absolute botmux binary against the
575
- * real bots.json/send-cred files exposed by the HOME overlay lowerdir. */
576
- export function sandboxCredentialHidePaths(home, botmuxHome = join(home, '.botmux')) {
577
- // Mask the entire botmux state root. Exact-file masks are insufficient:
578
- // setup/update creates timestamped bots.json backups, including ones created
579
- // after a long-lived sandbox starts. Trusted skill roots and the per-session
580
- // outbox are explicitly re-bound after privacy masks by buildSandboxArgs.
581
- return [...new Set([
582
- join(home, '.botmux'),
583
- botmuxHome,
584
- join(home, '.lark-cli'),
585
- join(home, '.lark-cli-bots'),
586
- ])].sort();
331
+ /** Spawn-setup rollback: reclaim the mountpoints we pre-created FROM THE
332
+ * IN-MEMORY accumulator (NOT the manifest on the failure paths the manifest
333
+ * may never have been written, so reading it back would reclaim nothing and
334
+ * leak every created ancestor), then drop the per-session tree. Used when
335
+ * prepareDirectSandbox bails after masks were materialised (a mkdir/write
336
+ * threw mid-way, the MCP gateway socket check fails, or the manifest write
337
+ * itself fails). */
338
+ function rollbackSandboxSetup(sessionRoot, createdMasks) {
339
+ reclaimMaskEntries(createdMasks);
340
+ try {
341
+ rmSync(sessionRoot, { recursive: true, force: true });
342
+ }
343
+ catch { /* */ }
587
344
  }
588
- /**
589
- * Whether the LOCAL bwrap file sandbox applies to this spawn at all.
590
- * - macOS enforces `sandbox: true` via the Seatbelt write-sandbox instead.
591
- * - riff has NO local CLI process to wrap (execution happens in riff's own
592
- * remote sandbox); without this bypass the worker's fail-safe "backend not
593
- * sandboxable" hard error would brick every sandbox-enabled bot the moment
594
- * it switches to riff (the dashboard agent switch does not clear `sandbox`).
595
- */
596
- export function localSandboxApplies(platform, backendType) {
597
- return platform !== 'darwin' && backendType !== 'riff';
345
+ /** Create a mask mountpoint on the host (all missing ancestors too), pushing
346
+ * EACH level we actually create deepest first into the caller-owned
347
+ * `sink` accumulator IMMEDIATELY (before attempting the next level), each with
348
+ * its (dev, ino). This is what lets rollback reclaim partially-created chains:
349
+ * if a deeper level throws (e.g. ENAMETOOLONG on the leaf), every ancestor we
350
+ * already made is already in `sink` for the caller's rollback. `kind` applies
351
+ * to the leaf; ancestors are always dirs. No-op when the leaf already exists
352
+ * (a pre-existing host path is never our cleanup target). Throws on failure so
353
+ * the caller can fail closed — with `sink` holding whatever succeeded. */
354
+ function createMaskMount(leaf, kind, sink) {
355
+ if (existsSync(leaf))
356
+ return; // pre-existing host path — never our cleanup target
357
+ // Walk up to the shallowest missing ancestor, creating each level so we can
358
+ // record + later reclaim exactly what WE added (mkdir recursive would create
359
+ // them but hide which levels were ours).
360
+ const toCreate = [];
361
+ let p = leaf;
362
+ while (!existsSync(p)) {
363
+ toCreate.push(p);
364
+ const parent = dirname(p);
365
+ if (parent === p)
366
+ break; // reached '/'
367
+ p = parent;
368
+ }
369
+ toCreate.reverse(); // shallowest → deepest so mkdir parents exist first
370
+ for (let i = 0; i < toCreate.length; i++) {
371
+ const path = toCreate[i];
372
+ const isLeaf = i === toCreate.length - 1;
373
+ if (isLeaf && kind === 'file')
374
+ writeFileSync(path, '');
375
+ else
376
+ mkdirSync(path);
377
+ const st = lstatSync(path);
378
+ // record deepest-first (unshift) so teardown removes children before parents
379
+ // — and record IMMEDIATELY so a throw on the next level still leaves this
380
+ // one visible to the caller's rollback.
381
+ sink.unshift({ path, kind: isLeaf ? kind : 'dir', dev: st.dev, ino: st.ino });
382
+ }
598
383
  }
384
+ // Test-only surface for the deny-mask lifecycle (trust-boundary regression
385
+ // coverage: manifest tamper defense, dev+ino identity, multi-level cleanup,
386
+ // partial-create rollback).
387
+ export const __testOnly_maskMounts = {
388
+ MASK_MANIFEST_NAME,
389
+ createMaskMount,
390
+ writeMaskManifest,
391
+ reclaimMaskMounts,
392
+ reclaimMaskEntries,
393
+ };
599
394
  /**
600
- * Build the sandboxed spawn for a CLI session, or return null when sandboxing
601
- * is off / unsupported / a required overlay mount fails (fail-safe = the worker
602
- * treats null as a hard error and does NOT silently run unsandboxed).
395
+ * Build the bwrap DIRECT-mode spawn for a CLI session, or return null when the
396
+ * runtime deps are unavailable / setup fails (fail-safe: the worker treats
397
+ * null as a hard error and never silently runs unsandboxed).
603
398
  *
604
- * Layout under <dataDir>/sandboxes/<sessionId>/: outbox, shimbin, and the
605
- * landable proj-upper. When the project contains dataDir (notably cwd=HOME),
606
- * proj-upper is a symlink to scratch under /var/tmp/botmux-sbx/<sessionId>/ so
607
- * overlay upper/work never sit inside their own lower. Host-only merged
608
- * mountpoints and the HOME overlay upper/work also live under that runtime root.
399
+ * Layout under <dataDir>/sandboxes/<sessionId>/: outbox, shimbin, empties.
400
+ * No overlays, no upper/work dirs writes inside readWrite zones hit the
401
+ * real filesystem directly.
609
402
  */
610
- export function prepareSandbox(opts) {
611
- if (!opts.enabled)
612
- return null;
403
+ export function prepareDirectSandbox(opts) {
613
404
  if (process.platform !== 'linux')
614
- return null; // overlayfs + bwrap are Linux-only
615
- // Auto-provision deps so the user needn't pre-install (bwrap; + fuse-overlayfs
616
- // for the rootless/userspace overlay path). Fail the spawn if unavailable.
617
- const needFuse = process.env.BOTMUX_SANDBOX_FUSE === '1' || process.getuid?.() !== 0;
618
- if (!ensureSandboxDeps(needFuse))
619
405
  return null;
620
- const dataDir = resolveSandboxMountPath(opts.dataDir);
621
- const botmuxHome = dirname(dataDir);
622
- const overlayPaths = sandboxOverlayPaths(dataDir, opts.sessionId);
623
- const sessionRoot = overlayPaths.sessionRoot;
406
+ if (!ensureSandboxDeps())
407
+ return null;
408
+ const sessionRoot = join(canonical(opts.dataDir), 'sandboxes', opts.sessionId);
624
409
  const outbox = join(sessionRoot, 'outbox');
625
410
  const shimBin = join(sessionRoot, 'shimbin');
626
411
  const empties = join(sessionRoot, 'empties');
627
- const landingProjUpper = join(sessionRoot, 'proj-upper'); // stable /land path
628
- const landingProjWork = join(sessionRoot, 'proj-work');
629
- const projMerged = overlayPaths.projectMerged;
630
- const homeMerged = overlayPaths.homeMerged;
631
- // HOME overlay upper/work and both merged mountpoints MUST be outside HOME.
632
- const vartmp = overlayPaths.runtimeRoot;
633
- const homeUpper = join(vartmp, 'home-upper');
634
- const homeWork = join(vartmp, 'home-work');
635
- for (const d of [outbox, shimBin, empties])
412
+ // A single, always-empty directory ro-bound over DIRECTORY-shaped deny rules
413
+ // (real content hidden + read-only mount). Kept empty for the session's life.
414
+ const emptyDir = join(sessionRoot, 'empty');
415
+ for (const d of [outbox, shimBin, empties, emptyDir])
636
416
  mkdirSync(d, { recursive: true });
637
- const home = resolveSandboxMountPath(homedir());
638
- // Masking BOTMUX_HOME is mandatory for the credential boundary. A layout
639
- // that makes it `/` or the whole user home cannot be masked without erasing
640
- // the sandbox runtime itself, so refuse the spawn instead of silently
641
- // exposing the custom session/config root.
642
- if (botmuxHome === '/' || botmuxHome === home) {
643
- console.error(`[sandbox] unsafe SESSION_DATA_DIR layout: BOTMUX_HOME resolves to ${botmuxHome}`);
644
- return null;
645
- }
646
- // BOTMUX_SANDBOX_SRC overrides the LOWER project source for spike testing only.
647
- const projectSource = resolveSandboxMountPath(process.env.BOTMUX_SANDBOX_SRC || opts.sourceWorkingDir);
648
- const projectMount = resolveSandboxMountPath(opts.sourceWorkingDir);
649
- const projectSharesHome = projectMount === home;
650
- // A same-session re-spawn (e.g. in-pane /clear) re-enters here; unmount any
651
- // stale merged overlays first so we don't stack a second mount on the same dir.
652
- unmountSandboxOverlays(overlayPaths);
653
- // overlayfs/fuse-overlayfs upper+work must not live inside lower. The normal
654
- // project layout keeps them under dataDir, but cwd=HOME makes dataDir a child
655
- // of the project lower. Put the actual scratch outside HOME and leave a
656
- // host-only symlink at the stable /land path. Preserve an old real directory
657
- // on same-session upgrade so an existing changeset is never discarded.
658
- let projUpper = landingProjUpper;
659
- let projWork = landingProjWork;
660
- if (coversRoot(projectSource, dataDir)) {
661
- const externalUpper = join(vartmp, 'proj-upper');
662
- const externalWork = join(vartmp, 'proj-work');
663
- if (coversRoot(projectSource, vartmp)) {
664
- console.error(`[sandbox] cannot place project overlay scratch outside lower ${projectSource}`);
665
- return null;
666
- }
667
- let existing = null;
417
+ // `botmux` shim → THIS build's cli.js so in-sandbox `botmux send` hits relay
418
+ // mode (and never needs bots.json, which the policy doesn't expose).
419
+ const shim = join(shimBin, 'botmux');
420
+ writeFileSync(shim, `#!/bin/sh\nexec node ${JSON.stringify(distCliJs())} "$@"\n`);
421
+ chmodSync(shim, 0o755);
422
+ // usrmerge symlinks to replicate; deny rules that are FILES on the host need
423
+ // a file-shaped mask, everything else (existing dir OR a not-yet-existing
424
+ // path) is masked as a directory. We do NOT skip absent denies: leaving a
425
+ // denied path unmasked inside a read-write parent let the sandbox mkdir+write
426
+ // it onto the host and read anything created there mid-session (a TOCTOU).
427
+ const symlinks = [];
428
+ for (const p of USRMERGE_CANDIDATES) {
668
429
  try {
669
- existing = lstatSync(landingProjUpper);
670
- }
671
- catch { /* first spawn */ }
672
- if (existing?.isSymbolicLink()) {
673
- mkdirSync(externalUpper, { recursive: true });
674
- let linked = '';
675
- try {
676
- linked = realpathSync(landingProjUpper);
677
- }
678
- catch { /* broken link */ }
679
- if (linked && linked !== realpathSync(externalUpper)) {
680
- console.error(`[sandbox] refusing unexpected proj-upper symlink target: ${linked}`);
681
- return null;
682
- }
683
- if (!linked) {
684
- try {
685
- unlinkSync(landingProjUpper);
686
- }
687
- catch {
688
- return null;
689
- }
690
- symlinkSync(externalUpper, landingProjUpper, 'dir');
691
- }
692
- projUpper = externalUpper;
693
- projWork = externalWork;
430
+ if (lstatSync(p).isSymbolicLink())
431
+ symlinks.push({ path: p, target: readlinkSync(p) });
694
432
  }
695
- else if (!existing) {
696
- mkdirSync(externalUpper, { recursive: true });
697
- symlinkSync(externalUpper, landingProjUpper, 'dir');
698
- projUpper = externalUpper;
699
- projWork = externalWork;
433
+ catch { /* absent on this distro */ }
434
+ }
435
+ const filePaths = new Set();
436
+ for (const r of opts.policy.rules) {
437
+ if (r.access !== 'deny')
438
+ continue;
439
+ try {
440
+ if (statSync(r.path).isFile())
441
+ filePaths.add(r.path);
700
442
  }
701
- else {
702
- // Upgrade compatibility: an EMPTY pre-fix upper has no changes to save,
703
- // so convert it in place. A non-empty one may contain whiteouts/xattrs
704
- // that cannot be copied losslessly across filesystems; fail safe and keep
705
- // it untouched rather than reintroducing a recursive in-lower overlay.
706
- let entries = null;
707
- try {
708
- entries = readdirSync(landingProjUpper);
709
- }
710
- catch { /* not a readable dir */ }
711
- if (entries?.length === 0) {
712
- try {
713
- rmSync(landingProjUpper, { recursive: true, force: true });
714
- rmSync(landingProjWork, { recursive: true, force: true });
715
- mkdirSync(externalUpper, { recursive: true });
716
- symlinkSync(externalUpper, landingProjUpper, 'dir');
717
- projUpper = externalUpper;
718
- projWork = externalWork;
719
- }
720
- catch {
721
- return null;
722
- }
723
- }
724
- else {
725
- console.error(`[sandbox] refusing legacy in-lower proj-upper with pending changes for session ${opts.sessionId}; land or back up the changeset before restarting`);
726
- return null;
727
- }
443
+ catch { /* absent → dir-shaped mask */ }
444
+ }
445
+ const compiled = compileToBwrap(opts.policy, { symlinks, emptyDir, emptiesDir: empties, filePaths, chdir: opts.chdir });
446
+ // The shared empty dir + every empty placeholder file are the ro-bind SOURCES
447
+ // for deny masks. mode 000 the mask itself is unreadable/unlistable (a real
448
+ // deny reads as EPERM, not "empty").
449
+ try {
450
+ chmodSync(emptyDir, 0o000);
451
+ }
452
+ catch { /* */ }
453
+ for (const f of compiled.emptyFiles) {
454
+ try {
455
+ writeFileSync(f.path, '', { mode: 0o000 });
728
456
  }
457
+ catch { /* */ }
729
458
  }
730
- // Mount the HOME overlay unless the project itself is HOME. In that overlap
731
- // case the project overlay is the single layer bound at HOME; mounting a
732
- // second home overlay would only waste a FUSE mount before being shadowed.
733
- if (!projectSharesHome) {
734
- const homeOk = mountOverlay({ lower: home, upper: homeUpper, work: homeWork, merged: homeMerged });
735
- if (!homeOk) {
736
- return null; // fail-safe: no silent unsandboxed run
459
+ // bwrap cannot bind onto a MISSING target, and a missing target under a
460
+ // read-write parent would make bwrap materialise it on the host anyway (true
461
+ // for BOTH the ro-bind and the tmpfs mask branches). So pre-create every mask
462
+ // mountpoint that doesn't already exist — recording EACH host level we create
463
+ // (leaf + any missing ancestors, with dev+ino identity) in a persisted
464
+ // manifest so any teardown path (incl. after a daemon restart, when only
465
+ // sessionRoot is known) can rmdir/unlink-if-empty exactly those, never a user
466
+ // path. Pre-creating a DIRECTORY for a not-yet-existing deny also blocks the
467
+ // host from later creating a same-named FILE there — fail-closed, an accepted
468
+ // compat cost of never leaking a denied path.
469
+ //
470
+ // FAIL CLOSED: if creating a mountpoint OR persisting the manifest fails, roll
471
+ // back everything and abort the spawn — starting the session anyway would
472
+ // either break the mask (bwrap bind fails) or leak host mountpoints with no
473
+ // record to reclaim them.
474
+ const createdMasks = [];
475
+ try {
476
+ for (const m of compiled.maskMounts) {
477
+ // createMaskMount pushes each level into createdMasks IMMEDIATELY, so a
478
+ // mid-chain throw still leaves the partial ancestors visible for rollback.
479
+ createMaskMount(m.path, m.kind, createdMasks);
737
480
  }
738
481
  }
739
- // Mount the PROJECT overlay. proj-upper = the landable changeset.
740
- const projOk = mountOverlay({ lower: projectSource, upper: projUpper, work: projWork, merged: projMerged });
741
- if (!projOk) {
742
- if (!projectSharesHome)
743
- unmountOverlay(homeMerged);
744
- return null; // fail-safe
482
+ catch (err) {
483
+ rollbackSandboxSetup(sessionRoot, createdMasks);
484
+ console.error(`[sandbox] failed to pre-create deny mask mountpoint (${err?.message ?? err}) — aborting spawn (fail closed)`);
485
+ return null;
745
486
  }
746
- // Record the project LOWER source so landing can tell a wholesale-REPLACED dir
747
- // (existed in the lower at create time) from a purely-NEW dir (overlayfs marks
748
- // BOTH opaque, so the lower is the only reliable discriminator — and the live
749
- // landing target may have drifted, so we must check the lower-at-create, not it).
750
- try {
751
- writeFileSync(join(sessionRoot, 'meta.json'), JSON.stringify({ projectLower: projectSource }));
487
+ if (!writeMaskManifest(sessionRoot, createdMasks)) {
488
+ rollbackSandboxSetup(sessionRoot, createdMasks);
489
+ console.error('[sandbox] failed to persist deny-mask cleanup manifest aborting spawn (fail closed)');
490
+ return null;
752
491
  }
753
- catch { /* */ }
754
- // `botmux` shim THIS build's cli.js (readable natively via --ro-bind / /), so
755
- // in-sandbox `botmux send` hits relay mode (and never the host bots.json).
756
- const shim = join(shimBin, 'botmux');
757
- writeFileSync(shim, `#!/bin/sh\nexec node ${JSON.stringify(distCliJs())} "$@"\n`);
758
- chmodSync(shim, 0o755);
759
- // Credential masks are mandatory; per-bot privacy masks extend them.
760
- // Existing dirs → tmpfs blank; files → empty read-only placeholder. A missing
761
- // path that contains another requested mask must also be a directory: mounting
762
- // it as an empty file makes a later child mount fail with ENOTDIR (for example
763
- // a missing ~/.lark-cli-bots plus ~/.lark-cli-bots/<sibling>).
764
- // `~` resolves like the docs' examples (`~/.ssh`) — an unexpanded tilde would
765
- // fail existsSync and mask a literal `~/...` path, leaving the real one readable.
766
- const hideDirs = [];
767
- const hideFiles = [];
768
- const finalHideDirs = [];
769
- const finalHideFiles = [];
770
- const postReadonlyHideDirs = [];
771
- const postReadonlyHideFiles = [];
772
- let emptyIdx = 0;
773
- const customBotsConfig = process.env.BOTS_CONFIG?.trim();
774
- const credentialPaths = [
775
- // SESSION_DATA_DIR may live outside ~/.botmux. Its parent is the
776
- // authoritative BOTMUX_HOME everywhere else (send-cred, per-bot homes,
777
- // sessions/receipts); mask that exact root so a custom location cannot be
778
- // recovered through the sandbox's initial read-only bind of `/`.
779
- ...sandboxCredentialHidePaths(home, botmuxHome),
780
- ...(customBotsConfig ? [resolve(customBotsConfig)] : []),
781
- ];
782
- const classifyMasks = (rawPaths, dirs, files) => {
783
- const paths = [...new Set(rawPaths
784
- .filter((raw) => typeof raw === 'string' && raw.length > 0)
785
- .map(raw => canonicalizeWithMissingTail(expandTilde(raw, home))))];
786
- const hasMaskedDescendant = (parent) => paths.some(candidate => {
787
- if (candidate === parent)
788
- return false;
789
- const rel = relative(resolve(parent), resolve(candidate));
790
- return rel !== ''
791
- && rel !== '..'
792
- && !rel.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)
793
- && !isAbsolute(rel);
794
- });
795
- for (const p of paths) {
796
- let isDir = false;
797
- try {
798
- isDir = existsSync(p) ? statSync(p).isDirectory() : hasMaskedDescendant(p);
799
- }
800
- catch {
801
- isDir = hasMaskedDescendant(p);
802
- }
803
- if (isDir) {
804
- dirs.push(p);
805
- }
806
- else {
807
- const empty = join(empties, `mask-${emptyIdx++}`);
808
- try {
809
- writeFileSync(empty, '');
810
- }
811
- catch { /* */ }
812
- files.push({ path: p, empty });
813
- }
492
+ const args = [...compiled.args];
493
+ // Shim bin at a fixed path under the fresh /run tmpfs — appended after the
494
+ // rule mounts (later mount wins over the tmpfs). PATH points here first.
495
+ args.push('--ro-bind', shimBin, '/run/sbxbin');
496
+ for (const rawTarget of [...new Set(opts.trustedBotmuxCommandPaths ?? [])]) {
497
+ if (typeof rawTarget !== 'string' || !isAbsolute(rawTarget))
498
+ continue;
499
+ const target = resolve(rawTarget);
500
+ try {
501
+ if (!lstatSync(target).isFile())
502
+ continue;
503
+ args.push('--ro-bind', shim, target);
814
504
  }
815
- };
816
- classifyMasks([...credentialPaths, ...(opts.hidePaths ?? [])], hideDirs, hideFiles);
817
- classifyMasks(opts.finalHidePaths ?? [], finalHideDirs, finalHideFiles);
818
- classifyMasks(opts.postReadonlyHidePaths ?? [], postReadonlyHideDirs, postReadonlyHideFiles);
819
- let mcpGatewaySocket;
505
+ catch { /* missing/stale config target — PATH shim remains available */ }
506
+ }
820
507
  let sandboxMcpGatewaySocketPath;
821
508
  if (opts.mcpGatewaySocketPath) {
822
509
  try {
823
510
  const socketPath = resolve(opts.mcpGatewaySocketPath);
824
- if (!lstatSync(socketPath).isSocket())
511
+ if (!lstatSync(socketPath).isSocket()) {
512
+ rollbackSandboxSetup(sessionRoot, createdMasks);
825
513
  return null;
514
+ }
826
515
  const hostDir = realpathSync(dirname(socketPath));
827
516
  const sandboxDir = '/run/botmux-mcp';
828
- mcpGatewaySocket = { hostDir, sandboxDir };
517
+ args.push('--dir', sandboxDir, '--ro-bind', hostDir, sandboxDir);
829
518
  sandboxMcpGatewaySocketPath = join(sandboxDir, basename(socketPath));
830
519
  }
831
520
  catch {
521
+ // Spawn-setup failure AFTER mask mountpoints were pre-created: reclaim the
522
+ // empty ones (from the in-memory list) and drop the tree so nothing leaks.
523
+ rollbackSandboxSetup(sessionRoot, createdMasks);
832
524
  return null;
833
525
  }
834
526
  }
835
- // CLI auth/login paths kept real+writable (token refresh / login must persist,
836
- // unlike isolated project edits). Resolve `~` and bind only existing paths a
837
- // missing auth file isn't a valid mountpoint (the CLI must be logged in on the
838
- // host; login-from-scratch inside the sandbox isn't supported).
839
- const authReal = resolveExistingPaths(opts.authPaths, home);
840
- const trustedWritableRoots = resolveExistingPaths(opts.trustedWritablePaths, home);
841
- const readonlyRoots = resolveExistingPaths(opts.readonlyRoots, home);
842
- const userReadonlyRoots = resolveUserReadonlyRoots(opts.userReadonlyPaths, home, projectMount);
843
- const plan = {
844
- projectMount,
845
- projectMerged: projMerged,
846
- home,
847
- homeMerged,
848
- outbox,
849
- mcpGatewaySocket,
850
- hideDirs,
851
- hideFiles,
852
- authReal,
853
- trustedWritableRoots,
854
- finalHideDirs,
855
- finalHideFiles,
856
- readonlyRoots,
857
- postReadonlyHideDirs,
858
- postReadonlyHideFiles,
859
- userReadonlyRoots,
860
- net: opts.net !== false,
861
- };
862
- const args = buildSandboxArgs(plan);
863
- // Shim bin at a fixed path UNDER the /run tmpfs — the whole real fs is bound
864
- // read-only (`--ro-bind / /`), so bwrap can't mkdir a new mountpoint at the
865
- // root (/sbxbin) → it must live under a writable tmpfs (/run). PATH points here.
866
- args.push('--ro-bind', shimBin, '/run/sbxbin');
867
- const trustedBotmuxRoots = [...new Set([
868
- canonicalizeWithMissingTail(join(home, '.botmux')),
869
- canonicalizeWithMissingTail(botmuxHome),
870
- ])];
871
- for (const rawTarget of [...new Set(opts.trustedBotmuxCommandPaths ?? [])]) {
872
- if (!rawTarget || typeof rawTarget !== 'string')
873
- continue;
874
- const target = canonicalizeWithMissingTail(expandTilde(rawTarget, home));
875
- const trustedRoot = trustedBotmuxRoots.find(root => {
876
- const rel = relative(root, target);
877
- return rel !== '' && rel !== '..' && !rel.startsWith('../') && !isAbsolute(rel);
878
- });
879
- // External commands stay visible through the initial read-only bind of `/`.
880
- // Only replace commands below a masked Botmux root, and reject symlink escapes.
881
- if (!trustedRoot)
882
- continue;
883
- const rel = relative(trustedRoot, target);
884
- let current = trustedRoot;
885
- for (const segment of dirname(rel).split('/').filter(segment => segment && segment !== '.')) {
886
- current = join(current, segment);
887
- args.push('--dir', current);
527
+ // Authoritative child env via bwrap --setenv (works on pty AND tmux — the
528
+ // tmux backend only forwards a fixed whitelist).
529
+ //
530
+ // PATH: the fresh tmpfs root binds executable dirs at their CANONICAL host
531
+ // paths (the policy's readOnly exec rules are realpath'd by the worker). The
532
+ // host's own $PATH is LEXICAL and can point at symlink-form dirs (e.g.
533
+ // ~/.local/bin a shared-drive/fnm/nvm path) that don't exist in the fresh
534
+ // root so the trusted `botmux` shim's bare `node` would fail `not found`
535
+ // and the MCP gateway would exit (Connection closed). Prepend the canonical
536
+ // dirs of node + the CLI bin (deduped) so bare-name resolution always hits a
537
+ // bound path, THEN keep the host PATH as a lexical fallback.
538
+ const canonicalExecDirs = [];
539
+ const pushExecDir = (p) => {
540
+ if (!p)
541
+ return;
542
+ try {
543
+ const dir = dirname(realpathSync(p));
544
+ if (isAbsolute(dir) && !canonicalExecDirs.includes(dir))
545
+ canonicalExecDirs.push(dir);
888
546
  }
889
- args.push('--ro-bind', shim, target);
890
- }
891
- // botmux skill/plugin dir (claude `--plugin-dir` points here; carries the
892
- // botmux-send etc. skills, no secrets). Re-exposed read-only at its real path.
893
- const pluginDir = join(home, '.botmux', 'claude-plugin');
894
- args.push('--ro-bind-try', pluginDir, pluginDir);
895
- // Re-expose any bin dir living under /run (fnm/nvm/volta symlink farms) that the
896
- // `--tmpfs /run` above just masked — else the resolved cliBin / the node its
897
- // shebang needs / an adapter's declared second-stage binary vanish in-sandbox
898
- // and the CLI crash-loops on spawn. ONLY executable paths (never cwd/path args):
899
- // - opts.cliBin: the direct bwrap target
900
- // - process.execPath: the daemon's own node (under /run too when fnm-managed)
901
- // - opts.extraExecPaths: adapter-declared second-stage execs, e.g. codex-app's
902
- // real codex (its resolvedBin is the daemon node, so cliBin alone misses it).
903
- args.push(...reexposeRunBinArgs([opts.cliBin, process.execPath, ...(opts.extraExecPaths ?? [])]));
904
- // Authoritative child env via bwrap --setenv (works on pty AND tmux — the tmux
905
- // backend only forwards a fixed whitelist, which excludes HOME/PATH/relay).
547
+ catch { /* unresolvable — skip */ }
548
+ };
549
+ pushExecDir(process.execPath); // node
550
+ pushExecDir(opts.cliBin); // the CLI binary
906
551
  const env = {
907
- HOME: home, // MUST match where the overlay is bound (canonical);
908
- // a symlink-form HOME dangles when its parent is masked (e.g. tmpfs /tmp)
909
- BOTMUX_SEND_RELAY: outbox, // routes `botmux send` to the daemon outbox watcher
910
- PATH: `/run/sbxbin:${process.env.PATH ?? ''}`, // /run/sbxbin first so `botmux` = the relay shim
552
+ HOME: opts.home,
553
+ BOTMUX_SEND_RELAY: outbox,
554
+ PATH: ['/run/sbxbin', ...canonicalExecDirs, process.env.PATH ?? ''].filter(Boolean).join(':'),
911
555
  };
912
- // The daemon discovery dir lives under the masked BOTMUX_HOME, so the only
913
- // way an in-sandbox CLI can dial the daemon's loopback IPC (session-scoped,
914
- // capability-gated routes like the v3 workflow relay) is this port marker.
915
- // Not a credential: every route it reaches authenticates independently.
916
556
  if (process.env.BOTMUX_DAEMON_IPC_PORT) {
917
557
  env.BOTMUX_DAEMON_IPC_PORT = process.env.BOTMUX_DAEMON_IPC_PORT;
918
558
  }
@@ -920,100 +560,82 @@ export function prepareSandbox(opts) {
920
560
  env[MCP_GATEWAY_SOCKET_ENV] = sandboxMcpGatewaySocketPath;
921
561
  env[MCP_GATEWAY_REQUIRED_ENV] = '1';
922
562
  }
923
- // Never inherit a custom credential config path into the sandbox. Its host
924
- // path is masked above as defense in depth, while unsetenv prevents an
925
- // absolute botmux/lark client from being pointed at it explicitly.
926
- args.push('--unsetenv', 'BOTS_CONFIG');
927
- args.push('--unsetenv', 'BOTMUX_HOST_RELAY_AUTHORIZED');
928
- // Forward proxy vars so the CLI reaches the API on the tmux backend too.
929
563
  for (const k of PROXY_ENV_KEYS) {
930
564
  const v = process.env[k];
931
565
  if (typeof v === 'string' && v)
932
566
  env[k] = v;
933
567
  }
568
+ args.push('--unsetenv', 'BOTS_CONFIG');
569
+ args.push('--unsetenv', 'BOTMUX_HOST_RELAY_AUTHORIZED');
934
570
  for (const [k, v] of Object.entries(env))
935
571
  args.push('--setenv', k, v);
936
- args.push('--', opts.cliBin, ...opts.cliArgs);
572
+ // Canonicalize the CLI binary before execvp: on a symlinked-$HOME host
573
+ // (e.g. /home/u → /data00/home/u shared-drive mount) the worker hands us the
574
+ // lexical path (~/.local/bin/claude → /home/u/.local/bin/claude), but the
575
+ // sandbox only binds CANONICAL exec dirs (/data00/...). The lexical /home/u
576
+ // prefix does not exist in the fresh bwrap root, so bwrap's execvp fails with
577
+ // "No such file or directory" and the CLI never starts (pane dies instantly).
578
+ // realpath makes the exec target land on a bound path. Best-effort: an
579
+ // unresolvable path falls back to the lexical form (bwrap will fail-closed).
580
+ let execBin = opts.cliBin;
581
+ try {
582
+ execBin = realpathSync(opts.cliBin);
583
+ }
584
+ catch { /* keep lexical; spawn fails closed */ }
585
+ args.push('--', execBin, ...opts.cliArgs);
937
586
  return {
938
587
  bin: 'bwrap',
939
588
  args,
940
589
  env,
941
590
  outbox,
942
- workDir: landingProjUpper,
943
- homeUpper,
944
591
  cleanup: () => {
945
- unmountSandboxOverlays(overlayPaths);
592
+ // Reclaim empty deny-mask mountpoints we created on the host BEFORE
593
+ // dropping the manifest with the rest of the tree.
594
+ reclaimMaskMounts(sessionRoot);
946
595
  try {
947
596
  rmSync(sessionRoot, { recursive: true, force: true });
948
597
  }
949
598
  catch { /* */ }
950
- try {
951
- rmSync(vartmp, { recursive: true, force: true });
952
- }
953
- catch { /* */ }
954
599
  },
955
600
  };
956
601
  }
957
602
  /**
958
- * Re-attach the daemon/worker side to an ALREADY-spawned sandbox session WITHOUT
959
- * touching the overlays. Used on daemon-restart reattach to a persistent
960
- * (tmux/herdr/zellij) pane whose bwrap'd CLI is still alive: the CLI is bound to
961
- * its own namespace-pinned overlay, so we must NOT unmount/remount (that would
962
- * leave a duplicate host-side mount the CLI isn't using). We only need the outbox
963
- * path back so the watcher can keep servicing the live CLI's `botmux send`, plus
964
- * the workDir (upper changeset for landing) and a cleanup that tears the residue
965
- * down at close/exit. Returns null if the session has no sandbox tree on disk
966
- * (never sandboxed). Linux-only, mirrors prepareSandbox's layout.
603
+ * Re-attach the daemon/worker side to an ALREADY-spawned sandbox session (a
604
+ * live bwrap'd CLI surviving in a tmux/herdr/zellij pane across a daemon
605
+ * restart). Only the outbox path is needed back so the watcher keeps servicing
606
+ * the live CLI's `botmux send`, plus a cleanup that removes the tree at
607
+ * close/exit. Returns null if the session has no sandbox tree on disk (never
608
+ * sandboxed). Linux-only, mirrors prepareDirectSandbox's layout.
967
609
  */
968
610
  export function attachSandboxOutbox(opts) {
969
611
  if (process.platform !== 'linux')
970
612
  return null;
971
- const overlayPaths = sandboxOverlayPaths(opts.dataDir, opts.sessionId);
972
- const sessionRoot = overlayPaths.sessionRoot;
973
- const outbox = join(sessionRoot, 'outbox');
974
- const projUpper = join(sessionRoot, 'proj-upper');
975
- if (!existsSync(outbox) && !existsSync(projUpper))
613
+ const sessionRoot = join(canonical(opts.dataDir), 'sandboxes', opts.sessionId);
614
+ if (!existsSync(sessionRoot))
976
615
  return null; // never sandboxed
977
- // Ensure the outbox exists (the watcher reads it); never (re)mount here.
616
+ const outbox = join(sessionRoot, 'outbox');
978
617
  try {
979
618
  mkdirSync(outbox, { recursive: true });
980
619
  }
981
620
  catch { /* */ }
982
621
  return {
983
622
  outbox,
984
- workDir: projUpper,
985
623
  cleanup: () => {
986
- unmountSandboxOverlays(overlayPaths);
624
+ // Reclaim empty deny-mask mountpoints we created on the host BEFORE
625
+ // dropping the manifest with the rest of the tree.
626
+ reclaimMaskMounts(sessionRoot);
987
627
  try {
988
628
  rmSync(sessionRoot, { recursive: true, force: true });
989
629
  }
990
630
  catch { /* */ }
991
- try {
992
- rmSync(overlayPaths.runtimeRoot, { recursive: true, force: true });
993
- }
994
- catch { /* */ }
995
631
  },
996
632
  };
997
633
  }
998
- /** Reclaim one session's overlay residue: unmount both merged overlays + rm the
999
- * per-session tree (incl. the /var/tmp home scratch). Idempotent / best-effort. */
1000
- function reclaimSandbox(dataDir, sid) {
1001
- const overlayPaths = sandboxOverlayPaths(dataDir, sid);
1002
- unmountSandboxOverlays(overlayPaths);
1003
- try {
1004
- rmSync(overlayPaths.sessionRoot, { recursive: true, force: true });
1005
- }
1006
- catch { /* */ }
1007
- try {
1008
- rmSync(overlayPaths.runtimeRoot, { recursive: true, force: true });
1009
- }
1010
- catch { /* */ }
1011
- }
1012
634
  /** Scan the process table for sandbox session-ids referenced by any running
1013
- * process's argv. A live bwrap's bind/overlay paths contain `sandboxes/<sid>`
1014
- * and `botmux-sbx/<sid>`, so this physically detects which sandbox dirs are
1015
- * still in useby overlay sessions AND old clone-model sessions alike. Used as
1016
- * a hard guard so the sweep never deletes a dir out from under a live CLI. */
635
+ * process's argv (a live bwrap's bind paths contain `sandboxes/<sid>`). Hard
636
+ * guard so the sweep never deletes an outbox out from under a live CLI whose
637
+ * session record was lost the outbox is bind-mounted INTO the live sandbox,
638
+ * so removing the host-side source would break its relay. */
1017
639
  function liveSandboxSids() {
1018
640
  const live = new Set();
1019
641
  let pids;
@@ -1023,7 +645,7 @@ function liveSandboxSids() {
1023
645
  catch {
1024
646
  return live;
1025
647
  }
1026
- const re = /(?:sandboxes|botmux-sbx)\/([^/\0]+)/g;
648
+ const re = /sandboxes\/([^/\0]+)/g;
1027
649
  for (const pid of pids) {
1028
650
  if (!/^\d+$/.test(pid))
1029
651
  continue;
@@ -1042,29 +664,16 @@ function liveSandboxSids() {
1042
664
  return live;
1043
665
  }
1044
666
  /**
1045
- * Reclaim leaked sandbox residue.
1046
- *
1047
- * Two classes of leak are reclaimed:
1048
- * 1. NON-ACTIVE orphanssid not in `activeSessionIds`: the session is gone, so
1049
- * any leftover mount/dir is pure residue (the original startup-sweep case,
1050
- * guarding against a daemon crash/kill that skipped killCli()).
1051
- * 2. ACTIVE-but-DEAD — sid IS in `activeSessionIds`, yet NEITHER of its merged
1052
- * overlays is still mounted. This closes the blind spot where a sandboxed
1053
- * worker was SIGKILL'd (straggler reaper) or crashed: the session stays
1054
- * status='active' on disk, so the old "skip if active" rule would let the
1055
- * leaked upper/work dirs survive across restarts indefinitely. We only GC an
1056
- * active sid when its mounts are ALREADY gone — we NEVER tear down a live
1057
- * mount (a CLI persisting in a tmux/herdr/zellij pane is still bound to it),
1058
- * so a genuinely-live persistent session keeps its changeset.
1059
- *
1060
- * Safe to call repeatedly: wire once at daemon bootstrap AND on a periodic timer
1061
- * (the SIGKILL/straggler path can't run worker-side killCli(), so a startup-only
1062
- * sweep would let a crashed-active session's mount survive for the whole next
1063
- * daemon lifetime — one daemon per bot can run for days).
667
+ * Reclaim leaked per-session sandbox trees (outbox/shim/empties of sessions
668
+ * that no longer exist) — plain directory residue in the direct model, no
669
+ * mounts. Guards: never touch an ACTIVE session's tree (it may be suspended,
670
+ * intending to resume its outbox must survive) and never touch a tree
671
+ * referenced by a live process (a reattached pane whose session record was
672
+ * lost). Safe to call repeatedly: wired at daemon bootstrap AND on a periodic
673
+ * timer.
1064
674
  */
1065
675
  export function sweepOrphanSandboxes(dataDir, activeSessionIds) {
1066
- const sandboxDataDir = resolveSandboxMountPath(dataDir);
1067
- const root = join(sandboxDataDir, 'sandboxes');
676
+ const root = join(canonical(dataDir), 'sandboxes');
1068
677
  let sids = [];
1069
678
  try {
1070
679
  sids = readdirSync(root);
@@ -1072,45 +681,34 @@ export function sweepOrphanSandboxes(dataDir, activeSessionIds) {
1072
681
  catch {
1073
682
  return;
1074
683
  } // no sandboxes dir yet
1075
- // Grace before reclaiming an ACTIVE-but-unmounted sandbox: a worker that just
1076
- // (re)spawned creates the outbox/shimbin dirs a few syscalls BEFORE it mounts
1077
- // the overlay. Without this, a sweep firing in that tiny window would nuke an
1078
- // in-progress session's outbox. Non-active orphans are reclaimed immediately
1079
- // (no live worker can be mid-spawn for a session that isn't active).
1080
- const ACTIVE_DEAD_GRACE_MS = 60_000;
684
+ // Grace so a worker mid-spawn (dirs created a few syscalls before the CLI
685
+ // process appears in /proc) can't have its outbox swept.
686
+ const GRACE_MS = 60_000;
1081
687
  const now = Date.now();
1082
- // Hard physical guard: NEVER reclaim a session whose dir is referenced by a
1083
- // live process. A running bwrap binds/overlays paths containing the sid, so a
1084
- // process-table scan catches BOTH overlay sessions (merged mounts) AND old
1085
- // clone-model sessions re-attached after a daemon restart (which have NO
1086
- // overlay mount, so the isMounted check below would wrongly deem them dead and
1087
- // delete their bind-source dirs out from under the live CLI). This is the root
1088
- // cause of the 2026-06-10 incident — keep it as the FIRST gate.
1089
688
  const live = liveSandboxSids();
1090
689
  for (const sid of sids) {
1091
- const overlayPaths = sandboxOverlayPaths(sandboxDataDir, sid);
1092
- const sessionRoot = overlayPaths.sessionRoot;
1093
690
  if (live.has(sid))
1094
- continue; // a running process holds this sandbox — leave it
1095
- if (activeSessionIds.has(sid)) {
1096
- // Active session: keep it while a host-side overlay is still mounted (= a
1097
- // live CLI may be bound to the changeset). If BOTH merged overlays are gone
1098
- // AND the tree is older than the spawn grace, the worker/CLI is dead →
1099
- // reclaim the dead residue. We NEVER tear down a live mount, so a genuinely
1100
- // live persistent (tmux/herdr/zellij) session keeps its changeset.
1101
- if (hasMountedSandboxOverlay(overlayPaths))
1102
- continue;
1103
- let ageOk = false;
1104
- try {
1105
- ageOk = now - statSync(sessionRoot).mtimeMs > ACTIVE_DEAD_GRACE_MS;
1106
- }
1107
- catch {
1108
- ageOk = false;
1109
- }
1110
- if (!ageOk)
1111
- continue; // too fresh — could be a worker mid-spawn
691
+ continue; // a running process holds this tree
692
+ if (activeSessionIds.has(sid))
693
+ continue; // active (possibly suspended) session
694
+ const sessionRoot = join(root, sid);
695
+ let ageOk = false;
696
+ try {
697
+ ageOk = now - statSync(sessionRoot).mtimeMs > GRACE_MS;
1112
698
  }
1113
- reclaimSandbox(sandboxDataDir, sid);
699
+ catch {
700
+ ageOk = false;
701
+ }
702
+ if (!ageOk)
703
+ continue;
704
+ // Reclaim empty deny-mask mountpoints recorded in this tree's manifest
705
+ // BEFORE removing the tree (which holds the manifest). Only runs once we've
706
+ // confirmed no live bwrap references the sid (liveSandboxSids above).
707
+ reclaimMaskMounts(sessionRoot);
708
+ try {
709
+ rmSync(sessionRoot, { recursive: true, force: true });
710
+ }
711
+ catch { /* */ }
1114
712
  }
1115
713
  }
1116
714
  // Presentation-only flags the sandbox may pass through. Path-bearing flags