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
package/dist/worker.js
CHANGED
|
@@ -15,11 +15,12 @@
|
|
|
15
15
|
import { randomBytes } from 'node:crypto';
|
|
16
16
|
import { mkdirSync, writeFileSync, unlinkSync, rmdirSync, existsSync, statSync, lstatSync, readdirSync, readlinkSync, readFileSync, realpathSync, copyFileSync, watch as fsWatch, createWriteStream, openSync, closeSync, fstatSync, constants as fsConstants } from 'node:fs';
|
|
17
17
|
import { atomicWriteFileSync } from './utils/atomic-write.js';
|
|
18
|
-
import { join, basename, dirname } from 'node:path';
|
|
18
|
+
import { join, basename, dirname, delimiter } from 'node:path';
|
|
19
19
|
import { homedir, tmpdir } from 'node:os';
|
|
20
20
|
import { spawnSync } from 'node:child_process';
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
21
|
+
import { evaluateCredentialOnlyIsolationGate, credentialIsolationRequired, deviceCredentialIsolationMarkerPath, isCredentialIsolationReservedBasename, buildCredentialIsolationRules, buildSeatbeltProfile, isolatedPaneReattachSafe, sendCredFilePath, botHomePath, buildCliExecutableReadCarveOuts, isolationPaneMarkerContent, } from './adapters/cli/read-isolation.js';
|
|
22
|
+
import { buildFsPolicy, compileToSeatbelt, migrateLegacySandboxFields } from './adapters/cli/fs-policy.js';
|
|
23
|
+
import { killPersistentBackendTarget, killPersistentSession, probePersistentBackendTarget } from './core/persistent-backend.js';
|
|
23
24
|
import { readProcessStartIdentity } from './core/session-marker.js';
|
|
24
25
|
import { drainTranscript, joinAssistantText, trailingAssistantText, findJsonlContainingFingerprint, findJsonlsContainingExactContent, findLatestJsonl, extractLastAssistantTurn, extractTurnStartText, splitTranscriptEventsByCutoff } from './services/claude-transcript.js';
|
|
25
26
|
import { BridgeTurnQueue, makeFingerprint, normaliseForFingerprint } from './services/bridge-turn-queue.js';
|
|
@@ -40,16 +41,19 @@ import { sessionMcpGatewayPathRegex, startSessionMcpGatewayHost, } from './core/
|
|
|
40
41
|
import { MCP_GATEWAY_REQUIRED_ENV, MCP_GATEWAY_SOCKET_ENV, } from './core/plugins/mcp/environment.js';
|
|
41
42
|
import { readSessionMcpRuntimeManifest, sessionMcpRuntimeHostOnlyPaths, } from './core/plugins/mcp/session-runtime.js';
|
|
42
43
|
import { prepareCliPluginGeneration } from './core/plugins/cli-generation.js';
|
|
43
|
-
import { loadBotConfigs } from './bot-registry.js';
|
|
44
|
+
import { loadBotConfigs, resolveBrandLabel } from './bot-registry.js';
|
|
44
45
|
import { readGlobalConfig } from './global-config.js';
|
|
45
|
-
import { deriveTerminalViewToken, resolveTerminalAccessForRequest, safeTerminalTokenEqual, } from './core/terminal-write-auth.js';
|
|
46
|
+
import { deriveTerminalViewToken, deriveTerminalWriteToken, resolveTerminalAccessForRequest, safeTerminalTokenEqual, } from './core/terminal-write-auth.js';
|
|
46
47
|
import { readPlatformBinding } from './platform/binding.js';
|
|
47
48
|
import { loadDashboardSecret, loadPersistedToken } from './dashboard/auth.js';
|
|
48
49
|
import { InflightInputTracker } from './core/inflight-input-tracker.js';
|
|
49
50
|
import { shouldRunQuietRotation, evaluatePidResolverPullback, decideFingerprintSwitch, shouldHealAbsentBaseline, sessionIdFromJsonlPath, SESSION_ID_FILENAME_RE, } from './services/bridge-rotation-policy.js';
|
|
50
51
|
import { CodexBridgeQueue } from './services/codex-bridge-queue.js';
|
|
52
|
+
import { generateCodexAppThreadTitle, readCodexAppThreadMetadata, setCodexAppThreadName, } from './services/codex-app-threads.js';
|
|
53
|
+
import { buildBotmuxLarkNativeSessionTitle } from './core/session-title.js';
|
|
51
54
|
import { drainCodexRollout, findCodexRolloutBySessionId, findCodexRolloutByPid, splitCodexEventsByCutoff, extractLastCodexTurn, codexSessionIdFromRolloutPath } from './services/codex-transcript.js';
|
|
52
55
|
import { drainTraexRollout, findTraexRolloutBySessionId, findTraexRolloutByPid } from './services/traex-transcript.js';
|
|
56
|
+
import { parseTraexUserInputQuestions } from './services/traex-user-input.js';
|
|
53
57
|
import { cocoEventsPathForSession, drainCocoEvents, findCocoSessionByPid } from './services/coco-transcript.js';
|
|
54
58
|
import { currentHermesStateOffset, drainHermesStateDb, resolveHermesStateDbPath } from './services/hermes-transcript.js';
|
|
55
59
|
import { filterHermesEventsForBotmuxSession } from './services/hermes-session-filter.js';
|
|
@@ -62,6 +66,7 @@ import { drainCursorTranscript, findCursorChatIdByPid, findCursorTranscriptByCha
|
|
|
62
66
|
import { shouldObserveCursorChatId, shouldPersistObservedCursorChatId } from './services/cursor-resume-policy.js';
|
|
63
67
|
import { extractKiroSessionIdFromOutput } from './services/kiro-session.js';
|
|
64
68
|
import { baselineJsonlCursor } from './services/jsonl-cursor.js';
|
|
69
|
+
import { fileURLToPath } from 'node:url';
|
|
65
70
|
import { createServer as createHttpServer } from 'node:http';
|
|
66
71
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
67
72
|
import { listenWebTerminalWithFallback } from './utils/web-terminal-listen.js';
|
|
@@ -87,13 +92,16 @@ import { ZellijObserveBackend } from './adapters/backend/zellij-observe-backend.
|
|
|
87
92
|
import { zellijEnv } from './setup/ensure-zellij.js';
|
|
88
93
|
import { isObserveBackend } from './adapters/backend/types.js';
|
|
89
94
|
import { selectSessionBackend, decideBackendGate, backendGateUserMessage } from './adapters/backend/session-backend-selector.js';
|
|
95
|
+
import { buildReproduceCommand, selectReproduceLaunch } from './adapters/backend/reproduce-command.js';
|
|
90
96
|
import { deriveRiffReposFromDirs, deriveRiffRepoFromWorkingDir, isValidRiffBaseUrl, isValidRiffSandboxCluster, } from './adapters/backend/riff-backend.js';
|
|
91
|
-
import {
|
|
97
|
+
import { prepareDirectSandbox, prepareCredentialOnlySandbox, credentialOnlySandboxAvailable, probeHostCredentialIsolationMechanism, attachSandboxOutbox, startOutboxWatcher, sandboxEnabled, localSandboxApplies, } from './adapters/backend/sandbox.js';
|
|
92
98
|
import { DEVICE_AUTHORITY_DIRECTORY, DEVICE_CREDENTIAL_FILE, } from './platform/device-paths.js';
|
|
93
99
|
import { tmuxEnv, probeTmuxFunctionalWithRetry } from './setup/ensure-tmux.js';
|
|
94
100
|
import { tmuxRestartJitterMs } from './core/tmux-recovery.js';
|
|
95
101
|
import { IdleDetector } from './utils/idle-detector.js';
|
|
96
102
|
import { ScreenAnalyzer } from './utils/screen-analyzer.js';
|
|
103
|
+
import { StuckDetector, matchHookReviewScreen } from './utils/stuck-detector.js';
|
|
104
|
+
import { processStuckWarningTuiKeys, shouldRearmStuckDetector } from './utils/stuck-key-guard.js';
|
|
97
105
|
import { captureToPng } from './utils/screenshot-renderer.js';
|
|
98
106
|
import { snapshotToPng, snapshotToText, shouldCaptureScreen, isScreenSelfDriven } from './utils/transient-snapshot.js';
|
|
99
107
|
import { chooseWebTerminalSeed } from './utils/web-terminal-seed.js';
|
|
@@ -109,7 +117,8 @@ import * as pty from 'node-pty';
|
|
|
109
117
|
import { createHash } from 'node:crypto';
|
|
110
118
|
import { hasInstalledSessionReadyHook, installHook, } from './adapters/hook-installer.js';
|
|
111
119
|
import { hookCommandFor } from './adapters/hook-command.js';
|
|
112
|
-
import { parseDaemonIpcPort } from './utils/daemon-discovery.js';
|
|
120
|
+
import { findOnlineDaemon, parseDaemonIpcPort } from './utils/daemon-discovery.js';
|
|
121
|
+
import { fetchDaemonIpc } from './core/daemon-ipc-auth.js';
|
|
113
122
|
import { withCodexAppContext } from './utils/codex-app-context.js';
|
|
114
123
|
import { resolveCodexAppFinalTurnIdentity } from './adapters/cli/codex-app-turn.js';
|
|
115
124
|
import { RunnerControlDecoder } from './adapters/cli/runner-control-channel.js';
|
|
@@ -250,6 +259,238 @@ async function codexRolloutProbe(cliId, threadId, promptText, timeoutMs) {
|
|
|
250
259
|
} while (Date.now() < deadline);
|
|
251
260
|
return false;
|
|
252
261
|
}
|
|
262
|
+
function codexNativeTitleEnv(cfg) {
|
|
263
|
+
const env = {
|
|
264
|
+
...redactChildEnv(process.env),
|
|
265
|
+
...sanitizePerBotEnv(cfg.env),
|
|
266
|
+
};
|
|
267
|
+
env.PATH = `${join(homedir(), '.botmux', 'bin')}${delimiter}${env.PATH ?? ''}`;
|
|
268
|
+
return env;
|
|
269
|
+
}
|
|
270
|
+
function registerNativeTitleForceClose(forceClose) {
|
|
271
|
+
nativeSessionTitleSyncForceClosers.add(forceClose);
|
|
272
|
+
return () => nativeSessionTitleSyncForceClosers.delete(forceClose);
|
|
273
|
+
}
|
|
274
|
+
async function applyCodexNativeSessionTitle(threadId, title, cfg, engine, wait, expectedRevision) {
|
|
275
|
+
if (expectedRevision !== nativeSessionTitleRevision)
|
|
276
|
+
return false;
|
|
277
|
+
if (engine?.activeThreadId === threadId) {
|
|
278
|
+
if (wait === 'resume') {
|
|
279
|
+
await engine.waitForThreadUpdatedAfter(nativeSessionTitleResumeUpdatedAt ?? Math.floor(Date.now() / 1000), 10_000);
|
|
280
|
+
}
|
|
281
|
+
else if (wait === 'preview') {
|
|
282
|
+
await engine.waitForThreadPreview(10_000);
|
|
283
|
+
}
|
|
284
|
+
if (expectedRevision !== nativeSessionTitleRevision)
|
|
285
|
+
return false;
|
|
286
|
+
await engine.setThreadName(title);
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
const adapter = createCliAdapterSync('codex', cfg.cliPathOverride);
|
|
290
|
+
const abortController = new AbortController();
|
|
291
|
+
nativeSessionTitleSyncAbortControllers.add(abortController);
|
|
292
|
+
try {
|
|
293
|
+
await setCodexAppThreadName({
|
|
294
|
+
threadId,
|
|
295
|
+
name: title,
|
|
296
|
+
codexBin: adapter.resolvedBin,
|
|
297
|
+
cwd: cfg.workingDir,
|
|
298
|
+
env: codexNativeTitleEnv(cfg),
|
|
299
|
+
timeoutMs: 10_000,
|
|
300
|
+
signal: abortController.signal,
|
|
301
|
+
detached: true,
|
|
302
|
+
waitForExistingPreview: wait === 'preview',
|
|
303
|
+
...(wait === 'resume' ? {
|
|
304
|
+
waitForUpdatedAfter: nativeSessionTitleResumeUpdatedAt ?? Math.floor(Date.now() / 1000),
|
|
305
|
+
} : {}),
|
|
306
|
+
registerForceClose: registerNativeTitleForceClose,
|
|
307
|
+
});
|
|
308
|
+
return expectedRevision === nativeSessionTitleRevision;
|
|
309
|
+
}
|
|
310
|
+
finally {
|
|
311
|
+
nativeSessionTitleSyncAbortControllers.delete(abortController);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
/** 首条 UserMessage 落盘后先写 fallback,再用隔离的临时 turn 生成语义标题。 */
|
|
315
|
+
async function syncFreshCodexNativeSessionTitle(threadId, engine) {
|
|
316
|
+
const cfg = lastInitConfig;
|
|
317
|
+
const fallbackTitle = cfg?.nativeSessionTitle?.trim();
|
|
318
|
+
if (!cfg || cfg.cliId !== 'codex' || cfg.adoptMode || !fallbackTitle || !threadId)
|
|
319
|
+
return;
|
|
320
|
+
if (nativeSessionTitleAppliedThreadId === threadId || nativeSessionTitleSyncInFlight === threadId)
|
|
321
|
+
return;
|
|
322
|
+
const revision = nativeSessionTitleRevision;
|
|
323
|
+
const resumeGeneration = nativeSessionTitleCurrentGenerationResume;
|
|
324
|
+
let syncLatestTitle = false;
|
|
325
|
+
nativeSessionTitleSyncInFlight = threadId;
|
|
326
|
+
try {
|
|
327
|
+
const fallbackApplied = await applyCodexNativeSessionTitle(threadId, fallbackTitle, cfg, engine, resumeGeneration ? 'resume' : 'preview', revision);
|
|
328
|
+
if (!fallbackApplied || revision !== nativeSessionTitleRevision) {
|
|
329
|
+
syncLatestTitle = true;
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
nativeSessionTitleAppliedThreadId = threadId;
|
|
333
|
+
log(`Applied native Codex session title: ${fallbackTitle}`);
|
|
334
|
+
const sourceText = cfg.nativeSessionTitlePrompt?.trim() ?? '';
|
|
335
|
+
cfg.nativeSessionTitlePrompt = undefined;
|
|
336
|
+
if (!sourceText)
|
|
337
|
+
return;
|
|
338
|
+
const adapter = createCliAdapterSync('codex', cfg.cliPathOverride);
|
|
339
|
+
const abortController = new AbortController();
|
|
340
|
+
nativeSessionTitleSyncAbortControllers.add(abortController);
|
|
341
|
+
let semanticCore;
|
|
342
|
+
try {
|
|
343
|
+
semanticCore = await generateCodexAppThreadTitle({
|
|
344
|
+
sourceText,
|
|
345
|
+
codexBin: adapter.resolvedBin,
|
|
346
|
+
env: codexNativeTitleEnv(cfg),
|
|
347
|
+
model: cfg.model,
|
|
348
|
+
timeoutMs: 30_000,
|
|
349
|
+
signal: abortController.signal,
|
|
350
|
+
detached: true,
|
|
351
|
+
registerForceClose: registerNativeTitleForceClose,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
finally {
|
|
355
|
+
nativeSessionTitleSyncAbortControllers.delete(abortController);
|
|
356
|
+
}
|
|
357
|
+
if (revision !== nativeSessionTitleRevision) {
|
|
358
|
+
syncLatestTitle = true;
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (!semanticCore) {
|
|
362
|
+
log('Native Codex semantic title generation unavailable; keeping fallback title');
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const semanticTitle = buildBotmuxLarkNativeSessionTitle(semanticCore);
|
|
366
|
+
if (semanticTitle === fallbackTitle)
|
|
367
|
+
return;
|
|
368
|
+
const semanticApplied = await applyCodexNativeSessionTitle(threadId, semanticTitle, cfg, engine, 'none', revision);
|
|
369
|
+
if (!semanticApplied || revision !== nativeSessionTitleRevision) {
|
|
370
|
+
syncLatestTitle = true;
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
cfg.nativeSessionTitle = semanticTitle;
|
|
374
|
+
nativeSessionTitleAppliedThreadId = threadId;
|
|
375
|
+
send({ type: 'native_session_title_generated', title: semanticTitle });
|
|
376
|
+
log(`Applied generated native Codex session title: ${semanticTitle}`);
|
|
377
|
+
}
|
|
378
|
+
catch (err) {
|
|
379
|
+
if (revision !== nativeSessionTitleRevision)
|
|
380
|
+
syncLatestTitle = true;
|
|
381
|
+
else
|
|
382
|
+
log(`Native Codex session title sync failed: ${err?.message ?? err}`);
|
|
383
|
+
}
|
|
384
|
+
finally {
|
|
385
|
+
if (nativeSessionTitleSyncInFlight === threadId)
|
|
386
|
+
nativeSessionTitleSyncInFlight = undefined;
|
|
387
|
+
if (syncLatestTitle) {
|
|
388
|
+
void syncFreshCodexNativeSessionTitle(threadId, engine);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
/** 在 resume 首条输入前记录 updatedAt,后续用其确认历史派生标题已完成回写。 */
|
|
393
|
+
async function captureCodexResumeTitleBaseline(threadId, engine) {
|
|
394
|
+
const cfg = lastInitConfig;
|
|
395
|
+
if (!cfg || cfg.cliId !== 'codex' || cfg.adoptMode || !cfg.nativeSessionTitle)
|
|
396
|
+
return;
|
|
397
|
+
nativeSessionTitleResumeUpdatedAt = Math.floor(Date.now() / 1000);
|
|
398
|
+
try {
|
|
399
|
+
if (engine?.activeThreadId === threadId) {
|
|
400
|
+
const metadata = await engine.readThreadMetadata(7000);
|
|
401
|
+
if (metadata.updatedAt !== undefined)
|
|
402
|
+
nativeSessionTitleResumeUpdatedAt = metadata.updatedAt;
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const adapter = createCliAdapterSync('codex', cfg.cliPathOverride);
|
|
406
|
+
const env = {
|
|
407
|
+
...redactChildEnv(process.env),
|
|
408
|
+
...sanitizePerBotEnv(cfg.env),
|
|
409
|
+
};
|
|
410
|
+
env.PATH = `${join(homedir(), '.botmux', 'bin')}${delimiter}${env.PATH ?? ''}`;
|
|
411
|
+
const abortController = new AbortController();
|
|
412
|
+
nativeSessionTitleSyncAbortControllers.add(abortController);
|
|
413
|
+
try {
|
|
414
|
+
const metadata = await readCodexAppThreadMetadata({
|
|
415
|
+
threadId,
|
|
416
|
+
codexBin: adapter.resolvedBin,
|
|
417
|
+
cwd: cfg.workingDir,
|
|
418
|
+
env,
|
|
419
|
+
timeoutMs: 7000,
|
|
420
|
+
signal: abortController.signal,
|
|
421
|
+
detached: true,
|
|
422
|
+
registerForceClose: registerNativeTitleForceClose,
|
|
423
|
+
});
|
|
424
|
+
if (metadata.updatedAt !== undefined)
|
|
425
|
+
nativeSessionTitleResumeUpdatedAt = metadata.updatedAt;
|
|
426
|
+
}
|
|
427
|
+
finally {
|
|
428
|
+
nativeSessionTitleSyncAbortControllers.delete(abortController);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
catch (err) {
|
|
432
|
+
log(`Could not capture Codex resume title baseline: ${err?.message ?? err}`);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
/** 每次 CLI spawn 后重建当前 generation 的标题同步状态。 */
|
|
436
|
+
async function prepareCodexNativeTitleGeneration(cfg, engine) {
|
|
437
|
+
if (cfg.cliId !== 'codex' || cfg.adoptMode || !cfg.nativeSessionTitle)
|
|
438
|
+
return;
|
|
439
|
+
nativeSessionTitleAppliedThreadId = undefined;
|
|
440
|
+
nativeSessionTitleResumeUpdatedAt = undefined;
|
|
441
|
+
nativeSessionTitleCurrentGenerationResume = lastSpawnEffectiveResume;
|
|
442
|
+
if (!lastSpawnEffectiveResume)
|
|
443
|
+
return;
|
|
444
|
+
const threadId = engine?.activeThreadId ?? lastSpawnEffectiveCliSessionId ?? cfg.cliSessionId;
|
|
445
|
+
if (threadId)
|
|
446
|
+
await captureCodexResumeTitleBaseline(threadId, engine);
|
|
447
|
+
}
|
|
448
|
+
/** Bridge TRAE app-server's native request_user_input request to botmux's
|
|
449
|
+
* existing Lark ask broker. The app-server owns tool execution in RPC mode, so
|
|
450
|
+
* returning this response resumes the same turn without terminal key driving. */
|
|
451
|
+
async function bridgeTraexUserInput(cfg, params) {
|
|
452
|
+
const parsed = parseTraexUserInputQuestions(params);
|
|
453
|
+
if (parsed.kind === 'unsupported') {
|
|
454
|
+
// Returning empty answers makes TraeX silently complete the tool as if no
|
|
455
|
+
// one answered, dropping the whole batch. Throw instead so the RPC engine
|
|
456
|
+
// replies with a JSON-RPC error and the failure is visible on the turn.
|
|
457
|
+
throw new Error(`requestUserInput cannot be represented as an ask card: ${parsed.reason}`);
|
|
458
|
+
}
|
|
459
|
+
const { questions } = parsed;
|
|
460
|
+
const daemon = findOnlineDaemon(cfg.larkAppId);
|
|
461
|
+
if (!daemon)
|
|
462
|
+
throw new Error(`daemon not found for larkAppId=${cfg.larkAppId}`);
|
|
463
|
+
const response = await fetchDaemonIpc(daemon.ipcPort, '/api/asks', {
|
|
464
|
+
method: 'POST',
|
|
465
|
+
headers: { 'content-type': 'application/json' },
|
|
466
|
+
body: JSON.stringify({
|
|
467
|
+
sessionId: cfg.sessionId,
|
|
468
|
+
chatId: cfg.chatId,
|
|
469
|
+
larkAppId: cfg.larkAppId,
|
|
470
|
+
rootMessageId: cfg.rootMessageId || null,
|
|
471
|
+
questions: questions.map(entry => entry.question),
|
|
472
|
+
timeoutMs: 3_600_000,
|
|
473
|
+
}),
|
|
474
|
+
});
|
|
475
|
+
if (!response.ok) {
|
|
476
|
+
throw new Error(`ask broker HTTP ${response.status}: ${(await response.text()).slice(0, 200)}`);
|
|
477
|
+
}
|
|
478
|
+
const result = await response.json();
|
|
479
|
+
// Timeout/cancel/invalidated — surface as an error rather than an empty answer
|
|
480
|
+
// that TraeX would treat as "no one answered" and silently skip.
|
|
481
|
+
if (result.kind !== 'answered') {
|
|
482
|
+
throw new Error(`ask not answered (${result.kind ?? 'unknown'})`);
|
|
483
|
+
}
|
|
484
|
+
const customText = result.comment?.trim() ?? '';
|
|
485
|
+
const answers = {};
|
|
486
|
+
questions.forEach((entry, index) => {
|
|
487
|
+
const selected = result.answers?.[index] ?? [];
|
|
488
|
+
const values = selected.length > 0 ? [...selected] : customText ? [customText] : [];
|
|
489
|
+
if (values.length > 0)
|
|
490
|
+
answers[entry.id] = { answers: values };
|
|
491
|
+
});
|
|
492
|
+
return { answers };
|
|
493
|
+
}
|
|
253
494
|
/** Stand up (or re-establish) the per-session codex app-server + botmux-owned
|
|
254
495
|
* thread and point remote{WsUrl,ThreadId} at it, so the next spawnCli launches
|
|
255
496
|
* `codex --remote <ws> resume <thread>` and input flows over JSON-RPC. Fully
|
|
@@ -276,7 +517,21 @@ async function engageCodexRpc(cfg) {
|
|
|
276
517
|
const engineEnv = { ...redactChildEnv(process.env) };
|
|
277
518
|
engineEnv.PATH = `${join(homedir(), '.botmux', 'bin')}:${engineEnv.PATH ?? ''}`;
|
|
278
519
|
engineEnv.BOTMUX_SESSION_ID = cfg.sessionId;
|
|
520
|
+
// In Codex/TraeX RPC mode the app-server, not the remote viewer TUI, runs
|
|
521
|
+
// model shell tools. Give that process the same non-secret route binding as
|
|
522
|
+
// spawnCli so `botmux ask` can post into the current Lark thread.
|
|
523
|
+
engineEnv.BOTMUX_CHAT_ID = cfg.chatId;
|
|
524
|
+
if (cfg.chatType)
|
|
525
|
+
engineEnv.BOTMUX_CHAT_TYPE = cfg.chatType;
|
|
526
|
+
else
|
|
527
|
+
delete engineEnv.BOTMUX_CHAT_TYPE;
|
|
279
528
|
engineEnv.BOTMUX_LARK_APP_ID = cfg.larkAppId;
|
|
529
|
+
engineEnv.BOTMUX_ROOT_MESSAGE_ID = cfg.rootMessageId;
|
|
530
|
+
engineEnv.BOTMUX_SESSION_SCOPE = cfg.rootMessageId?.startsWith('om_') ? 'thread' : 'chat';
|
|
531
|
+
if (cfg.ownerOpenId)
|
|
532
|
+
engineEnv.BOTMUX_OWNER_OPEN_ID = cfg.ownerOpenId;
|
|
533
|
+
else
|
|
534
|
+
delete engineEnv.BOTMUX_OWNER_OPEN_ID;
|
|
280
535
|
// The app-server owns model execution in RPC mode. Its MCP gateway child
|
|
281
536
|
// must inherit the trusted host socket just like a native CLI process does.
|
|
282
537
|
if (sessionMcpGatewayHost) {
|
|
@@ -295,6 +550,10 @@ async function engageCodexRpc(cfg) {
|
|
|
295
550
|
engine = new CodexRpcEngine({
|
|
296
551
|
cliBin, cwd: cfg.workingDir, env: engineEnv, sessionId: cfg.sessionId,
|
|
297
552
|
model: cfg.model, log: (m) => log(m),
|
|
553
|
+
appServerFeatures: cfg.cliId === 'traex' ? ['default_mode_request_user_input'] : undefined,
|
|
554
|
+
onRequestUserInput: cfg.cliId === 'traex'
|
|
555
|
+
? (params) => bridgeTraexUserInput(cfg, params)
|
|
556
|
+
: undefined,
|
|
298
557
|
onDead: () => {
|
|
299
558
|
if (codexRpcEngine === engine) {
|
|
300
559
|
log('Codex RPC app-server died; replacing the tmux session and re-engaging the thread');
|
|
@@ -348,10 +607,13 @@ async function engageCodexRpc(cfg) {
|
|
|
348
607
|
codexBridgeMarkPendingTurn(cfg.prompt, cfg.turnId);
|
|
349
608
|
outcome = first; // 'accepted' | 'ambiguous' — both stay engaged, prompt never re-queued
|
|
350
609
|
}
|
|
351
|
-
persistCliSessionId(threadId);
|
|
352
610
|
codexRpcEngine = engine;
|
|
353
611
|
remoteWsUrl = engine.wsUrl;
|
|
354
612
|
remoteThreadId = threadId;
|
|
613
|
+
persistCliSessionId(threadId);
|
|
614
|
+
if (!wantResume && cfg.prompt && outcome === 'accepted') {
|
|
615
|
+
void syncFreshCodexNativeSessionTitle(threadId, engine);
|
|
616
|
+
}
|
|
355
617
|
log(`Codex RPC input engaged (${outcome}${wantResume ? '/resume' : '/fresh'}): app-server ${engine.wsUrl} thread ${threadId}`);
|
|
356
618
|
return outcome;
|
|
357
619
|
}
|
|
@@ -739,7 +1001,12 @@ const authedClients = new WeakSet();
|
|
|
739
1001
|
const clientPtys = new Map();
|
|
740
1002
|
/** Managed-Herdr viewers survive an in-worker /restart while backend changes. */
|
|
741
1003
|
const herdrWebBindings = new Map();
|
|
742
|
-
|
|
1004
|
+
// Standalone/test fallback. Production replaces this after init with a stable
|
|
1005
|
+
// per-session HMAC derived from the host-only dashboard secret, so an
|
|
1006
|
+
// already-issued 「操作链接」/write link survives a worker restart (a silent
|
|
1007
|
+
// daemon restart re-forks every worker — a per-process random token would 403
|
|
1008
|
+
// every previously-issued operate link).
|
|
1009
|
+
let writeToken = randomBytes(16).toString('hex');
|
|
743
1010
|
// Standalone/test fallback. Production replaces this after init with a stable
|
|
744
1011
|
// per-session HMAC derived from the host-only dashboard secret.
|
|
745
1012
|
let viewToken = randomBytes(32).toString('base64url');
|
|
@@ -754,6 +1021,15 @@ function refreshTerminalViewToken() {
|
|
|
754
1021
|
if (secret && sessionId)
|
|
755
1022
|
viewToken = deriveTerminalViewToken(secret, sessionId);
|
|
756
1023
|
}
|
|
1024
|
+
/** Re-derive the stable write (operate) token from the host-only dashboard
|
|
1025
|
+
* secret so a restarted worker mints the SAME token — keeping already-issued
|
|
1026
|
+
* 「操作链接」/write links valid across restarts. Falls back to the random
|
|
1027
|
+
* boot token when the secret is unavailable (standalone/test). */
|
|
1028
|
+
function refreshTerminalWriteToken() {
|
|
1029
|
+
const secret = loadDashboardSecret(DASHBOARD_SECRET_PATH);
|
|
1030
|
+
if (secret && sessionId)
|
|
1031
|
+
writeToken = deriveTerminalWriteToken(secret, sessionId);
|
|
1032
|
+
}
|
|
757
1033
|
/**
|
|
758
1034
|
* Resolve terminal write permission for one request. The platform-injected
|
|
759
1035
|
* `X-Botmux-Role` header is trusted only when this machine is bound to a central
|
|
@@ -790,6 +1066,10 @@ function ensureZellijAttachConfig() {
|
|
|
790
1066
|
}
|
|
791
1067
|
let sessionId = '';
|
|
792
1068
|
let lastInitConfig = null;
|
|
1069
|
+
/** Dashboard「复现命令」:session 冷启时最终交给 backend.spawn 的真实调用
|
|
1070
|
+
* (bin + argv + cwd + 关键 env)。原样保留,worker `ready` 时随消息上报给 daemon
|
|
1071
|
+
* 持久化。仅有写权限的 dashboard 视图可见。 */
|
|
1072
|
+
let capturedSpawnCommand = null;
|
|
793
1073
|
let deferredTopicOutputTail = '';
|
|
794
1074
|
const reportedDeferredTopicRoots = new Set();
|
|
795
1075
|
const CLI_DISPLAY_NAMES = { 'claude-code': 'Claude', seed: 'Seed', relay: 'Relay', aiden: 'Aiden', coco: 'CoCo', codex: 'Codex', 'codex-app': 'Codex App', cursor: 'Cursor', gemini: 'Gemini', genius: 'Genius', opencode: 'OpenCode', antigravity: 'Antigravity', mtr: 'MTR', hermes: 'Hermes', mira: 'Mira', mir: 'Mir CLI', traex: 'TRAE', pi: 'Pi', copilot: 'Copilot', 'oh-my-pi': 'Oh My Pi', kimi: 'Kimi', grok: 'Grok Build', 'kiro-cli': 'Kiro', riff: 'Riff' };
|
|
@@ -1073,6 +1353,21 @@ const pendingRawInputs = [];
|
|
|
1073
1353
|
* administrative TUI command: never type-ahead while the agent is busy, never
|
|
1074
1354
|
* open a model turn, and latest-wins if several renames arrive before idle. */
|
|
1075
1355
|
let pendingSessionRename = null;
|
|
1356
|
+
let nativeSessionTitleSyncInFlight;
|
|
1357
|
+
let nativeSessionTitleAppliedThreadId;
|
|
1358
|
+
let nativeSessionTitleRevision = 0;
|
|
1359
|
+
let nativeSessionTitleResumeUpdatedAt;
|
|
1360
|
+
let nativeSessionTitleCurrentGenerationResume = false;
|
|
1361
|
+
const nativeSessionTitleSyncAbortControllers = new Set();
|
|
1362
|
+
const nativeSessionTitleSyncForceClosers = new Set();
|
|
1363
|
+
function stopNativeSessionTitleSync() {
|
|
1364
|
+
for (const abortController of nativeSessionTitleSyncAbortControllers)
|
|
1365
|
+
abortController.abort();
|
|
1366
|
+
nativeSessionTitleSyncAbortControllers.clear();
|
|
1367
|
+
for (const forceClose of nativeSessionTitleSyncForceClosers)
|
|
1368
|
+
forceClose();
|
|
1369
|
+
nativeSessionTitleSyncForceClosers.clear();
|
|
1370
|
+
}
|
|
1076
1371
|
/** True after the rename command's Enter lands and until the TUI returns to its
|
|
1077
1372
|
* prompt. Blocks type-ahead user messages from racing into the command UI. */
|
|
1078
1373
|
let sessionRenameInFlight = false;
|
|
@@ -3734,6 +4029,7 @@ function parkCrashDiagnosticTerminal(code, signal) {
|
|
|
3734
4029
|
// both when the next message respawns the CLI.
|
|
3735
4030
|
stopScreenUpdates();
|
|
3736
4031
|
stopScreenAnalyzer();
|
|
4032
|
+
stopStuckDetector();
|
|
3737
4033
|
log(`Crash diagnostic tmux session parked at ${TmuxBackend.diagnosticSessionName(sessionId)}`);
|
|
3738
4034
|
return true;
|
|
3739
4035
|
}
|
|
@@ -3849,6 +4145,68 @@ function stopScreenAnalyzer() {
|
|
|
3849
4145
|
screenAnalyzer = null;
|
|
3850
4146
|
tuiPromptBlocking = false;
|
|
3851
4147
|
}
|
|
4148
|
+
// ─── Stuck Detector (AI-free fallback for blocked CLI states) ───────────────
|
|
4149
|
+
let stuckDetector = null;
|
|
4150
|
+
// Monotonic counter bumped on every CLI/backend (re)start within this worker.
|
|
4151
|
+
// Paired with the backend object identity, it lets the worker verify that a
|
|
4152
|
+
// stuck-warning card click still targets the same CLI instance that was stuck —
|
|
4153
|
+
// a restart within the same Node worker must invalidate outstanding cards.
|
|
4154
|
+
let cliLifetimeNonce = 0;
|
|
4155
|
+
function startStuckDetector() {
|
|
4156
|
+
const sd = config.stuckDetector;
|
|
4157
|
+
if (!sd.enabled)
|
|
4158
|
+
return;
|
|
4159
|
+
stopStuckDetector();
|
|
4160
|
+
stuckDetector = new StuckDetector(sd.timeoutMs, {
|
|
4161
|
+
isActuallyStuck: () => {
|
|
4162
|
+
// Scope gate: this PR only handles the Codex PreToolUse hook-review
|
|
4163
|
+
// screen. Other CLIs (Claude Code, Gemini, ...) must never see the
|
|
4164
|
+
// Codex-specific t/Enter/Esc card, even if their output happens to
|
|
4165
|
+
// contain the same strings.
|
|
4166
|
+
if (lastInitConfig?.cliId !== 'codex')
|
|
4167
|
+
return false;
|
|
4168
|
+
// Only warn if the CLI is not at its idle prompt AND no TUI prompt card
|
|
4169
|
+
// is already posted. A long legitimate turn (model thinking, tool calls)
|
|
4170
|
+
// must not trigger this.
|
|
4171
|
+
if (isPromptReady)
|
|
4172
|
+
return false;
|
|
4173
|
+
if (tuiPromptBlocking)
|
|
4174
|
+
return false;
|
|
4175
|
+
// Anti-false-positive: if the PTY produced output recently the CLI is
|
|
4176
|
+
// still actively working (model streaming, tool output, spinner) — not
|
|
4177
|
+
// stuck. Require quiescence before firing.
|
|
4178
|
+
const sincePty = Date.now() - lastPtyActivityAtMs;
|
|
4179
|
+
if (sincePty < 15_000)
|
|
4180
|
+
return false;
|
|
4181
|
+
// Do NOT gate on durableTurnInFlight: the original hook-review incident
|
|
4182
|
+
// ran on a non-durable (ordinary IM) turn, and a durable turn's 20s
|
|
4183
|
+
// submit-recheck failure clears durableTurnInFlight before the 45s
|
|
4184
|
+
// detector fires. The PTY-quiescence + !isPromptReady combination is
|
|
4185
|
+
// sufficient to detect a genuinely stalled turn.
|
|
4186
|
+
return true;
|
|
4187
|
+
},
|
|
4188
|
+
onStuck: (elapsedMs, matchedLabel) => {
|
|
4189
|
+
// Prefer lastAnalyzerSnapshot (the capture-pane authoritative current
|
|
4190
|
+
// screen) over the long-lived renderer, which can drift under tmux.
|
|
4191
|
+
const snapshot = lastAnalyzerSnapshot || renderer?.rawSnapshot() || '';
|
|
4192
|
+
log(`StuckDetector: turn unresolved for ${Math.round(elapsedMs / 1000)}s${matchedLabel ? ` (${matchedLabel})` : ''}`);
|
|
4193
|
+
send({
|
|
4194
|
+
type: 'stuck_warning',
|
|
4195
|
+
elapsedMs,
|
|
4196
|
+
snapshot: snapshot.slice(-3000),
|
|
4197
|
+
matchedPattern: matchedLabel,
|
|
4198
|
+
turnId: currentBotmuxTurnId,
|
|
4199
|
+
dispatchAttempt: currentBotmuxDispatchAttempt,
|
|
4200
|
+
cliLifetime: cliLifetimeNonce,
|
|
4201
|
+
});
|
|
4202
|
+
},
|
|
4203
|
+
getSnapshot: () => lastAnalyzerSnapshot || renderer?.rawSnapshot() || '',
|
|
4204
|
+
});
|
|
4205
|
+
}
|
|
4206
|
+
function stopStuckDetector() {
|
|
4207
|
+
stuckDetector?.dispose();
|
|
4208
|
+
stuckDetector = null;
|
|
4209
|
+
}
|
|
3852
4210
|
// ─── Screenshot Capture (PNG → Feishu image_key) ────────────────────────────
|
|
3853
4211
|
const SCREENSHOT_INTERVAL_MS = 10_000;
|
|
3854
4212
|
const POST_ACTION_DELAY_MS = 1_000;
|
|
@@ -4096,24 +4454,32 @@ const KEY_TO_ANSI = {
|
|
|
4096
4454
|
* Execute an AI-provided key sequence with delays between each key.
|
|
4097
4455
|
* @param keys — key names like ["Down","Down","Space","Up","Up"]
|
|
4098
4456
|
* @param isFinal — if true, this action ends the prompt (clear blocking state)
|
|
4457
|
+
* @returns true if all keys were written successfully; false if backend is gone
|
|
4458
|
+
* or a write threw (caller must NOT send tui_keys_delivered on false).
|
|
4099
4459
|
*/
|
|
4100
4460
|
async function handleTuiKeys(keys, isFinal) {
|
|
4101
4461
|
if (!backend || keys.length === 0)
|
|
4102
|
-
return;
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4462
|
+
return false;
|
|
4463
|
+
try {
|
|
4464
|
+
if ('sendSpecialKeys' in backend) {
|
|
4465
|
+
const b = backend;
|
|
4466
|
+
// Send each key individually with 100ms delay for TUI state processing
|
|
4467
|
+
for (const key of keys) {
|
|
4468
|
+
b.sendSpecialKeys(key);
|
|
4469
|
+
await new Promise(r => setTimeout(r, 100));
|
|
4470
|
+
}
|
|
4109
4471
|
}
|
|
4110
|
-
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
|
|
4472
|
+
else {
|
|
4473
|
+
for (const key of keys) {
|
|
4474
|
+
backend.write(KEY_TO_ANSI[key] ?? key);
|
|
4475
|
+
await new Promise(r => setTimeout(r, 100));
|
|
4476
|
+
}
|
|
4115
4477
|
}
|
|
4116
4478
|
}
|
|
4479
|
+
catch (e) {
|
|
4480
|
+
logError(`handleTuiKeys write failed: ${e?.message ?? e}`);
|
|
4481
|
+
return false;
|
|
4482
|
+
}
|
|
4117
4483
|
if (isFinal) {
|
|
4118
4484
|
tuiPromptBlocking = false;
|
|
4119
4485
|
if (isPromptReady) {
|
|
@@ -4123,6 +4489,7 @@ async function handleTuiKeys(keys, isFinal) {
|
|
|
4123
4489
|
screenAnalyzer?.notifySelection('final');
|
|
4124
4490
|
}
|
|
4125
4491
|
log(`TUI keys: ${keys.join(' ')}${isFinal ? ' (final)' : ''}`);
|
|
4492
|
+
return true;
|
|
4126
4493
|
}
|
|
4127
4494
|
// 待注入的 TUI 命令队列。生命周期绑定当前 CLI 进程:killCli() 会清空它,
|
|
4128
4495
|
// 防止 restart 后残留命令重放进新 CLI——新增清理状态时记得同步那里。
|
|
@@ -4630,6 +4997,7 @@ function markPromptReady() {
|
|
|
4630
4997
|
// worker-local replay of the durable attempt.
|
|
4631
4998
|
if (!durableTurnInFlight)
|
|
4632
4999
|
inflightInputs.onTurnComplete();
|
|
5000
|
+
stuckDetector?.disarm();
|
|
4633
5001
|
maybeEmitWorkflowTranscriptOutput();
|
|
4634
5002
|
if (awaitingFirstPrompt) {
|
|
4635
5003
|
awaitingFirstPrompt = false;
|
|
@@ -4814,6 +5182,7 @@ function scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId
|
|
|
4814
5182
|
persistCliSessionId(cliSessionId);
|
|
4815
5183
|
if (codexBridgeFallbackActive())
|
|
4816
5184
|
codexBridgeNotifyCliSessionId(cliSessionId);
|
|
5185
|
+
void syncFreshCodexNativeSessionTitle(cliSessionId, codexRpcEngine);
|
|
4817
5186
|
}
|
|
4818
5187
|
log(`Deferred recheck found submit in ${transcriptLabel} — suppressing warning. preview="${preview}"`);
|
|
4819
5188
|
return;
|
|
@@ -5098,8 +5467,10 @@ async function flushPending() {
|
|
|
5098
5467
|
// auto-re-queued: a lost/late ack surfaces as a submit-failure the user can
|
|
5099
5468
|
// resend manually, and the stable clientUserMessageId lets codex dedupe a
|
|
5100
5469
|
// resend (Codex delta P1-1).
|
|
5101
|
-
if (!codexRpcEngine)
|
|
5470
|
+
if (!codexRpcEngine) {
|
|
5102
5471
|
inflightInputs.onWrite(item);
|
|
5472
|
+
stuckDetector?.arm();
|
|
5473
|
+
}
|
|
5103
5474
|
const msg = item.content;
|
|
5104
5475
|
const logicalMsg = item.logicalContent ?? msg;
|
|
5105
5476
|
currentBotmuxTurnId = item.turnId;
|
|
@@ -5197,6 +5568,13 @@ async function flushPending() {
|
|
|
5197
5568
|
if (codexBridgeActive)
|
|
5198
5569
|
codexBridgeNotifyCliSessionId(result.cliSessionId);
|
|
5199
5570
|
}
|
|
5571
|
+
if (lastInitConfig?.cliId === 'codex' && result?.submitted !== false) {
|
|
5572
|
+
const threadId = result?.cliSessionId
|
|
5573
|
+
?? codexRpcEngine?.activeThreadId
|
|
5574
|
+
?? lastInitConfig.cliSessionId;
|
|
5575
|
+
if (threadId)
|
|
5576
|
+
void syncFreshCodexNativeSessionTitle(threadId, codexRpcEngine);
|
|
5577
|
+
}
|
|
5200
5578
|
// `&& backend`: if the CLI exited during this write (pane gone → onExit
|
|
5201
5579
|
// nulled backend) the user already got a "CLI exited" notice; don't also
|
|
5202
5580
|
// nag that the submit wasn't confirmed.
|
|
@@ -5222,7 +5600,7 @@ async function flushPending() {
|
|
|
5222
5600
|
}
|
|
5223
5601
|
function sendToPty(content, turnId, opts = {}) {
|
|
5224
5602
|
if (!cliAdapter)
|
|
5225
|
-
return;
|
|
5603
|
+
return false;
|
|
5226
5604
|
const next = {
|
|
5227
5605
|
content,
|
|
5228
5606
|
turnId,
|
|
@@ -5239,7 +5617,7 @@ function sendToPty(content, turnId, opts = {}) {
|
|
|
5239
5617
|
if (cliRestartInProgress || !backend) {
|
|
5240
5618
|
pendingMessages.push(next);
|
|
5241
5619
|
log(`Queued message while CLI backend is restarting (${pendingMessages.length} pending)`);
|
|
5242
|
-
return;
|
|
5620
|
+
return true;
|
|
5243
5621
|
}
|
|
5244
5622
|
const supportsTypeAhead = pendingInputAllowsTypeAhead(cliAdapter.supportsTypeAhead === true, durableTurnInFlight, next);
|
|
5245
5623
|
const shouldMergeQueued = opts.dispatchAttempt === undefined && !durableTurnInFlight
|
|
@@ -5296,6 +5674,7 @@ function sendToPty(content, turnId, opts = {}) {
|
|
|
5296
5674
|
log(`Queued message (${pendingMessages.length} pending): "${content.substring(0, 80)}" — ${cliName()} ${awaitingFirstPrompt ? 'still booting' : 'is busy'}`);
|
|
5297
5675
|
scheduleBusyPatternIdleProbe(`${cliName()} queued-message`);
|
|
5298
5676
|
}
|
|
5677
|
+
return true;
|
|
5299
5678
|
}
|
|
5300
5679
|
// ─── Screen Update Timer ─────────────────────────────────────────────────────
|
|
5301
5680
|
function startScreenUpdates() {
|
|
@@ -5733,6 +6112,7 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
5733
6112
|
});
|
|
5734
6113
|
effectiveBackendType = 'herdr';
|
|
5735
6114
|
backend = herdrBe;
|
|
6115
|
+
cliLifetimeNonce++;
|
|
5736
6116
|
// Same as tmux/zellij adopt: writeInput (grok preferSessionId via
|
|
5737
6117
|
// findGrokSessionByPid, claude pid-state) needs cliPid/cliCwd on the
|
|
5738
6118
|
// PtyHandle. spawn() overwrites cliCwd from opts.cwd — use adoptCwd when
|
|
@@ -5791,6 +6171,7 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
5791
6171
|
: new TmuxPipeBackend(cfg.adoptTmuxTarget, { cliPid: cfg.adoptCliPid });
|
|
5792
6172
|
effectiveBackendType = cfg.adoptZellijPaneId ? 'zellij' : 'tmux';
|
|
5793
6173
|
backend = observeBe;
|
|
6174
|
+
cliLifetimeNonce++;
|
|
5794
6175
|
// writeInput (grok concurrent prompt_history binding, claude pid-state)
|
|
5795
6176
|
// reads these fields off the PtyHandle — constructor only stores
|
|
5796
6177
|
// watchCliPid for liveness, so surface them explicitly for adopt.
|
|
@@ -5970,11 +6351,6 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
5970
6351
|
}
|
|
5971
6352
|
}
|
|
5972
6353
|
}
|
|
5973
|
-
const selectedBackend = selectSessionBackend({ sessionId: cfg.sessionId, backendType: effectiveBackend, backendConfig: riffBackendConfig });
|
|
5974
|
-
isTmuxMode = selectedBackend.isTmuxMode;
|
|
5975
|
-
isPipeMode = selectedBackend.isPipeMode;
|
|
5976
|
-
isZellijMode = selectedBackend.isZellijMode;
|
|
5977
|
-
backend = selectedBackend.backend;
|
|
5978
6354
|
const adapterSessionId = cfg.resume
|
|
5979
6355
|
? (cfg.originalSessionId ?? cfg.sessionId)
|
|
5980
6356
|
: cfg.sessionId;
|
|
@@ -5990,81 +6366,20 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
5990
6366
|
// so a fork inherits the whole submit-confirm + bridge-fallback machinery.
|
|
5991
6367
|
let claudeDataDir = cliAdapter.claudeDataDir;
|
|
5992
6368
|
let effectiveReadyHookInstall = cliAdapter.hookInstall;
|
|
5993
|
-
//
|
|
5994
|
-
//
|
|
5995
|
-
//
|
|
5996
|
-
//
|
|
5997
|
-
//
|
|
5998
|
-
|
|
5999
|
-
|
|
6000
|
-
|
|
6001
|
-
!!process.env.SESSION_DATA_DIR;
|
|
6002
|
-
if (claudeDataDir && willFileSandbox) {
|
|
6003
|
-
const redirected = sandboxedClaudeDataDir(cfg.sessionId, claudeDataDir, {
|
|
6004
|
-
sourceWorkingDir: cfg.workingDir,
|
|
6005
|
-
dataDir: process.env.SESSION_DATA_DIR,
|
|
6006
|
-
});
|
|
6007
|
-
log(`[sandbox] redirecting Claude bridge dataDir → overlay upper: ${redirected}`);
|
|
6008
|
-
claudeDataDir = redirected;
|
|
6009
|
-
}
|
|
6010
|
-
// v2 read isolation: relocate the CLI's data root into the per-bot BOT_HOME
|
|
6011
|
-
// (`<BOTMUX_HOME>/bots/<appId>/{claude,codex}`) so each bot's transcripts/memory
|
|
6012
|
-
// land in its OWN (Seatbelt-allowed) dir, NOT the shared/global ~/.claude|~/.codex
|
|
6013
|
-
// (which v2 denies wholesale). Decided EARLY — like willFileSandbox above — so
|
|
6014
|
-
// every JSONL/bridge/resume path below already targets the per-bot dir. The
|
|
6015
|
-
// matching CLAUDE_CONFIG_DIR/CODEX_HOME env, per-bot provisioning and Seatbelt
|
|
6016
|
-
// wrapper are applied at spawn time further down. This gate is the SINGLE
|
|
6017
|
-
// decision point: configured-but-unenforceable fail-closes HERE (never run a
|
|
6018
|
-
// session unisolated that asked for isolation).
|
|
6019
|
-
// UNIFIED file sandbox: read isolation engages with the same toggle as write
|
|
6020
|
-
// isolation. On darwin the legacy standalone `readIsolation` flag ALSO engages it
|
|
6021
|
-
// (Seatbelt can read-deny without a write overlay). On Linux read isolation RIDES
|
|
6022
|
-
// the bwrap sandbox (its masks go into the same bwrap plan), so it requires the
|
|
6023
|
-
// sandbox to be on — hence the trigger is the sandbox toggle there, never the bare
|
|
6024
|
-
// legacy flag (which would redirect the data dir but apply no masks → a hole).
|
|
6025
|
-
// riff runs in its own REMOTE sandbox with no local CLI process — every
|
|
6026
|
-
// local isolation flavor (Linux bwrap, macOS Seatbelt write-sandbox, read
|
|
6027
|
-
// isolation incl. the legacy fail-closed flag) is meaningless for it and
|
|
6028
|
-
// must be bypassed on ALL platforms, or a sandbox/readIsolation-enabled bot
|
|
6029
|
-
// bricks the moment it switches to riff.
|
|
6030
|
-
const riffRemoteBackend = effectiveBackendType === 'riff';
|
|
6369
|
+
// ── UNIFIED file sandbox (fs-policy, 2026-07-16 refactor) ──
|
|
6370
|
+
// ONE toggle, BOTH platforms, identical three-tier deny-by-default semantics
|
|
6371
|
+
// (adapters/cli/fs-policy.ts). Legacy `readIsolation` is auto-migrated to
|
|
6372
|
+
// `sandbox` at daemon startup; honored here too for an unmigrated read-only
|
|
6373
|
+
// BOTS_CONFIG. riff runs in its own REMOTE sandbox with no local CLI process —
|
|
6374
|
+
// local confinement is meaningless there and must be bypassed on ALL
|
|
6375
|
+
// platforms, or a sandbox-enabled bot bricks the moment it switches to riff.
|
|
6376
|
+
const riffRemoteBackend = !localSandboxApplies(effectiveBackendType);
|
|
6031
6377
|
if (riffRemoteBackend && (cfg.sandbox === true || cfg.readIsolation === true)) {
|
|
6032
|
-
log('Sandbox
|
|
6033
|
-
}
|
|
6034
|
-
const
|
|
6035
|
-
|
|
6036
|
-
|
|
6037
|
-
// FAIL-CLOSED semantics: you asked for read isolation → refuse to start without it.
|
|
6038
|
-
const explicitLegacyReadIso = process.platform === 'darwin' && cfg.readIsolation === true && !riffRemoteBackend;
|
|
6039
|
-
const readIsolationGate = evaluateReadIsolationGate({
|
|
6040
|
-
configured: wantsFileSandbox || explicitLegacyReadIso,
|
|
6041
|
-
adapterSupports: cliAdapter.supportsReadIsolation === true,
|
|
6042
|
-
wrapperCliSet: !!cfg.wrapperCli,
|
|
6043
|
-
platform: process.platform,
|
|
6044
|
-
sessionDataDirSet: !!process.env.SESSION_DATA_DIR,
|
|
6045
|
-
});
|
|
6046
|
-
// Fail-closed ONLY for the explicit legacy flag. Under the UNIFIED sandbox toggle
|
|
6047
|
-
// read isolation is BEST-EFFORT: a CLI/platform that can't enforce the read-deny
|
|
6048
|
-
// (e.g. a non-supporting adapter) still runs WRITE-isolated — it just doesn't get
|
|
6049
|
-
// the read masks. Never brick a sandboxed session just because read-iso is N/A.
|
|
6050
|
-
if (readIsolationGate.failClosedReason && explicitLegacyReadIso) {
|
|
6051
|
-
throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: ${readIsolationGate.failClosedReason}`);
|
|
6052
|
-
}
|
|
6053
|
-
const willReadIsolate = readIsolationGate.enabled;
|
|
6054
|
-
// macOS FILE SANDBOX — the Seatbelt WRITE-isolation twin of the Linux bwrap
|
|
6055
|
-
// overlay. On darwin, `sandbox: true` (or the global sandboxEnabled()) confines
|
|
6056
|
-
// WRITES via the same `sandbox-exec` wrapper as read isolation instead of bwrap
|
|
6057
|
-
// (Linux-only). Reads stay open, matching the Linux "read-all / write-isolated"
|
|
6058
|
-
// model; if read isolation is ALSO on, its read-deny set layers into the SAME
|
|
6059
|
-
// profile. Like bwrap this is FAIL-SAFE: a requested sandbox that can't be
|
|
6060
|
-
// established (unsandboxable backend, no SESSION_DATA_DIR) is a hard error at the
|
|
6061
|
-
// spawn site below, never a silent unconfined run.
|
|
6062
|
-
const willWriteSandbox = process.platform === 'darwin' && wantsFileSandbox;
|
|
6063
|
-
if (willWriteSandbox && !process.env.SESSION_DATA_DIR) {
|
|
6064
|
-
throw new Error(`[file-sandbox] refusing to start session ${cfg.sessionId}: missing SESSION_DATA_DIR`);
|
|
6065
|
-
}
|
|
6066
|
-
const fullIsolationCoversCredentials = !riffRemoteBackend && ((process.platform === 'darwin' && (willReadIsolate || willWriteSandbox))
|
|
6067
|
-
|| (process.platform === 'linux' && wantsFileSandbox));
|
|
6378
|
+
log('Sandbox flag set but backend is riff (remote sandbox, no local process) — local sandbox bypassed');
|
|
6379
|
+
}
|
|
6380
|
+
const sandboxRequested = !riffRemoteBackend
|
|
6381
|
+
&& (cfg.sandbox === true || cfg.readIsolation === true || sandboxEnabled());
|
|
6382
|
+
const fullIsolationCoversCredentials = sandboxRequested;
|
|
6068
6383
|
let credentialMechanismAvailable = true;
|
|
6069
6384
|
let credentialMechanismExecutable;
|
|
6070
6385
|
if (mandatoryCredentialIsolation && !riffRemoteBackend && !fullIsolationCoversCredentials) {
|
|
@@ -6097,19 +6412,50 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
6097
6412
|
const credentialOnlySeatbelt = credentialIsolationGate.mode === 'seatbelt';
|
|
6098
6413
|
const credentialOnlyBwrap = credentialIsolationGate.mode === 'bwrap';
|
|
6099
6414
|
const appliedIsolationCapabilities = [];
|
|
6100
|
-
if (credentialBoundaryActive
|
|
6101
|
-
|| willReadIsolate
|
|
6102
|
-
|| (process.platform === 'linux' && wantsFileSandbox)) {
|
|
6415
|
+
if (credentialBoundaryActive || sandboxRequested) {
|
|
6103
6416
|
appliedIsolationCapabilities.push('credential');
|
|
6104
6417
|
}
|
|
6105
|
-
if (
|
|
6106
|
-
appliedIsolationCapabilities.push('read');
|
|
6107
|
-
if (willWriteSandbox || (process.platform === 'linux' && wantsFileSandbox)) {
|
|
6108
|
-
appliedIsolationCapabilities.push('write');
|
|
6109
|
-
}
|
|
6418
|
+
if (sandboxRequested)
|
|
6419
|
+
appliedIsolationCapabilities.push('read', 'write');
|
|
6110
6420
|
currentCliCredentialIsolated = appliedIsolationCapabilities.includes('credential');
|
|
6111
6421
|
const isolationRuntimeDataDir = process.env.SESSION_DATA_DIR
|
|
6112
6422
|
?? join(defaultBotmuxHome, 'data');
|
|
6423
|
+
let mcpRuntimeManifest = readSessionMcpRuntimeManifest(cfg.sessionId, config.session.dataDir);
|
|
6424
|
+
const hasMcpRuntimeEntries = !!cliAdapter.mcpGateway && !!mcpRuntimeManifest?.entries.length;
|
|
6425
|
+
const selectBackend = () => selectSessionBackend({
|
|
6426
|
+
sessionId: cfg.sessionId,
|
|
6427
|
+
backendType: effectiveBackend,
|
|
6428
|
+
backendConfig: riffBackendConfig,
|
|
6429
|
+
persistentBackendTarget: cfg.persistentBackendTarget,
|
|
6430
|
+
// Old builds could place managed agents in a user's shared Herdr session.
|
|
6431
|
+
// Preserve that recorded target for compatibility unless this incarnation
|
|
6432
|
+
// requires an isolation/MCP boundary that only a Botmux-owned session can
|
|
6433
|
+
// safely provide. Fresh tasks use distinct agents in one machine-wide host.
|
|
6434
|
+
reuseRecordedHerdrTarget: !sandboxRequested
|
|
6435
|
+
&& !hasMcpRuntimeEntries,
|
|
6436
|
+
});
|
|
6437
|
+
let selectedBackend = selectBackend();
|
|
6438
|
+
isTmuxMode = selectedBackend.isTmuxMode;
|
|
6439
|
+
isPipeMode = selectedBackend.isPipeMode;
|
|
6440
|
+
isZellijMode = selectedBackend.isZellijMode;
|
|
6441
|
+
backend = selectedBackend.backend;
|
|
6442
|
+
// BOT_HOME CLI-data redirect: sandboxed bots whose adapter supports it keep
|
|
6443
|
+
// their CLI data (transcripts/memory/auth) in their own BOT_HOME via
|
|
6444
|
+
// CLAUDE_CONFIG_DIR/CODEX_HOME — under deny-by-default the global ~/.claude|
|
|
6445
|
+
// ~/.codex are simply not exposed. Best-effort: a non-supporting adapter
|
|
6446
|
+
// keeps its REAL data dirs instead, which the policy exposes readWrite (the
|
|
6447
|
+
// sandbox itself still applies). Decided EARLY so every JSONL/bridge/resume
|
|
6448
|
+
// path below already targets the right dir. wrapperCli strips spawn args, so
|
|
6449
|
+
// the redirect (and its env) can't be guaranteed there → not redirected.
|
|
6450
|
+
const willRedirectCliData = sandboxRequested
|
|
6451
|
+
&& cliAdapter.supportsReadIsolation === true
|
|
6452
|
+
&& !cfg.wrapperCli
|
|
6453
|
+
&& !!process.env.SESSION_DATA_DIR;
|
|
6454
|
+
// Bump the CLI-lifetime nonce: any stuck-warning card posted by a previous
|
|
6455
|
+
// backend instance (within this same worker) must not inject keys into the
|
|
6456
|
+
// new one. The worker echoes this nonce in stuck_warning and re-checks it on
|
|
6457
|
+
// tui_keys, alongside the backend object identity.
|
|
6458
|
+
cliLifetimeNonce++;
|
|
6113
6459
|
// Every bot — isolated OR not — gets its own BOT_HOME dir as a ready-made private-
|
|
6114
6460
|
// storage slot. An isolated sibling denies this path regardless of whether the owner
|
|
6115
6461
|
// is isolated (deny uses the full bots.json), so a non-isolated bot can drop private
|
|
@@ -6128,7 +6474,7 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
6128
6474
|
}
|
|
6129
6475
|
let isolationBotHome;
|
|
6130
6476
|
let isolatedCodexHome;
|
|
6131
|
-
if (
|
|
6477
|
+
if (willRedirectCliData) {
|
|
6132
6478
|
isolationBotHome = ownBotHome;
|
|
6133
6479
|
const isClaudeFam = !!claudeDataDir;
|
|
6134
6480
|
if (isClaudeFam)
|
|
@@ -6175,28 +6521,21 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
6175
6521
|
// on EVERY restart, e.g. for a sandboxed session whose transcript lives in
|
|
6176
6522
|
// an ephemeral overlay upper that the probe can't see). Computed here (not at
|
|
6177
6523
|
// the spawn site below) so the pre-flight can short-circuit on it.
|
|
6178
|
-
|
|
6179
|
-
? TmuxBackend.sessionName(cfg.sessionId)
|
|
6180
|
-
: effectiveBackendType === 'herdr'
|
|
6181
|
-
? HerdrBackend.sessionName(cfg.sessionId)
|
|
6182
|
-
: effectiveBackendType === 'zellij'
|
|
6183
|
-
? ZellijBackend.sessionName(cfg.sessionId)
|
|
6184
|
-
: undefined;
|
|
6524
|
+
let persistentSessionName = selectedBackend.persistentSessionName;
|
|
6185
6525
|
// [read-isolation] Before we decide to reattach a persistent pane: a pane can
|
|
6186
6526
|
// survive a daemon restart still running a CLI that may NOT be isolated (e.g.
|
|
6187
6527
|
// spawned before isolation was enabled, or by an old build). Isolation is only
|
|
6188
6528
|
// injectable at spawn time, so reattaching such a pane would silently run
|
|
6189
|
-
// unisolated. We stamp a
|
|
6190
|
-
//
|
|
6191
|
-
// so the probe below sees no pane and we cold-spawn fresh isolated. A
|
|
6192
|
-
//
|
|
6193
|
-
//
|
|
6529
|
+
// unisolated. We stamp a boot-id marker when we spawn an isolated pane; if this
|
|
6530
|
+
// isolated bot's existing pane is NOT stamped by THIS daemon lifetime, kill it
|
|
6531
|
+
// so the probe below sees no pane and we cold-spawn fresh isolated. A pane from
|
|
6532
|
+
// this lifetime (suspend→resume) keeps its marker → reattaches normally (it is
|
|
6533
|
+
// still the isolated process). This lets isolated bots use tmux/zellij/herdr.
|
|
6194
6534
|
if (appliedIsolationCapabilities.length > 0 && persistentSessionName && effectiveBackendType !== 'pty') {
|
|
6195
|
-
const
|
|
6196
|
-
|
|
6197
|
-
|
|
6198
|
-
|
|
6199
|
-
: HerdrBackend.hasSession(persistentSessionName);
|
|
6535
|
+
const persistentTarget = selectedBackend.persistentBackendTarget;
|
|
6536
|
+
const paneLive = persistentTarget
|
|
6537
|
+
? probePersistentBackendTarget(persistentTarget) === 'exists'
|
|
6538
|
+
: false;
|
|
6200
6539
|
if (paneLive) {
|
|
6201
6540
|
let marker = null;
|
|
6202
6541
|
marker = readRegularHostFileNoFollow(join(isolationRuntimeDataDir, 'read-isolation', `${cfg.sessionId}.boot`));
|
|
@@ -6211,7 +6550,18 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
6211
6550
|
// obsolete permissions. Kill it before publishing any new capability.
|
|
6212
6551
|
log(`[read-isolation] legacy/unmarked persistent pane for ${cfg.sessionId} — killing + cold-spawning with current policy`);
|
|
6213
6552
|
try {
|
|
6214
|
-
|
|
6553
|
+
const persistentTarget = selectedBackend.persistentBackendTarget;
|
|
6554
|
+
if (persistentTarget)
|
|
6555
|
+
killPersistentBackendTarget(persistentTarget);
|
|
6556
|
+
else
|
|
6557
|
+
killPersistentSession(effectiveBackendType, persistentSessionName);
|
|
6558
|
+
selectedBackend = selectBackend();
|
|
6559
|
+
isTmuxMode = selectedBackend.isTmuxMode;
|
|
6560
|
+
isPipeMode = selectedBackend.isPipeMode;
|
|
6561
|
+
isZellijMode = selectedBackend.isZellijMode;
|
|
6562
|
+
backend = selectedBackend.backend;
|
|
6563
|
+
cliLifetimeNonce++;
|
|
6564
|
+
persistentSessionName = selectedBackend.persistentSessionName;
|
|
6215
6565
|
}
|
|
6216
6566
|
catch (e) {
|
|
6217
6567
|
throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: could not kill stale persistent pane (${e.message})`);
|
|
@@ -6219,28 +6569,32 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
6219
6569
|
}
|
|
6220
6570
|
}
|
|
6221
6571
|
}
|
|
6222
|
-
let
|
|
6572
|
+
let willReattachPersistent = selectedBackend.isReattach === true;
|
|
6223
6573
|
if (cliAdapter.mcpGateway && mcpRuntimeManifest?.entries.length && persistentSessionName && effectiveBackendType !== 'pty') {
|
|
6224
|
-
const
|
|
6225
|
-
|
|
6226
|
-
|
|
6227
|
-
|
|
6228
|
-
: HerdrBackend.hasSession(persistentSessionName);
|
|
6574
|
+
const persistentTarget = selectedBackend.persistentBackendTarget;
|
|
6575
|
+
const paneLive = persistentTarget
|
|
6576
|
+
? probePersistentBackendTarget(persistentTarget) === 'exists'
|
|
6577
|
+
: false;
|
|
6229
6578
|
if (paneLive) {
|
|
6230
6579
|
// The trusted Gateway host belongs to the worker and cannot survive a
|
|
6231
6580
|
// worker/daemon replacement. Cold-resume the CLI so its MCP client gets a
|
|
6232
6581
|
// fresh relay socket instead of reattaching to a dead connection.
|
|
6233
6582
|
log(`[mcp-gateway] persistent pane ${cfg.sessionId} has plugin MCP state — cold-resuming with a fresh host`);
|
|
6234
|
-
|
|
6583
|
+
const persistentTarget = selectedBackend.persistentBackendTarget;
|
|
6584
|
+
if (persistentTarget)
|
|
6585
|
+
killPersistentBackendTarget(persistentTarget);
|
|
6586
|
+
else
|
|
6587
|
+
killPersistentSession(effectiveBackendType, persistentSessionName);
|
|
6588
|
+
selectedBackend = selectBackend();
|
|
6589
|
+
isTmuxMode = selectedBackend.isTmuxMode;
|
|
6590
|
+
isPipeMode = selectedBackend.isPipeMode;
|
|
6591
|
+
isZellijMode = selectedBackend.isZellijMode;
|
|
6592
|
+
backend = selectedBackend.backend;
|
|
6593
|
+
cliLifetimeNonce++;
|
|
6594
|
+
persistentSessionName = selectedBackend.persistentSessionName;
|
|
6595
|
+
willReattachPersistent = selectedBackend.isReattach === true;
|
|
6235
6596
|
}
|
|
6236
6597
|
}
|
|
6237
|
-
const willReattachPersistent = persistentSessionName
|
|
6238
|
-
? effectiveBackendType === 'tmux'
|
|
6239
|
-
? TmuxBackend.hasSession(persistentSessionName)
|
|
6240
|
-
: effectiveBackendType === 'zellij'
|
|
6241
|
-
? ZellijBackend.hasSession(persistentSessionName)
|
|
6242
|
-
: HerdrBackend.hasSession(persistentSessionName)
|
|
6243
|
-
: false;
|
|
6244
6598
|
// The plugin set is stable only for the lifetime of one real CLI process.
|
|
6245
6599
|
// A warm worker reattach keeps the existing Gateway and catalog untouched;
|
|
6246
6600
|
// every fresh/resumed CLI spawn atomically refreshes both from current Bot config.
|
|
@@ -6428,81 +6782,39 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
6428
6782
|
lastSpawnDeferInitialPrompt = deferInitialPrompt;
|
|
6429
6783
|
kiroSessionIdCaptureArmed = cfg.cliId === 'kiro-cli' && !effectiveCliSessionId && !willReattachPersistent;
|
|
6430
6784
|
kiroSessionIdCaptureBuffer = '';
|
|
6431
|
-
//
|
|
6432
|
-
//
|
|
6433
|
-
//
|
|
6434
|
-
//
|
|
6435
|
-
//
|
|
6436
|
-
|
|
6437
|
-
// pane spawned isolated (still the confined process).
|
|
6438
|
-
let readIsolationCtx;
|
|
6439
|
-
if (willReadIsolate) {
|
|
6440
|
-
const sessionDataDir = process.env.SESSION_DATA_DIR;
|
|
6441
|
-
readIsolationCtx = {
|
|
6442
|
-
homeDir: homedir(),
|
|
6443
|
-
botmuxHome: dirname(sessionDataDir),
|
|
6444
|
-
defaultBotmuxHome: join(homedir(), '.botmux'),
|
|
6445
|
-
sessionDataDir,
|
|
6446
|
-
currentAppId: cfg.larkAppId,
|
|
6447
|
-
currentSessionId: cfg.sessionId,
|
|
6448
|
-
extraDenyPaths: cfg.readDenyExtraPaths,
|
|
6449
|
-
};
|
|
6450
|
-
readIsolationOriginCapabilityFile = process.platform === 'darwin'
|
|
6451
|
-
? managedOriginCapabilityPath(sessionDataDir, cfg.sessionId)
|
|
6452
|
-
: null;
|
|
6453
|
-
// Replace any legacy child-planted symlink before Seatbelt canonicalizes
|
|
6454
|
-
// carve-outs. A failure is fatal: spawning with a missing/ambiguous
|
|
6455
|
-
// capability transport would either break all IPC or reopen an attacker-
|
|
6456
|
-
// selected realpath in the generated profile.
|
|
6457
|
-
if (readIsolationOriginCapabilityFile) {
|
|
6458
|
-
publishSandboxRelayCapability({ failClosed: true });
|
|
6459
|
-
}
|
|
6460
|
-
// Write this bot's OWN send-credential into its BOT_HOME (the same per-bot
|
|
6461
|
-
// private storage as its CLI data; siblings' BOT_HOMEs are whole-denied).
|
|
6462
|
-
// `botmux send` reads the secret from here instead of bots.json — so the
|
|
6463
|
-
// secret never travels via env/argv (no cross-bot `ps aux` leak) and the CLI
|
|
6464
|
-
// never needs to escape the sandbox.
|
|
6785
|
+
// Sandboxed sessions: write this bot's OWN send-credential into its BOT_HOME.
|
|
6786
|
+
// `botmux send` reads the secret from here instead of bots.json (which
|
|
6787
|
+
// deny-by-default never exposes) — the secret never travels via env/argv (no
|
|
6788
|
+
// cross-bot `ps aux` leak) and the CLI never needs to escape the sandbox.
|
|
6789
|
+
// The worker itself runs on the host (NOT sandboxed) and keeps full access.
|
|
6790
|
+
if (sandboxRequested && process.env.SESSION_DATA_DIR) {
|
|
6465
6791
|
try {
|
|
6466
|
-
const credPath = sendCredFilePath(
|
|
6792
|
+
const credPath = sendCredFilePath(process.env.SESSION_DATA_DIR, cfg.larkAppId);
|
|
6467
6793
|
mkdirSync(dirname(credPath), { recursive: true });
|
|
6468
6794
|
writeFileSync(credPath, JSON.stringify({ larkAppId: cfg.larkAppId, larkAppSecret: cfg.larkAppSecret, brand: cfg.brand }), { mode: 0o600 });
|
|
6469
6795
|
}
|
|
6470
6796
|
catch (e) {
|
|
6471
|
-
log(`[
|
|
6797
|
+
log(`[sandbox] WARN could not write send-cred file: ${e.message}`);
|
|
6472
6798
|
}
|
|
6473
6799
|
}
|
|
6474
|
-
//
|
|
6475
|
-
//
|
|
6476
|
-
|
|
6477
|
-
|
|
6478
|
-
|
|
6479
|
-
|
|
6480
|
-
|
|
6481
|
-
|
|
6482
|
-
|
|
6483
|
-
|
|
6484
|
-
|
|
6485
|
-
|
|
6486
|
-
|
|
6487
|
-
|
|
6488
|
-
};
|
|
6489
|
-
const sandboxHome = canonicalSandboxPath(homedir());
|
|
6490
|
-
writeSandboxRules = buildWriteSandboxRules({
|
|
6491
|
-
homeDir: sandboxHome,
|
|
6492
|
-
botmuxHome: canonicalSandboxPath(dirname(sessionDataDir)),
|
|
6493
|
-
defaultBotmuxHome: canonicalSandboxPath(join(homedir(), '.botmux')),
|
|
6494
|
-
sessionDataDir: canonicalSandboxPath(sessionDataDir),
|
|
6495
|
-
workingDir: cfg.workingDir,
|
|
6496
|
-
currentAppId: cfg.larkAppId,
|
|
6497
|
-
// TMPDIR on macOS resolves under /private/var/folders (already allowed), but
|
|
6498
|
-
// pass it explicitly so a customized TMPDIR is covered too.
|
|
6499
|
-
extraWritePaths: process.env.TMPDIR ? [process.env.TMPDIR] : [],
|
|
6500
|
-
});
|
|
6501
|
-
}
|
|
6800
|
+
// In the file sandbox, canonicalize the workingDir handed to buildArgs. CLIs
|
|
6801
|
+
// that pass it as a chdir arg (codex/traex `-C`) fail otherwise on a symlinked
|
|
6802
|
+
// $HOME host: the lexical /home/u/... prefix doesn't exist inside the bwrap
|
|
6803
|
+
// root (only canonical /data00/... is bound), so the CLI's chdir/readlink
|
|
6804
|
+
// ENOENTs and it aborts with "No such file or directory (os error 2)". Off
|
|
6805
|
+
// sandbox this is a no-op (same dir); best-effort if unresolvable.
|
|
6806
|
+
const buildArgsWorkingDir = sandboxRequested
|
|
6807
|
+
? (() => { try {
|
|
6808
|
+
return realpathSync(cfg.workingDir);
|
|
6809
|
+
}
|
|
6810
|
+
catch {
|
|
6811
|
+
return cfg.workingDir;
|
|
6812
|
+
} })()
|
|
6813
|
+
: cfg.workingDir;
|
|
6502
6814
|
const args = cliAdapter.buildArgs({
|
|
6503
6815
|
sessionId: effectiveAdapterSessionId,
|
|
6504
6816
|
resume: effectiveResume,
|
|
6505
|
-
workingDir:
|
|
6817
|
+
workingDir: buildArgsWorkingDir,
|
|
6506
6818
|
resumeSessionId: effectiveCliSessionId,
|
|
6507
6819
|
initialPrompt: preparedInitialPrompt,
|
|
6508
6820
|
botName: cfg.botName,
|
|
@@ -6512,12 +6824,7 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
6512
6824
|
model: ttadkGateway ? undefined : cfg.model,
|
|
6513
6825
|
disableCliBypass: cfg.disableCliBypass === true,
|
|
6514
6826
|
skillPluginDir: cfg.skillPluginDir,
|
|
6515
|
-
readIsolation:
|
|
6516
|
-
// Set (as a pair) by the init handler when hybrid RPC input is engaged;
|
|
6517
|
-
// makes buildArgs launch `codex --remote <ws> resume <thread>` instead of
|
|
6518
|
-
// the normal paste-mode codex. Undefined for every other bot/CLI.
|
|
6519
|
-
remoteWsUrl,
|
|
6520
|
-
remoteThreadId,
|
|
6827
|
+
readIsolation: willRedirectCliData,
|
|
6521
6828
|
});
|
|
6522
6829
|
// Pi's deferred long-first-prompt command is implemented by a session-scoped
|
|
6523
6830
|
// extension. Keep its launch args across owned process restarts while the
|
|
@@ -6616,15 +6923,18 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
6616
6923
|
delete childEnv.BOTMUX_CHAT_TYPE;
|
|
6617
6924
|
childEnv.BOTMUX_LARK_APP_ID = cfg.larkAppId;
|
|
6618
6925
|
childEnv.BOTMUX_ROOT_MESSAGE_ID = cfg.rootMessageId;
|
|
6619
|
-
//
|
|
6620
|
-
//
|
|
6621
|
-
//
|
|
6622
|
-
//
|
|
6623
|
-
//
|
|
6624
|
-
|
|
6625
|
-
|
|
6626
|
-
|
|
6627
|
-
|
|
6926
|
+
// This bot's resolved brandLabel template, injected so a SANDBOXED `botmux
|
|
6927
|
+
// send` renders the role-name footer without reading bots.json (deny-by-
|
|
6928
|
+
// default → EPERM → role footer would silently fall back to the default
|
|
6929
|
+
// [botmux] label). resolveBrandLabel honours this env first (gated on the
|
|
6930
|
+
// own appId). Only set the key when a brandLabel is configured (present-but-
|
|
6931
|
+
// empty '' = suppress is preserved; unset key → the CLI falls through). It is
|
|
6932
|
+
// a cosmetic markdown template, not a credential, so env-passing is safe.
|
|
6933
|
+
{
|
|
6934
|
+
const bl = resolveBrandLabel(cfg.larkAppId);
|
|
6935
|
+
if (typeof bl === 'string')
|
|
6936
|
+
childEnv.BOTMUX_BRAND_LABEL = bl;
|
|
6937
|
+
}
|
|
6628
6938
|
// NOTE: under read isolation `botmux send` gets this bot's secret from the worker-
|
|
6629
6939
|
// written cred FILE in its BOT_HOME (send-cred.json, see sendCredFilePath) located
|
|
6630
6940
|
// via the BOTMUX_LARK_APP_ID above — NOT from the env. The secret is deliberately kept OUT
|
|
@@ -6668,10 +6978,27 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
6668
6978
|
// already guarantee the pane starts clean, and the CLI's built-in default
|
|
6669
6979
|
// preserves stock semantics.
|
|
6670
6980
|
if (isolationBotHome) {
|
|
6981
|
+
// In the file sandbox, bwrap binds only CANONICAL paths (the fs-policy
|
|
6982
|
+
// realpaths every rule). On a symlinked-$HOME host (/home/u → /data00/home/u)
|
|
6983
|
+
// the lexical BOT_HOME (/home/u/.botmux/bots/…/claude) does NOT exist in the
|
|
6984
|
+
// fresh bwrap root, so the CLI can't open its own CLAUDE_CONFIG_DIR/CODEX_HOME
|
|
6985
|
+
// → settings.json (with ANTHROPIC_BASE_URL/token) is unreadable → it falls
|
|
6986
|
+
// back to the public endpoint and fails to connect. Canonicalize so the env
|
|
6987
|
+
// points at the same path bwrap bound. Best-effort: keep lexical if unresolved.
|
|
6988
|
+
const canonicalizeForSandbox = (p) => {
|
|
6989
|
+
if (!sandboxRequested)
|
|
6990
|
+
return p;
|
|
6991
|
+
try {
|
|
6992
|
+
return realpathSync(p);
|
|
6993
|
+
}
|
|
6994
|
+
catch {
|
|
6995
|
+
return p;
|
|
6996
|
+
}
|
|
6997
|
+
};
|
|
6671
6998
|
if (claudeDataDir)
|
|
6672
|
-
childEnv.CLAUDE_CONFIG_DIR = claudeDataDir; // = <BOT_HOME>/claude
|
|
6999
|
+
childEnv.CLAUDE_CONFIG_DIR = canonicalizeForSandbox(claudeDataDir); // = <BOT_HOME>/claude
|
|
6673
7000
|
else
|
|
6674
|
-
childEnv.CODEX_HOME = isolatedCodexHome;
|
|
7001
|
+
childEnv.CODEX_HOME = canonicalizeForSandbox(isolatedCodexHome);
|
|
6675
7002
|
}
|
|
6676
7003
|
// Per-bot env (bots.json `env`): extra vars for THIS bot's CLI only — e.g.
|
|
6677
7004
|
// ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN to run a bot on GLM/a 3rd-party
|
|
@@ -6694,356 +7021,362 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
6694
7021
|
let spawnBin = cliAdapter.resolvedBin;
|
|
6695
7022
|
let spawnArgs = args;
|
|
6696
7023
|
let spawnCwd = cfg.workingDir;
|
|
6697
|
-
//
|
|
6698
|
-
//
|
|
6699
|
-
//
|
|
6700
|
-
//
|
|
6701
|
-
//
|
|
6702
|
-
|
|
6703
|
-
|
|
6704
|
-
|
|
6705
|
-
|
|
6706
|
-
|
|
6707
|
-
|
|
6708
|
-
|
|
6709
|
-
|
|
7024
|
+
// Dashboard「复现命令」:在**任何** sandbox 包装(下方 macOS Seatbelt / Linux bwrap /
|
|
7025
|
+
// credential-only)之前,记下**基础 CLI** 的 bin/args(cliAdapter.resolvedBin +
|
|
7026
|
+
// buildArgs 产出)。独立维护、绝不从已被外层包装的 spawnBin/spawnArgs 回推。最终
|
|
7027
|
+
// 复现形态(是否套 wrapperCli)由 selectReproduceLaunch 在 spawn 时统一决策——见
|
|
7028
|
+
// reproduce-command.ts。这里只锁定"包装前的基础"这个事实。
|
|
7029
|
+
const reproduceBaseBin = spawnBin;
|
|
7030
|
+
const reproduceBaseArgs = [...spawnArgs];
|
|
7031
|
+
// ── UNIFIED file sandbox (fs-policy): ONE policy source, BOTH platforms. ──
|
|
7032
|
+
// Three-tier deny-by-default whitelist compiled to Seatbelt (darwin) or bwrap
|
|
7033
|
+
// (linux). Cross-bot read isolation is inherent (siblings' data simply isn't
|
|
7034
|
+
// exposed) — no enumeration, no deny-list to keep in sync.
|
|
7035
|
+
if (sandboxRequested) {
|
|
7036
|
+
const dataDir = process.env.SESSION_DATA_DIR;
|
|
7037
|
+
// FAIL-SAFE (not fail-open): a requested sandbox that can't be established
|
|
7038
|
+
// must be a HARD ERROR, never a silent unconfined run.
|
|
7039
|
+
if (effectiveBackendType !== 'pty' && effectiveBackendType !== 'tmux') {
|
|
7040
|
+
const msg = `Sandbox ENABLED but backend "${effectiveBackendType}" is not sandboxable (only pty/tmux) — aborting spawn to avoid an unsandboxed run`;
|
|
7041
|
+
log(msg);
|
|
7042
|
+
throw new Error(msg);
|
|
7043
|
+
}
|
|
7044
|
+
if (!dataDir) {
|
|
7045
|
+
const msg = 'Sandbox ENABLED but SESSION_DATA_DIR is unset — aborting spawn to avoid an unsandboxed run';
|
|
7046
|
+
log(msg);
|
|
7047
|
+
throw new Error(msg);
|
|
7048
|
+
}
|
|
7049
|
+
// Both engines match CANONICAL paths (Seatbelt resolves symlinks, bwrap
|
|
7050
|
+
// resolves mount sources) — realpath everything, or a symlinked prefix
|
|
7051
|
+
// (the /tmp→/private/tmp class) silently fail-opens.
|
|
6710
7052
|
const canonical = (p) => { try {
|
|
6711
7053
|
return realpathSync(p);
|
|
6712
7054
|
}
|
|
6713
7055
|
catch {
|
|
6714
7056
|
return p;
|
|
6715
7057
|
} };
|
|
6716
|
-
|
|
6717
|
-
|
|
6718
|
-
|
|
6719
|
-
|
|
6720
|
-
|
|
6721
|
-
|
|
6722
|
-
|
|
6723
|
-
|
|
6724
|
-
|
|
6725
|
-
|
|
6726
|
-
|
|
6727
|
-
|
|
6728
|
-
|
|
6729
|
-
|
|
6730
|
-
|
|
6731
|
-
denyPaths = buildV2DenyPaths(profileCtx).map(canonical);
|
|
6732
|
-
denyRegexes = buildV2DenyRegexes(profileCtx);
|
|
6733
|
-
const carve = buildV2CarveOuts(profileCtx);
|
|
6734
|
-
const capabilityCarvePath = managedOriginCapabilityPath(profileCtx.sessionDataDir, profileCtx.currentSessionId);
|
|
6735
|
-
allowPaths = [
|
|
6736
|
-
// Never realpath the capability leaf: another legacy sandbox can race
|
|
6737
|
-
// a symlink into that shared host directory while multiple persistent
|
|
6738
|
-
// sessions recover. Keeping the exact path may fail closed on a raced
|
|
6739
|
-
// symlink, but can never turn its target into a Seatbelt allow rule.
|
|
6740
|
-
...carve.allowPaths.map(path => path === capabilityCarvePath ? path : canonical(path)),
|
|
6741
|
-
...buildCliExecutableReadCarveOuts({
|
|
6742
|
-
homeDir: profileCtx.homeDir,
|
|
6743
|
-
cliId: cliAdapter.id,
|
|
6744
|
-
resolvedBin: canonical(cliAdapter.resolvedBin),
|
|
6745
|
-
}),
|
|
6746
|
-
// buildV2DenyPaths masks the shared pi-initial-prompts root. Re-open
|
|
6747
|
-
// only this session's private child directory for Pi's @file/extension.
|
|
6748
|
-
...piInitialPromptReadonlyRoots.map(canonical),
|
|
6749
|
-
];
|
|
6750
|
-
finalDenyPaths = carve.finalDenyPaths.map(canonical);
|
|
6751
|
-
traverseDirs = carve.traverseDirs.map(canonical);
|
|
6752
|
-
protectedWrites = buildReadIsolationProtectedWriteRules(profileCtx, {
|
|
6753
|
-
// Keep lexical roots as well as their canonical targets. Canonical
|
|
6754
|
-
// rules protect contents reached through a symlink; lexical literals
|
|
6755
|
-
// prevent replacing the symlink/root directory entry itself.
|
|
6756
|
-
dashboardRoots: [
|
|
6757
|
-
readIsolationCtx.defaultBotmuxHome ?? join(readIsolationCtx.homeDir, '.botmux'),
|
|
6758
|
-
readIsolationCtx.botmuxHome,
|
|
6759
|
-
],
|
|
6760
|
-
sessionDataDirs: [readIsolationCtx.sessionDataDir],
|
|
6761
|
-
});
|
|
6762
|
-
}
|
|
6763
|
-
if (credentialBoundaryActive) {
|
|
6764
|
-
const credentialRules = buildCredentialIsolationRules({
|
|
6765
|
-
homeDir: canonical(hostHomeDir),
|
|
6766
|
-
botmuxHome: canonical(configuredBotmuxHome),
|
|
6767
|
-
defaultBotmuxHome: canonical(defaultBotmuxHome),
|
|
6768
|
-
});
|
|
6769
|
-
denyPaths = [...new Set([...denyPaths, ...credentialRules.denyPaths.map(canonical)])];
|
|
6770
|
-
denyRegexes = [...new Set([...denyRegexes, ...credentialRules.denyRegexes])];
|
|
6771
|
-
protectedWrites = {
|
|
6772
|
-
denyWritePaths: [...new Set([
|
|
6773
|
-
...(protectedWrites?.denyWritePaths ?? []),
|
|
6774
|
-
...credentialRules.denyWritePaths.map(canonical),
|
|
6775
|
-
])],
|
|
6776
|
-
denyWriteRegexes: [...new Set([
|
|
6777
|
-
...(protectedWrites?.denyWriteRegexes ?? []),
|
|
6778
|
-
...credentialRules.denyWriteRegexes,
|
|
6779
|
-
])],
|
|
6780
|
-
denyWriteLiterals: [...new Set([
|
|
6781
|
-
...(protectedWrites?.denyWriteLiterals ?? []),
|
|
6782
|
-
...credentialRules.denyWriteLiterals.map(canonical),
|
|
6783
|
-
defaultBotmuxHome,
|
|
6784
|
-
configuredBotmuxHome,
|
|
6785
|
-
])],
|
|
6786
|
-
};
|
|
6787
|
-
}
|
|
6788
|
-
if (mcpRuntimeManifest) {
|
|
6789
|
-
finalDenyPaths.push(...sessionMcpRuntimeHostOnlyPaths(mcpRuntimeManifest, config.session.dataDir).map(canonical));
|
|
6790
|
-
}
|
|
6791
|
-
// Every isolated macOS CLI is denied access to all same-UID Gateway socket
|
|
6792
|
-
// directories. A session with its own Gateway gets one narrow, later READ
|
|
6793
|
-
// carve-out for its token; writes stay denied so the CLI cannot unlink or
|
|
6794
|
-
// replace even its own worker-managed socket. Sessions without plugin MCP
|
|
6795
|
-
// state get no read carve-out at all.
|
|
6796
|
-
const gatewaySocketRoot = canonical(sessionMcpGatewayHost ? dirname(sessionMcpGatewayHost.socketDir) : tmpdir());
|
|
6797
|
-
const gatewaySocketRegex = sessionMcpGatewayPathRegex(gatewaySocketRoot);
|
|
6798
|
-
denyRegexes.push(gatewaySocketRegex);
|
|
6799
|
-
if (sessionMcpGatewayHost)
|
|
6800
|
-
allowPaths.push(canonical(sessionMcpGatewayHost.socketDir));
|
|
6801
|
-
protectedWrites = {
|
|
6802
|
-
denyWritePaths: protectedWrites?.denyWritePaths ?? [],
|
|
6803
|
-
denyWriteRegexes: [...(protectedWrites?.denyWriteRegexes ?? []), gatewaySocketRegex],
|
|
6804
|
-
denyWriteLiterals: protectedWrites?.denyWriteLiterals ?? [],
|
|
7058
|
+
const sandboxHome = canonical(homedir());
|
|
7059
|
+
const expandTilde = (raw) => raw.replace(/^~(?=\/|$)/, sandboxHome);
|
|
7060
|
+
const keepExisting = (paths) => {
|
|
7061
|
+
const out = [];
|
|
7062
|
+
for (const raw of paths) {
|
|
7063
|
+
if (!raw || typeof raw !== 'string')
|
|
7064
|
+
continue;
|
|
7065
|
+
const p = expandTilde(raw);
|
|
7066
|
+
try {
|
|
7067
|
+
if (existsSync(p))
|
|
7068
|
+
out.push(canonical(p));
|
|
7069
|
+
}
|
|
7070
|
+
catch { /* */ }
|
|
7071
|
+
}
|
|
7072
|
+
return out;
|
|
6805
7073
|
};
|
|
6806
|
-
//
|
|
6807
|
-
//
|
|
6808
|
-
|
|
6809
|
-
|
|
6810
|
-
|
|
6811
|
-
|
|
6812
|
-
|
|
6813
|
-
|
|
7074
|
+
// User three-tier lists: the new sandboxPaths field, or a pre-migration
|
|
7075
|
+
// session/config's legacy fields mapped through the SAME lossless mapping
|
|
7076
|
+
// the startup migration uses.
|
|
7077
|
+
const legacyMapped = migrateLegacySandboxFields({
|
|
7078
|
+
sandbox: cfg.sandbox,
|
|
7079
|
+
readIsolation: cfg.readIsolation,
|
|
7080
|
+
sandboxReadonlyPaths: cfg.sandboxReadonlyPaths,
|
|
7081
|
+
sandboxHidePaths: cfg.sandboxHidePaths,
|
|
7082
|
+
readDenyExtraPaths: cfg.readDenyExtraPaths,
|
|
7083
|
+
sandboxPaths: cfg.sandboxPaths,
|
|
7084
|
+
});
|
|
7085
|
+
const userLists = cfg.sandboxPaths ?? legacyMapped?.sandboxPaths;
|
|
7086
|
+
const droppedUser = [];
|
|
7087
|
+
const resolveUser = (paths) => {
|
|
7088
|
+
const out = [];
|
|
7089
|
+
for (const raw of paths ?? []) {
|
|
7090
|
+
if (!raw || typeof raw !== 'string')
|
|
7091
|
+
continue;
|
|
7092
|
+
const p = expandTilde(raw);
|
|
7093
|
+
if (existsSync(p))
|
|
7094
|
+
out.push(canonical(p));
|
|
7095
|
+
else
|
|
7096
|
+
droppedUser.push(raw);
|
|
6814
7097
|
}
|
|
7098
|
+
return out;
|
|
7099
|
+
};
|
|
7100
|
+
// deny paths are NOT existence-filtered on EITHER platform: a deny must guard
|
|
7101
|
+
// a path that does not exist YET (else the agent creates it under an exposed
|
|
7102
|
+
// rw parent and the deny is bypassed — codex review finding). Seatbelt keeps
|
|
7103
|
+
// the rule literally; bwrap masks it with a tmpfs the mount creates (writes
|
|
7104
|
+
// land in the ephemeral tmpfs, the real path is never created).
|
|
7105
|
+
const resolveDeny = (paths) => (paths ?? []).filter((p) => typeof p === 'string' && !!p).map(p => canonical(expandTilde(p)));
|
|
7106
|
+
const userPaths = {
|
|
7107
|
+
readWrite: resolveUser(userLists?.readWrite),
|
|
7108
|
+
readOnly: resolveUser(userLists?.readOnly),
|
|
7109
|
+
deny: resolveDeny(userLists?.deny),
|
|
7110
|
+
};
|
|
7111
|
+
if (droppedUser.length)
|
|
7112
|
+
log(`[sandbox] sandboxPaths entries dropped (path not found): ${droppedUser.join(', ')}`);
|
|
7113
|
+
// Every executable spawned INSIDE the sandbox must be readable: the CLI
|
|
7114
|
+
// binary's dir, the daemon's own node (fnm farms under /run land here),
|
|
7115
|
+
// adapter second-stage bins, plus the standalone-codex package tree.
|
|
7116
|
+
const execDirs = [cliAdapter.resolvedBin, process.execPath, ...(cliAdapter.sandboxExtraExecPaths?.() ?? [])]
|
|
7117
|
+
.filter((p) => typeof p === 'string' && !!p)
|
|
7118
|
+
.map(p => dirname(canonical(p)));
|
|
7119
|
+
const execCarve = buildCliExecutableReadCarveOuts({
|
|
7120
|
+
homeDir: sandboxHome,
|
|
7121
|
+
cliId: cliAdapter.id,
|
|
7122
|
+
resolvedBin: canonical(cliAdapter.resolvedBin),
|
|
7123
|
+
}).map(canonical);
|
|
7124
|
+
// Linux relay outbox (created by prepareDirectSandbox; in the policy so the
|
|
7125
|
+
// compiled plan binds it read-write).
|
|
7126
|
+
const outbox = process.platform === 'linux'
|
|
7127
|
+
? join(canonical(dataDir), 'sandboxes', cfg.sessionId, 'outbox')
|
|
6815
7128
|
: undefined;
|
|
6816
|
-
|
|
6817
|
-
|
|
6818
|
-
|
|
6819
|
-
|
|
6820
|
-
|
|
6821
|
-
|
|
6822
|
-
|
|
6823
|
-
|
|
6824
|
-
|
|
6825
|
-
|
|
6826
|
-
|
|
6827
|
-
|
|
6828
|
-
|
|
6829
|
-
|
|
6830
|
-
|
|
6831
|
-
|
|
6832
|
-
|
|
7129
|
+
// Pre-create the outbox BEFORE buildFsPolicy so it survives the allow-rule
|
|
7130
|
+
// existence-filter below (a not-yet-existing readWrite path is dropped, and
|
|
7131
|
+
// bwrap can't bind a missing source → the sandboxed `botmux send` relay
|
|
7132
|
+
// would EPERM/ENOENT writing its <hash>.content into an unbound dir).
|
|
7133
|
+
// prepareDirectSandbox re-mkdirs it too; recursive make is idempotent.
|
|
7134
|
+
if (outbox) {
|
|
7135
|
+
try {
|
|
7136
|
+
mkdirSync(outbox, { recursive: true });
|
|
7137
|
+
}
|
|
7138
|
+
catch { /* best-effort; prepareDirectSandbox retries */ }
|
|
7139
|
+
}
|
|
7140
|
+
// The botmux install/checkout root (dir containing dist/ + node_modules).
|
|
7141
|
+
// This module compiles to <checkout>/dist/worker.js, so `../../` from here is
|
|
7142
|
+
// the checkout root. Exposed readOnly so `botmux` + claude hooks can exec
|
|
7143
|
+
// `node <checkout>/dist/cli.js`.
|
|
7144
|
+
const botmuxInstallRoot = canonical(dirname(dirname(fileURLToPath(import.meta.url))));
|
|
7145
|
+
// Pre-create the OWN writable dirs/files the sandboxed CLI creates on
|
|
7146
|
+
// demand, so they EXIST at spawn and survive the existence-filter below
|
|
7147
|
+
// (bwrap can't bind a nonexistent source; a dropped rule → the CLI's
|
|
7148
|
+
// mkdir/write EPERMs).
|
|
7149
|
+
// - data/turn-sends/<sessionId>.jsonl: `botmux send` appends its dedup
|
|
7150
|
+
// marker. The policy grants ONLY this exact file (not the whole dir) so a
|
|
7151
|
+
// sandboxed CLI can't rewrite another session's marker — pre-create the
|
|
7152
|
+
// file itself (touch) so the single-file bind has a source.
|
|
7153
|
+
// - data/attachments/<self>: `botmux quoted`/downloadResources writes here
|
|
7154
|
+
// (BOT_HOME + outbox are created elsewhere.) Best-effort — a failure just
|
|
7155
|
+
// reverts to the pre-existing drop behaviour.
|
|
6833
7156
|
try {
|
|
6834
|
-
const
|
|
6835
|
-
mkdirSync(
|
|
6836
|
-
|
|
7157
|
+
const tsDir = join(dataDir, 'turn-sends');
|
|
7158
|
+
mkdirSync(tsDir, { recursive: true });
|
|
7159
|
+
const tsFile = join(tsDir, `${cfg.sessionId}.jsonl`);
|
|
7160
|
+
if (!existsSync(tsFile))
|
|
7161
|
+
writeFileSync(tsFile, '');
|
|
6837
7162
|
}
|
|
6838
|
-
catch { /*
|
|
6839
|
-
|
|
6840
|
-
|
|
6841
|
-
// bwrap directly) and tmux (the tmux pane's command becomes `bwrap … -- cli`);
|
|
6842
|
-
// env is carried via bwrap --setenv (see prepareSandbox), not the backend.
|
|
6843
|
-
// Linux ONLY — on macOS the same `sandbox: true` is enforced by the Seatbelt
|
|
6844
|
-
// write-sandbox above (willWriteSandbox), so bwrap must not run here.
|
|
6845
|
-
//
|
|
6846
|
-
// riff bypass: there is NO local CLI process to wrap — execution happens in
|
|
6847
|
-
// riff's own remote sandbox, and the fail-safe "backend not sandboxable"
|
|
6848
|
-
// hard error below would otherwise brick every sandbox-enabled bot the
|
|
6849
|
-
// moment it switches to riff (the dashboard agent switch clears only
|
|
6850
|
-
// readIsolation, not `sandbox`). Bypassing is safe (nothing local runs);
|
|
6851
|
-
// log it so the state is visible.
|
|
6852
|
-
if (cfg.sandbox === true && effectiveBackendType === 'riff') {
|
|
6853
|
-
log('Sandbox flag set but backend is riff (remote sandbox, no local process) — local file sandbox bypassed');
|
|
6854
|
-
}
|
|
6855
|
-
const sandboxOn = localSandboxApplies(process.platform, effectiveBackendType) && (cfg.sandbox === true || sandboxEnabled());
|
|
6856
|
-
// Linux read isolation: build the bwrap MASK set (the Seatbelt read-deny twin) and
|
|
6857
|
-
// fold it into the SAME overlay sandbox plan below. Enumerate sibling bots from the
|
|
6858
|
-
// FILESYSTEM (the worker runs unsandboxed on the host; bots.json may be denied) —
|
|
6859
|
-
// spawn-time-static, so a bot added after this spawn is covered on its next cold
|
|
6860
|
-
// start (the accepted Linux tradeoff vs the macOS regex). Only when read isolation
|
|
6861
|
-
// is enforceable here (willReadIsolate ⇒ readIsolationCtx set) AND on Linux.
|
|
6862
|
-
let readIsoLinuxMasks;
|
|
6863
|
-
if (readIsolationCtx && process.platform === 'linux') {
|
|
6864
|
-
const canon = (p) => { try {
|
|
6865
|
-
return realpathSync(p);
|
|
7163
|
+
catch { /* */ }
|
|
7164
|
+
try {
|
|
7165
|
+
mkdirSync(join(dataDir, 'attachments', cfg.larkAppId), { recursive: true });
|
|
6866
7166
|
}
|
|
6867
|
-
catch {
|
|
6868
|
-
|
|
6869
|
-
|
|
6870
|
-
//
|
|
6871
|
-
// (bwrap masks resolve symlinks — a symlinked home/data root would else fail-open).
|
|
6872
|
-
const ctxReal = {
|
|
6873
|
-
...readIsolationCtx,
|
|
6874
|
-
homeDir: canon(readIsolationCtx.homeDir),
|
|
6875
|
-
botmuxHome: canon(readIsolationCtx.botmuxHome),
|
|
6876
|
-
defaultBotmuxHome: canon(readIsolationCtx.defaultBotmuxHome ?? join(readIsolationCtx.homeDir, '.botmux')),
|
|
6877
|
-
sessionDataDir: canon(readIsolationCtx.sessionDataDir),
|
|
6878
|
-
};
|
|
6879
|
-
const ids = new Set();
|
|
6880
|
-
const scan = (dir, pick) => {
|
|
6881
|
-
try {
|
|
6882
|
-
for (const name of readdirSync(dir)) {
|
|
6883
|
-
const id = pick(name);
|
|
6884
|
-
if (id)
|
|
6885
|
-
ids.add(id);
|
|
6886
|
-
}
|
|
6887
|
-
}
|
|
6888
|
-
catch { /* dir absent → no siblings there */ }
|
|
6889
|
-
};
|
|
6890
|
-
scan(join(ctxReal.botmuxHome, 'bots'), n => n); // sibling BOT_HOME dirs
|
|
6891
|
-
scan(join(ctxReal.homeDir, '.lark-cli-bots'), n => n); // sibling lark configs
|
|
6892
|
-
scan(ctxReal.sessionDataDir, n => { const m = /^sessions-(.+)\.json$/.exec(n); return m ? m[1] : null; });
|
|
6893
|
-
ids.delete(cfg.larkAppId); // never mask own
|
|
6894
|
-
let sidecars = [];
|
|
7167
|
+
catch { /* */ }
|
|
7168
|
+
// schedules.json is a shared RMW store (`botmux schedule`); pre-create as an
|
|
7169
|
+
// empty map if absent (same content schedule-store itself writes) so a fresh
|
|
7170
|
+
// install's first in-sandbox `schedule add` can bind+write it on bwrap too.
|
|
6895
7171
|
try {
|
|
6896
|
-
|
|
7172
|
+
const sf = join(dataDir, 'schedules.json');
|
|
7173
|
+
if (!existsSync(sf))
|
|
7174
|
+
writeFileSync(sf, '{}');
|
|
6897
7175
|
}
|
|
6898
7176
|
catch { /* */ }
|
|
6899
|
-
const
|
|
6900
|
-
|
|
6901
|
-
|
|
6902
|
-
//
|
|
6903
|
-
//
|
|
6904
|
-
|
|
6905
|
-
|
|
6906
|
-
|
|
6907
|
-
|
|
6908
|
-
|
|
6909
|
-
|
|
6910
|
-
|
|
6911
|
-
|
|
6912
|
-
|
|
6913
|
-
|
|
6914
|
-
|
|
6915
|
-
|
|
6916
|
-
if (sandboxOn) {
|
|
6917
|
-
// FAIL-SAFE (not fail-open): when the sandbox is requested, a missing
|
|
6918
|
-
// precondition (no SESSION_DATA_DIR, or a backend we can't wrap) must be a
|
|
6919
|
-
// HARD ERROR, never a silent skip — otherwise the CLI would spawn UNSANDBOXED
|
|
6920
|
-
// with full host write access and no log, the exact opposite of the
|
|
6921
|
-
// oncall-untrusted-agent invariant. In normal operation worker-pool sets
|
|
6922
|
-
// SESSION_DATA_DIR and the backend is pty/tmux, so this never trips.
|
|
6923
|
-
const dataDir = process.env.SESSION_DATA_DIR;
|
|
6924
|
-
if (effectiveBackendType !== 'pty' && effectiveBackendType !== 'tmux') {
|
|
6925
|
-
const msg = `Sandbox ENABLED but backend "${effectiveBackendType}" is not sandboxable (only pty/tmux) — aborting spawn to avoid an unsandboxed run`;
|
|
6926
|
-
log(msg);
|
|
6927
|
-
throw new Error(msg);
|
|
7177
|
+
const mandatoryDenyPaths = [];
|
|
7178
|
+
const mandatoryDenyRegexes = [];
|
|
7179
|
+
const mandatoryReadOnlyPaths = [];
|
|
7180
|
+
// Linux: the per-session sandbox tree (`sandboxes/<sid>`) holds the deny-mask
|
|
7181
|
+
// cleanup manifest + the mode-000 empty ro-bind SOURCES. If SESSION_DATA_DIR
|
|
7182
|
+
// is configured INSIDE the working dir (a custom data dir under a RW-bound
|
|
7183
|
+
// project), the project's readWrite rule would otherwise cover the whole
|
|
7184
|
+
// tree and let the sandboxed CLI tamper with the manifest (→ trick teardown
|
|
7185
|
+
// into deleting arbitrary host paths) or the empty sources. Deny the tree
|
|
7186
|
+
// as a MANDATORY rule (user paths can't override); the outbox is re-granted
|
|
7187
|
+
// readWrite via ctx.outbox as a deeper carve-out (the compiler masks the
|
|
7188
|
+
// tree with a tmpfs that hosts the nested outbox bind, then remounts it RO —
|
|
7189
|
+
// manifest/empties stay unreadable AND unwritable in-sandbox). bwrap reads
|
|
7190
|
+
// ro-bind SOURCES from the host FS, so the empty masks still work even though
|
|
7191
|
+
// the child can't see them.
|
|
7192
|
+
if (process.platform === 'linux') {
|
|
7193
|
+
mandatoryDenyPaths.push(join(canonical(dataDir), 'sandboxes', cfg.sessionId));
|
|
6928
7194
|
}
|
|
6929
|
-
|
|
6930
|
-
|
|
6931
|
-
|
|
6932
|
-
|
|
7195
|
+
readIsolationOriginCapabilityFile = process.platform === 'darwin'
|
|
7196
|
+
? managedOriginCapabilityPath(dataDir, cfg.sessionId)
|
|
7197
|
+
: null;
|
|
7198
|
+
// The macOS child reads the per-session rotating capability directly.
|
|
7199
|
+
// Materialize it before the policy's existence filter, and make the exact
|
|
7200
|
+
// file a mandatory read-only carve-out that user rules cannot shadow.
|
|
7201
|
+
if (readIsolationOriginCapabilityFile) {
|
|
7202
|
+
publishSandboxRelayCapability({ failClosed: true });
|
|
7203
|
+
mandatoryReadOnlyPaths.push(readIsolationOriginCapabilityFile);
|
|
6933
7204
|
}
|
|
6934
|
-
|
|
6935
|
-
|
|
6936
|
-
|
|
6937
|
-
|
|
6938
|
-
|
|
6939
|
-
|
|
6940
|
-
|
|
6941
|
-
|
|
6942
|
-
|
|
6943
|
-
|
|
6944
|
-
|
|
6945
|
-
|
|
6946
|
-
|
|
6947
|
-
|
|
6948
|
-
|
|
6949
|
-
catch { /* */ }
|
|
6950
|
-
}
|
|
6951
|
-
if (sandboxCleanup) {
|
|
6952
|
-
try {
|
|
6953
|
-
sandboxCleanup();
|
|
7205
|
+
if (credentialBoundaryActive) {
|
|
7206
|
+
const credentialRules = buildCredentialIsolationRules({
|
|
7207
|
+
homeDir: sandboxHome,
|
|
7208
|
+
botmuxHome: canonical(configuredBotmuxHome),
|
|
7209
|
+
defaultBotmuxHome: canonical(defaultBotmuxHome),
|
|
7210
|
+
});
|
|
7211
|
+
mandatoryDenyPaths.push(...credentialRules.denyPaths.map(canonical));
|
|
7212
|
+
mandatoryDenyRegexes.push(...credentialRules.denyRegexes);
|
|
7213
|
+
// Materialize every existing atomic sidecar as a concrete Linux mask.
|
|
7214
|
+
// Seatbelt additionally keeps the regexes above for files created later.
|
|
7215
|
+
for (const root of [...new Set([defaultBotmuxHome, configuredBotmuxHome])]) {
|
|
7216
|
+
try {
|
|
7217
|
+
for (const name of readdirSync(root)) {
|
|
7218
|
+
if (isCredentialIsolationReservedBasename(name)) {
|
|
7219
|
+
mandatoryDenyPaths.push(canonical(join(root, name)));
|
|
6954
7220
|
}
|
|
6955
|
-
catch { /* */ }
|
|
6956
7221
|
}
|
|
6957
|
-
sandboxCleanup = att.cleanup;
|
|
6958
|
-
sandboxRelayOutbox = att.outbox;
|
|
6959
|
-
sandboxStopWatcher = startOutboxWatcher(att.outbox, childEnv, cfg.sessionId, { authorize: authorizeManagedSend });
|
|
6960
|
-
publishSandboxRelayCapability();
|
|
6961
|
-
log(`Sandbox REATTACH (${cfg.cliId}): live pane CLI kept, re-wired outbox=${att.outbox} (no remount)`);
|
|
6962
7222
|
}
|
|
6963
|
-
|
|
6964
|
-
|
|
6965
|
-
|
|
6966
|
-
|
|
6967
|
-
|
|
7223
|
+
catch { /* absent authority root */ }
|
|
7224
|
+
}
|
|
7225
|
+
}
|
|
7226
|
+
if (mcpRuntimeManifest) {
|
|
7227
|
+
mandatoryDenyPaths.push(...sessionMcpRuntimeHostOnlyPaths(mcpRuntimeManifest, config.session.dataDir).map(canonical));
|
|
7228
|
+
}
|
|
7229
|
+
if (process.platform === 'darwin') {
|
|
7230
|
+
const gatewaySocketRoot = canonical(sessionMcpGatewayHost ? dirname(sessionMcpGatewayHost.socketDir) : tmpdir());
|
|
7231
|
+
mandatoryDenyRegexes.push(sessionMcpGatewayPathRegex(gatewaySocketRoot));
|
|
7232
|
+
if (sessionMcpGatewayHost) {
|
|
7233
|
+
mandatoryReadOnlyPaths.push(canonical(sessionMcpGatewayHost.socketDir));
|
|
7234
|
+
}
|
|
7235
|
+
}
|
|
7236
|
+
const policy = buildFsPolicy({
|
|
7237
|
+
platform: process.platform,
|
|
7238
|
+
homeDir: sandboxHome,
|
|
7239
|
+
botmuxHome: canonical(dirname(dataDir)),
|
|
7240
|
+
sessionDataDir: canonical(dataDir),
|
|
7241
|
+
workingDir: canonical(cfg.workingDir),
|
|
7242
|
+
currentAppId: cfg.larkAppId,
|
|
7243
|
+
sessionId: cfg.sessionId,
|
|
7244
|
+
botHome: canonical(ownBotHome),
|
|
7245
|
+
redirectedCliData: willRedirectCliData,
|
|
7246
|
+
cliDataPaths: willRedirectCliData ? undefined : keepExisting([
|
|
7247
|
+
cliAdapter.claudeDataDir,
|
|
7248
|
+
claudeDataDir ? `${sandboxHome}/.claude.json` : undefined,
|
|
7249
|
+
claudeDataDir ? `${sandboxHome}/.claude.json.lock` : undefined,
|
|
7250
|
+
claudeDataDir ? `${sandboxHome}/.claude.lock` : undefined,
|
|
7251
|
+
claudeDataDir ? `${sandboxHome}/.local/state/claude` : undefined,
|
|
7252
|
+
]),
|
|
7253
|
+
authPaths: keepExisting([...(cliAdapter.authPaths ?? [])]),
|
|
7254
|
+
execPaths: keepExisting([...execDirs, ...execCarve]),
|
|
7255
|
+
readonlyRoots: keepExisting([
|
|
7256
|
+
...(cfg.skillReadonlyRoots ?? []),
|
|
7257
|
+
...piInitialPromptReadonlyRoots,
|
|
7258
|
+
]),
|
|
7259
|
+
botmuxInstallRoot,
|
|
7260
|
+
outbox,
|
|
7261
|
+
extraWritePaths: keepExisting([process.env.TMPDIR]),
|
|
7262
|
+
userPaths,
|
|
7263
|
+
mandatoryDenyPaths,
|
|
7264
|
+
mandatoryDenyRegexes,
|
|
7265
|
+
mandatoryReadOnlyPaths,
|
|
7266
|
+
net: cfg.sandboxNetwork !== false,
|
|
7267
|
+
// Claude Code saves ~/.claude.json atomically via a PID/random-suffixed
|
|
7268
|
+
// sibling — only relevant when the data dir is NOT redirected to BOT_HOME.
|
|
7269
|
+
writeRegexes: process.platform === 'darwin' && !willRedirectCliData && claudeDataDir
|
|
7270
|
+
? [`^${sandboxHome.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/\\.claude\\.json\\.tmp\\.[^/]+$`]
|
|
7271
|
+
: [],
|
|
7272
|
+
});
|
|
7273
|
+
// Existence-filter only the ALLOW rules (baseline entries are candidates,
|
|
7274
|
+
// not guarantees): a non-existent allow path has nothing to expose, and
|
|
7275
|
+
// bwrap cannot bind a non-existent SOURCE. DENY rules are kept regardless —
|
|
7276
|
+
// they must guard a path created later in the session (Seatbelt keeps the
|
|
7277
|
+
// literal rule; bwrap masks with a tmpfs whose mountpoint the mount creates).
|
|
7278
|
+
policy.rules = policy.rules.filter(r => {
|
|
7279
|
+
if (r.access === 'deny')
|
|
7280
|
+
return true;
|
|
7281
|
+
try {
|
|
7282
|
+
return existsSync(r.path);
|
|
7283
|
+
}
|
|
7284
|
+
catch {
|
|
7285
|
+
return false;
|
|
7286
|
+
}
|
|
7287
|
+
});
|
|
7288
|
+
if (process.platform === 'darwin') {
|
|
7289
|
+
if (!locateOnPath('sandbox-exec')) {
|
|
7290
|
+
throw new Error(`[file-sandbox] refusing to start session ${cfg.sessionId}: sandbox-exec not found`);
|
|
7291
|
+
}
|
|
7292
|
+
const profileDir = join(dataDir, 'read-isolation');
|
|
7293
|
+
mkdirSync(profileDir, { recursive: true });
|
|
7294
|
+
const profilePath = join(profileDir, `${cfg.sessionId}.sb`);
|
|
7295
|
+
writeFileSync(profilePath, compileToSeatbelt(policy), { mode: 0o600 });
|
|
7296
|
+
seatbeltProfilePath = profilePath;
|
|
7297
|
+
spawnArgs = ['-f', profilePath, spawnBin, ...spawnArgs];
|
|
7298
|
+
spawnBin = 'sandbox-exec';
|
|
7299
|
+
log(`[file-sandbox] wrapping ${cliAdapter.id} in Seatbelt (fs-policy, ${policy.rules.length} rules): sandbox-exec -f ${profilePath}`);
|
|
7300
|
+
}
|
|
7301
|
+
else if (willReattachPersistent) {
|
|
7302
|
+
// Daemon-restart reattach to a live bwrap'd pane: backend.spawn() ignores
|
|
7303
|
+
// bin/args and just re-attaches. Only re-wire the outbox watcher (so the
|
|
7304
|
+
// live CLI's `botmux send` keeps being serviced) + the cleanup ref. The
|
|
7305
|
+
// direct model has NO mounts — cleanup is a plain rm at close/exit.
|
|
7306
|
+
const att = attachSandboxOutbox({ sessionId: cfg.sessionId, dataDir });
|
|
7307
|
+
if (att) {
|
|
7308
|
+
if (sandboxStopWatcher) {
|
|
7309
|
+
try {
|
|
7310
|
+
sandboxStopWatcher();
|
|
7311
|
+
}
|
|
7312
|
+
catch { /* */ }
|
|
6968
7313
|
}
|
|
7314
|
+
sandboxCleanup = att.cleanup;
|
|
7315
|
+
sandboxRelayOutbox = att.outbox;
|
|
7316
|
+
sandboxStopWatcher = startOutboxWatcher(att.outbox, childEnv, cfg.sessionId, { authorize: authorizeManagedSend });
|
|
7317
|
+
publishSandboxRelayCapability();
|
|
7318
|
+
log(`Sandbox REATTACH (${cfg.cliId}): live pane CLI kept, re-wired outbox=${att.outbox}`);
|
|
6969
7319
|
}
|
|
6970
7320
|
else {
|
|
6971
|
-
|
|
6972
|
-
|
|
6973
|
-
|
|
6974
|
-
|
|
6975
|
-
|
|
6976
|
-
|
|
6977
|
-
|
|
6978
|
-
|
|
6979
|
-
|
|
6980
|
-
|
|
6981
|
-
|
|
6982
|
-
|
|
6983
|
-
|
|
6984
|
-
|
|
6985
|
-
|
|
6986
|
-
|
|
6987
|
-
|
|
6988
|
-
|
|
6989
|
-
|
|
6990
|
-
|
|
6991
|
-
|
|
6992
|
-
|
|
6993
|
-
|
|
6994
|
-
|
|
6995
|
-
|
|
6996
|
-
|
|
6997
|
-
|
|
6998
|
-
mcpGatewaySocketPath: sessionMcpGatewayHost?.socketPath,
|
|
6999
|
-
trustedBotmuxCommandPaths: [defaultGatewayEntry().command],
|
|
7000
|
-
userReadonlyPaths: cfg.sandboxReadonlyPaths ?? [],
|
|
7001
|
-
net: cfg.sandboxNetwork !== false,
|
|
7002
|
-
});
|
|
7003
|
-
if (sbx) {
|
|
7004
|
-
spawnBin = sbx.bin;
|
|
7005
|
-
spawnArgs = sbx.args;
|
|
7006
|
-
// In the overlay model the child still chdirs to projectMount (via bwrap
|
|
7007
|
-
// --chdir), so spawnCwd stays the real workingDir; the overlay merged dir
|
|
7008
|
-
// is bound there. sbx.workDir is the UPPER changeset (for landing), NOT a cwd.
|
|
7009
|
-
Object.assign(childEnv, sbx.env);
|
|
7010
|
-
if (sandboxStopWatcher) {
|
|
7011
|
-
try {
|
|
7012
|
-
sandboxStopWatcher();
|
|
7013
|
-
}
|
|
7014
|
-
catch { /* */ }
|
|
7015
|
-
}
|
|
7016
|
-
if (sandboxCleanup) {
|
|
7017
|
-
try {
|
|
7018
|
-
sandboxCleanup();
|
|
7019
|
-
}
|
|
7020
|
-
catch { /* */ }
|
|
7021
|
-
}
|
|
7022
|
-
sandboxCleanup = sbx.cleanup;
|
|
7023
|
-
sandboxRelayOutbox = sbx.outbox;
|
|
7024
|
-
// session-id is FORCED here so a relayed send can't target another session.
|
|
7025
|
-
sandboxStopWatcher = startOutboxWatcher(sbx.outbox, childEnv, cfg.sessionId, { authorize: authorizeManagedSend });
|
|
7026
|
-
publishSandboxRelayCapability();
|
|
7027
|
-
log(`Sandbox ON (${cfg.cliId}): upper=${sbx.workDir} outbox=${sbx.outbox}`);
|
|
7321
|
+
log(`Sandbox REATTACH (${cfg.cliId}): no on-disk sandbox tree — reattaching live pane as-is`);
|
|
7322
|
+
}
|
|
7323
|
+
}
|
|
7324
|
+
else {
|
|
7325
|
+
const sbx = prepareDirectSandbox({
|
|
7326
|
+
sessionId: cfg.sessionId,
|
|
7327
|
+
dataDir,
|
|
7328
|
+
policy,
|
|
7329
|
+
chdir: canonical(cfg.workingDir),
|
|
7330
|
+
home: sandboxHome,
|
|
7331
|
+
cliBin: cliAdapter.resolvedBin,
|
|
7332
|
+
cliArgs: args,
|
|
7333
|
+
trustedBotmuxCommandPaths: [defaultGatewayEntry().command],
|
|
7334
|
+
mcpGatewaySocketPath: sessionMcpGatewayHost?.socketPath,
|
|
7335
|
+
});
|
|
7336
|
+
if (!sbx) {
|
|
7337
|
+
// FAIL-SAFE: never silently run unsandboxed.
|
|
7338
|
+
const msg = 'sandbox requested but could not be established (bwrap missing or setup failed) — aborting spawn';
|
|
7339
|
+
log(msg);
|
|
7340
|
+
throw new Error(msg);
|
|
7341
|
+
}
|
|
7342
|
+
spawnBin = sbx.bin;
|
|
7343
|
+
spawnArgs = sbx.args;
|
|
7344
|
+
Object.assign(childEnv, sbx.env);
|
|
7345
|
+
if (sandboxStopWatcher) {
|
|
7346
|
+
try {
|
|
7347
|
+
sandboxStopWatcher();
|
|
7028
7348
|
}
|
|
7029
|
-
|
|
7030
|
-
|
|
7031
|
-
|
|
7032
|
-
|
|
7033
|
-
|
|
7034
|
-
throw new Error('sandbox requested but could not be established');
|
|
7349
|
+
catch { /* */ }
|
|
7350
|
+
}
|
|
7351
|
+
if (sandboxCleanup) {
|
|
7352
|
+
try {
|
|
7353
|
+
sandboxCleanup();
|
|
7035
7354
|
}
|
|
7355
|
+
catch { /* */ }
|
|
7036
7356
|
}
|
|
7357
|
+
sandboxCleanup = sbx.cleanup;
|
|
7358
|
+
sandboxRelayOutbox = sbx.outbox;
|
|
7359
|
+
// session-id is FORCED here so a relayed send can't target another session.
|
|
7360
|
+
sandboxStopWatcher = startOutboxWatcher(sbx.outbox, childEnv, cfg.sessionId, { authorize: authorizeManagedSend });
|
|
7361
|
+
publishSandboxRelayCapability();
|
|
7362
|
+
log(`Sandbox ON (${cfg.cliId}, fs-policy ${policy.rules.length} rules): outbox=${sbx.outbox}`);
|
|
7037
7363
|
}
|
|
7038
|
-
|
|
7039
|
-
|
|
7040
|
-
|
|
7364
|
+
}
|
|
7365
|
+
// Fresh sandboxed spawn on a persistent backend: stamp the pane with this
|
|
7366
|
+
// daemon's boot id so a later reattach can be trusted (see the stale-pane
|
|
7367
|
+
// guard above). pty needs no marker (never reattached).
|
|
7368
|
+
if (appliedIsolationCapabilities.length > 0 && persistentSessionName && !willReattachPersistent) {
|
|
7369
|
+
try {
|
|
7370
|
+
const markerDir = join(isolationRuntimeDataDir, 'read-isolation');
|
|
7371
|
+
mkdirSync(markerDir, { recursive: true });
|
|
7372
|
+
writeFileSync(join(markerDir, `${cfg.sessionId}.boot`), isolationPaneMarkerContent(cfg.daemonBootId ?? '', appliedIsolationCapabilities), { mode: 0o600 });
|
|
7041
7373
|
}
|
|
7374
|
+
catch { /* non-fatal: worst case a same-lifetime reattach cold-spawns instead */ }
|
|
7042
7375
|
}
|
|
7043
7376
|
// 通用启动前缀(wrapperCli):把启动命令重写成 `<wrapperCli> <CLI 参数>`(首 token 当
|
|
7044
7377
|
// bin 走 PATH 解析),无需 wrapper 脚本、跨系统。aiden x claude 形态会剥掉 aiden 拒收的
|
|
7045
7378
|
// --settings(见 buildWrappedLaunch)。与文件沙盒互斥:沙盒已把命令重写成 bwrap,叠加
|
|
7046
|
-
//
|
|
7379
|
+
// 前缀会破坏隔离,故沙盒开启时跳过并告警(网关 + oncall 沙盒本就不是合理组合)。
|
|
7047
7380
|
// CJADK_INTERACTIVE is a cjadk-only knob we set on the cjadk wrapper branch
|
|
7048
7381
|
// below. Strip any value inherited from the daemon's own env first so a
|
|
7049
7382
|
// daemon launched under `cjadk feishu` (which exports it) can't leak it via
|
|
@@ -7052,8 +7385,8 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
7052
7385
|
// keeps the behaviour intentional rather than ambient. (Codex review note.)
|
|
7053
7386
|
delete childEnv.CJADK_INTERACTIVE;
|
|
7054
7387
|
if (cfg.wrapperCli && cfg.wrapperCli.trim()) {
|
|
7055
|
-
if (
|
|
7056
|
-
log(`wrapperCli="${cfg.wrapperCli}" ignored: file sandbox enabled and takes precedence (cannot combine launch prefix with
|
|
7388
|
+
if (sandboxRequested) {
|
|
7389
|
+
log(`wrapperCli="${cfg.wrapperCli}" ignored: file sandbox enabled and takes precedence (cannot combine launch prefix with the sandbox wrapper)`);
|
|
7057
7390
|
}
|
|
7058
7391
|
else {
|
|
7059
7392
|
const launch = buildWrappedLaunch(cfg.wrapperCli, spawnArgs, (b) => locateOnPath(b) ?? b, {
|
|
@@ -7087,6 +7420,19 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
7087
7420
|
}
|
|
7088
7421
|
}
|
|
7089
7422
|
}
|
|
7423
|
+
// Publish the exact selected resource BEFORE spawn. This both restores host
|
|
7424
|
+
// affinity for later in-worker restarts and closes the crash window where a
|
|
7425
|
+
// shared Herdr agent could be created but the daemon still knew only bmx-*.
|
|
7426
|
+
// A failed spawn leaving an intent stamp is safe: lifecycle probes see the
|
|
7427
|
+
// missing agent and close/cold-resume instead of leaking an untracked pane.
|
|
7428
|
+
cfg.persistentBackendTarget = selectedBackend.persistentBackendTarget;
|
|
7429
|
+
if (lastInitConfig) {
|
|
7430
|
+
lastInitConfig.persistentBackendTarget = selectedBackend.persistentBackendTarget;
|
|
7431
|
+
}
|
|
7432
|
+
send({
|
|
7433
|
+
type: 'persistent_backend_target',
|
|
7434
|
+
target: selectedBackend.persistentBackendTarget,
|
|
7435
|
+
});
|
|
7090
7436
|
// Mandatory credential-only confinement is the OUTERMOST launch wrapper so
|
|
7091
7437
|
// wrapperCli and every descendant it starts inherit the boundary. Full
|
|
7092
7438
|
// Seatbelt/bwrap sessions were already wrapped above and never enter these
|
|
@@ -7224,6 +7570,32 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
7224
7570
|
log(`[device-credential-isolation] wrapping ${cliAdapter.id} in credential-only bwrap `
|
|
7225
7571
|
+ `(${hideDirectories.size} authority dir(s), ${hideFiles.size} exact file(s))`);
|
|
7226
7572
|
}
|
|
7573
|
+
// Dashboard「复现命令」:算出本次冷启的**近似**可复现命令(bin + argv + cwd +
|
|
7574
|
+
// 权威注入 env),随 ready 上报、只驻 daemon 内存(含凭证,绝不落盘)。基础 CLI
|
|
7575
|
+
// bin/args 取 sandbox 包装前的快照,最终形态(含/不含 wrapperCli)由
|
|
7576
|
+
// selectReproduceLaunch 决策——绝不含 sandbox-exec/bwrap 外层。riff 返回 null。
|
|
7577
|
+
try {
|
|
7578
|
+
const reproduceLaunch = selectReproduceLaunch({
|
|
7579
|
+
baseBin: reproduceBaseBin,
|
|
7580
|
+
baseArgs: reproduceBaseArgs,
|
|
7581
|
+
wrapperCli: cfg.wrapperCli,
|
|
7582
|
+
sandboxOn: sandboxRequested,
|
|
7583
|
+
binResolver: (b) => locateOnPath(b) ?? b,
|
|
7584
|
+
ttadkModel: cfg.model,
|
|
7585
|
+
});
|
|
7586
|
+
capturedSpawnCommand = buildReproduceCommand({
|
|
7587
|
+
backendType: effectiveBackendType,
|
|
7588
|
+
bin: reproduceLaunch.bin,
|
|
7589
|
+
args: reproduceLaunch.args,
|
|
7590
|
+
cwd: spawnCwd,
|
|
7591
|
+
env: childEnv,
|
|
7592
|
+
injectEnv: perBotInjectKeys.length ? perBotInjectEnv : undefined,
|
|
7593
|
+
});
|
|
7594
|
+
}
|
|
7595
|
+
catch (err) {
|
|
7596
|
+
capturedSpawnCommand = null;
|
|
7597
|
+
log(`Failed to capture reproduce command: ${err?.message ?? err}`);
|
|
7598
|
+
}
|
|
7227
7599
|
backend.spawn(spawnBin, spawnArgs, {
|
|
7228
7600
|
cwd: spawnCwd,
|
|
7229
7601
|
cols: PTY_COLS,
|
|
@@ -7232,6 +7604,13 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
7232
7604
|
injectEnv: perBotInjectKeys.length ? perBotInjectEnv : undefined,
|
|
7233
7605
|
launchShell: lastInitConfig?.launchShell,
|
|
7234
7606
|
});
|
|
7607
|
+
if (selectedBackend.createdHerdrSessionName) {
|
|
7608
|
+
send({
|
|
7609
|
+
type: 'user_notify',
|
|
7610
|
+
turnId: currentBotmuxTurnId,
|
|
7611
|
+
message: `已创建 Botmux 专属 Herdr 会话:\`${selectedBackend.createdHerdrSessionName}\``,
|
|
7612
|
+
});
|
|
7613
|
+
}
|
|
7235
7614
|
// Write CLI PID marker so agent-facing subcommands (`botmux send`, etc.)
|
|
7236
7615
|
// can verify they were spawned inside a botmux session by walking the
|
|
7237
7616
|
// process tree and looking for a matching pid file in this directory.
|
|
@@ -7266,7 +7645,7 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
7266
7645
|
// MARKER inference is unaffected (the launcher-pid marker is still a valid
|
|
7267
7646
|
// ancestor of an in-CLI `botmux send`, and the env fallback covers it too).
|
|
7268
7647
|
const startWrapperRealPidResolve = (launcherPid) => {
|
|
7269
|
-
if (!cfg.wrapperCli || !cfg.wrapperCli.trim() ||
|
|
7648
|
+
if (!cfg.wrapperCli || !cfg.wrapperCli.trim() || sandboxRequested || !claudeDataDir)
|
|
7270
7649
|
return;
|
|
7271
7650
|
const targetCliId = cfg.cliId;
|
|
7272
7651
|
scheduleWrapperRealCliPid(launcherPid, {
|
|
@@ -7488,7 +7867,7 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
7488
7867
|
const readyHookAvailable = effectiveReadyHookInstall
|
|
7489
7868
|
? hasInstalledSessionReadyHook(effectiveReadyHookInstall)
|
|
7490
7869
|
: true; // Hermes emits BOTMUX_READY_COMMAND directly instead of a config hook.
|
|
7491
|
-
const isolatedReadyTransportRequired =
|
|
7870
|
+
const isolatedReadyTransportRequired = sandboxRequested || credentialBoundaryActive;
|
|
7492
7871
|
const readyPortAvailable = !isolatedReadyTransportRequired
|
|
7493
7872
|
|| parseDaemonIpcPort(childEnv.BOTMUX_DAEMON_IPC_PORT) !== undefined;
|
|
7494
7873
|
const readyCapabilityAvailable = !isolatedReadyTransportRequired
|
|
@@ -7519,44 +7898,59 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
7519
7898
|
}, READY_SIGNAL_TIMEOUT_MS);
|
|
7520
7899
|
readySignalTimer.unref?.();
|
|
7521
7900
|
}
|
|
7901
|
+
// A settled signal is a turn boundary. Drain structured transcripts before
|
|
7902
|
+
// markPromptReady(): that call may synchronously flush a type-ahead turn and
|
|
7903
|
+
// advance bridge attribution. Both screen-idle and authoritative Herdr status
|
|
7904
|
+
// must preserve this ordering.
|
|
7905
|
+
const drainBridgesThenMarkReady = (evidenceSource) => {
|
|
7906
|
+
if (bridgeJsonlPath) {
|
|
7907
|
+
try {
|
|
7908
|
+
bridgeDrainAndMaybeEmit();
|
|
7909
|
+
}
|
|
7910
|
+
catch (err) {
|
|
7911
|
+
log(`Bridge emit error: ${err.message}`);
|
|
7912
|
+
}
|
|
7913
|
+
}
|
|
7914
|
+
if (codexBridgeFallbackActive()) {
|
|
7915
|
+
try {
|
|
7916
|
+
codexBridgeDrainAndMaybeEmit();
|
|
7917
|
+
}
|
|
7918
|
+
catch (err) {
|
|
7919
|
+
log(`Codex bridge emit error: ${err.message}`);
|
|
7920
|
+
}
|
|
7921
|
+
}
|
|
7922
|
+
if (evidenceSource === 'screen')
|
|
7923
|
+
markPromptReadyFromPty();
|
|
7924
|
+
else
|
|
7925
|
+
markPromptReady();
|
|
7926
|
+
};
|
|
7522
7927
|
// Set up idle detection. Riff (remote HTTP backend) has no PTY output and
|
|
7523
7928
|
// is marked ready immediately after spawn (see below), so the idle detector
|
|
7524
7929
|
// is unnecessary — and without a readyPattern it would fire on every
|
|
7525
7930
|
// quiescence, repeatedly triggering markPromptReady() and duplicate cards.
|
|
7526
7931
|
if (effectiveBackendType !== 'riff') {
|
|
7527
7932
|
idleDetector = new IdleDetector(cliAdapter);
|
|
7528
|
-
idleDetector.onIdle(
|
|
7933
|
+
idleDetector.onIdle((evidenceSource) => {
|
|
7529
7934
|
log('Prompt detected (idle)');
|
|
7530
|
-
|
|
7531
|
-
// flushPending() which can immediately fire the next queued message
|
|
7532
|
-
// (type-ahead adapters), shifting bridgeQueue's notion of "current
|
|
7533
|
-
// turn" before we've had a chance to emit the previous one.
|
|
7534
|
-
if (bridgeJsonlPath) {
|
|
7535
|
-
try {
|
|
7536
|
-
bridgeDrainAndMaybeEmit();
|
|
7537
|
-
}
|
|
7538
|
-
catch (err) {
|
|
7539
|
-
log(`Bridge emit error: ${err.message}`);
|
|
7540
|
-
}
|
|
7541
|
-
}
|
|
7542
|
-
if (codexBridgeFallbackActive()) {
|
|
7543
|
-
try {
|
|
7544
|
-
codexBridgeDrainAndMaybeEmit();
|
|
7545
|
-
}
|
|
7546
|
-
catch (err) {
|
|
7547
|
-
log(`Codex bridge emit error: ${err.message}`);
|
|
7548
|
-
}
|
|
7549
|
-
}
|
|
7550
|
-
if (evidenceSource === 'screen') {
|
|
7551
|
-
markPromptReadyFromPty();
|
|
7552
|
-
}
|
|
7553
|
-
else {
|
|
7554
|
-
markPromptReady();
|
|
7555
|
-
}
|
|
7935
|
+
drainBridgesThenMarkReady(evidenceSource);
|
|
7556
7936
|
});
|
|
7557
7937
|
}
|
|
7558
7938
|
backend.onData(onPtyData);
|
|
7559
7939
|
const observedBackend = backend;
|
|
7940
|
+
if (observedBackend instanceof HerdrBackend) {
|
|
7941
|
+
observedBackend.onAgentStatus((status) => {
|
|
7942
|
+
if (backend !== observedBackend)
|
|
7943
|
+
return;
|
|
7944
|
+
if (status === 'idle' || status === 'done') {
|
|
7945
|
+
log(`Herdr agent ${status} — draining bridges before marking prompt ready`);
|
|
7946
|
+
drainBridgesThenMarkReady('structured');
|
|
7947
|
+
}
|
|
7948
|
+
else if (status === 'working') {
|
|
7949
|
+
isPromptReady = false;
|
|
7950
|
+
idleDetector?.reset();
|
|
7951
|
+
}
|
|
7952
|
+
});
|
|
7953
|
+
}
|
|
7560
7954
|
backend.onAccessUrl?.((url) => {
|
|
7561
7955
|
send({
|
|
7562
7956
|
type: 'riff_access_url',
|
|
@@ -7744,6 +8138,7 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
7744
8138
|
}
|
|
7745
8139
|
function killCli(opts = {}) {
|
|
7746
8140
|
currentCliCredentialIsolated = false;
|
|
8141
|
+
stopNativeSessionTitleSync();
|
|
7747
8142
|
stopSessionMcpGatewayHost();
|
|
7748
8143
|
stopCodexRpcEngine();
|
|
7749
8144
|
if (!opts.preservePending)
|
|
@@ -7767,6 +8162,7 @@ function killCli(opts = {}) {
|
|
|
7767
8162
|
readyPatternSeenDuringHold = false;
|
|
7768
8163
|
awaitingPostSessionStartPromptEvidence = false;
|
|
7769
8164
|
stopScreenAnalyzer();
|
|
8165
|
+
stopStuckDetector();
|
|
7770
8166
|
stopScreenUpdates();
|
|
7771
8167
|
backend?.kill();
|
|
7772
8168
|
backend = null;
|
|
@@ -7883,6 +8279,7 @@ async function restartCliProcess(reason, opts = {}) {
|
|
|
7883
8279
|
if (lastInitConfig) {
|
|
7884
8280
|
startScreenUpdates();
|
|
7885
8281
|
startScreenAnalyzer();
|
|
8282
|
+
startStuckDetector();
|
|
7886
8283
|
try {
|
|
7887
8284
|
const restartCfg = { ...lastInitConfig, resume: true, prompt: '', cliSessionId: rpcThreadId ?? lastInitConfig.cliSessionId };
|
|
7888
8285
|
// Re-engage RPC so the new --remote pane binds to the CURRENT app-server
|
|
@@ -7896,6 +8293,7 @@ async function restartCliProcess(reason, opts = {}) {
|
|
|
7896
8293
|
await engageCodexRpc(restartCfg);
|
|
7897
8294
|
}
|
|
7898
8295
|
await spawnCli(restartCfg, { pluginGenerationPrepared: rpcPluginGenerationPrepared });
|
|
8296
|
+
await prepareCodexNativeTitleGeneration(restartCfg, codexRpcEngine);
|
|
7899
8297
|
if (codexRpcEngine)
|
|
7900
8298
|
armRpcStartupDialogDismiss();
|
|
7901
8299
|
}
|
|
@@ -7960,8 +8358,24 @@ function startWebServer(host, preferredPort) {
|
|
|
7960
8358
|
// normal buffer until resize causes a redraw. Preserve that mode so
|
|
7961
8359
|
// scroll gestures continue to target the CLI's own transcript.
|
|
7962
8360
|
const forceRemoteScroll = effectiveBackendType === 'herdr' && cliAdapter?.altScreen === true;
|
|
8361
|
+
// The wheel burst cap throttles backends where a forwarded wheel is
|
|
8362
|
+
// EXPENSIVE or has no local terminal to drive:
|
|
8363
|
+
// • Herdr — each forwarded wheel → pane send-text + snapshot re-render.
|
|
8364
|
+
// • Riff — has NO drivable terminal; writes become remote task/follow-up
|
|
8365
|
+
// creations (handleTermAction even rejects ANSI to Riff), so an
|
|
8366
|
+
// uncapped spin would flood remote task creation.
|
|
8367
|
+
// This is a BACKEND property, independent of the adapter's declared
|
|
8368
|
+
// altScreen: an altScreen:false CLI (Claude/Codex) that enters the
|
|
8369
|
+
// alternate buffer at runtime (vim/less, or the CLI's own alt-screen) still
|
|
8370
|
+
// forwards via _fwdScroll and must inherit its backend's cap. So gate the
|
|
8371
|
+
// cap on a POSITIVE allowlist of cheap, locally-drivable terminal backends
|
|
8372
|
+
// (pty/tmux/zellij); every other backend — Herdr, Riff, and any future one
|
|
8373
|
+
// — stays safely capped by default.
|
|
8374
|
+
const localTerminalBackend = effectiveBackendType === 'pty'
|
|
8375
|
+
|| effectiveBackendType === 'tmux'
|
|
8376
|
+
|| effectiveBackendType === 'zellij';
|
|
7963
8377
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
7964
|
-
res.end(getTerminalHtml(hasWrite, platformReadonly, loginUrl, forceRemoteScroll));
|
|
8378
|
+
res.end(getTerminalHtml(hasWrite, platformReadonly, loginUrl, forceRemoteScroll, localTerminalBackend));
|
|
7965
8379
|
});
|
|
7966
8380
|
wss = new WebSocketServer({
|
|
7967
8381
|
server: httpServer,
|
|
@@ -8258,7 +8672,7 @@ function startWebServer(host, preferredPort) {
|
|
|
8258
8672
|
.then(resolve, reject);
|
|
8259
8673
|
});
|
|
8260
8674
|
}
|
|
8261
|
-
function getTerminalHtml(hasWrite, platformReadonly = false, loginUrl = '', forceRemoteScroll = false) {
|
|
8675
|
+
function getTerminalHtml(hasWrite, platformReadonly = false, loginUrl = '', forceRemoteScroll = false, localTerminalBackend = false) {
|
|
8262
8676
|
const label = sessionId.substring(0, 8);
|
|
8263
8677
|
return `<!DOCTYPE html>
|
|
8264
8678
|
<html>
|
|
@@ -8395,6 +8809,7 @@ if(isTouch){document.body.classList.add('touch');}
|
|
|
8395
8809
|
var hasToken=${hasWrite};
|
|
8396
8810
|
var platformReadonly=${platformReadonly};
|
|
8397
8811
|
var remoteScroll=${forceRemoteScroll};
|
|
8812
|
+
var localTerminalBackend=${localTerminalBackend};
|
|
8398
8813
|
if(!hasToken){
|
|
8399
8814
|
if(platformReadonly){var _lb=document.getElementById('login-banner');_lb.classList.add('show');}
|
|
8400
8815
|
else{var _rb=document.getElementById('readonly-banner');_rb.classList.add('show');_rb.addEventListener('click',function(){_rb.classList.remove('show')});}
|
|
@@ -8601,8 +9016,21 @@ window.addEventListener('resize',onViewportResize);
|
|
|
8601
9016
|
// A high-resolution trackpad emits dozens of wheel events for one gesture, so
|
|
8602
9017
|
// cap the whole continuous burst — not each browser event — then require an idle
|
|
8603
9018
|
// gap (or direction reversal) before loading the next history chunk.
|
|
9019
|
+
//
|
|
9020
|
+
// The burst ceiling throttles backends where a forwarded wheel is EXPENSIVE or
|
|
9021
|
+
// has no local terminal to drive: Herdr (each tick → pane send-text + snapshot
|
|
9022
|
+
// re-render) and Riff (no drivable terminal — writes become remote task/follow-up
|
|
9023
|
+
// creations, so an uncapped spin would flood remote task creation). That cost is
|
|
9024
|
+
// a BACKEND property, not the adapter's declared altScreen — an altScreen:false
|
|
9025
|
+
// CLI (Claude/Codex) that enters the alternate buffer at runtime (vim/less, the
|
|
9026
|
+
// CLI's own alt-screen) still hits this path and inherits its backend's cap. A
|
|
9027
|
+
// local PTY/tmux/zellij backend repaints its own alt-screen cheaply, so capping
|
|
9028
|
+
// it at 6 ticks is what froze a continuous wheel spin after ~2 notches — the
|
|
9029
|
+
// "not smooth / stuck" symptom. Gate the release on a POSITIVE allowlist of
|
|
9030
|
+
// cheap local terminal backends (localTerminalBackend); every other backend —
|
|
9031
|
+
// Herdr, Riff, and any future one — stays safely capped by default.
|
|
8604
9032
|
var _scrollAccum=0,_scrollBurstTicks=0,_scrollBurstDir=0,_scrollBurstT=0;
|
|
8605
|
-
var _SCROLL_STEP=33;var _SCROLL_BURST_MAX=6;var _SCROLL_BURST_IDLE_MS=250;
|
|
9033
|
+
var _SCROLL_STEP=33;var _SCROLL_BURST_MAX=localTerminalBackend?Infinity:6;var _SCROLL_BURST_IDLE_MS=250;
|
|
8606
9034
|
function _endScrollBurst(){
|
|
8607
9035
|
clearTimeout(_scrollBurstT);_scrollBurstT=0;
|
|
8608
9036
|
_scrollAccum=0;_scrollBurstTicks=0;_scrollBurstDir=0;
|
|
@@ -9058,6 +9486,10 @@ function send(msg) {
|
|
|
9058
9486
|
}
|
|
9059
9487
|
process.send?.(payload);
|
|
9060
9488
|
}
|
|
9489
|
+
function acknowledgeTurnInputCommitted(turnId) {
|
|
9490
|
+
if (turnId)
|
|
9491
|
+
send({ type: 'turn_input_committed', turnId });
|
|
9492
|
+
}
|
|
9061
9493
|
function publishLocalProcessAttestation(cliPid) {
|
|
9062
9494
|
const cliProcStart = cliPid ? readProcessStartIdentity(cliPid) : undefined;
|
|
9063
9495
|
send({
|
|
@@ -9115,11 +9547,13 @@ process.on('message', async (raw) => {
|
|
|
9115
9547
|
const msg = raw;
|
|
9116
9548
|
switch (msg.type) {
|
|
9117
9549
|
case 'init': {
|
|
9550
|
+
const initStartedAtMs = Date.now();
|
|
9118
9551
|
if (lastInitConfig)
|
|
9119
9552
|
return; // already initialized
|
|
9120
9553
|
lastInitConfig = msg;
|
|
9121
9554
|
sessionId = msg.sessionId;
|
|
9122
9555
|
refreshTerminalViewToken();
|
|
9556
|
+
refreshTerminalWriteToken();
|
|
9123
9557
|
if (msg.ownerOpenId)
|
|
9124
9558
|
process.env.__OWNER_OPEN_ID = msg.ownerOpenId;
|
|
9125
9559
|
// Pin this worker's i18n locale early so every t() call below resolves
|
|
@@ -9163,6 +9597,7 @@ process.on('message', async (raw) => {
|
|
|
9163
9597
|
port = await startWebServer(config.web.workerHost, msg.webPort);
|
|
9164
9598
|
startScreenUpdates();
|
|
9165
9599
|
startScreenAnalyzer();
|
|
9600
|
+
startStuckDetector();
|
|
9166
9601
|
}
|
|
9167
9602
|
else {
|
|
9168
9603
|
// Workflow attempts still expose a read-only web terminal so the
|
|
@@ -9206,6 +9641,7 @@ process.on('message', async (raw) => {
|
|
|
9206
9641
|
throw new Error('codex RPC resume: could not replace stale --remote pane; aborting init');
|
|
9207
9642
|
}
|
|
9208
9643
|
await spawnCli(msg, { pluginGenerationPrepared: rpcPluginGenerationPrepared });
|
|
9644
|
+
await prepareCodexNativeTitleGeneration(msg, codexRpcEngine);
|
|
9209
9645
|
if (codexRpcEngine)
|
|
9210
9646
|
armRpcStartupDialogDismiss(); // boundary #4: keep the --remote pane from freezing on a startup dialog
|
|
9211
9647
|
// Queue the initial prompt — flushed when CLI shows idle.
|
|
@@ -9237,6 +9673,7 @@ process.on('message', async (raw) => {
|
|
|
9237
9673
|
// execution — exactly-once, Codex P1-1). Ambiguous never reaches here.
|
|
9238
9674
|
// - RPC RESUME: queuePrompt=true → queue for post-ready flush (bridge
|
|
9239
9675
|
// marked at flush time, P0-1).
|
|
9676
|
+
let initialInputCommitted = false;
|
|
9240
9677
|
if (shouldQueueInitialPrompt({
|
|
9241
9678
|
hasPrompt: !!msg.prompt,
|
|
9242
9679
|
rpcEngineActive: !!codexRpcEngine,
|
|
@@ -9254,13 +9691,22 @@ process.on('message', async (raw) => {
|
|
|
9254
9691
|
vcMeetingImTurnOrigin: msg.vcMeetingImTurnOrigin,
|
|
9255
9692
|
codexAppInput: msg.promptCodexAppInput,
|
|
9256
9693
|
});
|
|
9694
|
+
initialInputCommitted = true;
|
|
9257
9695
|
}
|
|
9258
|
-
|
|
9259
|
-
|
|
9260
|
-
|
|
9261
|
-
|
|
9262
|
-
|
|
9263
|
-
if (
|
|
9696
|
+
else if (msg.prompt) {
|
|
9697
|
+
// A successful spawn with a non-queued prompt means the adapter baked
|
|
9698
|
+
// it into argv or the RPC engine already accepted it.
|
|
9699
|
+
initialInputCommitted = true;
|
|
9700
|
+
}
|
|
9701
|
+
if (initialInputCommitted)
|
|
9702
|
+
acknowledgeTurnInputCommitted(msg.turnId);
|
|
9703
|
+
// A backend may become prompt-ready before spawnCli() returns. The
|
|
9704
|
+
// initial prompt is queued only afterwards, so the earlier
|
|
9705
|
+
// markPromptReady() necessarily flushed an empty queue. This is normal
|
|
9706
|
+
// for riff (ready immediately) and can also happen when Herdr reports a
|
|
9707
|
+
// fast-starting TUI as idle during spawn. Flush again after enqueueing;
|
|
9708
|
+
// the ready flag keeps booting/busy backends gated.
|
|
9709
|
+
if (isPromptReady && pendingMessages.length > 0) {
|
|
9264
9710
|
flushPending();
|
|
9265
9711
|
}
|
|
9266
9712
|
send({
|
|
@@ -9268,6 +9714,11 @@ process.on('message', async (raw) => {
|
|
|
9268
9714
|
port,
|
|
9269
9715
|
token: writeToken,
|
|
9270
9716
|
viewToken,
|
|
9717
|
+
...(capturedSpawnCommand ? { spawnCommand: capturedSpawnCommand } : {}),
|
|
9718
|
+
// A fast initial turn can complete via `botmux send` before Herdr
|
|
9719
|
+
// reports idle and this ready IPC is emitted. Tell the daemon not to
|
|
9720
|
+
// post a stale Starting card after the final reply is already visible.
|
|
9721
|
+
replyAlreadySent: readSendMarkers().some(marker => marker.sentAtMs >= initStartedAtMs),
|
|
9271
9722
|
turnId: currentBotmuxTurnId,
|
|
9272
9723
|
dispatchAttempt: currentBotmuxDispatchAttempt,
|
|
9273
9724
|
});
|
|
@@ -9282,6 +9733,21 @@ process.on('message', async (raw) => {
|
|
|
9282
9733
|
// Mark new turn baseline so the streaming card only shows this turn's content
|
|
9283
9734
|
renderer?.markNewTurn();
|
|
9284
9735
|
const turnSeq = usageLimitTracker.beginTurn(currentUsageLimitSnapshot());
|
|
9736
|
+
if (msg.nativeSessionTitlePrompt
|
|
9737
|
+
&& msg.nativeSessionTitle
|
|
9738
|
+
&& lastInitConfig?.cliId === 'codex'
|
|
9739
|
+
&& !lastInitConfig.adoptMode) {
|
|
9740
|
+
nativeSessionTitleRevision += 1;
|
|
9741
|
+
nativeSessionTitleAppliedThreadId = undefined;
|
|
9742
|
+
lastInitConfig.nativeSessionTitle = msg.nativeSessionTitle;
|
|
9743
|
+
lastInitConfig.nativeSessionTitlePrompt = msg.nativeSessionTitlePrompt;
|
|
9744
|
+
stopNativeSessionTitleSync();
|
|
9745
|
+
const threadId = codexRpcEngine?.activeThreadId
|
|
9746
|
+
?? lastSpawnEffectiveCliSessionId
|
|
9747
|
+
?? lastInitConfig.cliSessionId;
|
|
9748
|
+
if (threadId)
|
|
9749
|
+
void syncFreshCodexNativeSessionTitle(threadId, codexRpcEngine);
|
|
9750
|
+
}
|
|
9285
9751
|
// Cancel any active tmux copy-mode scroll so user input reaches the CLI.
|
|
9286
9752
|
if (tmuxScrolledHalfPages > 0)
|
|
9287
9753
|
exitTmuxScrollMode();
|
|
@@ -9299,12 +9765,16 @@ process.on('message', async (raw) => {
|
|
|
9299
9765
|
log('Message received after crash-loop stop; retrying CLI start');
|
|
9300
9766
|
destroyCrashDiagnosticTerminal('retry after message');
|
|
9301
9767
|
stopScreenAnalyzer();
|
|
9768
|
+
stopStuckDetector();
|
|
9302
9769
|
stopScreenUpdates();
|
|
9303
9770
|
awaitingFirstPrompt = true;
|
|
9304
9771
|
startScreenUpdates();
|
|
9305
9772
|
startScreenAnalyzer();
|
|
9773
|
+
startStuckDetector();
|
|
9306
9774
|
try {
|
|
9307
|
-
|
|
9775
|
+
const restartCfg = { ...lastInitConfig, resume: true, prompt: '' };
|
|
9776
|
+
await spawnCli(restartCfg);
|
|
9777
|
+
await prepareCodexNativeTitleGeneration(restartCfg, codexRpcEngine);
|
|
9308
9778
|
}
|
|
9309
9779
|
catch (err) {
|
|
9310
9780
|
// Pass the message's own attempt (not the stale currentBotmux* from a
|
|
@@ -9386,6 +9856,9 @@ process.on('message', async (raw) => {
|
|
|
9386
9856
|
if (result && result.submitted === false) {
|
|
9387
9857
|
scheduleSubmitFailureNotify(content, result.recheck, 'submit history', undefined, result.failureReason, turnSeq);
|
|
9388
9858
|
}
|
|
9859
|
+
else {
|
|
9860
|
+
acknowledgeTurnInputCommitted(msg.turnId);
|
|
9861
|
+
}
|
|
9389
9862
|
}
|
|
9390
9863
|
catch (err) {
|
|
9391
9864
|
log(`Adopt writeInput error (${lastInitConfig?.cliId}): ${err.message}`);
|
|
@@ -9403,9 +9876,11 @@ process.on('message', async (raw) => {
|
|
|
9403
9876
|
// command (raw_input) fix.
|
|
9404
9877
|
await new Promise(r => setTimeout(r, 200));
|
|
9405
9878
|
backend.sendSpecialKeys('Enter');
|
|
9879
|
+
acknowledgeTurnInputCommitted(msg.turnId);
|
|
9406
9880
|
}
|
|
9407
9881
|
else {
|
|
9408
9882
|
backend.write(content + '\r');
|
|
9883
|
+
acknowledgeTurnInputCommitted(msg.turnId);
|
|
9409
9884
|
}
|
|
9410
9885
|
isPromptReady = false;
|
|
9411
9886
|
idleDetector?.reset();
|
|
@@ -9417,11 +9892,13 @@ process.on('message', async (raw) => {
|
|
|
9417
9892
|
// arrival. Marking now would race with a still-running previous
|
|
9418
9893
|
// turn whose `botmux send` could sneak its sentAtMs past this
|
|
9419
9894
|
// turn's markTimeMs and falsely suppress its fallback.
|
|
9420
|
-
sendToPty(content, msg.turnId, {
|
|
9895
|
+
const inputCommitted = sendToPty(content, msg.turnId, {
|
|
9421
9896
|
codexAppInput,
|
|
9422
9897
|
dispatchAttempt: msg.dispatchAttempt,
|
|
9423
9898
|
vcMeetingImTurnOrigin: msg.vcMeetingImTurnOrigin,
|
|
9424
9899
|
});
|
|
9900
|
+
if (inputCommitted)
|
|
9901
|
+
acknowledgeTurnInputCommitted(msg.turnId);
|
|
9425
9902
|
}
|
|
9426
9903
|
break;
|
|
9427
9904
|
}
|
|
@@ -9449,6 +9926,13 @@ process.on('message', async (raw) => {
|
|
|
9449
9926
|
// IPC handlers are concurrent with async init, so queue first even when
|
|
9450
9927
|
// the adapter/backend has not finished initializing. flushPending will
|
|
9451
9928
|
// capability-check and deliver the latest title once a real prompt is idle.
|
|
9929
|
+
nativeSessionTitleRevision += 1;
|
|
9930
|
+
nativeSessionTitleAppliedThreadId = undefined;
|
|
9931
|
+
if (lastInitConfig) {
|
|
9932
|
+
lastInitConfig.nativeSessionTitle = msg.title;
|
|
9933
|
+
lastInitConfig.nativeSessionTitlePrompt = undefined;
|
|
9934
|
+
}
|
|
9935
|
+
stopNativeSessionTitleSync();
|
|
9452
9936
|
pendingSessionRename = msg.title;
|
|
9453
9937
|
log(`Queued native session rename: ${msg.title}`);
|
|
9454
9938
|
void flushPending();
|
|
@@ -9545,7 +10029,49 @@ process.on('message', async (raw) => {
|
|
|
9545
10029
|
break;
|
|
9546
10030
|
}
|
|
9547
10031
|
case 'tui_keys': {
|
|
9548
|
-
|
|
10032
|
+
// Stale-card guard: if this key press came from a stuck-warning card,
|
|
10033
|
+
// delegate to processStuckWarningTuiKeys which does a FRESH capture (not
|
|
10034
|
+
// the 2s-cached lastAnalyzerSnapshot) and re-verifies the current screen
|
|
10035
|
+
// still matches the page type the card was built for. Fail-closed: any
|
|
10036
|
+
// mismatch (lifetime, capture, backend, page type, write failure) sends
|
|
10037
|
+
// stuck_warning_expired and drops the keys. ScreenAnalyzer TUI cards
|
|
10038
|
+
// (no stuckNonce) bypass the guard and write keys directly.
|
|
10039
|
+
let wroteKeys = false;
|
|
10040
|
+
if (msg.stuckNonce !== undefined && msg.stuckPageType) {
|
|
10041
|
+
const result = await processStuckWarningTuiKeys({
|
|
10042
|
+
stuckNonce: msg.stuckNonce,
|
|
10043
|
+
stuckPageType: msg.stuckPageType,
|
|
10044
|
+
stuckCliLifetime: msg.stuckCliLifetime,
|
|
10045
|
+
keys: msg.keys,
|
|
10046
|
+
isFinal: msg.isFinal,
|
|
10047
|
+
}, {
|
|
10048
|
+
getBackend: () => backend,
|
|
10049
|
+
getCurrentLifetime: () => cliLifetimeNonce,
|
|
10050
|
+
renderCols,
|
|
10051
|
+
renderRows,
|
|
10052
|
+
turnId: currentBotmuxTurnId,
|
|
10053
|
+
dispatchAttempt: currentBotmuxDispatchAttempt,
|
|
10054
|
+
capture: snapshotToText,
|
|
10055
|
+
match: matchHookReviewScreen,
|
|
10056
|
+
writeKeys: handleTuiKeys,
|
|
10057
|
+
sendExpired: (nonce, turnId, dispatchAttempt) => send({ type: 'stuck_warning_expired', nonce, turnId, dispatchAttempt }),
|
|
10058
|
+
sendDelivered: (nonce, turnId, dispatchAttempt) => send({ type: 'tui_keys_delivered', nonce, turnId, dispatchAttempt }),
|
|
10059
|
+
log,
|
|
10060
|
+
});
|
|
10061
|
+
wroteKeys = result.wroteKeys;
|
|
10062
|
+
}
|
|
10063
|
+
else {
|
|
10064
|
+
await handleTuiKeys(msg.keys, msg.isFinal);
|
|
10065
|
+
wroteKeys = true;
|
|
10066
|
+
}
|
|
10067
|
+
// Re-arm the stuck detector ONLY when the card-handler explicitly flags
|
|
10068
|
+
// this as a stuck-warning card's Enter action (advances to the next
|
|
10069
|
+
// review layer) AND keys were actually written. An expired click (CLI
|
|
10070
|
+
// recovered, page changed) must NOT re-arm — the detector should stay
|
|
10071
|
+
// disarmed until the next real stall. t/Esc and all ScreenAnalyzer cards
|
|
10072
|
+
// never set this flag.
|
|
10073
|
+
if (shouldRearmStuckDetector(!!msg.rearmStuckDetector, wroteKeys))
|
|
10074
|
+
stuckDetector?.arm();
|
|
9549
10075
|
break;
|
|
9550
10076
|
}
|
|
9551
10077
|
case 'inject_command': {
|
|
@@ -9710,6 +10236,7 @@ process.on('message', async (raw) => {
|
|
|
9710
10236
|
});
|
|
9711
10237
|
// ─── Cleanup ─────────────────────────────────────────────────────────────────
|
|
9712
10238
|
function cleanup() {
|
|
10239
|
+
stopNativeSessionTitleSync();
|
|
9713
10240
|
cleanupPiInitialPromptFiles();
|
|
9714
10241
|
stopSessionMcpGatewayHost();
|
|
9715
10242
|
if (tmuxRestartTimer) {
|
|
@@ -9814,7 +10341,11 @@ function teardownSandboxBestEffort() {
|
|
|
9814
10341
|
// and process.exit(1), killing a live session over a dropped log write. Install
|
|
9815
10342
|
// the guard before any further stdout writes (log() writes to process.stdout).
|
|
9816
10343
|
installStdioEpipeGuard();
|
|
9817
|
-
process.on('exit', () => {
|
|
10344
|
+
process.on('exit', () => {
|
|
10345
|
+
stopNativeSessionTitleSync();
|
|
10346
|
+
teardownSandboxBestEffort();
|
|
10347
|
+
stopCodexRpcEngine();
|
|
10348
|
+
});
|
|
9818
10349
|
process.on('uncaughtException', (err) => {
|
|
9819
10350
|
// A broken pipe on stdout/stderr (or any socket) must not tear down a live
|
|
9820
10351
|
// session — the stdio guard handles those it can; this is the backstop.
|