@openparachute/agent 0.1.2 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.parachute/module.json +124 -8
- package/LICENSE +2 -16
- package/README.md +118 -166
- package/package.json +35 -42
- package/scripts/spawn-agent.ts +371 -0
- package/src/_parked/interactive-spawn.test.ts +324 -0
- package/src/_parked/interactive-spawn.ts +701 -0
- package/src/agent-defs.test.ts +1504 -0
- package/src/agent-defs.ts +1702 -0
- package/src/agent-mcp-config.test.ts +115 -0
- package/src/agent-mcp-config.ts +115 -0
- package/src/agents.test.ts +360 -0
- package/src/agents.ts +379 -0
- package/src/auth.test.ts +46 -0
- package/src/auth.ts +140 -0
- package/src/backends/attached-queue.test.ts +376 -0
- package/src/backends/attached-queue.ts +372 -0
- package/src/backends/programmatic.test.ts +1715 -0
- package/src/backends/programmatic.ts +927 -0
- package/src/backends/registry.test.ts +1494 -0
- package/src/backends/registry.ts +1202 -0
- package/src/backends/stream-json.test.ts +570 -0
- package/src/backends/stream-json.ts +392 -0
- package/src/backends/types.ts +223 -0
- package/src/bridge.ts +417 -0
- package/src/channel-backend-wiring.test.ts +237 -0
- package/src/credentials.test.ts +274 -0
- package/src/credentials.ts +380 -0
- package/src/cron.test.ts +342 -0
- package/src/cron.ts +380 -0
- package/src/daemon-agent-def-api.test.ts +166 -0
- package/src/daemon-agent-defs-api.test.ts +953 -0
- package/src/daemon-agent-env-api.test.ts +338 -0
- package/src/daemon-attached-queue-store.test.ts +65 -0
- package/src/daemon-config-api.test.ts +962 -0
- package/src/daemon-jobs-api.test.ts +271 -0
- package/src/daemon-vault-chat.test.ts +250 -0
- package/src/daemon.test.ts +746 -0
- package/src/daemon.ts +3314 -0
- package/src/def-vaults.test.ts +136 -0
- package/src/def-vaults.ts +165 -0
- package/src/delivery-state.test.ts +110 -0
- package/src/delivery-state.ts +154 -0
- package/src/effective-env.test.ts +114 -0
- package/src/effective-env.ts +184 -0
- package/src/env-compat.ts +39 -0
- package/src/grants.test.ts +638 -0
- package/src/grants.ts +675 -0
- package/src/hub-jwt.test.ts +161 -0
- package/src/hub-jwt.ts +182 -0
- package/src/jobs.test.ts +245 -0
- package/src/jobs.ts +266 -0
- package/src/mcp-http.test.ts +265 -0
- package/src/mcp-http.ts +771 -0
- package/src/mint-token.test.ts +152 -0
- package/src/mint-token.ts +139 -0
- package/src/module-manifest.test.ts +158 -0
- package/src/oauth-discovery.ts +134 -0
- package/src/programmatic-wiring.test.ts +838 -0
- package/src/registry.test.ts +227 -0
- package/src/registry.ts +228 -0
- package/src/resolve-port.test.ts +64 -0
- package/src/routing.test.ts +184 -0
- package/src/routing.ts +76 -0
- package/src/runner.test.ts +506 -0
- package/src/runner.ts +255 -0
- package/src/sandbox/config.test.ts +150 -0
- package/src/sandbox/config.ts +102 -0
- package/src/sandbox/egress.test.ts +113 -0
- package/src/sandbox/egress.ts +123 -0
- package/src/sandbox/index.ts +180 -0
- package/src/sandbox/live-seatbelt.test.ts +277 -0
- package/src/sandbox/mounts.test.ts +154 -0
- package/src/sandbox/mounts.ts +133 -0
- package/src/sandbox/sandbox.test.ts +168 -0
- package/src/sandbox/types.ts +382 -0
- package/src/services-manifest.test.ts +106 -0
- package/src/services-manifest.ts +95 -0
- package/src/spa-serve.test.ts +116 -0
- package/src/spa-serve.ts +116 -0
- package/src/spawn-agent-cli.test.ts +172 -0
- package/src/spawn-agent.test.ts +1218 -0
- package/src/spawn-agent.ts +569 -0
- package/src/spawn-deps.test.ts +54 -0
- package/src/spawn-deps.ts +166 -0
- package/src/telegram/api.ts +153 -0
- package/src/terminal-assets.test.ts +50 -0
- package/src/terminal-assets.ts +79 -0
- package/src/terminal-ui.ts +305 -0
- package/src/terminal.test.ts +530 -0
- package/src/terminal.ts +458 -0
- package/src/transport.ts +270 -0
- package/src/transports/http-ui.test.ts +455 -0
- package/src/transports/http-ui.ts +201 -0
- package/src/transports/telegram.test.ts +174 -0
- package/src/transports/telegram.ts +426 -0
- package/src/transports/vault.test.ts +2011 -0
- package/src/transports/vault.ts +1790 -0
- package/src/ui-kit.test.ts +178 -0
- package/src/ui-kit.ts +402 -0
- package/tsconfig.json +8 -14
- package/web/ui/dist/assets/index-C-iWdFFV.css +1 -0
- package/web/ui/dist/assets/index-VFETBk0a.js +60 -0
- package/web/ui/dist/index.html +15 -0
- package/web/ui/tsconfig.json +2 -1
- package/.claude/scheduled_tasks.lock +0 -1
- package/.claude/settings.json +0 -5
- package/.claude/skills/add-atomic-chat-tool/SKILL.md +0 -243
- package/.claude/skills/add-atomic-chat-tool/atomic-chat-mcp-stdio.ts +0 -229
- package/.claude/skills/add-codex/SKILL.md +0 -161
- package/.claude/skills/add-dashboard/SKILL.md +0 -138
- package/.claude/skills/add-dashboard/resources/dashboard-pusher.ts +0 -495
- package/.claude/skills/add-emacs/SKILL.md +0 -296
- package/.claude/skills/add-gcal-tool/SKILL.md +0 -210
- package/.claude/skills/add-gchat/REMOVE.md +0 -6
- package/.claude/skills/add-gchat/SKILL.md +0 -92
- package/.claude/skills/add-gchat/VERIFY.md +0 -3
- package/.claude/skills/add-github/REMOVE.md +0 -6
- package/.claude/skills/add-github/SKILL.md +0 -148
- package/.claude/skills/add-github/VERIFY.md +0 -3
- package/.claude/skills/add-gmail-tool/SKILL.md +0 -229
- package/.claude/skills/add-imessage/REMOVE.md +0 -6
- package/.claude/skills/add-imessage/SKILL.md +0 -113
- package/.claude/skills/add-imessage/VERIFY.md +0 -3
- package/.claude/skills/add-karpathy-llm-wiki/SKILL.md +0 -110
- package/.claude/skills/add-karpathy-llm-wiki/llm-wiki.md +0 -75
- package/.claude/skills/add-linear/REMOVE.md +0 -6
- package/.claude/skills/add-linear/SKILL.md +0 -168
- package/.claude/skills/add-linear/VERIFY.md +0 -3
- package/.claude/skills/add-macos-statusbar/SKILL.md +0 -133
- package/.claude/skills/add-macos-statusbar/add/src/statusbar.swift +0 -147
- package/.claude/skills/add-matrix/REMOVE.md +0 -6
- package/.claude/skills/add-matrix/SKILL.md +0 -148
- package/.claude/skills/add-matrix/VERIFY.md +0 -3
- package/.claude/skills/add-ollama-provider/SKILL.md +0 -179
- package/.claude/skills/add-ollama-tool/SKILL.md +0 -193
- package/.claude/skills/add-opencode/SKILL.md +0 -229
- package/.claude/skills/add-parallel/SKILL.md +0 -290
- package/.claude/skills/add-resend/REMOVE.md +0 -6
- package/.claude/skills/add-resend/SKILL.md +0 -93
- package/.claude/skills/add-resend/VERIFY.md +0 -3
- package/.claude/skills/add-signal/REMOVE.md +0 -13
- package/.claude/skills/add-signal/SKILL.md +0 -318
- package/.claude/skills/add-signal/VERIFY.md +0 -5
- package/.claude/skills/add-slack/REMOVE.md +0 -6
- package/.claude/skills/add-slack/SKILL.md +0 -112
- package/.claude/skills/add-slack/VERIFY.md +0 -3
- package/.claude/skills/add-teams/REMOVE.md +0 -6
- package/.claude/skills/add-teams/SKILL.md +0 -207
- package/.claude/skills/add-teams/VERIFY.md +0 -3
- package/.claude/skills/add-vercel/SKILL.md +0 -147
- package/.claude/skills/add-vercel/container-skills/vercel-cli/SKILL.md +0 -103
- package/.claude/skills/add-webex/REMOVE.md +0 -6
- package/.claude/skills/add-webex/SKILL.md +0 -88
- package/.claude/skills/add-webex/VERIFY.md +0 -3
- package/.claude/skills/add-wechat/REMOVE.md +0 -49
- package/.claude/skills/add-wechat/SKILL.md +0 -170
- package/.claude/skills/add-wechat/scripts/wire-dm.ts +0 -172
- package/.claude/skills/add-whatsapp/SKILL.md +0 -264
- package/.claude/skills/add-whatsapp-cloud/REMOVE.md +0 -6
- package/.claude/skills/add-whatsapp-cloud/SKILL.md +0 -95
- package/.claude/skills/add-whatsapp-cloud/VERIFY.md +0 -3
- package/.claude/skills/claw/SKILL.md +0 -131
- package/.claude/skills/claw/scripts/claw +0 -374
- package/.claude/skills/convert-to-apple-container/SKILL.md +0 -212
- package/.claude/skills/customize/SKILL.md +0 -110
- package/.claude/skills/debug/SKILL.md +0 -349
- package/.claude/skills/get-qodo-rules/SKILL.md +0 -122
- package/.claude/skills/get-qodo-rules/references/output-format.md +0 -41
- package/.claude/skills/get-qodo-rules/references/pagination.md +0 -33
- package/.claude/skills/get-qodo-rules/references/repository-scope.md +0 -26
- package/.claude/skills/init-first-agent/SKILL.md +0 -120
- package/.claude/skills/init-onecli/SKILL.md +0 -270
- package/.claude/skills/manage-channels/SKILL.md +0 -87
- package/.claude/skills/manage-mounts/SKILL.md +0 -47
- package/.claude/skills/migrate-from-openclaw/MIGRATE_CRONS.md +0 -100
- package/.claude/skills/migrate-from-openclaw/SKILL.md +0 -447
- package/.claude/skills/migrate-from-openclaw/scripts/discover-openclaw.ts +0 -734
- package/.claude/skills/migrate-from-openclaw/scripts/extract-channel-credentials.ts +0 -476
- package/.claude/skills/migrate-nanoclaw/SKILL.md +0 -484
- package/.claude/skills/migrate-nanoclaw/diagnostics.md +0 -51
- package/.claude/skills/qodo-pr-resolver/SKILL.md +0 -326
- package/.claude/skills/qodo-pr-resolver/resources/providers.md +0 -329
- package/.claude/skills/update-nanoclaw/SKILL.md +0 -243
- package/.claude/skills/update-nanoclaw/diagnostics.md +0 -48
- package/.claude/skills/update-skills/SKILL.md +0 -130
- package/.claude/skills/use-native-credential-proxy/SKILL.md +0 -167
- package/.claude/skills/x-integration/SKILL.md +0 -417
- package/.claude/skills/x-integration/agent.ts +0 -243
- package/.claude/skills/x-integration/host.ts +0 -155
- package/.claude/skills/x-integration/lib/browser.ts +0 -148
- package/.claude/skills/x-integration/lib/config.ts +0 -62
- package/.claude/skills/x-integration/scripts/like.ts +0 -56
- package/.claude/skills/x-integration/scripts/post.ts +0 -66
- package/.claude/skills/x-integration/scripts/quote.ts +0 -80
- package/.claude/skills/x-integration/scripts/reply.ts +0 -74
- package/.claude/skills/x-integration/scripts/retweet.ts +0 -62
- package/.claude/skills/x-integration/scripts/setup.ts +0 -87
- package/.github/CODEOWNERS +0 -10
- package/.github/PULL_REQUEST_TEMPLATE.md +0 -18
- package/.github/workflows/bump-version.yml +0 -35
- package/.github/workflows/ci.yml +0 -39
- package/.github/workflows/label-pr.yml +0 -40
- package/.github/workflows/update-tokens.yml +0 -43
- package/.husky/pre-commit +0 -1
- package/.mcp.json +0 -3
- package/.nvmrc +0 -1
- package/.prettierrc +0 -4
- package/CHANGELOG.md +0 -263
- package/CLAUDE.md +0 -307
- package/CODE_OF_CONDUCT.md +0 -128
- package/CONTRIBUTING.md +0 -159
- package/CONTRIBUTORS.md +0 -26
- package/LICENSE-NANOCLAW-MIT +0 -21
- package/README_ja.md +0 -194
- package/README_zh.md +0 -194
- package/assets/nanoclaw-favicon.png +0 -0
- package/assets/nanoclaw-icon.png +0 -0
- package/assets/nanoclaw-logo-dark.png +0 -0
- package/assets/nanoclaw-logo.png +0 -0
- package/assets/nanoclaw-profile.jpeg +0 -0
- package/assets/nanoclaw-sales.png +0 -0
- package/assets/social-preview.jpg +0 -0
- package/config-examples/mount-allowlist.json +0 -25
- package/container/.dockerignore +0 -2
- package/container/CLAUDE.md +0 -21
- package/container/Dockerfile +0 -121
- package/container/agent-runner/bun.lock +0 -243
- package/container/agent-runner/package.json +0 -22
- package/container/agent-runner/scripts/sdk-signal-probe.ts +0 -169
- package/container/agent-runner/src/config.ts +0 -55
- package/container/agent-runner/src/db/connection.ts +0 -267
- package/container/agent-runner/src/db/index.ts +0 -20
- package/container/agent-runner/src/db/messages-in.ts +0 -138
- package/container/agent-runner/src/db/messages-out.ts +0 -143
- package/container/agent-runner/src/db/session-routing.ts +0 -30
- package/container/agent-runner/src/db/session-state.test.ts +0 -100
- package/container/agent-runner/src/db/session-state.ts +0 -79
- package/container/agent-runner/src/destinations.ts +0 -135
- package/container/agent-runner/src/formatter.test.ts +0 -167
- package/container/agent-runner/src/formatter.ts +0 -260
- package/container/agent-runner/src/index.ts +0 -110
- package/container/agent-runner/src/integration.test.ts +0 -121
- package/container/agent-runner/src/mcp-tools/agents.instructions.md +0 -26
- package/container/agent-runner/src/mcp-tools/agents.ts +0 -66
- package/container/agent-runner/src/mcp-tools/core.instructions.md +0 -27
- package/container/agent-runner/src/mcp-tools/core.ts +0 -262
- package/container/agent-runner/src/mcp-tools/index.ts +0 -22
- package/container/agent-runner/src/mcp-tools/interactive.instructions.md +0 -22
- package/container/agent-runner/src/mcp-tools/interactive.ts +0 -169
- package/container/agent-runner/src/mcp-tools/scheduling.instructions.md +0 -40
- package/container/agent-runner/src/mcp-tools/scheduling.ts +0 -299
- package/container/agent-runner/src/mcp-tools/self-mod.instructions.md +0 -25
- package/container/agent-runner/src/mcp-tools/self-mod.ts +0 -120
- package/container/agent-runner/src/mcp-tools/server.ts +0 -54
- package/container/agent-runner/src/mcp-tools/types.ts +0 -6
- package/container/agent-runner/src/poll-loop.test.ts +0 -248
- package/container/agent-runner/src/poll-loop.ts +0 -437
- package/container/agent-runner/src/providers/claude.ts +0 -379
- package/container/agent-runner/src/providers/factory.test.ts +0 -19
- package/container/agent-runner/src/providers/factory.ts +0 -13
- package/container/agent-runner/src/providers/index.ts +0 -6
- package/container/agent-runner/src/providers/mock.ts +0 -77
- package/container/agent-runner/src/providers/provider-registry.ts +0 -33
- package/container/agent-runner/src/providers/types.ts +0 -82
- package/container/agent-runner/src/scheduling/task-script.ts +0 -121
- package/container/agent-runner/src/timezone.test.ts +0 -93
- package/container/agent-runner/src/timezone.ts +0 -107
- package/container/agent-runner/tsconfig.json +0 -14
- package/container/build.sh +0 -48
- package/container/entrypoint.sh +0 -16
- package/container/skills/agent-browser/SKILL.md +0 -159
- package/container/skills/frontend-engineer/SKILL.md +0 -157
- package/container/skills/self-customize/SKILL.md +0 -87
- package/container/skills/slack-formatting/SKILL.md +0 -94
- package/container/skills/vercel-cli/SKILL.md +0 -111
- package/container/skills/welcome/SKILL.md +0 -85
- package/docs/APPLE-CONTAINER-NETWORKING.md +0 -90
- package/docs/BRANCH-FORK-MAINTENANCE.md +0 -81
- package/docs/README.md +0 -25
- package/docs/SDK_DEEP_DIVE.md +0 -643
- package/docs/SECURITY.md +0 -162
- package/docs/agent-runner-details.md +0 -749
- package/docs/api-details.md +0 -365
- package/docs/architecture-diagram.html +0 -422
- package/docs/architecture-diagram.md +0 -215
- package/docs/architecture.md +0 -751
- package/docs/audit/2026-04-30-channel-endpoint-audit.md +0 -36
- package/docs/build-and-runtime.md +0 -80
- package/docs/cross-mount-stress/README.md +0 -112
- package/docs/cross-mount-stress/container-writer-retry.mjs +0 -55
- package/docs/cross-mount-stress/container-writer-slow.mjs +0 -42
- package/docs/cross-mount-stress/container-writer.mjs +0 -47
- package/docs/cross-mount-stress/host-writer-retry.mjs +0 -55
- package/docs/cross-mount-stress/host-writer-slow.mjs +0 -43
- package/docs/cross-mount-stress/host-writer.mjs +0 -47
- package/docs/db-central.md +0 -316
- package/docs/db-session.md +0 -183
- package/docs/db.md +0 -119
- package/docs/design/2026-04-29-vault-management-ui.md +0 -231
- package/docs/design/2026-04-30-channel-wiring-rework.md +0 -234
- package/docs/design/2026-05-01-channel-wiring-approvals-deep-dive.md +0 -272
- package/docs/design/2026-05-02-channel-policy-and-approval-routing.md +0 -250
- package/docs/docker-sandboxes.md +0 -359
- package/docs/isolation-model.md +0 -88
- package/docs/ollama.md +0 -79
- package/docs/parachute-integration.md +0 -109
- package/docs/post-night-rebirth-reflections.md +0 -151
- package/eslint.config.js +0 -32
- package/pnpm-workspace.yaml +0 -8
- package/repo-tokens/README.md +0 -113
- package/repo-tokens/action.yml +0 -186
- package/repo-tokens/badge.svg +0 -23
- package/repo-tokens/examples/green.svg +0 -14
- package/repo-tokens/examples/red.svg +0 -14
- package/repo-tokens/examples/yellow-green.svg +0 -14
- package/repo-tokens/examples/yellow.svg +0 -14
- package/scripts/chat.ts +0 -101
- package/scripts/cleanup-sessions.sh +0 -150
- package/scripts/init-cli-agent.ts +0 -172
- package/scripts/init-first-agent.ts +0 -378
- package/scripts/parachute.ts +0 -158
- package/scripts/run-migrations.ts +0 -105
- package/scripts/sanity-live-poll.ts +0 -95
- package/scripts/seed-discord.ts +0 -80
- package/scripts/test-v2-agent.ts +0 -106
- package/scripts/test-v2-channel-e2e.ts +0 -265
- package/scripts/test-v2-host.ts +0 -184
- package/src/channels/adapter.ts +0 -214
- package/src/channels/api-translator.test.ts +0 -306
- package/src/channels/api-translator.ts +0 -214
- package/src/channels/ask-question.ts +0 -46
- package/src/channels/channel-registry.test.ts +0 -421
- package/src/channels/channel-registry.ts +0 -313
- package/src/channels/chat-sdk-bridge.test.ts +0 -84
- package/src/channels/chat-sdk-bridge.ts +0 -652
- package/src/channels/cli.ts +0 -276
- package/src/channels/discord.ts +0 -90
- package/src/channels/index.ts +0 -17
- package/src/channels/telegram-markdown-sanitize.test.ts +0 -78
- package/src/channels/telegram-markdown-sanitize.ts +0 -55
- package/src/channels/telegram-pairing.test.ts +0 -254
- package/src/channels/telegram-pairing.ts +0 -339
- package/src/channels/telegram.ts +0 -279
- package/src/channels/trust-hint.test.ts +0 -48
- package/src/channels/trust-hint.ts +0 -75
- package/src/claude-md-compose.migrate.test.ts +0 -64
- package/src/claude-md-compose.ts +0 -205
- package/src/command-gate.ts +0 -63
- package/src/config.test.ts +0 -93
- package/src/config.ts +0 -128
- package/src/container-config.ts +0 -167
- package/src/container-runner.test.ts +0 -32
- package/src/container-runner.ts +0 -576
- package/src/container-runtime.test.ts +0 -269
- package/src/container-runtime.ts +0 -167
- package/src/db/_bun-sqlite-shim.ts +0 -88
- package/src/db/agent-activity.test.ts +0 -155
- package/src/db/agent-activity.ts +0 -121
- package/src/db/agent-groups.ts +0 -77
- package/src/db/connection.migrate.test.ts +0 -176
- package/src/db/connection.ts +0 -259
- package/src/db/db-v2.test.ts +0 -440
- package/src/db/dropped-messages.ts +0 -44
- package/src/db/index.ts +0 -40
- package/src/db/messaging-groups.ts +0 -252
- package/src/db/migrations/001-initial.ts +0 -112
- package/src/db/migrations/002-chat-sdk-state.ts +0 -36
- package/src/db/migrations/008-dropped-messages.ts +0 -27
- package/src/db/migrations/009-drop-pending-credentials.ts +0 -13
- package/src/db/migrations/010-engage-modes.ts +0 -103
- package/src/db/migrations/011-pending-sender-approvals.ts +0 -40
- package/src/db/migrations/012-channel-registration.ts +0 -48
- package/src/db/migrations/013-approval-render-metadata.ts +0 -27
- package/src/db/migrations/014-secrets.ts +0 -44
- package/src/db/migrations/015-secrets-drop-host-pattern.ts +0 -18
- package/src/db/migrations/016-secret-assignments.ts +0 -30
- package/src/db/migrations/017-agent-activity.ts +0 -40
- package/src/db/migrations/018-oauth-app-configs.ts +0 -34
- package/src/db/migrations/019-oauth-app-connections.ts +0 -48
- package/src/db/migrations/020-agent-app-connections.ts +0 -28
- package/src/db/migrations/021-pending-oauth-states.ts +0 -35
- package/src/db/migrations/022-app-connections-provider.ts +0 -25
- package/src/db/migrations/023-agent-group-secret-mode.test.ts +0 -124
- package/src/db/migrations/023-agent-group-secret-mode.ts +0 -65
- package/src/db/migrations/024-collapse-approvals.test.ts +0 -249
- package/src/db/migrations/024-collapse-approvals.ts +0 -182
- package/src/db/migrations/025-secret-mode-check.test.ts +0 -155
- package/src/db/migrations/025-secret-mode-check.ts +0 -49
- package/src/db/migrations/026-user-dms-bot-id.test.ts +0 -116
- package/src/db/migrations/026-user-dms-bot-id.ts +0 -54
- package/src/db/migrations/027-provider-credentials.ts +0 -41
- package/src/db/migrations/_test-helpers.ts +0 -41
- package/src/db/migrations/index.ts +0 -127
- package/src/db/migrations/module-agent-to-agent-destinations.ts +0 -84
- package/src/db/migrations/module-approvals-pending-approvals.ts +0 -42
- package/src/db/migrations/module-approvals-title-options.ts +0 -40
- package/src/db/schema.ts +0 -258
- package/src/db/session-db.test.ts +0 -93
- package/src/db/session-db.ts +0 -325
- package/src/db/sessions.ts +0 -241
- package/src/delivery.test.ts +0 -148
- package/src/delivery.ts +0 -445
- package/src/env.ts +0 -74
- package/src/group-folder.test.ts +0 -35
- package/src/group-folder.ts +0 -44
- package/src/group-init.ts +0 -92
- package/src/host-core.test.ts +0 -456
- package/src/host-sweep.test.ts +0 -146
- package/src/host-sweep.ts +0 -287
- package/src/index.ts +0 -232
- package/src/install-slug.ts +0 -33
- package/src/log.test.ts +0 -81
- package/src/log.ts +0 -117
- package/src/mcp/http.ts +0 -72
- package/src/mcp/server.ts +0 -92
- package/src/mcp/stdio.ts +0 -51
- package/src/mcp/tools/activity.ts +0 -88
- package/src/mcp/tools/agent-groups.ts +0 -183
- package/src/mcp/tools/approvals.ts +0 -122
- package/src/mcp/tools/channels.test.ts +0 -126
- package/src/mcp/tools/channels.ts +0 -134
- package/src/mcp/tools/index.ts +0 -27
- package/src/mcp/tools/oauth.ts +0 -48
- package/src/mcp/tools/secrets.ts +0 -169
- package/src/mcp/tools/sessions.ts +0 -135
- package/src/mcp/types.ts +0 -51
- package/src/modules/agent-to-agent/agent-route.test.ts +0 -46
- package/src/modules/agent-to-agent/agent-route.ts +0 -223
- package/src/modules/agent-to-agent/create-agent.ts +0 -127
- package/src/modules/agent-to-agent/db/agent-destinations.ts +0 -135
- package/src/modules/agent-to-agent/index.ts +0 -22
- package/src/modules/agent-to-agent/write-destinations.ts +0 -59
- package/src/modules/approvals/agent.md +0 -45
- package/src/modules/approvals/index.ts +0 -21
- package/src/modules/approvals/picks.test.ts +0 -291
- package/src/modules/approvals/primitive.ts +0 -279
- package/src/modules/approvals/project.md +0 -27
- package/src/modules/approvals/response-handler.ts +0 -87
- package/src/modules/index.ts +0 -24
- package/src/modules/interactive/agent.md +0 -21
- package/src/modules/interactive/index.ts +0 -69
- package/src/modules/interactive/project.md +0 -12
- package/src/modules/mount-security/expand-path.test.ts +0 -82
- package/src/modules/mount-security/index.ts +0 -459
- package/src/modules/mount-security/migrate.test.ts +0 -91
- package/src/modules/permissions/access.ts +0 -28
- package/src/modules/permissions/channel-approval.test.ts +0 -389
- package/src/modules/permissions/channel-approval.ts +0 -188
- package/src/modules/permissions/db/agent-group-members.ts +0 -44
- package/src/modules/permissions/db/pending-channel-approvals.test.ts +0 -86
- package/src/modules/permissions/db/pending-channel-approvals.ts +0 -66
- package/src/modules/permissions/db/pending-sender-approvals.ts +0 -60
- package/src/modules/permissions/db/user-dms.ts +0 -58
- package/src/modules/permissions/db/user-roles.ts +0 -85
- package/src/modules/permissions/db/users.ts +0 -38
- package/src/modules/permissions/index.ts +0 -421
- package/src/modules/permissions/permissions.test.ts +0 -358
- package/src/modules/permissions/sender-approval.test.ts +0 -641
- package/src/modules/permissions/sender-approval.ts +0 -165
- package/src/modules/permissions/user-dm.ts +0 -200
- package/src/modules/provider-credentials/db.ts +0 -121
- package/src/modules/provider-credentials/index.ts +0 -12
- package/src/modules/provider-credentials/spawn.test.ts +0 -206
- package/src/modules/provider-credentials/spawn.ts +0 -114
- package/src/modules/scheduling/actions.ts +0 -113
- package/src/modules/scheduling/db.test.ts +0 -282
- package/src/modules/scheduling/db.ts +0 -148
- package/src/modules/scheduling/index.ts +0 -34
- package/src/modules/scheduling/recurrence.test.ts +0 -98
- package/src/modules/scheduling/recurrence.ts +0 -54
- package/src/modules/self-mod/agent.md +0 -30
- package/src/modules/self-mod/apply.ts +0 -85
- package/src/modules/self-mod/index.ts +0 -30
- package/src/modules/self-mod/project.md +0 -39
- package/src/modules/self-mod/request.ts +0 -91
- package/src/modules/typing/index.ts +0 -165
- package/src/oauth/agent-app-connections.ts +0 -103
- package/src/oauth/app-configs.test.ts +0 -64
- package/src/oauth/app-configs.ts +0 -114
- package/src/oauth/app-connections.test.ts +0 -109
- package/src/oauth/app-connections.ts +0 -178
- package/src/oauth/crypto.ts +0 -56
- package/src/oauth/flow.ts +0 -104
- package/src/oauth/providers/google.test.ts +0 -38
- package/src/oauth/providers/google.ts +0 -46
- package/src/oauth/providers/index.ts +0 -48
- package/src/oauth/state-store.test.ts +0 -54
- package/src/oauth/state-store.ts +0 -93
- package/src/parachute/README.md +0 -27
- package/src/parachute/create-agent.test.ts +0 -83
- package/src/parachute/create-agent.ts +0 -122
- package/src/parachute/group-status.test.ts +0 -165
- package/src/parachute/group-status.ts +0 -136
- package/src/parachute/types.ts +0 -41
- package/src/parachute/vault-mcp.test.ts +0 -251
- package/src/parachute/vault-mcp.ts +0 -232
- package/src/platform-id.test.ts +0 -104
- package/src/platform-id.ts +0 -109
- package/src/providers/index.ts +0 -6
- package/src/providers/provider-container-registry.ts +0 -58
- package/src/response-registry.ts +0 -45
- package/src/router.ts +0 -530
- package/src/secrets/crypto.test.ts +0 -45
- package/src/secrets/crypto.ts +0 -55
- package/src/secrets/index.ts +0 -461
- package/src/secrets/master-key.ts +0 -70
- package/src/secrets/secrets.test.ts +0 -651
- package/src/session-manager.attachments.test.ts +0 -171
- package/src/session-manager.dup-skip.test.ts +0 -173
- package/src/session-manager.migrate.test.ts +0 -59
- package/src/session-manager.ts +0 -451
- package/src/startup-bootstrap.test.ts +0 -226
- package/src/startup-bootstrap.ts +0 -207
- package/src/state-sqlite.ts +0 -182
- package/src/timezone.test.ts +0 -64
- package/src/timezone.ts +0 -37
- package/src/types.ts +0 -233
- package/src/web/auth.test.ts +0 -335
- package/src/web/auth.ts +0 -214
- package/src/web/discord-validate.test.ts +0 -77
- package/src/web/discord-validate.ts +0 -88
- package/src/web/hub-discovery.test.ts +0 -98
- package/src/web/hub-discovery.ts +0 -69
- package/src/web/routes/activity.ts +0 -106
- package/src/web/routes/agent-provider.test.ts +0 -282
- package/src/web/routes/agent-provider.ts +0 -309
- package/src/web/routes/approvals.ts +0 -185
- package/src/web/routes/apps.ts +0 -434
- package/src/web/routes/channels-mg-detail.test.ts +0 -324
- package/src/web/routes/channels-mga-detail.test.ts +0 -472
- package/src/web/routes/channels.ts +0 -311
- package/src/web/routes/oauth-providers.ts +0 -42
- package/src/web/routes/secrets.test.ts +0 -220
- package/src/web/routes/secrets.ts +0 -317
- package/src/web/routes/sessions.ts +0 -123
- package/src/web/routes/settings.test.ts +0 -106
- package/src/web/routes/settings.ts +0 -247
- package/src/web/routes/setup-status.ts +0 -205
- package/src/web/routes/vaults.test.ts +0 -389
- package/src/web/routes/vaults.ts +0 -225
- package/src/web/server-version.test.ts +0 -16
- package/src/web/server.ts +0 -1024
- package/src/web/services-manifest.test.ts +0 -148
- package/src/web/services-manifest.ts +0 -66
- package/src/web/static-serve.test.ts +0 -255
- package/src/web/static-serve.ts +0 -104
- package/src/web/telegram-validate.test.ts +0 -116
- package/src/web/telegram-validate.ts +0 -107
- package/src/web/vault-proxy.test.ts +0 -214
- package/src/web/vault-proxy.ts +0 -120
- package/src/web/wire-channel.ts +0 -181
- package/src/webhook-server.ts +0 -134
- package/vitest.config.ts +0 -18
- package/web/README.md +0 -63
- package/web/ui/index.html +0 -13
- package/web/ui/package.json +0 -35
- package/web/ui/pnpm-lock.yaml +0 -2164
- package/web/ui/scripts/verify-base.mjs +0 -31
- package/web/ui/src/App.tsx +0 -88
- package/web/ui/src/components/ActivityFeed.tsx +0 -444
- package/web/ui/src/components/AgentGroupPicker.tsx +0 -263
- package/web/ui/src/components/AgentProviderCards.tsx +0 -220
- package/web/ui/src/components/CredentialForm.tsx +0 -214
- package/web/ui/src/components/ScopeGrants.tsx +0 -74
- package/web/ui/src/components/StatusDot.tsx +0 -43
- package/web/ui/src/components/VaultPicker.tsx +0 -127
- package/web/ui/src/components/setup/AdapterInstallStep.tsx +0 -178
- package/web/ui/src/components/setup/AgentGroupStep.tsx +0 -43
- package/web/ui/src/components/setup/ChannelPickStep.tsx +0 -74
- package/web/ui/src/components/setup/DoneStep.tsx +0 -49
- package/web/ui/src/components/setup/PrereqStep.tsx +0 -129
- package/web/ui/src/components/setup/TestConnectionStep.tsx +0 -108
- package/web/ui/src/components/setup/TestMessageStep.tsx +0 -104
- package/web/ui/src/components/setup/WireChannelStep.tsx +0 -166
- package/web/ui/src/components/setup/types.ts +0 -105
- package/web/ui/src/lib/api.test.ts +0 -410
- package/web/ui/src/lib/api.ts +0 -1248
- package/web/ui/src/lib/auth.test.ts +0 -352
- package/web/ui/src/lib/auth.ts +0 -405
- package/web/ui/src/lib/channel-adapters.ts +0 -136
- package/web/ui/src/main.tsx +0 -19
- package/web/ui/src/routes/ApprovalsList.tsx +0 -294
- package/web/ui/src/routes/Apps.tsx +0 -613
- package/web/ui/src/routes/ChannelWireDetail.test.tsx +0 -233
- package/web/ui/src/routes/ChannelWireDetail.tsx +0 -403
- package/web/ui/src/routes/ChannelsList.tsx +0 -158
- package/web/ui/src/routes/GroupDetail.test.tsx +0 -206
- package/web/ui/src/routes/GroupDetail.tsx +0 -880
- package/web/ui/src/routes/GroupList.tsx +0 -187
- package/web/ui/src/routes/MessagingGroupDetail.test.tsx +0 -233
- package/web/ui/src/routes/MessagingGroupDetail.tsx +0 -306
- package/web/ui/src/routes/NewGroupWizard.tsx +0 -390
- package/web/ui/src/routes/OAuthCallback.tsx +0 -56
- package/web/ui/src/routes/SecretsList.tsx +0 -942
- package/web/ui/src/routes/SessionsList.tsx +0 -220
- package/web/ui/src/routes/SettingsAgentProvider.tsx +0 -109
- package/web/ui/src/routes/SettingsApprovals.tsx +0 -234
- package/web/ui/src/routes/SetupWizard.tsx +0 -219
- package/web/ui/src/routes/VaultDetail.test.tsx +0 -363
- package/web/ui/src/routes/VaultDetail.tsx +0 -960
- package/web/ui/src/routes/VaultsList.tsx +0 -295
- package/web/ui/src/routes/WireChannelPage.tsx +0 -413
- package/web/ui/src/styles.css +0 -608
- package/web/ui/src/test/setup.ts +0 -23
- package/web/ui/src/vite-env.d.ts +0 -10
- package/web/ui/vite.config.ts +0 -34
- package/web/ui/vitest.config.ts +0 -25
package/src/daemon.ts
ADDED
|
@@ -0,0 +1,3314 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* parachute-agent daemon — the transport-agnostic orchestrator.
|
|
4
|
+
*
|
|
5
|
+
* Runs as a long-lived HTTP server (launchd, systemd, or manual). It loads a
|
|
6
|
+
* channel registry (name → transport), starts each transport, and routes
|
|
7
|
+
* inbound traffic to the bridges subscribed to that channel. Bridges connect
|
|
8
|
+
* via SSE (`/events?channel=<name>`) for inbound and POST outbound to the HTTP
|
|
9
|
+
* API with a `channel` field.
|
|
10
|
+
*
|
|
11
|
+
* Telegram is one transport behind the registry; the daemon core touches no
|
|
12
|
+
* platform API directly.
|
|
13
|
+
*
|
|
14
|
+
* Port resolution (see `resolvePort`): the hub supervisor's injected `PORT`
|
|
15
|
+
* wins, then the `PARACHUTE_AGENT_PORT` override (legacy `PARACHUTE_CHANNEL_PORT`
|
|
16
|
+
* still honored), then the compiled-in canonical default 1941. The daemon binds
|
|
17
|
+
* AND self-registers the resolved port, so the supervisor's probe/proxy and the
|
|
18
|
+
* bound port never disagree (agent#41).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { mkdirSync, readFileSync, existsSync, readdirSync } from "fs";
|
|
22
|
+
import { join } from "path";
|
|
23
|
+
import { homedir } from "os";
|
|
24
|
+
import { timingSafeEqual } from "node:crypto";
|
|
25
|
+
import { upsertService, listVaultNames } from "./services-manifest.ts";
|
|
26
|
+
|
|
27
|
+
/** Constant-time webhook-secret compare. Length check first (a length mismatch
|
|
28
|
+
* is never equal); timingSafeEqual on equal-length buffers avoids the
|
|
29
|
+
* short-circuit timing leak of `===`. Empty configured/presented → never match. */
|
|
30
|
+
function webhookSecretMatches(presented: string, configured: string): boolean {
|
|
31
|
+
if (!presented || !configured || presented.length !== configured.length) return false;
|
|
32
|
+
return timingSafeEqual(Buffer.from(presented), Buffer.from(configured));
|
|
33
|
+
}
|
|
34
|
+
import type {
|
|
35
|
+
Transport,
|
|
36
|
+
TransportContext,
|
|
37
|
+
InboundMessage,
|
|
38
|
+
ReplyArgs,
|
|
39
|
+
ReactArgs,
|
|
40
|
+
EditArgs,
|
|
41
|
+
PermissionArgs,
|
|
42
|
+
DownloadArgs,
|
|
43
|
+
} from "./transport.ts";
|
|
44
|
+
import { ChannelConfigError } from "./transport.ts";
|
|
45
|
+
import {
|
|
46
|
+
loadRegistry,
|
|
47
|
+
instantiateTransport,
|
|
48
|
+
upsertChannelEntry,
|
|
49
|
+
removeChannelEntry,
|
|
50
|
+
defaultStateDir,
|
|
51
|
+
type Channel,
|
|
52
|
+
type ChannelEntry,
|
|
53
|
+
} from "./registry.ts";
|
|
54
|
+
import { VaultTransport, AGENT_VAULT_TRIGGER_TEMPLATE, noteAgentKey } from "./transports/vault.ts";
|
|
55
|
+
import {
|
|
56
|
+
AgentDefRegistry,
|
|
57
|
+
AgentDefWriteError,
|
|
58
|
+
type DefVaultBinding,
|
|
59
|
+
type InstantiateDeps,
|
|
60
|
+
} from "./agent-defs.ts";
|
|
61
|
+
import {
|
|
62
|
+
resolveDefVaults,
|
|
63
|
+
readDefVaultsFile,
|
|
64
|
+
writeDefVaultsFile,
|
|
65
|
+
DEFAULT_DEF_VAULT_URL,
|
|
66
|
+
DEFAULT_HUB_ORIGIN,
|
|
67
|
+
} from "./def-vaults.ts";
|
|
68
|
+
import { mintScopedToken, vaultScope } from "./mint-token.ts";
|
|
69
|
+
import { GrantsClient } from "./grants.ts";
|
|
70
|
+
import { resolveEffectiveEnv } from "./effective-env.ts";
|
|
71
|
+
import { VaultJobStore, validateJob, vaultTransportFor, type Job } from "./jobs.ts";
|
|
72
|
+
import { Runner, realTickDriver } from "./runner.ts";
|
|
73
|
+
import { nextRunAfter } from "./cron.ts";
|
|
74
|
+
import {
|
|
75
|
+
setDefaultClaudeCredential,
|
|
76
|
+
setChannelClaudeCredential,
|
|
77
|
+
removeChannelClaudeCredential,
|
|
78
|
+
describeClaudeCredentials,
|
|
79
|
+
setChannelEnvVar,
|
|
80
|
+
removeChannelEnvVar,
|
|
81
|
+
describeChannelEnv,
|
|
82
|
+
DenylistedEnvError,
|
|
83
|
+
} from "./credentials.ts";
|
|
84
|
+
import { ClientRegistry, sseFrame } from "./routing.ts";
|
|
85
|
+
import { DeliveryState } from "./delivery-state.ts";
|
|
86
|
+
import {
|
|
87
|
+
requireScope,
|
|
88
|
+
extractToken,
|
|
89
|
+
json as authJson,
|
|
90
|
+
SCOPE_READ,
|
|
91
|
+
SCOPE_WRITE,
|
|
92
|
+
SCOPE_SEND,
|
|
93
|
+
SCOPE_ADMIN,
|
|
94
|
+
SCOPE_TERMINAL,
|
|
95
|
+
} from "./auth.ts";
|
|
96
|
+
import {
|
|
97
|
+
createTerminalWsHandlers,
|
|
98
|
+
type TerminalWsData,
|
|
99
|
+
} from "./terminal.ts";
|
|
100
|
+
import { TERMINAL_UI_HTML } from "./terminal-ui.ts";
|
|
101
|
+
import { serveTerminalAsset } from "./terminal-assets.ts";
|
|
102
|
+
import { isSpaPath, serveSpa, spaDistDir } from "./spa-serve.ts";
|
|
103
|
+
import {
|
|
104
|
+
buildSpecFromBody,
|
|
105
|
+
setupProgrammaticSpawn,
|
|
106
|
+
SpawnRequestError,
|
|
107
|
+
AGENT_NAME_SLUG,
|
|
108
|
+
type AgentInfo,
|
|
109
|
+
} from "./agents.ts";
|
|
110
|
+
import { SpawnDepsError, sessionsDir as defaultSessionsDir, resolveSpawnDeps } from "./spawn-deps.ts";
|
|
111
|
+
import {
|
|
112
|
+
ProgrammaticBackend,
|
|
113
|
+
realProgrammaticSpawn,
|
|
114
|
+
type ProgrammaticBackendDeps,
|
|
115
|
+
} from "./backends/programmatic.ts";
|
|
116
|
+
import {
|
|
117
|
+
ProgrammaticAgentRegistry,
|
|
118
|
+
type WriteOutbound,
|
|
119
|
+
type WriteThread,
|
|
120
|
+
type WriteCallback,
|
|
121
|
+
type QueuedMessage,
|
|
122
|
+
type TurnEventSink,
|
|
123
|
+
} from "./backends/registry.ts";
|
|
124
|
+
import {
|
|
125
|
+
AttachedQueueRegistry,
|
|
126
|
+
type AttachedQueueStore,
|
|
127
|
+
} from "./backends/attached-queue.ts";
|
|
128
|
+
import { readPersistedSpec, sessionWorkspace } from "./spawn-agent.ts";
|
|
129
|
+
import { normalizeChannel } from "./sandbox/types.ts";
|
|
130
|
+
import { CredentialNotConfiguredError } from "./credentials.ts";
|
|
131
|
+
import { MintError } from "./mint-token.ts";
|
|
132
|
+
import { validateHubJwt, getHubOrigin } from "./hub-jwt.ts";
|
|
133
|
+
import {
|
|
134
|
+
handleProtectedResource,
|
|
135
|
+
handleAuthorizationServer,
|
|
136
|
+
mcpWwwAuthenticate,
|
|
137
|
+
} from "./oauth-discovery.ts";
|
|
138
|
+
import {
|
|
139
|
+
handleMcp,
|
|
140
|
+
pushToChannel as mcpPushToChannel,
|
|
141
|
+
pushPermissionVerdict as mcpPushPermissionVerdict,
|
|
142
|
+
mcpSessionCount,
|
|
143
|
+
assertMcpSdkStreamContract,
|
|
144
|
+
} from "./mcp-http.ts";
|
|
145
|
+
|
|
146
|
+
// Re-export the shared auth surface so existing importers of the daemon module
|
|
147
|
+
// keep working; the canonical home is now `auth.ts` (shared with http-ui.ts).
|
|
148
|
+
export { requireScope, SCOPE_READ, SCOPE_WRITE, SCOPE_SEND } from "./auth.ts";
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// Config
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
const STATE_DIR = defaultStateDir();
|
|
155
|
+
const INBOX_DIR = join(STATE_DIR, "inbox");
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Resolve the HTTP port the daemon binds (and self-registers in services.json),
|
|
159
|
+
* honoring sources in priority order:
|
|
160
|
+
*
|
|
161
|
+
* 1. `PORT` — the hub supervisor injects this from the module's services.json
|
|
162
|
+
* `entry.port` (the canonical pattern vault/scribe follow). It is the port
|
|
163
|
+
* the supervisor ALSO probes for readiness and proxies `/agent/*` to, so
|
|
164
|
+
* the daemon MUST bind it or the supervisor reports `started_but_unbound`
|
|
165
|
+
* and the proxy routes to a dead port (agent#41).
|
|
166
|
+
* 2. `PARACHUTE_AGENT_PORT` — manual override for a daemon run outside the
|
|
167
|
+
* supervisor. Falls back to the legacy `PARACHUTE_CHANNEL_PORT` (the
|
|
168
|
+
* pre-rename env var; still honored during the channel→agent transition).
|
|
169
|
+
* 3. `1941` — the compiled-in canonical default.
|
|
170
|
+
*
|
|
171
|
+
* Pre-#41 the daemon read only `PARACHUTE_CHANNEL_PORT`, so it ignored the
|
|
172
|
+
* supervisor's `PORT` and bound 1941 regardless — the supervisor's injected
|
|
173
|
+
* port and the bound port could disagree, stranding the proxy. Honoring `PORT`
|
|
174
|
+
* first closes that gap.
|
|
175
|
+
*
|
|
176
|
+
* Read at call time (not at import) so tests can drive each tier deterministically.
|
|
177
|
+
*
|
|
178
|
+
* Uses `||` (not `??`) for the fall-through so an EMPTY-string env value falls
|
|
179
|
+
* through rather than being treated as "set": `PORT=""` with `??` would yield
|
|
180
|
+
* `parseInt("")` = NaN and bind port 0 / garbage. `||` skips the empty string
|
|
181
|
+
* to the next tier — matches vault's defensive `parseInt(...) || ... || DEFAULT`.
|
|
182
|
+
* The final `1941` literal also guards a non-numeric value (`PORT="abc"` →
|
|
183
|
+
* `parseInt` NaN → falsy → falls through to the default).
|
|
184
|
+
*/
|
|
185
|
+
export function resolvePort(env: NodeJS.ProcessEnv = process.env): number {
|
|
186
|
+
return (
|
|
187
|
+
parseInt(env.PORT ?? "", 10) ||
|
|
188
|
+
parseInt(env.PARACHUTE_AGENT_PORT ?? "", 10) ||
|
|
189
|
+
parseInt(env.PARACHUTE_CHANNEL_PORT ?? "", 10) ||
|
|
190
|
+
1941
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const PORT = resolvePort();
|
|
195
|
+
|
|
196
|
+
/** Channel a bridge subscribes to when `?channel=` is omitted (back-compat). */
|
|
197
|
+
const DEFAULT_CHANNEL = "telegram";
|
|
198
|
+
|
|
199
|
+
/** Package version + install dir, for services.json self-registration. */
|
|
200
|
+
const PKG_VERSION = ((): string => {
|
|
201
|
+
try {
|
|
202
|
+
return JSON.parse(readFileSync(join(import.meta.dir, "..", "package.json"), "utf8")).version ?? "0.0.0";
|
|
203
|
+
} catch {
|
|
204
|
+
return "0.0.0";
|
|
205
|
+
}
|
|
206
|
+
})();
|
|
207
|
+
const INSTALL_DIR = join(import.meta.dir, "..");
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The argv the hub supervisor should spawn to (re)start this module — written
|
|
211
|
+
* into our services.json row so `parachute restart agent` / reboot-survival /
|
|
212
|
+
* adopt all have a command to run. Without it the supervisor knows the port but
|
|
213
|
+
* not how to start the process, so a manually-run `bun src/daemon.ts` daemon
|
|
214
|
+
* can't be supervised (agent#34).
|
|
215
|
+
*
|
|
216
|
+
* Sourced from our own `.parachute/module.json` `startCmd` (the canonical
|
|
217
|
+
* declaration the hub already prefers when it can read the install dir),
|
|
218
|
+
* falling back to the package.json `bin` name when the manifest is unreadable.
|
|
219
|
+
* The bin (`parachute-agent` → `src/daemon.ts`) runs the daemon directly and
|
|
220
|
+
* ignores extra argv, so the literal command is stable regardless of any
|
|
221
|
+
* subcommand the hub's first-party fallback might carry.
|
|
222
|
+
*/
|
|
223
|
+
export function resolveStartCmd(installDir: string): string[] {
|
|
224
|
+
try {
|
|
225
|
+
const manifest = JSON.parse(
|
|
226
|
+
readFileSync(join(installDir, ".parachute", "module.json"), "utf8"),
|
|
227
|
+
) as { startCmd?: unknown };
|
|
228
|
+
if (
|
|
229
|
+
Array.isArray(manifest.startCmd) &&
|
|
230
|
+
manifest.startCmd.length > 0 &&
|
|
231
|
+
manifest.startCmd.every((a) => typeof a === "string")
|
|
232
|
+
) {
|
|
233
|
+
return manifest.startCmd as string[];
|
|
234
|
+
}
|
|
235
|
+
} catch {
|
|
236
|
+
// fall through to the bin-name default
|
|
237
|
+
}
|
|
238
|
+
return ["parachute-agent"];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const START_CMD: string[] = resolveStartCmd(INSTALL_DIR);
|
|
242
|
+
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// Registry + routing
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Extract the agent-to-agent CALLBACK fields ("reply_to") from a flattened inbound `meta`
|
|
249
|
+
* (the vault transport's ingestInbound copies the note's metadata into `meta`, all string-
|
|
250
|
+
* valued). A SENDING agent stamps these on the inbound note it writes to the recipient:
|
|
251
|
+
* - `reply_to` — the sender's channel name; where to deliver the completion
|
|
252
|
+
* callback. Absent → no callback (an ordinary turn).
|
|
253
|
+
* - `correlation_id` — an opaque id the sender matches replies to requests with.
|
|
254
|
+
* - `delegation_depth` — how many hops deep this message is (the loop guard's counter).
|
|
255
|
+
* The vault stores it as a STRING, so coerce to a finite integer
|
|
256
|
+
* here; a missing/garbage value reads as 0 (a top-level turn).
|
|
257
|
+
*
|
|
258
|
+
* Returns ONLY the keys that are present, so spreading it into a {@link QueuedMessage} is a
|
|
259
|
+
* clean no-op when this isn't a delegated request. NOTE we read `reply_to` from metadata —
|
|
260
|
+
* NOT to be confused with the Telegram quote-reply `reply_to` on ReplyArgs (a message-id,
|
|
261
|
+
* a different axis that lives on the outbound side).
|
|
262
|
+
*/
|
|
263
|
+
export function callbackFieldsFromMeta(
|
|
264
|
+
meta: Record<string, string> | undefined,
|
|
265
|
+
): Pick<QueuedMessage, "replyTo" | "correlationId" | "delegationDepth"> {
|
|
266
|
+
if (!meta) return {};
|
|
267
|
+
const out: Pick<QueuedMessage, "replyTo" | "correlationId" | "delegationDepth"> = {};
|
|
268
|
+
if (typeof meta.reply_to === "string" && meta.reply_to) out.replyTo = meta.reply_to;
|
|
269
|
+
if (typeof meta.correlation_id === "string" && meta.correlation_id) {
|
|
270
|
+
out.correlationId = meta.correlation_id;
|
|
271
|
+
}
|
|
272
|
+
// Coerce the string-typed depth to a finite positive integer. Anything else — absent, "",
|
|
273
|
+
// "abc", a negative, OR a literal "0" — is OMITTED here; the drain's `?? 0` fallback
|
|
274
|
+
// (maybeDeliverCallback) treats an absent `delegationDepth` as 0, so a depth-0 message
|
|
275
|
+
// still gets to call back (the ceiling, not the floor, is what stops a runaway chain).
|
|
276
|
+
// We only bother storing a value when it's a meaningful positive depth.
|
|
277
|
+
const depth = Number(meta.delegation_depth);
|
|
278
|
+
if (Number.isFinite(depth) && depth > 0) out.delegationDepth = Math.floor(depth);
|
|
279
|
+
return out;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Build the per-channel context a transport routes through. Exported for tests
|
|
283
|
+
* (the inbound-routing fork lives here). */
|
|
284
|
+
export function contextFor(
|
|
285
|
+
registry: ClientRegistry,
|
|
286
|
+
channel: string,
|
|
287
|
+
deliveryState: DeliveryState,
|
|
288
|
+
programmatic?: ProgrammaticAgentRegistry,
|
|
289
|
+
attachedQueue?: AttachedQueueRegistry,
|
|
290
|
+
): TransportContext {
|
|
291
|
+
return {
|
|
292
|
+
channel,
|
|
293
|
+
emit(msg: InboundMessage): void {
|
|
294
|
+
// ── DAEMON ROUTING FORK (design 2026-06-18-channel-backend.md, the load-bearing
|
|
295
|
+
// change). Route inbound by the agent's BACKEND:
|
|
296
|
+
//
|
|
297
|
+
// backend: attached → the AttachedQueueRegistry path. The inbound
|
|
298
|
+
// `#agent/message/inbound` note IS the queue item (durable in the vault,
|
|
299
|
+
// status:pending by default). There is NO `claude -p`, NO serial worker, and
|
|
300
|
+
// NO live push — a connected Claude Code session PULLS it via the channel MCP
|
|
301
|
+
// surface. So an attached inbound is a NO-OP here beyond its own durability:
|
|
302
|
+
// we MUST NOT enqueue to the programmatic worker (that would run a turn the
|
|
303
|
+
// attached model deliberately doesn't), and we don't advance the delivery
|
|
304
|
+
// high-water-mark (there's no live subscriber to deliver to; the durable note
|
|
305
|
+
// queue + claim status is the durability, not replay). Checked FIRST so an
|
|
306
|
+
// attached agent NEVER falls through to the programmatic enqueue below.
|
|
307
|
+
if (attachedQueue?.hasChannel(channel)) {
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
// PROGRAMMATIC ROUTING (design 2026-06-16 step 3). If a programmatic agent is
|
|
311
|
+
// registered for this channel, the inbound becomes one on-demand `claude -p`
|
|
312
|
+
// turn — ENQUEUE it (the per-channel serial worker drains it) and do NOT also
|
|
313
|
+
// push to SSE/MCP: a programmatic agent has no live subscriber, so a fan-out
|
|
314
|
+
// would reach no one AND the delivery high-water-mark must NOT advance (there's
|
|
315
|
+
// nothing to deliver to; the queue is the durability). The note's id rides in
|
|
316
|
+
// `meta.note_id` so the reply threads to it.
|
|
317
|
+
if (programmatic?.hasChannel(channel)) {
|
|
318
|
+
programmatic.enqueue(channel, {
|
|
319
|
+
content: msg.content,
|
|
320
|
+
...(msg.meta?.note_id ? { inReplyTo: msg.meta.note_id } : {}),
|
|
321
|
+
// AGENT-TO-AGENT CALLBACK ROUTING ("reply_to") — pull the callback fields a
|
|
322
|
+
// SENDING agent stamped on this inbound note's metadata (flattened into `meta` by
|
|
323
|
+
// the vault transport's ingestInbound). When `reply_to` is present, the drain
|
|
324
|
+
// delivers a callback to that channel on turn completion. See callbackFieldsFromMeta.
|
|
325
|
+
...callbackFieldsFromMeta(msg.meta),
|
|
326
|
+
// Phase 1: carry inbound file attachments through to the turn (the programmatic
|
|
327
|
+
// backend stages them into the agent's private workspace so the turn can Read them).
|
|
328
|
+
...(msg.attachments && msg.attachments.length > 0 ? { attachments: msg.attachments } : {}),
|
|
329
|
+
});
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
// PENDING-INBOUND BUFFER (agent#121). No LIVE programmatic agent for this channel —
|
|
333
|
+
// but if the channel is EXPECTED to gain one (a def maps here; instantiation may be
|
|
334
|
+
// in flight, or a brief channel/agent desync), we must OWN the message, not drop it:
|
|
335
|
+
// the vault trigger acks success on our 200 and NEVER retries, so a silent drop is a
|
|
336
|
+
// PERMANENT loss (0 turns, 0 threads, no reply — the bug). Buffer it; `register()`
|
|
337
|
+
// replays the buffer in order once the agent is live. A genuinely UNKNOWN channel
|
|
338
|
+
// (nothing maps to it) returns "unknown": nothing to deliver to, so we log + fall
|
|
339
|
+
// through to the push path (which reaches no one) and still 200. We do NOT advance the
|
|
340
|
+
// delivery high-water-mark here (no real delivery happened; the durable note + the
|
|
341
|
+
// pending buffer / replay is the durability).
|
|
342
|
+
if (programmatic) {
|
|
343
|
+
const outcome = programmatic.queuePending(channel, {
|
|
344
|
+
content: msg.content,
|
|
345
|
+
...(msg.meta?.note_id ? { inReplyTo: msg.meta.note_id } : {}),
|
|
346
|
+
// Carry the callback fields through the PENDING buffer too — a delegated request
|
|
347
|
+
// that arrives before its recipient agent is live must still trigger a callback
|
|
348
|
+
// once the buffered turn runs on register() (the agent#121 replay path).
|
|
349
|
+
...callbackFieldsFromMeta(msg.meta),
|
|
350
|
+
// Phase 1: carry inbound attachments through the pending buffer too, so a turn
|
|
351
|
+
// that runs on register() still stages them.
|
|
352
|
+
...(msg.attachments && msg.attachments.length > 0 ? { attachments: msg.attachments } : {}),
|
|
353
|
+
});
|
|
354
|
+
if (outcome === "queued") return;
|
|
355
|
+
// outcome === "unknown" — not an expected programmatic channel. It may still be a
|
|
356
|
+
// genuine push/bridge channel (telegram, a connected session), so fall through to
|
|
357
|
+
// the normal SSE/MCP push below rather than dropping outright. If THAT also reaches
|
|
358
|
+
// no one (0 subscribers), the message is logged-as-undelivered by leaving the
|
|
359
|
+
// high-water-mark behind (the existing no-silent-loss behavior), and for a truly
|
|
360
|
+
// orphaned channel there is, by definition, nothing more we can do.
|
|
361
|
+
}
|
|
362
|
+
// Route on the bound `channel`, NOT msg.channel — the transport's own
|
|
363
|
+
// channel is authoritative. This makes it impossible for a transport to
|
|
364
|
+
// emit onto another channel (closing a silent cross-channel-leak footgun)
|
|
365
|
+
// even if a future transport sets msg.channel incorrectly.
|
|
366
|
+
const sseDelivered = registry.routeToChannel(channel, "message", {
|
|
367
|
+
content: msg.content,
|
|
368
|
+
meta: msg.meta,
|
|
369
|
+
source: msg.source,
|
|
370
|
+
});
|
|
371
|
+
// ALSO wake any HTTP MCP sessions on this channel — a session connected
|
|
372
|
+
// over /mcp/<channel> (vs. the stdio bridge over /events) receives the
|
|
373
|
+
// same inbound as a server-pushed notifications/claude/agent. Additive:
|
|
374
|
+
// the SSE path above is untouched.
|
|
375
|
+
const mcpDelivered = mcpPushToChannel(channel, msg.content, msg.meta);
|
|
376
|
+
|
|
377
|
+
// Advance the per-channel delivery high-water-mark ONLY on a real delivery
|
|
378
|
+
// (≥1 live subscriber across SSE bridges + MCP sessions). If nobody was
|
|
379
|
+
// listening (delivered === 0) we deliberately leave the mark BEHIND so this
|
|
380
|
+
// message replays the next time a session (re)connects — the spine of the
|
|
381
|
+
// no-silent-loss fix. The note's ts rides in `meta.ts` (ingestInbound
|
|
382
|
+
// flattens the note metadata, which carries the vault-written `ts`).
|
|
383
|
+
const delivered = sseDelivered + mcpDelivered;
|
|
384
|
+
const ts = msg.meta?.ts;
|
|
385
|
+
if (delivered > 0 && typeof ts === "string" && ts) {
|
|
386
|
+
deliveryState.advance(channel, ts);
|
|
387
|
+
}
|
|
388
|
+
},
|
|
389
|
+
emitPermissionVerdict(v): void {
|
|
390
|
+
registry.routeToChannel(channel, "permission_verdict", v);
|
|
391
|
+
mcpPushPermissionVerdict(channel, v);
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Instantiate one channel entry, start its transport, and register it in the
|
|
398
|
+
* LIVE channels map — the single per-channel "bring a channel up" path. Boot
|
|
399
|
+
* (`main`) and the config-management hot-add both go through here so they can't
|
|
400
|
+
* drift. If a channel with the same name is already live, its old transport is
|
|
401
|
+
* stopped first (config-API replace semantics).
|
|
402
|
+
*
|
|
403
|
+
* `start()` is awaited so a hot-add only reports success once the transport is
|
|
404
|
+
* actually receiving (e.g. the vault transport has fired its schema upsert). At
|
|
405
|
+
* boot a throw is logged per-channel and doesn't abort the others; the config
|
|
406
|
+
* API surfaces the throw to the caller as a 500.
|
|
407
|
+
*/
|
|
408
|
+
async function addChannelLive(
|
|
409
|
+
channels: Map<string, Channel>,
|
|
410
|
+
registry: ClientRegistry,
|
|
411
|
+
entry: ChannelEntry,
|
|
412
|
+
deliveryState: DeliveryState,
|
|
413
|
+
programmatic?: ProgrammaticAgentRegistry,
|
|
414
|
+
attachedQueue?: AttachedQueueRegistry,
|
|
415
|
+
): Promise<Channel> {
|
|
416
|
+
const existing = channels.get(entry.name);
|
|
417
|
+
if (existing) {
|
|
418
|
+
// Replace: stop the old transport before swapping it out so it releases any
|
|
419
|
+
// resources (pollers, SSE clients) before the new one starts.
|
|
420
|
+
try {
|
|
421
|
+
await existing.transport.stop();
|
|
422
|
+
} catch (err) {
|
|
423
|
+
console.error(`parachute-agent: stopping old transport for "${entry.name}" failed (continuing):`, err);
|
|
424
|
+
}
|
|
425
|
+
channels.delete(entry.name);
|
|
426
|
+
}
|
|
427
|
+
const transport = instantiateTransport(entry);
|
|
428
|
+
const channel: Channel = { name: entry.name, transport, entry };
|
|
429
|
+
channels.set(entry.name, channel);
|
|
430
|
+
await transport.start(contextFor(registry, entry.name, deliveryState, programmatic, attachedQueue));
|
|
431
|
+
return channel;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Stop a live channel's transport and remove it from the map. Idempotent — a
|
|
436
|
+
* missing name is a no-op returning false. The transport's `stop()` is awaited
|
|
437
|
+
* so it releases resources before we drop the reference.
|
|
438
|
+
*/
|
|
439
|
+
async function removeChannelLive(
|
|
440
|
+
channels: Map<string, Channel>,
|
|
441
|
+
name: string,
|
|
442
|
+
): Promise<boolean> {
|
|
443
|
+
const channel = channels.get(name);
|
|
444
|
+
if (!channel) return false;
|
|
445
|
+
try {
|
|
446
|
+
await channel.transport.stop();
|
|
447
|
+
} catch (err) {
|
|
448
|
+
console.error(`parachute-agent: stopping transport for "${name}" failed (continuing):`, err);
|
|
449
|
+
}
|
|
450
|
+
channels.delete(name);
|
|
451
|
+
return true;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// ---------------------------------------------------------------------------
|
|
455
|
+
// Vault-native agent definitions (design 2026-06-17-vault-native-agents, Phase 4a)
|
|
456
|
+
// ---------------------------------------------------------------------------
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Build the vault `ChannelEntry` for a vault-native agent's wake channel, from its
|
|
460
|
+
* def-vault binding. The agent's conversation lives in its def-vault, so the channel
|
|
461
|
+
* is a `vault` transport pointed at the SAME vault + token the def registry reads
|
|
462
|
+
* from (own-vault scoping — 4a). This is the exact `ChannelEntry` shape the existing
|
|
463
|
+
* create-agent flow + boot persist; we just synthesize it from the binding instead
|
|
464
|
+
* of from channels.json (the note IS the definition).
|
|
465
|
+
*/
|
|
466
|
+
export function defVaultChannelEntry(name: string, binding: DefVaultBinding): ChannelEntry {
|
|
467
|
+
return {
|
|
468
|
+
name,
|
|
469
|
+
transport: "vault",
|
|
470
|
+
config: {
|
|
471
|
+
vault: binding.vault,
|
|
472
|
+
...(binding.vaultUrl ? { vaultUrl: binding.vaultUrl } : {}),
|
|
473
|
+
token: binding.token,
|
|
474
|
+
},
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Build the {@link InstantiateDeps} the {@link AgentDefRegistry} drives, wired to the
|
|
480
|
+
* SAME machinery the create-agent flow + boot use — so a vault-defined agent comes up
|
|
481
|
+
* byte-for-byte like a UI-created one, only its SOURCE differs (a note, not a form):
|
|
482
|
+
* - ensureChannel → `addChannelLive` with a vault `ChannelEntry` from the binding;
|
|
483
|
+
* - setupAndRegister → `setupProgrammaticSpawn` (persist spec.json) + `programmatic.register`;
|
|
484
|
+
* - deregister → `programmatic.deregister`;
|
|
485
|
+
* - removeChannel → `removeChannelLive`.
|
|
486
|
+
*
|
|
487
|
+
* `setupProgrammaticSpawn` resolves the Claude credential early — a missing one
|
|
488
|
+
* throws `CredentialNotConfiguredError`, which the registry catches + stamps the
|
|
489
|
+
* note `status: error` (the agent can't run turns without auth; the note surfaces
|
|
490
|
+
* the gap rather than registering a dead agent). Secrets stay local throughout.
|
|
491
|
+
*/
|
|
492
|
+
export function buildInstantiateDeps(
|
|
493
|
+
channels: Map<string, Channel>,
|
|
494
|
+
registry: ClientRegistry,
|
|
495
|
+
deliveryState: DeliveryState,
|
|
496
|
+
programmatic: ProgrammaticAgentRegistry,
|
|
497
|
+
attachedQueue: AttachedQueueRegistry,
|
|
498
|
+
): InstantiateDeps {
|
|
499
|
+
return {
|
|
500
|
+
ensureChannel: async (name, binding) => {
|
|
501
|
+
// EXPECT-BEFORE-LIVE (agent#121). Mark this channel EXPECTED to gain a programmatic
|
|
502
|
+
// agent BEFORE we bring the channel transport live — closing the desync window: once
|
|
503
|
+
// the channel is live the vault trigger can fire an inbound, but the agent isn't
|
|
504
|
+
// `register()`ed until `setupAndRegister` runs (a later step). An inbound landing in
|
|
505
|
+
// that window now QUEUES PENDING (owned, replayed on register) instead of dropping.
|
|
506
|
+
// Harmless for a `channel`-backend agent — its inbound is handled by the attachedQueue
|
|
507
|
+
// routing fork first, so the expected mark is never consulted for it. The mark is
|
|
508
|
+
// cleared on register (the live index takes over) or on teardown (unexpectChannel).
|
|
509
|
+
programmatic.expectChannel(normalizeChannel(name).name);
|
|
510
|
+
await addChannelLive(
|
|
511
|
+
channels,
|
|
512
|
+
registry,
|
|
513
|
+
defVaultChannelEntry(name, binding),
|
|
514
|
+
deliveryState,
|
|
515
|
+
programmatic,
|
|
516
|
+
attachedQueue,
|
|
517
|
+
);
|
|
518
|
+
},
|
|
519
|
+
setupAndRegister: async (spec) => {
|
|
520
|
+
// ── BACKEND FORK (design 2026-06-18-channel-backend.md). An `attached` agent
|
|
521
|
+
// does NOT register with the programmatic registry (no `claude -p`, no serial
|
|
522
|
+
// worker) — it registers with the AttachedQueueRegistry, whose store is the
|
|
523
|
+
// agent's live VaultTransport (the durable inbound-note queue). A `programmatic`
|
|
524
|
+
// agent takes the existing path (persist spec.json + register the serial worker).
|
|
525
|
+
//
|
|
526
|
+
// DUAL-READ: a spec carrying the legacy backend value `"channel"` (un-normalized,
|
|
527
|
+
// e.g. read straight from an old spec.json) is treated as `"attached"` here too —
|
|
528
|
+
// belt-and-suspenders on top of the parse-path normalization in agent-defs.ts.
|
|
529
|
+
if (spec.backend === "attached" || (spec.backend as string) === "channel") {
|
|
530
|
+
const store = attachedQueueStoreFor(channels, spec.channels[0]);
|
|
531
|
+
if (!store) {
|
|
532
|
+
throw new Error(
|
|
533
|
+
`cannot register attached-backend agent "${spec.name}": its wake channel is not a ` +
|
|
534
|
+
`live vault transport (the queue needs the vault as its durable store)`,
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
attachedQueue.register(spec, store);
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
// Persist spec.json (so boot re-register + per-turn deliver find the workspace)
|
|
541
|
+
// then register — the same two steps the web programmatic spawn runs.
|
|
542
|
+
setupProgrammaticSpawn(spec);
|
|
543
|
+
await programmatic.register({ ...spec, backend: "programmatic" });
|
|
544
|
+
},
|
|
545
|
+
// Deregister covers BOTH registries — an agent lives in exactly one, and
|
|
546
|
+
// deregister is a no-op (returns false) where it isn't registered. OR the two so
|
|
547
|
+
// a reload/delete tears the agent down regardless of its backend.
|
|
548
|
+
deregister: async (name) => {
|
|
549
|
+
// Capture the wake channel BEFORE deregister drops the indexes, so we can clear the
|
|
550
|
+
// EXPECTED mark + any stranded pending buffer for a genuinely-removed agent (agent#121
|
|
551
|
+
// teardown — a deleted def must not leave its channel marked expected forever).
|
|
552
|
+
const wakeChannel = programmatic.getByName(name)?.channel;
|
|
553
|
+
const fromProgrammatic = await programmatic.deregister(name);
|
|
554
|
+
const fromChannel = attachedQueue.deregister(name);
|
|
555
|
+
if (wakeChannel) programmatic.unexpectChannel(wakeChannel);
|
|
556
|
+
return fromProgrammatic || fromChannel;
|
|
557
|
+
},
|
|
558
|
+
removeChannel: async (name) => removeChannelLive(channels, name),
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* The real "add a def-vault" implementation behind `POST /api/agent-vaults`: mint the
|
|
564
|
+
* vault's `vault:<name>:write` token (attenuated to the operator bearer, the SAME path
|
|
565
|
+
* `resolveDefVaults` mints the default with), persist it into `agent-vaults.json`
|
|
566
|
+
* (0600 — it carries a token), then `addVault` + `loadAll` for THAT vault so its defs
|
|
567
|
+
* come up LIVE immediately (no restart). Re-resolves the manager bearer + hub origin at
|
|
568
|
+
* request time (dynamic-read discipline — a credential set after boot is picked up).
|
|
569
|
+
* Returns the non-secret view; throws on a missing operator token, a mint refusal, or
|
|
570
|
+
* a duplicate vault. No-ops cleanly when no registry is wired.
|
|
571
|
+
*/
|
|
572
|
+
function defaultAddDefVault(
|
|
573
|
+
agentDefs: AgentDefRegistry | undefined,
|
|
574
|
+
): (args: { vault: string; url?: string }) => Promise<{ vault: string; url: string; tokenPresent: boolean }> {
|
|
575
|
+
return async ({ vault, url }) => {
|
|
576
|
+
if (!agentDefs) {
|
|
577
|
+
throw new Error("no def-vault registry configured (the vault-native agent path is idle)");
|
|
578
|
+
}
|
|
579
|
+
if (agentDefs.hasVault(vault)) {
|
|
580
|
+
throw new Error(`def-vault "${vault}" is already configured`);
|
|
581
|
+
}
|
|
582
|
+
const vaultUrl = url && url.length > 0 ? url : DEFAULT_DEF_VAULT_URL;
|
|
583
|
+
// Resolve the operator bearer + hub origin at request time (a credential set after
|
|
584
|
+
// boot is picked up). A missing operator token → can't mint a child token.
|
|
585
|
+
let managerBearer: string;
|
|
586
|
+
try {
|
|
587
|
+
managerBearer = resolveSpawnDeps().managerBearer;
|
|
588
|
+
} catch {
|
|
589
|
+
throw new Error(
|
|
590
|
+
"cannot mint the def-vault token — no operator token (the hub isn't provisioned yet)",
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
if (!managerBearer) {
|
|
594
|
+
throw new Error(
|
|
595
|
+
"cannot mint the def-vault token — no operator token (the hub isn't provisioned yet)",
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
const minted = await mintScopedToken(
|
|
599
|
+
{ scope: vaultScope(vault, "write") },
|
|
600
|
+
{ hubOrigin: getHubOrigin() || DEFAULT_HUB_ORIGIN, managerBearer },
|
|
601
|
+
);
|
|
602
|
+
const binding: DefVaultBinding = { vault, vaultUrl, token: minted.token };
|
|
603
|
+
// Persist into agent-vaults.json (merge: keep existing entries, append this one).
|
|
604
|
+
// Source the existing set from the LIVE registry bindings (which carry the real
|
|
605
|
+
// boot-minted tokens) — NOT a tokenless reconstruction from vaultNames(), which
|
|
606
|
+
// would clobber a boot-minted default's token to empty on disk and 401 next boot.
|
|
607
|
+
// Prefer the on-disk file when present (it's the durable record); fall back to the
|
|
608
|
+
// live bindings when no file has been written yet.
|
|
609
|
+
const stateDir = defaultStateDir();
|
|
610
|
+
const existing = readDefVaultsFile(stateDir)?.vaults ?? agentDefs.liveBindings();
|
|
611
|
+
const merged = [...existing.filter((v) => v.vault !== vault), binding];
|
|
612
|
+
writeDefVaultsFile({ vaults: merged }, stateDir);
|
|
613
|
+
// Bring the vault up LIVE: register it + load its defs now (the immediate path).
|
|
614
|
+
// NOTE: loadAll() reloads ALL configured def-vaults, not just the one just added —
|
|
615
|
+
// a slight over-read, acceptable at the current handful-of-vaults scale.
|
|
616
|
+
agentDefs.addVault(binding);
|
|
617
|
+
await agentDefs.loadAll();
|
|
618
|
+
return { vault, url: vaultUrl, tokenPresent: true };
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Build a {@link AttachedQueueStore} for a channel name from its live VaultTransport —
|
|
624
|
+
* the durable inbound-note queue an ATTACHED-backend agent's connected session pulls
|
|
625
|
+
* from (design 2026-06-18). Returns null when the channel isn't a live vault transport
|
|
626
|
+
* (an attached agent's queue REQUIRES the vault as its source of truth). The store is a
|
|
627
|
+
* thin adapter over the transport's `listInboundQueue` / `setInboundStatus` / `reply`
|
|
628
|
+
* — the same `reply()` the programmatic worker uses, so the outbound is durable +
|
|
629
|
+
* loop-safe (tagged `#agent/message/outbound`, which the inbound trigger never fires on).
|
|
630
|
+
*/
|
|
631
|
+
export function attachedQueueStoreFor(
|
|
632
|
+
channels: Map<string, Channel>,
|
|
633
|
+
channelName: string | { name: string } | undefined,
|
|
634
|
+
): AttachedQueueStore | null {
|
|
635
|
+
const name = typeof channelName === "string" ? channelName : channelName?.name;
|
|
636
|
+
if (!name) return null;
|
|
637
|
+
const vt = channels.get(name)?.transport;
|
|
638
|
+
if (!(vt instanceof VaultTransport)) return null;
|
|
639
|
+
return {
|
|
640
|
+
listInboundQueue: (opts) => vt.listInboundQueue(opts),
|
|
641
|
+
// Forward ALL FOUR args — the 4th `ifUpdatedAt` is the CAS precondition the
|
|
642
|
+
// single-claim guard (agent#101) depends on. A 3-arg arrow silently dropped it,
|
|
643
|
+
// collapsing every claim to `force:true` (last-write-wins) and DISABLING the CAS in
|
|
644
|
+
// production (the double-claim race PR #116 closed was re-opened for attached agents).
|
|
645
|
+
setInboundStatus: (id, status, claimedAt, ifUpdatedAt) =>
|
|
646
|
+
vt.setInboundStatus(id, status, claimedAt, ifUpdatedAt),
|
|
647
|
+
reply: async (args) => {
|
|
648
|
+
return vt.reply({
|
|
649
|
+
channel: name,
|
|
650
|
+
text: args.text,
|
|
651
|
+
...(args.inReplyTo ? { meta: { in_reply_to: args.inReplyTo } } : {}),
|
|
652
|
+
});
|
|
653
|
+
},
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// ---------------------------------------------------------------------------
|
|
658
|
+
// Programmatic-agent backend wiring (design 2026-06-16)
|
|
659
|
+
// ---------------------------------------------------------------------------
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Build the {@link WriteOutbound} the programmatic registry posts a turn's reply
|
|
663
|
+
* through: resolve the channel's transport from the live `channels` map and call its
|
|
664
|
+
* `reply()` — the SAME outbound path the interactive `reply` tool uses, so a
|
|
665
|
+
* programmatic reply is durable + renders in the chat UI exactly like an
|
|
666
|
+
* interactive one. For a VaultTransport this writes a `#agent/message/outbound`
|
|
667
|
+
* note; the vault inbound trigger keys on `#agent/message/inbound`, so writing the
|
|
668
|
+
* reply CANNOT re-trigger the inbound webhook (verified: no loop). `inReplyTo`
|
|
669
|
+
* threads the reply to the inbound note id.
|
|
670
|
+
*
|
|
671
|
+
* A missing transport (channel deregistered between the turn + its reply) throws —
|
|
672
|
+
* the registry's drain logs it and moves on; it never re-runs the turn (which would
|
|
673
|
+
* fork the conversation).
|
|
674
|
+
*/
|
|
675
|
+
export function buildWriteOutbound(channels: Map<string, Channel>): WriteOutbound {
|
|
676
|
+
return async (channel, reply, inReplyTo, threadId) => {
|
|
677
|
+
const ch = channels.get(channel);
|
|
678
|
+
if (!ch) {
|
|
679
|
+
throw new Error(`no live transport for channel "${channel}" — cannot post the reply`);
|
|
680
|
+
}
|
|
681
|
+
// Carry the in-reply-to + the per-turn thread id through the transport's `meta` escape
|
|
682
|
+
// hatch. The vault transport stamps `meta.thread` into the outbound note's
|
|
683
|
+
// `metadata.thread` — the explicit definition→thread→message link the outbound note
|
|
684
|
+
// gets (multi-threaded: the per-fire note leaf; single-threaded: a per-turn id).
|
|
685
|
+
const meta: Record<string, string> = {};
|
|
686
|
+
if (inReplyTo) meta.in_reply_to = inReplyTo;
|
|
687
|
+
if (threadId) meta.thread = threadId;
|
|
688
|
+
const sent = await ch.transport.reply({
|
|
689
|
+
channel,
|
|
690
|
+
text: reply,
|
|
691
|
+
...(Object.keys(meta).length > 0 ? { meta } : {}),
|
|
692
|
+
});
|
|
693
|
+
// Surface the written outbound note id so the agent-to-agent callback can point its
|
|
694
|
+
// `source_message` at it (the orchestrator pulls the full reply from there). `reply()`
|
|
695
|
+
// returns `{ sent: [noteId] }`; the first id is the note. Absent/empty → undefined,
|
|
696
|
+
// and the callback simply omits source_message.
|
|
697
|
+
return { ...(sent?.sent?.[0] ? { id: sent.sent[0] } : {}) };
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Build the {@link WriteThread} the programmatic registry posts each turn's thread note
|
|
703
|
+
* through — the UNIFIED model, called for BOTH modes (the structural unification: every
|
|
704
|
+
* turn materializes a thread note). Resolve the channel's transport from the live
|
|
705
|
+
* `channels` map and call its `writeThread()` (a VaultTransport writes a `#agent/thread`
|
|
706
|
+
* note; single-threaded upserts one note per channel, multi-threaded writes one per fire).
|
|
707
|
+
* A transport without a durable store (telegram) has no `writeThread`; we no-op there (the
|
|
708
|
+
* turn still runs — it just leaves no thread note). A missing transport (channel
|
|
709
|
+
* deregistered between the turn + its thread record) throws; the registry logs it and moves
|
|
710
|
+
* on (it never re-runs the turn).
|
|
711
|
+
*/
|
|
712
|
+
export function buildWriteThread(channels: Map<string, Channel>): WriteThread {
|
|
713
|
+
return async (thread) => {
|
|
714
|
+
const ch = channels.get(thread.channel);
|
|
715
|
+
if (!ch) {
|
|
716
|
+
throw new Error(
|
|
717
|
+
`no live transport for channel "${thread.channel}" — cannot write the thread note`,
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
// Only a transport with a durable store implements writeThread (the VaultTransport).
|
|
721
|
+
if (!ch.transport.writeThread) return;
|
|
722
|
+
await ch.transport.writeThread({
|
|
723
|
+
channel: thread.channel,
|
|
724
|
+
...(thread.name ? { name: thread.name } : {}),
|
|
725
|
+
...(thread.definition ? { definition: thread.definition } : {}),
|
|
726
|
+
mode: thread.mode,
|
|
727
|
+
status: thread.status,
|
|
728
|
+
input: thread.input,
|
|
729
|
+
output: thread.output,
|
|
730
|
+
started_at: thread.started_at,
|
|
731
|
+
ended_at: thread.ended_at,
|
|
732
|
+
...(thread.usage ? { usage: thread.usage } : {}),
|
|
733
|
+
// The Claude session UUID — persisted to the note's `metadata.session` (thread≡session
|
|
734
|
+
// record) so the next turn `--resume`s it (read back via `readThreadSession`).
|
|
735
|
+
...(thread.session ? { session: thread.session } : {}),
|
|
736
|
+
// Forward the per-turn thread id + same-turn flag + lifecycle phase to the transport.
|
|
737
|
+
// These are LOAD-BEARING (not optional decoration):
|
|
738
|
+
// - threadId — multi-threaded targets the SAME per-fire note across the start-ensure,
|
|
739
|
+
// the end-record, AND the outbound-failure re-record (else each mints a duplicate).
|
|
740
|
+
// - sameTurn — the outbound-failure re-record keeps turn_count (no double-count).
|
|
741
|
+
// - phase — `start` (working-ensure: turn_count UNCHANGED) vs `end` (turn counted).
|
|
742
|
+
...(thread.threadId ? { threadId: thread.threadId } : {}),
|
|
743
|
+
...(thread.sameTurn ? { sameTurn: true } : {}),
|
|
744
|
+
...(thread.phase ? { phase: thread.phase } : {}),
|
|
745
|
+
});
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Build the {@link WriteCallback} the programmatic registry delivers an agent-to-agent
|
|
751
|
+
* completion callback through (the "reply_to" substrate). Resolve the SENDER's (`reply_to`)
|
|
752
|
+
* channel transport from the live `channels` map and write a CALLBACK inbound note there
|
|
753
|
+
* (`writeCallback` → a `#agent/message/inbound` note + the {@link CallbackMetadata}
|
|
754
|
+
* contract). The vault trigger on that note wakes the sender's agent through the normal
|
|
755
|
+
* inbound path — so an orchestrator is resumed by its own channel exactly as if a human
|
|
756
|
+
* had messaged it, and the per-channel serial drain handles N returning callbacks FIFO.
|
|
757
|
+
*
|
|
758
|
+
* UNKNOWN / not-live reply_to channel (reuses the #122 own-it-don't-strand posture): if the
|
|
759
|
+
* channel has no live VaultTransport, we LOG and return WITHOUT throwing — a callback that
|
|
760
|
+
* can't be delivered must not crash the recipient's drain or strand its queue. (We don't
|
|
761
|
+
* throw — unlike buildWriteOutbound/buildWriteThread, where a missing transport IS an error
|
|
762
|
+
* worth surfacing — because a callback is best-effort orchestration sugar: the recipient's
|
|
763
|
+
* turn already ran + recorded; only the onward notification is lost, and the sender can still
|
|
764
|
+
* poll the recipient's thread/transcript out-of-band.)
|
|
765
|
+
*
|
|
766
|
+
* LOOP SAFETY: `writeCallback` writes the inbound WITHOUT a `reply_to` (terminal callback),
|
|
767
|
+
* so the woken sender's turn cannot auto-emit another callback. Verified end-to-end:
|
|
768
|
+
* callback note → vault trigger → /api/vault/inbound → contextFor.emit → the sender's drain;
|
|
769
|
+
* `callbackFieldsFromMeta` finds no `reply_to`, so `maybeDeliverCallback` no-ops there.
|
|
770
|
+
*/
|
|
771
|
+
export function buildWriteCallback(channels: Map<string, Channel>): WriteCallback {
|
|
772
|
+
return async (channel, content, meta) => {
|
|
773
|
+
const ch = channels.get(channel);
|
|
774
|
+
const vt = ch?.transport instanceof VaultTransport ? ch.transport : undefined;
|
|
775
|
+
if (!vt || !vt.writeCallback) {
|
|
776
|
+
// Own-it-don't-strand: no live vault transport for the reply_to channel. The sender
|
|
777
|
+
// may have been torn down, or never been a vault-backed channel. Log + drop — the
|
|
778
|
+
// recipient turn already completed + recorded; we never throw (which would surface as
|
|
779
|
+
// an error in the recipient's drain).
|
|
780
|
+
console.warn(
|
|
781
|
+
`parachute-agent: callback for source "${meta.source_channel}" could not be delivered ` +
|
|
782
|
+
`— reply_to channel "${channel}" has no live vault transport (dropping the callback; ` +
|
|
783
|
+
`the turn itself completed + recorded normally).`,
|
|
784
|
+
);
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
// `meta` is the registry's CallbackMeta; the transport's CallbackMetadata is the
|
|
788
|
+
// structurally-identical local mirror (the transport layer doesn't import the backend
|
|
789
|
+
// layer), so it passes without a cast.
|
|
790
|
+
await vt.writeCallback(content, meta);
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Build the {@link ProgrammaticAgentRegistry}'s pre-turn session read — the thread≡session
|
|
796
|
+
* record. Resolve the channel's transport from the live `channels` map and read the
|
|
797
|
+
* persisted Claude session UUID off its deterministic `#agent/thread` note (only the
|
|
798
|
+
* VaultTransport implements `readThreadSession`; telegram/http-ui omit it → undefined →
|
|
799
|
+
* the turn creates a fresh session). The registry calls this BEFORE a single-threaded turn
|
|
800
|
+
* so the turn `--resume`s its prior conversation. Mirrors {@link buildWriteThread}.
|
|
801
|
+
*/
|
|
802
|
+
export function buildReadSession(
|
|
803
|
+
channels: Map<string, Channel>,
|
|
804
|
+
): (channel: string, name: string) => Promise<string | undefined> {
|
|
805
|
+
return async (channel, name) => {
|
|
806
|
+
const ch = channels.get(channel);
|
|
807
|
+
if (!ch?.transport.readThreadSession) return undefined;
|
|
808
|
+
return ch.transport.readThreadSession(channel, name);
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* Build the {@link ProgrammaticAgentRegistry}'s session CLEAR — the per-agent restart /
|
|
814
|
+
* reset. Resolve the channel's transport and wipe the persisted session on its
|
|
815
|
+
* deterministic `#agent/thread` note (only the VaultTransport implements
|
|
816
|
+
* `clearThreadSession`; telegram/http-ui omit it → a clean no-op). `resetSession` calls
|
|
817
|
+
* this so the agent's NEXT turn finds no session and starts a fresh claude conversation.
|
|
818
|
+
* Mirrors {@link buildReadSession}.
|
|
819
|
+
*/
|
|
820
|
+
export function buildClearSession(
|
|
821
|
+
channels: Map<string, Channel>,
|
|
822
|
+
): (channel: string, name: string) => Promise<void> {
|
|
823
|
+
return async (channel, name) => {
|
|
824
|
+
const ch = channels.get(channel);
|
|
825
|
+
if (!ch?.transport.clearThreadSession) return;
|
|
826
|
+
await ch.transport.clearThreadSession(channel, name);
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* Build the REAL programmatic-agent registry — the {@link ProgrammaticBackend}
|
|
832
|
+
* wired to the env-resolved spawn deps, plus the outbound-write + thread-note +
|
|
833
|
+
* session-read seams over the live `channels`. The session UUID lives on the durable
|
|
834
|
+
* `#agent/thread` note (`metadata.session`) — read pre-turn via `readSession`
|
|
835
|
+
* ({@link buildReadSession}) and persisted post-turn via `writeThread` — there is no
|
|
836
|
+
* separate session store. Lazily defaulted by `createFetchHandler` and constructed
|
|
837
|
+
* explicitly by `main` (so the same instance the routes use is the one the transports'
|
|
838
|
+
* `contextFor` enqueues onto).
|
|
839
|
+
*
|
|
840
|
+
* Best-effort on the backend deps: if the operator token / hub origin can't be
|
|
841
|
+
* resolved yet, the backend still constructs (its mint happens per-turn and will
|
|
842
|
+
* surface the error there as a `{ ok: false }` — not at boot), so a daemon with no
|
|
843
|
+
* hub provisioned yet still starts and can register programmatic agents.
|
|
844
|
+
*/
|
|
845
|
+
export function createDefaultProgrammaticRegistry(
|
|
846
|
+
channels: Map<string, Channel>,
|
|
847
|
+
onTurnEvent?: TurnEventSink,
|
|
848
|
+
): ProgrammaticAgentRegistry {
|
|
849
|
+
// Resolve the spawn deps lazily/defensively — a missing operator token must not
|
|
850
|
+
// crash boot (the interactive path resolves per-spawn too). We read what we can
|
|
851
|
+
// and let the per-turn mint surface any gap as a failure-value.
|
|
852
|
+
let backendDeps: ProgrammaticBackendDeps;
|
|
853
|
+
try {
|
|
854
|
+
const deps = resolveSpawnDeps();
|
|
855
|
+
backendDeps = {
|
|
856
|
+
hubOrigin: deps.hubOrigin,
|
|
857
|
+
managerBearer: deps.managerBearer,
|
|
858
|
+
...(deps.vaultUrl ? { vaultUrl: deps.vaultUrl } : {}),
|
|
859
|
+
sessionsDir: deps.sessionsDir,
|
|
860
|
+
runtimeReadOnly: deps.runtimeReadOnly,
|
|
861
|
+
spawnFn: realProgrammaticSpawn(),
|
|
862
|
+
...(deps.claudeBin ? { claudeBin: deps.claudeBin } : {}),
|
|
863
|
+
// 4b: the hub grants client — reuses the manager bearer (same operator token
|
|
864
|
+
// the vault-token mint uses). Lets each `claude -p` turn inject the agent's
|
|
865
|
+
// APPROVED cross-resource grants (other-vault MCP, service env/MCP). design
|
|
866
|
+
// 2026-06-17-agent-connectors-4b.md.
|
|
867
|
+
grants: new GrantsClient({ hubOrigin: deps.hubOrigin, managerBearer: deps.managerBearer }),
|
|
868
|
+
};
|
|
869
|
+
} catch {
|
|
870
|
+
// No operator token yet — construct with placeholders; a per-turn mint will
|
|
871
|
+
// fail cleanly (as a value) until the hub is provisioned. The registry + queue
|
|
872
|
+
// still work; only the actual `claude -p` turn needs the credential.
|
|
873
|
+
backendDeps = {
|
|
874
|
+
hubOrigin: "",
|
|
875
|
+
managerBearer: "",
|
|
876
|
+
sessionsDir: defaultSessionsDir(),
|
|
877
|
+
runtimeReadOnly: [],
|
|
878
|
+
spawnFn: realProgrammaticSpawn(),
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
const backend = new ProgrammaticBackend(backendDeps);
|
|
882
|
+
return new ProgrammaticAgentRegistry({
|
|
883
|
+
backend,
|
|
884
|
+
writeOutbound: buildWriteOutbound(channels),
|
|
885
|
+
writeThread: buildWriteThread(channels),
|
|
886
|
+
writeCallback: buildWriteCallback(channels),
|
|
887
|
+
readSession: buildReadSession(channels),
|
|
888
|
+
clearSession: buildClearSession(channels),
|
|
889
|
+
...(onTurnEvent ? { onTurnEvent } : {}),
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Build the {@link TurnEventSink} that pushes a programmatic turn's live progress
|
|
895
|
+
* (interim assistant text + tool_use, plus the registry's done/error lifecycle
|
|
896
|
+
* events) to the channel's turn-event SSE subscribers — the chat UI's "watch it
|
|
897
|
+
* work" view (design 2026-06-16 build item #1).
|
|
898
|
+
*
|
|
899
|
+
* Transport choice (documented in the PR): a DEDICATED per-channel SSE stream
|
|
900
|
+
* (`/api/channels/<ch>/turn-events`) over the existing {@link ClientRegistry},
|
|
901
|
+
* NOT the durable-message poll. Rationale — the chat already POLLs vault channels
|
|
902
|
+
* for their DURABLE transcript (the `#agent/message` notes, the record of truth);
|
|
903
|
+
* turn progress is EPHEMERAL and chunk-frequent, so polling would be coarse + would
|
|
904
|
+
* surface partial state as if durable. An SSE stream is the clean real-time fit and
|
|
905
|
+
* reuses the registry/`sseFrame` infra already in the daemon. The durable path is
|
|
906
|
+
* untouched: the final `result` still becomes the `#agent/message/outbound` note,
|
|
907
|
+
* and the live stream is purely additive progress that the UI finalizes against it.
|
|
908
|
+
*
|
|
909
|
+
* Keyed by channel; fans out to every subscriber on that channel. A 0-subscriber
|
|
910
|
+
* turn is a clean no-op (the events drop; the durable note still lands) — there is
|
|
911
|
+
* no high-water-mark / replay for live progress (it's ephemeral by design).
|
|
912
|
+
*/
|
|
913
|
+
export function buildTurnEventSink(turnEvents: ClientRegistry): TurnEventSink {
|
|
914
|
+
return (channel, event) => {
|
|
915
|
+
// routeToChannel swallows dead-stream enqueues (drops the client); a 0-subscriber
|
|
916
|
+
// channel returns 0 delivered — both are fine, progress is best-effort.
|
|
917
|
+
turnEvents.routeToChannel(channel, "turn", event);
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* Map the registered programmatic agents to the {@link AgentInfo} shape the
|
|
923
|
+
* `/api/agents` list returns — `backend: "programmatic"` + a live `status`
|
|
924
|
+
* (`idle` | `working` | `queued:N`) in place of the interactive `attached`/
|
|
925
|
+
* `mcp_sessions` liveness (design 2026-06-16 step 6). No tmux session, so `session`
|
|
926
|
+
* is the conventional `<name>-agent` label for display continuity and `attached` is
|
|
927
|
+
* always false.
|
|
928
|
+
*/
|
|
929
|
+
export function listProgrammaticAgents(programmatic: ProgrammaticAgentRegistry): AgentInfo[] {
|
|
930
|
+
const dir = defaultSessionsDir();
|
|
931
|
+
return programmatic
|
|
932
|
+
.list()
|
|
933
|
+
.map((h) => {
|
|
934
|
+
const s = programmatic.statusOf(h.channel);
|
|
935
|
+
const status = s.state === "queued" ? `queued:${s.queued}` : s.state;
|
|
936
|
+
const workspace = sessionWorkspace(dir, h.name);
|
|
937
|
+
const hasPrompt = typeof h.spec.systemPrompt === "string" && h.spec.systemPrompt.length > 0;
|
|
938
|
+
// Surface the working dir only when set AND still present on disk (a deleted
|
|
939
|
+
// dir post-spawn shouldn't show a dead-path badge — mirrors `hasWorkspace`).
|
|
940
|
+
const hasWorkingDir =
|
|
941
|
+
typeof h.spec.workspace === "string" && h.spec.workspace.length > 0 && existsSync(h.spec.workspace);
|
|
942
|
+
return {
|
|
943
|
+
name: h.name,
|
|
944
|
+
session: `${h.name}-agent`,
|
|
945
|
+
workspace,
|
|
946
|
+
hasWorkspace: existsSync(join(workspace, "spec.json")),
|
|
947
|
+
backend: "programmatic" as const,
|
|
948
|
+
status,
|
|
949
|
+
...(hasPrompt ? { systemPromptMode: h.spec.systemPromptMode ?? "append" } : {}),
|
|
950
|
+
...(hasWorkingDir ? { workingDir: h.spec.workspace } : {}),
|
|
951
|
+
};
|
|
952
|
+
})
|
|
953
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* Map the registered CHANNEL-backend agents to the {@link AgentInfo} shape the
|
|
958
|
+
* `/api/agents` list returns (#102 — the v2 API layer stops rejecting `channel`).
|
|
959
|
+
* A channel agent has no tmux session + no daemon-run turn: its turns are handled by
|
|
960
|
+
* a Claude Code session the operator connects to the channel's MCP endpoint, and the
|
|
961
|
+
* inbound notes accumulate as a durable queue. So `attached` is always false, the
|
|
962
|
+
* `session` label is the conventional `<name>-agent` for display continuity, and the
|
|
963
|
+
* live `status` is `queued:N` (N = pending inbound waiting for the connected session)
|
|
964
|
+
* or `idle`. The pending counts are read from the queue in parallel (one vault read
|
|
965
|
+
* each) — best-effort: a queue read failure degrades that agent's status to `idle`,
|
|
966
|
+
* never failing the whole list. NEVER surfaces a token/secret.
|
|
967
|
+
*/
|
|
968
|
+
export async function listAttachedAgents(attachedQueue: AttachedQueueRegistry): Promise<AgentInfo[]> {
|
|
969
|
+
const dir = defaultSessionsDir();
|
|
970
|
+
const records = attachedQueue.list();
|
|
971
|
+
return Promise.all(
|
|
972
|
+
records.map(async (rec) => {
|
|
973
|
+
let status = "idle";
|
|
974
|
+
try {
|
|
975
|
+
const view = await attachedQueue.pending(rec.channel);
|
|
976
|
+
status = view.count > 0 ? `queued:${view.count}` : "idle";
|
|
977
|
+
} catch {
|
|
978
|
+
// A queue read failure shouldn't sink the list — show idle, not an error.
|
|
979
|
+
}
|
|
980
|
+
const workspace = sessionWorkspace(dir, rec.name);
|
|
981
|
+
const info: AgentInfo = {
|
|
982
|
+
name: rec.name,
|
|
983
|
+
session: `${rec.name}-agent`,
|
|
984
|
+
workspace,
|
|
985
|
+
hasWorkspace: existsSync(join(workspace, "spec.json")),
|
|
986
|
+
backend: "attached",
|
|
987
|
+
status,
|
|
988
|
+
channel: rec.channel,
|
|
989
|
+
...(rec.systemPrompt ? { systemPromptMode: "append" as const } : {}),
|
|
990
|
+
...(rec.vault ? { vault: rec.vault } : {}),
|
|
991
|
+
};
|
|
992
|
+
return info;
|
|
993
|
+
}),
|
|
994
|
+
).then((infos) => infos.sort((a, b) => a.name.localeCompare(b.name)));
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* BOOT RE-REGISTER (design 2026-06-16 step 2). Scan the per-session workspaces under
|
|
999
|
+
* the sessions dir, read each `spec.json`, and re-register every spec whose
|
|
1000
|
+
* `backend === "programmatic"` into the live registry — so a programmatic agent,
|
|
1001
|
+
* which has no resident process to survive a restart, resumes routing inbound to an
|
|
1002
|
+
* on-demand turn after a daemon restart. The session UUID lives on the `#agent/thread`
|
|
1003
|
+
* note (`metadata.session`), so that next turn reads it back + `--resume`s the prior
|
|
1004
|
+
* conversation, so no message is lost in the restart window beyond the normal
|
|
1005
|
+
* inbound-trigger durability.
|
|
1006
|
+
*
|
|
1007
|
+
* INTERACTIVE specs are SKIPPED — their tmux sessions survive a daemon restart on
|
|
1008
|
+
* their own (or are restarted via the supervisor), and re-registering them here
|
|
1009
|
+
* would be wrong (they aren't programmatic). Best-effort: an unreadable spec / a
|
|
1010
|
+
* register failure is logged per-agent and never aborts boot. Returns the count
|
|
1011
|
+
* re-registered. `sessionsDirPath` is injectable for tests.
|
|
1012
|
+
*
|
|
1013
|
+
* ORPHAN GUARD (agent#75 — defense-in-depth). A spec dir is durable cruft: it can
|
|
1014
|
+
* outlive the channel it was spawned for (a deleted agent whose workspace wasn't
|
|
1015
|
+
* swept, a crash mid-spawn, a leaked test fixture, a hand-copied dir). Re-registering
|
|
1016
|
+
* a programmatic agent whose wake channel ISN'T in the live channels config would
|
|
1017
|
+
* resurrect a PHANTOM agent — one with nothing to receive for (no live channel feeds
|
|
1018
|
+
* it inbound), confusing the operator and the agent list. So we re-register ONLY a
|
|
1019
|
+
* spec whose wake channel STILL EXISTS in `channels` (the live channels.json-derived
|
|
1020
|
+
* map); a spec for a missing channel is SKIPPED with a one-line notice, making any
|
|
1021
|
+
* orphaned/leaked spec dir inert. The wake channel is keyed exactly as the registry
|
|
1022
|
+
* keys it (`normalizeChannel(spec.channels[0]).name` — see `ProgrammaticAgentRegistry`).
|
|
1023
|
+
* A spec with an EMPTY channels array is also skipped (it has no wake channel to key /
|
|
1024
|
+
* route on — re-registering it would throw at the registry's channelOf).
|
|
1025
|
+
*/
|
|
1026
|
+
export async function reregisterProgrammaticAgents(
|
|
1027
|
+
programmatic: ProgrammaticAgentRegistry,
|
|
1028
|
+
channels: Map<string, Channel>,
|
|
1029
|
+
sessionsDirPath: string = defaultSessionsDir(),
|
|
1030
|
+
): Promise<number> {
|
|
1031
|
+
let entries: string[];
|
|
1032
|
+
try {
|
|
1033
|
+
entries = readdirSync(sessionsDirPath, { withFileTypes: true })
|
|
1034
|
+
.filter((d) => d.isDirectory())
|
|
1035
|
+
.map((d) => d.name);
|
|
1036
|
+
} catch {
|
|
1037
|
+
// No sessions dir yet (first boot) — nothing to re-register.
|
|
1038
|
+
return 0;
|
|
1039
|
+
}
|
|
1040
|
+
let count = 0;
|
|
1041
|
+
for (const name of entries) {
|
|
1042
|
+
const workspace = sessionWorkspace(sessionsDirPath, name);
|
|
1043
|
+
const spec = readPersistedSpec(workspace);
|
|
1044
|
+
// Re-register ONLY specs that explicitly persisted `backend: "programmatic"`.
|
|
1045
|
+
// A spec with no `backend` field (pre-field, was interactive) or the retired
|
|
1046
|
+
// `backend: "interactive"` value is SKIPPED — the interactive backend was retired
|
|
1047
|
+
// 2026-06-19 (design 2026-06-19-retire-interactive-backend.md), so a stale
|
|
1048
|
+
// interactive spec on disk is inert: never migrated to programmatic, never launched.
|
|
1049
|
+
if (!spec || spec.backend !== "programmatic") continue;
|
|
1050
|
+
// ORPHAN GUARD: a spec with no wake channel, or whose wake channel isn't a live
|
|
1051
|
+
// channel, has nothing to receive for — skip it so a leaked/stale spec dir can't
|
|
1052
|
+
// resurrect a phantom agent. Keyed exactly as the registry keys the channel.
|
|
1053
|
+
const wakeChannel = spec.channels[0]
|
|
1054
|
+
? normalizeChannel(spec.channels[0]).name
|
|
1055
|
+
: undefined;
|
|
1056
|
+
if (!wakeChannel) {
|
|
1057
|
+
console.log(
|
|
1058
|
+
`parachute-agent: skipping re-register of "${spec.name}" — spec declares no channel.`,
|
|
1059
|
+
);
|
|
1060
|
+
continue;
|
|
1061
|
+
}
|
|
1062
|
+
if (!channels.has(wakeChannel)) {
|
|
1063
|
+
console.log(
|
|
1064
|
+
`parachute-agent: skipping re-register of "${spec.name}" — channel "${wakeChannel}" not configured.`,
|
|
1065
|
+
);
|
|
1066
|
+
continue;
|
|
1067
|
+
}
|
|
1068
|
+
try {
|
|
1069
|
+
await programmatic.register(spec);
|
|
1070
|
+
count++;
|
|
1071
|
+
console.log(`parachute-agent: re-registered programmatic agent "${spec.name}" (channel ${wakeChannel}).`);
|
|
1072
|
+
} catch (err) {
|
|
1073
|
+
console.error(
|
|
1074
|
+
`parachute-agent: failed to re-register programmatic agent "${name}" from spec.json: ${(err as Error).message}`,
|
|
1075
|
+
);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
if (count > 0) {
|
|
1079
|
+
console.log(`parachute-agent: re-registered ${count} programmatic agent(s) from persisted specs.`);
|
|
1080
|
+
}
|
|
1081
|
+
return count;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// ---------------------------------------------------------------------------
|
|
1085
|
+
// HTTP server
|
|
1086
|
+
// ---------------------------------------------------------------------------
|
|
1087
|
+
|
|
1088
|
+
function json(data: unknown, status = 200): Response {
|
|
1089
|
+
return new Response(JSON.stringify(data), {
|
|
1090
|
+
status,
|
|
1091
|
+
headers: { "content-type": "application/json" },
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
/**
|
|
1096
|
+
* 302-redirect a retired server-rendered page to the v2 SPA (Phase 4c). The
|
|
1097
|
+
* Location is RELATIVE so the browser resolves it against the request URL,
|
|
1098
|
+
* working both daemon-direct (`/ui` → `/app/...`) and hub-proxied (`/agent/ui`
|
|
1099
|
+
* → `/agent/app/...`) without the daemon needing to know its public mount
|
|
1100
|
+
* (the hub strips the `/agent` prefix before the daemon ever sees the path).
|
|
1101
|
+
*
|
|
1102
|
+
* From a single-segment page like `/ui` or `/agents`, a relative `app/` target
|
|
1103
|
+
* resolves to `/app/` (and `app/chat` → `/app/chat`); the SPA's BrowserRouter
|
|
1104
|
+
* (basename `/app` or `/agent/app`) then renders the matching route.
|
|
1105
|
+
*/
|
|
1106
|
+
function redirect(location: string): Response {
|
|
1107
|
+
return new Response(null, { status: 302, headers: { Location: location } });
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
// ---------------------------------------------------------------------------
|
|
1111
|
+
// Auth gates
|
|
1112
|
+
//
|
|
1113
|
+
// Both layers share `requireScope` from `auth.ts` (validate a hub-issued JWT
|
|
1114
|
+
// against the hub's JWKS via scope-guard, assert a scope). It accepts the token
|
|
1115
|
+
// from an `Authorization: Bearer` header OR a `?token=` query param.
|
|
1116
|
+
//
|
|
1117
|
+
// Layer 1 — bridge / session↔channel. The session↔channel connection is
|
|
1118
|
+
// authenticated with hub-issued JWTs, exactly like a vault MCP client. A
|
|
1119
|
+
// launched session has full machine access, so we do NOT rely on loopback trust
|
|
1120
|
+
// — any session on any machine presents a hub token (`aud: "agent"`, scopes
|
|
1121
|
+
// `agent:read`/`agent:write`) as a Bearer header and the daemon validates
|
|
1122
|
+
// it against the hub's JWKS. Scope split: subscribing to inbound events is
|
|
1123
|
+
// `agent:read`; sending anything out (reply/react/edit/permission/download)
|
|
1124
|
+
// is `agent:write`.
|
|
1125
|
+
//
|
|
1126
|
+
// Layer 2 — human / chat UI — gates the http-ui transport's `send` (POST,
|
|
1127
|
+
// `agent:send`) + `/ui/events` SSE (`?token=` query, `agent:read`) inside
|
|
1128
|
+
// `http-ui.ts`'s ingestHttp using the same `requireScope`.
|
|
1129
|
+
//
|
|
1130
|
+
// Discovery + the page itself (/health, /.parachute/config[/schema], /ui) stay
|
|
1131
|
+
// OPEN — non-sensitive, and /ui must load to bootstrap its token fetch.
|
|
1132
|
+
// ---------------------------------------------------------------------------
|
|
1133
|
+
|
|
1134
|
+
/**
|
|
1135
|
+
* Decide whether a terminal WebSocket upgrade is authorized + which tmux session
|
|
1136
|
+
* it targets. Pure over its inputs (no `server.upgrade`, no pty) so the auth +
|
|
1137
|
+
* routing layer is unit-testable without a live hub or a real socket — the same
|
|
1138
|
+
* shape the HTTP gate tests rely on.
|
|
1139
|
+
*
|
|
1140
|
+
* Auth: OPERATOR-GATED on `agent:admin` (`SCOPE_TERMINAL`). The token rides in
|
|
1141
|
+
* as a `?token=` query param (browsers can't set Authorization on
|
|
1142
|
+
* `new WebSocket()`), so `allowQueryParam: true`. The no-token path
|
|
1143
|
+
* short-circuits to 401 before any JWKS fetch (testable offline).
|
|
1144
|
+
*
|
|
1145
|
+
* The path segment is an AGENT name — the tmux session is `<name>-agent`. An agent
|
|
1146
|
+
* has its OWN name (chosen at spawn), which is NOT necessarily a configured
|
|
1147
|
+
* channel (the 1:1 channel↔session assumption from the launch-session.sh era no
|
|
1148
|
+
* longer holds — an operator can name an agent anything). So we DON'T require the
|
|
1149
|
+
* name to be a known channel; we slug-guard it (it lands UNESCAPED in a tmux `-t`
|
|
1150
|
+
* target) and let the attach handle a non-existent session — `tmux attach` to a
|
|
1151
|
+
* missing session fails cleanly and the relay closes 1000 ("session ended"), no
|
|
1152
|
+
* reconnect loop. Operator-only behind agent:admin, so there's no enumeration
|
|
1153
|
+
* concern. (`channels` is no longer consulted; kept in the signature for the
|
|
1154
|
+
* stable call shape.)
|
|
1155
|
+
*
|
|
1156
|
+
* Returns either `{ ok: true, ... }` with the tmux session name (`<name>-agent`)
|
|
1157
|
+
* + the client's requested geometry, or `{ ok: false, response }` carrying the
|
|
1158
|
+
* deny Response the caller returns as-is.
|
|
1159
|
+
*/
|
|
1160
|
+
export async function authorizeTerminalUpgrade(
|
|
1161
|
+
req: Request,
|
|
1162
|
+
url: URL,
|
|
1163
|
+
_channels: Map<string, Channel>,
|
|
1164
|
+
agentName: string,
|
|
1165
|
+
): Promise<
|
|
1166
|
+
| { ok: true; channel: string; session: string; cols: number; rows: number }
|
|
1167
|
+
| { ok: false; response: Response }
|
|
1168
|
+
> {
|
|
1169
|
+
// Slug-guard: the name lands unescaped in a tmux `-t <session>` target and the
|
|
1170
|
+
// session string `<name>-agent`. Reject anything that isn't a strict slug.
|
|
1171
|
+
if (!AGENT_NAME_SLUG.test(agentName)) {
|
|
1172
|
+
return {
|
|
1173
|
+
ok: false,
|
|
1174
|
+
response: authJson(
|
|
1175
|
+
{ error: `invalid agent name "${agentName}" (alphanumeric, dash, underscore only)` },
|
|
1176
|
+
400,
|
|
1177
|
+
),
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
// Operator-grade gate. allowQueryParam: true — the only way a browser
|
|
1181
|
+
// WebSocket can present the token (no Authorization header on `new WebSocket`).
|
|
1182
|
+
const denied = await requireScope(req, url, SCOPE_TERMINAL, true);
|
|
1183
|
+
if (denied) return { ok: false, response: denied };
|
|
1184
|
+
|
|
1185
|
+
// tmux session name convention: `<name>-agent`. Attach a viewer pty to THIS
|
|
1186
|
+
// session; the session itself is created by the spawn path.
|
|
1187
|
+
const session = `${agentName}-agent`;
|
|
1188
|
+
const cols = clampQueryDim(url.searchParams.get("cols"), 80);
|
|
1189
|
+
const rows = clampQueryDim(url.searchParams.get("rows"), 24);
|
|
1190
|
+
return { ok: true, channel: agentName, session, cols, rows };
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
/** Is this request a WebSocket upgrade? (case-insensitive `Upgrade: websocket`). */
|
|
1194
|
+
export function isWebSocketUpgrade(req: Request): boolean {
|
|
1195
|
+
return (req.headers.get("upgrade") ?? "").toLowerCase() === "websocket";
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
/**
|
|
1199
|
+
* Coerce an untrusted JSON object into a `Record<string,string>` for a def note's
|
|
1200
|
+
* extra metadata bag — every value stringified (the vault stores metadata as strings).
|
|
1201
|
+
* Non-object input yields an empty map. Used by the agent-def write routes so a caller
|
|
1202
|
+
* passing `{ workspace: "/x", filesystem: "workspace" }` lands as string metadata.
|
|
1203
|
+
*/
|
|
1204
|
+
function coerceStringMap(v: unknown): Record<string, string> {
|
|
1205
|
+
const out: Record<string, string> = {};
|
|
1206
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) return out;
|
|
1207
|
+
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
|
|
1208
|
+
if (val === undefined || val === null) continue;
|
|
1209
|
+
out[k] = typeof val === "string" ? val : String(val);
|
|
1210
|
+
}
|
|
1211
|
+
return out;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/** Parse + clamp a `?cols=`/`?rows=` query dim to [1, 9999], with a fallback. */
|
|
1215
|
+
function clampQueryDim(raw: string | null, fallback: number): number {
|
|
1216
|
+
const n = raw === null ? NaN : parseInt(raw, 10);
|
|
1217
|
+
if (!Number.isFinite(n) || n < 1) return fallback;
|
|
1218
|
+
return n > 9999 ? 9999 : n;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/**
|
|
1222
|
+
* Build the daemon's HTTP fetch handler over a channel registry + client
|
|
1223
|
+
* registry. Extracted as a factory so tests can exercise routing + the auth
|
|
1224
|
+
* gate on an ephemeral `Bun.serve` without booting the real daemon (and without
|
|
1225
|
+
* a live hub — the no-token 401 path short-circuits before JWKS).
|
|
1226
|
+
*
|
|
1227
|
+
* `server` is the `Bun.serve` instance (passed as `fetch`'s 2nd arg at runtime),
|
|
1228
|
+
* needed for `server.upgrade()` on the terminal WS route. It's optional so the
|
|
1229
|
+
* existing tests (which call the handler with one arg) keep working — a terminal
|
|
1230
|
+
* upgrade request with no server falls through to the normal 426-style refusal.
|
|
1231
|
+
*/
|
|
1232
|
+
export function createFetchHandler(
|
|
1233
|
+
channels: Map<string, Channel>,
|
|
1234
|
+
registry: ClientRegistry,
|
|
1235
|
+
opts?: {
|
|
1236
|
+
deliveryState?: DeliveryState;
|
|
1237
|
+
programmatic?: ProgrammaticAgentRegistry;
|
|
1238
|
+
/**
|
|
1239
|
+
* The ATTACHED-backend queue registry (design 2026-06-18-channel-backend.md) — the
|
|
1240
|
+
* durable inbound-note queue + claim tracker a connected Claude Code session pulls
|
|
1241
|
+
* from via the channel MCP surface (`next-message` / `pending` / `reply` /
|
|
1242
|
+
* `release`). `main` passes the boot instance (the SAME one the transports'
|
|
1243
|
+
* `contextFor` routing fork checks); tests inject a fake-store-backed instance.
|
|
1244
|
+
* Optional — when absent, the channel MCP tools no-op (no attached agents).
|
|
1245
|
+
*/
|
|
1246
|
+
attachedQueue?: AttachedQueueRegistry;
|
|
1247
|
+
/**
|
|
1248
|
+
* The per-channel turn-event SSE registry (the streaming view, design build
|
|
1249
|
+
* item #1). The `/api/channels/<ch>/turn-events` SSE route registers subscribers
|
|
1250
|
+
* here; the programmatic registry's turn-event sink fans out to them. `main`
|
|
1251
|
+
* passes the boot instance (the SAME one the lazily-defaulted programmatic
|
|
1252
|
+
* registry pushes to); tests inject one to assert the live-progress fan-out.
|
|
1253
|
+
*/
|
|
1254
|
+
turnEvents?: ClientRegistry;
|
|
1255
|
+
/**
|
|
1256
|
+
* The vault-native scheduled-job store (runner, design 2026-06-17). The
|
|
1257
|
+
* `/api/jobs*` routes read/write through it. `main` passes the boot instance
|
|
1258
|
+
* (shared with the runner); tests inject one (or let it default lazily) to
|
|
1259
|
+
* exercise the routes against a fake-vault transport.
|
|
1260
|
+
*/
|
|
1261
|
+
jobStore?: VaultJobStore;
|
|
1262
|
+
/**
|
|
1263
|
+
* The runner — used by `POST /api/jobs/:id/run` (fire now). `main` passes the
|
|
1264
|
+
* boot instance; tests inject a fake. Optional: if absent, the run-now route
|
|
1265
|
+
* fires inline via the job store + the channel's `injectInbound` (so the route
|
|
1266
|
+
* still works in a plain createFetchHandler).
|
|
1267
|
+
*/
|
|
1268
|
+
runner?: Runner;
|
|
1269
|
+
/**
|
|
1270
|
+
* The vault-native agent-def registry (design 2026-06-17-vault-native-agents,
|
|
1271
|
+
* Phase 4a). The `POST /api/vault/agent-def` reload webhook drives it. `main`
|
|
1272
|
+
* passes the boot instance; tests inject one. Optional — when absent, the reload
|
|
1273
|
+
* route is a clean no-op ack (a daemon with no def-vaults configured).
|
|
1274
|
+
*/
|
|
1275
|
+
agentDefs?: AgentDefRegistry;
|
|
1276
|
+
/**
|
|
1277
|
+
* Add a def-vault to the live registry — the `POST /api/agent-vaults` body of work
|
|
1278
|
+
* (mint the vault's write token, persist `agent-vaults.json`, `addVault` + `loadAll`
|
|
1279
|
+
* for it). Injected so tests exercise the route WITHOUT a live hub mint or a real
|
|
1280
|
+
* vault; `main` leaves it unset and the route uses the real mint
|
|
1281
|
+
* (`mintScopedToken`) + the persisted-file path. Returns the resulting binding's
|
|
1282
|
+
* non-secret view (name + url + token-present).
|
|
1283
|
+
*/
|
|
1284
|
+
addDefVault?: (args: { vault: string; url?: string }) => Promise<{
|
|
1285
|
+
vault: string;
|
|
1286
|
+
url: string;
|
|
1287
|
+
tokenPresent: boolean;
|
|
1288
|
+
}>;
|
|
1289
|
+
},
|
|
1290
|
+
): (req: Request, server?: { upgrade: (req: Request, opts: { data: TerminalWsData }) => boolean }) => Promise<Response> {
|
|
1291
|
+
// The per-channel turn-event SSE registry — subscribers of the live "watch it
|
|
1292
|
+
// work" stream. Defaulted to a fresh instance so a plain createFetchHandler still
|
|
1293
|
+
// serves the route; `main` shares its boot instance so the lazily-defaulted
|
|
1294
|
+
// programmatic registry below pushes to the SAME subscribers the route registers.
|
|
1295
|
+
const turnEvents: ClientRegistry = opts?.turnEvents ?? new ClientRegistry();
|
|
1296
|
+
|
|
1297
|
+
// The programmatic-agent registry (design 2026-06-16) — inbound for a registered
|
|
1298
|
+
// channel routes to an on-demand `claude -p` turn instead of a push. `main`
|
|
1299
|
+
// constructs the real one (with the real backend + the outbound-write wiring);
|
|
1300
|
+
// tests inject a fake-backed instance. Defaulted lazily to the real registry so a
|
|
1301
|
+
// plain `createFetchHandler(channels, registry)` still wires programmatic agents —
|
|
1302
|
+
// and threads the turn-event sink so its turns stream to this handler's `turnEvents`.
|
|
1303
|
+
const programmatic: ProgrammaticAgentRegistry =
|
|
1304
|
+
opts?.programmatic ?? createDefaultProgrammaticRegistry(channels, buildTurnEventSink(turnEvents));
|
|
1305
|
+
|
|
1306
|
+
// The CHANNEL-backend queue registry (design 2026-06-18). `main` shares its boot
|
|
1307
|
+
// instance (the SAME one the transports' `contextFor` routing fork checks + the
|
|
1308
|
+
// channel MCP surface dispatches to). Defaulted to a fresh instance so a plain
|
|
1309
|
+
// createFetchHandler still serves the channel MCP tools (it just has no channel
|
|
1310
|
+
// agents registered until one is instantiated). Tests inject a fake-store-backed one.
|
|
1311
|
+
const attachedQueue: AttachedQueueRegistry = opts?.attachedQueue ?? new AttachedQueueRegistry();
|
|
1312
|
+
|
|
1313
|
+
// Per-channel delivery high-water-mark store (durable infra). `contextFor.emit`
|
|
1314
|
+
// advances it on a real delivery; the daemon's `main` passes the boot-time
|
|
1315
|
+
// instance, tests get a throwaway whose default mark is "now". (The deaf-on-restart
|
|
1316
|
+
// backlog replay that used to READ this mark was retired with the interactive
|
|
1317
|
+
// backend — design 2026-06-19-retire-interactive-backend.md.)
|
|
1318
|
+
const deliveryState: DeliveryState = opts?.deliveryState ?? new DeliveryState();
|
|
1319
|
+
|
|
1320
|
+
// The vault-native scheduled-job store (runner, design 2026-06-17). Defaulted to
|
|
1321
|
+
// a fresh store over the live channels so a plain createFetchHandler serves the
|
|
1322
|
+
// /api/jobs routes; `main` shares its boot instance with the runner so the routes
|
|
1323
|
+
// and the scheduler operate on the same vault.
|
|
1324
|
+
const jobStore: VaultJobStore = opts?.jobStore ?? new VaultJobStore(channels);
|
|
1325
|
+
|
|
1326
|
+
// The vault-native agent-def registry (Phase 4a). Optional — when absent the
|
|
1327
|
+
// reload webhook is a no-op ack (a daemon with no def-vaults). `main` passes the
|
|
1328
|
+
// boot instance so the route reloads the same set the boot instantiated.
|
|
1329
|
+
const agentDefs: AgentDefRegistry | undefined = opts?.agentDefs;
|
|
1330
|
+
|
|
1331
|
+
// Add-a-def-vault (the `POST /api/agent-vaults` body of work). Defaulted to the real
|
|
1332
|
+
// mint + persist path so a plain createFetchHandler serves the route; tests inject a
|
|
1333
|
+
// stub so the route is exercised WITHOUT a live hub mint or a real vault. Returns the
|
|
1334
|
+
// resulting binding's non-secret view (name + url + token-present).
|
|
1335
|
+
const addDefVault = opts?.addDefVault ?? defaultAddDefVault(agentDefs);
|
|
1336
|
+
|
|
1337
|
+
/** Resolve the transport for a channel name, or null on miss. */
|
|
1338
|
+
function transportFor(channel: string | undefined): Transport | null {
|
|
1339
|
+
if (!channel) return null;
|
|
1340
|
+
return channels.get(channel)?.transport ?? null;
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
function channelError(channel: string | undefined): Response {
|
|
1344
|
+
if (!channel) {
|
|
1345
|
+
return json({ error: "missing 'channel' field in request body" }, 400);
|
|
1346
|
+
}
|
|
1347
|
+
return json(
|
|
1348
|
+
{
|
|
1349
|
+
error: `unknown channel "${channel}" — known channels: ${[...channels.keys()].join(", ") || "(none)"}`,
|
|
1350
|
+
},
|
|
1351
|
+
400,
|
|
1352
|
+
);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
function methodMissing(channel: string, method: string): Response {
|
|
1356
|
+
const kind = channels.get(channel)?.transport.kind ?? "unknown";
|
|
1357
|
+
return json(
|
|
1358
|
+
{ error: `transport "${kind}" for channel "${channel}" does not support ${method}` },
|
|
1359
|
+
400,
|
|
1360
|
+
);
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// Idempotency for the vault inbound webhook: a small bounded set of recently-
|
|
1364
|
+
// seen note ids so a duplicate trigger delivery doesn't double-wake the
|
|
1365
|
+
// session. Bounded by eviction (oldest-out) so it can't grow unbounded.
|
|
1366
|
+
const seenInboundNoteIds = new Set<string>();
|
|
1367
|
+
const SEEN_INBOUND_CAP = 2048;
|
|
1368
|
+
function markSeen(noteId: string): boolean {
|
|
1369
|
+
if (seenInboundNoteIds.has(noteId)) return false; // already processed
|
|
1370
|
+
seenInboundNoteIds.add(noteId);
|
|
1371
|
+
if (seenInboundNoteIds.size > SEEN_INBOUND_CAP) {
|
|
1372
|
+
// Evict the oldest insertion (Set preserves insertion order).
|
|
1373
|
+
const oldest = seenInboundNoteIds.values().next().value;
|
|
1374
|
+
if (oldest !== undefined) seenInboundNoteIds.delete(oldest);
|
|
1375
|
+
}
|
|
1376
|
+
return true;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
return async function fetch(req, server) {
|
|
1380
|
+
const url = new URL(req.url);
|
|
1381
|
+
|
|
1382
|
+
// -------------------------------------------------------------------
|
|
1383
|
+
// Terminal WebSocket upgrade — `/terminal/<agent>` (design §5).
|
|
1384
|
+
//
|
|
1385
|
+
// The in-page xterm.js terminal attaches to the channel's tmux session
|
|
1386
|
+
// (`<channel>-agent`) via Bun's native pty. Externally this is
|
|
1387
|
+
// `<hub>/agent/terminal/<channel>`; the hub strips `/agent` (stripPrefix)
|
|
1388
|
+
// and forwards the `Upgrade: websocket` over its Bun-native WS bridge (which
|
|
1389
|
+
// honors agent's `websocket: true` declaration), so the daemon sees the
|
|
1390
|
+
// bare `/terminal/<channel>` upgrade here. OPERATOR-GATED on agent:admin
|
|
1391
|
+
// (the most dangerous capability), token via `?token=`. Must run BEFORE the
|
|
1392
|
+
// generic routing so the upgrade isn't 404'd.
|
|
1393
|
+
const termMatch = url.pathname.match(/^\/terminal\/([^/]+)$/);
|
|
1394
|
+
if (termMatch && isWebSocketUpgrade(req)) {
|
|
1395
|
+
const channelName = decodeURIComponent(termMatch[1]!);
|
|
1396
|
+
const decision = await authorizeTerminalUpgrade(req, url, channels, channelName);
|
|
1397
|
+
if (!decision.ok) return decision.response;
|
|
1398
|
+
if (!server?.upgrade) {
|
|
1399
|
+
// No server handle (e.g. a unit test calling the handler directly, or a
|
|
1400
|
+
// build where Bun.serve didn't pass it) — the upgrade can't happen here.
|
|
1401
|
+
return authJson(
|
|
1402
|
+
{ error: "websocket upgrade unavailable on this server" },
|
|
1403
|
+
503,
|
|
1404
|
+
);
|
|
1405
|
+
}
|
|
1406
|
+
const data: TerminalWsData = {
|
|
1407
|
+
session: decision.session,
|
|
1408
|
+
channel: decision.channel,
|
|
1409
|
+
cols: decision.cols,
|
|
1410
|
+
rows: decision.rows,
|
|
1411
|
+
};
|
|
1412
|
+
const upgraded = server.upgrade(req, { data });
|
|
1413
|
+
if (upgraded) {
|
|
1414
|
+
// Bun's contract: return undefined from fetch after a successful upgrade
|
|
1415
|
+
// — the socket now belongs to the websocket handlers.
|
|
1416
|
+
return undefined as unknown as Response;
|
|
1417
|
+
}
|
|
1418
|
+
return authJson({ error: "websocket upgrade failed" }, 400);
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
// Terminal renderer assets (xterm.js + addon-fit + css) served SAME-ORIGIN
|
|
1422
|
+
// (design §5; replaces the CDN load that broke behind strict networks/CSP).
|
|
1423
|
+
// Public like the page itself — these are vendored static JS/CSS, no secrets.
|
|
1424
|
+
// Must run BEFORE the `/terminal/<channel>` page match (this is a 2-segment
|
|
1425
|
+
// path the single-segment termMatch wouldn't catch, but keep it explicit).
|
|
1426
|
+
const assetMatch = url.pathname.match(/^\/terminal\/assets\/([^/]+)$/);
|
|
1427
|
+
if (req.method === "GET" && assetMatch) {
|
|
1428
|
+
const served = serveTerminalAsset(decodeURIComponent(assetMatch[1]!));
|
|
1429
|
+
return served ?? json({ error: "not found" }, 404);
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
// Terminal view (the xterm.js page) — `/terminal` or `/terminal/<channel>`
|
|
1433
|
+
// as a plain GET (no upgrade) serves the page; the page then opens the WS to
|
|
1434
|
+
// `/terminal/<channel>`. Loads OPEN (like /ui and /admin) so it can bootstrap
|
|
1435
|
+
// its hub-minted agent:admin token fetch; the WS upgrade above is what's
|
|
1436
|
+
// gated. Served by the daemon (spans every channel via a picker).
|
|
1437
|
+
if (req.method === "GET" && (url.pathname === "/terminal" || termMatch)) {
|
|
1438
|
+
return new Response(TERMINAL_UI_HTML, {
|
|
1439
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
1440
|
+
});
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
// Retired server-rendered pages (Phase 4c) — the v2 SPA now covers Home /
|
|
1444
|
+
// Agents / Config (the Agents view) and Schedules (the agent detail). Each
|
|
1445
|
+
// page route 302s to the SPA app root so operator bookmarks keep working.
|
|
1446
|
+
// The relative `app/` Location resolves daemon-direct AND hub-proxied (see
|
|
1447
|
+
// `redirect`). The SPA itself is served by `serveSpa` at `/app` below; ALL
|
|
1448
|
+
// the data-plane routes (`/api/*`, `/ui/events`, …) are untouched.
|
|
1449
|
+
if (
|
|
1450
|
+
req.method === "GET" &&
|
|
1451
|
+
(url.pathname === "/agents" || url.pathname === "/jobs" || url.pathname === "/home")
|
|
1452
|
+
) {
|
|
1453
|
+
return redirect("app/");
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
// Bare root — historically a 404 (no page lived here). Send it to the SPA
|
|
1457
|
+
// app root too, so a bookmark on the module root lands somewhere useful.
|
|
1458
|
+
// Relative `app/` → `/app/` direct, `/agent/app/` proxied.
|
|
1459
|
+
if (req.method === "GET" && url.pathname === "/") {
|
|
1460
|
+
return redirect("app/");
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
// Agent UI v2 SPA (the agent-centric React surface) — served at the NEW
|
|
1464
|
+
// `/app` mount, reachable at `<hub>/agent/app/` over the hub proxy. Coexists
|
|
1465
|
+
// with the daemon-rendered HTML pages above (the design's incremental
|
|
1466
|
+
// migration; the HTML retires in a later phase). Serves `index.html` for the
|
|
1467
|
+
// SPA route(s) + `dist/assets/*` for assets; a missing `dist/` → 503 with a
|
|
1468
|
+
// "run build" hint (dev-checkout case). Loads OPEN (like /ui, /admin, /agents)
|
|
1469
|
+
// so it can bootstrap its hub-minted `agent:admin` token; the `/api/*` calls
|
|
1470
|
+
// it makes are what `requireScope` gates. Bundle path is anchored to the
|
|
1471
|
+
// install dir so a `bun src/daemon.ts` from any cwd finds web/ui/dist/.
|
|
1472
|
+
if (req.method === "GET" && isSpaPath(url.pathname)) {
|
|
1473
|
+
return serveSpa(spaDistDir(INSTALL_DIR), url.pathname);
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
// Health check — per-channel client counts. Programmatic agents (design
|
|
1477
|
+
// 2026-06-16 step 6) are listed separately with their backend + live status
|
|
1478
|
+
// (`programmatic · idle|working|queued:N`) instead of `mcp_sessions` — a
|
|
1479
|
+
// programmatic agent has no live subscriber, so SSE/MCP counts don't describe it.
|
|
1480
|
+
if (url.pathname === "/health") {
|
|
1481
|
+
return json({
|
|
1482
|
+
status: "ok",
|
|
1483
|
+
channels: [...channels.values()].map((c) => ({
|
|
1484
|
+
name: c.name,
|
|
1485
|
+
kind: c.transport.kind,
|
|
1486
|
+
clients: registry.countForChannel(c.name),
|
|
1487
|
+
mcp_sessions: mcpSessionCount(c.name),
|
|
1488
|
+
})),
|
|
1489
|
+
total_clients: registry.size,
|
|
1490
|
+
programmatic_agents: programmatic.list().map((h) => {
|
|
1491
|
+
const s = programmatic.statusOf(h.channel);
|
|
1492
|
+
return {
|
|
1493
|
+
name: h.name,
|
|
1494
|
+
channel: h.channel,
|
|
1495
|
+
backend: "programmatic",
|
|
1496
|
+
status: s.state === "queued" ? `queued:${s.queued}` : s.state,
|
|
1497
|
+
};
|
|
1498
|
+
}),
|
|
1499
|
+
});
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
// Self-describing config (runner pattern) — read-only, no secrets.
|
|
1503
|
+
//
|
|
1504
|
+
// `triggerTemplate` is MODULE-OWNED DATA: the prescribed vault trigger this
|
|
1505
|
+
// channel needs the hub to register on its behalf (PR 3). The hub GETs this,
|
|
1506
|
+
// substitutes the channel name into the `<channel>` placeholders, fills the
|
|
1507
|
+
// `<hub-origin>` in `action.webhook`, and injects `action.auth.bearer` (a
|
|
1508
|
+
// minted agent:send JWT) — so the channel owns its own trigger shape rather
|
|
1509
|
+
// than the hub hardcoding it.
|
|
1510
|
+
if (req.method === "GET" && url.pathname === "/.parachute/config") {
|
|
1511
|
+
return json({
|
|
1512
|
+
channels: [...channels.values()].map((c) => ({
|
|
1513
|
+
name: c.name,
|
|
1514
|
+
transport: c.transport.kind,
|
|
1515
|
+
})),
|
|
1516
|
+
triggerTemplate: AGENT_VAULT_TRIGGER_TEMPLATE,
|
|
1517
|
+
});
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
if (req.method === "GET" && url.pathname === "/.parachute/config/schema") {
|
|
1521
|
+
return json({
|
|
1522
|
+
title: "parachute-agent config",
|
|
1523
|
+
description: "Named channels, each bound to a transport.",
|
|
1524
|
+
type: "object",
|
|
1525
|
+
properties: {
|
|
1526
|
+
channels: {
|
|
1527
|
+
type: "array",
|
|
1528
|
+
items: {
|
|
1529
|
+
type: "object",
|
|
1530
|
+
properties: {
|
|
1531
|
+
name: { type: "string", description: "Unique channel name bridges subscribe to." },
|
|
1532
|
+
transport: {
|
|
1533
|
+
type: "string",
|
|
1534
|
+
enum: ["telegram", "http-ui", "vault"],
|
|
1535
|
+
description: "Transport kind backing this channel.",
|
|
1536
|
+
},
|
|
1537
|
+
config: {
|
|
1538
|
+
type: "object",
|
|
1539
|
+
description: "Transport-specific config (secrets live here, not returned by /config).",
|
|
1540
|
+
},
|
|
1541
|
+
},
|
|
1542
|
+
required: ["name", "transport"],
|
|
1543
|
+
},
|
|
1544
|
+
},
|
|
1545
|
+
},
|
|
1546
|
+
required: ["channels"],
|
|
1547
|
+
});
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
// ---------------------------------------------------------------------
|
|
1551
|
+
// Channel config-management API — the hub writes channels.json + hot-adds
|
|
1552
|
+
// the channel to the LIVE daemon, so a frictionless setup never hand-edits a
|
|
1553
|
+
// file or restarts the daemon. Gated on a hub JWT with `agent:admin`.
|
|
1554
|
+
//
|
|
1555
|
+
// POST /api/channels { name, transport, config } → write + hot-add
|
|
1556
|
+
// GET /api/channels → list (name + transport + vault; NO secrets)
|
|
1557
|
+
// DELETE /api/channels/:name → stop + unregister + remove from channels.json
|
|
1558
|
+
//
|
|
1559
|
+
// Externally hub strips `/agent`, so these are `<hub>/agent/api/channels`.
|
|
1560
|
+
// ---------------------------------------------------------------------
|
|
1561
|
+
if (url.pathname === "/api/channels" && (req.method === "GET" || req.method === "POST")) {
|
|
1562
|
+
const denied = await requireScope(req, url, SCOPE_ADMIN);
|
|
1563
|
+
if (denied) return denied;
|
|
1564
|
+
|
|
1565
|
+
if (req.method === "GET") {
|
|
1566
|
+
// List configured channels — surface ONLY name + transport + vault (for a
|
|
1567
|
+
// vault transport). NEVER the token/secret: this is an admin read, but the
|
|
1568
|
+
// file holds credentials we don't echo back.
|
|
1569
|
+
return json({
|
|
1570
|
+
channels: [...channels.values()].map((c) => {
|
|
1571
|
+
const out: { name: string; transport: string; vault?: string } = {
|
|
1572
|
+
name: c.name,
|
|
1573
|
+
transport: c.transport.kind,
|
|
1574
|
+
};
|
|
1575
|
+
const v = (c.entry.config as { vault?: unknown } | undefined)?.vault;
|
|
1576
|
+
if (typeof v === "string") out.vault = v;
|
|
1577
|
+
return out;
|
|
1578
|
+
}),
|
|
1579
|
+
});
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
// POST — create/replace a channel.
|
|
1583
|
+
let cfgBody: { name?: unknown; transport?: unknown; config?: unknown };
|
|
1584
|
+
try {
|
|
1585
|
+
cfgBody = (await req.json()) as typeof cfgBody;
|
|
1586
|
+
} catch {
|
|
1587
|
+
return json({ error: "invalid JSON body" }, 400);
|
|
1588
|
+
}
|
|
1589
|
+
if (typeof cfgBody.name !== "string" || cfgBody.name.length === 0) {
|
|
1590
|
+
return json({ error: "body.name (string) is required" }, 400);
|
|
1591
|
+
}
|
|
1592
|
+
if (typeof cfgBody.transport !== "string" || cfgBody.transport.length === 0) {
|
|
1593
|
+
return json({ error: "body.transport (string) is required" }, 400);
|
|
1594
|
+
}
|
|
1595
|
+
const entry: ChannelEntry = {
|
|
1596
|
+
name: cfgBody.name,
|
|
1597
|
+
transport: cfgBody.transport,
|
|
1598
|
+
config:
|
|
1599
|
+
cfgBody.config && typeof cfgBody.config === "object"
|
|
1600
|
+
? (cfgBody.config as Record<string, unknown>)
|
|
1601
|
+
: undefined,
|
|
1602
|
+
};
|
|
1603
|
+
// Validate the entry by instantiating it FIRST (constructor throws on a
|
|
1604
|
+
// missing required field — e.g. a vault channel with no token). We do this
|
|
1605
|
+
// before writing channels.json so a bad request never persists a broken
|
|
1606
|
+
// entry. `addChannelLive` re-instantiates; the throwaway here is the gate.
|
|
1607
|
+
try {
|
|
1608
|
+
instantiateTransport(entry);
|
|
1609
|
+
} catch (err) {
|
|
1610
|
+
return json({ error: `invalid channel config: ${(err as Error).message}` }, 400);
|
|
1611
|
+
}
|
|
1612
|
+
// Persist FIRST (chmod 600 — holds a token), then hot-add to the live
|
|
1613
|
+
// daemon. If the hot-add throws, the file is already written, so a daemon
|
|
1614
|
+
// restart would still pick it up; we surface the error AND a restart hint.
|
|
1615
|
+
try {
|
|
1616
|
+
// Resolve the state dir at request time (defaultStateDir reads the env)
|
|
1617
|
+
// so the persisted file always lands where the daemon would next read it,
|
|
1618
|
+
// even if the env was set after module load (and so it's testable).
|
|
1619
|
+
upsertChannelEntry(entry, defaultStateDir());
|
|
1620
|
+
} catch (err) {
|
|
1621
|
+
return json({ error: `failed to write channels.json: ${(err as Error).message}` }, 500);
|
|
1622
|
+
}
|
|
1623
|
+
try {
|
|
1624
|
+
await addChannelLive(channels, registry, entry, deliveryState, programmatic, attachedQueue);
|
|
1625
|
+
} catch (err) {
|
|
1626
|
+
return json(
|
|
1627
|
+
{
|
|
1628
|
+
ok: true,
|
|
1629
|
+
name: entry.name,
|
|
1630
|
+
transport: entry.transport,
|
|
1631
|
+
live: false,
|
|
1632
|
+
restart_needed: true,
|
|
1633
|
+
error: `channel persisted but hot-add failed: ${(err as Error).message}`,
|
|
1634
|
+
},
|
|
1635
|
+
200,
|
|
1636
|
+
);
|
|
1637
|
+
}
|
|
1638
|
+
return json({ ok: true, name: entry.name, transport: entry.transport, live: true });
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
const delMatch = url.pathname.match(/^\/api\/channels\/([^/]+)$/);
|
|
1642
|
+
if (delMatch && req.method === "DELETE") {
|
|
1643
|
+
const denied = await requireScope(req, url, SCOPE_ADMIN);
|
|
1644
|
+
if (denied) return denied;
|
|
1645
|
+
const name = decodeURIComponent(delMatch[1]!);
|
|
1646
|
+
const wasLive = await removeChannelLive(channels, name);
|
|
1647
|
+
// Always rewrite channels.json (idempotent) so the file matches the live
|
|
1648
|
+
// state even if the channel was only on disk (added before a restart).
|
|
1649
|
+
try {
|
|
1650
|
+
removeChannelEntry(name, defaultStateDir());
|
|
1651
|
+
} catch (err) {
|
|
1652
|
+
return json({ error: `failed to update channels.json: ${(err as Error).message}` }, 500);
|
|
1653
|
+
}
|
|
1654
|
+
if (!wasLive) {
|
|
1655
|
+
// Not in the live map. Either never live, or removed from disk only.
|
|
1656
|
+
return json({ ok: true, name, removed: false }, 200);
|
|
1657
|
+
}
|
|
1658
|
+
return json({ ok: true, name, removed: true });
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
// ---------------------------------------------------------------------
|
|
1662
|
+
// Scheduled-jobs API — the runner (design 2026-06-17). A job is "an
|
|
1663
|
+
// automated human": send message M to a vault agent A on cron S. Storage is
|
|
1664
|
+
// VAULT-NATIVE (`#agent/job` notes in the target channel's vault); these
|
|
1665
|
+
// routes read/write through the shared `jobStore`. ALL gated on
|
|
1666
|
+
// `agent:admin` (operator-only, like /api/channels). The runner does the
|
|
1667
|
+
// injecting; these routes just CRUD the durable job notes (+ fire-now).
|
|
1668
|
+
//
|
|
1669
|
+
// GET /api/jobs → list (across the live vault channels)
|
|
1670
|
+
// POST /api/jobs { id, channel, message, schedule, enabled? } → create
|
|
1671
|
+
// DELETE /api/jobs/:id → delete the job note
|
|
1672
|
+
// POST /api/jobs/:id/run → fire now (inject the inbound message immediately)
|
|
1673
|
+
//
|
|
1674
|
+
// Externally hub strips `/agent`, so these are `<hub>/agent/api/jobs`.
|
|
1675
|
+
// ---------------------------------------------------------------------
|
|
1676
|
+
if (url.pathname === "/api/jobs" && (req.method === "GET" || req.method === "POST")) {
|
|
1677
|
+
const denied = await requireScope(req, url, SCOPE_ADMIN);
|
|
1678
|
+
if (denied) return denied;
|
|
1679
|
+
|
|
1680
|
+
if (req.method === "GET") {
|
|
1681
|
+
// List across every live vault channel. A vault read failure surfaces as a
|
|
1682
|
+
// 502 (not a silently-empty list that looks like "no jobs").
|
|
1683
|
+
try {
|
|
1684
|
+
const jobs = await jobStore.listAll();
|
|
1685
|
+
// `nextRunAt` is computed-in-memory (the stored note never carries it —
|
|
1686
|
+
// see the Job docblock), so the persisted list lacks it and the UI's
|
|
1687
|
+
// "Next run" column would always be "—". Derive it here for ENABLED jobs
|
|
1688
|
+
// (a disabled job isn't scheduled → no next run). Per-job guard: a bad tz
|
|
1689
|
+
// (a RangeError out of nextRunAfter) must not 502 the whole list.
|
|
1690
|
+
const now = new Date();
|
|
1691
|
+
const withNext = jobs.map((j) => {
|
|
1692
|
+
if (!j.enabled) return j;
|
|
1693
|
+
try {
|
|
1694
|
+
const next = nextRunAfter(j.schedule.cron, j.schedule.tz, now);
|
|
1695
|
+
return next ? { ...j, nextRunAt: next.toISOString() } : j;
|
|
1696
|
+
} catch {
|
|
1697
|
+
return j;
|
|
1698
|
+
}
|
|
1699
|
+
});
|
|
1700
|
+
return json({ jobs: withNext });
|
|
1701
|
+
} catch (err) {
|
|
1702
|
+
return json({ error: `failed to list jobs: ${(err as Error).message}` }, 502);
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
// POST — create/replace a job.
|
|
1707
|
+
let body: { id?: unknown; channel?: unknown; message?: unknown; schedule?: unknown; enabled?: unknown };
|
|
1708
|
+
try {
|
|
1709
|
+
body = (await req.json()) as typeof body;
|
|
1710
|
+
} catch {
|
|
1711
|
+
return json({ error: "invalid JSON body" }, 400);
|
|
1712
|
+
}
|
|
1713
|
+
// Validate against the LIVE channels: known + vault-backed + parseable cron.
|
|
1714
|
+
const validation = validateJob(body, (name) => {
|
|
1715
|
+
if (!channels.has(name)) return null;
|
|
1716
|
+
return channels.get(name)!.transport instanceof VaultTransport;
|
|
1717
|
+
});
|
|
1718
|
+
if (!validation.ok) return json({ error: validation.error }, 400);
|
|
1719
|
+
|
|
1720
|
+
const job: Job = {
|
|
1721
|
+
id: body.id as string,
|
|
1722
|
+
channel: body.channel as string,
|
|
1723
|
+
message: (body.message as string).trim(),
|
|
1724
|
+
schedule: body.schedule as Job["schedule"],
|
|
1725
|
+
enabled: body.enabled === undefined ? true : Boolean(body.enabled),
|
|
1726
|
+
createdAt: new Date().toISOString(),
|
|
1727
|
+
};
|
|
1728
|
+
try {
|
|
1729
|
+
const saved = await jobStore.upsert(job);
|
|
1730
|
+
return json({ ok: true, job: saved });
|
|
1731
|
+
} catch (err) {
|
|
1732
|
+
return json({ error: `failed to write job: ${(err as Error).message}` }, 502);
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
// POST /api/jobs/:id/run — fire now (inject the message immediately).
|
|
1737
|
+
{
|
|
1738
|
+
const runMatch = url.pathname.match(/^\/api\/jobs\/([^/]+)\/run$/);
|
|
1739
|
+
if (runMatch && req.method === "POST") {
|
|
1740
|
+
const denied = await requireScope(req, url, SCOPE_ADMIN);
|
|
1741
|
+
if (denied) return denied;
|
|
1742
|
+
const id = decodeURIComponent(runMatch[1]!);
|
|
1743
|
+
try {
|
|
1744
|
+
// Prefer the shared runner (records bookkeeping consistently with a
|
|
1745
|
+
// scheduled fire). Fall back to an inline fire via the job store + the
|
|
1746
|
+
// channel's injectInbound when no runner is wired (plain handler/tests).
|
|
1747
|
+
if (opts?.runner) {
|
|
1748
|
+
const status = await opts.runner.runNow(id);
|
|
1749
|
+
return json({ ok: true, id, status });
|
|
1750
|
+
}
|
|
1751
|
+
const jobs = await jobStore.listAll();
|
|
1752
|
+
const job = jobs.find((j) => j.id === id);
|
|
1753
|
+
if (!job) return json({ error: `unknown job "${id}"` }, 404);
|
|
1754
|
+
const transport = vaultTransportFor(channels, job.channel);
|
|
1755
|
+
if (!transport) {
|
|
1756
|
+
return json({ error: `job "${id}" targets a non-vault channel "${job.channel}"` }, 400);
|
|
1757
|
+
}
|
|
1758
|
+
await transport.injectInbound({ content: job.message, sender: `runner:${job.id}` });
|
|
1759
|
+
return json({ ok: true, id, status: "ok" });
|
|
1760
|
+
} catch (err) {
|
|
1761
|
+
return json({ error: `failed to run job: ${(err as Error).message}` }, 502);
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
// DELETE /api/jobs/:id — remove the job note. We must resolve which channel's
|
|
1767
|
+
// vault holds it; list once to find the job's channel, then delete there.
|
|
1768
|
+
{
|
|
1769
|
+
const jobDelMatch = url.pathname.match(/^\/api\/jobs\/([^/]+)$/);
|
|
1770
|
+
if (jobDelMatch && req.method === "DELETE") {
|
|
1771
|
+
const denied = await requireScope(req, url, SCOPE_ADMIN);
|
|
1772
|
+
if (denied) return denied;
|
|
1773
|
+
const id = decodeURIComponent(jobDelMatch[1]!);
|
|
1774
|
+
try {
|
|
1775
|
+
const jobs = await jobStore.listAll();
|
|
1776
|
+
const job = jobs.find((j) => j.id === id);
|
|
1777
|
+
if (!job || !job.noteId) return json({ ok: true, id, removed: false }, 200);
|
|
1778
|
+
await jobStore.remove(job.noteId, job.channel);
|
|
1779
|
+
return json({ ok: true, id, removed: true });
|
|
1780
|
+
} catch (err) {
|
|
1781
|
+
return json({ error: `failed to delete job: ${(err as Error).message}` }, 502);
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
// ---------------------------------------------------------------------
|
|
1787
|
+
// Claude OAuth credential store (design §6) — the per-channel secret a
|
|
1788
|
+
// launched agent session runs on (`CLAUDE_CODE_OAUTH_TOKEN`). Same
|
|
1789
|
+
// `agent:admin` gate + 0600 file-store + redaction-on-read posture as the
|
|
1790
|
+
// channel config API above. The token comes from `claude setup-token`.
|
|
1791
|
+
//
|
|
1792
|
+
// GET /api/credentials/claude → { defaultSet, channels:[names] } (NO secret)
|
|
1793
|
+
// POST /api/credentials/claude { token } → set the default/operator token
|
|
1794
|
+
// POST /api/credentials/claude/:channel { token } → set a per-channel override
|
|
1795
|
+
// DELETE /api/credentials/claude/:channel → remove an override (falls back to default)
|
|
1796
|
+
//
|
|
1797
|
+
// Externally hub strips `/agent`, so these are `<hub>/agent/api/credentials/claude`.
|
|
1798
|
+
// ---------------------------------------------------------------------
|
|
1799
|
+
if (url.pathname === "/api/credentials/claude" && (req.method === "GET" || req.method === "POST")) {
|
|
1800
|
+
const denied = await requireScope(req, url, SCOPE_ADMIN);
|
|
1801
|
+
if (denied) return denied;
|
|
1802
|
+
|
|
1803
|
+
if (req.method === "GET") {
|
|
1804
|
+
// Inspect WITHOUT leaking the secret: whether a default is set + which
|
|
1805
|
+
// channels carry an override (names only).
|
|
1806
|
+
return json(describeClaudeCredentials(defaultStateDir()));
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
// POST — set the default / operator-level token.
|
|
1810
|
+
let credBody: { token?: unknown };
|
|
1811
|
+
try {
|
|
1812
|
+
credBody = (await req.json()) as typeof credBody;
|
|
1813
|
+
} catch {
|
|
1814
|
+
return json({ error: "invalid JSON body" }, 400);
|
|
1815
|
+
}
|
|
1816
|
+
if (typeof credBody.token !== "string" || credBody.token.length === 0) {
|
|
1817
|
+
return json({ error: "body.token (non-empty string) is required" }, 400);
|
|
1818
|
+
}
|
|
1819
|
+
try {
|
|
1820
|
+
setDefaultClaudeCredential(credBody.token, defaultStateDir());
|
|
1821
|
+
} catch (err) {
|
|
1822
|
+
return json({ error: `failed to write credentials.json: ${(err as Error).message}` }, 500);
|
|
1823
|
+
}
|
|
1824
|
+
// Echo back only the fact of the write — never the token.
|
|
1825
|
+
return json({ ok: true, scope: "default" });
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
const credMatch = url.pathname.match(/^\/api\/credentials\/claude\/([^/]+)$/);
|
|
1829
|
+
if (credMatch && (req.method === "POST" || req.method === "DELETE")) {
|
|
1830
|
+
const denied = await requireScope(req, url, SCOPE_ADMIN);
|
|
1831
|
+
if (denied) return denied;
|
|
1832
|
+
const channel = decodeURIComponent(credMatch[1]!);
|
|
1833
|
+
|
|
1834
|
+
if (req.method === "DELETE") {
|
|
1835
|
+
let removed: boolean;
|
|
1836
|
+
try {
|
|
1837
|
+
removed = removeChannelClaudeCredential(channel, defaultStateDir());
|
|
1838
|
+
} catch (err) {
|
|
1839
|
+
return json({ error: `failed to update credentials.json: ${(err as Error).message}` }, 500);
|
|
1840
|
+
}
|
|
1841
|
+
return json({ ok: true, channel, removed });
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
// POST — set a per-channel override.
|
|
1845
|
+
let credBody: { token?: unknown };
|
|
1846
|
+
try {
|
|
1847
|
+
credBody = (await req.json()) as typeof credBody;
|
|
1848
|
+
} catch {
|
|
1849
|
+
return json({ error: "invalid JSON body" }, 400);
|
|
1850
|
+
}
|
|
1851
|
+
if (typeof credBody.token !== "string" || credBody.token.length === 0) {
|
|
1852
|
+
return json({ error: "body.token (non-empty string) is required" }, 400);
|
|
1853
|
+
}
|
|
1854
|
+
try {
|
|
1855
|
+
setChannelClaudeCredential(channel, credBody.token, defaultStateDir());
|
|
1856
|
+
} catch (err) {
|
|
1857
|
+
return json({ error: `failed to write credentials.json: ${(err as Error).message}` }, 500);
|
|
1858
|
+
}
|
|
1859
|
+
return json({ ok: true, scope: "channel", channel });
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
// ---------------------------------------------------------------------
|
|
1863
|
+
// Generic per-channel ENV-VAR store (GH_TOKEN / CLOUDFLARE_API_TOKEN / …) —
|
|
1864
|
+
// the secrets a launched agent's `gh`/`git`/build tooling needs. Same
|
|
1865
|
+
// `agent:admin` gate + 0600 file-store + redaction-on-read posture as the
|
|
1866
|
+
// Claude credential API above. A blank/omitted `channel` targets the
|
|
1867
|
+
// operator-level DEFAULT layer; a channel name targets that channel's override.
|
|
1868
|
+
// Denylisted names (the Claude-auth trio) are REJECTED with a 400 — they'd break
|
|
1869
|
+
// the managed subscription-billing guarantee.
|
|
1870
|
+
//
|
|
1871
|
+
// GET /api/credentials/env → { default:[names], channels:{ch:[names]} } (NO values)
|
|
1872
|
+
// POST /api/credentials/env { channel?, name, value } → set
|
|
1873
|
+
// DELETE /api/credentials/env { channel?, name } (or ?channel=&name=) → remove
|
|
1874
|
+
//
|
|
1875
|
+
// Externally hub strips `/agent`, so these are `<hub>/agent/api/credentials/env`.
|
|
1876
|
+
// ---------------------------------------------------------------------
|
|
1877
|
+
if (
|
|
1878
|
+
url.pathname === "/api/credentials/env" &&
|
|
1879
|
+
(req.method === "GET" || req.method === "POST" || req.method === "DELETE")
|
|
1880
|
+
) {
|
|
1881
|
+
const denied = await requireScope(req, url, SCOPE_ADMIN);
|
|
1882
|
+
if (denied) return denied;
|
|
1883
|
+
|
|
1884
|
+
if (req.method === "GET") {
|
|
1885
|
+
// Inspect WITHOUT leaking values: names per channel + the default layer.
|
|
1886
|
+
return json(describeChannelEnv(defaultStateDir()));
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
let envBody: { channel?: unknown; name?: unknown; value?: unknown };
|
|
1890
|
+
try {
|
|
1891
|
+
envBody = (await req.json()) as typeof envBody;
|
|
1892
|
+
} catch {
|
|
1893
|
+
return json({ error: "invalid JSON body" }, 400);
|
|
1894
|
+
}
|
|
1895
|
+
// `channel` is optional — blank/absent/empty means the operator-level default.
|
|
1896
|
+
const channelRaw = typeof envBody.channel === "string" ? envBody.channel : "";
|
|
1897
|
+
const channel = channelRaw.length > 0 ? channelRaw : null;
|
|
1898
|
+
if (typeof envBody.name !== "string" || envBody.name.length === 0) {
|
|
1899
|
+
return json({ error: "body.name (non-empty string) is required" }, 400);
|
|
1900
|
+
}
|
|
1901
|
+
const name = envBody.name;
|
|
1902
|
+
|
|
1903
|
+
if (req.method === "DELETE") {
|
|
1904
|
+
let removed: boolean;
|
|
1905
|
+
try {
|
|
1906
|
+
removed = removeChannelEnvVar(channel, name, defaultStateDir());
|
|
1907
|
+
} catch (err) {
|
|
1908
|
+
return json({ error: `failed to update credentials.json: ${(err as Error).message}` }, 500);
|
|
1909
|
+
}
|
|
1910
|
+
return json({ ok: true, scope: channel ? "channel" : "default", ...(channel ? { channel } : {}), name, removed });
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
// POST — set the var.
|
|
1914
|
+
if (typeof envBody.value !== "string" || envBody.value.length === 0) {
|
|
1915
|
+
return json({ error: "body.value (non-empty string) is required" }, 400);
|
|
1916
|
+
}
|
|
1917
|
+
try {
|
|
1918
|
+
setChannelEnvVar(channel, name, envBody.value, defaultStateDir());
|
|
1919
|
+
} catch (err) {
|
|
1920
|
+
// A denylisted name (ANTHROPIC_API_KEY/CLAUDE_API_KEY/CLAUDE_CODE_OAUTH_TOKEN)
|
|
1921
|
+
// or a malformed name is the operator's mistake → 400 with the clear reason.
|
|
1922
|
+
if (err instanceof DenylistedEnvError) return json({ error: err.message }, 400);
|
|
1923
|
+
if ((err as Error).message?.startsWith("credentials:")) {
|
|
1924
|
+
return json({ error: (err as Error).message }, 400);
|
|
1925
|
+
}
|
|
1926
|
+
return json({ error: `failed to write credentials.json: ${(err as Error).message}` }, 500);
|
|
1927
|
+
}
|
|
1928
|
+
// Echo back only the fact of the write — never the value.
|
|
1929
|
+
return json({ ok: true, scope: channel ? "channel" : "default", ...(channel ? { channel } : {}), name });
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
// ---------------------------------------------------------------------
|
|
1933
|
+
// Agent management API (the web spawn/list/kill surface, design §4/§5).
|
|
1934
|
+
// Operator-gated on `agent:admin`. The interactive (tmux) backend was retired
|
|
1935
|
+
// 2026-06-19 (design 2026-06-19-retire-interactive-backend.md): there is no
|
|
1936
|
+
// tmux session to list/spawn/kill anymore. The two live backends are
|
|
1937
|
+
// PROGRAMMATIC (daemon-run `claude -p` turns) + CHANNEL (a Claude Code session
|
|
1938
|
+
// the operator connects handles the turn; vault-native — defined as an
|
|
1939
|
+
// #agent/definition note, not via this POST).
|
|
1940
|
+
//
|
|
1941
|
+
// GET /api/agents → list registered programmatic + channel agents
|
|
1942
|
+
// POST /api/agents { name, channels, vault?, ... } → register a programmatic agent
|
|
1943
|
+
// DELETE /api/agents/:name → deregister the agent
|
|
1944
|
+
//
|
|
1945
|
+
// Externally hub strips `/agent`, so these are `<hub>/agent/api/agents`.
|
|
1946
|
+
// ---------------------------------------------------------------------
|
|
1947
|
+
if (url.pathname === "/api/agents" && (req.method === "GET" || req.method === "POST")) {
|
|
1948
|
+
const denied = await requireScope(req, url, SCOPE_ADMIN);
|
|
1949
|
+
if (denied) return denied;
|
|
1950
|
+
|
|
1951
|
+
if (req.method === "GET") {
|
|
1952
|
+
try {
|
|
1953
|
+
// The list merges registered PROGRAMMATIC agents (design 2026-06-16 step 6)
|
|
1954
|
+
// + registered CHANNEL-backend agents (#102). Neither has a tmux session, so
|
|
1955
|
+
// each carries its `backend` + a live `status` (idle|working|queued:N); a
|
|
1956
|
+
// channel agent also surfaces its wake `channel` + backing `vault`.
|
|
1957
|
+
const programmaticInfos = listProgrammaticAgents(programmatic);
|
|
1958
|
+
const channelInfos = await listAttachedAgents(attachedQueue);
|
|
1959
|
+
return json({ agents: [...programmaticInfos, ...channelInfos] });
|
|
1960
|
+
} catch (err) {
|
|
1961
|
+
return json({ error: `failed to list agents: ${(err as Error).message}` }, 500);
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
// POST — register a programmatic agent from a spec. `buildSpecFromBody` accepts
|
|
1966
|
+
// only `backend: "programmatic"` (the default); a `channel` agent is vault-native
|
|
1967
|
+
// and an `interactive` backend is retired — both rejected with a clear 400.
|
|
1968
|
+
let spawnBody: unknown;
|
|
1969
|
+
try {
|
|
1970
|
+
spawnBody = await req.json();
|
|
1971
|
+
} catch {
|
|
1972
|
+
return json({ error: "invalid JSON body" }, 400);
|
|
1973
|
+
}
|
|
1974
|
+
let spec;
|
|
1975
|
+
try {
|
|
1976
|
+
spec = buildSpecFromBody(spawnBody);
|
|
1977
|
+
} catch (err) {
|
|
1978
|
+
if (err instanceof SpawnRequestError) return json({ error: err.message }, 400);
|
|
1979
|
+
throw err;
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
// CHANNEL EXCLUSION: a channel routes inbound to at most one agent. Refuse a
|
|
1983
|
+
// spawn for a DIFFERENT programmatic agent onto an already-occupied wake channel
|
|
1984
|
+
// (re-spawning the SAME name onto its OWN channel is the idempotent-replace path).
|
|
1985
|
+
const wakeChannel = normalizeChannel(spec.channels[0]!).name;
|
|
1986
|
+
if (programmatic.hasChannel(wakeChannel) && programmatic.getByChannel(wakeChannel)?.name !== spec.name) {
|
|
1987
|
+
return json(
|
|
1988
|
+
{
|
|
1989
|
+
error: `programmatic agent "${programmatic.getByChannel(wakeChannel)?.name}" already ` +
|
|
1990
|
+
`serves channel "${wakeChannel}". Kill it first, or pick a different channel.`,
|
|
1991
|
+
},
|
|
1992
|
+
409,
|
|
1993
|
+
);
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
// PROGRAMMATIC spawn — no tmux. Validate + persist spec.json (the no-tmux
|
|
1997
|
+
// setup), then register in the live registry (so inbound for the channel
|
|
1998
|
+
// enqueues). Boot re-registers from the persisted spec on the next restart.
|
|
1999
|
+
try {
|
|
2000
|
+
const setup = setupProgrammaticSpawn(spec);
|
|
2001
|
+
await programmatic.register({ ...spec, backend: "programmatic" });
|
|
2002
|
+
return json(setup);
|
|
2003
|
+
} catch (err) {
|
|
2004
|
+
if (err instanceof SpawnRequestError) return json({ error: err.message }, 400);
|
|
2005
|
+
if (err instanceof CredentialNotConfiguredError) return json({ error: err.message }, 400);
|
|
2006
|
+
return json({ error: (err as Error).message }, 400);
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
// PER-SESSION restart — POST /api/agents/:name/restart (agent:admin). For a
|
|
2011
|
+
// programmatic agent this RESETS the conversation (clears the persisted session id
|
|
2012
|
+
// so the next message starts fresh; the agent stays registered) — there is no
|
|
2013
|
+
// resident process to restart. Must match BEFORE the single-segment DELETE below.
|
|
2014
|
+
const restartMatch = url.pathname.match(/^\/api\/agents\/([^/]+)\/restart$/);
|
|
2015
|
+
if (restartMatch && req.method === "POST") {
|
|
2016
|
+
const denied = await requireScope(req, url, SCOPE_ADMIN);
|
|
2017
|
+
if (denied) return denied;
|
|
2018
|
+
const name = decodeURIComponent(restartMatch[1]!);
|
|
2019
|
+
if (programmatic.hasName(name)) {
|
|
2020
|
+
await programmatic.resetSession(name);
|
|
2021
|
+
return json({
|
|
2022
|
+
ok: true,
|
|
2023
|
+
name,
|
|
2024
|
+
backend: "programmatic",
|
|
2025
|
+
session_reset: true,
|
|
2026
|
+
note: "programmatic agent — conversation reset (next message starts a fresh session); no process to restart.",
|
|
2027
|
+
});
|
|
2028
|
+
}
|
|
2029
|
+
// No programmatic agent by that name — nothing to restart (a channel agent has
|
|
2030
|
+
// no daemon-run turn to reset; the interactive backend is retired).
|
|
2031
|
+
return json(
|
|
2032
|
+
{ error: `no programmatic agent named "${name}" to restart` },
|
|
2033
|
+
404,
|
|
2034
|
+
);
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
const agentMatch = url.pathname.match(/^\/api\/agents\/([^/]+)$/);
|
|
2038
|
+
if (agentMatch && req.method === "DELETE") {
|
|
2039
|
+
const denied = await requireScope(req, url, SCOPE_ADMIN);
|
|
2040
|
+
if (denied) return denied;
|
|
2041
|
+
const name = decodeURIComponent(agentMatch[1]!);
|
|
2042
|
+
// PROGRAMMATIC delete — deregister (drop the channel/name indexes + queue,
|
|
2043
|
+
// clear the backend session). No tmux to kill (the interactive backend retired).
|
|
2044
|
+
if (programmatic.hasName(name)) {
|
|
2045
|
+
const deregistered = await programmatic.deregister(name);
|
|
2046
|
+
return json({ ok: true, name, backend: "programmatic", killed: deregistered });
|
|
2047
|
+
}
|
|
2048
|
+
// No live agent by that name (interactive tmux sessions are no longer managed
|
|
2049
|
+
// here) — a no-op success so a delete of an already-gone agent is idempotent.
|
|
2050
|
+
return json({ ok: true, name, killed: false });
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
// Installed vault instances (for the agents page's vault picker) — derived
|
|
2054
|
+
// from the vault module's registered `/vault/<name>` paths in services.json.
|
|
2055
|
+
// No secrets; agent:admin-gated to match the rest of the agents surface.
|
|
2056
|
+
if (url.pathname === "/api/vaults" && req.method === "GET") {
|
|
2057
|
+
const denied = await requireScope(req, url, SCOPE_ADMIN);
|
|
2058
|
+
if (denied) return denied;
|
|
2059
|
+
return json({ vaults: listVaultNames() });
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
// ---------------------------------------------------------------------
|
|
2063
|
+
// EFFECTIVE ENV — the env-var NAMES an agent's `claude -p` turn runs with
|
|
2064
|
+
// (operability: see-what-env-a-turn-runs-with). NAMES ONLY, never values —
|
|
2065
|
+
// the same redaction posture as GET /api/credentials/env (describeChannelEnv).
|
|
2066
|
+
// Composed from three tagged sources, in precedence order channel > default >
|
|
2067
|
+
// grant (mirrors resolveChannelEnv + buildAgentChildEnv's spawn-time merge):
|
|
2068
|
+
// - "default" — the operator-level env.default layer
|
|
2069
|
+
// - "channel" — the per-agent override layer (env.channels[<agent>])
|
|
2070
|
+
// - "grant:<service>" — service env vars an APPROVED grant WOULD inject at spawn,
|
|
2071
|
+
// derived from the def's already-resolved connections via
|
|
2072
|
+
// serviceEnvVar() — NO grant material is fetched.
|
|
2073
|
+
// A lower-precedence entry shadowed by a higher one is marked overridden:true.
|
|
2074
|
+
// RESILIENT: the env-store layers always resolve (a local file read); a missing
|
|
2075
|
+
// def (agent not vault-native / idle registry) returns the env layers + a note,
|
|
2076
|
+
// never a 500. admin-gated to match the rest of the agents surface.
|
|
2077
|
+
//
|
|
2078
|
+
// GET /api/agents/<name>/env → { env: [{ name, source, overridden? }], note? }
|
|
2079
|
+
//
|
|
2080
|
+
// Externally hub strips `/agent`, so this is `<hub>/agent/api/agents/<name>/env`.
|
|
2081
|
+
// Safe to add AFTER the single-segment `/api/agents/<name>` DELETE + `/restart`
|
|
2082
|
+
// routes above: the `\/env$` suffix + GET-only method never collide with them.
|
|
2083
|
+
// ---------------------------------------------------------------------
|
|
2084
|
+
const envMatch = url.pathname.match(/^\/api\/agents\/([^/]+)\/env$/);
|
|
2085
|
+
if (envMatch && req.method === "GET") {
|
|
2086
|
+
const denied = await requireScope(req, url, SCOPE_ADMIN);
|
|
2087
|
+
if (denied) return denied;
|
|
2088
|
+
const name = decodeURIComponent(envMatch[1]!);
|
|
2089
|
+
// Find the agent's live def by name (agent ≡ channel). Its `connections` carry the
|
|
2090
|
+
// hub-resolved grant status (resolved at instantiate, NOT a live material fetch), so
|
|
2091
|
+
// the grant-env names derive without any secret fetch. Absent → env-store layers only.
|
|
2092
|
+
const def = agentDefs?.listDetailed().find((d) => d.name === name);
|
|
2093
|
+
return json(
|
|
2094
|
+
resolveEffectiveEnv(name, {
|
|
2095
|
+
...(def ? { connections: def.connections } : {}),
|
|
2096
|
+
hasDef: Boolean(def),
|
|
2097
|
+
}),
|
|
2098
|
+
);
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
// ---------------------------------------------------------------------
|
|
2102
|
+
// Vault-native agent DEFINITIONS — the v2 API layer (design
|
|
2103
|
+
// 2026-06-18-agent-ui-v2-and-reactivity.md Part 2 Phase 1). A `#agent/definition`
|
|
2104
|
+
// note IS the agent (body = system prompt, metadata = config); these routes
|
|
2105
|
+
// list + create + edit + delete them in a configured def-vault, reloading the
|
|
2106
|
+
// changed note into a LIVE agent IMMEDIATELY (the per-note reload, NOT the 60s
|
|
2107
|
+
// poll). NO secrets surfaced (no tokens). Externally `<hub>/agent/api/agent-defs`.
|
|
2108
|
+
//
|
|
2109
|
+
// GET /api/agent-defs → list (read-scoped) — per def: noteId, name,
|
|
2110
|
+
// backend, mode, vault, status, pending,
|
|
2111
|
+
// systemPromptPreview, wants, channel
|
|
2112
|
+
// GET /api/agent-defs/<noteId> → one def, FULL (read-scoped) — noteId, name,
|
|
2113
|
+
// backend, vault, mode, wants, systemPrompt
|
|
2114
|
+
// (FULL body), status. Pre-fills the edit form.
|
|
2115
|
+
// POST /api/agent-defs { vault, name, backend, systemPrompt, wants?,
|
|
2116
|
+
// metadata? } → write note + reload live (admin)
|
|
2117
|
+
// PATCH /api/agent-defs/<noteId> { systemPrompt?, wants?, metadata? } → edit +
|
|
2118
|
+
// reload (admin)
|
|
2119
|
+
// DELETE /api/agent-defs/<noteId> → delete note + deregister (admin)
|
|
2120
|
+
// ---------------------------------------------------------------------
|
|
2121
|
+
if (url.pathname === "/api/agent-defs" && (req.method === "GET" || req.method === "POST")) {
|
|
2122
|
+
// GET is READ-scoped (a listing, no secrets); POST is admin (it mints/writes).
|
|
2123
|
+
const scope = req.method === "GET" ? SCOPE_READ : SCOPE_ADMIN;
|
|
2124
|
+
const denied = await requireScope(req, url, scope);
|
|
2125
|
+
if (denied) return denied;
|
|
2126
|
+
if (!agentDefs) {
|
|
2127
|
+
// No def-vaults configured — an empty list (GET) / a clear 400 (POST).
|
|
2128
|
+
if (req.method === "GET") return json({ defs: [] });
|
|
2129
|
+
return json({ error: "no def-vaults configured (add one via POST /api/agent-vaults)" }, 400);
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
if (req.method === "GET") {
|
|
2133
|
+
return json({ defs: agentDefs.listDetailed() });
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
// POST — create a new def note + reload it live.
|
|
2137
|
+
let body: { vault?: unknown; name?: unknown; backend?: unknown; systemPrompt?: unknown; wants?: unknown; metadata?: unknown };
|
|
2138
|
+
try {
|
|
2139
|
+
body = (await req.json()) as typeof body;
|
|
2140
|
+
} catch {
|
|
2141
|
+
return json({ error: "invalid JSON body" }, 400);
|
|
2142
|
+
}
|
|
2143
|
+
if (typeof body.vault !== "string" || body.vault.length === 0) {
|
|
2144
|
+
return json({ error: "body.vault (string) is required" }, 400);
|
|
2145
|
+
}
|
|
2146
|
+
if (typeof body.name !== "string" || body.name.length === 0) {
|
|
2147
|
+
return json({ error: "body.name (string) is required" }, 400);
|
|
2148
|
+
}
|
|
2149
|
+
// DUAL-READ the legacy backend value `"channel"` → canonical `"attached"`, so an
|
|
2150
|
+
// API client still passing the pre-rename value is accepted (and persisted as the
|
|
2151
|
+
// canonical value by createDef). The routing key `channel` is a separate concept.
|
|
2152
|
+
const rawBackend = body.backend === undefined ? "programmatic" : body.backend;
|
|
2153
|
+
const backend = rawBackend === "channel" ? "attached" : rawBackend;
|
|
2154
|
+
if (backend !== "programmatic" && backend !== "attached") {
|
|
2155
|
+
return json({ error: 'body.backend must be "programmatic" or "attached"' }, 400);
|
|
2156
|
+
}
|
|
2157
|
+
if (body.systemPrompt !== undefined && typeof body.systemPrompt !== "string") {
|
|
2158
|
+
return json({ error: "body.systemPrompt must be a string" }, 400);
|
|
2159
|
+
}
|
|
2160
|
+
if (body.wants !== undefined && typeof body.wants !== "string") {
|
|
2161
|
+
return json({ error: "body.wants must be a comma-separated string" }, 400);
|
|
2162
|
+
}
|
|
2163
|
+
if (body.metadata !== undefined && (typeof body.metadata !== "object" || body.metadata === null || Array.isArray(body.metadata))) {
|
|
2164
|
+
return json({ error: "body.metadata must be an object of strings" }, 400);
|
|
2165
|
+
}
|
|
2166
|
+
try {
|
|
2167
|
+
const detail = await agentDefs.createDef({
|
|
2168
|
+
vault: body.vault,
|
|
2169
|
+
name: body.name,
|
|
2170
|
+
backend,
|
|
2171
|
+
systemPrompt: typeof body.systemPrompt === "string" ? body.systemPrompt : "",
|
|
2172
|
+
...(typeof body.wants === "string" ? { wants: body.wants } : {}),
|
|
2173
|
+
...(body.metadata ? { metadata: coerceStringMap(body.metadata) } : {}),
|
|
2174
|
+
});
|
|
2175
|
+
return json({ ok: true, def: detail }, 201);
|
|
2176
|
+
} catch (err) {
|
|
2177
|
+
if (err instanceof AgentDefWriteError) return json({ error: err.message }, err.status);
|
|
2178
|
+
return json({ error: `failed to create agent def: ${(err as Error).message}` }, 502);
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
// GET /api/agent-defs/<noteId> — the FULL editable def (the whole system-prompt
|
|
2183
|
+
// body, not the list's ~200-char preview) so the edit form pre-fills correctly.
|
|
2184
|
+
// READ-scoped, mirroring GET /api/agent-defs (a listing, no secrets — the body is
|
|
2185
|
+
// the prompt, never a token). 404 for an unknown id / a note that isn't a live def.
|
|
2186
|
+
const defGetMatch = url.pathname.match(/^\/api\/agent-defs\/(.+)$/);
|
|
2187
|
+
if (defGetMatch && req.method === "GET") {
|
|
2188
|
+
const denied = await requireScope(req, url, SCOPE_READ);
|
|
2189
|
+
if (denied) return denied;
|
|
2190
|
+
const noteId = decodeURIComponent(defGetMatch[1]!);
|
|
2191
|
+
if (!agentDefs) {
|
|
2192
|
+
return json({ error: "no def-vaults configured" }, 400);
|
|
2193
|
+
}
|
|
2194
|
+
try {
|
|
2195
|
+
const full = await agentDefs.getFullDef(noteId);
|
|
2196
|
+
if (!full) return json({ error: `note ${noteId} is not a live agent definition` }, 404);
|
|
2197
|
+
return json({ def: full });
|
|
2198
|
+
} catch (err) {
|
|
2199
|
+
if (err instanceof AgentDefWriteError) return json({ error: err.message }, err.status);
|
|
2200
|
+
return json({ error: `failed to fetch agent def: ${(err as Error).message}` }, 502);
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
const defMatch = url.pathname.match(/^\/api\/agent-defs\/(.+)$/);
|
|
2205
|
+
if (defMatch && (req.method === "PATCH" || req.method === "DELETE")) {
|
|
2206
|
+
const denied = await requireScope(req, url, SCOPE_ADMIN);
|
|
2207
|
+
if (denied) return denied;
|
|
2208
|
+
const noteId = decodeURIComponent(defMatch[1]!);
|
|
2209
|
+
if (!agentDefs) {
|
|
2210
|
+
return json({ error: "no def-vaults configured" }, 400);
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
if (req.method === "DELETE") {
|
|
2214
|
+
try {
|
|
2215
|
+
const removed = await agentDefs.deleteDef(noteId);
|
|
2216
|
+
// FIX 5 (PR #3) — surface a PARTIAL success: the note delete completed, but if
|
|
2217
|
+
// best-effort grant cleanup failed, say so (the agent's approved hub grants may
|
|
2218
|
+
// be orphaned) rather than reporting a clean full success. The delete itself is
|
|
2219
|
+
// still a 200 (the def IS gone — grant GC is best-effort, not delete-blocking).
|
|
2220
|
+
if (!removed.grantsReconciled) {
|
|
2221
|
+
console.warn(
|
|
2222
|
+
`parachute-agent: deleted agent def "${removed.name}" but grant cleanup failed — ` +
|
|
2223
|
+
`its approved hub grants may be orphaned.`,
|
|
2224
|
+
);
|
|
2225
|
+
}
|
|
2226
|
+
return json({ ok: true, ...removed, removed: true });
|
|
2227
|
+
} catch (err) {
|
|
2228
|
+
if (err instanceof AgentDefWriteError) return json({ error: err.message }, err.status);
|
|
2229
|
+
return json({ error: `failed to delete agent def: ${(err as Error).message}` }, 502);
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
// PATCH — edit body and/or metadata, reload live.
|
|
2234
|
+
let body: { systemPrompt?: unknown; wants?: unknown; metadata?: unknown };
|
|
2235
|
+
try {
|
|
2236
|
+
body = (await req.json()) as typeof body;
|
|
2237
|
+
} catch {
|
|
2238
|
+
return json({ error: "invalid JSON body" }, 400);
|
|
2239
|
+
}
|
|
2240
|
+
if (body.systemPrompt !== undefined && typeof body.systemPrompt !== "string") {
|
|
2241
|
+
return json({ error: "body.systemPrompt must be a string" }, 400);
|
|
2242
|
+
}
|
|
2243
|
+
if (body.wants !== undefined && typeof body.wants !== "string") {
|
|
2244
|
+
return json({ error: "body.wants must be a comma-separated string" }, 400);
|
|
2245
|
+
}
|
|
2246
|
+
if (body.metadata !== undefined && (typeof body.metadata !== "object" || body.metadata === null || Array.isArray(body.metadata))) {
|
|
2247
|
+
return json({ error: "body.metadata must be an object of strings" }, 400);
|
|
2248
|
+
}
|
|
2249
|
+
try {
|
|
2250
|
+
const detail = await agentDefs.editDef(noteId, {
|
|
2251
|
+
...(typeof body.systemPrompt === "string" ? { systemPrompt: body.systemPrompt } : {}),
|
|
2252
|
+
...(typeof body.wants === "string" ? { wants: body.wants } : {}),
|
|
2253
|
+
...(body.metadata ? { metadata: coerceStringMap(body.metadata) } : {}),
|
|
2254
|
+
});
|
|
2255
|
+
return json({ ok: true, def: detail });
|
|
2256
|
+
} catch (err) {
|
|
2257
|
+
if (err instanceof AgentDefWriteError) return json({ error: err.message }, err.status);
|
|
2258
|
+
return json({ error: `failed to edit agent def: ${(err as Error).message}` }, 502);
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
// ---------------------------------------------------------------------
|
|
2263
|
+
// Module-level DEF-VAULT list — which vault(s) this module reads
|
|
2264
|
+
// `#agent/definition` notes from (`agent-vaults.json`). Today invisible +
|
|
2265
|
+
// uneditable; the v2 API surfaces + manages it. NO token VALUE surfaced (only
|
|
2266
|
+
// present/absent). Externally `<hub>/agent/api/agent-vaults`. Admin-scoped.
|
|
2267
|
+
//
|
|
2268
|
+
// GET /api/agent-vaults → list { vault, url, tokenPresent } (read)
|
|
2269
|
+
// POST /api/agent-vaults { vault, url? } → mint token + persist + live (admin)
|
|
2270
|
+
// DELETE /api/agent-vaults/<name> → drop from file + deregister its agents (admin)
|
|
2271
|
+
// ---------------------------------------------------------------------
|
|
2272
|
+
if (url.pathname === "/api/agent-vaults" && (req.method === "GET" || req.method === "POST")) {
|
|
2273
|
+
// GET is READ-scoped to mirror GET /api/agent-defs — the listing is non-sensitive
|
|
2274
|
+
// ({vault,url,tokenPresent}); `tokenPresent` is a boolean, NEVER the token value.
|
|
2275
|
+
// POST is admin (it mints a token + writes config).
|
|
2276
|
+
const scope = req.method === "GET" ? SCOPE_READ : SCOPE_ADMIN;
|
|
2277
|
+
const denied = await requireScope(req, url, scope);
|
|
2278
|
+
if (denied) return denied;
|
|
2279
|
+
|
|
2280
|
+
if (req.method === "GET") {
|
|
2281
|
+
// Source of truth: the LIVE registry's bound vaults (a boot-minted binding
|
|
2282
|
+
// shows its token even before the file write lands). NEVER the token value. We
|
|
2283
|
+
// fall back to the persisted file only when no registry is wired (idle path),
|
|
2284
|
+
// so the listing isn't silently empty. The url defaults to the loopback vault.
|
|
2285
|
+
if (agentDefs) {
|
|
2286
|
+
return json({ vaults: agentDefs.vaultStatuses() });
|
|
2287
|
+
}
|
|
2288
|
+
let persisted: DefVaultBinding[] = [];
|
|
2289
|
+
try {
|
|
2290
|
+
persisted = readDefVaultsFile(defaultStateDir())?.vaults ?? [];
|
|
2291
|
+
} catch {
|
|
2292
|
+
persisted = [];
|
|
2293
|
+
}
|
|
2294
|
+
const vaults = persisted
|
|
2295
|
+
.map((v) => ({
|
|
2296
|
+
vault: v.vault,
|
|
2297
|
+
url: v.vaultUrl ?? DEFAULT_DEF_VAULT_URL,
|
|
2298
|
+
tokenPresent: typeof v.token === "string" && v.token.length > 0,
|
|
2299
|
+
}))
|
|
2300
|
+
.sort((a, b) => a.vault.localeCompare(b.vault));
|
|
2301
|
+
return json({ vaults });
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
// POST — add a def-vault (mint token + persist + load its defs live).
|
|
2305
|
+
let body: { vault?: unknown; url?: unknown };
|
|
2306
|
+
try {
|
|
2307
|
+
body = (await req.json()) as typeof body;
|
|
2308
|
+
} catch {
|
|
2309
|
+
return json({ error: "invalid JSON body" }, 400);
|
|
2310
|
+
}
|
|
2311
|
+
if (typeof body.vault !== "string" || body.vault.length === 0) {
|
|
2312
|
+
return json({ error: "body.vault (string) is required" }, 400);
|
|
2313
|
+
}
|
|
2314
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(body.vault)) {
|
|
2315
|
+
return json({ error: `body.vault "${body.vault}" must be a slug (alphanumeric, dash, underscore)` }, 400);
|
|
2316
|
+
}
|
|
2317
|
+
if (body.url !== undefined && typeof body.url !== "string") {
|
|
2318
|
+
return json({ error: "body.url must be a string (the vault REST origin)" }, 400);
|
|
2319
|
+
}
|
|
2320
|
+
try {
|
|
2321
|
+
const added = await addDefVault({
|
|
2322
|
+
vault: body.vault,
|
|
2323
|
+
...(typeof body.url === "string" && body.url.length > 0 ? { url: body.url } : {}),
|
|
2324
|
+
});
|
|
2325
|
+
return json({ ok: true, vault: added }, 201);
|
|
2326
|
+
} catch (err) {
|
|
2327
|
+
if (err instanceof MintError) {
|
|
2328
|
+
return json({ error: `token mint failed: ${err.message}` }, err.status >= 400 && err.status < 600 ? err.status : 502);
|
|
2329
|
+
}
|
|
2330
|
+
// A duplicate / no-operator-token / no-registry error → 400 (operator-actionable).
|
|
2331
|
+
return json({ error: `failed to add def-vault: ${(err as Error).message}` }, 400);
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
const vaultDelMatch = url.pathname.match(/^\/api\/agent-vaults\/([^/]+)$/);
|
|
2336
|
+
if (vaultDelMatch && req.method === "DELETE") {
|
|
2337
|
+
const denied = await requireScope(req, url, SCOPE_ADMIN);
|
|
2338
|
+
if (denied) return denied;
|
|
2339
|
+
const name = decodeURIComponent(vaultDelMatch[1]!);
|
|
2340
|
+
if (!agentDefs) {
|
|
2341
|
+
return json({ error: "no def-vaults configured" }, 400);
|
|
2342
|
+
}
|
|
2343
|
+
// GUARD: don't remove the last def-vault — that would orphan the module's whole
|
|
2344
|
+
// vault-native path (no vault to define agents in). Mirror the channels.json
|
|
2345
|
+
// posture: removing the only one is a clear 400, not a silent orphan.
|
|
2346
|
+
const names = agentDefs.vaultNames();
|
|
2347
|
+
if (!names.includes(name)) {
|
|
2348
|
+
return json({ ok: true, vault: name, removed: false }, 200);
|
|
2349
|
+
}
|
|
2350
|
+
if (names.length <= 1) {
|
|
2351
|
+
return json(
|
|
2352
|
+
{ error: `cannot remove the only def-vault "${name}" — the vault-native agent path would have no vault to define agents in. Add another first.` },
|
|
2353
|
+
400,
|
|
2354
|
+
);
|
|
2355
|
+
}
|
|
2356
|
+
// ORDERING (#106 review): persist the file FIRST, then tear down in-memory state.
|
|
2357
|
+
// The prior order (deregister → write → remove) left an INCOHERENT state on a write
|
|
2358
|
+
// failure: agents already torn down but the vault still in the live registry, while
|
|
2359
|
+
// the on-disk file was unchanged — so a restart re-instantiated agents the operator
|
|
2360
|
+
// had just deleted. Writing first means a write failure leaves EVERYTHING untouched
|
|
2361
|
+
// (vault + agents still live, file unchanged); only after the durable write commits
|
|
2362
|
+
// do we deregister the agents and drop the vault from the live registry.
|
|
2363
|
+
try {
|
|
2364
|
+
const stateDir = defaultStateDir();
|
|
2365
|
+
const file = readDefVaultsFile(stateDir);
|
|
2366
|
+
if (file) {
|
|
2367
|
+
writeDefVaultsFile({ vaults: file.vaults.filter((v) => v.vault !== name) }, stateDir);
|
|
2368
|
+
}
|
|
2369
|
+
} catch (err) {
|
|
2370
|
+
return json({ error: `failed to update agent-vaults.json: ${(err as Error).message}` }, 500);
|
|
2371
|
+
}
|
|
2372
|
+
// File is durable without this vault → tear down its live agents + drop it from the
|
|
2373
|
+
// live registry. A deregister failure now leaves the file already-correct, so a
|
|
2374
|
+
// restart converges to the intended (removed) state rather than resurrecting it.
|
|
2375
|
+
try {
|
|
2376
|
+
await agentDefs.deregisterAllForVault(name);
|
|
2377
|
+
} catch (err) {
|
|
2378
|
+
return json({ error: `failed to deregister agents for "${name}": ${(err as Error).message}` }, 502);
|
|
2379
|
+
}
|
|
2380
|
+
agentDefs.removeVault(name);
|
|
2381
|
+
return json({ ok: true, vault: name, removed: true });
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
// ---------------------------------------------------------------------
|
|
2385
|
+
// OAuth discovery for the HTTP MCP surface — RFC 9728 + RFC 8414, in the
|
|
2386
|
+
// PATH-INSERTION form (`.well-known` ABOVE the resource path). This is the
|
|
2387
|
+
// shape a Claude Code HTTP-MCP client probes when adding the channel by URL
|
|
2388
|
+
// (the same shape vault serves). For the resource at `/mcp/<channel>`:
|
|
2389
|
+
//
|
|
2390
|
+
// /.well-known/oauth-protected-resource/mcp/<channel>
|
|
2391
|
+
// /.well-known/oauth-authorization-server/mcp/<channel>
|
|
2392
|
+
//
|
|
2393
|
+
// Both are PUBLIC (no auth) — they have to be reachable before the client
|
|
2394
|
+
// holds a token. Externally they're `<hub>/agent/.well-known/...`; hub's
|
|
2395
|
+
// stripPrefix removes `/agent`, so the daemon matches the bare path and
|
|
2396
|
+
// re-adds the prefix in the advertised URLs via x-forwarded-host.
|
|
2397
|
+
// ---------------------------------------------------------------------
|
|
2398
|
+
if (req.method === "GET") {
|
|
2399
|
+
const prm = url.pathname.match(/^\/\.well-known\/oauth-protected-resource\/mcp\/([^/]+)$/);
|
|
2400
|
+
if (prm) return handleProtectedResource(req, decodeURIComponent(prm[1]!));
|
|
2401
|
+
const asm = url.pathname.match(/^\/\.well-known\/oauth-authorization-server\/mcp\/([^/]+)$/);
|
|
2402
|
+
if (asm) return handleAuthorizationServer(req, decodeURIComponent(asm[1]!));
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
// SSE event stream — bridges subscribe by channel. Bridge-facing: requires
|
|
2406
|
+
// a hub JWT with `agent:read`.
|
|
2407
|
+
if (req.method === "GET" && url.pathname === "/events") {
|
|
2408
|
+
const denied = await requireScope(req, url, SCOPE_READ);
|
|
2409
|
+
if (denied) return denied;
|
|
2410
|
+
let channel = url.searchParams.get("channel") ?? undefined;
|
|
2411
|
+
if (!channel) {
|
|
2412
|
+
channel = DEFAULT_CHANNEL;
|
|
2413
|
+
console.warn(
|
|
2414
|
+
`parachute-agent: /events without ?channel= — defaulting to "${DEFAULT_CHANNEL}". ` +
|
|
2415
|
+
`This back-compat default is deprecated; pass ?channel=<name>.`,
|
|
2416
|
+
);
|
|
2417
|
+
}
|
|
2418
|
+
const subscribedChannel = channel;
|
|
2419
|
+
const clientId = crypto.randomUUID();
|
|
2420
|
+
const stream = new ReadableStream<string>({
|
|
2421
|
+
start(controller) {
|
|
2422
|
+
registry.add(clientId, {
|
|
2423
|
+
channel: subscribedChannel,
|
|
2424
|
+
enqueue: (payload) => controller.enqueue(payload),
|
|
2425
|
+
});
|
|
2426
|
+
controller.enqueue(": connected\n\n");
|
|
2427
|
+
// (The deaf-on-restart BACKLOG REPLAY that used to fire here — replaying the
|
|
2428
|
+
// messages a reconnecting stdio bridge missed while detached — was retired
|
|
2429
|
+
// with the interactive backend: design 2026-06-19-retire-interactive-
|
|
2430
|
+
// backend.md. The live route still pushes new inbound to subscribed clients.)
|
|
2431
|
+
},
|
|
2432
|
+
cancel() {
|
|
2433
|
+
registry.remove(clientId);
|
|
2434
|
+
},
|
|
2435
|
+
});
|
|
2436
|
+
return new Response(stream, {
|
|
2437
|
+
headers: {
|
|
2438
|
+
"content-type": "text/event-stream",
|
|
2439
|
+
"cache-control": "no-cache",
|
|
2440
|
+
connection: "keep-alive",
|
|
2441
|
+
},
|
|
2442
|
+
});
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
// Reply — bridge-facing: requires `agent:write`.
|
|
2446
|
+
if (req.method === "POST" && url.pathname === "/api/reply") {
|
|
2447
|
+
const denied = await requireScope(req, url, SCOPE_WRITE);
|
|
2448
|
+
if (denied) return denied;
|
|
2449
|
+
try {
|
|
2450
|
+
const body = (await req.json()) as {
|
|
2451
|
+
channel?: string;
|
|
2452
|
+
chat_id?: string;
|
|
2453
|
+
text?: string;
|
|
2454
|
+
reply_to?: string;
|
|
2455
|
+
files?: string[];
|
|
2456
|
+
meta?: Record<string, string>;
|
|
2457
|
+
};
|
|
2458
|
+
const transport = transportFor(body.channel);
|
|
2459
|
+
if (!transport) return channelError(body.channel);
|
|
2460
|
+
const result = await transport.reply(toReplyArgs(body));
|
|
2461
|
+
return json({ sent: result.sent });
|
|
2462
|
+
} catch (err) {
|
|
2463
|
+
return errResponse(err);
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
// React — bridge-facing: requires `agent:write`.
|
|
2468
|
+
if (req.method === "POST" && url.pathname === "/api/react") {
|
|
2469
|
+
const denied = await requireScope(req, url, SCOPE_WRITE);
|
|
2470
|
+
if (denied) return denied;
|
|
2471
|
+
try {
|
|
2472
|
+
const body = (await req.json()) as {
|
|
2473
|
+
channel?: string;
|
|
2474
|
+
chat_id?: string;
|
|
2475
|
+
message_id: string;
|
|
2476
|
+
emoji: string;
|
|
2477
|
+
meta?: Record<string, string>;
|
|
2478
|
+
};
|
|
2479
|
+
const transport = transportFor(body.channel);
|
|
2480
|
+
if (!transport) return channelError(body.channel);
|
|
2481
|
+
if (!transport.react) return methodMissing(body.channel!, "react");
|
|
2482
|
+
const args: ReactArgs = {
|
|
2483
|
+
channel: body.channel!,
|
|
2484
|
+
message_id: body.message_id,
|
|
2485
|
+
emoji: body.emoji,
|
|
2486
|
+
meta: mergeMeta(body),
|
|
2487
|
+
};
|
|
2488
|
+
await transport.react(args);
|
|
2489
|
+
return json({ ok: true });
|
|
2490
|
+
} catch (err) {
|
|
2491
|
+
return errResponse(err);
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
// Edit message — bridge-facing: requires `agent:write`.
|
|
2496
|
+
if (req.method === "POST" && url.pathname === "/api/edit") {
|
|
2497
|
+
const denied = await requireScope(req, url, SCOPE_WRITE);
|
|
2498
|
+
if (denied) return denied;
|
|
2499
|
+
try {
|
|
2500
|
+
const body = (await req.json()) as {
|
|
2501
|
+
channel?: string;
|
|
2502
|
+
chat_id?: string;
|
|
2503
|
+
message_id: string;
|
|
2504
|
+
text: string;
|
|
2505
|
+
meta?: Record<string, string>;
|
|
2506
|
+
};
|
|
2507
|
+
const transport = transportFor(body.channel);
|
|
2508
|
+
if (!transport) return channelError(body.channel);
|
|
2509
|
+
if (!transport.edit) return methodMissing(body.channel!, "edit");
|
|
2510
|
+
const args: EditArgs = {
|
|
2511
|
+
channel: body.channel!,
|
|
2512
|
+
message_id: body.message_id,
|
|
2513
|
+
text: body.text,
|
|
2514
|
+
meta: mergeMeta(body),
|
|
2515
|
+
};
|
|
2516
|
+
await transport.edit(args);
|
|
2517
|
+
return json({ ok: true });
|
|
2518
|
+
} catch (err) {
|
|
2519
|
+
return errResponse(err);
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
// Permission prompt — bridge forwards permission_request here.
|
|
2524
|
+
// Bridge-facing: requires `agent:write`.
|
|
2525
|
+
if (req.method === "POST" && url.pathname === "/api/permission") {
|
|
2526
|
+
const denied = await requireScope(req, url, SCOPE_WRITE);
|
|
2527
|
+
if (denied) return denied;
|
|
2528
|
+
try {
|
|
2529
|
+
const body = (await req.json()) as {
|
|
2530
|
+
channel?: string;
|
|
2531
|
+
request_id: string;
|
|
2532
|
+
tool_name: string;
|
|
2533
|
+
description: string;
|
|
2534
|
+
input_preview: string;
|
|
2535
|
+
};
|
|
2536
|
+
const transport = transportFor(body.channel);
|
|
2537
|
+
if (!transport) return channelError(body.channel);
|
|
2538
|
+
if (!transport.sendPermission) return methodMissing(body.channel!, "sendPermission");
|
|
2539
|
+
const args: PermissionArgs = {
|
|
2540
|
+
channel: body.channel!,
|
|
2541
|
+
request_id: body.request_id,
|
|
2542
|
+
tool_name: body.tool_name,
|
|
2543
|
+
description: body.description,
|
|
2544
|
+
input_preview: body.input_preview,
|
|
2545
|
+
};
|
|
2546
|
+
const result = await transport.sendPermission(args);
|
|
2547
|
+
return json({ sent: result.sent });
|
|
2548
|
+
} catch (err) {
|
|
2549
|
+
return errResponse(err);
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
// Download attachment — bridge-facing: requires `agent:write`.
|
|
2554
|
+
if (req.method === "POST" && url.pathname === "/api/download") {
|
|
2555
|
+
const denied = await requireScope(req, url, SCOPE_WRITE);
|
|
2556
|
+
if (denied) return denied;
|
|
2557
|
+
try {
|
|
2558
|
+
const body = (await req.json()) as { channel?: string; file_id: string };
|
|
2559
|
+
const transport = transportFor(body.channel);
|
|
2560
|
+
if (!transport) return channelError(body.channel);
|
|
2561
|
+
if (!transport.download) return methodMissing(body.channel!, "download");
|
|
2562
|
+
const args: DownloadArgs = { channel: body.channel!, file_id: body.file_id };
|
|
2563
|
+
const result = await transport.download(args);
|
|
2564
|
+
return json({ path: result.path });
|
|
2565
|
+
} catch (err) {
|
|
2566
|
+
return errResponse(err);
|
|
2567
|
+
}
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
// Vault inbound webhook — a vault trigger POSTs here when a new
|
|
2571
|
+
// `#agent/message/inbound` note appears. Resolves the target channel from
|
|
2572
|
+
// `note.metadata.channel`, asserts it's a vault-transport channel, and hands
|
|
2573
|
+
// the note to that transport's `ingestInbound`, which `ctx.emit`s it →
|
|
2574
|
+
// wakes the subscribed bridge / MCP session.
|
|
2575
|
+
//
|
|
2576
|
+
// Auth — two paths, in order:
|
|
2577
|
+
// 1. PREFERRED: `Authorization: Bearer <hub JWT>` (aud:agent, scope
|
|
2578
|
+
// `agent:send` — the trigger is effectively "posting an inbound
|
|
2579
|
+
// message"). The hub registers the trigger with `action.auth.bearer`
|
|
2580
|
+
// set to a minted agent:send token, so a fresh setup never touches a
|
|
2581
|
+
// shared secret. Validated via the same scope-guard path as the bridge.
|
|
2582
|
+
// 2. DEPRECATED back-compat: a shared `?secret=` (or `X-Channel-Webhook-Secret`)
|
|
2583
|
+
// validated against the target channel's vault-transport `webhookSecret`,
|
|
2584
|
+
// for existing manual setups whose triggers still ride the secret in the
|
|
2585
|
+
// URL. Logs a one-line deprecation warning when used.
|
|
2586
|
+
// A request with NEITHER → 401. We keep the uniform-401 (no channel
|
|
2587
|
+
// enumeration) behavior on both paths.
|
|
2588
|
+
if (req.method === "POST" && url.pathname === "/api/vault/inbound") {
|
|
2589
|
+
let body: {
|
|
2590
|
+
trigger?: string;
|
|
2591
|
+
event?: string;
|
|
2592
|
+
note?: {
|
|
2593
|
+
id?: string;
|
|
2594
|
+
path?: string;
|
|
2595
|
+
content?: string;
|
|
2596
|
+
tags?: string[];
|
|
2597
|
+
metadata?: Record<string, unknown>;
|
|
2598
|
+
// The vault `send: "json"` trigger payload includes the note's attachments
|
|
2599
|
+
// inline (each `{ id, path, mimeType, ... }`) — the has-attachments signal +
|
|
2600
|
+
// fast-path the transport uses to surface inbound files (Phase 1).
|
|
2601
|
+
attachments?: Array<{ id?: string; path?: string; mimeType?: string }>;
|
|
2602
|
+
};
|
|
2603
|
+
};
|
|
2604
|
+
try {
|
|
2605
|
+
body = (await req.json()) as typeof body;
|
|
2606
|
+
} catch {
|
|
2607
|
+
return json({ error: "invalid JSON body" }, 400);
|
|
2608
|
+
}
|
|
2609
|
+
const note = body.note;
|
|
2610
|
+
if (!note || typeof note.id !== "string" || !note.id) {
|
|
2611
|
+
return json({ error: "body must include note.id" }, 400);
|
|
2612
|
+
}
|
|
2613
|
+
// Dual-read the routing key: the NEW `agent` field, falling back to the legacy
|
|
2614
|
+
// `channel` field (the expand-phase dual-read) — a note written by either an
|
|
2615
|
+
// agent-speaking or a legacy channel-speaking writer routes.
|
|
2616
|
+
const channelName = noteAgentKey(note.metadata);
|
|
2617
|
+
if (!channelName) {
|
|
2618
|
+
return json(
|
|
2619
|
+
{ error: "note.metadata.agent (or legacy channel) is required to route the message" },
|
|
2620
|
+
400,
|
|
2621
|
+
);
|
|
2622
|
+
}
|
|
2623
|
+
const ch = channels.get(channelName);
|
|
2624
|
+
const vt = ch?.transport instanceof VaultTransport ? ch.transport : undefined;
|
|
2625
|
+
|
|
2626
|
+
// Branch on Authorization-header PRESENCE, not token truthiness. A
|
|
2627
|
+
// whitespace-only `Authorization: Bearer ` (which extractBearer trims to
|
|
2628
|
+
// empty/falsy) must NOT fall through to the `?secret=` path — that would let
|
|
2629
|
+
// a caller who knows the secret but lacks a valid JWT force the secret path.
|
|
2630
|
+
// Any Authorization header at all → JWT path, full stop; a malformed/empty
|
|
2631
|
+
// token fails hard via requireScope's 401. The deprecated `?secret=`
|
|
2632
|
+
// fallback runs ONLY when there is no Authorization header.
|
|
2633
|
+
const authHeader = req.headers.get("authorization");
|
|
2634
|
+
if (authHeader !== null) {
|
|
2635
|
+
// JWT path — validate the hub token, require agent:send. This is a
|
|
2636
|
+
// tailnet-reachable webhook, so we keep it uniform-401: any auth failure
|
|
2637
|
+
// (missing/malformed/expired token OR insufficient scope OR unknown
|
|
2638
|
+
// channel) collapses to the SAME 401, so it can't be probed for valid
|
|
2639
|
+
// scopes or channel names. (requireScope would otherwise distinguish 401
|
|
2640
|
+
// vs 403 — fine for the operator-facing config API, but this endpoint
|
|
2641
|
+
// stays opaque.)
|
|
2642
|
+
const denied = await requireScope(req, url, SCOPE_SEND);
|
|
2643
|
+
if (denied || !vt) {
|
|
2644
|
+
return json({ error: "unauthorized" }, 401);
|
|
2645
|
+
}
|
|
2646
|
+
} else {
|
|
2647
|
+
// DEPRECATED shared-secret fallback — only reachable with NO Authorization
|
|
2648
|
+
// header. The secret is per-channel, so resolve the channel first, then
|
|
2649
|
+
// constant-time compare. Uniform 401 for an unknown vault channel, a
|
|
2650
|
+
// channel with no configured secret (nothing to validate against), OR a
|
|
2651
|
+
// bad secret — never reveal which (no channel enumeration on this
|
|
2652
|
+
// tailnet-reachable endpoint). webhookSecretMatches treats an empty/absent
|
|
2653
|
+
// configured secret as never-matching, so a JWT-only channel (no secret)
|
|
2654
|
+
// can't be opened by a `?secret=` request.
|
|
2655
|
+
const presented =
|
|
2656
|
+
url.searchParams.get("secret") ?? req.headers.get("x-channel-webhook-secret") ?? "";
|
|
2657
|
+
if (!vt || !webhookSecretMatches(presented, vt.webhookSecret ?? "")) {
|
|
2658
|
+
return json({ error: "unauthorized" }, 401);
|
|
2659
|
+
}
|
|
2660
|
+
console.warn(
|
|
2661
|
+
`parachute-agent: /api/vault/inbound authenticated via DEPRECATED ?secret= shared secret ` +
|
|
2662
|
+
`for channel "${channelName}". Migrate to a hub-JWT trigger (action.auth.bearer, scope agent:send).`,
|
|
2663
|
+
);
|
|
2664
|
+
}
|
|
2665
|
+
// Idempotency: a duplicate trigger delivery for the same note must not
|
|
2666
|
+
// double-wake. First-seen → process; already-seen → ack without emitting.
|
|
2667
|
+
if (markSeen(note.id)) {
|
|
2668
|
+
// Await — ingestInbound is async when the note carries attachments (it fetches
|
|
2669
|
+
// the attachment list before emitting). The `note.attachments` inline list from
|
|
2670
|
+
// the trigger payload is forwarded as the has-attachments signal (Phase 1).
|
|
2671
|
+
await vt.ingestInbound({
|
|
2672
|
+
id: note.id,
|
|
2673
|
+
content: note.content,
|
|
2674
|
+
tags: note.tags,
|
|
2675
|
+
metadata: note.metadata,
|
|
2676
|
+
...(note.attachments ? { attachments: note.attachments } : {}),
|
|
2677
|
+
});
|
|
2678
|
+
}
|
|
2679
|
+
// Never write back to the note — the v1 trigger handles its own
|
|
2680
|
+
// created/rendered_at markers vault-side.
|
|
2681
|
+
return json({ ok: true });
|
|
2682
|
+
}
|
|
2683
|
+
|
|
2684
|
+
// ---------------------------------------------------------------------
|
|
2685
|
+
// Vault-native agent-def RELOAD webhook — POST /api/vault/agent-def
|
|
2686
|
+
// (design 2026-06-17-vault-native-agents, Phase 4a). A vault trigger on
|
|
2687
|
+
// `#agent/definition` created/updated/deleted POSTs here; we reload that one
|
|
2688
|
+
// agent (per-note granularity). Mirrors /api/vault/inbound's auth (hub JWT,
|
|
2689
|
+
// scope agent:send — the trigger is a vault→module action) and its uniform-401.
|
|
2690
|
+
// Body: { event?, vault?, note: { id, ... } }. `vault` names the source
|
|
2691
|
+
// def-vault (the hub fills it / it defaults to the single configured one when
|
|
2692
|
+
// exactly one is bound). Externally `<hub>/agent/api/vault/agent-def`.
|
|
2693
|
+
// ---------------------------------------------------------------------
|
|
2694
|
+
if (req.method === "POST" && url.pathname === "/api/vault/agent-def") {
|
|
2695
|
+
const denied = await requireScope(req, url, SCOPE_SEND);
|
|
2696
|
+
if (denied) return json({ error: "unauthorized" }, 401);
|
|
2697
|
+
if (!agentDefs) {
|
|
2698
|
+
// No def-vaults configured — nothing to reload. Clean ack (the trigger
|
|
2699
|
+
// shouldn't have fired, but don't error a benign delivery).
|
|
2700
|
+
return json({ ok: true, reloaded: "skipped" });
|
|
2701
|
+
}
|
|
2702
|
+
let body: {
|
|
2703
|
+
event?: "created" | "updated" | "deleted";
|
|
2704
|
+
vault?: string;
|
|
2705
|
+
note?: { id?: string; path?: string; metadata?: Record<string, unknown> };
|
|
2706
|
+
};
|
|
2707
|
+
try {
|
|
2708
|
+
body = (await req.json()) as typeof body;
|
|
2709
|
+
} catch {
|
|
2710
|
+
return json({ error: "invalid JSON body" }, 400);
|
|
2711
|
+
}
|
|
2712
|
+
const noteId =
|
|
2713
|
+
typeof body.note?.id === "string" && body.note.id
|
|
2714
|
+
? body.note.id
|
|
2715
|
+
: typeof body.note?.path === "string"
|
|
2716
|
+
? body.note.path
|
|
2717
|
+
: undefined;
|
|
2718
|
+
if (!noteId) {
|
|
2719
|
+
return json({ error: "body must include note.id" }, 400);
|
|
2720
|
+
}
|
|
2721
|
+
// Resolve the source vault: the explicit `vault` field, else the sole
|
|
2722
|
+
// configured def-vault (the single-vault default — unambiguous), else 400.
|
|
2723
|
+
let vault = typeof body.vault === "string" && body.vault ? body.vault : undefined;
|
|
2724
|
+
if (!vault) {
|
|
2725
|
+
const names = agentDefs.list();
|
|
2726
|
+
const distinct = new Set([...names.map((d) => d.vault)]);
|
|
2727
|
+
// Fall back to the lone bound vault even with zero live defs yet.
|
|
2728
|
+
if (agentDefs.vaultCount === 1) {
|
|
2729
|
+
vault = agentDefs.soleVaultName();
|
|
2730
|
+
} else if (distinct.size === 1) {
|
|
2731
|
+
vault = [...distinct][0];
|
|
2732
|
+
}
|
|
2733
|
+
}
|
|
2734
|
+
if (!vault) {
|
|
2735
|
+
return json({ error: "body.vault is required (multiple def-vaults configured)" }, 400);
|
|
2736
|
+
}
|
|
2737
|
+
// Coerce `event` to the declared union (it's an untrusted webhook body) — any
|
|
2738
|
+
// unrecognized value becomes `undefined` (a hint only; reload() re-reads ground
|
|
2739
|
+
// truth regardless, but keep the runtime honest with the type contract).
|
|
2740
|
+
const event =
|
|
2741
|
+
body.event === "created" || body.event === "updated" || body.event === "deleted"
|
|
2742
|
+
? body.event
|
|
2743
|
+
: undefined;
|
|
2744
|
+
const result = await agentDefs.reload(vault, noteId, event);
|
|
2745
|
+
return json({ ok: true, reloaded: result });
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
// Turn-event SSE — GET /api/channels/<ch>/turn-events (chat-facing; gated on
|
|
2749
|
+
// `agent:read`, same scope as the transcript poll + /ui/events). The streaming
|
|
2750
|
+
// view (design 2026-06-16 build item #1): the chat subscribes here to watch a
|
|
2751
|
+
// PROGRAMMATIC turn work in real time — interim assistant text + tool_use, then a
|
|
2752
|
+
// done/error lifecycle event. EPHEMERAL by design: no backlog/replay (the durable
|
|
2753
|
+
// record is the `#agent/message/outbound` note the turn still writes). A channel
|
|
2754
|
+
// with no programmatic agent simply never receives a `turn` frame (the stream
|
|
2755
|
+
// stays open + idle). Open to any live channel — unknown channel still opens the
|
|
2756
|
+
// stream (it just never emits), matching the low-stakes ephemeral contract.
|
|
2757
|
+
// Externally `<hub>/agent/api/channels/<ch>/turn-events`.
|
|
2758
|
+
{
|
|
2759
|
+
const turnMatch = url.pathname.match(/^\/api\/channels\/([^/]+)\/turn-events$/);
|
|
2760
|
+
if (req.method === "GET" && turnMatch) {
|
|
2761
|
+
// allowQueryParam=true: this SSE is consumed by a browser EventSource, which
|
|
2762
|
+
// cannot set an Authorization header — it authenticates via ?token=. Without
|
|
2763
|
+
// this the live-streaming view 401s in the browser and never connects. (The
|
|
2764
|
+
// stdio-bridge /events SSE uses a Bearer header, so it doesn't need this.)
|
|
2765
|
+
const denied = await requireScope(req, url, SCOPE_READ, true);
|
|
2766
|
+
if (denied) return denied;
|
|
2767
|
+
const channelName = decodeURIComponent(turnMatch[1]!);
|
|
2768
|
+
const clientId = crypto.randomUUID();
|
|
2769
|
+
const stream = new ReadableStream<string>({
|
|
2770
|
+
start(controller) {
|
|
2771
|
+
turnEvents.add(clientId, {
|
|
2772
|
+
channel: channelName,
|
|
2773
|
+
enqueue: (payload) => controller.enqueue(payload),
|
|
2774
|
+
});
|
|
2775
|
+
controller.enqueue(": connected\n\n");
|
|
2776
|
+
},
|
|
2777
|
+
cancel() {
|
|
2778
|
+
turnEvents.remove(clientId);
|
|
2779
|
+
},
|
|
2780
|
+
});
|
|
2781
|
+
return new Response(stream, {
|
|
2782
|
+
headers: {
|
|
2783
|
+
"content-type": "text/event-stream",
|
|
2784
|
+
"cache-control": "no-cache",
|
|
2785
|
+
connection: "keep-alive",
|
|
2786
|
+
},
|
|
2787
|
+
});
|
|
2788
|
+
}
|
|
2789
|
+
}
|
|
2790
|
+
|
|
2791
|
+
// Transcript read — GET /api/channels/<ch>/messages (chat-facing; gated on
|
|
2792
|
+
// `agent:read`, same as /ui/events). The built-in chat polls this to render
|
|
2793
|
+
// a channel's durable history and pick up replies + messages from other
|
|
2794
|
+
// clients (Telegram, other browsers). Behavior by transport:
|
|
2795
|
+
// - vault → loadTranscript() against the channel's vault (the daemon does
|
|
2796
|
+
// the vault I/O with the channel's stored vault token — the chat's
|
|
2797
|
+
// agent:read token never touches the vault).
|
|
2798
|
+
// - http-ui → that transport's traffic is ephemeral (SSE-only, no buffer),
|
|
2799
|
+
// so there's no durable transcript to replay → { messages: [] }.
|
|
2800
|
+
// - other (telegram) → no transcript surface here → { messages: [] }.
|
|
2801
|
+
// 404 for an unknown channel. Externally `<hub>/agent/api/channels/<ch>/messages`.
|
|
2802
|
+
{
|
|
2803
|
+
const msgMatch = url.pathname.match(/^\/api\/channels\/([^/]+)\/messages$/);
|
|
2804
|
+
if (req.method === "GET" && msgMatch) {
|
|
2805
|
+
const denied = await requireScope(req, url, SCOPE_READ);
|
|
2806
|
+
if (denied) return denied;
|
|
2807
|
+
const channelName = decodeURIComponent(msgMatch[1]!);
|
|
2808
|
+
const ch = channels.get(channelName);
|
|
2809
|
+
if (!ch) {
|
|
2810
|
+
return json(
|
|
2811
|
+
{
|
|
2812
|
+
error: `unknown channel "${channelName}" — known channels: ${[...channels.keys()].join(", ") || "(none)"}`,
|
|
2813
|
+
},
|
|
2814
|
+
404,
|
|
2815
|
+
);
|
|
2816
|
+
}
|
|
2817
|
+
if (ch.transport instanceof VaultTransport) {
|
|
2818
|
+
try {
|
|
2819
|
+
const messages = await ch.transport.loadTranscript();
|
|
2820
|
+
return json({ messages });
|
|
2821
|
+
} catch (err) {
|
|
2822
|
+
// The vault read failed (unreachable / bad token / 5xx). Surface a
|
|
2823
|
+
// 502 so the chat shows "couldn't load history" rather than a silent
|
|
2824
|
+
// empty transcript that looks like "no messages yet".
|
|
2825
|
+
return json({ error: String(err) }, 502);
|
|
2826
|
+
}
|
|
2827
|
+
}
|
|
2828
|
+
// http-ui + telegram: no durable transcript to replay here.
|
|
2829
|
+
return json({ messages: [] });
|
|
2830
|
+
}
|
|
2831
|
+
}
|
|
2832
|
+
|
|
2833
|
+
// Send for a VAULT channel — POST /api/channels/<ch>/send (chat-facing; gated
|
|
2834
|
+
// on `agent:send`, same scope http-ui's send uses). The daemon owns this for
|
|
2835
|
+
// vault transports because the http-ui transport's ingestHttp only matches its
|
|
2836
|
+
// OWN channel name; a vault channel needs the daemon to dispatch. For a vault
|
|
2837
|
+
// channel the daemon writes a `#agent/message/inbound` note via the channel's
|
|
2838
|
+
// stored vault token — which WAKES the session through the existing vault
|
|
2839
|
+
// trigger (we do NOT also emit; that would double-wake). http-ui channels fall
|
|
2840
|
+
// through to their transport's ingestHttp (unchanged), so this guard handles
|
|
2841
|
+
// ONLY vault channels and passes everything else on.
|
|
2842
|
+
{
|
|
2843
|
+
const sendMatch = url.pathname.match(/^\/api\/channels\/([^/]+)\/send$/);
|
|
2844
|
+
if (req.method === "POST" && sendMatch) {
|
|
2845
|
+
const channelName = decodeURIComponent(sendMatch[1]!);
|
|
2846
|
+
const ch = channels.get(channelName);
|
|
2847
|
+
// Only intercept VAULT channels; let http-ui keep its ingestHttp send path
|
|
2848
|
+
// (and an unknown channel falls through to the final 404, matching prior
|
|
2849
|
+
// behavior — http-ui's ingestHttp also only answered for a live channel).
|
|
2850
|
+
if (ch && ch.transport instanceof VaultTransport) {
|
|
2851
|
+
const denied = await requireScope(req, url, SCOPE_SEND);
|
|
2852
|
+
if (denied) return denied;
|
|
2853
|
+
let text: string;
|
|
2854
|
+
try {
|
|
2855
|
+
const body = (await req.json()) as { text?: unknown };
|
|
2856
|
+
if (typeof body.text !== "string" || body.text.length === 0) {
|
|
2857
|
+
return json({ error: "body must be { text: <non-empty string> }" }, 400);
|
|
2858
|
+
}
|
|
2859
|
+
text = body.text;
|
|
2860
|
+
} catch {
|
|
2861
|
+
return json({ error: "invalid JSON body" }, 400);
|
|
2862
|
+
}
|
|
2863
|
+
try {
|
|
2864
|
+
// Writing the inbound note IS the wake (via the vault trigger) — the
|
|
2865
|
+
// transport deliberately does not emit. Return { ok, id } so the chat
|
|
2866
|
+
// can reconcile its optimistic echo against the real note id on the
|
|
2867
|
+
// next poll.
|
|
2868
|
+
const { id } = await ch.transport.writeInbound(text, "operator");
|
|
2869
|
+
return json({ ok: true, id });
|
|
2870
|
+
} catch (err) {
|
|
2871
|
+
return errResponse(err);
|
|
2872
|
+
}
|
|
2873
|
+
}
|
|
2874
|
+
}
|
|
2875
|
+
}
|
|
2876
|
+
|
|
2877
|
+
// Retired built-in chat page (Phase 4c) — the SPA Chat view replaces it.
|
|
2878
|
+
// EXACT `/ui` only (NOT a prefix): `/ui/events` is the message SSE the SPA
|
|
2879
|
+
// Chat depends on and is owned by the http-ui transport's `ingestHttp` (run
|
|
2880
|
+
// at the bottom of this handler) — it MUST keep routing. Redirect to the SPA
|
|
2881
|
+
// Chat route: relative `app/chat` → `/app/chat` direct / `/agent/app/chat`
|
|
2882
|
+
// proxied, which the SPA BrowserRouter (basename `/app`|`/agent/app`) renders
|
|
2883
|
+
// as the `/chat` route (`web/ui/src/App.tsx`).
|
|
2884
|
+
if (req.method === "GET" && url.pathname === "/ui") {
|
|
2885
|
+
return redirect("app/chat");
|
|
2886
|
+
}
|
|
2887
|
+
|
|
2888
|
+
// Retired config/admin page (Phase 4c) — def-vaults + the unified create
|
|
2889
|
+
// flow live in the SPA now. 302 to the SPA app root. `configUiUrl` in
|
|
2890
|
+
// module.json points at `/agent/app/` so the hub frames the SPA directly.
|
|
2891
|
+
if (req.method === "GET" && url.pathname === "/admin") {
|
|
2892
|
+
return redirect("app/");
|
|
2893
|
+
}
|
|
2894
|
+
|
|
2895
|
+
// Stateful HTTP MCP — a session connects directly over HTTP (URL + OAuth,
|
|
2896
|
+
// no stdio bridge): POST/GET/DELETE /mcp/<channel>. Externally this is
|
|
2897
|
+
// `<hub>/agent/mcp/<channel>`; hub's stripPrefix removes `/agent`, so the
|
|
2898
|
+
// daemon sees `/mcp/<channel>`. A session needs `agent:read` to connect +
|
|
2899
|
+
// receive the wake; the reply/react/edit tools additionally require
|
|
2900
|
+
// `agent:write`, enforced inside the tool handlers from the connection's
|
|
2901
|
+
// own scopes. This endpoint is ADDITIVE — the stdio bridge over /events is
|
|
2902
|
+
// unchanged.
|
|
2903
|
+
const mcpMatch = url.pathname.match(/^\/mcp\/([^/]+)$/);
|
|
2904
|
+
if (mcpMatch) {
|
|
2905
|
+
const channel = decodeURIComponent(mcpMatch[1]!);
|
|
2906
|
+
const transport = transportFor(channel);
|
|
2907
|
+
if (!transport) {
|
|
2908
|
+
return json(
|
|
2909
|
+
{
|
|
2910
|
+
error: `unknown channel "${channel}" — known channels: ${[...channels.keys()].join(", ") || "(none)"}`,
|
|
2911
|
+
},
|
|
2912
|
+
404,
|
|
2913
|
+
);
|
|
2914
|
+
}
|
|
2915
|
+
// Gate on agent:read — short-circuits to 401 pre-JWKS when no token is
|
|
2916
|
+
// presented (testable without a live hub, same as the other endpoints).
|
|
2917
|
+
// On a 401 (no/invalid bearer), decorate with the RFC 9728
|
|
2918
|
+
// `WWW-Authenticate` challenge so a Claude Code HTTP-MCP client knows
|
|
2919
|
+
// where to discover OAuth (mirrors vault's withMcpChallenge). The other
|
|
2920
|
+
// endpoints (/events, /api/*) stay plain 401 — only the /mcp path drives
|
|
2921
|
+
// a spec OAuth client, so only it carries the challenge.
|
|
2922
|
+
const denied = await requireScope(req, url, SCOPE_READ);
|
|
2923
|
+
if (denied) {
|
|
2924
|
+
if (denied.status === 401) {
|
|
2925
|
+
const headers = new Headers(denied.headers);
|
|
2926
|
+
headers.set("WWW-Authenticate", mcpWwwAuthenticate(req, channel));
|
|
2927
|
+
return new Response(await denied.text(), { status: 401, headers });
|
|
2928
|
+
}
|
|
2929
|
+
return denied;
|
|
2930
|
+
}
|
|
2931
|
+
// Re-validate to surface the caller's scopes for the write-tool checks.
|
|
2932
|
+
// (requireScope already proved the token valid + carrying agent:read;
|
|
2933
|
+
// this second pass hits the warm JWKS cache.) A token present but missing
|
|
2934
|
+
// here would have been rejected above, so claims must resolve.
|
|
2935
|
+
let scopes: string[] = [];
|
|
2936
|
+
try {
|
|
2937
|
+
const token = extractToken(req, url);
|
|
2938
|
+
if (token) scopes = (await validateHubJwt(token)).scopes;
|
|
2939
|
+
} catch {
|
|
2940
|
+
// Unreachable in practice (requireScope passed); fall back to read-only.
|
|
2941
|
+
scopes = [SCOPE_READ];
|
|
2942
|
+
}
|
|
2943
|
+
return handleMcp(req, channel, transport, scopes, attachedQueue);
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
// Give each transport a chance to handle a route the daemon didn't. Runs
|
|
2947
|
+
// after the daemon's own built-in routes and before the final 404. A
|
|
2948
|
+
// transport returns a Response if it owns the path, or null to pass.
|
|
2949
|
+
for (const ch of channels.values()) {
|
|
2950
|
+
const res = await ch.transport.ingestHttp?.(req, url);
|
|
2951
|
+
if (res) return res;
|
|
2952
|
+
}
|
|
2953
|
+
|
|
2954
|
+
return json({ error: "not found" }, 404);
|
|
2955
|
+
};
|
|
2956
|
+
}
|
|
2957
|
+
|
|
2958
|
+
// ---------------------------------------------------------------------------
|
|
2959
|
+
// Request helpers (module-scope; hoisted, referenced from inside the factory)
|
|
2960
|
+
// ---------------------------------------------------------------------------
|
|
2961
|
+
|
|
2962
|
+
/**
|
|
2963
|
+
* Map a thrown error to a response: ChannelConfigError → 400 (operator must fix
|
|
2964
|
+
* config), anything else → 500 (runtime fault). Lets callers distinguish the two.
|
|
2965
|
+
*/
|
|
2966
|
+
function errResponse(err: unknown): Response {
|
|
2967
|
+
if (err instanceof ChannelConfigError) return json({ error: err.message }, 400);
|
|
2968
|
+
return json({ error: String(err) }, 500);
|
|
2969
|
+
}
|
|
2970
|
+
|
|
2971
|
+
/**
|
|
2972
|
+
* Build the meta map for outbound calls. Telegram addressing historically came
|
|
2973
|
+
* in as a top-level `chat_id`; preserve that by folding it into `meta.chat_id`
|
|
2974
|
+
* while letting an explicit `meta` object take precedence/extend.
|
|
2975
|
+
*/
|
|
2976
|
+
function mergeMeta(body: { chat_id?: string; meta?: Record<string, string> }): Record<string, string> {
|
|
2977
|
+
const meta: Record<string, string> = { ...(body.meta ?? {}) };
|
|
2978
|
+
if (body.chat_id !== undefined && meta.chat_id === undefined) meta.chat_id = body.chat_id;
|
|
2979
|
+
return meta;
|
|
2980
|
+
}
|
|
2981
|
+
|
|
2982
|
+
function toReplyArgs(body: {
|
|
2983
|
+
channel?: string;
|
|
2984
|
+
chat_id?: string;
|
|
2985
|
+
text?: string;
|
|
2986
|
+
reply_to?: string;
|
|
2987
|
+
files?: string[];
|
|
2988
|
+
meta?: Record<string, string>;
|
|
2989
|
+
}): ReplyArgs {
|
|
2990
|
+
return {
|
|
2991
|
+
channel: body.channel!,
|
|
2992
|
+
text: body.text,
|
|
2993
|
+
files: body.files,
|
|
2994
|
+
reply_to: body.reply_to,
|
|
2995
|
+
meta: mergeMeta(body),
|
|
2996
|
+
};
|
|
2997
|
+
}
|
|
2998
|
+
|
|
2999
|
+
// ---------------------------------------------------------------------------
|
|
3000
|
+
// Boot — load the registry, bind Bun.serve, start every transport.
|
|
3001
|
+
//
|
|
3002
|
+
// Gated on `import.meta.main` so importing this module (e.g. from a test that
|
|
3003
|
+
// only wants `createFetchHandler` / `requireScope`) does NOT load the registry,
|
|
3004
|
+
// bind a port, or `process.exit` on a missing config.
|
|
3005
|
+
// ---------------------------------------------------------------------------
|
|
3006
|
+
|
|
3007
|
+
function main(): void {
|
|
3008
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
3009
|
+
mkdirSync(INBOX_DIR, { recursive: true });
|
|
3010
|
+
|
|
3011
|
+
// Verify the one MCP SDK internal our HTTP-MCP delivery accounting reads
|
|
3012
|
+
// (`_streamMapping['_GET_stream']`, see assertMcpSdkStreamContract). A screaming
|
|
3013
|
+
// boot error on SDK drift beats discovering it as silent message loss later.
|
|
3014
|
+
assertMcpSdkStreamContract();
|
|
3015
|
+
|
|
3016
|
+
let channels: Map<string, Channel>;
|
|
3017
|
+
try {
|
|
3018
|
+
channels = loadRegistry({ stateDir: STATE_DIR });
|
|
3019
|
+
} catch (err) {
|
|
3020
|
+
console.error(`parachute-agent: failed to load channel registry: ${err}`);
|
|
3021
|
+
process.exit(1);
|
|
3022
|
+
}
|
|
3023
|
+
|
|
3024
|
+
if (channels.size === 0) {
|
|
3025
|
+
// Zero channels is a valid STARTING state, not a fatal error. The daemon must
|
|
3026
|
+
// stay up and serve its HTTP surface so an operator can create the first agent
|
|
3027
|
+
// (the /agent/admin + create-agent UI POST to this very daemon — exiting here is
|
|
3028
|
+
// a chicken-and-egg: you couldn't define the first channel), and so future
|
|
3029
|
+
// vault-defined agents can appear into a running module. Channels added live
|
|
3030
|
+
// (via the API/UI, or hot-added) are picked up immediately. So: warn + idle.
|
|
3031
|
+
console.warn(
|
|
3032
|
+
`parachute-agent: no channels configured yet — starting idle.\n` +
|
|
3033
|
+
` Create an agent via the admin UI at /agent/app/ (or add ${join(STATE_DIR, "channels.json")}).\n` +
|
|
3034
|
+
` The daemon stays up; channels added live are picked up immediately.`,
|
|
3035
|
+
);
|
|
3036
|
+
}
|
|
3037
|
+
|
|
3038
|
+
const registry = new ClientRegistry();
|
|
3039
|
+
|
|
3040
|
+
// Per-channel delivery high-water-mark store, constructed ONCE at boot with the
|
|
3041
|
+
// daemon's boot time as the default mark — so a channel with no persisted mark
|
|
3042
|
+
// replays only messages that arrive AFTER this start (the deaf-window case),
|
|
3043
|
+
// never its whole vault history. Persisted marks (from a prior run) survive the
|
|
3044
|
+
// restart and replay exactly the gap. Shared by `contextFor.emit` (advance) and
|
|
3045
|
+
// both connect-hook replays (MCP session + SSE bridge).
|
|
3046
|
+
const deliveryState = new DeliveryState({
|
|
3047
|
+
stateDir: STATE_DIR,
|
|
3048
|
+
defaultMark: new Date().toISOString(),
|
|
3049
|
+
});
|
|
3050
|
+
|
|
3051
|
+
// The per-channel turn-event SSE registry (the streaming view, design build item
|
|
3052
|
+
// #1), constructed ONCE at boot and shared by the fetch handler's
|
|
3053
|
+
// `/api/channels/<ch>/turn-events` route (subscriber registration) and the
|
|
3054
|
+
// programmatic registry's turn-event sink (live-progress fan-out) — so a turn's
|
|
3055
|
+
// interim events reach the chat subscribers the route registered.
|
|
3056
|
+
const turnEvents = new ClientRegistry();
|
|
3057
|
+
|
|
3058
|
+
// The PROGRAMMATIC-agent registry (design 2026-06-16), constructed ONCE at boot
|
|
3059
|
+
// and shared by the fetch handler (the /api/agents + /health routes), the
|
|
3060
|
+
// transports' `contextFor` (inbound enqueue), and the boot re-register below — so
|
|
3061
|
+
// the SAME instance the routes operate on is the one inbound enqueues onto. Built
|
|
3062
|
+
// here (not lazily in createFetchHandler) precisely so the transports started
|
|
3063
|
+
// below route inbound to it. Threaded with the turn-event sink so each turn streams
|
|
3064
|
+
// its interim progress to `turnEvents` (the chat's live view).
|
|
3065
|
+
const programmatic = createDefaultProgrammaticRegistry(channels, buildTurnEventSink(turnEvents));
|
|
3066
|
+
|
|
3067
|
+
// The ATTACHED-backend queue registry (design 2026-06-18-channel-backend.md),
|
|
3068
|
+
// constructed ONCE at boot and shared by the fetch handler (the channel MCP surface),
|
|
3069
|
+
// the transports' `contextFor` (the routing fork — an attached inbound is NOT enqueued
|
|
3070
|
+
// to the programmatic worker), the agent-def instantiate path (a `backend:attached`
|
|
3071
|
+
// def registers here, not with programmatic), and the periodic sweep below. The
|
|
3072
|
+
// durable queue + claim state lives on the inbound notes in each channel's vault, so
|
|
3073
|
+
// this registry holds no per-message state of its own — it's the claim/peek/reply
|
|
3074
|
+
// surface over those notes.
|
|
3075
|
+
const attachedQueue = new AttachedQueueRegistry();
|
|
3076
|
+
|
|
3077
|
+
// The terminal WS handler set (pty↔socket relay + backpressure flow control,
|
|
3078
|
+
// src/terminal.ts). One handler object serves every terminal connection;
|
|
3079
|
+
// per-connection state lives on `ws.data`. The fetch handler routes accepted
|
|
3080
|
+
// upgrades into these via `server.upgrade(req, { data })`.
|
|
3081
|
+
const terminalWs = createTerminalWsHandlers();
|
|
3082
|
+
|
|
3083
|
+
// The vault-native scheduled-job store + the runner (design 2026-06-17). The
|
|
3084
|
+
// store reads/writes `#agent/job` notes in each vault channel's vault; the
|
|
3085
|
+
// runner ticks every 30s, loading jobs from the store, firing due ones by
|
|
3086
|
+
// injecting an inbound note onto the job's vault channel (the existing trigger →
|
|
3087
|
+
// agent-turn → outbound flow does the rest). Shared with the fetch handler so
|
|
3088
|
+
// the /api/jobs routes + the scheduler operate on the SAME store, and "Run now"
|
|
3089
|
+
// goes through the runner's bookkeeping path.
|
|
3090
|
+
const jobStore = new VaultJobStore(channels);
|
|
3091
|
+
const runner = new Runner({
|
|
3092
|
+
loadJobs: () => jobStore.listAll(),
|
|
3093
|
+
// Fire = inject an inbound note onto the job's vault channel, exactly like a
|
|
3094
|
+
// human typing in chat. Resolve the channel's vault transport at fire time so
|
|
3095
|
+
// a job whose channel was deleted logs + records an error rather than throwing
|
|
3096
|
+
// the tick. No new authority — uses the channel's existing vault write token.
|
|
3097
|
+
fire: async (job) => {
|
|
3098
|
+
const transport = vaultTransportFor(channels, job.channel);
|
|
3099
|
+
if (!transport) {
|
|
3100
|
+
throw new Error(`channel "${job.channel}" is not a live vault channel`);
|
|
3101
|
+
}
|
|
3102
|
+
await transport.injectInbound({ content: job.message, sender: `runner:${job.id}` });
|
|
3103
|
+
},
|
|
3104
|
+
// Persist bookkeeping (lastRunAt/lastStatus) back onto the job note (addressed
|
|
3105
|
+
// by its vault note id). A job loaded from the store always carries `noteId`.
|
|
3106
|
+
persistFire: async (job) => {
|
|
3107
|
+
if (!job.noteId) return; // nothing to address (shouldn't happen for a loaded job).
|
|
3108
|
+
await jobStore.patch(job.noteId, job.channel, {
|
|
3109
|
+
lastRunAt: job.lastRunAt,
|
|
3110
|
+
lastStatus: job.lastStatus,
|
|
3111
|
+
});
|
|
3112
|
+
},
|
|
3113
|
+
driver: realTickDriver(),
|
|
3114
|
+
});
|
|
3115
|
+
|
|
3116
|
+
// The vault-native agent-def registry (design 2026-06-17-vault-native-agents,
|
|
3117
|
+
// Phase 4a). Reads `#agent/definition` notes from the configured def-vaults and
|
|
3118
|
+
// instantiates each as a live agent (a vault channel + a programmatic agent) via
|
|
3119
|
+
// the SAME machinery the create-agent flow uses (buildInstantiateDeps). Constructed
|
|
3120
|
+
// here (empty) so it's shared with the fetch handler's reload webhook; the boot
|
|
3121
|
+
// resolve below (resolveDefVaults → addVault → loadAll) fills it. ADDITIVE to
|
|
3122
|
+
// channels.json — both paths coexist.
|
|
3123
|
+
const agentDefs = new AgentDefRegistry(
|
|
3124
|
+
buildInstantiateDeps(channels, registry, deliveryState, programmatic, attachedQueue),
|
|
3125
|
+
);
|
|
3126
|
+
|
|
3127
|
+
const fetchHandler = createFetchHandler(channels, registry, { deliveryState, programmatic, attachedQueue, turnEvents, jobStore, runner, agentDefs });
|
|
3128
|
+
const server = Bun.serve<TerminalWsData, never>({
|
|
3129
|
+
port: PORT,
|
|
3130
|
+
hostname: "127.0.0.1",
|
|
3131
|
+
idleTimeout: 0,
|
|
3132
|
+
// `fetch` receives `server` as its 2nd arg at runtime — needed for
|
|
3133
|
+
// `server.upgrade()` on the terminal WS route.
|
|
3134
|
+
fetch: (req, srv) => fetchHandler(req, srv),
|
|
3135
|
+
websocket: terminalWs,
|
|
3136
|
+
});
|
|
3137
|
+
|
|
3138
|
+
console.log(`parachute-agent: daemon listening on http://127.0.0.1:${PORT}`);
|
|
3139
|
+
console.log(`parachute-agent: state dir: ${STATE_DIR}`);
|
|
3140
|
+
console.log(
|
|
3141
|
+
`parachute-agent: ${channels.size} channel(s): ${[...channels.values()]
|
|
3142
|
+
.map((c) => `${c.name}→${c.transport.kind}`)
|
|
3143
|
+
.join(", ")}`,
|
|
3144
|
+
);
|
|
3145
|
+
|
|
3146
|
+
// Self-register into ~/.parachute/services.json so hub lists this module in the
|
|
3147
|
+
// portal and reverse-proxies `<expose>/agent/*` → this loopback daemon.
|
|
3148
|
+
// Best-effort: a failure must not stop the daemon from serving locally. Honors
|
|
3149
|
+
// PARACHUTE_HOME, so sandboxed/e2e daemons never touch the real services.json.
|
|
3150
|
+
try {
|
|
3151
|
+
upsertService({
|
|
3152
|
+
name: "parachute-agent",
|
|
3153
|
+
port: PORT,
|
|
3154
|
+
paths: ["/agent"],
|
|
3155
|
+
health: "/health",
|
|
3156
|
+
version: PKG_VERSION,
|
|
3157
|
+
displayName: "Agent",
|
|
3158
|
+
tagline: "Chat with your Claude Code sessions — a channel per session.",
|
|
3159
|
+
installDir: INSTALL_DIR,
|
|
3160
|
+
// The command the hub supervisor spawns to start/restart/adopt us. Without
|
|
3161
|
+
// this the supervisor knows our port but not how to launch the process, so
|
|
3162
|
+
// `parachute restart agent` 404s and we don't survive reboot (agent#34).
|
|
3163
|
+
startCmd: START_CMD,
|
|
3164
|
+
stripPrefix: true,
|
|
3165
|
+
uiUrl: "/agent/app/", // portal "Open UI" link → the SPA (canonical in module.json, which hub prefers; written here only as a services.json fallback hint)
|
|
3166
|
+
configUiUrl: "/agent/app/", // module-owned config surface (modular-UI P4); hub frames/links it. Canonical in module.json (hub prefers it); this is a services.json fallback hint.
|
|
3167
|
+
// WebSocket support — tells the hub's Bun-native upgrade bridge to forward
|
|
3168
|
+
// `Upgrade: websocket` requests on `/agent/*` to this daemon (the
|
|
3169
|
+
// in-page terminal, design §5.1). DENY-BY-DEFAULT in the hub: without this
|
|
3170
|
+
// the upgrade is refused (426) before it ever reaches us. Declared on
|
|
3171
|
+
// module.json too (the install-time contract); the hub honors either
|
|
3172
|
+
// source. No hub change needed — the hub already reads this field.
|
|
3173
|
+
websocket: true,
|
|
3174
|
+
// The terminal mount, declared as a `uis` sub-unit with audience "surface"
|
|
3175
|
+
// so the hub's audience gate PASSES IT THROUGH (the agent daemon owns
|
|
3176
|
+
// admission end-to-end — operator-grade agent:admin, enforced here). A
|
|
3177
|
+
// `surface` audience is the same pass-through the no-uis-match default
|
|
3178
|
+
// gives, but declaring it explicitly future-proofs against a later `uis`
|
|
3179
|
+
// declaration accidentally gating the terminal at hub-users. Design §5.3.
|
|
3180
|
+
uis: {
|
|
3181
|
+
// The web spawn/list/kill surface — the DEFAULT way to operate (spawn an
|
|
3182
|
+
// agent, scope it, watch it). audience "surface" so the hub passes it
|
|
3183
|
+
// through; agent owns admission end-to-end (operator-grade agent:admin,
|
|
3184
|
+
// enforced on every /api/agents call). Design §4/§5.
|
|
3185
|
+
agents: {
|
|
3186
|
+
displayName: "Agents",
|
|
3187
|
+
tagline: "Spawn, scope, and watch sandboxed Claude Code sessions.",
|
|
3188
|
+
path: "/agent/agents",
|
|
3189
|
+
audience: "surface",
|
|
3190
|
+
},
|
|
3191
|
+
terminal: {
|
|
3192
|
+
displayName: "Terminal",
|
|
3193
|
+
tagline: "Attach to a session's live tmux pane in the browser.",
|
|
3194
|
+
path: "/agent/terminal",
|
|
3195
|
+
audience: "surface",
|
|
3196
|
+
},
|
|
3197
|
+
},
|
|
3198
|
+
});
|
|
3199
|
+
console.log(`parachute-agent: self-registered into services.json (port ${PORT}, mount /agent)`);
|
|
3200
|
+
} catch (err) {
|
|
3201
|
+
console.error(`parachute-agent: services.json self-registration failed (continuing): ${err}`);
|
|
3202
|
+
}
|
|
3203
|
+
|
|
3204
|
+
// Start each channel via the same single-channel add path the config API uses
|
|
3205
|
+
// (`addChannelLive`), so boot and hot-add can't drift. The map already holds
|
|
3206
|
+
// the channels (from `loadRegistry`); addChannelLive replaces-in-place, which
|
|
3207
|
+
// for a freshly-instantiated boot transport means stop()→re-instantiate→start.
|
|
3208
|
+
// Per-channel failures are logged and don't abort the others; the daemon must
|
|
3209
|
+
// still serve the channels that did come up. Pass the programmatic registry so a
|
|
3210
|
+
// channel with a registered programmatic agent routes inbound to its serial queue.
|
|
3211
|
+
for (const channel of [...channels.values()]) {
|
|
3212
|
+
addChannelLive(channels, registry, channel.entry, deliveryState, programmatic, attachedQueue).catch((err) => {
|
|
3213
|
+
console.error(`parachute-agent: transport "${channel.name}" start failed:`, err);
|
|
3214
|
+
});
|
|
3215
|
+
}
|
|
3216
|
+
|
|
3217
|
+
// BOOT RE-REGISTER (design 2026-06-16 step 2). A programmatic agent has NO
|
|
3218
|
+
// resident process, so it doesn't survive a daemon restart as a tmux session
|
|
3219
|
+
// would — but its spec.json (carrying `backend: "programmatic"`) persists. Scan
|
|
3220
|
+
// the per-session workspaces and re-register every programmatic spec so inbound
|
|
3221
|
+
// for its channel resumes routing to an on-demand turn (the session UUID on the
|
|
3222
|
+
// `#agent/thread` note makes the next turn `--resume` the prior conversation — no
|
|
3223
|
+
// deaf problem). Best-
|
|
3224
|
+
// effort: a single bad spec is logged and skipped. The live `channels` map gates
|
|
3225
|
+
// it: only a spec whose wake channel is a configured channel is re-registered, so
|
|
3226
|
+
// a leaked/orphaned spec dir can't resurrect a phantom agent (agent#75).
|
|
3227
|
+
void reregisterProgrammaticAgents(programmatic, channels);
|
|
3228
|
+
|
|
3229
|
+
// Start the runner's scheduled-job tick (design 2026-06-17). Tolerant of an
|
|
3230
|
+
// empty/missing job set (no `#agent/job` notes → idle) and of a daemon with no
|
|
3231
|
+
// vault channels (listAll queries nothing → idle). A job targeting a now-deleted
|
|
3232
|
+
// channel sets lastStatus:error on fire rather than throwing the tick. The tick
|
|
3233
|
+
// is `unref`'d so it never keeps the process alive on its own.
|
|
3234
|
+
runner.start();
|
|
3235
|
+
console.log(`parachute-agent: runner started (scheduled-job tick)`);
|
|
3236
|
+
|
|
3237
|
+
// ATTACHED-BACKEND CLAIM TTL SWEEP (design 2026-06-18-channel-backend.md). A periodic
|
|
3238
|
+
// tick scans every attached-backend agent's in-flight inbound notes and resets any
|
|
3239
|
+
// claimed longer than the claim TTL (15 min) back to `pending` — so a crashed /
|
|
3240
|
+
// abandoned connected session can't strand the queue. Cheap + idempotent (a
|
|
3241
|
+
// channel with no attached agents lists nothing). `unref` so it never holds the
|
|
3242
|
+
// process open; runs at the same 30s cadence as the runner tick.
|
|
3243
|
+
const sweepIntervalMs = parseInt(process.env.PARACHUTE_AGENT_SWEEP_MS ?? "", 10) || 30_000;
|
|
3244
|
+
const channelSweep = setInterval(() => {
|
|
3245
|
+
void attachedQueue.sweepExpired().catch((err) => {
|
|
3246
|
+
console.error(`parachute-agent: attached-queue sweep failed (continuing): ${(err as Error).message}`);
|
|
3247
|
+
});
|
|
3248
|
+
}, sweepIntervalMs);
|
|
3249
|
+
channelSweep.unref?.();
|
|
3250
|
+
|
|
3251
|
+
// VAULT-NATIVE AGENT DEFINITIONS (design 2026-06-17-vault-native-agents, Phase 4a).
|
|
3252
|
+
// Resolve the def-vault bindings (agent-vaults.json, or the minted single-`default`
|
|
3253
|
+
// default), add each to the registry, and instantiate every `#agent/definition`
|
|
3254
|
+
// note in them — each becomes a live agent (a vault channel + a programmatic agent).
|
|
3255
|
+
// Fire-and-forget so a slow/unreachable vault never blocks the daemon from serving;
|
|
3256
|
+
// the reload webhook (POST /api/vault/agent-def) keeps them in sync reactively, and
|
|
3257
|
+
// a poll fallback re-syncs vaults without trigger support. Best-effort throughout —
|
|
3258
|
+
// a def-vault failure is logged and never affects channels.json-defined channels.
|
|
3259
|
+
let agentDefPoll: ReturnType<typeof setInterval> | undefined;
|
|
3260
|
+
void (async () => {
|
|
3261
|
+
let managerBearer: string | null = null;
|
|
3262
|
+
try {
|
|
3263
|
+
managerBearer = resolveSpawnDeps().managerBearer;
|
|
3264
|
+
} catch {
|
|
3265
|
+
// No operator token yet — resolveDefVaults handles the null (idle vault-native
|
|
3266
|
+
// path; channels.json unaffected).
|
|
3267
|
+
}
|
|
3268
|
+
// 4b: wire the hub grants client now the manager bearer is resolved (the registry
|
|
3269
|
+
// was constructed before the operator token was read). With it, each def's `wants:`
|
|
3270
|
+
// connections register as pending grants on instantiate + status derives from the
|
|
3271
|
+
// hub's grant statuses. No bearer → null → the registry falls back to the pure
|
|
3272
|
+
// status (pending if anything is declared) and the vault-native path still runs
|
|
3273
|
+
// own-vault. design 2026-06-17-agent-connectors-4b.md.
|
|
3274
|
+
if (managerBearer) {
|
|
3275
|
+
agentDefs.setGrantsClient(new GrantsClient({ hubOrigin: getHubOrigin(), managerBearer }));
|
|
3276
|
+
}
|
|
3277
|
+
const bindings = await resolveDefVaults({ hubOrigin: getHubOrigin(), managerBearer });
|
|
3278
|
+
for (const b of bindings) agentDefs.addVault(b);
|
|
3279
|
+
if (bindings.length === 0) return; // nothing bound — vault-native path idle.
|
|
3280
|
+
const n = await agentDefs.loadAll();
|
|
3281
|
+
console.log(
|
|
3282
|
+
`parachute-agent: vault-native agent defs — ${n} instantiated from ${bindings.length} def-vault(s).`,
|
|
3283
|
+
);
|
|
3284
|
+
// Poll fallback (every 60s): re-load all defs so a created/updated/deleted note
|
|
3285
|
+
// converges even with no webhook. The created/updated reload webhook is the fast path;
|
|
3286
|
+
// this is the safety net — AND the ONLY automatic path for a DELETE (there is no vault
|
|
3287
|
+
// `deleted` trigger, so a def removed out-of-band converges only here; loadAll's
|
|
3288
|
+
// removed-def diff deregisters the orphaned agent). `unref` so it never holds the
|
|
3289
|
+
// process open. Cheap + idempotent (re-instantiate replaces in place).
|
|
3290
|
+
const interval = parseInt(process.env.PARACHUTE_AGENT_DEF_POLL_MS ?? "", 10) || 60_000;
|
|
3291
|
+
agentDefPoll = setInterval(() => {
|
|
3292
|
+
void agentDefs.loadAll().catch((err) => {
|
|
3293
|
+
console.error(`parachute-agent: agent-def poll failed (continuing): ${(err as Error).message}`);
|
|
3294
|
+
});
|
|
3295
|
+
}, interval);
|
|
3296
|
+
agentDefPoll.unref?.();
|
|
3297
|
+
})().catch((err) => {
|
|
3298
|
+
console.error(`parachute-agent: vault-native agent-def boot failed (continuing): ${(err as Error).message}`);
|
|
3299
|
+
});
|
|
3300
|
+
|
|
3301
|
+
// Graceful shutdown — stop the runner + all transports.
|
|
3302
|
+
async function shutdown(): Promise<void> {
|
|
3303
|
+
runner.stop();
|
|
3304
|
+
clearInterval(channelSweep);
|
|
3305
|
+
if (agentDefPoll) clearInterval(agentDefPoll);
|
|
3306
|
+
await Promise.allSettled([...channels.values()].map((c) => c.transport.stop()));
|
|
3307
|
+
server.stop();
|
|
3308
|
+
process.exit(0);
|
|
3309
|
+
}
|
|
3310
|
+
process.on("SIGINT", shutdown);
|
|
3311
|
+
process.on("SIGTERM", shutdown);
|
|
3312
|
+
}
|
|
3313
|
+
|
|
3314
|
+
if (import.meta.main) main();
|