botmux 3.14.0 → 3.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +2 -2
- package/README.md +2 -2
- package/dist/.runtime-build-id +1 -1
- package/dist/adapters/backend/destroy-result.d.ts +159 -0
- package/dist/adapters/backend/destroy-result.d.ts.map +1 -0
- package/dist/adapters/backend/destroy-result.js +223 -0
- package/dist/adapters/backend/destroy-result.js.map +1 -0
- package/dist/adapters/backend/herdr-backend.d.ts +4 -0
- package/dist/adapters/backend/herdr-backend.d.ts.map +1 -1
- package/dist/adapters/backend/herdr-backend.js +23 -1
- package/dist/adapters/backend/herdr-backend.js.map +1 -1
- package/dist/adapters/backend/mojo-backend.d.ts +577 -0
- package/dist/adapters/backend/mojo-backend.d.ts.map +1 -0
- package/dist/adapters/backend/mojo-backend.js +2471 -0
- package/dist/adapters/backend/mojo-backend.js.map +1 -0
- package/dist/adapters/backend/mojo-budgets.d.ts +21 -0
- package/dist/adapters/backend/mojo-budgets.d.ts.map +1 -0
- package/dist/adapters/backend/mojo-budgets.js +22 -0
- package/dist/adapters/backend/mojo-budgets.js.map +1 -0
- package/dist/adapters/backend/mojo-isolated-workspace.d.ts +69 -0
- package/dist/adapters/backend/mojo-isolated-workspace.d.ts.map +1 -0
- package/dist/adapters/backend/mojo-isolated-workspace.js +213 -0
- package/dist/adapters/backend/mojo-isolated-workspace.js.map +1 -0
- package/dist/adapters/backend/mojo-process-tree.d.ts +292 -0
- package/dist/adapters/backend/mojo-process-tree.d.ts.map +1 -0
- package/dist/adapters/backend/mojo-process-tree.js +486 -0
- package/dist/adapters/backend/mojo-process-tree.js.map +1 -0
- package/dist/adapters/backend/mojo-types.d.ts +542 -0
- package/dist/adapters/backend/mojo-types.d.ts.map +1 -0
- package/dist/adapters/backend/mojo-types.js +682 -0
- package/dist/adapters/backend/mojo-types.js.map +1 -0
- package/dist/adapters/backend/reproduce-command.js +3 -3
- package/dist/adapters/backend/reproduce-command.js.map +1 -1
- package/dist/adapters/backend/riff-backend.js +1 -1
- package/dist/adapters/backend/riff-backend.js.map +1 -1
- package/dist/adapters/backend/sandbox.d.ts +44 -7
- package/dist/adapters/backend/sandbox.d.ts.map +1 -1
- package/dist/adapters/backend/sandbox.js +30 -7
- package/dist/adapters/backend/sandbox.js.map +1 -1
- package/dist/adapters/backend/session-backend-selector.d.ts +14 -1
- package/dist/adapters/backend/session-backend-selector.d.ts.map +1 -1
- package/dist/adapters/backend/session-backend-selector.js +82 -10
- package/dist/adapters/backend/session-backend-selector.js.map +1 -1
- package/dist/adapters/backend/tmux-pipe-backend.d.ts +30 -0
- package/dist/adapters/backend/tmux-pipe-backend.d.ts.map +1 -1
- package/dist/adapters/backend/tmux-pipe-backend.js +60 -0
- package/dist/adapters/backend/tmux-pipe-backend.js.map +1 -1
- package/dist/adapters/backend/types.d.ts +108 -6
- package/dist/adapters/backend/types.d.ts.map +1 -1
- package/dist/adapters/backend/types.js.map +1 -1
- package/dist/adapters/backend/zmx-backend.d.ts +11 -0
- package/dist/adapters/backend/zmx-backend.d.ts.map +1 -1
- package/dist/adapters/backend/zmx-backend.js +16 -1
- package/dist/adapters/backend/zmx-backend.js.map +1 -1
- package/dist/adapters/cli/codex-app.d.ts.map +1 -1
- package/dist/adapters/cli/codex-app.js +2 -2
- package/dist/adapters/cli/codex-app.js.map +1 -1
- package/dist/adapters/cli/codex.d.ts.map +1 -1
- package/dist/adapters/cli/codex.js +7 -0
- package/dist/adapters/cli/codex.js.map +1 -1
- package/dist/adapters/cli/copilot.d.ts +3 -1
- package/dist/adapters/cli/copilot.d.ts.map +1 -1
- package/dist/adapters/cli/copilot.js +23 -5
- package/dist/adapters/cli/copilot.js.map +1 -1
- package/dist/adapters/cli/cursor.d.ts.map +1 -1
- package/dist/adapters/cli/cursor.js +26 -4
- package/dist/adapters/cli/cursor.js.map +1 -1
- package/dist/adapters/cli/dsh.d.ts.map +1 -1
- package/dist/adapters/cli/dsh.js +5 -1
- package/dist/adapters/cli/dsh.js.map +1 -1
- package/dist/adapters/cli/grok.d.ts.map +1 -1
- package/dist/adapters/cli/grok.js +24 -5
- package/dist/adapters/cli/grok.js.map +1 -1
- package/dist/adapters/cli/kimi.d.ts.map +1 -1
- package/dist/adapters/cli/kimi.js +19 -1
- package/dist/adapters/cli/kimi.js.map +1 -1
- package/dist/adapters/cli/mojo.d.ts +28 -0
- package/dist/adapters/cli/mojo.d.ts.map +1 -0
- package/dist/adapters/cli/mojo.js +58 -0
- package/dist/adapters/cli/mojo.js.map +1 -0
- package/dist/adapters/cli/pi.d.ts +20 -5
- package/dist/adapters/cli/pi.d.ts.map +1 -1
- package/dist/adapters/cli/pi.js +27 -5
- package/dist/adapters/cli/pi.js.map +1 -1
- package/dist/adapters/cli/read-isolation.d.ts +251 -1
- package/dist/adapters/cli/read-isolation.d.ts.map +1 -1
- package/dist/adapters/cli/read-isolation.js +254 -1
- package/dist/adapters/cli/read-isolation.js.map +1 -1
- package/dist/adapters/cli/registry.d.ts +13 -1
- package/dist/adapters/cli/registry.d.ts.map +1 -1
- package/dist/adapters/cli/registry.js +19 -1
- package/dist/adapters/cli/registry.js.map +1 -1
- package/dist/adapters/cli/runner-input.d.ts +3 -3
- package/dist/adapters/cli/runner-input.d.ts.map +1 -1
- package/dist/adapters/cli/runner-input.js +4 -3
- package/dist/adapters/cli/runner-input.js.map +1 -1
- package/dist/adapters/cli/shared-hints.d.ts +3 -1
- package/dist/adapters/cli/shared-hints.d.ts.map +1 -1
- package/dist/adapters/cli/shared-hints.js +51 -30
- package/dist/adapters/cli/shared-hints.js.map +1 -1
- package/dist/adapters/cli/types.d.ts +33 -4
- package/dist/adapters/cli/types.d.ts.map +1 -1
- package/dist/bot-registry.d.ts +42 -0
- package/dist/bot-registry.d.ts.map +1 -1
- package/dist/bot-registry.js +54 -4
- package/dist/bot-registry.js.map +1 -1
- package/dist/cli/arg-utils.d.ts +5 -0
- package/dist/cli/arg-utils.d.ts.map +1 -1
- package/dist/cli/arg-utils.js +7 -0
- package/dist/cli/arg-utils.js.map +1 -1
- package/dist/cli/bots-list-output.d.ts +130 -2
- package/dist/cli/bots-list-output.d.ts.map +1 -1
- package/dist/cli/bots-list-output.js +173 -27
- package/dist/cli/bots-list-output.js.map +1 -1
- package/dist/cli/daemon-lifecycle-env.d.ts +10 -3
- package/dist/cli/daemon-lifecycle-env.d.ts.map +1 -1
- package/dist/cli/daemon-lifecycle-env.js +38 -11
- package/dist/cli/daemon-lifecycle-env.js.map +1 -1
- package/dist/cli/dashboard-command.d.ts +15 -0
- package/dist/cli/dashboard-command.d.ts.map +1 -1
- package/dist/cli/dashboard-command.js +22 -0
- package/dist/cli/dashboard-command.js.map +1 -1
- package/dist/cli/log-tail.d.ts +54 -0
- package/dist/cli/log-tail.d.ts.map +1 -0
- package/dist/cli/log-tail.js +167 -0
- package/dist/cli/log-tail.js.map +1 -0
- package/dist/cli/pm2-env.d.ts +16 -0
- package/dist/cli/pm2-env.d.ts.map +1 -0
- package/dist/cli/pm2-env.js +63 -0
- package/dist/cli/pm2-env.js.map +1 -0
- package/dist/cli/pm2-fleet-lock.d.ts +26 -0
- package/dist/cli/pm2-fleet-lock.d.ts.map +1 -0
- package/dist/cli/pm2-fleet-lock.js +50 -0
- package/dist/cli/pm2-fleet-lock.js.map +1 -0
- package/dist/cli/pm2-god-retirement.d.ts +59 -0
- package/dist/cli/pm2-god-retirement.d.ts.map +1 -0
- package/dist/cli/pm2-god-retirement.js +95 -0
- package/dist/cli/pm2-god-retirement.js.map +1 -0
- package/dist/cli/session-list-wake.d.ts +41 -0
- package/dist/cli/session-list-wake.d.ts.map +1 -0
- package/dist/cli/session-list-wake.js +102 -0
- package/dist/cli/session-list-wake.js.map +1 -0
- package/dist/cli/workflow.d.ts.map +1 -1
- package/dist/cli/workflow.js +26 -0
- package/dist/cli/workflow.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +941 -118
- package/dist/cli.js.map +1 -1
- package/dist/codex-app-runner.js +261 -49
- package/dist/codex-app-runner.js.map +1 -1
- package/dist/core/cli-runtime-update.d.ts +8 -0
- package/dist/core/cli-runtime-update.d.ts.map +1 -1
- package/dist/core/cli-runtime-update.js +127 -8
- package/dist/core/cli-runtime-update.js.map +1 -1
- package/dist/core/close-residual.d.ts +64 -0
- package/dist/core/close-residual.d.ts.map +1 -0
- package/dist/core/close-residual.js +93 -0
- package/dist/core/close-residual.js.map +1 -0
- package/dist/core/closed-session-card.d.ts.map +1 -1
- package/dist/core/closed-session-card.js +11 -3
- package/dist/core/closed-session-card.js.map +1 -1
- package/dist/core/command-discovery.d.ts.map +1 -1
- package/dist/core/command-discovery.js +4 -1
- package/dist/core/command-discovery.js.map +1 -1
- package/dist/core/command-handler.d.ts.map +1 -1
- package/dist/core/command-handler.js +129 -27
- package/dist/core/command-handler.js.map +1 -1
- package/dist/core/cost-calculator.d.ts +3 -1
- package/dist/core/cost-calculator.d.ts.map +1 -1
- package/dist/core/cost-calculator.js +230 -1
- package/dist/core/cost-calculator.js.map +1 -1
- package/dist/core/daemon-background-close.d.ts +19 -0
- package/dist/core/daemon-background-close.d.ts.map +1 -0
- package/dist/core/daemon-background-close.js +39 -0
- package/dist/core/daemon-background-close.js.map +1 -0
- package/dist/core/dashboard-command/overview.d.ts +4 -0
- package/dist/core/dashboard-command/overview.d.ts.map +1 -1
- package/dist/core/dashboard-command/overview.js +8 -1
- package/dist/core/dashboard-command/overview.js.map +1 -1
- package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
- package/dist/core/dashboard-ipc-server.js +415 -42
- package/dist/core/dashboard-ipc-server.js.map +1 -1
- package/dist/core/dashboard-rows.d.ts +4 -0
- package/dist/core/dashboard-rows.d.ts.map +1 -1
- package/dist/core/dashboard-rows.js +3 -0
- package/dist/core/dashboard-rows.js.map +1 -1
- package/dist/core/dashboard-url.d.ts +37 -0
- package/dist/core/dashboard-url.d.ts.map +1 -1
- package/dist/core/dashboard-url.js +71 -0
- package/dist/core/dashboard-url.js.map +1 -1
- package/dist/core/deferred-schedule-settlement.d.ts +15 -1
- package/dist/core/deferred-schedule-settlement.d.ts.map +1 -1
- package/dist/core/deferred-schedule-settlement.js +4 -1
- package/dist/core/deferred-schedule-settlement.js.map +1 -1
- package/dist/core/device-isolation-daemon.d.ts +91 -1
- package/dist/core/device-isolation-daemon.d.ts.map +1 -1
- package/dist/core/device-isolation-daemon.js +346 -2
- package/dist/core/device-isolation-daemon.js.map +1 -1
- package/dist/core/local-terminal-opener.d.ts.map +1 -1
- package/dist/core/local-terminal-opener.js +5 -1
- package/dist/core/local-terminal-opener.js.map +1 -1
- package/dist/core/loopback-target.d.ts +14 -0
- package/dist/core/loopback-target.d.ts.map +1 -0
- package/dist/core/loopback-target.js +20 -0
- package/dist/core/loopback-target.js.map +1 -0
- package/dist/core/maintenance.d.ts.map +1 -1
- package/dist/core/maintenance.js +16 -2
- package/dist/core/maintenance.js.map +1 -1
- package/dist/core/mojo-containment-command.d.ts +20 -0
- package/dist/core/mojo-containment-command.d.ts.map +1 -0
- package/dist/core/mojo-containment-command.js +266 -0
- package/dist/core/mojo-containment-command.js.map +1 -0
- package/dist/core/mojo-containment.d.ts +575 -0
- package/dist/core/mojo-containment.d.ts.map +1 -0
- package/dist/core/mojo-containment.js +1158 -0
- package/dist/core/mojo-containment.js.map +1 -0
- package/dist/core/mojo-launcher-env-quarantine.d.ts +30 -0
- package/dist/core/mojo-launcher-env-quarantine.d.ts.map +1 -0
- package/dist/core/mojo-launcher-env-quarantine.js +175 -0
- package/dist/core/mojo-launcher-env-quarantine.js.map +1 -0
- package/dist/core/mojo-session-identity.d.ts +3 -0
- package/dist/core/mojo-session-identity.d.ts.map +1 -0
- package/dist/core/mojo-session-identity.js +126 -0
- package/dist/core/mojo-session-identity.js.map +1 -0
- package/dist/core/persistent-backend.d.ts +32 -8
- package/dist/core/persistent-backend.d.ts.map +1 -1
- package/dist/core/persistent-backend.js +55 -17
- package/dist/core/persistent-backend.js.map +1 -1
- package/dist/core/plugins/mcp/gateway.d.ts +14 -1
- package/dist/core/plugins/mcp/gateway.d.ts.map +1 -1
- package/dist/core/plugins/mcp/gateway.js +70 -3
- package/dist/core/plugins/mcp/gateway.js.map +1 -1
- package/dist/core/plugins/mcp/host.d.ts +2 -0
- package/dist/core/plugins/mcp/host.d.ts.map +1 -1
- package/dist/core/plugins/mcp/host.js +2 -0
- package/dist/core/plugins/mcp/host.js.map +1 -1
- package/dist/core/plugins/pm2.d.ts.map +1 -1
- package/dist/core/plugins/pm2.js +16 -3
- package/dist/core/plugins/pm2.js.map +1 -1
- package/dist/core/plugins/service-manager.d.ts +9 -0
- package/dist/core/plugins/service-manager.d.ts.map +1 -1
- package/dist/core/plugins/service-manager.js +33 -16
- package/dist/core/plugins/service-manager.js.map +1 -1
- package/dist/core/preview-port-owner.d.ts +109 -0
- package/dist/core/preview-port-owner.d.ts.map +1 -0
- package/dist/core/preview-port-owner.js +320 -0
- package/dist/core/preview-port-owner.js.map +1 -0
- package/dist/core/remote-cli-ids.d.ts +5 -0
- package/dist/core/remote-cli-ids.d.ts.map +1 -0
- package/dist/core/remote-cli-ids.js +24 -0
- package/dist/core/remote-cli-ids.js.map +1 -0
- package/dist/core/{riff-shutdown-detach.d.ts → remote-shutdown-detach.d.ts} +40 -39
- package/dist/core/remote-shutdown-detach.d.ts.map +1 -0
- package/dist/core/{riff-shutdown-detach.js → remote-shutdown-detach.js} +85 -83
- package/dist/core/remote-shutdown-detach.js.map +1 -0
- package/dist/core/{riff-worker-shutdown-readiness.d.ts → remote-worker-shutdown-readiness.d.ts} +5 -4
- package/dist/core/remote-worker-shutdown-readiness.d.ts.map +1 -0
- package/dist/core/{riff-worker-shutdown-readiness.js → remote-worker-shutdown-readiness.js} +4 -3
- package/dist/core/remote-worker-shutdown-readiness.js.map +1 -0
- package/dist/core/reply-target.d.ts +16 -1
- package/dist/core/reply-target.d.ts.map +1 -1
- package/dist/core/reply-target.js +61 -0
- package/dist/core/reply-target.js.map +1 -1
- package/dist/core/session-group-birth.d.ts +26 -0
- package/dist/core/session-group-birth.d.ts.map +1 -1
- package/dist/core/session-group-birth.js +81 -4
- package/dist/core/session-group-birth.js.map +1 -1
- package/dist/core/session-manager.d.ts +15 -2
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +259 -5
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/session-model.d.ts +46 -0
- package/dist/core/session-model.d.ts.map +1 -0
- package/dist/core/session-model.js +42 -0
- package/dist/core/session-model.js.map +1 -0
- package/dist/core/session-owner-reminder.d.ts +63 -0
- package/dist/core/session-owner-reminder.d.ts.map +1 -0
- package/dist/core/session-owner-reminder.js +196 -0
- package/dist/core/session-owner-reminder.js.map +1 -0
- package/dist/core/session-preview-registry.d.ts +53 -0
- package/dist/core/session-preview-registry.d.ts.map +1 -0
- package/dist/core/session-preview-registry.js +82 -0
- package/dist/core/session-preview-registry.js.map +1 -0
- package/dist/core/session-preview.d.ts +114 -0
- package/dist/core/session-preview.d.ts.map +1 -0
- package/dist/core/session-preview.js +184 -0
- package/dist/core/session-preview.js.map +1 -0
- package/dist/core/session-rename.d.ts.map +1 -1
- package/dist/core/session-rename.js +5 -3
- package/dist/core/session-rename.js.map +1 -1
- package/dist/core/session-wake-deadline.d.ts +5 -0
- package/dist/core/session-wake-deadline.d.ts.map +1 -0
- package/dist/core/session-wake-deadline.js +13 -0
- package/dist/core/session-wake-deadline.js.map +1 -0
- package/dist/core/shutdown-budgets.d.ts +7 -6
- package/dist/core/shutdown-budgets.d.ts.map +1 -1
- package/dist/core/shutdown-budgets.js +11 -10
- package/dist/core/shutdown-budgets.js.map +1 -1
- package/dist/core/skills/frontmatter.d.ts.map +1 -1
- package/dist/core/skills/frontmatter.js +6 -1
- package/dist/core/skills/frontmatter.js.map +1 -1
- package/dist/core/terminal-control-grant.d.ts +74 -0
- package/dist/core/terminal-control-grant.d.ts.map +1 -0
- package/dist/core/terminal-control-grant.js +183 -0
- package/dist/core/terminal-control-grant.js.map +1 -0
- package/dist/core/terminal-proxy.d.ts.map +1 -1
- package/dist/core/terminal-proxy.js +4 -5
- package/dist/core/terminal-proxy.js.map +1 -1
- package/dist/core/terminal-write-auth.d.ts +4 -11
- package/dist/core/terminal-write-auth.d.ts.map +1 -1
- package/dist/core/terminal-write-auth.js +19 -16
- 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 +36 -29
- package/dist/core/trigger-session.js.map +1 -1
- package/dist/core/types.d.ts +88 -10
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js +9 -7
- package/dist/core/types.js.map +1 -1
- package/dist/core/workbench-link.d.ts +81 -0
- package/dist/core/workbench-link.d.ts.map +1 -0
- package/dist/core/workbench-link.js +109 -0
- package/dist/core/workbench-link.js.map +1 -0
- package/dist/core/worker-pool.d.ts +312 -11
- package/dist/core/worker-pool.d.ts.map +1 -1
- package/dist/core/worker-pool.js +2515 -217
- package/dist/core/worker-pool.js.map +1 -1
- package/dist/daemon.d.ts +6 -0
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +447 -116
- package/dist/daemon.js.map +1 -1
- package/dist/dashboard/aggregator.d.ts +33 -3
- package/dist/dashboard/aggregator.d.ts.map +1 -1
- package/dist/dashboard/aggregator.js +93 -17
- package/dist/dashboard/aggregator.js.map +1 -1
- package/dist/dashboard/auth-session-connections.d.ts +59 -0
- package/dist/dashboard/auth-session-connections.d.ts.map +1 -0
- package/dist/dashboard/auth-session-connections.js +109 -0
- package/dist/dashboard/auth-session-connections.js.map +1 -0
- package/dist/dashboard/auth.d.ts +99 -1
- package/dist/dashboard/auth.d.ts.map +1 -1
- package/dist/dashboard/auth.js +176 -64
- package/dist/dashboard/auth.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 +4 -0
- package/dist/dashboard/bot-payload.js.map +1 -1
- package/dist/dashboard/control-audit.d.ts +112 -0
- package/dist/dashboard/control-audit.d.ts.map +1 -0
- package/dist/dashboard/control-audit.js +195 -0
- package/dist/dashboard/control-audit.js.map +1 -0
- package/dist/dashboard/control-csrf.d.ts +123 -0
- package/dist/dashboard/control-csrf.d.ts.map +1 -0
- package/dist/dashboard/control-csrf.js +392 -0
- package/dist/dashboard/control-csrf.js.map +1 -0
- package/dist/dashboard/daemon-reconcile.d.ts +26 -0
- package/dist/dashboard/daemon-reconcile.d.ts.map +1 -0
- package/dist/dashboard/daemon-reconcile.js +46 -0
- package/dist/dashboard/daemon-reconcile.js.map +1 -0
- package/dist/dashboard/debug-terminal.d.ts.map +1 -1
- package/dist/dashboard/debug-terminal.js +18 -4
- package/dist/dashboard/debug-terminal.js.map +1 -1
- package/dist/dashboard/events-sse.d.ts +60 -0
- package/dist/dashboard/events-sse.d.ts.map +1 -0
- package/dist/dashboard/events-sse.js +63 -0
- package/dist/dashboard/events-sse.js.map +1 -0
- package/dist/dashboard/h5-auth.d.ts +256 -0
- package/dist/dashboard/h5-auth.d.ts.map +1 -0
- package/dist/dashboard/h5-auth.js +825 -0
- package/dist/dashboard/h5-auth.js.map +1 -0
- package/dist/dashboard/managed-spawn.d.ts +36 -0
- package/dist/dashboard/managed-spawn.d.ts.map +1 -0
- package/dist/dashboard/managed-spawn.js +169 -0
- package/dist/dashboard/managed-spawn.js.map +1 -0
- package/dist/dashboard/preview-content-capability.d.ts +41 -0
- package/dist/dashboard/preview-content-capability.d.ts.map +1 -0
- package/dist/dashboard/preview-content-capability.js +127 -0
- package/dist/dashboard/preview-content-capability.js.map +1 -0
- package/dist/dashboard/preview-contract.d.ts +85 -0
- package/dist/dashboard/preview-contract.d.ts.map +1 -0
- package/dist/dashboard/preview-contract.js +198 -0
- package/dist/dashboard/preview-contract.js.map +1 -0
- package/dist/dashboard/preview-guard-page.d.ts +52 -0
- package/dist/dashboard/preview-guard-page.d.ts.map +1 -0
- package/dist/dashboard/preview-guard-page.js +162 -0
- package/dist/dashboard/preview-guard-page.js.map +1 -0
- package/dist/dashboard/preview-interaction.d.ts +57 -0
- package/dist/dashboard/preview-interaction.d.ts.map +1 -0
- package/dist/dashboard/preview-interaction.js +198 -0
- package/dist/dashboard/preview-interaction.js.map +1 -0
- package/dist/dashboard/preview-proxy.d.ts +113 -0
- package/dist/dashboard/preview-proxy.d.ts.map +1 -0
- package/dist/dashboard/preview-proxy.js +543 -0
- package/dist/dashboard/preview-proxy.js.map +1 -0
- package/dist/dashboard/public-redact.d.ts +23 -0
- package/dist/dashboard/public-redact.d.ts.map +1 -1
- package/dist/dashboard/public-redact.js +90 -21
- package/dist/dashboard/public-redact.js.map +1 -1
- package/dist/dashboard/request-identity.d.ts +77 -0
- package/dist/dashboard/request-identity.d.ts.map +1 -0
- package/dist/dashboard/request-identity.js +72 -0
- package/dist/dashboard/request-identity.js.map +1 -0
- package/dist/dashboard/session-cleanup.d.ts +5 -0
- package/dist/dashboard/session-cleanup.d.ts.map +1 -1
- package/dist/dashboard/session-cleanup.js +4 -0
- package/dist/dashboard/session-cleanup.js.map +1 -1
- package/dist/dashboard/settings-write-applier.d.ts +5 -1
- package/dist/dashboard/settings-write-applier.d.ts.map +1 -1
- package/dist/dashboard/settings-write-applier.js +14 -0
- package/dist/dashboard/settings-write-applier.js.map +1 -1
- package/dist/dashboard/standing-link.d.ts +76 -0
- package/dist/dashboard/standing-link.d.ts.map +1 -0
- package/dist/dashboard/standing-link.js +111 -0
- package/dist/dashboard/standing-link.js.map +1 -0
- package/dist/dashboard/terminal-control.d.ts +105 -0
- package/dist/dashboard/terminal-control.d.ts.map +1 -0
- package/dist/dashboard/terminal-control.js +296 -0
- package/dist/dashboard/terminal-control.js.map +1 -0
- package/dist/dashboard/terminal-front-proxy.d.ts +55 -0
- package/dist/dashboard/terminal-front-proxy.d.ts.map +1 -0
- package/dist/dashboard/terminal-front-proxy.js +397 -0
- package/dist/dashboard/terminal-front-proxy.js.map +1 -0
- package/dist/dashboard/terminal-view-capability.d.ts +97 -0
- package/dist/dashboard/terminal-view-capability.d.ts.map +1 -0
- package/dist/dashboard/terminal-view-capability.js +145 -0
- package/dist/dashboard/terminal-view-capability.js.map +1 -0
- package/dist/dashboard/web/agent-workbench-api.d.ts +59 -0
- package/dist/dashboard/web/agent-workbench-api.d.ts.map +1 -0
- package/dist/dashboard/web/agent-workbench-api.js +227 -0
- package/dist/dashboard/web/agent-workbench-api.js.map +1 -0
- package/dist/dashboard/web/agent-workbench-appearance-menu.d.ts +59 -0
- package/dist/dashboard/web/agent-workbench-appearance-menu.d.ts.map +1 -0
- package/dist/dashboard/web/agent-workbench-appearance-menu.js +195 -0
- package/dist/dashboard/web/agent-workbench-appearance-menu.js.map +1 -0
- package/dist/dashboard/web/agent-workbench-appearance.d.ts +222 -0
- package/dist/dashboard/web/agent-workbench-appearance.d.ts.map +1 -0
- package/dist/dashboard/web/agent-workbench-appearance.js +499 -0
- package/dist/dashboard/web/agent-workbench-appearance.js.map +1 -0
- package/dist/dashboard/web/agent-workbench-capabilities.d.ts +13 -0
- package/dist/dashboard/web/agent-workbench-capabilities.d.ts.map +1 -0
- package/dist/dashboard/web/agent-workbench-capabilities.js +24 -0
- package/dist/dashboard/web/agent-workbench-capabilities.js.map +1 -0
- package/dist/dashboard/web/agent-workbench-chat.d.ts +90 -0
- package/dist/dashboard/web/agent-workbench-chat.d.ts.map +1 -0
- package/dist/dashboard/web/agent-workbench-chat.js +168 -0
- package/dist/dashboard/web/agent-workbench-chat.js.map +1 -0
- package/dist/dashboard/web/agent-workbench-dock-page.d.ts +3 -0
- package/dist/dashboard/web/agent-workbench-dock-page.d.ts.map +1 -0
- package/dist/dashboard/web/agent-workbench-dock-page.js +23 -0
- package/dist/dashboard/web/agent-workbench-dock-page.js.map +1 -0
- package/dist/dashboard/web/agent-workbench-dock-view.d.ts +22 -0
- package/dist/dashboard/web/agent-workbench-dock-view.d.ts.map +1 -0
- package/dist/dashboard/web/agent-workbench-dock-view.js +76 -0
- package/dist/dashboard/web/agent-workbench-dock-view.js.map +1 -0
- package/dist/dashboard/web/agent-workbench-model.d.ts +196 -0
- package/dist/dashboard/web/agent-workbench-model.d.ts.map +1 -0
- package/dist/dashboard/web/agent-workbench-model.js +529 -0
- package/dist/dashboard/web/agent-workbench-model.js.map +1 -0
- package/dist/dashboard/web/agent-workbench-page.d.ts +3 -0
- package/dist/dashboard/web/agent-workbench-page.d.ts.map +1 -0
- package/dist/dashboard/web/agent-workbench-page.js +27 -0
- package/dist/dashboard/web/agent-workbench-page.js.map +1 -0
- package/dist/dashboard/web/agent-workbench-panes.d.ts +49 -0
- package/dist/dashboard/web/agent-workbench-panes.d.ts.map +1 -0
- package/dist/dashboard/web/agent-workbench-panes.js +468 -0
- package/dist/dashboard/web/agent-workbench-panes.js.map +1 -0
- package/dist/dashboard/web/agent-workbench-session-list.d.ts +42 -0
- package/dist/dashboard/web/agent-workbench-session-list.d.ts.map +1 -0
- package/dist/dashboard/web/agent-workbench-session-list.js +353 -0
- package/dist/dashboard/web/agent-workbench-session-list.js.map +1 -0
- package/dist/dashboard/web/agent-workbench-storage.d.ts +49 -0
- package/dist/dashboard/web/agent-workbench-storage.d.ts.map +1 -0
- package/dist/dashboard/web/agent-workbench-storage.js +197 -0
- package/dist/dashboard/web/agent-workbench-storage.js.map +1 -0
- package/dist/dashboard/web/agent-workbench-touch.d.ts +46 -0
- package/dist/dashboard/web/agent-workbench-touch.d.ts.map +1 -0
- package/dist/dashboard/web/agent-workbench-touch.js +96 -0
- package/dist/dashboard/web/agent-workbench-touch.js.map +1 -0
- package/dist/dashboard/web/agent-workbench-view.d.ts +34 -0
- package/dist/dashboard/web/agent-workbench-view.d.ts.map +1 -0
- package/dist/dashboard/web/agent-workbench-view.js +439 -0
- package/dist/dashboard/web/agent-workbench-view.js.map +1 -0
- package/dist/dashboard/web/app.d.ts.map +1 -1
- package/dist/dashboard/web/app.js +63 -2
- package/dist/dashboard/web/app.js.map +1 -1
- package/dist/dashboard/web/bot-defaults-page.d.ts +18 -0
- package/dist/dashboard/web/bot-defaults-page.d.ts.map +1 -1
- package/dist/dashboard/web/bot-defaults-page.js +362 -36
- package/dist/dashboard/web/bot-defaults-page.js.map +1 -1
- package/dist/dashboard/web/bot-defaults.d.ts +8 -0
- package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
- package/dist/dashboard/web/bot-defaults.js.map +1 -1
- package/dist/dashboard/web/control-csrf.d.ts +4 -0
- package/dist/dashboard/web/control-csrf.d.ts.map +1 -0
- package/dist/dashboard/web/control-csrf.js +26 -0
- package/dist/dashboard/web/control-csrf.js.map +1 -0
- package/dist/dashboard/web/dashboard-routes.d.ts.map +1 -1
- package/dist/dashboard/web/dashboard-routes.js +2 -0
- package/dist/dashboard/web/dashboard-routes.js.map +1 -1
- package/dist/dashboard/web/groups-page.d.ts.map +1 -1
- package/dist/dashboard/web/groups-page.js +11 -2
- package/dist/dashboard/web/groups-page.js.map +1 -1
- package/dist/dashboard/web/i18n.d.ts.map +1 -1
- package/dist/dashboard/web/i18n.js +140 -6
- package/dist/dashboard/web/i18n.js.map +1 -1
- package/dist/dashboard/web/overview-page.d.ts.map +1 -1
- package/dist/dashboard/web/overview-page.js +3 -2
- package/dist/dashboard/web/overview-page.js.map +1 -1
- package/dist/dashboard/web/sessions-page.d.ts.map +1 -1
- package/dist/dashboard/web/sessions-page.js +41 -1
- package/dist/dashboard/web/sessions-page.js.map +1 -1
- package/dist/dashboard/web/sessions.d.ts.map +1 -1
- package/dist/dashboard/web/sessions.js +5 -1
- package/dist/dashboard/web/sessions.js.map +1 -1
- package/dist/dashboard/web/settings-page.d.ts +3 -0
- package/dist/dashboard/web/settings-page.d.ts.map +1 -1
- package/dist/dashboard/web/settings-page.js +56 -10
- package/dist/dashboard/web/settings-page.js.map +1 -1
- package/dist/dashboard/web/skills-page.d.ts.map +1 -1
- package/dist/dashboard/web/skills-page.js +4 -2
- package/dist/dashboard/web/skills-page.js.map +1 -1
- package/dist/dashboard/web/store.d.ts +27 -2
- package/dist/dashboard/web/store.d.ts.map +1 -1
- package/dist/dashboard/web/store.js +43 -4
- package/dist/dashboard/web/store.js.map +1 -1
- package/dist/dashboard/web/ui.d.ts +3 -0
- package/dist/dashboard/web/ui.d.ts.map +1 -1
- package/dist/dashboard/web/ui.js +11 -0
- package/dist/dashboard/web/ui.js.map +1 -1
- package/dist/dashboard/web/update-action.d.ts +12 -0
- package/dist/dashboard/web/update-action.d.ts.map +1 -1
- package/dist/dashboard/web/update-action.js +11 -0
- package/dist/dashboard/web/update-action.js.map +1 -1
- package/dist/dashboard/workbench-doctor.d.ts +114 -0
- package/dist/dashboard/workbench-doctor.d.ts.map +1 -0
- package/dist/dashboard/workbench-doctor.js +620 -0
- package/dist/dashboard/workbench-doctor.js.map +1 -0
- package/dist/dashboard/workbench-ticket.d.ts +165 -0
- package/dist/dashboard/workbench-ticket.d.ts.map +1 -0
- package/dist/dashboard/workbench-ticket.js +495 -0
- package/dist/dashboard/workbench-ticket.js.map +1 -0
- package/dist/dashboard-web/app.js +1 -1
- package/dist/dashboard-web/chunks/agent-workbench-dock-page-IKAC5CFL.js +1 -0
- package/dist/dashboard-web/chunks/agent-workbench-page-M53UN5EB.js +1 -0
- package/dist/dashboard-web/chunks/bot-defaults-page-YKE5SDZF.js +10 -0
- package/dist/dashboard-web/chunks/chunk-22WIADUT.js +2 -0
- package/dist/dashboard-web/chunks/chunk-24ONYQMR.js +1 -0
- package/dist/dashboard-web/chunks/{chunk-6O7KXFD2.js → chunk-2W6SCN3M.js} +1 -1
- package/dist/dashboard-web/chunks/chunk-4XNJX6B3.js +1 -0
- package/dist/dashboard-web/chunks/{chunk-Q2MT4VWH.js → chunk-F6KNPF4R.js} +11 -9
- package/dist/dashboard-web/chunks/chunk-FXW3V4JR.js +1 -0
- package/dist/dashboard-web/chunks/chunk-G5ZS2YUS.js +1 -0
- package/dist/dashboard-web/chunks/chunk-KTQ7B6ZR.js +1 -0
- package/dist/dashboard-web/chunks/{chunk-U3ZFSO7E.js → chunk-NACP7BMR.js} +1 -1
- package/dist/dashboard-web/chunks/{connectors-page-2KZO5KSJ.js → connectors-page-VOISXXII.js} +1 -1
- package/dist/dashboard-web/chunks/{feedback-page-CLVTOLLO.js → feedback-page-RN6HRZMS.js} +1 -1
- package/dist/dashboard-web/chunks/groups-page-UQT6DCB7.js +8 -0
- package/dist/dashboard-web/chunks/{insights-page-4RPU2CRU.js → insights-page-HJFDYR5N.js} +3 -3
- package/dist/dashboard-web/chunks/{monitor-room-OTKQ2CXK.js → monitor-room-V7PCP47V.js} +1 -1
- package/dist/dashboard-web/chunks/{monitoring-page-IJPUB6JT.js → monitoring-page-NKEKVQAP.js} +1 -1
- package/dist/dashboard-web/chunks/{office-page-QSR3J44Y.js → office-page-GVJF73XW.js} +1 -1
- package/dist/dashboard-web/chunks/overview-page-KHLV6VTI.js +1 -0
- package/dist/dashboard-web/chunks/{plugin-page-VEGTCEAS.js → plugin-page-J3OF5IY4.js} +1 -1
- package/dist/dashboard-web/chunks/{roles-page-6NWJMOB5.js → roles-page-WQIRSOLZ.js} +1 -1
- package/dist/dashboard-web/chunks/{schedules-page-6I3LNXD4.js → schedules-page-J2DEY2X6.js} +1 -1
- package/dist/dashboard-web/chunks/sessions-page-7BQFEA5P.js +8 -0
- package/dist/dashboard-web/chunks/settings-page-BJEIOFJH.js +2 -0
- package/dist/dashboard-web/chunks/{skills-page-M2Q6L5CB.js → skills-page-HGIFR3IU.js} +2 -2
- package/dist/dashboard-web/chunks/{team-federation-page-7I7FDE3B.js → team-federation-page-MR5ZPL4B.js} +1 -1
- package/dist/dashboard-web/chunks/{v3-page-HQXRL65L.js → v3-page-GBR5JWPE.js} +2 -2
- package/dist/dashboard-web/chunks/{whiteboards-page-LQKC4TAZ.js → whiteboards-page-QAZM4BN3.js} +1 -1
- package/dist/dashboard-web/index.html +8 -1
- package/dist/dashboard-web/style.css +2006 -10
- package/dist/dashboard.js +1017 -264
- package/dist/dashboard.js.map +1 -1
- package/dist/dsh-runner.js +8 -1
- package/dist/dsh-runner.js.map +1 -1
- package/dist/global-config.d.ts +30 -0
- package/dist/global-config.d.ts.map +1 -1
- package/dist/global-config.js +33 -0
- package/dist/global-config.js.map +1 -1
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +46 -27
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/zh.d.ts.map +1 -1
- package/dist/i18n/zh.js +43 -27
- package/dist/i18n/zh.js.map +1 -1
- package/dist/im/lark/card-builder.d.ts +1 -1
- package/dist/im/lark/card-builder.d.ts.map +1 -1
- package/dist/im/lark/card-builder.js +22 -6
- package/dist/im/lark/card-builder.js.map +1 -1
- package/dist/im/lark/card-handler.d.ts.map +1 -1
- package/dist/im/lark/card-handler.js +118 -24
- package/dist/im/lark/card-handler.js.map +1 -1
- package/dist/im/lark/client.d.ts +15 -0
- package/dist/im/lark/client.d.ts.map +1 -1
- package/dist/im/lark/client.js +30 -0
- package/dist/im/lark/client.js.map +1 -1
- package/dist/im/lark/event-dispatcher.d.ts +48 -0
- package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
- package/dist/im/lark/event-dispatcher.js +138 -41
- package/dist/im/lark/event-dispatcher.js.map +1 -1
- package/dist/im/lark/identity-cache.d.ts +14 -6
- package/dist/im/lark/identity-cache.d.ts.map +1 -1
- package/dist/im/lark/identity-cache.js +69 -26
- package/dist/im/lark/identity-cache.js.map +1 -1
- package/dist/im/lark/lark-hosts.d.ts +13 -0
- package/dist/im/lark/lark-hosts.d.ts.map +1 -1
- package/dist/im/lark/lark-hosts.js +16 -0
- package/dist/im/lark/lark-hosts.js.map +1 -1
- package/dist/im/lark/overview-card.d.ts +22 -0
- package/dist/im/lark/overview-card.d.ts.map +1 -1
- package/dist/im/lark/overview-card.js +53 -3
- package/dist/im/lark/overview-card.js.map +1 -1
- package/dist/im/lark/sessions-card.d.ts.map +1 -1
- package/dist/im/lark/sessions-card.js +26 -3
- package/dist/im/lark/sessions-card.js.map +1 -1
- package/dist/im/lark/workflow-slash-command.d.ts +6 -0
- package/dist/im/lark/workflow-slash-command.d.ts.map +1 -1
- package/dist/im/lark/workflow-slash-command.js +7 -0
- package/dist/im/lark/workflow-slash-command.js.map +1 -1
- package/dist/index-daemon.js +27 -4
- package/dist/index-daemon.js.map +1 -1
- package/dist/index-dashboard.d.ts +3 -0
- package/dist/index-dashboard.d.ts.map +1 -0
- package/dist/index-dashboard.js +94 -0
- package/dist/index-dashboard.js.map +1 -0
- package/dist/platform/team-agents-client.d.ts +199 -0
- package/dist/platform/team-agents-client.d.ts.map +1 -0
- package/dist/platform/team-agents-client.js +263 -0
- package/dist/platform/team-agents-client.js.map +1 -0
- package/dist/platform/tunnel-client.d.ts +6 -0
- package/dist/platform/tunnel-client.d.ts.map +1 -1
- package/dist/platform/tunnel-client.js.map +1 -1
- package/dist/services/async-trigger-state.d.ts +7 -3
- package/dist/services/async-trigger-state.d.ts.map +1 -1
- package/dist/services/async-trigger-state.js +28 -0
- package/dist/services/async-trigger-state.js.map +1 -1
- package/dist/services/async-trigger-store.d.ts +13 -4
- package/dist/services/async-trigger-store.d.ts.map +1 -1
- package/dist/services/async-trigger-store.js +55 -8
- package/dist/services/async-trigger-store.js.map +1 -1
- package/dist/services/bot-config-store.d.ts +1 -11
- package/dist/services/bot-config-store.d.ts.map +1 -1
- package/dist/services/bot-config-store.js +27 -3
- package/dist/services/bot-config-store.js.map +1 -1
- package/dist/services/bot-profile-store.d.ts +17 -2
- package/dist/services/bot-profile-store.d.ts.map +1 -1
- package/dist/services/bot-profile-store.js +93 -12
- package/dist/services/bot-profile-store.js.map +1 -1
- package/dist/services/bridge-fallback-gate.d.ts +15 -7
- package/dist/services/bridge-fallback-gate.d.ts.map +1 -1
- package/dist/services/bridge-fallback-gate.js +43 -16
- package/dist/services/bridge-fallback-gate.js.map +1 -1
- package/dist/services/bridge-turn-queue.d.ts +10 -1
- package/dist/services/bridge-turn-queue.d.ts.map +1 -1
- package/dist/services/bridge-turn-queue.js +29 -9
- package/dist/services/bridge-turn-queue.js.map +1 -1
- package/dist/services/claude-transcript.d.ts +19 -0
- package/dist/services/claude-transcript.d.ts.map +1 -1
- package/dist/services/claude-transcript.js +57 -5
- package/dist/services/claude-transcript.js.map +1 -1
- package/dist/services/codex-app-threads.d.ts +2 -0
- package/dist/services/codex-app-threads.d.ts.map +1 -1
- package/dist/services/codex-app-threads.js +1 -1
- package/dist/services/codex-app-threads.js.map +1 -1
- package/dist/services/codex-reasoning-effort.d.ts +8 -0
- package/dist/services/codex-reasoning-effort.d.ts.map +1 -1
- package/dist/services/codex-reasoning-effort.js +24 -0
- package/dist/services/codex-reasoning-effort.js.map +1 -1
- package/dist/services/cursor-resume-policy.d.ts.map +1 -1
- package/dist/services/cursor-resume-policy.js +15 -3
- package/dist/services/cursor-resume-policy.js.map +1 -1
- package/dist/services/open-platform-rename.d.ts.map +1 -1
- package/dist/services/open-platform-rename.js +3 -2
- package/dist/services/open-platform-rename.js.map +1 -1
- package/dist/services/ordinary-turn-recovery.d.ts +76 -0
- package/dist/services/ordinary-turn-recovery.d.ts.map +1 -0
- package/dist/services/ordinary-turn-recovery.js +349 -0
- package/dist/services/ordinary-turn-recovery.js.map +1 -0
- package/dist/services/peer-cross-ref-store.d.ts +8 -0
- package/dist/services/peer-cross-ref-store.d.ts.map +1 -0
- package/dist/services/peer-cross-ref-store.js +95 -0
- package/dist/services/peer-cross-ref-store.js.map +1 -0
- package/dist/services/pi-transcript.d.ts.map +1 -1
- package/dist/services/pi-transcript.js +10 -6
- package/dist/services/pi-transcript.js.map +1 -1
- package/dist/services/resume-fresh-policy.d.ts +17 -0
- package/dist/services/resume-fresh-policy.d.ts.map +1 -0
- package/dist/services/resume-fresh-policy.js +24 -0
- package/dist/services/resume-fresh-policy.js.map +1 -0
- package/dist/services/session-group-title.d.ts +1 -0
- package/dist/services/session-group-title.d.ts.map +1 -1
- package/dist/services/session-group-title.js +16 -1
- package/dist/services/session-group-title.js.map +1 -1
- package/dist/services/session-groups-store.d.ts +30 -0
- package/dist/services/session-groups-store.d.ts.map +1 -1
- package/dist/services/session-groups-store.js +26 -4
- package/dist/services/session-groups-store.js.map +1 -1
- package/dist/services/session-owner-notification.d.ts +9 -0
- package/dist/services/session-owner-notification.d.ts.map +1 -0
- package/dist/services/session-owner-notification.js +10 -0
- package/dist/services/session-owner-notification.js.map +1 -0
- package/dist/services/session-owner-reminder-config-store.d.ts +9 -0
- package/dist/services/session-owner-reminder-config-store.d.ts.map +1 -0
- package/dist/services/session-owner-reminder-config-store.js +24 -0
- package/dist/services/session-owner-reminder-config-store.js.map +1 -0
- package/dist/services/session-owner-reminder-store.d.ts +5 -0
- package/dist/services/session-owner-reminder-store.d.ts.map +1 -0
- package/dist/services/session-owner-reminder-store.js +54 -0
- package/dist/services/session-owner-reminder-store.js.map +1 -0
- package/dist/services/session-store.d.ts +98 -21
- package/dist/services/session-store.d.ts.map +1 -1
- package/dist/services/session-store.js +406 -48
- package/dist/services/session-store.js.map +1 -1
- package/dist/services/structured-bridge-clis.d.ts +15 -2
- package/dist/services/structured-bridge-clis.d.ts.map +1 -1
- package/dist/services/structured-bridge-clis.js +16 -1
- package/dist/services/structured-bridge-clis.js.map +1 -1
- package/dist/services/transcript-resolver.d.ts +1 -1
- package/dist/services/transcript-resolver.d.ts.map +1 -1
- package/dist/services/transcript-resolver.js +6 -1
- package/dist/services/transcript-resolver.js.map +1 -1
- package/dist/setup/bot-config-editor.d.ts.map +1 -1
- package/dist/setup/bot-config-editor.js +7 -2
- package/dist/setup/bot-config-editor.js.map +1 -1
- package/dist/setup/ensure-herdr-integrations.d.ts +13 -0
- package/dist/setup/ensure-herdr-integrations.d.ts.map +1 -1
- package/dist/setup/ensure-herdr-integrations.js +21 -3
- package/dist/setup/ensure-herdr-integrations.js.map +1 -1
- package/dist/setup/open-platform-automation.d.ts +1 -0
- package/dist/setup/open-platform-automation.d.ts.map +1 -1
- package/dist/setup/open-platform-automation.js +107 -7
- package/dist/setup/open-platform-automation.js.map +1 -1
- package/dist/skills/definitions.d.ts +17 -0
- package/dist/skills/definitions.d.ts.map +1 -1
- package/dist/skills/definitions.js +78 -8
- package/dist/skills/definitions.js.map +1 -1
- package/dist/skills/injection-mode.d.ts +24 -3
- package/dist/skills/injection-mode.d.ts.map +1 -1
- package/dist/skills/injection-mode.js +64 -15
- package/dist/skills/injection-mode.js.map +1 -1
- package/dist/skills/installer.d.ts +18 -0
- package/dist/skills/installer.d.ts.map +1 -1
- package/dist/skills/installer.js +45 -1
- package/dist/skills/installer.js.map +1 -1
- package/dist/types.d.ts +222 -10
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/bot-routing.d.ts +1 -1
- package/dist/utils/bot-routing.d.ts.map +1 -1
- package/dist/utils/bot-routing.js.map +1 -1
- package/dist/utils/child-env.d.ts +92 -2
- package/dist/utils/child-env.d.ts.map +1 -1
- package/dist/utils/child-env.js +195 -1
- package/dist/utils/child-env.js.map +1 -1
- package/dist/utils/cli-usage-limit.d.ts +25 -14
- package/dist/utils/cli-usage-limit.d.ts.map +1 -1
- package/dist/utils/cli-usage-limit.js +25 -15
- package/dist/utils/cli-usage-limit.js.map +1 -1
- package/dist/utils/daemon-rejection-guard.d.ts +35 -0
- package/dist/utils/daemon-rejection-guard.d.ts.map +1 -0
- package/dist/utils/daemon-rejection-guard.js +101 -0
- package/dist/utils/daemon-rejection-guard.js.map +1 -0
- package/dist/utils/dashboard-env.d.ts +53 -0
- package/dist/utils/dashboard-env.d.ts.map +1 -0
- package/dist/utils/dashboard-env.js +116 -0
- package/dist/utils/dashboard-env.js.map +1 -0
- package/dist/utils/global-install.d.ts.map +1 -1
- package/dist/utils/global-install.js +61 -5
- package/dist/utils/global-install.js.map +1 -1
- package/dist/utils/install-diagnostics.d.ts +14 -7
- package/dist/utils/install-diagnostics.d.ts.map +1 -1
- package/dist/utils/install-diagnostics.js +20 -11
- package/dist/utils/install-diagnostics.js.map +1 -1
- package/dist/utils/local-dev-update.d.ts +132 -0
- package/dist/utils/local-dev-update.d.ts.map +1 -0
- package/dist/utils/local-dev-update.js +176 -0
- package/dist/utils/local-dev-update.js.map +1 -0
- package/dist/utils/pending-input-queue.d.ts +12 -1
- package/dist/utils/pending-input-queue.d.ts.map +1 -1
- package/dist/utils/pending-input-queue.js.map +1 -1
- package/dist/worker.js +1517 -236
- package/dist/worker.js.map +1 -1
- package/dist/workflows/definition.d.ts +68 -68
- package/dist/workflows/events/payloads.d.ts +14 -14
- package/dist/workflows/events/schema.d.ts +312 -312
- package/dist/workflows/migration/v2-run-archive-schema.d.ts +40 -40
- package/dist/workflows/v3/cli-run.d.ts.map +1 -1
- package/dist/workflows/v3/cli-run.js +7 -0
- package/dist/workflows/v3/cli-run.js.map +1 -1
- package/dist/workflows/v3/goal-cli.d.ts +3 -0
- package/dist/workflows/v3/goal-cli.d.ts.map +1 -1
- package/dist/workflows/v3/goal-cli.js +11 -0
- package/dist/workflows/v3/goal-cli.js.map +1 -1
- package/dist/workflows/v3/run-envelope.d.ts +144 -144
- package/package.json +7 -2
- package/dist/cli/pm2-god-admission.d.ts +0 -8
- package/dist/cli/pm2-god-admission.d.ts.map +0 -1
- package/dist/cli/pm2-god-admission.js +0 -25
- package/dist/cli/pm2-god-admission.js.map +0 -1
- package/dist/core/riff-shutdown-detach.d.ts.map +0 -1
- package/dist/core/riff-shutdown-detach.js.map +0 -1
- package/dist/core/riff-worker-shutdown-readiness.d.ts.map +0 -1
- package/dist/core/riff-worker-shutdown-readiness.js.map +0 -1
- package/dist/dashboard-web/chunks/bot-defaults-page-4RENHE7J.js +0 -10
- package/dist/dashboard-web/chunks/chunk-2WQDS65D.js +0 -1
- package/dist/dashboard-web/chunks/chunk-G5XGBCM6.js +0 -1
- package/dist/dashboard-web/chunks/chunk-WLIVBPJC.js +0 -1
- package/dist/dashboard-web/chunks/groups-page-HLASRTSY.js +0 -8
- package/dist/dashboard-web/chunks/overview-page-6OK63V4V.js +0 -1
- package/dist/dashboard-web/chunks/sessions-page-B2TXGJDO.js +0 -8
- package/dist/dashboard-web/chunks/settings-page-6KJADSC6.js +0 -2
|
@@ -0,0 +1,2471 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MojoBackend — API-backed SessionBackend for @byted/mojo.
|
|
3
|
+
*
|
|
4
|
+
* Implements botmux's pseudo-PTY SessionBackend surface on top of mojo's
|
|
5
|
+
* headless mode, in the same spirit as RiffBackend. Verified empirically
|
|
6
|
+
* against @byted/mojo 1.0.10 (linux-x64).
|
|
7
|
+
*
|
|
8
|
+
* ── Why not a TUI adapter (kimi/grok style) ──────────────────────────────────
|
|
9
|
+
* 1. `--yolo` / `-r` / `-c` / `--output-format` / `--timeout` / `--idle-timeout`
|
|
10
|
+
* are all "仅 -p" (headless only). Passing them without `-p` does not launch
|
|
11
|
+
* a TUI — the process just blocks on stdin until EOF.
|
|
12
|
+
* 2. mojo keeps NO local per-session transcript (`~/.mojo` holds only
|
|
13
|
+
* credentials/ memory/ skills/; session state is server-side), so the
|
|
14
|
+
* grok-style "tail updates.jsonl for turn end" bridge is impossible and only
|
|
15
|
+
* unreliable screen-scraping would remain.
|
|
16
|
+
*
|
|
17
|
+
* ── Why foreground streaming and not `--background` + polling ────────────────
|
|
18
|
+
* `--background` looks attractive (instant id, ask_user/confirm interactions,
|
|
19
|
+
* survives restarts) but it is CREATE-ONLY — verified:
|
|
20
|
+
* mojo -p --background -r <sid> → error invalid_argument "--background 不支持:--resume"
|
|
21
|
+
* mojo -p --background -c → error invalid_argument "--background 不支持:--continue"
|
|
22
|
+
* A chat bot is inherently multi-turn, so a create-only submit path would start a
|
|
23
|
+
* fresh context-less session on every IM message. Foreground `-p -r <sid>` does
|
|
24
|
+
* resume correctly (including sessions originally created by --background), and
|
|
25
|
+
* additionally gives real token-level streaming plus an exact turn boundary.
|
|
26
|
+
*
|
|
27
|
+
* The cost, made explicit so it isn't discovered in production: in foreground
|
|
28
|
+
* mode mojo AUTO-SKIPS ask_user and cancels the turn —
|
|
29
|
+
* warnings: ["agent 的提问(ask-user)在非交互模式下被自动跳过"]
|
|
30
|
+
* error: {code:"cancelled"}, exit code 1
|
|
31
|
+
* We detect exactly that and tell the user to supply the missing detail, instead
|
|
32
|
+
* of leaving them with a silently empty turn. See ASK_USER_SKIPPED_RE.
|
|
33
|
+
*
|
|
34
|
+
* ── Event stream (`-p --output-format stream-json --include-partial`) ────────
|
|
35
|
+
* {type:"system", subtype:"init", session_id, model} ← id available up-front
|
|
36
|
+
* {type:"text_delta", text} ← incremental
|
|
37
|
+
* {type:"text", text} ← whole segment
|
|
38
|
+
* {type:"tool_call", id, name, input}
|
|
39
|
+
* {type:"result", status, result, session_id, duration_ms, num_tool_calls,
|
|
40
|
+
* warnings, error} ← exact turn boundary
|
|
41
|
+
*
|
|
42
|
+
* NOTE: the foreground envelope is NOT the same shape as the `--background` /
|
|
43
|
+
* `session.*` schema-v1 envelope (which additionally carries schema_version,
|
|
44
|
+
* operation, state, turn_id, result_complete, interaction). Never assume `state`
|
|
45
|
+
* or `result_complete` exists on a foreground result. Also `error` is an OBJECT
|
|
46
|
+
* ({code, message, retryable}), not a string.
|
|
47
|
+
*/
|
|
48
|
+
import { randomBytes } from 'node:crypto';
|
|
49
|
+
import { spawn as spawnProcess } from 'node:child_process';
|
|
50
|
+
import { classifyUnprovenTermination } from './destroy-result.js';
|
|
51
|
+
import { acquireContainmentHandle, containmentHandleKey, containmentHandles, killPreparedBoundary, prepareContainmentBoundary, proveContainmentQuiescent, recordContainmentHandle, releaseContainmentHandle, strongHandleFromPreparedBoundary, containedProvenQuiescence, containmentQuiescence, weakHandleRootStillOriginal, } from '../../core/mojo-containment.js';
|
|
52
|
+
import { MOJO_TREE_NONCE_ENV, terminationOutcomeFromQuiescence, quiescenceFromScan, readProcessIdentity, scanMojoTree, signalTurnTreeGroup, } from './mojo-process-tree.js';
|
|
53
|
+
import { accessSync, constants as fsConstants, writeFileSync } from 'node:fs';
|
|
54
|
+
import { delimiter, join } from 'node:path';
|
|
55
|
+
import { locateOnPath } from '../cli/registry.js';
|
|
56
|
+
import { buildWrappedLaunch } from '../../setup/cli-selection.js';
|
|
57
|
+
import { logger } from '../../utils/logger.js';
|
|
58
|
+
import { cleanupMojoIsolatedWorkspace, ensureMojoIsolatedWorkspace, } from './mojo-isolated-workspace.js';
|
|
59
|
+
import { buildEffectiveChildEnv, deriveMojoExecutionMode, findReservedMojoCliFlags, mojoRemoteProofFailureReason, isMojoRemoteGone, MOJO_CANONICAL_JWT_ENV_KEY, MOJO_CONTROL_ENV_KEYS, } from './mojo-types.js';
|
|
60
|
+
import { MOJO_CLI_TIMEOUT_MS, MOJO_DESTROY_SETTLE_MS, MOJO_CHILD_TERMINATION_PROOF_MS } from './mojo-budgets.js';
|
|
61
|
+
/** mojo silently drops an agent clarifying question in headless mode and marks
|
|
62
|
+
* the turn cancelled. Matching this is the difference between a helpful nudge
|
|
63
|
+
* and a mystifying empty reply. */
|
|
64
|
+
const ASK_USER_SKIPPED_RE = /ask-?user|提问.*被自动跳过/i;
|
|
65
|
+
/**
|
|
66
|
+
* The server-side session stays RUNNING for a short window AFTER the foreground
|
|
67
|
+
* process has already emitted its `result` event and exited. Verified: firing the
|
|
68
|
+
* next turn immediately fails with
|
|
69
|
+
* mojo: 会话 <sid> 正在执行中(RUNNING),稍后再试 (exit 1)
|
|
70
|
+
* A human typing in IM rarely hits this, but botmux flushes queued follow-ups the
|
|
71
|
+
* instant a turn boundary fires — so it hits reliably there. Retry with backoff
|
|
72
|
+
* instead of surfacing a spurious error to the user.
|
|
73
|
+
*/
|
|
74
|
+
const SESSION_BUSY_RE = /正在执行中|RUNNING)|already running/i;
|
|
75
|
+
/**
|
|
76
|
+
* A resumed session id can stop being resumable (server-side GC, expiry, or a
|
|
77
|
+
* session created under a different workspace/agent). Without handling this the
|
|
78
|
+
* lineage is a permanent trap: every later message re-sends the same dead `-r
|
|
79
|
+
* <sid>` and fails forever.
|
|
80
|
+
*
|
|
81
|
+
* ⚠️ NOT EMPIRICALLY VERIFIED — @byted/mojo was not installable in the porting
|
|
82
|
+
* environment (npm 404, internal registry only), so the exact wording is
|
|
83
|
+
* unknown. The patterns below are deliberately BROAD, and the decision is
|
|
84
|
+
* additionally gated on `-r` having actually been passed (see maybeDropLineage),
|
|
85
|
+
* so a false positive costs one lost context rather than a wedged session.
|
|
86
|
+
* Calibrate against real output in the E2E pass — see OPEN_ITEMS.md.
|
|
87
|
+
*/
|
|
88
|
+
const RESUME_DEAD_RE = /会话.*(不存在|已过期|已结束|无效|未找到)|session.*(not\s*found|expired|invalid|does not exist)|not_found|invalid_session/i;
|
|
89
|
+
const BUSY_RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000];
|
|
90
|
+
/**
|
|
91
|
+
* Pre-exec cgroup enrolment shim (P0: the spawn→enrolment window).
|
|
92
|
+
*
|
|
93
|
+
* Post-spawn migration cannot capture descendants the child forked before the
|
|
94
|
+
* parent's `cgroup.procs` write landed — cgroup v2 does not retroactively move
|
|
95
|
+
* an existing process's descendants — so the enrolment has to happen INSIDE the
|
|
96
|
+
* child, before any target code runs. `/bin/sh` writes its own pid into the
|
|
97
|
+
* prepared boundary and only then `exec`s the real launch, keeping the same
|
|
98
|
+
* pid. Between fork and the write the process executes just this shim, which
|
|
99
|
+
* forks nothing, so every descendant of the target is born enrolled.
|
|
100
|
+
*
|
|
101
|
+
* The `|| exit 97` is the handshake: an enrolment failure must NOT fall through
|
|
102
|
+
* to running a credentialed binary outside the boundary. Exit 97 is reserved —
|
|
103
|
+
* the parent maps it to a refused turn (see MOJO_ENROLL_FAILED_EXIT).
|
|
104
|
+
*
|
|
105
|
+
* Invoked as: sh -c SHIM <name> <cgroup.procs path> <bin> <args...>
|
|
106
|
+
* ($0 = name, $1 = procs path; after `shift`, "$@" = bin + args.)
|
|
107
|
+
*/
|
|
108
|
+
// NOTE: `exec "$@"` NOT `exec -- "$@"`. dash (the Linux /bin/sh) treats `exec --`
|
|
109
|
+
// as an attempt to exec a command literally named `--` and fails, so the shim
|
|
110
|
+
// would never reach mojo. The `--` guard is unneeded anyway: the bin is always a
|
|
111
|
+
// resolved ABSOLUTE path (never starts with `-`), so there is no option-injection
|
|
112
|
+
// to guard against.
|
|
113
|
+
export const MOJO_CGROUP_ENROLL_SHIM = 'echo "$$" > "$1" || exit 97; shift; exec "$@"';
|
|
114
|
+
export const MOJO_ENROLL_FAILED_EXIT = 97;
|
|
115
|
+
export class MojoBackend {
|
|
116
|
+
/** Mutable: applyLivePatch rotates credentials without a refork. */
|
|
117
|
+
config;
|
|
118
|
+
sessionId;
|
|
119
|
+
dataCb = null;
|
|
120
|
+
taskDoneCb = null;
|
|
121
|
+
exitCb = null;
|
|
122
|
+
taskIdCb = null;
|
|
123
|
+
turnFinalCb = null;
|
|
124
|
+
outputBuffer = '';
|
|
125
|
+
/**
|
|
126
|
+
* This turn's assistant answer, accumulated from the SAME text the user
|
|
127
|
+
* sees on the card (emitText is the single choke point for model prose —
|
|
128
|
+
* tool-call/warning chrome goes through emitLine and is deliberately left
|
|
129
|
+
* out). Reset per turn in runTurn(); handed to turnFinalCb at settleTurn().
|
|
130
|
+
*/
|
|
131
|
+
turnFinalText = '';
|
|
132
|
+
/** mojo-side session id — the resume lineage. */
|
|
133
|
+
cliSessionId = null;
|
|
134
|
+
child = null;
|
|
135
|
+
killed = false;
|
|
136
|
+
closing = false;
|
|
137
|
+
/** A close attempt observed evidence that something credentialed may still be
|
|
138
|
+
* alive (an unproven local subtree, or a dispatched turn with no lineage).
|
|
139
|
+
*
|
|
140
|
+
* ONE-WAY for the lifetime of this backend: nothing clears it. The CLOSE stays
|
|
141
|
+
* retryable though — a later destroySession() whose terminateChildProven()
|
|
142
|
+
* succeeds proceeds to the remote cancel and returns ok:true — and that
|
|
143
|
+
* liveness property IS covered by a test, because a fence that also wedged the
|
|
144
|
+
* close would be worse than the bug it fixes.
|
|
145
|
+
*
|
|
146
|
+
* Honest scope note: the one-way lifetime itself is an implementation fact, not
|
|
147
|
+
* a tested guarantee. Once a close succeeds `killed` refuses writes anyway, so
|
|
148
|
+
* clearing this field at that point is an equivalent mutation (verified: it
|
|
149
|
+
* survives). Do not read it as a proven invariant. */
|
|
150
|
+
admissionFenced = false;
|
|
151
|
+
/** Graceful daemon shutdown is a non-cancelling detach. Fence only writes
|
|
152
|
+
* arriving after prepare, then wait just long enough for an already accepted
|
|
153
|
+
* first turn to publish its `system/init` lineage. */
|
|
154
|
+
shutdownDetaching = false;
|
|
155
|
+
shutdownDetachPrepared = false;
|
|
156
|
+
shutdownDetachAttempt = null;
|
|
157
|
+
shutdownDetachInFlight = null;
|
|
158
|
+
shutdownDetachAbortInFlight = null;
|
|
159
|
+
shutdownDetachWake = null;
|
|
160
|
+
lineageWaiters = new Set();
|
|
161
|
+
/** At least one turn crossed the adapter boundary while no lineage was
|
|
162
|
+
* known. A later process exit without `system/init` cannot prove that no
|
|
163
|
+
* remote session was created, so shutdown must not persist authoritative
|
|
164
|
+
* null merely because the local write promise settled. */
|
|
165
|
+
acceptedWriteWithoutLineage = false;
|
|
166
|
+
/** Inherited by every descendant of every turn, so the subtree stays
|
|
167
|
+
* enumerable after setsid/reparenting. Per BACKEND, not per turn: a tool left
|
|
168
|
+
* behind by an earlier turn must still be found. */
|
|
169
|
+
/**
|
|
170
|
+
* Env nonce injected into the turn subtree, inherited by every descendant.
|
|
171
|
+
*
|
|
172
|
+
* NOT `readonly`, and NOT freshly random per instance: a replacement worker
|
|
173
|
+
* generation builds a NEW backend for the SAME session, and a new nonce would
|
|
174
|
+
* make the previous generation's tree unenumerable forever (the env signal is
|
|
175
|
+
* the only one that survives setsid + reparenting). So it is adopted from an
|
|
176
|
+
* inherited containment handle whenever one is outstanding.
|
|
177
|
+
*/
|
|
178
|
+
treeNonce = `botmux-mojo-${randomBytes(12).toString('hex')}`;
|
|
179
|
+
/**
|
|
180
|
+
* Worker generation, used only for operator-facing logs on the handle. Derived
|
|
181
|
+
* from how many handles this session already has outstanding, so a replacement
|
|
182
|
+
* generation is distinguishable from the first one.
|
|
183
|
+
*/
|
|
184
|
+
containmentGeneration;
|
|
185
|
+
/**
|
|
186
|
+
* The cgroup boundary prepared for the CURRENT turn, created before spawn so
|
|
187
|
+
* the child can enrol itself pre-exec (see MOJO_CGROUP_ENROLL_SHIM). Null on
|
|
188
|
+
* hosts without cgroup v2 delegation — those turns get a weak handle instead.
|
|
189
|
+
*/
|
|
190
|
+
preparedBoundary = null;
|
|
191
|
+
/** Realpath of this session's isolated workspace (host execution only).
|
|
192
|
+
* Populated lazily by resolveCwd(); spawn and close share this exact
|
|
193
|
+
* string so the close-side daemon-registry match cannot drift. */
|
|
194
|
+
isolatedWorkspace;
|
|
195
|
+
/** True for control-plane-only instances (the workerless orphan-cancel
|
|
196
|
+
* helper): they never run an agent turn, so isolating their cwd would
|
|
197
|
+
* only mint a junk workspace dir (and potentially a junk daemon) for a
|
|
198
|
+
* sentinel session id. */
|
|
199
|
+
controlPlaneOnly = false;
|
|
200
|
+
/** One-shot resolver for the CURRENT runTurn promise, fired by settleTurn.
|
|
201
|
+
* The turn is accounted for by its result event, never by the client
|
|
202
|
+
* process ending — see runTurn for why the process may outlive the turn. */
|
|
203
|
+
turnResolve = null;
|
|
204
|
+
/**
|
|
205
|
+
* Latched once a strong boundary proved unusable at runtime — the shim's
|
|
206
|
+
* enrolment write was rejected (exit 97). The prepare-time probe only opens
|
|
207
|
+
* cgroup.procs; a host that rejects the pid WRITE (delegation containment)
|
|
208
|
+
* would otherwise fail EVERY turn with exit 97 forever. After the first such
|
|
209
|
+
* failure this backend degrades to the weak post-spawn handle instead.
|
|
210
|
+
*/
|
|
211
|
+
strongBoundaryUnusable = false;
|
|
212
|
+
/** True for the turn currently in flight iff it launched through the cgroup
|
|
213
|
+
* enrolment shim, so a genuine mojo `exit 97` is not misread as an enrolment
|
|
214
|
+
* failure (and vice versa) on weak-handle hosts where no shim runs. */
|
|
215
|
+
usedEnrolShim = false;
|
|
216
|
+
/**
|
|
217
|
+
* Latched when a spawned turn's containment handle could NOT be persisted
|
|
218
|
+
* AND the started subtree could not be proven terminated afterwards. While
|
|
219
|
+
* set, every close/destroy proof is refused: there is a tree nothing durable
|
|
220
|
+
* describes, so publishing a closed row would drop the device-isolation
|
|
221
|
+
* blocker over a subtree we cannot enumerate.
|
|
222
|
+
*/
|
|
223
|
+
containmentUnrecorded = false;
|
|
224
|
+
/**
|
|
225
|
+
* Root pid of the most recent turn, kept AFTER `this.child` is cleared.
|
|
226
|
+
*
|
|
227
|
+
* The child's own `close` handler nulls `this.child`, so a later `/close` had
|
|
228
|
+
* nothing left to check and skipped the subtree scan entirely — the exact hole
|
|
229
|
+
* that let a survivor go unnoticed once its parent had exited.
|
|
230
|
+
*/
|
|
231
|
+
lastTurnPid = null;
|
|
232
|
+
/**
|
|
233
|
+
* Recycle-proof identity of `lastTurnPid`, captured AT SPAWN.
|
|
234
|
+
*
|
|
235
|
+
* The pid number alone is not a handle: by the time teardown runs, the kernel
|
|
236
|
+
* may have recycled it onto an unrelated process, and `kill(-pid)` would then
|
|
237
|
+
* signal a stranger's whole process group. Binding boot id + starttime at
|
|
238
|
+
* spawn is what lets the signal path prove it is still aiming at OUR child.
|
|
239
|
+
*/
|
|
240
|
+
turnIdentity = null;
|
|
241
|
+
/**
|
|
242
|
+
* Evidence class of the last quiescence attempt. DIAGNOSTIC ONLY.
|
|
243
|
+
*
|
|
244
|
+
* The previous wording claimed the blocker decision requires
|
|
245
|
+
* `boundaryProof === true` on this value. It did not, and still does not:
|
|
246
|
+
* nothing in production reads `TurnQuiescence.boundaryProof`, so that was a
|
|
247
|
+
* claim about code that was never written. The real gate is
|
|
248
|
+
* `TerminationOutcome.boundaryProven` (see the close path below), which is
|
|
249
|
+
* derived from `containmentReleaseDecision` in mojo-containment.ts. This field
|
|
250
|
+
* is kept for logs and for tests that assert the grading, and it is read
|
|
251
|
+
* through the `lastTurnQuiescence` getter only.
|
|
252
|
+
*/
|
|
253
|
+
lastQuiescence = null;
|
|
254
|
+
lastTermination = null;
|
|
255
|
+
/** True once the current turn has emitted its `result` event, so a late
|
|
256
|
+
* process exit cannot fire a second turn boundary. */
|
|
257
|
+
turnSettled = true;
|
|
258
|
+
/** Buffer for partial NDJSON lines across stdout chunks. */
|
|
259
|
+
stdoutTail = '';
|
|
260
|
+
/** Set when --include-partial deltas have already rendered this turn's text,
|
|
261
|
+
* so the trailing whole-segment `text` event isn't printed twice. */
|
|
262
|
+
streamedThisTurn = false;
|
|
263
|
+
cliTimeoutMs = MOJO_CLI_TIMEOUT_MS;
|
|
264
|
+
/** How long /close waits for an in-flight turn to publish its session id
|
|
265
|
+
* before tearing down. Must stay well under the worker's close/restart
|
|
266
|
+
* race so teardown never becomes the thing that times out. */
|
|
267
|
+
destroySettleMs = MOJO_DESTROY_SETTLE_MS;
|
|
268
|
+
/**
|
|
269
|
+
* Captured from spawn(). The worker owns the authoritative cwd + env (the
|
|
270
|
+
* BOTMUX_* session context, per-bot `env`, credential paths, proxies) and
|
|
271
|
+
* hands them over exactly once; ignoring them silently drops repo selection,
|
|
272
|
+
* per-bot tokens and proxy settings. `config` values still win where set, so
|
|
273
|
+
* an explicit bots.json override remains authoritative.
|
|
274
|
+
*/
|
|
275
|
+
spawnOpts = null;
|
|
276
|
+
/**
|
|
277
|
+
* Resolved launch PREFIX from BotConfig.wrapperCli (e.g. `env VAR=x mojo`,
|
|
278
|
+
* a ttadk gateway). The worker resolves the prefix into a real bin + args and
|
|
279
|
+
* passes them to spawn(); a PTY CLI is wrapped once for the life of its
|
|
280
|
+
* process, but mojo is invoked per turn, so the prefix must be re-applied to
|
|
281
|
+
* EVERY invocation. Null when no wrapper is configured, in which case the
|
|
282
|
+
* plain binary is used.
|
|
283
|
+
*/
|
|
284
|
+
launchPrefix = null;
|
|
285
|
+
/** Guard so the config-side wrapper resolution is attempted at most once. */
|
|
286
|
+
wrapperResolved = false;
|
|
287
|
+
/** Resolved once per session — see resolveBin. */
|
|
288
|
+
pinnedBin = null;
|
|
289
|
+
/**
|
|
290
|
+
* Live JWT, THREE states — the distinction is why a clear used to fail:
|
|
291
|
+
* undefined → no live snapshot received; resolve from config/env as before
|
|
292
|
+
* string → use exactly this
|
|
293
|
+
* null → explicitly cleared; do NOT fall back to any config-layer env
|
|
294
|
+
*
|
|
295
|
+
* The daemon already folds the ambient fallback into the snapshot it sends, so
|
|
296
|
+
* `null` genuinely means "no credential from any config layer". Previously a
|
|
297
|
+
* clear only set `config.jwt = undefined`, and buildEnv then re-read `jwtEnv`
|
|
298
|
+
* out of the init-time `config.env` / `injectEnv`, reviving a stale token.
|
|
299
|
+
*/
|
|
300
|
+
liveJwt = undefined;
|
|
301
|
+
/**
|
|
302
|
+
* Generic CLI args the worker composed for this session (today: CLI_EXTRA_ARGS,
|
|
303
|
+
* e.g. `--timeout 77`). The mojo adapter's buildArgs() returns [], so anything
|
|
304
|
+
* arriving here came from the worker's shared arg pipeline and must be applied
|
|
305
|
+
* to every turn — dropping it made the flag work with a wrapper configured
|
|
306
|
+
* (buildWrappedLaunch folds spawnArgs into the prefix) but silently vanish
|
|
307
|
+
* without one.
|
|
308
|
+
*/
|
|
309
|
+
extraCliArgs = [];
|
|
310
|
+
writeChain = Promise.resolve();
|
|
311
|
+
constructor(config, sessionId, opts) {
|
|
312
|
+
this.config = config;
|
|
313
|
+
this.sessionId = sessionId;
|
|
314
|
+
this.controlPlaneOnly = opts?.controlPlaneOnly === true;
|
|
315
|
+
// Adopt the nonce of any subtree this session already owns. A replacement
|
|
316
|
+
// generation MUST keep hunting the previous generation's tree, and the env
|
|
317
|
+
// nonce is the only signal that survives setsid + reparenting to init — a
|
|
318
|
+
// fresh random value here would strand that tree permanently.
|
|
319
|
+
//
|
|
320
|
+
// `containmentHandles` THROWS on an unreadable store rather than answering
|
|
321
|
+
// "none". Letting that escape the constructor is deliberate: a backend that
|
|
322
|
+
// cannot know what it inherited must not come up and start a turn it can
|
|
323
|
+
// never prove quiescent.
|
|
324
|
+
const inherited = containmentHandles(sessionId);
|
|
325
|
+
this.containmentGeneration = inherited.length;
|
|
326
|
+
const adopted = inherited[0]?.nonce;
|
|
327
|
+
if (adopted !== undefined) {
|
|
328
|
+
this.treeNonce = adopted;
|
|
329
|
+
logger.warn(`[mojo] session ${sessionId}: adopting ${inherited.length} unproven turn `
|
|
330
|
+
+ 'subtree(s) from a previous worker generation; the device-isolation blocker '
|
|
331
|
+
+ 'stays until they are proven quiescent');
|
|
332
|
+
}
|
|
333
|
+
// Daemon-restart resume: the persisted mojo session id restores the
|
|
334
|
+
// lineage so the first write after a restart continues the conversation
|
|
335
|
+
// instead of cold-booting a context-less session.
|
|
336
|
+
if (config.resumeCliSessionId)
|
|
337
|
+
this.cliSessionId = config.resumeCliSessionId;
|
|
338
|
+
}
|
|
339
|
+
// ── SessionBackend surface ───────────────────────────────────────────────
|
|
340
|
+
spawn(bin, args, opts) {
|
|
341
|
+
// No persistent process is started here — the headless CLI is invoked
|
|
342
|
+
// once per turn — but the spawn contract is still where the worker hands
|
|
343
|
+
// over the authoritative cwd/env, so keep them for buildEnv()/runTurn().
|
|
344
|
+
this.spawnOpts = opts;
|
|
345
|
+
// FAIL CLOSED on a launch prefix we did not ask for.
|
|
346
|
+
//
|
|
347
|
+
// This used to assume a non-empty `bin` could only be wrapperCli, on the
|
|
348
|
+
// grounds that the FILE sandbox is refused for this backend before spawn
|
|
349
|
+
// (backendSandboxCompatibilityError). That misses a second, INDEPENDENT
|
|
350
|
+
// wrapping path: mandatory device-credential isolation, which
|
|
351
|
+
// read-isolation.ts documents as "independent of the optional bot sandbox
|
|
352
|
+
// toggle" and which rewrites spawnBin whenever the host is enrolled and the
|
|
353
|
+
// session is not provably remote — e.g. a mojo bot with no `cloud` set.
|
|
354
|
+
//
|
|
355
|
+
// In that state the old code dropped the wrapper AND passed its argv to
|
|
356
|
+
// mojo as extraCliArgs, so a boundary the platform mandates vanished while
|
|
357
|
+
// the session looked healthy. Refusing is the only safe answer here: this
|
|
358
|
+
// backend cannot tell which confinement it was handed, and guessing is
|
|
359
|
+
// what caused the silent downgrade.
|
|
360
|
+
if (bin && !this.config.wrapperCli) {
|
|
361
|
+
// Say WHY this session is not provably remote, from the shared helper.
|
|
362
|
+
// The old text always advised "run fully remote (cloud on, localDaemon
|
|
363
|
+
// off)" — useless for the common case where cloud is already on and the
|
|
364
|
+
// blocker is an env key, which is exactly the state that sends a session
|
|
365
|
+
// down this path in the first place.
|
|
366
|
+
const proofGap = mojoRemoteProofFailureReason(this.config);
|
|
367
|
+
throw new Error(`[mojo] refusing to launch session ${this.sessionId}: unexpected launch wrapper `
|
|
368
|
+
+ `"${bin}" was supplied but no wrapperCli is configured. The mojo backend `
|
|
369
|
+
+ 'invokes the CLI per turn and cannot carry an unknown confinement wrapper. '
|
|
370
|
+
+ (proofGap
|
|
371
|
+
? `This session is not provably remote, which is what engaged the wrapper: ${proofGap} `
|
|
372
|
+
+ 'Resolve that so the credential boundary is satisfied remotely, or '
|
|
373
|
+
+ 'configure wrapperCli explicitly.'
|
|
374
|
+
: 'Configure wrapperCli explicitly if this bot needs a launch prefix.'));
|
|
375
|
+
}
|
|
376
|
+
// Generic extra args come from the config: the worker deliberately keeps
|
|
377
|
+
// them out of both the spawn args and the wrapper prefix so they can be
|
|
378
|
+
// appended AFTER our own flags on every turn (last-value-wins). Fall back
|
|
379
|
+
// to the spawn args for any caller that has not been updated.
|
|
380
|
+
const requestedExtraArgs = this.config.extraCliArgs
|
|
381
|
+
? [...this.config.extraCliArgs]
|
|
382
|
+
: (args.length > 0 && !this.config.wrapperCli ? [...args] : []);
|
|
383
|
+
// Defence in depth: the worker already refuses these, but this backend is
|
|
384
|
+
// also constructed by the daemon's cancel path. A reserved flag reaching
|
|
385
|
+
// here would override the frozen control plane, so drop it loudly rather
|
|
386
|
+
// than letting it through.
|
|
387
|
+
const reservedExtra = findReservedMojoCliFlags(requestedExtraArgs);
|
|
388
|
+
if (reservedExtra.length > 0) {
|
|
389
|
+
logger.warn(`[mojo] ignoring platform-owned flag(s) in extra CLI args: ${reservedExtra.join(' ')}`);
|
|
390
|
+
this.extraCliArgs = [];
|
|
391
|
+
}
|
|
392
|
+
else {
|
|
393
|
+
this.extraCliArgs = requestedExtraArgs;
|
|
394
|
+
}
|
|
395
|
+
if (this.config.wrapperCli) {
|
|
396
|
+
this.wrapperResolved = true;
|
|
397
|
+
if (bin) {
|
|
398
|
+
// The worker resolves the prefix with `[]` for args, so whatever
|
|
399
|
+
// arrives here is the wrapper itself and nothing else.
|
|
400
|
+
this.launchPrefix = { bin, args: [...args] };
|
|
401
|
+
logger.info(`[mojo] launch prefix from wrapperCli: ${bin} ${args.join(' ')}`);
|
|
402
|
+
}
|
|
403
|
+
else {
|
|
404
|
+
// Never claim a wrapper was applied while running the bare binary.
|
|
405
|
+
logger.warn(`[mojo] wrapperCli="${this.config.wrapperCli}" was configured but the worker `
|
|
406
|
+
+ 'supplied no launch binary — running mojo unwrapped');
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (this.extraCliArgs.length > 0) {
|
|
410
|
+
logger.info(`[mojo] extra CLI args applied per turn: ${this.extraCliArgs.join(' ')}`);
|
|
411
|
+
}
|
|
412
|
+
// Execution-mode audit line (review F4): host execution BY DEFAULT is a
|
|
413
|
+
// posture change, so make "came from default" vs "came from explicit
|
|
414
|
+
// config" distinguishable in the log. Same shared derivation as
|
|
415
|
+
// buildArgs/buildEnv — label and env value cannot drift by construction.
|
|
416
|
+
const execMode = deriveMojoExecutionMode(this.config).label;
|
|
417
|
+
logger.info(`[mojo] spawn ${this.sessionId} in ${this.resolveCwd() ?? '(inherited cwd)'} (headless CLI invoked per turn, execution=${execMode})`);
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Resolve the executable + leading args for one invocation, re-applying the
|
|
421
|
+
* wrapperCli prefix when present.
|
|
422
|
+
*
|
|
423
|
+
* The prefix normally arrives pre-resolved from the worker via spawn(). The
|
|
424
|
+
* daemon's workerless cancel path never calls spawn(), so when a wrapper is
|
|
425
|
+
* configured but unresolved we resolve it here from the config — otherwise
|
|
426
|
+
* `/close` would run an unwrapped binary that a wrapper-dependent setup
|
|
427
|
+
* (e.g. a gateway that injects auth) cannot reach.
|
|
428
|
+
*/
|
|
429
|
+
resolveLaunch(cliArgs) {
|
|
430
|
+
const prefix = this.launchPrefix ?? this.resolveConfiguredWrapper();
|
|
431
|
+
if (prefix) {
|
|
432
|
+
return { bin: prefix.bin, args: [...prefix.args, ...cliArgs] };
|
|
433
|
+
}
|
|
434
|
+
return { bin: this.resolveBin(), args: cliArgs };
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Resolve the binary ONCE and reuse it for every turn of this session.
|
|
438
|
+
*
|
|
439
|
+
* Without pinning, a bare `mojo` was re-resolved on each turn against the
|
|
440
|
+
* then-current PATH, so anything able to influence the environment between
|
|
441
|
+
* turns could substitute the executable. The live patch no longer carries
|
|
442
|
+
* `env` at all, but pinning removes the class of problem rather than one
|
|
443
|
+
* instance of it — and it also keeps a session on one binary if PATH shifts
|
|
444
|
+
* underneath a long-running worker.
|
|
445
|
+
*/
|
|
446
|
+
resolveBin() {
|
|
447
|
+
if (this.pinnedBin)
|
|
448
|
+
return this.pinnedBin;
|
|
449
|
+
const configured = this.config.bin?.trim();
|
|
450
|
+
if (configured) {
|
|
451
|
+
this.pinnedBin = configured;
|
|
452
|
+
return this.pinnedBin;
|
|
453
|
+
}
|
|
454
|
+
// Resolve against the EFFECTIVE child PATH, not the daemon's own.
|
|
455
|
+
// `locateOnPath` reads this process's env, which silently ignored a
|
|
456
|
+
// per-bot `PATH` — the child would then run a different binary than the
|
|
457
|
+
// one that was pinned, changing the documented semantics of per-bot env.
|
|
458
|
+
this.pinnedBin = this.locateOnEffectivePath('mojo') ?? 'mojo';
|
|
459
|
+
logger.info(`[mojo] pinned binary for this session: ${this.pinnedBin}`);
|
|
460
|
+
return this.pinnedBin;
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Find an executable using the PATH the CHILD will actually see.
|
|
464
|
+
*
|
|
465
|
+
* Layered exactly like buildEnv (worker env → per-bot injectEnv → mojo.env),
|
|
466
|
+
* so a per-bot PATH override takes effect. Falls back to the caller's own PATH
|
|
467
|
+
* when spawn() has not run (direct/unit use).
|
|
468
|
+
*/
|
|
469
|
+
locateOnEffectivePath(cmd) {
|
|
470
|
+
const childPath = this.config.env?.PATH
|
|
471
|
+
?? this.spawnOpts?.injectEnv?.PATH
|
|
472
|
+
?? this.spawnOpts?.env?.PATH;
|
|
473
|
+
if (!childPath)
|
|
474
|
+
return locateOnPath(cmd);
|
|
475
|
+
for (const dir of childPath.split(delimiter)) {
|
|
476
|
+
if (!dir)
|
|
477
|
+
continue;
|
|
478
|
+
const candidate = join(dir, cmd);
|
|
479
|
+
try {
|
|
480
|
+
accessSync(candidate, fsConstants.X_OK);
|
|
481
|
+
return candidate;
|
|
482
|
+
}
|
|
483
|
+
catch { /* not here */ }
|
|
484
|
+
}
|
|
485
|
+
// Explicit child PATH is authoritative: do NOT fall back to the daemon's
|
|
486
|
+
// ambient PATH. Falling back is how an ambient install shadowed a per-bot
|
|
487
|
+
// one, which defeats the point of resolving on the child PATH at all.
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
/** Lazily resolve (and memoize) `config.wrapperCli` when spawn() never ran. */
|
|
491
|
+
resolveConfiguredWrapper() {
|
|
492
|
+
if (this.wrapperResolved)
|
|
493
|
+
return this.launchPrefix;
|
|
494
|
+
this.wrapperResolved = true;
|
|
495
|
+
const wrapperCli = this.config.wrapperCli?.trim();
|
|
496
|
+
if (!wrapperCli)
|
|
497
|
+
return null;
|
|
498
|
+
try {
|
|
499
|
+
// cliArgs is [] on purpose: the mojo adapter bakes nothing into launch
|
|
500
|
+
// args, so this yields the PREFIX only, and the per-turn args are
|
|
501
|
+
// appended by resolveLaunch.
|
|
502
|
+
// Same effective-PATH resolution as resolveBin: a per-bot PATH must
|
|
503
|
+
// decide the wrapper binary too, or the two disagree about which
|
|
504
|
+
// install is in use.
|
|
505
|
+
const launch = buildWrappedLaunch(wrapperCli, [], b => this.locateOnEffectivePath(b) ?? b);
|
|
506
|
+
if (!launch.bin)
|
|
507
|
+
return null;
|
|
508
|
+
this.launchPrefix = { bin: launch.bin, args: launch.args };
|
|
509
|
+
logger.info(`[mojo] launch prefix resolved from config: ${launch.bin} ${launch.args.join(' ')}`);
|
|
510
|
+
return this.launchPrefix;
|
|
511
|
+
}
|
|
512
|
+
catch (err) {
|
|
513
|
+
// Never let an unusable wrapper turn teardown into a crash — but do
|
|
514
|
+
// not pretend it was applied either.
|
|
515
|
+
logger.warn(`[mojo] could not resolve wrapperCli "${wrapperCli}": ${String(err)}`);
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
/** bots.json `mojo.cwd` wins; otherwise the worker's session working dir. */
|
|
520
|
+
/** The operator-facing working directory (the repo). Kept separate from
|
|
521
|
+
* resolveCwd(): host execution runs the CLI in an isolated per-session
|
|
522
|
+
* directory instead, and the decorate() preamble points the agent back
|
|
523
|
+
* here for repo work. */
|
|
524
|
+
realWorkingDir() {
|
|
525
|
+
return this.config.cwd ?? this.spawnOpts?.cwd;
|
|
526
|
+
}
|
|
527
|
+
/** True when this config executes tools on the bot host (shared derivation
|
|
528
|
+
* with buildEnv/buildArgs — see deriveMojoExecutionMode). */
|
|
529
|
+
hostExecution() {
|
|
530
|
+
return deriveMojoExecutionMode(this.config).agentLocalDaemon === '1';
|
|
531
|
+
}
|
|
532
|
+
/** HOME for the isolated workspace root. Prefer the CHILD env the worker
|
|
533
|
+
* hands over — in production it equals the daemon's own HOME (so the
|
|
534
|
+
* workerless close path, which uses os.homedir(), matches), while in
|
|
535
|
+
* tests it keeps backend instances from minting directories under the
|
|
536
|
+
* developer's real ~/.botmux (the full suite did exactly that once). */
|
|
537
|
+
isolationHome() {
|
|
538
|
+
const home = this.config.env?.HOME ?? this.spawnOpts?.injectEnv?.HOME ?? this.spawnOpts?.env?.HOME;
|
|
539
|
+
return typeof home === 'string' && home.length > 0 ? home : undefined;
|
|
540
|
+
}
|
|
541
|
+
resolveCwd() {
|
|
542
|
+
if (this.controlPlaneOnly || !this.hostExecution())
|
|
543
|
+
return this.realWorkingDir();
|
|
544
|
+
// Host execution: run the CLI from a physically distinct per-session
|
|
545
|
+
// directory. mojo keys its local execution daemon on
|
|
546
|
+
// hash(process.cwd()) — realpath, so a symlink would collapse back
|
|
547
|
+
// into the shared daemon (see mojo-isolated-workspace.ts for the
|
|
548
|
+
// whole P0/P1 story). Cached: spawn and close must use the SAME
|
|
549
|
+
// realpath string or the close-side registry match silently misses.
|
|
550
|
+
this.isolatedWorkspace ??= ensureMojoIsolatedWorkspace(this.sessionId, this.isolationHome());
|
|
551
|
+
return this.isolatedWorkspace;
|
|
552
|
+
}
|
|
553
|
+
write(data) {
|
|
554
|
+
if (this.killed || this.closing || this.shutdownDetaching)
|
|
555
|
+
return false;
|
|
556
|
+
if (this.containmentUnrecorded) {
|
|
557
|
+
// A prior turn's subtree could not be recorded AND could not be proven
|
|
558
|
+
// terminated. Running another credentialed turn on top of a possibly
|
|
559
|
+
// live, undescribed tree is exactly the fail-open the latch exists to
|
|
560
|
+
// prevent — refuse until a close proves (or contains) it.
|
|
561
|
+
this.emitLine('❌ mojo 进程围栏存在未登记的残留子进程,已拒绝新一轮,请先 /close 本会话。', 'err');
|
|
562
|
+
return false;
|
|
563
|
+
}
|
|
564
|
+
const text = data.trim();
|
|
565
|
+
if (!text)
|
|
566
|
+
return false;
|
|
567
|
+
if (!this.cliSessionId)
|
|
568
|
+
this.acceptedWriteWithoutLineage = true;
|
|
569
|
+
// Serialize turns: mojo rejects a concurrent turn on the same session,
|
|
570
|
+
// and a second message arriving before the first init event would fork a
|
|
571
|
+
// duplicate session (cliSessionId still null).
|
|
572
|
+
this.writeChain = this.writeChain
|
|
573
|
+
.then(() => (this.killed || this.closing) ? undefined : this.runTurnWithBusyRetry(text))
|
|
574
|
+
.catch((err) => {
|
|
575
|
+
logger.warn(`[mojo] turn failed: ${String(err)}`);
|
|
576
|
+
this.emitLine(`❌ mojo 执行失败:${this.fmtErr(err)}`, 'err');
|
|
577
|
+
this.settleTurn();
|
|
578
|
+
});
|
|
579
|
+
return true;
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Rotate the JWT on a LIVE session, without a refork.
|
|
583
|
+
*
|
|
584
|
+
* Needed because the config is otherwise read once at worker init, so every
|
|
585
|
+
* subsequent per-turn CLI invocation kept using the ORIGINAL token — a rotated
|
|
586
|
+
* credential never took effect.
|
|
587
|
+
*
|
|
588
|
+
* Takes a COMPLETE snapshot rather than a sparse diff, so the two states that
|
|
589
|
+
* a sparse patch could not express both work:
|
|
590
|
+
* - `jwt: null` → cleared (a deleted `mojo.jwt` must not linger)
|
|
591
|
+
* - `jwt: <original>` → rolled back (A → B → A must return to A)
|
|
592
|
+
*
|
|
593
|
+
* Only the JWT is patchable. An `env` patch would be equivalent to replacing
|
|
594
|
+
* the launcher — see MOJO_LIVE_PATCH_KEYS.
|
|
595
|
+
*/
|
|
596
|
+
applyLivePatch(patch) {
|
|
597
|
+
if (patch.jwt === undefined)
|
|
598
|
+
return;
|
|
599
|
+
if (this.liveJwt === patch.jwt)
|
|
600
|
+
return;
|
|
601
|
+
this.liveJwt = patch.jwt;
|
|
602
|
+
// Never log the value.
|
|
603
|
+
logger.info(`[mojo] live JWT ${patch.jwt === null ? 'cleared' : 'rotated'}`);
|
|
604
|
+
}
|
|
605
|
+
resize(_cols, _rows) { }
|
|
606
|
+
onData(cb) { this.dataCb = cb; }
|
|
607
|
+
/**
|
|
608
|
+
* NOT fired on per-turn CLI exit — the binary is spawned and exits every
|
|
609
|
+
* turn, so forwarding that would tear the session down after the first
|
|
610
|
+
* reply. It IS fired from kill(), mirroring RiffBackend: the worker needs to
|
|
611
|
+
* learn the backend is gone on teardown / daemon restart, and nothing else
|
|
612
|
+
* tells it.
|
|
613
|
+
*/
|
|
614
|
+
onExit(cb) {
|
|
615
|
+
this.exitCb = cb;
|
|
616
|
+
}
|
|
617
|
+
/** Turn boundary — required: an API-backed backend produces no PTY output, so
|
|
618
|
+
* botmux's idle detector never fires and nothing else re-arms prompt-ready. */
|
|
619
|
+
onTaskDone(cb) { this.taskDoneCb = cb; }
|
|
620
|
+
/** This turn's assistant answer, for the worker's final_output bridge. A
|
|
621
|
+
* headless mojo session has no terminal the user could read instead, so an
|
|
622
|
+
* answer the agent never `botmux send`s would otherwise reach nobody. */
|
|
623
|
+
onTurnFinal(cb) { this.turnFinalCb = cb; }
|
|
624
|
+
/** Lineage id updates — forwarded to the daemon so multi-turn context
|
|
625
|
+
* survives a daemon restart. */
|
|
626
|
+
onTaskId(cb) {
|
|
627
|
+
this.taskIdCb = cb;
|
|
628
|
+
if (this.cliSessionId)
|
|
629
|
+
cb(this.cliSessionId);
|
|
630
|
+
}
|
|
631
|
+
captureCurrentScreen() { return this.outputBuffer; }
|
|
632
|
+
captureViewport() { return this.outputBuffer; }
|
|
633
|
+
getPaneSize() { return null; }
|
|
634
|
+
getChildPid() { return this.child?.pid ?? null; }
|
|
635
|
+
/**
|
|
636
|
+
* SIGTERM, then PROVE the child is gone (escalating to SIGKILL).
|
|
637
|
+
*
|
|
638
|
+
* `child.kill('SIGTERM')` returning true only means the signal was delivered.
|
|
639
|
+
* A child that ignores it keeps executing with the injected credential while
|
|
640
|
+
* the explicit close publishes the row as `closed` — and a closed row is
|
|
641
|
+
* filtered out of the device-isolation inventory
|
|
642
|
+
* (mergePersistedDeviceIsolationSessions), so the blocker vanishes with the
|
|
643
|
+
* process still alive. That is exactly the state this backend must never
|
|
644
|
+
* report as a successful teardown.
|
|
645
|
+
*
|
|
646
|
+
* Returns false when termination could not be proven; the caller must then
|
|
647
|
+
* refuse the close rather than let the row be published as closed.
|
|
648
|
+
*/
|
|
649
|
+
/** Overridable so a behaviour test can exercise the escalation ladder without
|
|
650
|
+
* burning the production budget in wall-clock. Production never changes it. */
|
|
651
|
+
get terminationProofBudgetMs() {
|
|
652
|
+
return MOJO_CHILD_TERMINATION_PROOF_MS;
|
|
653
|
+
}
|
|
654
|
+
/** Overridable so a test can point the scan at a synthetic /proc. */
|
|
655
|
+
get procRoot() { return '/proc'; }
|
|
656
|
+
/** Overridable so a test can point boundary preparation at a synthetic
|
|
657
|
+
* cgroup root. `undefined` = the real /sys/fs/cgroup. */
|
|
658
|
+
get cgroupRoot() { return undefined; }
|
|
659
|
+
/** Overridable so a test can point the delegated-parent lookup
|
|
660
|
+
* (/proc/self/cgroup) at a synthetic /proc. `undefined` = the real /proc. */
|
|
661
|
+
get cgroupProcRoot() { return undefined; }
|
|
662
|
+
/**
|
|
663
|
+
* SIGTERM the whole turn SUBTREE, then gather the best evidence this host can
|
|
664
|
+
* give that nothing in it survives (escalating to SIGKILL).
|
|
665
|
+
*
|
|
666
|
+
* Read the result precisely. `ok: true` means "no executing member was
|
|
667
|
+
* found", NOT "the credential is now unreachable". Enumeration cannot see a
|
|
668
|
+
* descendant that both setsid'd and scrubbed its own environ, so a clean scan
|
|
669
|
+
* is a DIAGNOSTIC signal only — `boundaryProven` is the field that says
|
|
670
|
+
* whether a real boundary was established, and only kernel-level containment
|
|
671
|
+
* (a per-session cgroup) can set it true. `ok: true` with
|
|
672
|
+
* `boundaryProven: false` is therefore legal and, for Linux weak handles, the
|
|
673
|
+
* common case; destroySession() below consumes the two fields separately and
|
|
674
|
+
* downgrades the second case to a residual close.
|
|
675
|
+
*
|
|
676
|
+
* Three fail-open paths are closed here.
|
|
677
|
+
*
|
|
678
|
+
* 1. `child.kill()` returning true only means the signal was DELIVERED. A
|
|
679
|
+
* child that ignores SIGTERM keeps executing with the injected credential
|
|
680
|
+
* while the close publishes the row as `closed` — and a closed row is
|
|
681
|
+
* filtered out of the device-isolation inventory, so the blocker vanishes
|
|
682
|
+
* with the process still alive.
|
|
683
|
+
* 2. Signalling the direct pid leaves DESCENDANTS alive.
|
|
684
|
+
* 3. Signalling the process GROUP still leaves descendants that escaped it via
|
|
685
|
+
* setsid/detached. Enumeration therefore unions PGID, the inherited env
|
|
686
|
+
* nonce and the PPID chain — see mojo-process-tree.
|
|
687
|
+
*
|
|
688
|
+
* Reports `ok: false` whenever no such evidence could be obtained, INCLUDING when
|
|
689
|
+
* the scan itself fails: "cannot enumerate" must never read as "nothing is
|
|
690
|
+
* running". The caller then refuses the close, which keeps the row active and
|
|
691
|
+
* so keeps the device-isolation blocker in place. (destroySession makes ONE
|
|
692
|
+
* exception, for a platform that can never enumerate at all — see the
|
|
693
|
+
* `unsupported-platform` branch there.)
|
|
694
|
+
*
|
|
695
|
+
* Zombie members are discounted, because a reaped process executes nothing and
|
|
696
|
+
* cannot use the credential; only a definite `Z` state qualifies.
|
|
697
|
+
*/
|
|
698
|
+
async terminateChildProven() {
|
|
699
|
+
const q = await this.proveTurnQuiescence();
|
|
700
|
+
// The projection is where evidence grading survives. Returning a bare
|
|
701
|
+
// boolean here is what discarded it: `diagnostic-clean` and
|
|
702
|
+
// `contained-proven` both collapsed to `true`, so a clean Linux scan --
|
|
703
|
+
// which cannot see a setsid'd, environ-scrubbed descendant -- authorised
|
|
704
|
+
// the same plain closed row as a real kernel-level proof.
|
|
705
|
+
const outcome = terminationOutcomeFromQuiescence(q);
|
|
706
|
+
this.lastTermination = outcome;
|
|
707
|
+
return outcome;
|
|
708
|
+
}
|
|
709
|
+
/** Structured evidence from the last termination attempt; see TerminationOutcome. */
|
|
710
|
+
get lastTerminationOutcome() {
|
|
711
|
+
return this.lastTermination;
|
|
712
|
+
}
|
|
713
|
+
/** Evidence class of the last termination attempt; see TurnQuiescence. */
|
|
714
|
+
get lastTurnQuiescence() {
|
|
715
|
+
return this.lastQuiescence;
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* SIGTERM the whole turn SUBTREE, then try to establish that nothing in it
|
|
719
|
+
* survives (escalating to SIGKILL), and report WHAT KIND of evidence we got.
|
|
720
|
+
*/
|
|
721
|
+
async proveTurnQuiescence() {
|
|
722
|
+
const record = (q) => { this.lastQuiescence = q; return q; };
|
|
723
|
+
const child = this.child;
|
|
724
|
+
// lastTurnPid, not just `child`: the child's own `close` handler clears
|
|
725
|
+
// `this.child`, and an escaped descendant outlives its parent. Falling back
|
|
726
|
+
// to the remembered root pid is what keeps the subtree checkable.
|
|
727
|
+
const rootPid = (typeof child?.pid === 'number' && child.pid > 0) ? child.pid : this.lastTurnPid;
|
|
728
|
+
// The DURABLE view of what this session still owns. In-memory state is not
|
|
729
|
+
// enough: on a replacement generation `lastTurnPid` is always null, and the
|
|
730
|
+
// old code concluded "no turn ever spawned, so there is no subtree" — which
|
|
731
|
+
// returned true for a session that may still have a live credentialed
|
|
732
|
+
// survivor from the previous generation. Only the store can retire that claim.
|
|
733
|
+
//
|
|
734
|
+
// A throw here (unreadable store) propagates and refuses the close, which is
|
|
735
|
+
// the correct fail-closed outcome.
|
|
736
|
+
const outstanding = containmentHandles(this.sessionId);
|
|
737
|
+
if (rootPid === null && outstanding.length === 0) {
|
|
738
|
+
// No in-memory root AND nothing outstanding in the store: genuinely
|
|
739
|
+
// nothing was ever credentialed under this session.
|
|
740
|
+
this.child = null;
|
|
741
|
+
return record({ kind: 'diagnostic-clean', boundaryProof: false });
|
|
742
|
+
}
|
|
743
|
+
const exited = new Promise(resolve => {
|
|
744
|
+
if (!child)
|
|
745
|
+
return resolve();
|
|
746
|
+
const done = () => resolve();
|
|
747
|
+
child.once('exit', done);
|
|
748
|
+
child.once('close', done);
|
|
749
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
750
|
+
resolve();
|
|
751
|
+
});
|
|
752
|
+
const budget = this.terminationProofBudgetMs;
|
|
753
|
+
const grace = Math.max(1, Math.floor(budget / 2));
|
|
754
|
+
// Never signal ourselves: the daemon shares neither nonce nor group, but an
|
|
755
|
+
// explicit guard is cheaper than trusting that while sending SIGKILL.
|
|
756
|
+
const excludePids = this.selfPids();
|
|
757
|
+
// rootPid may be null here while handles are still outstanding (a replacement
|
|
758
|
+
// generation inherited a tree it never spawned). There is no in-memory root to
|
|
759
|
+
// scan in that case, so the in-memory ladder is skipped and the inherited
|
|
760
|
+
// handles below become the ONLY thing that can retire the close.
|
|
761
|
+
if (rootPid === null) {
|
|
762
|
+
return record(this.dischargeContainment(outstanding, { kind: 'diagnostic-clean', boundaryProof: false }));
|
|
763
|
+
}
|
|
764
|
+
const look = () => quiescenceFromScan(
|
|
765
|
+
// turnIdentity was bound at spawn, while the pid was guaranteed to be
|
|
766
|
+
// this child. It gates PGID-based claiming inside the scan: without a
|
|
767
|
+
// verified root, only the env nonce (positive evidence of membership)
|
|
768
|
+
// may claim, so a recycled root pid can never pull a stranger's
|
|
769
|
+
// process group into a scan whose members get SIGKILLed.
|
|
770
|
+
scanMojoTree(rootPid, this.treeNonce, {
|
|
771
|
+
procRoot: this.procRoot,
|
|
772
|
+
excludePids,
|
|
773
|
+
...(this.turnIdentity && this.turnIdentity.pid === rootPid
|
|
774
|
+
? { rootIdentity: this.turnIdentity }
|
|
775
|
+
: {}),
|
|
776
|
+
}));
|
|
777
|
+
const settle = async (deadlineMs) => {
|
|
778
|
+
const deadline = Date.now() + deadlineMs;
|
|
779
|
+
for (;;) {
|
|
780
|
+
const now = look();
|
|
781
|
+
// Fail-closed: an unscannable host or an unsupported platform can
|
|
782
|
+
// never be read as "nothing is running", and retrying will not turn
|
|
783
|
+
// either into knowledge, so stop immediately.
|
|
784
|
+
if (now.kind === 'unscannable') {
|
|
785
|
+
logger.error(`[mojo] cannot enumerate turn subtree: ${now.reason}`);
|
|
786
|
+
return now;
|
|
787
|
+
}
|
|
788
|
+
if (now.kind === 'unsupported-platform') {
|
|
789
|
+
logger.error(`[mojo] cannot enumerate turn subtree on ${now.platform}: /proc is Linux-only, `
|
|
790
|
+
+ 'so quiescence cannot be established on this host');
|
|
791
|
+
return now;
|
|
792
|
+
}
|
|
793
|
+
if (now.kind !== 'alive')
|
|
794
|
+
return now;
|
|
795
|
+
if (Date.now() >= deadline)
|
|
796
|
+
return now;
|
|
797
|
+
// Plain sleep: racing an ALREADY-RESOLVED `exited` here spun the
|
|
798
|
+
// loop as fast as the event loop allowed (a busy loop until the
|
|
799
|
+
// deadline). The direct child's exit is not the condition anyway —
|
|
800
|
+
// the scan is.
|
|
801
|
+
await new Promise(r => setTimeout(r, 25).unref?.());
|
|
802
|
+
}
|
|
803
|
+
};
|
|
804
|
+
const signalTree = (signal) => {
|
|
805
|
+
// Group first: it reaches processes that /proc may not have listed yet.
|
|
806
|
+
// But NEVER as a bare kill(-rootPid): the pid may have been recycled, so
|
|
807
|
+
// the identity captured at spawn is re-verified first and the signal is
|
|
808
|
+
// skipped entirely on mismatch, on failure to verify, or off-Linux.
|
|
809
|
+
if (this.turnIdentity === null) {
|
|
810
|
+
logger.error('[mojo] refusing to signal the turn process group: no spawn-time identity was '
|
|
811
|
+
+ 'captured, so the pid cannot be proven to still be our child');
|
|
812
|
+
}
|
|
813
|
+
else {
|
|
814
|
+
const sent = signalTurnTreeGroup(this.turnIdentity, signal, { procRoot: this.procRoot });
|
|
815
|
+
if (sent.kind === 'identity-mismatch') {
|
|
816
|
+
logger.error(`[mojo] refusing to signal pid ${this.turnIdentity.pid}: it has been recycled `
|
|
817
|
+
+ `(starttime ${this.turnIdentity.starttime} -> ${sent.actual.starttime}); `
|
|
818
|
+
+ 'signalling it would hit an unrelated process group');
|
|
819
|
+
}
|
|
820
|
+
else if (sent.kind === 'unverifiable') {
|
|
821
|
+
logger.error(`[mojo] refusing to signal the turn process group: ${sent.reason}`);
|
|
822
|
+
}
|
|
823
|
+
else if (sent.kind === 'unsupported-platform') {
|
|
824
|
+
logger.error(`[mojo] refusing to signal the turn process group on ${sent.platform}: `
|
|
825
|
+
+ 'pid identity cannot be verified without /proc');
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
// Per-pid signals still go out: they come from an enumeration that just
|
|
829
|
+
// observed each pid, and they are positive (not group-negated) targets.
|
|
830
|
+
const scan = look();
|
|
831
|
+
if (scan.kind === 'alive') {
|
|
832
|
+
for (const pid of scan.pids) {
|
|
833
|
+
try {
|
|
834
|
+
process.kill(pid, signal);
|
|
835
|
+
}
|
|
836
|
+
catch { /* raced us */ }
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
else if (scan.kind === 'unscannable' || scan.kind === 'unsupported-platform') {
|
|
840
|
+
// Cannot enumerate: fall back to the direct child handle, which is the
|
|
841
|
+
// only target we can name without /proc.
|
|
842
|
+
try {
|
|
843
|
+
child?.kill(signal);
|
|
844
|
+
}
|
|
845
|
+
catch { /* gone */ }
|
|
846
|
+
}
|
|
847
|
+
};
|
|
848
|
+
signalTree('SIGTERM');
|
|
849
|
+
let verdict = await settle(grace);
|
|
850
|
+
if (verdict.kind === 'diagnostic-clean') {
|
|
851
|
+
this.child = null;
|
|
852
|
+
await exited.catch?.(() => undefined);
|
|
853
|
+
return record(this.dischargeContainment(outstanding, verdict));
|
|
854
|
+
}
|
|
855
|
+
if (verdict.kind === 'alive') {
|
|
856
|
+
// Escalate. SIGKILL cannot be caught, so a survivor after this means we
|
|
857
|
+
// genuinely cannot prove quiescence (e.g. uninterruptible state).
|
|
858
|
+
signalTree('SIGKILL');
|
|
859
|
+
verdict = await settle(budget - grace);
|
|
860
|
+
if (verdict.kind === 'diagnostic-clean') {
|
|
861
|
+
this.child = null;
|
|
862
|
+
return record(this.dischargeContainment(outstanding, verdict));
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
logger.error(`[mojo] turn subtree rooted at ${rootPid} could not be proven `
|
|
866
|
+
+ `quiescent (${verdict.kind}); refusing to report the close as successful — the session `
|
|
867
|
+
+ 'row stays active so its device-isolation blocker is retained');
|
|
868
|
+
// Deliberately NOT clearing this.child / lastTurnPid / turnIdentity: the
|
|
869
|
+
// close is refused, so a retry must be able to signal the same subtree again.
|
|
870
|
+
return record(verdict);
|
|
871
|
+
}
|
|
872
|
+
/**
|
|
873
|
+
* Mint and PERSIST the containment handle for a freshly spawned turn root.
|
|
874
|
+
*
|
|
875
|
+
* Extracted so it can be exercised directly: doing this at close time would be
|
|
876
|
+
* too late, because a crash between spawn and record is exactly the window that
|
|
877
|
+
* loses the tree, and a lost tree can never be proven quiescent afterwards.
|
|
878
|
+
*/
|
|
879
|
+
recordTurnContainment(rootPid) {
|
|
880
|
+
// Strong path: the boundary was prepared before spawn and the child enrols
|
|
881
|
+
// itself pre-exec, so the handle can simply adopt the directory — no pid
|
|
882
|
+
// migration, hence no spawn→enrolment window (the P0 this replaced).
|
|
883
|
+
const prepared = this.preparedBoundary;
|
|
884
|
+
if (prepared) {
|
|
885
|
+
recordContainmentHandle(strongHandleFromPreparedBoundary(prepared));
|
|
886
|
+
// The handle owns the directory now; a later spawn failure must not
|
|
887
|
+
// kill a boundary that belongs to a recorded turn.
|
|
888
|
+
this.preparedBoundary = null;
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
// acquireContainmentHandle cannot return null: on a host with neither a
|
|
892
|
+
// preparable cgroup boundary nor a readable boot id it mints an
|
|
893
|
+
// `unprovable` handle instead, which is persisted, can never be released,
|
|
894
|
+
// and reports unsupported-platform. That is what makes the residual-close
|
|
895
|
+
// path safe — there is always something durable holding the
|
|
896
|
+
// device-isolation blocker, so there is no "nothing was recorded" case
|
|
897
|
+
// left to handle here.
|
|
898
|
+
recordContainmentHandle(acquireContainmentHandle({
|
|
899
|
+
sessionId: this.sessionId,
|
|
900
|
+
generation: this.containmentGeneration,
|
|
901
|
+
rootPid,
|
|
902
|
+
nonce: this.treeNonce,
|
|
903
|
+
}));
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* Terminate a subtree whose containment handle could not be persisted.
|
|
907
|
+
*
|
|
908
|
+
* `containmentUnrecorded` is already latched by the caller. It is cleared
|
|
909
|
+
* only when the boundary is PROVEN empty (rmdir accepted by the kernel) —
|
|
910
|
+
* anything less keeps every close proof refused, because a tree nothing
|
|
911
|
+
* durable describes must not be closable.
|
|
912
|
+
*/
|
|
913
|
+
async containUnrecordedSpawn(child, cause) {
|
|
914
|
+
logger.error(`[mojo] containment handle could not be persisted for pid ${child.pid}; `
|
|
915
|
+
+ `terminating the just-started subtree: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
916
|
+
this.emitLine('❌ mojo 本轮启动失败:进程围栏无法登记,已终止刚启动的子进程。', 'err');
|
|
917
|
+
const prepared = this.preparedBoundary;
|
|
918
|
+
// Belt and braces alongside cgroup.kill: the direct child is its own
|
|
919
|
+
// group leader (detached:true), so the group signal reaches a pre-exec
|
|
920
|
+
// shim that has not enrolled yet.
|
|
921
|
+
if (typeof child.pid === 'number' && child.pid > 0) {
|
|
922
|
+
try {
|
|
923
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
924
|
+
}
|
|
925
|
+
catch { /* gone */ }
|
|
926
|
+
try {
|
|
927
|
+
child.kill('SIGKILL');
|
|
928
|
+
}
|
|
929
|
+
catch { /* gone */ }
|
|
930
|
+
}
|
|
931
|
+
// Wait for the reap: a SIGKILLed member lingers in cgroup.procs as a
|
|
932
|
+
// zombie until its parent (us) collects it, and the rmdir proof below
|
|
933
|
+
// would spin against that zombie forever.
|
|
934
|
+
await new Promise(resolve => {
|
|
935
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
936
|
+
return resolve();
|
|
937
|
+
const timer = setTimeout(resolve, 5_000);
|
|
938
|
+
timer.unref?.();
|
|
939
|
+
child.once('close', () => { clearTimeout(timer); resolve(); });
|
|
940
|
+
});
|
|
941
|
+
if (prepared) {
|
|
942
|
+
this.preparedBoundary = null;
|
|
943
|
+
if (await killPreparedBoundary(prepared)) {
|
|
944
|
+
this.containmentUnrecorded = false;
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
logger.error(`[mojo] boundary ${prepared.cgroupPath} could not be proven empty after the record `
|
|
948
|
+
+ 'failure; every close proof for this generation stays refused');
|
|
949
|
+
}
|
|
950
|
+
else {
|
|
951
|
+
// Weak path: no boundary to prove emptiness with. The latch stays set —
|
|
952
|
+
// the tree may have descendants only a recorded handle could enumerate.
|
|
953
|
+
logger.error('[mojo] unrecorded subtree was signalled but cannot be proven terminated '
|
|
954
|
+
+ '(no strong boundary); every close proof for this generation stays refused');
|
|
955
|
+
}
|
|
956
|
+
// Fail-closed state must live in the DURABLE store, not only in this
|
|
957
|
+
// memory latch: if the worker later dies, the workerless proof finds no
|
|
958
|
+
// outstanding handle and would publish a plain `closed`, dropping the
|
|
959
|
+
// blocker the latch was holding — and the latch dies with the process.
|
|
960
|
+
// Retry the record so `hasUnprovenContainment` stays true across a worker
|
|
961
|
+
// death (and `mojo-containment revoke` has something to target). If it
|
|
962
|
+
// still fails, the memory latch remains as the last line.
|
|
963
|
+
try {
|
|
964
|
+
recordContainmentHandle(prepared
|
|
965
|
+
? strongHandleFromPreparedBoundary(prepared)
|
|
966
|
+
: acquireContainmentHandle({
|
|
967
|
+
sessionId: this.sessionId,
|
|
968
|
+
generation: this.containmentGeneration,
|
|
969
|
+
rootPid: child.pid ?? this.lastTurnPid ?? 0,
|
|
970
|
+
nonce: this.treeNonce,
|
|
971
|
+
}));
|
|
972
|
+
}
|
|
973
|
+
catch (err) {
|
|
974
|
+
logger.error(`[mojo] durable re-record of the unrecorded subtree ALSO failed; the fail-closed `
|
|
975
|
+
+ `state survives only in memory for this worker's lifetime: ${String(err)}`);
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
/**
|
|
979
|
+
* Discharge every DURABLE handle this session owns, after the in-memory ladder
|
|
980
|
+
* believes its own root is gone.
|
|
981
|
+
*
|
|
982
|
+
* The in-memory verdict only ever speaks for the pid THIS backend spawned. A
|
|
983
|
+
* replacement generation inherits handles describing trees it never spawned, and
|
|
984
|
+
* those must be proven independently or the close stays refused. A handle leaves
|
|
985
|
+
* the store only against a `proven: true` verdict — `releaseContainmentHandle`
|
|
986
|
+
* takes the verdict itself and throws on anything else, so "clear the blocker
|
|
987
|
+
* without proof" is not representable here.
|
|
988
|
+
*/
|
|
989
|
+
dischargeContainment(outstanding, cleanVerdict) {
|
|
990
|
+
const decisions = [];
|
|
991
|
+
for (const handle of outstanding) {
|
|
992
|
+
// An inherited handle means this generation TOOK OVER responsibility for
|
|
993
|
+
// cleaning that tree. Only proving it (never signalling it) left an
|
|
994
|
+
// escaped survivor that nothing would ever kill: every /close retry
|
|
995
|
+
// re-proved it alive and refused, so the session could never be closed
|
|
996
|
+
// while the credentialed process kept running. Proof alone turns the
|
|
997
|
+
// handle into a permanent tombstone, so signal first, then prove.
|
|
998
|
+
//
|
|
999
|
+
// Signalling is strictly GATED, because a pid is not an identity:
|
|
1000
|
+
// * weak handle only when weakHandleRootStillOriginal confirms the
|
|
1001
|
+
// recorded pid is still the same process (same boot id
|
|
1002
|
+
// AND same starttime). Without that, the number may
|
|
1003
|
+
// have been recycled onto a stranger and negating it
|
|
1004
|
+
// would take down an unrelated process group.
|
|
1005
|
+
// * unprovable never: it has no pid to signal at all.
|
|
1006
|
+
// Failing the gate means "do not signal", NOT "the tree is gone" — the
|
|
1007
|
+
// proof below still has to speak for the subtree.
|
|
1008
|
+
if (handle.kind === 'tree-identity') {
|
|
1009
|
+
const rootStillOriginal = weakHandleRootStillOriginal(handle, { procRoot: this.procRoot });
|
|
1010
|
+
if (!rootStillOriginal) {
|
|
1011
|
+
logger.warn(`[mojo] inherited handle ${containmentHandleKey(handle)}: recorded root pid is no `
|
|
1012
|
+
+ 'longer the original process (recycled or gone), so its GROUP will not be '
|
|
1013
|
+
+ 'signalled; enumerated members are still signalled individually');
|
|
1014
|
+
}
|
|
1015
|
+
this.signalInheritedTree(handle, rootStillOriginal);
|
|
1016
|
+
}
|
|
1017
|
+
const verdict = proveContainmentQuiescent(handle, {
|
|
1018
|
+
procRoot: this.procRoot,
|
|
1019
|
+
// `scanned: false` must NEVER collapse into `pids: []`: an empty pid
|
|
1020
|
+
// list reads as "nothing alive", which is precisely the fail-open the
|
|
1021
|
+
// scanner's failure modes exist to prevent.
|
|
1022
|
+
scan: weak => {
|
|
1023
|
+
const scan = scanMojoTree(weak.rootPid, weak.nonce, {
|
|
1024
|
+
procRoot: this.procRoot,
|
|
1025
|
+
excludePids: this.selfPids(),
|
|
1026
|
+
// The handle's RECORDED identity, so PGID claiming stays
|
|
1027
|
+
// disabled once the pid stops being the original process.
|
|
1028
|
+
rootIdentity: { pid: weak.rootPid, bootId: weak.bootId, starttime: weak.startTime },
|
|
1029
|
+
});
|
|
1030
|
+
return scan.ok
|
|
1031
|
+
// Zombies are discounted here for the SAME reason the
|
|
1032
|
+
// in-memory verdict discounts them: a reaped process
|
|
1033
|
+
// executes nothing and cannot use the credential. Passing
|
|
1034
|
+
// them through made the two paths disagree — the ladder
|
|
1035
|
+
// called the tree clean while the handle proof called the
|
|
1036
|
+
// very same tree alive, so a correct close was refused and
|
|
1037
|
+
// the handle could never be discharged.
|
|
1038
|
+
? { scanned: true, pids: scan.members.filter(m => !m.zombie).map(m => m.pid) }
|
|
1039
|
+
: { scanned: false, pids: [], reason: scan.reason };
|
|
1040
|
+
},
|
|
1041
|
+
});
|
|
1042
|
+
if (!verdict.proven) {
|
|
1043
|
+
logger.error(`[mojo] inherited turn subtree (${containmentHandleKey(handle)}) could not be `
|
|
1044
|
+
+ `proven quiescent (${verdict.reason}); refusing the close so the `
|
|
1045
|
+
+ 'device-isolation blocker is retained');
|
|
1046
|
+
// Do NOT hand-roll the projection here. `unscannable` routes to a
|
|
1047
|
+
// FENCE, which latches write admission and fails the close; that is
|
|
1048
|
+
// right when a retry might still produce proof, and permanently wrong
|
|
1049
|
+
// on a host that can never enumerate at all. An INHERITED unprovable
|
|
1050
|
+
// handle is exactly that host: every /close after a worker generation
|
|
1051
|
+
// replacement re-derived `unscannable`, so admission stayed latched and
|
|
1052
|
+
// the session could never be closed -- the same permanent wedge C-7
|
|
1053
|
+
// fixed on the primary path, still reachable through this one.
|
|
1054
|
+
// `containmentQuiescence` owns the grading and maps an unprovable
|
|
1055
|
+
// handle to `unsupported-platform`, which routes to a RESIDUAL CLOSE:
|
|
1056
|
+
// the row closes, the blocker stays on the handle that was never
|
|
1057
|
+
// released. Grading belongs to the containment module, not to a
|
|
1058
|
+
// second copy of its rules living here.
|
|
1059
|
+
return containmentQuiescence(verdict);
|
|
1060
|
+
}
|
|
1061
|
+
// The decision is the containment module's to make, not ours: it says
|
|
1062
|
+
// whether the handle was actually discharged and whether a residual
|
|
1063
|
+
// must survive the close. Discarding it here and re-deriving the answer
|
|
1064
|
+
// from `handle.kind` is what made this path contradict the store --
|
|
1065
|
+
// a bootId-aged-out weak handle really was released, while the verdict
|
|
1066
|
+
// still reported `diagnostic-clean`, so the outcome asked the daemon to
|
|
1067
|
+
// keep a device-isolation blocker whose only evidence (the handle) had
|
|
1068
|
+
// just been deleted. The blocker IS the handle, so that was a lie in the
|
|
1069
|
+
// safe-looking direction, and a lie is what the review is about.
|
|
1070
|
+
decisions.push(releaseContainmentHandle(verdict));
|
|
1071
|
+
}
|
|
1072
|
+
// A boundary proof is the only thing that may upgrade the verdict, and
|
|
1073
|
+
// `boundaryProof` is the single field allowed to say so. It is true for
|
|
1074
|
+
// exactly ONE evidence — a changed boot id (a reboot), for a weak handle
|
|
1075
|
+
// OR a cgroup handle whose stamped bootId is gone. It is FALSE for a merely
|
|
1076
|
+
// clean /proc scan AND for an empty cgroup (a same-UID process can migrate
|
|
1077
|
+
// itself out of the leaf), both of which stay `diagnostic-clean` and keep
|
|
1078
|
+
// their residual.
|
|
1079
|
+
if (decisions.length > 0 && decisions.every(d => d.boundaryProof)) {
|
|
1080
|
+
// Built by the containment module, which owns the only constructor of a
|
|
1081
|
+
// proven boundary; this layer decides WHETHER it applies, never what it
|
|
1082
|
+
// looks like. Fabricating a verdict to feed containmentQuiescence would
|
|
1083
|
+
// be wrong here: a weak handle aged out by a changed boot id is a real
|
|
1084
|
+
// boundary proof, yet a synthesised cgroup-empty verdict on that same
|
|
1085
|
+
// handle would be graded back down to a diagnostic scan.
|
|
1086
|
+
return containedProvenQuiescence();
|
|
1087
|
+
}
|
|
1088
|
+
return cleanVerdict;
|
|
1089
|
+
}
|
|
1090
|
+
/** Pids that must never be signalled, whatever a scan says. */
|
|
1091
|
+
selfPids() {
|
|
1092
|
+
return [process.pid, process.ppid].filter(pid => typeof pid === 'number' && pid > 0);
|
|
1093
|
+
}
|
|
1094
|
+
/**
|
|
1095
|
+
* SIGKILL an inherited tree whose recorded root identity has already been
|
|
1096
|
+
* re-verified by the caller.
|
|
1097
|
+
*
|
|
1098
|
+
* SIGTERM is skipped deliberately: this tree belongs to a previous worker
|
|
1099
|
+
* generation that is already gone, so nobody is waiting to shut it down
|
|
1100
|
+
* gracefully, and the graceful attempt was made when that generation closed.
|
|
1101
|
+
* Every enumerated member is signalled individually as well, because a
|
|
1102
|
+
* descendant may have left the group via setsid and would survive the group
|
|
1103
|
+
* signal alone.
|
|
1104
|
+
*
|
|
1105
|
+
* Best effort by design: this only creates the CHANCE for the proof below to
|
|
1106
|
+
* succeed. If anything survives, the proof still refuses the close.
|
|
1107
|
+
*/
|
|
1108
|
+
signalInheritedTree(handle, rootStillOriginal) {
|
|
1109
|
+
logger.warn(`[mojo] signalling inherited turn subtree rooted at ${handle.rootPid}: this generation owns `
|
|
1110
|
+
+ 'its cleanup, and proving it without signalling would leave the session permanently unclosable');
|
|
1111
|
+
// The RECORDED identity, never a fresh read: re-reading the pid here and
|
|
1112
|
+
// passing that as "expected" made signalTurnTreeGroup compare a value to
|
|
1113
|
+
// itself, so the whole verification collapsed to a race — a pid recycled
|
|
1114
|
+
// between the caller's gate and this signal would still have its (new)
|
|
1115
|
+
// group taken down. signalTurnTreeGroup re-reads internally and refuses
|
|
1116
|
+
// on mismatch against the identity recorded when the tree was spawned.
|
|
1117
|
+
if (rootStillOriginal) {
|
|
1118
|
+
signalTurnTreeGroup({ pid: handle.rootPid, bootId: handle.bootId, starttime: handle.startTime }, 'SIGKILL', { procRoot: this.procRoot });
|
|
1119
|
+
}
|
|
1120
|
+
// Per-member signals stay safe even when the recorded root is long gone, and
|
|
1121
|
+
// this is the case that actually matters: a descendant that called setsid
|
|
1122
|
+
// OUTLIVES its parent, so the root-identity gate can never pass for it.
|
|
1123
|
+
//
|
|
1124
|
+
// Two properties make each kill attributable:
|
|
1125
|
+
// * HANDLE nonce, not this.treeNonce: the constructor only adopts the
|
|
1126
|
+
// FIRST inherited handle's nonce, so scanning a later handle with the
|
|
1127
|
+
// adopted nonce found nothing by env at all — its tree was only ever
|
|
1128
|
+
// reachable through fragile pgid number collisions.
|
|
1129
|
+
// * the recorded root identity gates PGID claiming inside the scan, so
|
|
1130
|
+
// every member here was claimed via the env nonce (or as a descendant
|
|
1131
|
+
// of one), which is positive evidence of THIS tree's membership — and
|
|
1132
|
+
// the target is a single positive pid rather than a negated group, so
|
|
1133
|
+
// even a raced pid cannot drag an unrelated group down with it.
|
|
1134
|
+
//
|
|
1135
|
+
// Without this, an escaped survivor was re-proven alive on every retry and
|
|
1136
|
+
// never signalled, so the session could never be closed at all.
|
|
1137
|
+
const scan = scanMojoTree(handle.rootPid, handle.nonce, {
|
|
1138
|
+
procRoot: this.procRoot,
|
|
1139
|
+
excludePids: this.selfPids(),
|
|
1140
|
+
rootIdentity: { pid: handle.rootPid, bootId: handle.bootId, starttime: handle.startTime },
|
|
1141
|
+
});
|
|
1142
|
+
if (!scan.ok)
|
|
1143
|
+
return;
|
|
1144
|
+
for (const member of scan.members) {
|
|
1145
|
+
if (member.zombie)
|
|
1146
|
+
continue; // already reaped; signalling it is pointless
|
|
1147
|
+
try {
|
|
1148
|
+
process.kill(member.pid, 'SIGKILL');
|
|
1149
|
+
}
|
|
1150
|
+
catch { /* raced us */ }
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
kill() {
|
|
1154
|
+
if (this.killed)
|
|
1155
|
+
return;
|
|
1156
|
+
this.killed = true;
|
|
1157
|
+
// Daemon shutdown / worker teardown. SIGTERM is not proof of anything, and
|
|
1158
|
+
// NOTHING is released here on purpose: the handles recorded at spawn are
|
|
1159
|
+
// durable, so an unproven tree survives the restart as an explicit blocker
|
|
1160
|
+
// instead of being silently forgotten. Calling releaseContainmentHandle on
|
|
1161
|
+
// this path would be exactly the laundering this review exists to remove.
|
|
1162
|
+
try {
|
|
1163
|
+
const stillOwned = containmentHandles(this.sessionId).length;
|
|
1164
|
+
if (stillOwned > 0) {
|
|
1165
|
+
logger.warn(`[mojo] shutdown leaves ${stillOwned} unproven turn subtree(s) for `
|
|
1166
|
+
+ `${this.sessionId}; the blocker will be reloaded on the next boot`);
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
catch (err) {
|
|
1170
|
+
// Never let an unreadable store turn shutdown into a crash; the store
|
|
1171
|
+
// being unreadable already means the next boot fails closed.
|
|
1172
|
+
logger.warn(`[mojo] cannot report outstanding turn subtrees at shutdown: ${String(err)}`);
|
|
1173
|
+
}
|
|
1174
|
+
this.shutdownDetachWake?.();
|
|
1175
|
+
for (const wake of this.lineageWaiters)
|
|
1176
|
+
wake();
|
|
1177
|
+
this.child?.kill('SIGTERM');
|
|
1178
|
+
this.child = null;
|
|
1179
|
+
// Mirror RiffBackend: the server-side mojo session KEEPS RUNNING here.
|
|
1180
|
+
// kill() fires on worker teardown / daemon restart, where the persisted
|
|
1181
|
+
// cliSessionId resumes the lineage afterwards. Cancelling the remote
|
|
1182
|
+
// session belongs to the explicit /close path (destroySession).
|
|
1183
|
+
this.exitCb?.(0, null);
|
|
1184
|
+
}
|
|
1185
|
+
/** Test-only view of the adopted lineage, so a teardown test can assert it is
|
|
1186
|
+
* still unset inside the pre-init window it is exercising. */
|
|
1187
|
+
get cliSessionIdForTest() {
|
|
1188
|
+
return this.cliSessionId ?? undefined;
|
|
1189
|
+
}
|
|
1190
|
+
/** /close teardown — cancel the server-side session so it stops consuming
|
|
1191
|
+
* cloud sandbox time after the IM session is gone. */
|
|
1192
|
+
async destroySession() {
|
|
1193
|
+
// Set when the local subtree is unprovable for a reason no retry can fix
|
|
1194
|
+
// (a platform with no /proc). Carried onto the successful result as an
|
|
1195
|
+
// explicit residual marker rather than silently dropped.
|
|
1196
|
+
let residualOnPlatform = false;
|
|
1197
|
+
// Set when the ladder completed cleanly but produced no unforgeable boundary
|
|
1198
|
+
// proof (Linux weak handle / diagnostic-clean). Distinct from
|
|
1199
|
+
// residualOnPlatform: that one is "this host has no instrument at all", this
|
|
1200
|
+
// one is "the instrument answered, and its answer is not proof".
|
|
1201
|
+
let residualBoundaryUnproven = false;
|
|
1202
|
+
if (this.shutdownDetaching) {
|
|
1203
|
+
return {
|
|
1204
|
+
ok: false,
|
|
1205
|
+
...(this.cliSessionId ? { taskId: this.cliSessionId } : {}),
|
|
1206
|
+
error: 'shutdown_detach_in_progress',
|
|
1207
|
+
};
|
|
1208
|
+
}
|
|
1209
|
+
if (this.containmentUnrecorded) {
|
|
1210
|
+
// A spawned subtree exists that no durable handle describes (the record
|
|
1211
|
+
// failed and termination could not be proven). Publishing ANY close
|
|
1212
|
+
// verdict would drop the device-isolation blocker over a tree we cannot
|
|
1213
|
+
// enumerate, so the close is refused outright.
|
|
1214
|
+
this.closing = true;
|
|
1215
|
+
this.admissionFenced = true;
|
|
1216
|
+
return {
|
|
1217
|
+
ok: false,
|
|
1218
|
+
...(this.cliSessionId ? { taskId: this.cliSessionId } : {}),
|
|
1219
|
+
error: 'containment_unrecorded_subtree',
|
|
1220
|
+
recovery: 'retryable',
|
|
1221
|
+
admission: 'fenced',
|
|
1222
|
+
};
|
|
1223
|
+
}
|
|
1224
|
+
// Gate FIRST so no new turn is accepted, then let the in-flight one settle
|
|
1225
|
+
// before tearing anything down. Killing the child here (as this used to)
|
|
1226
|
+
// destroyed the only source of the lineage: cliSessionId is adopted from
|
|
1227
|
+
// the first `system/init` line, so a /close inside the "turn dispatched,
|
|
1228
|
+
// init not yet arrived" window found it null, skipped the cancel, and never
|
|
1229
|
+
// fired taskIdCb — leaving the daemon's orphan fallback without an id too.
|
|
1230
|
+
// The remote session then leaked, still holding the injected credential.
|
|
1231
|
+
//
|
|
1232
|
+
// Bounded, and only worth waiting for while a turn is actually in flight.
|
|
1233
|
+
// Budget sits under the worker's own close/restart race (see
|
|
1234
|
+
// RiffBackend.destroySession for the layered deadlines).
|
|
1235
|
+
this.closing = true;
|
|
1236
|
+
// Gate on "a turn was dispatched and its lineage has not arrived", NOT on
|
|
1237
|
+
// `this.child`. Keying it on a live child meant a mojo that accepted the
|
|
1238
|
+
// write and then exited before emitting `system/init` skipped the wait
|
|
1239
|
+
// entirely — and with cliSessionId still null the cancel below was skipped
|
|
1240
|
+
// too, so this returned ok:true for a remote session we cannot even name.
|
|
1241
|
+
// `prepareShutdownDetach` already uses this exact predicate
|
|
1242
|
+
// (`lineageExpected`); the two protocols must agree about what "proven
|
|
1243
|
+
// gone" means.
|
|
1244
|
+
const lineageExpected = this.acceptedWriteWithoutLineage;
|
|
1245
|
+
if (lineageExpected && !this.cliSessionId) {
|
|
1246
|
+
await Promise.race([
|
|
1247
|
+
this.writeChain.catch(() => undefined),
|
|
1248
|
+
new Promise(r => setTimeout(r, this.destroySettleMs).unref?.()),
|
|
1249
|
+
]);
|
|
1250
|
+
}
|
|
1251
|
+
// Order matters: the local subtree is torn down BEFORE the remote cancel,
|
|
1252
|
+
// and a failure here returns immediately. The cancel is the only
|
|
1253
|
+
// irreversible step, so it must never run while an earlier step can still
|
|
1254
|
+
// veto the close -- previously an unproven local child still fell through
|
|
1255
|
+
// to a successful cancel, producing a failure the caller was told to roll
|
|
1256
|
+
// back even though the remote session was already gone forever.
|
|
1257
|
+
//
|
|
1258
|
+
// SIGTERM is not proof, and neither is the direct pid — see
|
|
1259
|
+
// terminateChildProven.
|
|
1260
|
+
const termination = await this.terminateChildProven();
|
|
1261
|
+
if (!termination.ok) {
|
|
1262
|
+
// Two very different facts reach this branch, and collapsing them is
|
|
1263
|
+
// what wedged non-Linux hosts.
|
|
1264
|
+
//
|
|
1265
|
+
// * "the instrument says something may still be running" — real
|
|
1266
|
+
// evidence of a possibly credentialed survivor. Admission must be
|
|
1267
|
+
// fenced, because admitting a new turn would layer it on top of a
|
|
1268
|
+
// live orphan.
|
|
1269
|
+
// * "this host has no instrument at all" — off Linux there is no
|
|
1270
|
+
// /proc, so no retry, no delay and no operator action can ever turn
|
|
1271
|
+
// this into a proof. Fencing it made /close fail forever AND
|
|
1272
|
+
// refused every rollback, so the session could neither be closed
|
|
1273
|
+
// nor written to again.
|
|
1274
|
+
//
|
|
1275
|
+
// classifyUnprovenTermination owns that split (see destroy-result.ts);
|
|
1276
|
+
// anything it does not positively recognise as a terminal platform
|
|
1277
|
+
// limit falls through to the fence, so the default stays fail-closed.
|
|
1278
|
+
const verdict = classifyUnprovenTermination(this.lastTurnQuiescence?.kind);
|
|
1279
|
+
if (verdict.outcome === 'residual-close') {
|
|
1280
|
+
// Deliberately NOT setting admissionFenced: that latch is exactly
|
|
1281
|
+
// the wedge. The credential boundary is carried instead by the
|
|
1282
|
+
// containment handle recorded at spawn — on a host this branch can
|
|
1283
|
+
// be reached from, that handle is `unprovable`, which can never be
|
|
1284
|
+
// released, so the device-isolation blocker is retained.
|
|
1285
|
+
//
|
|
1286
|
+
// Execution deliberately CONTINUES into the remote cancel below: a
|
|
1287
|
+
// residual local subtree is no reason to leak the remote session.
|
|
1288
|
+
logger.warn(`[mojo] session ${this.sessionId}: local turn subtree cannot be proven gone on `
|
|
1289
|
+
+ `this platform (${verdict.reason}); closing with a residual marker instead of `
|
|
1290
|
+
+ 'fencing the session forever — the device-isolation blocker is retained by the '
|
|
1291
|
+
+ 'durable containment handle');
|
|
1292
|
+
residualOnPlatform = true;
|
|
1293
|
+
}
|
|
1294
|
+
else {
|
|
1295
|
+
// `closing` deliberately STAYS true, and the fence is latched. The
|
|
1296
|
+
// close is retryable (the irreversible remote cancel below has NOT
|
|
1297
|
+
// run), but a process that may still hold the injected credential is
|
|
1298
|
+
// possibly alive — admitting a new turn would layer it on top of that
|
|
1299
|
+
// live orphan. Clearing `closing` here is what made a post-abort
|
|
1300
|
+
// write() succeed on exactly this state.
|
|
1301
|
+
this.admissionFenced = true;
|
|
1302
|
+
// A dispatched turn whose lineage never materialised is an UNCERTAIN
|
|
1303
|
+
// outcome in its own right, and this earlier local failure must not
|
|
1304
|
+
// launder it into `retryable`: the very next check below would have
|
|
1305
|
+
// returned `uncertain` for the same session. Whichever step fails
|
|
1306
|
+
// first, an unnamable remote session stays unnamable.
|
|
1307
|
+
const unnamedRemotePossible = lineageExpected && !this.cliSessionId;
|
|
1308
|
+
return {
|
|
1309
|
+
ok: false,
|
|
1310
|
+
...(this.cliSessionId ? { taskId: this.cliSessionId } : {}),
|
|
1311
|
+
error: verdict.reason,
|
|
1312
|
+
recovery: unnamedRemotePossible ? 'uncertain' : 'retryable',
|
|
1313
|
+
admission: 'fenced',
|
|
1314
|
+
};
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
else if (!termination.boundaryProven) {
|
|
1318
|
+
// THE GATE. The ladder completed and found no executing member, but no
|
|
1319
|
+
// unforgeable boundary was established -- on Linux this is the weak
|
|
1320
|
+
// handle / `diagnostic-clean` case, which the old bare boolean laundered
|
|
1321
|
+
// into a plain `closed` row. A closed row is filtered out of the
|
|
1322
|
+
// device-isolation inventory, so the blocker vanished for a subtree that
|
|
1323
|
+
// a setsid'd, environ-scrubbed descendant could still be living in.
|
|
1324
|
+
//
|
|
1325
|
+
// Reviewer's second option, deliberately not the first: the clean scan is
|
|
1326
|
+
// allowed to stop the signalling (`signalsStopped`) and the session row is
|
|
1327
|
+
// allowed to close, but it does NOT authorise a plain closed row. The
|
|
1328
|
+
// residual marker below is what carries the device-isolation blocker past
|
|
1329
|
+
// the close.
|
|
1330
|
+
//
|
|
1331
|
+
// Admission is deliberately NOT fenced: unlike the `!ok` branch there is
|
|
1332
|
+
// no positive evidence of a live member, and fencing every clean-scan
|
|
1333
|
+
// close would wedge ordinary Linux sessions forever. Execution continues
|
|
1334
|
+
// into the remote cancel for the same reason it does on the
|
|
1335
|
+
// residual-close path: a residual LOCAL subtree is no reason to leak the
|
|
1336
|
+
// REMOTE session.
|
|
1337
|
+
//
|
|
1338
|
+
// Releasing the handle is not done here by design -- that decision lives
|
|
1339
|
+
// in mojo-containment.ts, and only `boundaryProven === true` may authorise
|
|
1340
|
+
// it. This branch is the negative case, so it releases nothing.
|
|
1341
|
+
logger.warn(`[mojo] session ${this.sessionId}: local turn subtree scanned clean but the credential `
|
|
1342
|
+
+ `boundary is NOT proven (evidence ${termination.evidence}`
|
|
1343
|
+
+ `${termination.residual?.reason ? `: ${termination.residual.reason}` : ''}); closing with a `
|
|
1344
|
+
+ 'residual marker instead of a plain closed row — the device-isolation blocker is retained');
|
|
1345
|
+
residualBoundaryUnproven = true;
|
|
1346
|
+
}
|
|
1347
|
+
// A turn was dispatched but its lineage never materialised: there may be a
|
|
1348
|
+
// remote session we have no id for, so it cannot be cancelled and cannot be
|
|
1349
|
+
// claimed gone. Same verdict prepareShutdownDetach reaches from this state.
|
|
1350
|
+
if (lineageExpected && !this.cliSessionId) {
|
|
1351
|
+
// `closing` deliberately STAYS true. Clearing it here re-opened write
|
|
1352
|
+
// admission (a probe called write() straight after and it returned
|
|
1353
|
+
// true), which is exactly what `uncertain` must prevent: an unnamed
|
|
1354
|
+
// remote session may exist, so a fresh turn must not be layered on top
|
|
1355
|
+
// of a possible orphan. abortDestroySession() is the only legitimate
|
|
1356
|
+
// way back, and the worker must not call it for this verdict.
|
|
1357
|
+
// taskId is deliberately omitted rather than null: SessionDestroyResult
|
|
1358
|
+
// types it as an optional string, and "absent" is the honest answer —
|
|
1359
|
+
// there is no id to hand back for retry.
|
|
1360
|
+
//
|
|
1361
|
+
// `uncertain`, not `retryable`: an unnamed remote session may exist, so
|
|
1362
|
+
// admission must stay fenced instead of starting a fresh lineage on top
|
|
1363
|
+
// of a possible orphan. The row stays active, which keeps the
|
|
1364
|
+
// device-isolation blocker in place.
|
|
1365
|
+
this.admissionFenced = true;
|
|
1366
|
+
return {
|
|
1367
|
+
ok: false,
|
|
1368
|
+
error: 'mojo_lineage_not_materialized',
|
|
1369
|
+
recovery: 'uncertain',
|
|
1370
|
+
admission: 'fenced',
|
|
1371
|
+
};
|
|
1372
|
+
}
|
|
1373
|
+
if (this.cliSessionId) {
|
|
1374
|
+
let outcome;
|
|
1375
|
+
try {
|
|
1376
|
+
await this.runCliJson(['session', 'cancel', this.cliSessionId]);
|
|
1377
|
+
outcome = { kind: 'cancelled' };
|
|
1378
|
+
logger.info(`[mojo] cancelled session ${this.cliSessionId}`);
|
|
1379
|
+
}
|
|
1380
|
+
catch (err) {
|
|
1381
|
+
outcome = classifyMojoCancelFailure(err);
|
|
1382
|
+
logger.warn(`[mojo] session cancel failed: ${String(err)}`);
|
|
1383
|
+
}
|
|
1384
|
+
if (!isMojoRemoteGone(outcome)) {
|
|
1385
|
+
// Report it instead of swallowing it. This used to return void on
|
|
1386
|
+
// every path, so the worker ACKed a "successful" close and the
|
|
1387
|
+
// daemon published a closed row while the remote session kept
|
|
1388
|
+
// running and holding the injected credential.
|
|
1389
|
+
//
|
|
1390
|
+
// `killed` deliberately stays false and recovery is `retryable`:
|
|
1391
|
+
// the remote session was NOT torn down, so restoring admission is
|
|
1392
|
+
// both safe and required for the retry.
|
|
1393
|
+
this.closing = false;
|
|
1394
|
+
return {
|
|
1395
|
+
ok: false,
|
|
1396
|
+
taskId: this.cliSessionId,
|
|
1397
|
+
error: outcome.kind === 'failed' ? outcome.message : 'cancel not proven',
|
|
1398
|
+
recovery: 'retryable',
|
|
1399
|
+
// The local subtree was PROVEN gone above and the remote session
|
|
1400
|
+
// is named, so there is no unnamed survivor to fence against.
|
|
1401
|
+
admission: 'restorable',
|
|
1402
|
+
};
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
this.killed = true;
|
|
1406
|
+
// Reap this session's isolated execution daemon LAST, after the close
|
|
1407
|
+
// verdict is already decided: a reaping failure is a leaked idle daemon
|
|
1408
|
+
// (availability), never grounds to fail or roll back a close whose
|
|
1409
|
+
// remote cancel already happened. kill()/shutdown-detach deliberately
|
|
1410
|
+
// do NOT reap — the session survives a daemon restart and its daemon
|
|
1411
|
+
// must keep serving the resumed lineage.
|
|
1412
|
+
if (this.hostExecution() && !this.controlPlaneOnly) {
|
|
1413
|
+
await cleanupMojoIsolatedWorkspace(this.sessionId, { home: this.isolationHome() })
|
|
1414
|
+
.catch(() => undefined);
|
|
1415
|
+
// Drop the cached realpath WITH the directory: a later CLI
|
|
1416
|
+
// invocation on this instance (a retried close, a repeat cancel)
|
|
1417
|
+
// must re-create the workspace via resolveCwd instead of spawning
|
|
1418
|
+
// into a deleted cwd (ENOENT).
|
|
1419
|
+
this.isolatedWorkspace = undefined;
|
|
1420
|
+
}
|
|
1421
|
+
return {
|
|
1422
|
+
ok: true,
|
|
1423
|
+
...(this.cliSessionId ? { taskId: this.cliSessionId } : {}),
|
|
1424
|
+
...(residualOnPlatform
|
|
1425
|
+
? { residual: 'local_subtree_unprovable_on_platform' }
|
|
1426
|
+
: residualBoundaryUnproven
|
|
1427
|
+
? { residual: 'local_subtree_boundary_unproven' }
|
|
1428
|
+
: {}),
|
|
1429
|
+
};
|
|
1430
|
+
}
|
|
1431
|
+
/**
|
|
1432
|
+
* Roll back a FAILED prepare (restore write admission).
|
|
1433
|
+
*
|
|
1434
|
+
* Only valid when the cancel did not succeed. A proven cancel is irreversible:
|
|
1435
|
+
* the remote session is gone, so restoring admission would produce a session
|
|
1436
|
+
* that looks active but can never continue.
|
|
1437
|
+
*/
|
|
1438
|
+
abortDestroySession() {
|
|
1439
|
+
if (this.killed) {
|
|
1440
|
+
logger.warn('[mojo] abortDestroySession ignored: session was already torn down');
|
|
1441
|
+
// The session is gone for good, so writes are not "restored" here
|
|
1442
|
+
// either; saying otherwise would let the daemon clear a fence that the
|
|
1443
|
+
// irreversible teardown owns.
|
|
1444
|
+
return { admissionRestored: false, reason: 'session_already_torn_down' };
|
|
1445
|
+
}
|
|
1446
|
+
// A latched fence outranks the rollback. The daemon/worker may legitimately
|
|
1447
|
+
// abort a close it could not commit, but "the close was abandoned" is not
|
|
1448
|
+
// evidence that the possibly-live local subtree died, so restoring writes
|
|
1449
|
+
// here would defeat the fence destroySession deliberately kept.
|
|
1450
|
+
if (this.admissionFenced) {
|
|
1451
|
+
logger.warn('[mojo] abortDestroySession did NOT restore write admission: a previous close '
|
|
1452
|
+
+ 'could not prove local termination (or the turn lineage never materialised). '
|
|
1453
|
+
+ 'This session will not accept writes again; retry the close instead');
|
|
1454
|
+
// Reported, not swallowed: the daemon must persist "still fenced" rather
|
|
1455
|
+
// than infer success from a call that did not throw.
|
|
1456
|
+
return { admissionRestored: false, reason: 'local_termination_unproven' };
|
|
1457
|
+
}
|
|
1458
|
+
this.closing = false;
|
|
1459
|
+
return { admissionRestored: true };
|
|
1460
|
+
}
|
|
1461
|
+
/**
|
|
1462
|
+
* Prepare a daemon-restart detach without cancelling the remote Mojo
|
|
1463
|
+
* session. Unlike Riff, a Mojo turn can legitimately run for 60 seconds;
|
|
1464
|
+
* shutdown only needs the lineage from its first `system/init`, not the
|
|
1465
|
+
* whole answer. Therefore a pre-init turn waits at most destroySettleMs,
|
|
1466
|
+
* while a known lineage (or an idle backend with no accepted turn) prepares
|
|
1467
|
+
* immediately.
|
|
1468
|
+
*/
|
|
1469
|
+
async prepareShutdownDetach() {
|
|
1470
|
+
if (this.shutdownDetachInFlight)
|
|
1471
|
+
return this.shutdownDetachInFlight;
|
|
1472
|
+
if (this.shutdownDetachPrepared) {
|
|
1473
|
+
return { ok: true, taskId: this.cliSessionId };
|
|
1474
|
+
}
|
|
1475
|
+
if (this.killed) {
|
|
1476
|
+
return { ok: false, taskId: this.cliSessionId, error: 'backend_killed' };
|
|
1477
|
+
}
|
|
1478
|
+
if (this.closing) {
|
|
1479
|
+
return { ok: false, taskId: this.cliSessionId, error: 'explicit_close_in_progress' };
|
|
1480
|
+
}
|
|
1481
|
+
const attempt = Symbol('mojo-shutdown-detach');
|
|
1482
|
+
const acceptedWrites = this.writeChain;
|
|
1483
|
+
const lineageExpected = this.acceptedWriteWithoutLineage;
|
|
1484
|
+
this.shutdownDetachAttempt = attempt;
|
|
1485
|
+
this.shutdownDetaching = true;
|
|
1486
|
+
const prepare = (async () => {
|
|
1487
|
+
if (!this.cliSessionId && lineageExpected) {
|
|
1488
|
+
await new Promise((resolve) => {
|
|
1489
|
+
let settled = false;
|
|
1490
|
+
const finish = () => {
|
|
1491
|
+
if (settled)
|
|
1492
|
+
return;
|
|
1493
|
+
settled = true;
|
|
1494
|
+
clearTimeout(timer);
|
|
1495
|
+
this.lineageWaiters.delete(finish);
|
|
1496
|
+
if (this.shutdownDetachWake === finish)
|
|
1497
|
+
this.shutdownDetachWake = null;
|
|
1498
|
+
resolve();
|
|
1499
|
+
};
|
|
1500
|
+
const timer = setTimeout(finish, this.destroySettleMs);
|
|
1501
|
+
timer.unref?.();
|
|
1502
|
+
this.lineageWaiters.add(finish);
|
|
1503
|
+
this.shutdownDetachWake = finish;
|
|
1504
|
+
void acceptedWrites.then(finish, finish);
|
|
1505
|
+
if (this.cliSessionId)
|
|
1506
|
+
finish();
|
|
1507
|
+
});
|
|
1508
|
+
if (this.killed || this.shutdownDetachAttempt !== attempt || !this.shutdownDetaching) {
|
|
1509
|
+
return { ok: false, taskId: this.cliSessionId, error: 'shutdown_detach_aborted' };
|
|
1510
|
+
}
|
|
1511
|
+
if (!this.cliSessionId) {
|
|
1512
|
+
return {
|
|
1513
|
+
ok: false,
|
|
1514
|
+
taskId: null,
|
|
1515
|
+
error: 'mojo_lineage_not_materialized',
|
|
1516
|
+
};
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
if (this.closing) {
|
|
1520
|
+
return { ok: false, taskId: this.cliSessionId, error: 'explicit_close_in_progress' };
|
|
1521
|
+
}
|
|
1522
|
+
this.shutdownDetachPrepared = true;
|
|
1523
|
+
logger.info(`[mojo] graceful shutdown detach prepared`
|
|
1524
|
+
+ `${this.cliSessionId ? ` (session ${this.cliSessionId})` : ' (no session lineage)'}`);
|
|
1525
|
+
return { ok: true, taskId: this.cliSessionId };
|
|
1526
|
+
})();
|
|
1527
|
+
this.shutdownDetachInFlight = prepare.finally(() => {
|
|
1528
|
+
this.shutdownDetachInFlight = null;
|
|
1529
|
+
});
|
|
1530
|
+
return this.shutdownDetachInFlight;
|
|
1531
|
+
}
|
|
1532
|
+
async abortShutdownDetach() {
|
|
1533
|
+
if (this.killed) {
|
|
1534
|
+
return { ok: false, taskId: this.cliSessionId, error: 'backend_killed' };
|
|
1535
|
+
}
|
|
1536
|
+
if (this.shutdownDetachAbortInFlight)
|
|
1537
|
+
return this.shutdownDetachAbortInFlight;
|
|
1538
|
+
const pending = this.shutdownDetachInFlight;
|
|
1539
|
+
this.shutdownDetachAttempt = null;
|
|
1540
|
+
this.shutdownDetachPrepared = false;
|
|
1541
|
+
this.shutdownDetachWake?.();
|
|
1542
|
+
this.shutdownDetachAbortInFlight = (async () => {
|
|
1543
|
+
if (pending)
|
|
1544
|
+
await pending.catch(() => undefined);
|
|
1545
|
+
if (this.killed) {
|
|
1546
|
+
return { ok: false, taskId: this.cliSessionId, error: 'backend_killed' };
|
|
1547
|
+
}
|
|
1548
|
+
if (this.closing || this.shutdownDetachAttempt !== null) {
|
|
1549
|
+
return {
|
|
1550
|
+
ok: false,
|
|
1551
|
+
taskId: this.cliSessionId,
|
|
1552
|
+
error: this.closing ? 'explicit_close_in_progress' : 'new_shutdown_detach_in_progress',
|
|
1553
|
+
};
|
|
1554
|
+
}
|
|
1555
|
+
this.shutdownDetaching = false;
|
|
1556
|
+
logger.info('[mojo] graceful shutdown detach aborted; write admission restored');
|
|
1557
|
+
return { ok: true, taskId: this.cliSessionId };
|
|
1558
|
+
})().finally(() => {
|
|
1559
|
+
this.shutdownDetachAbortInFlight = null;
|
|
1560
|
+
});
|
|
1561
|
+
return this.shutdownDetachAbortInFlight;
|
|
1562
|
+
}
|
|
1563
|
+
commitShutdownDetach() {
|
|
1564
|
+
this.shutdownDetachPrepared = false;
|
|
1565
|
+
this.shutdownDetachAttempt = null;
|
|
1566
|
+
// Keep admission fenced until the worker exits immediately after commit.
|
|
1567
|
+
this.shutdownDetaching = true;
|
|
1568
|
+
}
|
|
1569
|
+
// ── One turn ─────────────────────────────────────────────────────────────
|
|
1570
|
+
buildArgs(prompt) {
|
|
1571
|
+
const args = ['-p', '--output-format', 'stream-json'];
|
|
1572
|
+
// Token-level deltas → the IM layer can live-edit the reply card.
|
|
1573
|
+
if (this.config.stream !== false)
|
|
1574
|
+
args.push('--include-partial');
|
|
1575
|
+
// `--help` says the default is to auto-REJECT tools needing confirmation,
|
|
1576
|
+
// but that is NOT what happens on the cloud-sandbox path: verified without
|
|
1577
|
+
// --yolo that `echo … > f && cat f` returned return_code 0 with the file
|
|
1578
|
+
// actually written, and that `rm -rf <dir>` likewise succeeded — no
|
|
1579
|
+
// rejection, no warning, no interaction. So this flag is belt-and-braces
|
|
1580
|
+
// rather than load-bearing; we keep it for explicitness and in case a
|
|
1581
|
+
// future mojo build does enforce a confirmation gate headlessly.
|
|
1582
|
+
//
|
|
1583
|
+
// Consequence worth stating plainly (review F4): host execution is now
|
|
1584
|
+
// the DEFAULT, and with --yolo there is no per-tool approval — a mojo
|
|
1585
|
+
// bot's blast radius on the host is bounded only by the OS user and the
|
|
1586
|
+
// bot's allowedUsers gate. Operators who don't accept that must set
|
|
1587
|
+
// `cloud: true` (fully-remote sandbox) or `localDaemon: false`.
|
|
1588
|
+
if (this.config.disableCliBypass !== true)
|
|
1589
|
+
args.push('--yolo');
|
|
1590
|
+
if (this.cliSessionId)
|
|
1591
|
+
args.push('-r', this.cliSessionId);
|
|
1592
|
+
if (this.config.model?.trim())
|
|
1593
|
+
args.push('--model', this.config.model.trim());
|
|
1594
|
+
if (this.config.workspaceId)
|
|
1595
|
+
args.push('--workspace-id', this.config.workspaceId);
|
|
1596
|
+
if (this.config.agentId && !this.cliSessionId)
|
|
1597
|
+
args.push('--agent-id', this.config.agentId);
|
|
1598
|
+
// Run in the cloud sandbox instead of touching the bot host's filesystem.
|
|
1599
|
+
// Shared derivation with buildEnv()/the spawn audit log — see
|
|
1600
|
+
// deriveMojoExecutionMode for the precedence rules (explicit
|
|
1601
|
+
// localDaemon wins and suppresses --cloud) and why hand-copying this
|
|
1602
|
+
// logic produced fail-opens before.
|
|
1603
|
+
if (deriveMojoExecutionMode(this.config).passCloudFlag)
|
|
1604
|
+
args.push('--cloud');
|
|
1605
|
+
if (this.config.idleTimeoutSec)
|
|
1606
|
+
args.push('--idle-timeout', String(this.config.idleTimeoutSec));
|
|
1607
|
+
// Before the positional prompt, which must stay last. Placed after our own
|
|
1608
|
+
// flags so an operator's CLI_EXTRA_ARGS can override them.
|
|
1609
|
+
args.push(...this.extraCliArgs);
|
|
1610
|
+
args.push(this.decorate(prompt));
|
|
1611
|
+
return args;
|
|
1612
|
+
}
|
|
1613
|
+
/** Retry the "session still RUNNING" race with backoff (see SESSION_BUSY_RE). */
|
|
1614
|
+
async runTurnWithBusyRetry(prompt) {
|
|
1615
|
+
for (let attempt = 0;; attempt++) {
|
|
1616
|
+
const busy = await this.runTurn(prompt);
|
|
1617
|
+
if (!busy)
|
|
1618
|
+
return;
|
|
1619
|
+
const delay = BUSY_RETRY_DELAYS_MS[attempt];
|
|
1620
|
+
if (delay === undefined) {
|
|
1621
|
+
this.emitLine('❌ mojo 会话持续处于执行中,本条消息未能送达,请稍后重发。', 'err');
|
|
1622
|
+
this.settleTurn();
|
|
1623
|
+
return;
|
|
1624
|
+
}
|
|
1625
|
+
logger.info(`[mojo] session busy; retrying in ${delay}ms`);
|
|
1626
|
+
await new Promise(r => setTimeout(r, delay));
|
|
1627
|
+
if (this.killed || this.closing)
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
/** Resolves `true` when the turn was rejected because the session is still
|
|
1632
|
+
* RUNNING (caller should retry), `false` once the turn is accounted for. */
|
|
1633
|
+
runTurn(prompt) {
|
|
1634
|
+
return new Promise((resolve, reject) => {
|
|
1635
|
+
const { bin, args } = this.resolveLaunch(this.buildArgs(prompt));
|
|
1636
|
+
this.turnSettled = false;
|
|
1637
|
+
this.streamedThisTurn = false;
|
|
1638
|
+
this.turnFinalText = '';
|
|
1639
|
+
this.stdoutTail = '';
|
|
1640
|
+
// The client PROCESS is not the turn. In host execution the CLI
|
|
1641
|
+
// auto-spawns the per-workspace mojo-daemon as its child and then
|
|
1642
|
+
// BABYSITS it — the process (and its stdio) can stay alive for
|
|
1643
|
+
// hours after the turn's result event (observed live, twice). The
|
|
1644
|
+
// turn is accounted for the moment settleTurn() runs; resolve
|
|
1645
|
+
// there, and treat any later process end as bookkeeping only.
|
|
1646
|
+
let resolved = false;
|
|
1647
|
+
const resolveOnce = (busy) => {
|
|
1648
|
+
if (resolved)
|
|
1649
|
+
return;
|
|
1650
|
+
resolved = true;
|
|
1651
|
+
if (this.turnResolve === settleHook)
|
|
1652
|
+
this.turnResolve = null;
|
|
1653
|
+
resolve(busy);
|
|
1654
|
+
};
|
|
1655
|
+
const settleHook = () => resolveOnce(false);
|
|
1656
|
+
this.turnResolve = settleHook;
|
|
1657
|
+
// The strong boundary must exist BEFORE the child does, and the child
|
|
1658
|
+
// must enter it BEFORE exec (see MOJO_CGROUP_ENROLL_SHIM): post-spawn
|
|
1659
|
+
// migration leaves already-forked descendants outside the cgroup, which
|
|
1660
|
+
// voids the entire strong proof. Null → weak handle fallback. Skipped
|
|
1661
|
+
// entirely once a prior turn proved enrolment impossible on this host.
|
|
1662
|
+
this.preparedBoundary = this.strongBoundaryUnusable
|
|
1663
|
+
? null
|
|
1664
|
+
: prepareContainmentBoundary({
|
|
1665
|
+
sessionId: this.sessionId,
|
|
1666
|
+
generation: this.containmentGeneration,
|
|
1667
|
+
nonce: this.treeNonce,
|
|
1668
|
+
}, {
|
|
1669
|
+
...(this.cgroupRoot !== undefined ? { cgroupRoot: this.cgroupRoot } : {}),
|
|
1670
|
+
...(this.cgroupProcRoot !== undefined ? { procRoot: this.cgroupProcRoot } : {}),
|
|
1671
|
+
});
|
|
1672
|
+
this.usedEnrolShim = this.preparedBoundary !== null;
|
|
1673
|
+
const launchBin = this.preparedBoundary ? '/bin/sh' : bin;
|
|
1674
|
+
const launchArgs = this.preparedBoundary
|
|
1675
|
+
? [
|
|
1676
|
+
'-c', MOJO_CGROUP_ENROLL_SHIM, 'mojo-cgroup-enroll',
|
|
1677
|
+
`${this.preparedBoundary.cgroupPath}/cgroup.procs`,
|
|
1678
|
+
bin, ...args,
|
|
1679
|
+
]
|
|
1680
|
+
: args;
|
|
1681
|
+
const child = spawnProcess(launchBin, launchArgs, {
|
|
1682
|
+
cwd: this.resolveCwd(),
|
|
1683
|
+
env: this.buildEnv(),
|
|
1684
|
+
// stdin MUST be closed: mojo waits on socket-type stdin and an open
|
|
1685
|
+
// pipe makes `-p` block until EOF (observed as a silent hang).
|
|
1686
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1687
|
+
// Own process GROUP, so teardown can prove the whole subtree is
|
|
1688
|
+
// gone instead of only the direct child. mojo runs tools and can
|
|
1689
|
+
// leave detached descendants that inherited X_JWT_TOKEN; those
|
|
1690
|
+
// survive a kill aimed at the direct pid, and the close would
|
|
1691
|
+
// still publish a `closed` row -- dropping the device-isolation
|
|
1692
|
+
// blocker while a credentialed process is still executing.
|
|
1693
|
+
//
|
|
1694
|
+
// Without a dedicated group there is no safe fix: the child would
|
|
1695
|
+
// share the daemon's group, so `kill(-pgid)` would take down the
|
|
1696
|
+
// daemon itself. detached only changes group/session membership
|
|
1697
|
+
// here; the pipes are still owned and awaited, so nothing is
|
|
1698
|
+
// orphaned by this flag on its own.
|
|
1699
|
+
detached: true,
|
|
1700
|
+
});
|
|
1701
|
+
this.child = child;
|
|
1702
|
+
if (typeof child.pid === 'number' && child.pid > 0) {
|
|
1703
|
+
this.lastTurnPid = child.pid;
|
|
1704
|
+
// Bind the identity NOW, while the pid is guaranteed to still be
|
|
1705
|
+
// this child: read later, it could already describe a recycled pid.
|
|
1706
|
+
const id = readProcessIdentity(child.pid, { procRoot: this.procRoot });
|
|
1707
|
+
this.turnIdentity = id.ok ? id.identity : null;
|
|
1708
|
+
if (!id.ok) {
|
|
1709
|
+
// Without an identity the group signal must be refused later, so
|
|
1710
|
+
// say so once, loudly, rather than discovering it during teardown.
|
|
1711
|
+
logger.warn(`[mojo] cannot bind turn identity for pid ${child.pid} (${id.reason}); `
|
|
1712
|
+
+ 'group signalling will be refused and quiescence cannot be proven');
|
|
1713
|
+
}
|
|
1714
|
+
// Close the EMPTY-AT-BIRTH window: the strong handle is recorded
|
|
1715
|
+
// now, but its cgroup stays empty until the shim's own write lands
|
|
1716
|
+
// (~1-5ms). A workerless prove+release racing that window would read
|
|
1717
|
+
// the cgroup empty and drop the blocker while the shim then execs a
|
|
1718
|
+
// credentialed binary. The parent writes child.pid too — idempotent
|
|
1719
|
+
// with the shim's write (same pid), so the cgroup is non-empty from
|
|
1720
|
+
// the instant the handle exists. This does NOT reintroduce the old
|
|
1721
|
+
// post-spawn-migration P0: the shim forks nothing before enrolling,
|
|
1722
|
+
// so there are no pre-enrolment descendants to miss.
|
|
1723
|
+
if (this.preparedBoundary) {
|
|
1724
|
+
try {
|
|
1725
|
+
writeFileSync(`${this.preparedBoundary.cgroupPath}/cgroup.procs`, `${child.pid}\n`);
|
|
1726
|
+
}
|
|
1727
|
+
catch {
|
|
1728
|
+
// The shim's own write remains the authoritative enrolment;
|
|
1729
|
+
// this is belt-and-braces. A failure here is not fatal.
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
// Mint and PERSIST the containment handle before the child can act.
|
|
1733
|
+
// Doing this at close time would be too late: a crash in between is
|
|
1734
|
+
// exactly the window that loses the tree, and a lost tree is one the
|
|
1735
|
+
// next generation can never prove quiescent.
|
|
1736
|
+
try {
|
|
1737
|
+
this.recordTurnContainment(child.pid);
|
|
1738
|
+
}
|
|
1739
|
+
catch (err) {
|
|
1740
|
+
// A child is RUNNING and nothing durable describes it. Failing
|
|
1741
|
+
// only the turn would leave that subtree alive behind a blocker
|
|
1742
|
+
// nobody recorded — so terminate it now, and refuse every close
|
|
1743
|
+
// proof until termination is itself proven (the latch is set
|
|
1744
|
+
// FIRST so a concurrent /close cannot slip through the async
|
|
1745
|
+
// window below).
|
|
1746
|
+
this.containmentUnrecorded = true;
|
|
1747
|
+
void this.containUnrecordedSpawn(child, err).catch((e) => {
|
|
1748
|
+
logger.error(`[mojo] containUnrecordedSpawn crashed (latch retained): ${String(e)}`);
|
|
1749
|
+
});
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
let stderr = '';
|
|
1753
|
+
child.stdout.on('data', (chunk) => {
|
|
1754
|
+
// Fence on child identity for the SAME reason finalize() does:
|
|
1755
|
+
// this client is not awaited (settleTurn resolves the turn), so
|
|
1756
|
+
// its pipe can still deliver bytes hours later — after a LATER
|
|
1757
|
+
// turn has taken over the shared stream/turn state. Without
|
|
1758
|
+
// this, a late line from the OLD client was consumed as the
|
|
1759
|
+
// CURRENT turn's output (reproduced: stale text delivered as
|
|
1760
|
+
// the next turn's answer, real answer dropped).
|
|
1761
|
+
if (this.child !== child)
|
|
1762
|
+
return;
|
|
1763
|
+
this.consume(chunk.toString());
|
|
1764
|
+
});
|
|
1765
|
+
child.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
1766
|
+
child.on('error', (err) => {
|
|
1767
|
+
if (this.child === child)
|
|
1768
|
+
this.child = null;
|
|
1769
|
+
// Spawn failed: nothing ever ran, so nobody enrolled. Reap the
|
|
1770
|
+
// prepared boundary (a recorded handle, if the record already
|
|
1771
|
+
// happened, owns the directory instead and preparedBoundary is null).
|
|
1772
|
+
const prepared = this.preparedBoundary;
|
|
1773
|
+
if (prepared) {
|
|
1774
|
+
this.preparedBoundary = null;
|
|
1775
|
+
void killPreparedBoundary(prepared);
|
|
1776
|
+
}
|
|
1777
|
+
reject(err);
|
|
1778
|
+
});
|
|
1779
|
+
// 'close' alone is NOT a reliable end-of-turn signal here: in host
|
|
1780
|
+
// execution the CLI auto-spawns the per-workspace mojo-daemon as its
|
|
1781
|
+
// CHILD, which inherits our stdout/stderr pipes and keeps them open
|
|
1782
|
+
// for its whole lifetime — 'close' then never fires even though the
|
|
1783
|
+
// client exited minutes ago, the runTurn promise stays pending, and
|
|
1784
|
+
// every later write queues forever (observed live: turn 2 of a DM
|
|
1785
|
+
// session hung 8+ minutes with the daemon healthy). 'exit' + settled
|
|
1786
|
+
// turn is already conclusive; when the turn is NOT settled yet, give
|
|
1787
|
+
// trailing pipe output a bounded grace and then finalize anyway.
|
|
1788
|
+
let finalized = false;
|
|
1789
|
+
const finalize = (code) => {
|
|
1790
|
+
if (finalized)
|
|
1791
|
+
return;
|
|
1792
|
+
finalized = true;
|
|
1793
|
+
// Guarded: a LATER turn may already own this.child by the time
|
|
1794
|
+
// this long-lived client finally ends.
|
|
1795
|
+
const wasCurrent = this.child === child;
|
|
1796
|
+
if (wasCurrent)
|
|
1797
|
+
this.child = null;
|
|
1798
|
+
// Turn already accounted for via its result event — this late
|
|
1799
|
+
// process end is bookkeeping only. Touching stream/turn state
|
|
1800
|
+
// here would corrupt whichever turn is CURRENTLY in flight.
|
|
1801
|
+
if (resolved)
|
|
1802
|
+
return;
|
|
1803
|
+
// Same child fence as the stdout handler: never flush an OLD
|
|
1804
|
+
// client's tail into whichever turn currently owns the stream.
|
|
1805
|
+
if (wasCurrent)
|
|
1806
|
+
this.flushTail();
|
|
1807
|
+
// The pre-exec shim's handshake: enrolment into the prepared cgroup
|
|
1808
|
+
// failed, so it exited WITHOUT exec'ing mojo (nothing credentialed
|
|
1809
|
+
// ran). Gated on `usedEnrolShim` so a genuine mojo exit 97 (on a
|
|
1810
|
+
// weak-handle host, where no shim runs) is NOT misread as this and
|
|
1811
|
+
// does not lose its normal exit handling. On a real enrolment
|
|
1812
|
+
// failure the strong boundary is latched unusable, so the NEXT turn
|
|
1813
|
+
// degrades to a weak handle instead of failing here forever (the
|
|
1814
|
+
// prepare-time probe cannot catch a write-time delegation refusal).
|
|
1815
|
+
if (this.usedEnrolShim && code === MOJO_ENROLL_FAILED_EXIT
|
|
1816
|
+
&& !this.streamedThisTurn && !this.turnSettled) {
|
|
1817
|
+
this.strongBoundaryUnusable = true;
|
|
1818
|
+
logger.error('[mojo] cgroup enrolment was rejected at write time (exit 97); this host cannot '
|
|
1819
|
+
+ 'host a strong boundary, degrading to the weak handle for subsequent turns');
|
|
1820
|
+
this.emitLine('❌ mojo 启动失败:无法进入进程围栏(cgroup 入组被拒),本轮未执行,后续改用降级隔离。', 'err');
|
|
1821
|
+
this.settleTurn();
|
|
1822
|
+
return resolveOnce(false);
|
|
1823
|
+
}
|
|
1824
|
+
// exit 2 == unknown model; stderr carries the authoritative list.
|
|
1825
|
+
if (code === 2 && /未知模型|unknown model/i.test(stderr)) {
|
|
1826
|
+
this.emitLine(`❌ 模型名无效。${stderr.trim()}`, 'err');
|
|
1827
|
+
this.settleTurn();
|
|
1828
|
+
return resolveOnce(false);
|
|
1829
|
+
}
|
|
1830
|
+
// Busy race: nothing was streamed and the session is still RUNNING.
|
|
1831
|
+
if (!this.turnSettled && SESSION_BUSY_RE.test(stderr))
|
|
1832
|
+
return resolveOnce(true);
|
|
1833
|
+
// Dead resume lineage → drop it and let the user retry fresh.
|
|
1834
|
+
if (!this.turnSettled && this.maybeDropLineage(stderr)) {
|
|
1835
|
+
this.settleTurn();
|
|
1836
|
+
return resolveOnce(false);
|
|
1837
|
+
}
|
|
1838
|
+
// A `result` event already settled the turn in the normal path
|
|
1839
|
+
// (including the ask-user cancellation, which also exits 1).
|
|
1840
|
+
if (!this.turnSettled) {
|
|
1841
|
+
if (code !== 0) {
|
|
1842
|
+
this.emitLine(`❌ mojo 退出码 ${code}${stderr.trim() ? `:${stderr.trim()}` : ''}`, 'err');
|
|
1843
|
+
}
|
|
1844
|
+
this.settleTurn();
|
|
1845
|
+
}
|
|
1846
|
+
resolveOnce(false);
|
|
1847
|
+
};
|
|
1848
|
+
child.on('close', (code) => finalize(code));
|
|
1849
|
+
child.on('exit', (code) => {
|
|
1850
|
+
if (this.turnSettled) {
|
|
1851
|
+
// The result event already accounted for this turn; nothing
|
|
1852
|
+
// the withheld pipes could still deliver changes the verdict.
|
|
1853
|
+
finalize(code);
|
|
1854
|
+
return;
|
|
1855
|
+
}
|
|
1856
|
+
// Not settled: stderr/stdout may still be in flight through the
|
|
1857
|
+
// pipes (they outlive the process). Bounded grace, then the same
|
|
1858
|
+
// single finalize path — 'close' beats the timer when it does fire.
|
|
1859
|
+
const t = setTimeout(() => finalize(code), 2_000);
|
|
1860
|
+
t.unref?.();
|
|
1861
|
+
});
|
|
1862
|
+
});
|
|
1863
|
+
}
|
|
1864
|
+
/**
|
|
1865
|
+
* Drop a dead resume lineage so the NEXT message starts a fresh session
|
|
1866
|
+
* instead of re-sending the same doomed `-r <sid>` forever.
|
|
1867
|
+
*
|
|
1868
|
+
* Mirrors RiffBackend's broken-lineage path: the `null` broadcast is what
|
|
1869
|
+
* clears the DAEMON-side persisted id — without it a daemon restart would
|
|
1870
|
+
* resurrect the very session we just declared dead.
|
|
1871
|
+
*
|
|
1872
|
+
* Returns true when the lineage was dropped (caller must not treat the turn
|
|
1873
|
+
* as a generic failure).
|
|
1874
|
+
*/
|
|
1875
|
+
maybeDropLineage(stderr) {
|
|
1876
|
+
// Only meaningful when this turn actually resumed something.
|
|
1877
|
+
if (!this.cliSessionId)
|
|
1878
|
+
return false;
|
|
1879
|
+
if (!RESUME_DEAD_RE.test(stderr))
|
|
1880
|
+
return false;
|
|
1881
|
+
logger.warn(`[mojo] resume lineage ${this.cliSessionId} looks dead; starting fresh next turn`);
|
|
1882
|
+
this.cliSessionId = null;
|
|
1883
|
+
this.taskIdCb?.(null);
|
|
1884
|
+
this.emitLine('⚠️ 之前的 mojo 会话已失效,下一条消息将新建会话(上下文不会延续)。', 'warn');
|
|
1885
|
+
return true;
|
|
1886
|
+
}
|
|
1887
|
+
/** Parse NDJSON incrementally — a chunk may split a line in half. */
|
|
1888
|
+
consume(chunk) {
|
|
1889
|
+
this.stdoutTail += chunk;
|
|
1890
|
+
const lines = this.stdoutTail.split('\n');
|
|
1891
|
+
this.stdoutTail = lines.pop() ?? '';
|
|
1892
|
+
for (const line of lines)
|
|
1893
|
+
this.handleLine(line);
|
|
1894
|
+
}
|
|
1895
|
+
flushTail() {
|
|
1896
|
+
const line = this.stdoutTail;
|
|
1897
|
+
this.stdoutTail = '';
|
|
1898
|
+
if (line.trim())
|
|
1899
|
+
this.handleLine(line);
|
|
1900
|
+
}
|
|
1901
|
+
handleLine(line) {
|
|
1902
|
+
const trimmed = line.trim();
|
|
1903
|
+
if (!trimmed)
|
|
1904
|
+
return;
|
|
1905
|
+
if (!trimmed.startsWith('{')) {
|
|
1906
|
+
// Startup notices / update hints are plain text — surface them dimly
|
|
1907
|
+
// rather than corrupting the transcript.
|
|
1908
|
+
logger.info(`[mojo] ${trimmed}`);
|
|
1909
|
+
return;
|
|
1910
|
+
}
|
|
1911
|
+
let ev;
|
|
1912
|
+
try {
|
|
1913
|
+
ev = JSON.parse(trimmed);
|
|
1914
|
+
}
|
|
1915
|
+
catch {
|
|
1916
|
+
logger.warn(`[mojo] unparseable stream line: ${trimmed.slice(0, 200)}`);
|
|
1917
|
+
return;
|
|
1918
|
+
}
|
|
1919
|
+
switch (ev.type) {
|
|
1920
|
+
case 'system': {
|
|
1921
|
+
const e = ev;
|
|
1922
|
+
if (e.subtype === 'init')
|
|
1923
|
+
this.adoptSession(e.session_id, e.model);
|
|
1924
|
+
return;
|
|
1925
|
+
}
|
|
1926
|
+
case 'text_delta': {
|
|
1927
|
+
const e = ev;
|
|
1928
|
+
if (e.text) {
|
|
1929
|
+
this.streamedThisTurn = true;
|
|
1930
|
+
this.emitText(e.text);
|
|
1931
|
+
}
|
|
1932
|
+
return;
|
|
1933
|
+
}
|
|
1934
|
+
case 'text': {
|
|
1935
|
+
// With --include-partial the deltas already rendered this text.
|
|
1936
|
+
const e = ev;
|
|
1937
|
+
if (!this.streamedThisTurn && e.text)
|
|
1938
|
+
this.emitText(e.text);
|
|
1939
|
+
return;
|
|
1940
|
+
}
|
|
1941
|
+
case 'tool_call': {
|
|
1942
|
+
const e = ev;
|
|
1943
|
+
this.emitLine(`🔧 ${e.name ?? '(tool)'}${this.summarizeInput(e.input)}`, 'info');
|
|
1944
|
+
return;
|
|
1945
|
+
}
|
|
1946
|
+
case 'tool_result': {
|
|
1947
|
+
// Without this the user sees `🔧 Bash {...}`, then 20–30s of dead
|
|
1948
|
+
// air while the tool runs, then a sudden final answer — it reads
|
|
1949
|
+
// like a hang. Surface a one-line outcome instead.
|
|
1950
|
+
const e = ev;
|
|
1951
|
+
this.emitLine(this.summarizeToolResult(e.output), 'plain');
|
|
1952
|
+
return;
|
|
1953
|
+
}
|
|
1954
|
+
case 'result':
|
|
1955
|
+
this.handleResult(ev);
|
|
1956
|
+
return;
|
|
1957
|
+
default:
|
|
1958
|
+
logger.info(`[mojo] unhandled event type: ${String(ev.type)}`);
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
adoptSession(id, model) {
|
|
1962
|
+
if (!id || id === this.cliSessionId)
|
|
1963
|
+
return;
|
|
1964
|
+
this.cliSessionId = id;
|
|
1965
|
+
this.acceptedWriteWithoutLineage = false;
|
|
1966
|
+
for (const wake of this.lineageWaiters)
|
|
1967
|
+
wake();
|
|
1968
|
+
// Available in the FIRST event, so the lineage is persisted even if the
|
|
1969
|
+
// turn later dies — no grok-style "recapture the id afterwards" needed.
|
|
1970
|
+
this.taskIdCb?.(id);
|
|
1971
|
+
logger.info(`[mojo] session ${id} (model=${model ?? 'default'})`);
|
|
1972
|
+
}
|
|
1973
|
+
handleResult(ev) {
|
|
1974
|
+
// Some flows emit only `result` without any text event.
|
|
1975
|
+
if (!this.streamedThisTurn && typeof ev.result === 'string' && ev.result) {
|
|
1976
|
+
this.emitText(ev.result);
|
|
1977
|
+
}
|
|
1978
|
+
const warnings = Array.isArray(ev.warnings) ? ev.warnings : [];
|
|
1979
|
+
const askSkipped = warnings.some(w => ASK_USER_SKIPPED_RE.test(String(w)));
|
|
1980
|
+
if (askSkipped) {
|
|
1981
|
+
// The single most confusing failure mode: the agent wanted to ask a
|
|
1982
|
+
// clarifying question, mojo dropped it, and the turn came back
|
|
1983
|
+
// cancelled with little or no text.
|
|
1984
|
+
this.emitLine('⚠️ mojo 想向你追问以确认细节,但无头模式下提问会被自动跳过,本回合已中断。', 'warn');
|
|
1985
|
+
this.emitLine('请把缺少的信息(例如具体文件 / 路径 / 目标)补全后重新发一次。', 'info');
|
|
1986
|
+
}
|
|
1987
|
+
else {
|
|
1988
|
+
for (const w of warnings)
|
|
1989
|
+
this.emitLine(`⚠️ ${String(w)}`, 'warn');
|
|
1990
|
+
}
|
|
1991
|
+
if (ev.error && !askSkipped)
|
|
1992
|
+
this.emitLine(`❌ ${this.fmtErr(ev.error)}`, 'err');
|
|
1993
|
+
this.settleTurn();
|
|
1994
|
+
}
|
|
1995
|
+
/**
|
|
1996
|
+
* Fire the turn boundary exactly once.
|
|
1997
|
+
*
|
|
1998
|
+
* This is the ONLY authority on when a mojo turn ends, which is why the
|
|
1999
|
+
* worker must not run its generic IdleDetector for this backend: that
|
|
2000
|
+
* detector infers "done" from ~2s of output quiescence, and a mojo turn goes
|
|
2001
|
+
* quiet for far longer while a tool runs. An early idle would re-arm
|
|
2002
|
+
* prompt-ready mid-turn, flushing queued messages into a session that is
|
|
2003
|
+
* still RUNNING (rejected — see SESSION_BUSY_RE) and attributing the reply
|
|
2004
|
+
* to the wrong turn/card. See the `isRemoteBackendType` gate in worker.ts.
|
|
2005
|
+
*/
|
|
2006
|
+
settleTurn() {
|
|
2007
|
+
// `turnSettled` alone provides the once-per-turn guarantee. A
|
|
2008
|
+
// `seenResults` Set used to be maintained alongside it and described as
|
|
2009
|
+
// "session ids whose boundary already fired", but nothing ever queried it
|
|
2010
|
+
// — it was only added to and, past a cap, cleared wholesale. Dead state
|
|
2011
|
+
// reading as if it enforced cross-turn dedup, so it is gone rather than
|
|
2012
|
+
// left to mislead. If per-session result dedup is ever actually needed,
|
|
2013
|
+
// it has to be a real lookup here.
|
|
2014
|
+
if (this.turnSettled)
|
|
2015
|
+
return;
|
|
2016
|
+
this.turnSettled = true;
|
|
2017
|
+
// BEFORE taskDoneCb: that callback re-arms prompt-ready and flushes
|
|
2018
|
+
// queued follow-ups, so emitting the answer afterwards would race the
|
|
2019
|
+
// next turn's card/turn attribution. A turn that produced no prose
|
|
2020
|
+
// (tool-only, cancelled, failed) hands over '' and the worker's gate
|
|
2021
|
+
// drops it — the backend does not decide deliverability.
|
|
2022
|
+
const finalText = this.turnFinalText;
|
|
2023
|
+
this.turnFinalText = '';
|
|
2024
|
+
this.turnFinalCb?.(finalText);
|
|
2025
|
+
this.taskDoneCb?.();
|
|
2026
|
+
// Free the runTurn promise (and with it the write chain) NOW: the
|
|
2027
|
+
// client process is deliberately not awaited — with an embedded
|
|
2028
|
+
// execution daemon as its child it can legitimately outlive the turn
|
|
2029
|
+
// by hours, and waiting on it wedged every subsequent turn.
|
|
2030
|
+
const r = this.turnResolve;
|
|
2031
|
+
this.turnResolve = null;
|
|
2032
|
+
r?.();
|
|
2033
|
+
}
|
|
2034
|
+
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
2035
|
+
/** `error` is an object ({code, message, retryable}) on both envelope shapes;
|
|
2036
|
+
* naive interpolation yields "[object Object]". */
|
|
2037
|
+
fmtErr(err) {
|
|
2038
|
+
if (!err)
|
|
2039
|
+
return '未知错误';
|
|
2040
|
+
if (typeof err === 'string')
|
|
2041
|
+
return err;
|
|
2042
|
+
if (err instanceof Error)
|
|
2043
|
+
return err.message;
|
|
2044
|
+
const e = err;
|
|
2045
|
+
const code = e.code ? `[${e.code}] ` : '';
|
|
2046
|
+
return `${code}${e.message ?? JSON.stringify(err)}`;
|
|
2047
|
+
}
|
|
2048
|
+
/** Condense a tool_result payload into one status line. The output is a JSON
|
|
2049
|
+
* string for shell-like tools ({return_code, stdout, stderr, status}) but may
|
|
2050
|
+
* be arbitrary text for others, so both shapes are handled. */
|
|
2051
|
+
summarizeToolResult(output) {
|
|
2052
|
+
if (output === undefined || output === null)
|
|
2053
|
+
return ' ↳ (无输出)';
|
|
2054
|
+
const raw = typeof output === 'string' ? output : JSON.stringify(output);
|
|
2055
|
+
let parsed;
|
|
2056
|
+
try {
|
|
2057
|
+
parsed = JSON.parse(raw);
|
|
2058
|
+
}
|
|
2059
|
+
catch { /* plain text result */ }
|
|
2060
|
+
if (parsed && typeof parsed === 'object' && 'return_code' in parsed) {
|
|
2061
|
+
const p = parsed;
|
|
2062
|
+
const ok = p.return_code === 0;
|
|
2063
|
+
const body = String(p.stdout || p.stderr || '').trim();
|
|
2064
|
+
const head = ok ? ' ↳ ✓' : ` ↳ ✗ exit ${String(p.return_code)}`;
|
|
2065
|
+
return body ? `${head} ${this.clip(body)}` : head;
|
|
2066
|
+
}
|
|
2067
|
+
return ` ↳ ${this.clip(raw)}`;
|
|
2068
|
+
}
|
|
2069
|
+
clip(s, n = 160) {
|
|
2070
|
+
const oneLine = s.replace(/\s+/g, ' ').trim();
|
|
2071
|
+
return oneLine.length > n ? `${oneLine.slice(0, n)}…` : oneLine;
|
|
2072
|
+
}
|
|
2073
|
+
summarizeInput(input) {
|
|
2074
|
+
if (!input)
|
|
2075
|
+
return '';
|
|
2076
|
+
const s = typeof input === 'string' ? input : JSON.stringify(input);
|
|
2077
|
+
const oneLine = s.replace(/\s+/g, ' ').trim();
|
|
2078
|
+
return oneLine ? ` ${oneLine.length > 120 ? `${oneLine.slice(0, 120)}…` : oneLine}` : '';
|
|
2079
|
+
}
|
|
2080
|
+
/**
|
|
2081
|
+
* Prepend the platform-owned skill block and the operator's systemPrompt.
|
|
2082
|
+
*
|
|
2083
|
+
* Order matters and is deliberate: the skill catalog is APPENDED after the
|
|
2084
|
+
* operator prompt, never merged into it. Folding it into `systemPrompt` would
|
|
2085
|
+
* mean a bot that sets its own prompt silently loses skill discovery — the
|
|
2086
|
+
* same trap riff documented for its mandatory routing rules.
|
|
2087
|
+
*
|
|
2088
|
+
* `builtinSkillBlock` is only populated for `prompt` / `off`; in `global`
|
|
2089
|
+
* mode the files are already on disk (~/.mojo/skills) so it stays empty.
|
|
2090
|
+
*/
|
|
2091
|
+
decorate(prompt) {
|
|
2092
|
+
const preamble = [
|
|
2093
|
+
this.config.systemPrompt?.trim(),
|
|
2094
|
+
this.config.builtinSkillBlock?.trim(),
|
|
2095
|
+
this.hostGuidanceBlock(),
|
|
2096
|
+
]
|
|
2097
|
+
.filter((s) => !!s)
|
|
2098
|
+
.join('\n\n');
|
|
2099
|
+
return preamble ? `${preamble}\n\n---\n\n${prompt}` : prompt;
|
|
2100
|
+
}
|
|
2101
|
+
/**
|
|
2102
|
+
* Host-execution guidance (undefined in cloud mode). Two compensations for
|
|
2103
|
+
* the isolated per-session cwd:
|
|
2104
|
+
* 1. the initial working directory is NOT the repo — point the agent at
|
|
2105
|
+
* the real one so repo work still lands in the right place;
|
|
2106
|
+
* 2. every botmux command carries an explicit `--session-id`: the
|
|
2107
|
+
* execution daemon's env belongs to whichever session spawned it, so
|
|
2108
|
+
* an inherited BOTMUX_SESSION_ID must never be what routes a reply
|
|
2109
|
+
* (defence in depth — the isolated daemon already carries the right
|
|
2110
|
+
* env, this survives even a regression back to a shared daemon).
|
|
2111
|
+
*/
|
|
2112
|
+
hostGuidanceBlock() {
|
|
2113
|
+
if (!this.hostExecution())
|
|
2114
|
+
return undefined;
|
|
2115
|
+
const repo = this.realWorkingDir();
|
|
2116
|
+
const lines = [
|
|
2117
|
+
repo
|
|
2118
|
+
? `你的工作仓库在 ${repo} 。当前初始工作目录是一个会话隔离目录,不含仓库文件;涉及仓库文件的读写或命令,请显式 \`cd ${repo}\` 后再执行。`
|
|
2119
|
+
: '当前初始工作目录是一个会话隔离目录;如需在某个仓库/目录下工作,请先显式 cd 过去。',
|
|
2120
|
+
`所有 botmux 命令请显式带 \`--session-id ${this.sessionId}\`(例:\`botmux send --session-id ${this.sessionId} ...\`),确保回话投递到本会话。`,
|
|
2121
|
+
];
|
|
2122
|
+
return lines.join('\n');
|
|
2123
|
+
}
|
|
2124
|
+
buildEnv() {
|
|
2125
|
+
// Layering, lowest → highest precedence:
|
|
2126
|
+
// worker-supplied env (BOTMUX_* session context, redacted process env)
|
|
2127
|
+
// → per-bot injectEnv (bots.json `env`, already sanitized)
|
|
2128
|
+
// → bots.json `mojo.env`
|
|
2129
|
+
// Falling back to process.env keeps direct/unit use working when spawn()
|
|
2130
|
+
// was never called.
|
|
2131
|
+
// Shared with the launcher's wrapper resolution — see
|
|
2132
|
+
// buildEffectiveChildEnv. Do NOT re-inline this layering: the two sites
|
|
2133
|
+
// drifted apart once already and the launcher silently dropped mojo.env.
|
|
2134
|
+
const env = buildEffectiveChildEnv({
|
|
2135
|
+
base: this.spawnOpts?.env ?? process.env,
|
|
2136
|
+
botEnv: this.spawnOpts?.injectEnv,
|
|
2137
|
+
mojoEnv: this.config.env,
|
|
2138
|
+
});
|
|
2139
|
+
// Prefer an injected JWT so the bot never depends on an interactive
|
|
2140
|
+
// `mojo auth login` on the host. Verified: X_JWT_TOKEN makes
|
|
2141
|
+
// `mojo auth status --json` report mode=jwt / source=env.
|
|
2142
|
+
//
|
|
2143
|
+
// Read from the ALREADY-MERGED env, never from process.env: the daemon's
|
|
2144
|
+
// ambient X_JWT_TOKEN is the lowest layer of that merge, so reaching back
|
|
2145
|
+
// to process.env here would let the host's token override a per-bot one
|
|
2146
|
+
// and silently run the bot as the wrong identity.
|
|
2147
|
+
// `jwtEnv` decides only WHERE the value is read from; the child is always
|
|
2148
|
+
// handed it under the canonical name below. The remote-execution proof
|
|
2149
|
+
// relies on exactly this (mojoUnprovableEnvKeys exempts the canonical name
|
|
2150
|
+
// and never `jwtEnv`), so both sites share one constant rather than two
|
|
2151
|
+
// literals that could drift apart and silently re-open the bypass.
|
|
2152
|
+
const jwtKey = this.config.jwtEnv ?? MOJO_CANONICAL_JWT_ENV_KEY;
|
|
2153
|
+
if (this.liveJwt !== undefined) {
|
|
2154
|
+
// A live snapshot is authoritative and already includes the daemon's
|
|
2155
|
+
// ambient fallback. `null` therefore means "no credential anywhere", so
|
|
2156
|
+
// the inherited value must be REMOVED rather than left to stand in —
|
|
2157
|
+
// otherwise deleting `mojo.jwt` / `jwtEnv` revived the stale token.
|
|
2158
|
+
delete env[jwtKey];
|
|
2159
|
+
delete env[MOJO_CANONICAL_JWT_ENV_KEY];
|
|
2160
|
+
if (this.liveJwt !== null)
|
|
2161
|
+
env[MOJO_CANONICAL_JWT_ENV_KEY] = this.liveJwt;
|
|
2162
|
+
}
|
|
2163
|
+
else {
|
|
2164
|
+
const jwt = this.config.jwt ?? env[jwtKey];
|
|
2165
|
+
if (jwt)
|
|
2166
|
+
env[MOJO_CANONICAL_JWT_ENV_KEY] = jwt;
|
|
2167
|
+
}
|
|
2168
|
+
// ── Control plane: config is the ONLY source ──────────────────────────
|
|
2169
|
+
// Drop every inherited control-plane variable BEFORE re-deriving it. The
|
|
2170
|
+
// mojo CLI reads its endpoint/profile/execution mode from env, so leaving
|
|
2171
|
+
// an inherited value in place is a back door around the frozen identity:
|
|
2172
|
+
// a live `env: { AGENT_BASE_URL: <tenant-b> }` would move an existing
|
|
2173
|
+
// session to another tenant even though `baseUrl` itself is frozen. Note
|
|
2174
|
+
// these were previously only CONDITIONALLY overwritten (`if (baseUrl)`),
|
|
2175
|
+
// so a session whose frozen snapshot had no baseUrl silently inherited it.
|
|
2176
|
+
//
|
|
2177
|
+
// Unconditional delete also means "frozen as unset" is honoured: the CLI
|
|
2178
|
+
// falls back to its own default instead of a value the operator added
|
|
2179
|
+
// after this session was created.
|
|
2180
|
+
for (const key of MOJO_CONTROL_ENV_KEYS)
|
|
2181
|
+
delete env[key];
|
|
2182
|
+
if (this.config.baseUrl)
|
|
2183
|
+
env.AGENT_BASE_URL = this.config.baseUrl;
|
|
2184
|
+
if (this.config.ppeEnv)
|
|
2185
|
+
env.MOJO_PPE_ENV = this.config.ppeEnv;
|
|
2186
|
+
// Execution mode. Host execution is the DEFAULT, matching every other
|
|
2187
|
+
// CLI adapter (claude-code, codex, … all run on the bot host): a mojo
|
|
2188
|
+
// bot with no `mojo` block used to be forced into the cloud sandbox
|
|
2189
|
+
// (AGENT_LOCAL_DAEMON=0 without --cloud), where `botmux` does not exist
|
|
2190
|
+
// while the skill catalog still teaches `botmux send` — the session
|
|
2191
|
+
// could neither see the host nor reply through the current bot.
|
|
2192
|
+
// '0' is written exactly when the config itself asks for it:
|
|
2193
|
+
// · cloud=true with localDaemon unset — the fully-remote shape that
|
|
2194
|
+
// isMojoFullyRemote() accepts as proof of remote execution, or
|
|
2195
|
+
// · an explicit localDaemon=false — operator opt-out of host tools
|
|
2196
|
+
// (without cloud=true the CLI then falls back to its sandbox).
|
|
2197
|
+
// `=== true` / `=== false`, NOT truthy: this value must stay in strict
|
|
2198
|
+
// lockstep with isMojoFullyRemote(), which compares strictly. A truthy
|
|
2199
|
+
// check here made the string "false" mean "local execution ON" while the
|
|
2200
|
+
// sandbox check read it as "not local, safe to bypass" — isolation off and
|
|
2201
|
+
// host execution on at once. Always written (never inherited), so an
|
|
2202
|
+
// ambient AGENT_LOCAL_DAEMON cannot flip the mode either way.
|
|
2203
|
+
env.AGENT_LOCAL_DAEMON = deriveMojoExecutionMode(this.config).agentLocalDaemon;
|
|
2204
|
+
// Never let an interactive upgrade prompt pollute the NDJSON stream.
|
|
2205
|
+
env.MOJO_NO_UPDATE = '1';
|
|
2206
|
+
// Termination authority, not configuration: this value is what makes the
|
|
2207
|
+
// subtree enumerable in /proc after a descendant escapes the process group
|
|
2208
|
+
// via setsid. It carries no privilege and is safe to expose to the child.
|
|
2209
|
+
//
|
|
2210
|
+
// Asserted LAST, as a hard invariant of this function: not only can no
|
|
2211
|
+
// config layer shadow it, no DELETE above can erase it either. Config
|
|
2212
|
+
// validation already rejects a `jwtEnv` naming a reserved key, but frozen
|
|
2213
|
+
// snapshots written by older builds bypass re-validation, and a
|
|
2214
|
+
// `delete env[jwtKey]` that hit this name would blind scanMojoTree to
|
|
2215
|
+
// every escaped descendant while the close still reported clean.
|
|
2216
|
+
env[MOJO_TREE_NONCE_ENV] = this.treeNonce;
|
|
2217
|
+
return env;
|
|
2218
|
+
}
|
|
2219
|
+
/** Single-shot CLI call returning one JSON envelope (session.* subcommands). */
|
|
2220
|
+
async runCliJson(args) {
|
|
2221
|
+
const out = await this.runCli(args);
|
|
2222
|
+
// Startup notices can precede the envelope — take the last JSON line
|
|
2223
|
+
// rather than parsing the whole buffer.
|
|
2224
|
+
const line = out.split(/\r?\n/).map(l => l.trim())
|
|
2225
|
+
.filter(l => l.startsWith('{') && l.endsWith('}')).pop();
|
|
2226
|
+
if (!line)
|
|
2227
|
+
throw new Error(`no JSON envelope in output: ${out.slice(0, 300)}`);
|
|
2228
|
+
const env = JSON.parse(line);
|
|
2229
|
+
if (env.error)
|
|
2230
|
+
throw new Error(this.fmtErr(env.error));
|
|
2231
|
+
return env;
|
|
2232
|
+
}
|
|
2233
|
+
runCli(args) {
|
|
2234
|
+
return new Promise((resolve, reject) => {
|
|
2235
|
+
const launch = this.resolveLaunch(args);
|
|
2236
|
+
const child = spawnProcess(launch.bin, launch.args, {
|
|
2237
|
+
cwd: this.resolveCwd(),
|
|
2238
|
+
env: this.buildEnv(),
|
|
2239
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
2240
|
+
});
|
|
2241
|
+
let stdout = '';
|
|
2242
|
+
let stderr = '';
|
|
2243
|
+
const timer = setTimeout(() => {
|
|
2244
|
+
child.kill('SIGKILL');
|
|
2245
|
+
reject(new Error(`mojo ${args.join(' ')} timed out after ${this.cliTimeoutMs}ms`));
|
|
2246
|
+
}, this.cliTimeoutMs);
|
|
2247
|
+
child.stdout.on('data', (d) => { stdout += d.toString(); });
|
|
2248
|
+
child.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
2249
|
+
child.on('error', (err) => { clearTimeout(timer); reject(err); });
|
|
2250
|
+
child.on('close', (code) => {
|
|
2251
|
+
clearTimeout(timer);
|
|
2252
|
+
if (code !== 0 && !stdout.trim()) {
|
|
2253
|
+
return reject(new Error(`mojo exited ${code}: ${stderr.trim() || '(no stderr)'}`));
|
|
2254
|
+
}
|
|
2255
|
+
resolve(stdout);
|
|
2256
|
+
});
|
|
2257
|
+
});
|
|
2258
|
+
}
|
|
2259
|
+
/** Probe the authoritative model list: an invalid --model exits 2 and prints
|
|
2260
|
+
* "可用模型:a、b、c" to stderr. */
|
|
2261
|
+
async probeModels() {
|
|
2262
|
+
try {
|
|
2263
|
+
await this.runCli(['-p', '--model', '__botmux_probe_invalid__', 'x']);
|
|
2264
|
+
return null;
|
|
2265
|
+
}
|
|
2266
|
+
catch (err) {
|
|
2267
|
+
const msg = err instanceof Error ? err.message : String(err ?? '');
|
|
2268
|
+
const m = /可用模型:(.+)$/m.exec(msg);
|
|
2269
|
+
return m ? m[1].split(/[、,]/).map(s => s.trim()).filter(Boolean) : null;
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
/** `mojo auth status --json` → {logged_in, identity, mode, source, expires_at}. */
|
|
2273
|
+
async authStatus() {
|
|
2274
|
+
const out = await this.runCli(['auth', 'status', '--json']);
|
|
2275
|
+
const line = out.split(/\r?\n/).map(l => l.trim()).filter(l => l.startsWith('{')).pop();
|
|
2276
|
+
return line ? JSON.parse(line) : null;
|
|
2277
|
+
}
|
|
2278
|
+
emitLine(text, style = 'info') {
|
|
2279
|
+
const codes = {
|
|
2280
|
+
info: '\x1b[36m',
|
|
2281
|
+
warn: '\x1b[33m',
|
|
2282
|
+
ok: '\x1b[32m',
|
|
2283
|
+
err: '\x1b[31m',
|
|
2284
|
+
title: '\x1b[1m',
|
|
2285
|
+
plain: '',
|
|
2286
|
+
};
|
|
2287
|
+
const open = codes[style] ?? '';
|
|
2288
|
+
const close = open ? '\x1b[0m' : '';
|
|
2289
|
+
const line = `\r\n${open}${text}${close}\r\n`;
|
|
2290
|
+
this.outputBuffer += line;
|
|
2291
|
+
this.dataCb?.(line);
|
|
2292
|
+
}
|
|
2293
|
+
/** Normalize newlines for xterm rendering (bare \n → \r\n). */
|
|
2294
|
+
emitText(text) {
|
|
2295
|
+
const normalized = text.replace(/\r?\n/g, '\r\n');
|
|
2296
|
+
this.outputBuffer += normalized;
|
|
2297
|
+
// Keep the bridge copy in the CLI's own newline convention — it is
|
|
2298
|
+
// destined for a Lark message, not a terminal.
|
|
2299
|
+
this.turnFinalText += text;
|
|
2300
|
+
this.dataCb?.(normalized);
|
|
2301
|
+
}
|
|
2302
|
+
}
|
|
2303
|
+
/**
|
|
2304
|
+
* Classify a failed `session cancel` into the outcome model.
|
|
2305
|
+
*
|
|
2306
|
+
* Currently ALWAYS `failed`. Distinguishing "the session had already finished"
|
|
2307
|
+
* from "cancellation is broken" requires the real @byted/mojo error codes/states,
|
|
2308
|
+
* which are not calibrated yet — and guessing from stderr text is precisely the
|
|
2309
|
+
* mistake that made the old boolean ambiguous. Failing closed here means a close
|
|
2310
|
+
* refuses rather than silently claiming a still-running session is gone.
|
|
2311
|
+
*
|
|
2312
|
+
* When the codes ARE calibrated (needs intranet CLI + a real JWT), this is the one
|
|
2313
|
+
* place that changes: return `already_terminal` with the matched code as evidence.
|
|
2314
|
+
*/
|
|
2315
|
+
function classifyMojoCancelFailure(err) {
|
|
2316
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2317
|
+
return { kind: 'failed', message, retryable: true };
|
|
2318
|
+
}
|
|
2319
|
+
/**
|
|
2320
|
+
* Cancel a mojo session by id WITHOUT a live backend instance.
|
|
2321
|
+
*
|
|
2322
|
+
* The daemon needs this on the workerless `/close` path: the worker is already
|
|
2323
|
+
* gone, so `MojoBackend.destroySession()` is unreachable, yet the server-side
|
|
2324
|
+
* session must stop consuming cloud sandbox time (and stop an agent that may
|
|
2325
|
+
* still hold injected credentials). Mirrors `cancelRiffTaskById`.
|
|
2326
|
+
*
|
|
2327
|
+
* One retry, then a STRUCTURED outcome — see MojoCancelOutcome for why this is no
|
|
2328
|
+
* longer a boolean.
|
|
2329
|
+
*/
|
|
2330
|
+
export async function cancelMojoSessionById(config, sessionId) {
|
|
2331
|
+
// Reuse the instance's CLI plumbing (env layering, JSON envelope parsing,
|
|
2332
|
+
// timeout) rather than duplicating spawn logic here. The sentinel session id
|
|
2333
|
+
// is only used for logging.
|
|
2334
|
+
const backend = (() => {
|
|
2335
|
+
try {
|
|
2336
|
+
return new MojoBackend(config, 'orphan-cancel', { controlPlaneOnly: true });
|
|
2337
|
+
}
|
|
2338
|
+
catch {
|
|
2339
|
+
return null;
|
|
2340
|
+
}
|
|
2341
|
+
})();
|
|
2342
|
+
if (backend === null) {
|
|
2343
|
+
// The constructor fails closed when the containment store is unreadable,
|
|
2344
|
+
// which is correct — but this function DECLARES a structured outcome, and a
|
|
2345
|
+
// throw here does not stay local. Its call site in worker-pool is a
|
|
2346
|
+
// fire-and-forget `void cancelMojoSessionById(...).then(...)` with no
|
|
2347
|
+
// .catch(), and the daemon installs no unhandledRejection handler, so on
|
|
2348
|
+
// Node 22 an unreadable store would terminate the daemon and every session
|
|
2349
|
+
// it serves. Fail-closed must not mean fail-crash.
|
|
2350
|
+
//
|
|
2351
|
+
// Reporting `failed` keeps the intended semantics and is strictly stronger:
|
|
2352
|
+
// an unreadable store is not proof the remote session is gone, so the caller
|
|
2353
|
+
// refuses the close and the device-isolation blocker is retained.
|
|
2354
|
+
logger.error(`[mojo] cannot construct a cancel backend for session ${sessionId}: the containment `
|
|
2355
|
+
+ 'store is unreadable, so the close is refused (the blocker is retained)');
|
|
2356
|
+
return {
|
|
2357
|
+
kind: 'failed',
|
|
2358
|
+
message: `containment store unreadable for session ${sessionId}`,
|
|
2359
|
+
retryable: true,
|
|
2360
|
+
};
|
|
2361
|
+
}
|
|
2362
|
+
const attempt = async () => {
|
|
2363
|
+
await backend['runCliJson'](['session', 'cancel', sessionId]);
|
|
2364
|
+
};
|
|
2365
|
+
// The LOCAL subtree first. "There is no worker" must never be read as "there is
|
|
2366
|
+
// no local process": the worker dying is exactly what orphans a credentialed
|
|
2367
|
+
// descendant. Cancelling the remote session says nothing about a local one, so
|
|
2368
|
+
// without this check the daemon publishes the row `closed`, the row drops out of
|
|
2369
|
+
// the device-isolation inventory, and the blocker disappears while a credentialed
|
|
2370
|
+
// process is still executing.
|
|
2371
|
+
const local = proveWorkerlessLocalSubtree(sessionId);
|
|
2372
|
+
if (local.unproven)
|
|
2373
|
+
return local.unproven;
|
|
2374
|
+
// A weak-only local proof must survive every remote-gone outcome, or the
|
|
2375
|
+
// caller publishes a plain `closed` row while the handle (and the blocker)
|
|
2376
|
+
// silently stays behind — the exact laundering this type exists to prevent.
|
|
2377
|
+
const carryResidual = (outcome) => local.residual !== null && outcome.kind !== 'failed'
|
|
2378
|
+
? { ...outcome, localResidual: local.residual }
|
|
2379
|
+
: outcome;
|
|
2380
|
+
try {
|
|
2381
|
+
await attempt();
|
|
2382
|
+
return carryResidual({ kind: 'cancelled' });
|
|
2383
|
+
}
|
|
2384
|
+
catch {
|
|
2385
|
+
try {
|
|
2386
|
+
await attempt();
|
|
2387
|
+
return carryResidual({ kind: 'cancelled' });
|
|
2388
|
+
}
|
|
2389
|
+
catch (err) {
|
|
2390
|
+
const outcome = classifyMojoCancelFailure(err);
|
|
2391
|
+
logger.warn(`[mojo] orphan session cancel failed (session ${sessionId} may keep running remotely): ${String(err)}`);
|
|
2392
|
+
return carryResidual(outcome);
|
|
2393
|
+
}
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
/**
|
|
2397
|
+
* Prove (and discharge) the LOCAL subtree a dead worker may have left behind.
|
|
2398
|
+
*
|
|
2399
|
+
* `unproven: null` means every outstanding handle was proven quiescent (or none
|
|
2400
|
+
* existed). `residual` then reports whether any of those proofs was weak-only,
|
|
2401
|
+
* in which case the handle stays in the store and the caller must surface a
|
|
2402
|
+
* residual close instead of a plain one.
|
|
2403
|
+
*/
|
|
2404
|
+
export function proveWorkerlessLocalSubtree(sessionId, opts = {}) {
|
|
2405
|
+
const procRoot = opts.procRoot ?? '/proc';
|
|
2406
|
+
const excludePids = [process.pid, process.ppid].filter(pid => typeof pid === 'number' && pid > 0);
|
|
2407
|
+
let outstanding;
|
|
2408
|
+
try {
|
|
2409
|
+
outstanding = containmentHandles(sessionId);
|
|
2410
|
+
}
|
|
2411
|
+
catch (err) {
|
|
2412
|
+
// Unreadable store: we cannot know what is outstanding, so the close must not
|
|
2413
|
+
// succeed. Retryable, because a later read may well work.
|
|
2414
|
+
return {
|
|
2415
|
+
unproven: {
|
|
2416
|
+
kind: 'failed',
|
|
2417
|
+
message: `cannot read containment store for ${sessionId}: ${String(err)}`,
|
|
2418
|
+
retryable: true,
|
|
2419
|
+
},
|
|
2420
|
+
residual: null,
|
|
2421
|
+
};
|
|
2422
|
+
}
|
|
2423
|
+
let residual = null;
|
|
2424
|
+
for (const handle of outstanding) {
|
|
2425
|
+
const verdict = proveContainmentQuiescent(handle, {
|
|
2426
|
+
procRoot,
|
|
2427
|
+
scan: weak => {
|
|
2428
|
+
const scan = scanMojoTree(weak.rootPid, weak.nonce, {
|
|
2429
|
+
procRoot,
|
|
2430
|
+
excludePids,
|
|
2431
|
+
// Recorded identity gates PGID claiming, as on the teardown path.
|
|
2432
|
+
rootIdentity: { pid: weak.rootPid, bootId: weak.bootId, starttime: weak.startTime },
|
|
2433
|
+
});
|
|
2434
|
+
// `scanned: false` must not collapse into `pids: []`: that reads as
|
|
2435
|
+
// "nothing alive" and would hand back a proof we do not have.
|
|
2436
|
+
return scan.ok
|
|
2437
|
+
// Same zombie discount as the teardown path, so the workerless
|
|
2438
|
+
// close cannot disagree with it about the same tree.
|
|
2439
|
+
? { scanned: true, pids: scan.members.filter(m => !m.zombie).map(m => m.pid) }
|
|
2440
|
+
: { scanned: false, pids: [], reason: scan.reason };
|
|
2441
|
+
},
|
|
2442
|
+
});
|
|
2443
|
+
if (!verdict.proven) {
|
|
2444
|
+
logger.error(`[mojo] workerless close for ${sessionId}: local subtree `
|
|
2445
|
+
+ `${containmentHandleKey(handle)} could not be proven quiescent (${verdict.reason}); `
|
|
2446
|
+
+ 'refusing to report the session closed so its device-isolation blocker is retained');
|
|
2447
|
+
return {
|
|
2448
|
+
unproven: {
|
|
2449
|
+
kind: 'failed',
|
|
2450
|
+
message: `local subtree unproven: ${verdict.reason}`,
|
|
2451
|
+
retryable: true,
|
|
2452
|
+
},
|
|
2453
|
+
residual: null,
|
|
2454
|
+
};
|
|
2455
|
+
}
|
|
2456
|
+
const decision = releaseContainmentHandle(verdict);
|
|
2457
|
+
if (decision.residual !== null) {
|
|
2458
|
+
// Weak evidence: the handle (and the blocker) stays. The close may
|
|
2459
|
+
// proceed, but only as a residual one — mirrors the live-worker
|
|
2460
|
+
// teardown's `boundaryProven === false` branch so the two paths can
|
|
2461
|
+
// never disagree about the same evidence grade.
|
|
2462
|
+
logger.warn(`[mojo] workerless close for ${sessionId}: local subtree `
|
|
2463
|
+
+ `${containmentHandleKey(handle)} scanned clean but the credential boundary is NOT `
|
|
2464
|
+
+ `proven (evidence ${decision.evidence}); the close will carry a residual marker `
|
|
2465
|
+
+ 'and the device-isolation blocker is retained');
|
|
2466
|
+
residual = 'local_subtree_boundary_unproven';
|
|
2467
|
+
}
|
|
2468
|
+
}
|
|
2469
|
+
return { unproven: null, residual };
|
|
2470
|
+
}
|
|
2471
|
+
//# sourceMappingURL=mojo-backend.js.map
|