botmux 3.4.3 → 3.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +10 -7
- package/README.md +11 -6
- package/dist/adapters/backend/herdr-backend.d.ts +43 -6
- package/dist/adapters/backend/herdr-backend.d.ts.map +1 -1
- package/dist/adapters/backend/herdr-backend.js +362 -28
- package/dist/adapters/backend/herdr-backend.js.map +1 -1
- package/dist/adapters/backend/reproduce-command.d.ts +39 -0
- package/dist/adapters/backend/reproduce-command.d.ts.map +1 -0
- package/dist/adapters/backend/reproduce-command.js +63 -0
- package/dist/adapters/backend/reproduce-command.js.map +1 -0
- package/dist/adapters/backend/sandbox.d.ts +99 -279
- package/dist/adapters/backend/sandbox.d.ts.map +1 -1
- package/dist/adapters/backend/sandbox.js +424 -826
- package/dist/adapters/backend/sandbox.js.map +1 -1
- package/dist/adapters/backend/session-backend-selector.d.ts +10 -1
- package/dist/adapters/backend/session-backend-selector.d.ts.map +1 -1
- package/dist/adapters/backend/session-backend-selector.js +73 -4
- package/dist/adapters/backend/session-backend-selector.js.map +1 -1
- package/dist/adapters/backend/types.d.ts +16 -0
- package/dist/adapters/backend/types.d.ts.map +1 -1
- package/dist/adapters/backend/types.js.map +1 -1
- package/dist/adapters/cli/codex.d.ts.map +1 -1
- package/dist/adapters/cli/codex.js +4 -0
- package/dist/adapters/cli/codex.js.map +1 -1
- package/dist/adapters/cli/fs-policy.d.ts +244 -0
- package/dist/adapters/cli/fs-policy.d.ts.map +1 -0
- package/dist/adapters/cli/fs-policy.js +539 -0
- package/dist/adapters/cli/fs-policy.js.map +1 -0
- package/dist/adapters/cli/pi-initial-prompt.d.ts.map +1 -1
- package/dist/adapters/cli/pi-initial-prompt.js +5 -1
- package/dist/adapters/cli/pi-initial-prompt.js.map +1 -1
- package/dist/adapters/cli/read-isolation.d.ts +14 -200
- package/dist/adapters/cli/read-isolation.d.ts.map +1 -1
- package/dist/adapters/cli/read-isolation.js +18 -477
- package/dist/adapters/cli/read-isolation.js.map +1 -1
- package/dist/adapters/cli/traex.d.ts.map +1 -1
- package/dist/adapters/cli/traex.js +5 -0
- package/dist/adapters/cli/traex.js.map +1 -1
- package/dist/adapters/cli/types.d.ts +3 -3
- package/dist/adapters/cli/types.d.ts.map +1 -1
- package/dist/adapters/hook-installer.d.ts +7 -0
- package/dist/adapters/hook-installer.d.ts.map +1 -1
- package/dist/adapters/hook-installer.js +56 -0
- package/dist/adapters/hook-installer.js.map +1 -1
- package/dist/bot-registry.d.ts +39 -23
- package/dist/bot-registry.d.ts.map +1 -1
- package/dist/bot-registry.js +134 -2
- package/dist/bot-registry.js.map +1 -1
- package/dist/cli/capabilities.d.ts +24 -0
- package/dist/cli/capabilities.d.ts.map +1 -0
- package/dist/cli/capabilities.js +25 -0
- package/dist/cli/capabilities.js.map +1 -0
- package/dist/cli/create-group-resolver.d.ts +24 -0
- package/dist/cli/create-group-resolver.d.ts.map +1 -1
- package/dist/cli/create-group-resolver.js +23 -0
- package/dist/cli/create-group-resolver.js.map +1 -1
- package/dist/cli/daemon-lifecycle-env.d.ts +5 -7
- package/dist/cli/daemon-lifecycle-env.d.ts.map +1 -1
- package/dist/cli/daemon-lifecycle-env.js +13 -7
- package/dist/cli/daemon-lifecycle-env.js.map +1 -1
- package/dist/cli/exact-chat-grant-client.d.ts +48 -0
- package/dist/cli/exact-chat-grant-client.d.ts.map +1 -0
- package/dist/cli/exact-chat-grant-client.js +108 -0
- package/dist/cli/exact-chat-grant-client.js.map +1 -0
- package/dist/cli/exact-chat-grant.d.ts +18 -0
- package/dist/cli/exact-chat-grant.d.ts.map +1 -0
- package/dist/cli/exact-chat-grant.js +97 -0
- package/dist/cli/exact-chat-grant.js.map +1 -0
- package/dist/cli.d.ts +10 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +795 -144
- package/dist/cli.js.map +1 -1
- package/dist/codex-rpc-engine.d.ts +26 -0
- package/dist/codex-rpc-engine.d.ts.map +1 -1
- package/dist/codex-rpc-engine.js +113 -2
- package/dist/codex-rpc-engine.js.map +1 -1
- package/dist/config.d.ts +16 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +16 -0
- package/dist/config.js.map +1 -1
- package/dist/core/bot-live-control.d.ts +41 -0
- package/dist/core/bot-live-control.d.ts.map +1 -0
- package/dist/core/bot-live-control.js +135 -0
- package/dist/core/bot-live-control.js.map +1 -0
- package/dist/core/command-handler.d.ts.map +1 -1
- package/dist/core/command-handler.js +44 -66
- package/dist/core/command-handler.js.map +1 -1
- package/dist/core/daemon-config-fence.d.ts +8 -0
- package/dist/core/daemon-config-fence.d.ts.map +1 -0
- package/dist/core/daemon-config-fence.js +13 -0
- package/dist/core/daemon-config-fence.js.map +1 -0
- package/dist/core/dashboard-events.d.ts +5 -0
- package/dist/core/dashboard-events.d.ts.map +1 -1
- package/dist/core/dashboard-events.js.map +1 -1
- package/dist/core/dashboard-ipc-server.d.ts +21 -2
- package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
- package/dist/core/dashboard-ipc-server.js +258 -32
- package/dist/core/dashboard-ipc-server.js.map +1 -1
- package/dist/core/dispatch-registry.d.ts +12 -0
- package/dist/core/dispatch-registry.d.ts.map +1 -0
- package/dist/core/dispatch-registry.js +33 -0
- package/dist/core/dispatch-registry.js.map +1 -0
- package/dist/core/dispatch.d.ts +99 -0
- package/dist/core/dispatch.d.ts.map +1 -1
- package/dist/core/dispatch.js +136 -0
- package/dist/core/dispatch.js.map +1 -1
- package/dist/core/maintenance.d.ts.map +1 -1
- package/dist/core/maintenance.js +10 -3
- package/dist/core/maintenance.js.map +1 -1
- package/dist/core/passthrough-commands.d.ts.map +1 -1
- package/dist/core/passthrough-commands.js +1 -1
- package/dist/core/passthrough-commands.js.map +1 -1
- package/dist/core/persistent-backend.d.ts +16 -2
- package/dist/core/persistent-backend.d.ts.map +1 -1
- package/dist/core/persistent-backend.js +40 -0
- package/dist/core/persistent-backend.js.map +1 -1
- package/dist/core/plugins/mcp/gateway-installer.d.ts.map +1 -1
- package/dist/core/plugins/mcp/gateway-installer.js +14 -2
- package/dist/core/plugins/mcp/gateway-installer.js.map +1 -1
- package/dist/core/session-discovery.d.ts +8 -1
- package/dist/core/session-discovery.d.ts.map +1 -1
- package/dist/core/session-discovery.js +140 -8
- package/dist/core/session-discovery.js.map +1 -1
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +102 -68
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/session-title.d.ts +8 -0
- package/dist/core/session-title.d.ts.map +1 -1
- package/dist/core/session-title.js +54 -0
- package/dist/core/session-title.js.map +1 -1
- package/dist/core/terminal-write-auth.d.ts +15 -0
- package/dist/core/terminal-write-auth.d.ts.map +1 -1
- package/dist/core/terminal-write-auth.js +20 -0
- package/dist/core/terminal-write-auth.js.map +1 -1
- package/dist/core/trigger-session.d.ts.map +1 -1
- package/dist/core/trigger-session.js +3 -2
- package/dist/core/trigger-session.js.map +1 -1
- package/dist/core/types.d.ts +33 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/core/worker-pool.d.ts +51 -8
- package/dist/core/worker-pool.d.ts.map +1 -1
- package/dist/core/worker-pool.js +439 -25
- package/dist/core/worker-pool.js.map +1 -1
- package/dist/daemon.d.ts +4 -2
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +403 -26
- package/dist/daemon.js.map +1 -1
- package/dist/dashboard/bot-onboarding.d.ts +127 -1
- package/dist/dashboard/bot-onboarding.d.ts.map +1 -1
- package/dist/dashboard/bot-onboarding.js +1104 -25
- package/dist/dashboard/bot-onboarding.js.map +1 -1
- package/dist/dashboard/bot-payload.d.ts +5 -0
- package/dist/dashboard/bot-payload.d.ts.map +1 -1
- package/dist/dashboard/bot-payload.js +7 -0
- package/dist/dashboard/bot-payload.js.map +1 -1
- package/dist/dashboard/debug-terminal.d.ts +18 -0
- package/dist/dashboard/debug-terminal.d.ts.map +1 -0
- package/dist/dashboard/debug-terminal.js +338 -0
- package/dist/dashboard/debug-terminal.js.map +1 -0
- package/dist/dashboard/public-redact.d.ts +2 -0
- package/dist/dashboard/public-redact.d.ts.map +1 -1
- package/dist/dashboard/public-redact.js +12 -3
- package/dist/dashboard/public-redact.js.map +1 -1
- package/dist/dashboard/registry.d.ts +8 -0
- package/dist/dashboard/registry.d.ts.map +1 -1
- package/dist/dashboard/registry.js +13 -0
- package/dist/dashboard/registry.js.map +1 -1
- package/dist/dashboard/settings-write-applier.d.ts +47 -1
- package/dist/dashboard/settings-write-applier.d.ts.map +1 -1
- package/dist/dashboard/settings-write-applier.js +102 -1
- package/dist/dashboard/settings-write-applier.js.map +1 -1
- package/dist/dashboard/web/app.d.ts.map +1 -1
- package/dist/dashboard/web/app.js +2 -0
- package/dist/dashboard/web/app.js.map +1 -1
- package/dist/dashboard/web/bot-defaults-page.d.ts +17 -0
- package/dist/dashboard/web/bot-defaults-page.d.ts.map +1 -1
- package/dist/dashboard/web/bot-defaults-page.js +249 -11
- package/dist/dashboard/web/bot-defaults-page.js.map +1 -1
- package/dist/dashboard/web/bot-defaults.d.ts +22 -0
- package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
- package/dist/dashboard/web/bot-defaults.js +19 -0
- package/dist/dashboard/web/bot-defaults.js.map +1 -1
- package/dist/dashboard/web/i18n.d.ts.map +1 -1
- package/dist/dashboard/web/i18n.js +114 -28
- package/dist/dashboard/web/i18n.js.map +1 -1
- package/dist/dashboard/web/icon-tooltip.d.ts +3 -0
- package/dist/dashboard/web/icon-tooltip.d.ts.map +1 -0
- package/dist/dashboard/web/icon-tooltip.js +108 -0
- package/dist/dashboard/web/icon-tooltip.js.map +1 -0
- package/dist/dashboard/web/sessions-kanban.js +2 -2
- package/dist/dashboard/web/sessions-kanban.js.map +1 -1
- package/dist/dashboard/web/sessions-page.d.ts.map +1 -1
- package/dist/dashboard/web/sessions-page.js +49 -53
- package/dist/dashboard/web/sessions-page.js.map +1 -1
- package/dist/dashboard/web/sessions.d.ts +2 -0
- package/dist/dashboard/web/sessions.d.ts.map +1 -1
- package/dist/dashboard/web/sessions.js +46 -2
- package/dist/dashboard/web/sessions.js.map +1 -1
- package/dist/dashboard/web/settings-page.d.ts +78 -0
- package/dist/dashboard/web/settings-page.d.ts.map +1 -1
- package/dist/dashboard/web/settings-page.js +88 -1
- package/dist/dashboard/web/settings-page.js.map +1 -1
- package/dist/dashboard/web/store.d.ts +2 -0
- package/dist/dashboard/web/store.d.ts.map +1 -1
- package/dist/dashboard/web/store.js +13 -1
- package/dist/dashboard/web/store.js.map +1 -1
- package/dist/dashboard-web/app.js +1 -1
- package/dist/dashboard-web/chunks/bot-defaults-page-AUKV7P4L.js +10 -0
- package/dist/dashboard-web/chunks/{chunk-P6GO5KT4.js → chunk-KPQAUX7V.js} +1 -1
- package/dist/dashboard-web/chunks/{chunk-UBOLHQCK.js → chunk-NRL6CVAS.js} +1 -1
- package/dist/dashboard-web/chunks/{chunk-JNRSPKLS.js → chunk-R7IAKAYX.js} +1 -1
- package/dist/dashboard-web/chunks/{chunk-CUV65BIQ.js → chunk-X2L65IOM.js} +8 -8
- package/dist/dashboard-web/chunks/{connectors-page-KTC2QWNW.js → connectors-page-HDQFEXZI.js} +1 -1
- package/dist/dashboard-web/chunks/{groups-page-B3EKGOLY.js → groups-page-6DKXBYOM.js} +1 -1
- package/dist/dashboard-web/chunks/{insights-page-X6RXNCWX.js → insights-page-NWARTJ6J.js} +1 -1
- package/dist/dashboard-web/chunks/{monitor-room-LLRA5LQW.js → monitor-room-AGX3XRJX.js} +1 -1
- package/dist/dashboard-web/chunks/{monitoring-page-7MT5FEOS.js → monitoring-page-GGGIWN4C.js} +1 -1
- package/dist/dashboard-web/chunks/{office-page-KD46D5TD.js → office-page-6SIVKSK3.js} +1 -1
- package/dist/dashboard-web/chunks/{overview-page-JUWNH32O.js → overview-page-FVR3NKAF.js} +1 -1
- package/dist/dashboard-web/chunks/{roles-page-P6F7NLOI.js → roles-page-WE2YB7KN.js} +1 -1
- package/dist/dashboard-web/chunks/{schedules-page-XIHWZST4.js → schedules-page-TB3I24XN.js} +1 -1
- package/dist/dashboard-web/chunks/sessions-page-KOOOASIL.js +2 -0
- package/dist/dashboard-web/chunks/settings-page-IEBR7TGN.js +2 -0
- package/dist/dashboard-web/chunks/{skills-page-A46KVWBL.js → skills-page-3QAT2IWD.js} +1 -1
- package/dist/dashboard-web/chunks/{team-federation-page-SDUC56DF.js → team-federation-page-K4VVAVAK.js} +1 -1
- package/dist/dashboard-web/chunks/{v3-page-J5JOTUSQ.js → v3-page-V6IOJR2B.js} +1 -1
- package/dist/dashboard-web/chunks/{whiteboards-page-U7LSOAWI.js → whiteboards-page-V6CGKRNX.js} +1 -1
- package/dist/dashboard-web/style.css +218 -10
- package/dist/dashboard.js +380 -8
- package/dist/dashboard.js.map +1 -1
- package/dist/features/codex-notifier/adoption.d.ts +11 -0
- package/dist/features/codex-notifier/adoption.d.ts.map +1 -0
- package/dist/features/codex-notifier/adoption.js +11 -0
- package/dist/features/codex-notifier/adoption.js.map +1 -0
- package/dist/features/codex-notifier/app-opener.d.ts +21 -0
- package/dist/features/codex-notifier/app-opener.d.ts.map +1 -0
- package/dist/features/codex-notifier/app-opener.js +33 -0
- package/dist/features/codex-notifier/app-opener.js.map +1 -0
- package/dist/features/codex-notifier/card-action.d.ts +27 -0
- package/dist/features/codex-notifier/card-action.d.ts.map +1 -0
- package/dist/features/codex-notifier/card-action.js +84 -0
- package/dist/features/codex-notifier/card-action.js.map +1 -0
- package/dist/features/codex-notifier/card.d.ts +12 -0
- package/dist/features/codex-notifier/card.d.ts.map +1 -0
- package/dist/features/codex-notifier/card.js +150 -0
- package/dist/features/codex-notifier/card.js.map +1 -0
- package/dist/features/codex-notifier/codex-context.d.ts +12 -0
- package/dist/features/codex-notifier/codex-context.d.ts.map +1 -0
- package/dist/features/codex-notifier/codex-context.js +161 -0
- package/dist/features/codex-notifier/codex-context.js.map +1 -0
- package/dist/features/codex-notifier/config.d.ts +9 -0
- package/dist/features/codex-notifier/config.d.ts.map +1 -0
- package/dist/features/codex-notifier/config.js +11 -0
- package/dist/features/codex-notifier/config.js.map +1 -0
- package/dist/features/codex-notifier/confirmed-turn.d.ts +20 -0
- package/dist/features/codex-notifier/confirmed-turn.d.ts.map +1 -0
- package/dist/features/codex-notifier/confirmed-turn.js +130 -0
- package/dist/features/codex-notifier/confirmed-turn.js.map +1 -0
- package/dist/features/codex-notifier/emitter.d.ts +13 -0
- package/dist/features/codex-notifier/emitter.d.ts.map +1 -0
- package/dist/features/codex-notifier/emitter.js +33 -0
- package/dist/features/codex-notifier/emitter.js.map +1 -0
- package/dist/features/codex-notifier/event-store.d.ts +29 -0
- package/dist/features/codex-notifier/event-store.d.ts.map +1 -0
- package/dist/features/codex-notifier/event-store.js +217 -0
- package/dist/features/codex-notifier/event-store.js.map +1 -0
- package/dist/features/codex-notifier/event.d.ts +46 -0
- package/dist/features/codex-notifier/event.d.ts.map +1 -0
- package/dist/features/codex-notifier/event.js +188 -0
- package/dist/features/codex-notifier/event.js.map +1 -0
- package/dist/features/codex-notifier/hook-cli.d.ts +23 -0
- package/dist/features/codex-notifier/hook-cli.d.ts.map +1 -0
- package/dist/features/codex-notifier/hook-cli.js +130 -0
- package/dist/features/codex-notifier/hook-cli.js.map +1 -0
- package/dist/features/codex-notifier/hook-installer.d.ts +19 -0
- package/dist/features/codex-notifier/hook-installer.d.ts.map +1 -0
- package/dist/features/codex-notifier/hook-installer.js +158 -0
- package/dist/features/codex-notifier/hook-installer.js.map +1 -0
- package/dist/features/codex-notifier/index.d.ts +21 -0
- package/dist/features/codex-notifier/index.d.ts.map +1 -0
- package/dist/features/codex-notifier/index.js +19 -0
- package/dist/features/codex-notifier/index.js.map +1 -0
- package/dist/features/codex-notifier/internal-turn.d.ts +5 -0
- package/dist/features/codex-notifier/internal-turn.d.ts.map +1 -0
- package/dist/features/codex-notifier/internal-turn.js +43 -0
- package/dist/features/codex-notifier/internal-turn.js.map +1 -0
- package/dist/features/codex-notifier/outbox-worker.d.ts +71 -0
- package/dist/features/codex-notifier/outbox-worker.d.ts.map +1 -0
- package/dist/features/codex-notifier/outbox-worker.js +290 -0
- package/dist/features/codex-notifier/outbox-worker.js.map +1 -0
- package/dist/features/codex-notifier/outbox.d.ts +25 -0
- package/dist/features/codex-notifier/outbox.d.ts.map +1 -0
- package/dist/features/codex-notifier/outbox.js +225 -0
- package/dist/features/codex-notifier/outbox.js.map +1 -0
- package/dist/features/codex-notifier/paths.d.ts +7 -0
- package/dist/features/codex-notifier/paths.d.ts.map +1 -0
- package/dist/features/codex-notifier/paths.js +20 -0
- package/dist/features/codex-notifier/paths.js.map +1 -0
- package/dist/features/codex-notifier/screen-lock.d.ts +12 -0
- package/dist/features/codex-notifier/screen-lock.d.ts.map +1 -0
- package/dist/features/codex-notifier/screen-lock.js +47 -0
- package/dist/features/codex-notifier/screen-lock.js.map +1 -0
- package/dist/features/codex-notifier/side-conversation-monitor.d.ts +102 -0
- package/dist/features/codex-notifier/side-conversation-monitor.d.ts.map +1 -0
- package/dist/features/codex-notifier/side-conversation-monitor.js +774 -0
- package/dist/features/codex-notifier/side-conversation-monitor.js.map +1 -0
- package/dist/features/codex-notifier/types.d.ts +42 -0
- package/dist/features/codex-notifier/types.d.ts.map +1 -0
- package/dist/features/codex-notifier/types.js +2 -0
- package/dist/features/codex-notifier/types.js.map +1 -0
- package/dist/features/codex-notifier/worker-lock.d.ts +8 -0
- package/dist/features/codex-notifier/worker-lock.d.ts.map +1 -0
- package/dist/features/codex-notifier/worker-lock.js +108 -0
- package/dist/features/codex-notifier/worker-lock.js.map +1 -0
- package/dist/global-config.d.ts +13 -0
- package/dist/global-config.d.ts.map +1 -1
- package/dist/global-config.js +34 -1
- package/dist/global-config.js.map +1 -1
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +1 -19
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/zh.d.ts.map +1 -1
- package/dist/i18n/zh.js +1 -19
- package/dist/i18n/zh.js.map +1 -1
- package/dist/im/lark/card-builder.d.ts +0 -13
- package/dist/im/lark/card-builder.d.ts.map +1 -1
- package/dist/im/lark/card-builder.js +2 -36
- package/dist/im/lark/card-builder.js.map +1 -1
- package/dist/im/lark/card-handler.d.ts +2 -0
- package/dist/im/lark/card-handler.d.ts.map +1 -1
- package/dist/im/lark/card-handler.js +166 -64
- package/dist/im/lark/card-handler.js.map +1 -1
- package/dist/im/lark/client.d.ts +98 -4
- package/dist/im/lark/client.d.ts.map +1 -1
- package/dist/im/lark/client.js +317 -29
- package/dist/im/lark/client.js.map +1 -1
- package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
- package/dist/im/lark/event-dispatcher.js +48 -33
- package/dist/im/lark/event-dispatcher.js.map +1 -1
- package/dist/services/codex-app-threads.d.ts +47 -0
- package/dist/services/codex-app-threads.d.ts.map +1 -1
- package/dist/services/codex-app-threads.js +502 -10
- package/dist/services/codex-app-threads.js.map +1 -1
- package/dist/services/exact-chat-grant.d.ts +83 -0
- package/dist/services/exact-chat-grant.d.ts.map +1 -0
- package/dist/services/exact-chat-grant.js +249 -0
- package/dist/services/exact-chat-grant.js.map +1 -0
- package/dist/services/group-creator.d.ts +11 -0
- package/dist/services/group-creator.d.ts.map +1 -1
- package/dist/services/group-creator.js +18 -9
- package/dist/services/group-creator.js.map +1 -1
- package/dist/services/local-cli-opener.d.ts.map +1 -1
- package/dist/services/local-cli-opener.js +10 -5
- package/dist/services/local-cli-opener.js.map +1 -1
- package/dist/services/sandbox-migration.d.ts +14 -0
- package/dist/services/sandbox-migration.d.ts.map +1 -0
- package/dist/services/sandbox-migration.js +85 -0
- package/dist/services/sandbox-migration.js.map +1 -0
- package/dist/services/sandbox-store.d.ts +28 -0
- package/dist/services/sandbox-store.d.ts.map +1 -1
- package/dist/services/sandbox-store.js +82 -0
- package/dist/services/sandbox-store.js.map +1 -1
- package/dist/services/traex-user-input.d.ts +19 -0
- package/dist/services/traex-user-input.d.ts.map +1 -0
- package/dist/services/traex-user-input.js +57 -0
- package/dist/services/traex-user-input.js.map +1 -0
- package/dist/setup/open-platform-automation.d.ts +24 -3
- package/dist/setup/open-platform-automation.d.ts.map +1 -1
- package/dist/setup/open-platform-automation.js +45 -8
- package/dist/setup/open-platform-automation.js.map +1 -1
- package/dist/setup/verify-permissions.d.ts +18 -0
- package/dist/setup/verify-permissions.d.ts.map +1 -1
- package/dist/setup/verify-permissions.js +60 -0
- package/dist/setup/verify-permissions.js.map +1 -1
- package/dist/types.d.ts +83 -5
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/child-env.d.ts +2 -2
- package/dist/utils/child-env.d.ts.map +1 -1
- package/dist/utils/child-env.js +14 -0
- package/dist/utils/child-env.js.map +1 -1
- package/dist/utils/stuck-detector.d.ts +45 -0
- package/dist/utils/stuck-detector.d.ts.map +1 -0
- package/dist/utils/stuck-detector.js +188 -0
- package/dist/utils/stuck-detector.js.map +1 -0
- package/dist/utils/stuck-key-guard.d.ts +75 -0
- package/dist/utils/stuck-key-guard.d.ts.map +1 -0
- package/dist/utils/stuck-key-guard.js +77 -0
- package/dist/utils/stuck-key-guard.js.map +1 -0
- package/dist/worker.js +1119 -588
- package/dist/worker.js.map +1 -1
- package/dist/workflows/events/payloads.d.ts +56 -0
- package/dist/workflows/events/payloads.d.ts.map +1 -1
- package/dist/workflows/events/payloads.js +9 -0
- package/dist/workflows/events/payloads.js.map +1 -1
- package/dist/workflows/events/schema.d.ts +86 -0
- package/dist/workflows/events/schema.d.ts.map +1 -1
- package/dist/workflows/shared/sandbox-policy.d.ts +10 -1
- package/dist/workflows/shared/sandbox-policy.d.ts.map +1 -1
- package/dist/workflows/shared/sandbox-policy.js +12 -0
- package/dist/workflows/shared/sandbox-policy.js.map +1 -1
- package/dist/workflows/v3/bot-resolve.d.ts.map +1 -1
- package/dist/workflows/v3/bot-resolve.js +36 -0
- package/dist/workflows/v3/bot-resolve.js.map +1 -1
- package/dist/workflows/v3/contract.d.ts +8 -0
- package/dist/workflows/v3/contract.d.ts.map +1 -1
- package/dist/workflows/v3/contract.js.map +1 -1
- package/package.json +1 -1
- package/dist/dashboard-web/chunks/bot-defaults-page-B5AY4B2J.js +0 -5
- package/dist/dashboard-web/chunks/sessions-page-2VECQ27T.js +0 -3
- package/dist/dashboard-web/chunks/settings-page-HMIUE2JA.js +0 -2
- package/dist/services/sandbox-land.d.ts +0 -45
- package/dist/services/sandbox-land.d.ts.map +0 -1
- package/dist/services/sandbox-land.js +0 -394
- package/dist/services/sandbox-land.js.map +0 -1
|
@@ -1,128 +1,45 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Linux file sandbox: bwrap DIRECT mode (fs-policy three-tier whitelist).
|
|
3
3
|
*
|
|
4
|
-
* Model (
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
*
|
|
23
|
-
*
|
|
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 {
|
|
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,
|
|
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
|
-
/**
|
|
34
|
-
*
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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',
|
|
122
|
-
has('dnf') ? ['dnf', 'install', '-y',
|
|
123
|
-
has('yum') ? ['yum', 'install', '-y',
|
|
124
|
-
has('apk') ? ['apk', 'add',
|
|
125
|
-
has('pacman') ? ['pacman', '-S', '--noconfirm',
|
|
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 &&
|
|
50
|
+
if (r.status === 0 && has('bwrap'))
|
|
134
51
|
return true;
|
|
135
52
|
}
|
|
136
|
-
const guide = pm ? `${isRoot ? '' : 'sudo '}${pm.join(' ')}` :
|
|
137
|
-
console.error(`[sandbox]
|
|
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
|
-
*
|
|
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(
|
|
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(
|
|
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
|
|
381
|
-
if (!
|
|
172
|
+
for (const path of binPaths) {
|
|
173
|
+
if (!path || typeof path !== 'string')
|
|
382
174
|
continue;
|
|
383
|
-
const
|
|
384
|
-
if (
|
|
385
|
-
dirs.add(
|
|
175
|
+
const dir = dirname(path);
|
|
176
|
+
if (dir.startsWith('/run/'))
|
|
177
|
+
dirs.add(dir);
|
|
386
178
|
}
|
|
387
179
|
const out = [];
|
|
388
|
-
for (const
|
|
389
|
-
out.push('--ro-bind-try',
|
|
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
|
-
/**
|
|
412
|
-
|
|
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
|
|
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
|
|
529
|
-
*
|
|
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
|
-
/**
|
|
534
|
-
*
|
|
535
|
-
*
|
|
536
|
-
*
|
|
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
|
-
*
|
|
539
|
-
*
|
|
540
|
-
*
|
|
541
|
-
*
|
|
542
|
-
*
|
|
543
|
-
*
|
|
544
|
-
*
|
|
545
|
-
*
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
573
|
-
*
|
|
574
|
-
*
|
|
575
|
-
*
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
-
*
|
|
590
|
-
*
|
|
591
|
-
*
|
|
592
|
-
*
|
|
593
|
-
*
|
|
594
|
-
*
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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
|
|
601
|
-
*
|
|
602
|
-
*
|
|
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,
|
|
605
|
-
*
|
|
606
|
-
*
|
|
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
|
|
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
|
-
|
|
621
|
-
|
|
622
|
-
const
|
|
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
|
-
|
|
628
|
-
|
|
629
|
-
const
|
|
630
|
-
const
|
|
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
|
-
|
|
638
|
-
//
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
//
|
|
647
|
-
const
|
|
648
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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
|
-
//
|
|
731
|
-
//
|
|
732
|
-
//
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
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
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
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
|
-
|
|
754
|
-
//
|
|
755
|
-
//
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
836
|
-
//
|
|
837
|
-
//
|
|
838
|
-
//
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
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
|
-
|
|
890
|
-
}
|
|
891
|
-
|
|
892
|
-
|
|
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,
|
|
908
|
-
|
|
909
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
959
|
-
*
|
|
960
|
-
*
|
|
961
|
-
*
|
|
962
|
-
*
|
|
963
|
-
*
|
|
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
|
|
972
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1014
|
-
*
|
|
1015
|
-
*
|
|
1016
|
-
*
|
|
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 = /
|
|
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
|
|
1046
|
-
*
|
|
1047
|
-
*
|
|
1048
|
-
*
|
|
1049
|
-
*
|
|
1050
|
-
*
|
|
1051
|
-
*
|
|
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
|
|
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
|
|
1076
|
-
//
|
|
1077
|
-
|
|
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
|
|
1095
|
-
if (activeSessionIds.has(sid))
|
|
1096
|
-
//
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
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
|
-
|
|
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
|