@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
|
@@ -0,0 +1,927 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The PROGRAMMATIC agent backend (design 2026-06-16-pluggable-agent-backend.md).
|
|
3
|
+
*
|
|
4
|
+
* Drives a channel agent by running ONE sandboxed `claude -p` turn per inbound
|
|
5
|
+
* message and capturing the reply — NO idle interactive session, so the whole
|
|
6
|
+
* deaf-on-restart fragility class (no-loss replay #67, per-session restart #68,
|
|
7
|
+
* dev-channels consent gate #70/#71) simply does not exist here. "Wake" is "run the
|
|
8
|
+
* next turn."
|
|
9
|
+
*
|
|
10
|
+
* ── The verified mechanic (spike against claude 2.1.179) ────────────────────────
|
|
11
|
+
* claude -p "<message>" \
|
|
12
|
+
* --output-format stream-json --verbose \
|
|
13
|
+
* --strict-mcp-config --mcp-config <path> \
|
|
14
|
+
* --dangerously-skip-permissions \
|
|
15
|
+
* [--session-id <uuid> | --resume <uuid>]
|
|
16
|
+
*
|
|
17
|
+
* - Runs on the SUBSCRIPTION (`apiKeySource: "none"` in the init event; the
|
|
18
|
+
* rate_limit_event shows the `five_hour` subscription pool) — NOT metered API,
|
|
19
|
+
* as long as no `ANTHROPIC_API_KEY`/`CLAUDE_API_KEY` is in the env. The
|
|
20
|
+
* `total_cost_usd` in the result is an equivalent-cost figure, not a charge.
|
|
21
|
+
* - The DAEMON owns the session uuid (it lives on the `#agent/thread` note's
|
|
22
|
+
* `metadata.session`), NOT a backend-private store. The caller resolves the turn's
|
|
23
|
+
* {@link TurnSession} and hands it in: `--session-id <uuid>` CREATES a session with
|
|
24
|
+
* that uuid (first turn) and `--resume <uuid>` CONTINUES it (subsequent turns) —
|
|
25
|
+
* both restore/establish full conversation continuity. The captured id still comes
|
|
26
|
+
* back on the result so the caller (the registry) can persist it onto the note.
|
|
27
|
+
* - `-p` has NO TUI → no consent gates at all (this backend avoids the #70/#71
|
|
28
|
+
* class by construction). Hence NO `--dangerously-load-development-channels`.
|
|
29
|
+
*
|
|
30
|
+
* ── What's deliberately ABSENT vs the interactive spawn ─────────────────────────
|
|
31
|
+
* - NO channel MCP entry. The daemon mediates messaging in this backend: it
|
|
32
|
+
* hands the agent the inbound text as the `-p` prompt, and turns the returned
|
|
33
|
+
* reply into an outbound `#agent/message/outbound` note itself (the wiring
|
|
34
|
+
* follow-up). The agent's `.mcp.json` carries the VAULT MCP only — so the agent
|
|
35
|
+
* has memory + tools, but inbound/outbound is the daemon's job, not the agent's.
|
|
36
|
+
* - NO `--dangerously-load-development-channels`, NO consent-gate auto-confirm.
|
|
37
|
+
*
|
|
38
|
+
* ── What's REUSED (not reinvented) ──────────────────────────────────────────────
|
|
39
|
+
* - `buildAgentChildEnv` — env scrub + `CLAUDE_CODE_OAUTH_TOKEN` inject + the #68
|
|
40
|
+
* per-channel env injection + the ANTHROPIC_API_KEY/CLAUDE_API_KEY denylist.
|
|
41
|
+
* - `resolveClaudeCredential` + `resolveChannelEnv` (credentials.ts) — the
|
|
42
|
+
* per-channel secret/env stores.
|
|
43
|
+
* - `seedAgentHome` — the per-session writable HOME/config/tmp (stability keystone).
|
|
44
|
+
* - `wrapArgvInSandbox` (spawn-agent.ts) — the SHARED sandbox seam: same egress
|
|
45
|
+
* floor + scoped-read confinement the interactive spawn gets.
|
|
46
|
+
* - `mintScopedToken` + `buildAgentMcpConfigJson` — the vault token mint + the
|
|
47
|
+
* inline MCP config writer.
|
|
48
|
+
*
|
|
49
|
+
* ── Single turn, serial per channel ─────────────────────────────────────────────
|
|
50
|
+
* {@link ProgrammaticBackend.deliver} runs ONE turn. The DAEMON (wiring follow-up)
|
|
51
|
+
* owns per-channel SERIAL processing — never two concurrent `claude -p` for the
|
|
52
|
+
* same channel/session, which would FORK the conversation. This backend does not
|
|
53
|
+
* itself enforce that ordering; it records the latest session id and runs the turn
|
|
54
|
+
* it is handed.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
58
|
+
import { join } from "node:path";
|
|
59
|
+
import type { AgentSpec } from "../sandbox/types.ts";
|
|
60
|
+
import type { InboundAttachment } from "../transport.ts";
|
|
61
|
+
import { normalizeChannel } from "../sandbox/types.ts";
|
|
62
|
+
import type { SandboxEngine } from "../sandbox/index.ts";
|
|
63
|
+
import {
|
|
64
|
+
buildAgentChildEnv,
|
|
65
|
+
mergeSandboxLaunchEnv,
|
|
66
|
+
resolveAgentCwd,
|
|
67
|
+
seedAgentHome,
|
|
68
|
+
sessionWorkspace,
|
|
69
|
+
shellJoin,
|
|
70
|
+
wrapArgvInSandbox,
|
|
71
|
+
} from "../spawn-agent.ts";
|
|
72
|
+
import {
|
|
73
|
+
mintScopedToken,
|
|
74
|
+
vaultScope,
|
|
75
|
+
type MintTokenDeps,
|
|
76
|
+
} from "../mint-token.ts";
|
|
77
|
+
import { buildAgentMcpConfigJson, vaultEntryKey } from "../agent-mcp-config.ts";
|
|
78
|
+
import { resolveClaudeCredential, resolveChannelEnv } from "../credentials.ts";
|
|
79
|
+
import { resolveInjectedGrants, type GrantsClient } from "../grants.ts";
|
|
80
|
+
import { parseStreamJsonStream } from "./stream-json.ts";
|
|
81
|
+
import type {
|
|
82
|
+
AgentBackend,
|
|
83
|
+
AgentHandle,
|
|
84
|
+
AgentStatus,
|
|
85
|
+
DeliverResult,
|
|
86
|
+
DeliverUsage,
|
|
87
|
+
InterimSink,
|
|
88
|
+
TurnSession,
|
|
89
|
+
} from "./types.ts";
|
|
90
|
+
|
|
91
|
+
/** Same slug shape `spawnAgent` enforces — a name lands in a path segment. */
|
|
92
|
+
const AGENT_NAME_SLUG = /^[a-z0-9_-]+$/i;
|
|
93
|
+
|
|
94
|
+
export const PROGRAMMATIC_BACKEND_KIND = "programmatic" as const;
|
|
95
|
+
|
|
96
|
+
/** The staging subdir (under the PRIVATE session workspace) inbound files are written into. */
|
|
97
|
+
export const ATTACHMENT_STAGING_DIR = "attachments" as const;
|
|
98
|
+
/**
|
|
99
|
+
* Per-attachment byte ceiling for staging. Matches the vault's own 100MB upload cap
|
|
100
|
+
* (parachute-vault `/api/storage` POST), so we never refuse a file the vault accepted —
|
|
101
|
+
* but caps a runaway/over-large blob from filling the workspace.
|
|
102
|
+
*/
|
|
103
|
+
export const ATTACHMENT_MAX_BYTES = 100 * 1024 * 1024;
|
|
104
|
+
/** Max number of attachments staged per turn — a sane bound on fan-out. */
|
|
105
|
+
export const ATTACHMENT_MAX_COUNT = 20;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Sanitize a (possibly untrusted, possibly path-ful) attachment filename/path to a SAFE
|
|
109
|
+
* BASENAME for staging — NO path traversal, NO directory components. `path`/`filename`
|
|
110
|
+
* come from VAULT DATA (not the operator), so this is the security boundary: we take the
|
|
111
|
+
* LAST path segment, drop any `..`/empty segments, strip NUL + leading dots, and replace
|
|
112
|
+
* every character outside `[A-Za-z0-9._-]` with `_`. The result can ONLY name a file
|
|
113
|
+
* DIRECTLY inside the staging dir — never escape it. Returns `"file"` for a degenerate
|
|
114
|
+
* input so a write target always exists. The caller additionally verifies the joined
|
|
115
|
+
* path stays under the staging dir (defense in depth).
|
|
116
|
+
*/
|
|
117
|
+
export function safeAttachmentBasename(name: string): string {
|
|
118
|
+
// Take the final segment across both slash flavors; this alone defeats `../../etc/x`
|
|
119
|
+
// (every `..` and the leading dirs are discarded — only the trailing segment survives).
|
|
120
|
+
const segments = name.split(/[/\\]+/);
|
|
121
|
+
let base = segments.length > 0 ? segments[segments.length - 1]! : "";
|
|
122
|
+
// Strip NUL bytes + control chars, collapse disallowed chars to `_`.
|
|
123
|
+
base = base.replace(/\0/g, "").replace(/[^A-Za-z0-9._-]/g, "_");
|
|
124
|
+
// No leading dots (no `.`, `..`, or hidden-file surprises).
|
|
125
|
+
base = base.replace(/^\.+/, "");
|
|
126
|
+
if (base.length === 0 || base === "." || base === "..") return "file";
|
|
127
|
+
// Bound the length so a pathological name can't blow up the path.
|
|
128
|
+
return base.slice(0, 200);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The minimal subprocess shape the runner awaits — a slice of `Bun.spawn`'s return
|
|
133
|
+
* (stdout/stderr streams + `exited`). Tests inject a fake that emits canned
|
|
134
|
+
* stream-json so no real `claude` is ever spawned.
|
|
135
|
+
*/
|
|
136
|
+
export interface SpawnedProc {
|
|
137
|
+
stdout: ReadableStream<Uint8Array> | null;
|
|
138
|
+
stderr: ReadableStream<Uint8Array> | null;
|
|
139
|
+
exited: Promise<number>;
|
|
140
|
+
}
|
|
141
|
+
export type ProgrammaticSpawnFn = (
|
|
142
|
+
argv: string[],
|
|
143
|
+
opts: { env: Record<string, string | undefined>; cwd: string },
|
|
144
|
+
) => SpawnedProc;
|
|
145
|
+
|
|
146
|
+
/** Wiring the programmatic backend resolves its launch side-effects from. */
|
|
147
|
+
export interface ProgrammaticBackendDeps {
|
|
148
|
+
/** Hub origin + manager bearer for minting the vault token (§4.3). */
|
|
149
|
+
hubOrigin: string;
|
|
150
|
+
managerBearer: string;
|
|
151
|
+
/** Vault base URL (if the spec binds a vault). Defaults to hubOrigin. */
|
|
152
|
+
vaultUrl?: string;
|
|
153
|
+
/** Base for session workspaces (e.g. `~/.parachute/agent/sessions`). */
|
|
154
|
+
sessionsDir: string;
|
|
155
|
+
/** Read-only runtime/config binds the sandbox always grants (the claude config dir, …). */
|
|
156
|
+
runtimeReadOnly: string[];
|
|
157
|
+
/** Resolve the Claude OAuth token (channel override ?? default ?? throw). Stub in tests. */
|
|
158
|
+
resolveClaudeToken?: (channel: string) => string;
|
|
159
|
+
/** Resolve the per-channel env injection (GH_TOKEN, CLOUDFLARE_API_TOKEN, …). Stub in tests. */
|
|
160
|
+
resolveChannelEnv?: (channel: string) => Record<string, string>;
|
|
161
|
+
/** Sandbox engine override (tests inject a fake). */
|
|
162
|
+
sandboxEngine?: SandboxEngine;
|
|
163
|
+
/** fetch override for the mint client (tests). */
|
|
164
|
+
fetchFn?: typeof fetch;
|
|
165
|
+
/**
|
|
166
|
+
* The hub grants client (4b — design 2026-06-17-agent-connectors-4b.md). When
|
|
167
|
+
* wired, each turn fetches the agent's APPROVED cross-resource grants FRESH and
|
|
168
|
+
* injects their material: granted-vault material → an extra MCP server in the
|
|
169
|
+
* agent's `--mcp-config`; granted-service material → an env var (e.g. GITHUB_TOKEN)
|
|
170
|
+
* and/or the service's MCP server. Fetched per-turn (never cached) so a revocation
|
|
171
|
+
* takes effect on the NEXT spawn. Optional: null/absent → no cross-resource grants
|
|
172
|
+
* (own-vault only, today's behavior). A grants-list failure is logged + the turn
|
|
173
|
+
* runs WITHOUT the extra grants (own-vault still works).
|
|
174
|
+
*/
|
|
175
|
+
grants?: GrantsClient | null;
|
|
176
|
+
/**
|
|
177
|
+
* The agent NAME used to key the agent's grants on the hub (`GET
|
|
178
|
+
* /admin/grants?agent=<name>`). Defaults to `spec.name` when absent. The grants are
|
|
179
|
+
* keyed by the agent name (= the def's name), which equals `spec.name` for a
|
|
180
|
+
* vault-native agent. Threaded explicitly so a future channel/agent-name split
|
|
181
|
+
* doesn't silently fetch the wrong agent's grants.
|
|
182
|
+
*/
|
|
183
|
+
grantsAgentName?: string;
|
|
184
|
+
/**
|
|
185
|
+
* The subprocess spawner — runs the sandbox-wrapped `claude -p`. Tests inject a
|
|
186
|
+
* fake that emits canned stream-json; the daemon uses the real Bun.spawn adapter.
|
|
187
|
+
*/
|
|
188
|
+
spawnFn: ProgrammaticSpawnFn;
|
|
189
|
+
/** Parent env to scrub from. Defaults to process.env. */
|
|
190
|
+
parentEnv?: Record<string, string | undefined>;
|
|
191
|
+
/** claude binary. Defaults to "claude". */
|
|
192
|
+
claudeBin?: string;
|
|
193
|
+
/** Optional ripgrep override threaded to the sandbox (macOS deny-path scan). */
|
|
194
|
+
ripgrep?: { command: string; args?: string[] };
|
|
195
|
+
/**
|
|
196
|
+
* Sleep used by the turn-level transient-retry backoff. Injected so tests don't
|
|
197
|
+
* actually wait the backoff. Defaults to a real `setTimeout`-backed sleep.
|
|
198
|
+
*/
|
|
199
|
+
sleepFn?: (ms: number) => Promise<void>;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Turn-level retry on TRANSIENT upstream errors (API 529/overload, 5xx, rate-limit,
|
|
204
|
+
* network). A 529 with no retry is exactly the "silent no-reply" a user hits under
|
|
205
|
+
* load; incremental backoff turns most of those into a delivered reply. The detector
|
|
206
|
+
* ({@link isTransientTurnError}) is conservative — a 4xx (auth/validation), a missing
|
|
207
|
+
* credential, or a deterministic subtype failure is NOT retried (it'd only burn time).
|
|
208
|
+
*/
|
|
209
|
+
export const TURN_MAX_ATTEMPTS = 3;
|
|
210
|
+
/** Incremental backoff before each retry (ms); length = TURN_MAX_ATTEMPTS - 1. */
|
|
211
|
+
export const TURN_RETRY_BACKOFF_MS: readonly number[] = [2_000, 5_000];
|
|
212
|
+
|
|
213
|
+
/** Does this turn-failure reason look like a transient upstream error worth retrying? */
|
|
214
|
+
export function isTransientTurnError(reason: string): boolean {
|
|
215
|
+
const r = reason.toLowerCase();
|
|
216
|
+
return (
|
|
217
|
+
/\b(429|500|502|503|504|529)\b/.test(reason) ||
|
|
218
|
+
r.includes("overloaded") ||
|
|
219
|
+
r.includes("rate limit") ||
|
|
220
|
+
r.includes("rate_limit") ||
|
|
221
|
+
r.includes("service unavailable") ||
|
|
222
|
+
r.includes("bad gateway") ||
|
|
223
|
+
r.includes("gateway time") ||
|
|
224
|
+
r.includes("internal server error") ||
|
|
225
|
+
r.includes("temporarily") ||
|
|
226
|
+
r.includes("timed out") ||
|
|
227
|
+
r.includes("timeout") ||
|
|
228
|
+
r.includes("etimedout") ||
|
|
229
|
+
r.includes("econnreset")
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Does this turn-failure reason look like a `--resume` of a session that no longer
|
|
235
|
+
* exists — claude's "No conversation found with session ID" class (expiry / transcript
|
|
236
|
+
* cleanup / a missing session jsonl)? Governs the ONE-TIME fresh-create fallback in
|
|
237
|
+
* {@link ProgrammaticBackend.deliver} that keeps an expired session from BRICKING the
|
|
238
|
+
* thread on every future turn (issue #132).
|
|
239
|
+
*
|
|
240
|
+
* ⚠️ TEXT-BASED — this matches claude's ERROR WORDING (anthropics/claude-code#33912),
|
|
241
|
+
* which is VERSION-FRAGILE: if claude changes the phrasing this detector silently stops
|
|
242
|
+
* matching. It is deliberately CONSERVATIVE — it only governs a RECOVERY fallback, so a
|
|
243
|
+
* MISS degrades to today's behavior (the pre-existing brick), never anything worse; it
|
|
244
|
+
* can't, e.g., turn a real failure into a spurious success. A future STRUCTURED error
|
|
245
|
+
* signal (an exit-code or a stream-json error subtype) would be more robust and is the
|
|
246
|
+
* preferred long-term fix. Kept tight enough not to match a generic failure: it requires
|
|
247
|
+
* "conversation"/"session" near the not-found phrasing (never a bare "not found").
|
|
248
|
+
*/
|
|
249
|
+
export function isSessionNotFoundError(reason: string): boolean {
|
|
250
|
+
return /no conversation found|session not found|no session (?:found |with )|conversation .{0,30}not found|could not find .{0,20}session/i.test(
|
|
251
|
+
reason,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Default real sleep for the retry backoff (overridable via `deps.sleepFn` in tests). */
|
|
256
|
+
const realSleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Build the `claude -p` invocation argv (PRE-sandbox-wrap) for one turn.
|
|
260
|
+
*
|
|
261
|
+
* The verified shape (claude 2.1.179): headless `-p` with the message as the
|
|
262
|
+
* prompt, stream-json output, the strict multi-entry MCP config, and
|
|
263
|
+
* skip-permissions (the turn is autonomous; the sandbox is the containment). The
|
|
264
|
+
* caller resolves the turn's session (the daemon owns the uuid — it lives on the
|
|
265
|
+
* `#agent/thread` note): when `sessionId` is present, `--resume <id>` CONTINUES the
|
|
266
|
+
* prior conversation (`resumeSession: true`) or `--session-id <id>` CREATES a session
|
|
267
|
+
* with that uuid (`resumeSession: false`).
|
|
268
|
+
*
|
|
269
|
+
* DELIBERATELY ABSENT: `--dangerously-load-development-channels` (no channel MCP in
|
|
270
|
+
* this backend — the daemon mediates messaging), and any TUI flag (`-p` has none).
|
|
271
|
+
*
|
|
272
|
+
* SYSTEM PROMPT (design 2026-06-16-channel-system-prompt.md): when the spec carries
|
|
273
|
+
* a `systemPrompt`, the per-session prompt FILE path is passed via the `-file`
|
|
274
|
+
* variant — `--append-system-prompt-file <path>` (append mode, keeps CC's default)
|
|
275
|
+
* or `--system-prompt-file <path>` (replace mode). The flags are PER-INVOCATION, so
|
|
276
|
+
* this is added on EVERY turn (including `--resume` turns) — the argv is rebuilt per
|
|
277
|
+
* `deliver`, and the file is (re)written each turn (see {@link ProgrammaticBackend.deliver}).
|
|
278
|
+
* The `-file` form (over the inline string form) is robust to long/multiline prompts
|
|
279
|
+
* and keeps the prompt visible-on-disk to the backend.
|
|
280
|
+
*/
|
|
281
|
+
export function buildProgrammaticClaudeArgs(opts: {
|
|
282
|
+
message: string;
|
|
283
|
+
mcpConfigPath: string;
|
|
284
|
+
/** The Claude session UUID for this turn (caller-resolved). Omitted → no session flag. */
|
|
285
|
+
sessionId?: string;
|
|
286
|
+
/** true → `--resume <sessionId>` (continue); false (default) → `--session-id <sessionId>` (create). */
|
|
287
|
+
resumeSession?: boolean;
|
|
288
|
+
claudeBin?: string;
|
|
289
|
+
/** Path to the per-session system-prompt file (omitted = no system-prompt flag). */
|
|
290
|
+
systemPromptFile?: string;
|
|
291
|
+
/** How the system prompt composes — append (default) keeps CC's base; replace overrides it. */
|
|
292
|
+
systemPromptMode?: "append" | "replace";
|
|
293
|
+
/**
|
|
294
|
+
* Model to run the turn on (`claude -p --model <value>`) — a CC alias
|
|
295
|
+
* (`opus`/`sonnet`/`haiku`) or a full model id. Omitted/empty → no `--model`
|
|
296
|
+
* flag, inheriting CC's default. Passed as a discrete argv element (no shell).
|
|
297
|
+
*/
|
|
298
|
+
model?: string;
|
|
299
|
+
}): string[] {
|
|
300
|
+
const bin = opts.claudeBin ?? "claude";
|
|
301
|
+
const argv = [
|
|
302
|
+
bin,
|
|
303
|
+
"-p",
|
|
304
|
+
opts.message,
|
|
305
|
+
"--output-format",
|
|
306
|
+
"stream-json",
|
|
307
|
+
"--verbose",
|
|
308
|
+
"--strict-mcp-config",
|
|
309
|
+
"--mcp-config",
|
|
310
|
+
opts.mcpConfigPath,
|
|
311
|
+
"--dangerously-skip-permissions",
|
|
312
|
+
];
|
|
313
|
+
// Model is OPTIONAL — only add the flag when the spec set one, so an unset
|
|
314
|
+
// model inherits Claude Code's own default rather than pinning a value here.
|
|
315
|
+
if (typeof opts.model === "string" && opts.model.trim().length > 0) {
|
|
316
|
+
argv.push("--model", opts.model.trim());
|
|
317
|
+
}
|
|
318
|
+
// System prompt (file-backed). Append KEEPS CC's capable default + adds the role;
|
|
319
|
+
// replace overrides it entirely. Re-passed every turn (the flag isn't persistent).
|
|
320
|
+
if (opts.systemPromptFile) {
|
|
321
|
+
const flag = opts.systemPromptMode === "replace" ? "--system-prompt-file" : "--append-system-prompt-file";
|
|
322
|
+
argv.push(flag, opts.systemPromptFile);
|
|
323
|
+
}
|
|
324
|
+
if (opts.sessionId) {
|
|
325
|
+
argv.push(opts.resumeSession ? "--resume" : "--session-id", opts.sessionId);
|
|
326
|
+
}
|
|
327
|
+
return argv;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Read the full text of a (possibly null) byte stream; null/error → "". */
|
|
331
|
+
async function drainStream(stream: ReadableStream<Uint8Array> | null): Promise<string> {
|
|
332
|
+
if (!stream) return "";
|
|
333
|
+
try {
|
|
334
|
+
return await new Response(stream).text();
|
|
335
|
+
} catch {
|
|
336
|
+
return "";
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* The programmatic backend — one sandboxed `claude -p` turn per message.
|
|
342
|
+
*
|
|
343
|
+
* `start` is lightweight (no resident process; a "session" is just the uuid the
|
|
344
|
+
* caller resolves per turn, persisted on the thread note — not here). `deliver` runs
|
|
345
|
+
* the turn with the caller-supplied {@link TurnSession} and returns a
|
|
346
|
+
* {@link DeliverResult} — a failure is a VALUE (`{ ok: false, error }`), never a
|
|
347
|
+
* throw. `stop` is a no-op (no process to kill, no store to clear — the session lives
|
|
348
|
+
* on the durable thread note). `status` is always live (there is nothing to keep alive).
|
|
349
|
+
*/
|
|
350
|
+
export class ProgrammaticBackend implements AgentBackend {
|
|
351
|
+
readonly kind = PROGRAMMATIC_BACKEND_KIND;
|
|
352
|
+
private readonly deps: ProgrammaticBackendDeps;
|
|
353
|
+
|
|
354
|
+
constructor(deps: ProgrammaticBackendDeps) {
|
|
355
|
+
this.deps = deps;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Bring an agent up for a channel. There is no resident process (and no session to
|
|
360
|
+
* pre-establish — the session uuid is resolved per turn by the caller and lives on
|
|
361
|
+
* the thread note) — this validates the spec and returns a handle keyed on the wake
|
|
362
|
+
* channel (the first channel). The actual `claude -p` invocation happens per-message
|
|
363
|
+
* in {@link deliver}.
|
|
364
|
+
*/
|
|
365
|
+
async start(spec: AgentSpec): Promise<AgentHandle> {
|
|
366
|
+
if (!AGENT_NAME_SLUG.test(spec.name)) {
|
|
367
|
+
throw new Error(
|
|
368
|
+
`ProgrammaticBackend.start: spec name "${spec.name}" must be a slug ` +
|
|
369
|
+
`(alphanumeric, dash, underscore only)`,
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
if (spec.channels.length === 0) {
|
|
373
|
+
throw new Error(`ProgrammaticBackend.start: spec "${spec.name}" declares no channels`);
|
|
374
|
+
}
|
|
375
|
+
const channel = normalizeChannel(spec.channels[0]!).name;
|
|
376
|
+
return { backend: this.kind, channel, name: spec.name, spec };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Run ONE `claude -p` turn for the handle's channel and return its reply.
|
|
381
|
+
*
|
|
382
|
+
* Order: resolve the Claude credential (throws → the daemon surfaces it) → mint
|
|
383
|
+
* the VAULT token (if the spec binds a vault) → write the vault-only `.mcp.json`
|
|
384
|
+
* → build the `-p` argv (with the caller's {@link TurnSession}: `--resume <id>` to
|
|
385
|
+
* continue, `--session-id <id>` to create) → sandbox-wrap via the shared seam →
|
|
386
|
+
* spawn → STREAM + parse the stream-json → return the DeliverResult (carrying the
|
|
387
|
+
* captured session id so the caller can persist it onto the thread note).
|
|
388
|
+
*
|
|
389
|
+
* STREAMING (design build item #1): the stdout stream-json is read INCREMENTALLY
|
|
390
|
+
* via {@link parseStreamJsonStream}. When `onInterim` is given, interim events
|
|
391
|
+
* (assistant text chunks + tool_use) are emitted as the turn runs so the daemon
|
|
392
|
+
* can render "watch it work" live; the FINAL parse (the authoritative `result`)
|
|
393
|
+
* is identical whether or not a sink is wired — the durable outbound note path is
|
|
394
|
+
* unchanged. `onInterim` is best-effort and must not throw.
|
|
395
|
+
*
|
|
396
|
+
* A failure (mint refused, non-zero exit, `is_error: true`, non-success subtype,
|
|
397
|
+
* empty output) returns `{ ok: false, error }` — it does NOT throw, so the daemon
|
|
398
|
+
* always learns the outcome inline.
|
|
399
|
+
*
|
|
400
|
+
* SESSION-EXPIRY SELF-HEAL (#132): a `--resume` turn whose Claude session no longer
|
|
401
|
+
* exists ("No conversation found with session ID" — expiry / transcript cleanup) is
|
|
402
|
+
* NOT a transient error (no retry helps) and, left alone, would BRICK the thread on
|
|
403
|
+
* EVERY future turn (the stale id stays on the thread note). When a resume turn fails
|
|
404
|
+
* with a {@link isSessionNotFoundError} reason, `deliver` falls back ONCE to a fresh
|
|
405
|
+
* `--session-id <new uuid>` create, re-establishing continuity from this turn forward.
|
|
406
|
+
* The new turn's echoed session id flows out on the {@link DeliverResult} exactly as
|
|
407
|
+
* usual, so the registry persists the NEW id onto the thread note and later turns
|
|
408
|
+
* resume it. The fallback fires AT MOST once (only on a resume turn; the create it
|
|
409
|
+
* runs has `resume: false`, so it can never re-trigger).
|
|
410
|
+
*/
|
|
411
|
+
async deliver(
|
|
412
|
+
handle: AgentHandle,
|
|
413
|
+
message: string,
|
|
414
|
+
session: TurnSession,
|
|
415
|
+
onInterim?: InterimSink,
|
|
416
|
+
attachments?: InboundAttachment[],
|
|
417
|
+
): Promise<DeliverResult> {
|
|
418
|
+
const spec = handle.spec;
|
|
419
|
+
if (!spec) {
|
|
420
|
+
return { ok: false, error: `ProgrammaticBackend.deliver: handle for "${handle.name}" carries no spec` };
|
|
421
|
+
}
|
|
422
|
+
const channel = handle.channel;
|
|
423
|
+
const workspace = sessionWorkspace(this.deps.sessionsDir, spec.name);
|
|
424
|
+
|
|
425
|
+
// Resolve the Claude OAuth credential keyed on the wake channel. A missing
|
|
426
|
+
// credential throws (CredentialNotConfigured) BEFORE any mint/spawn side effect.
|
|
427
|
+
const resolveToken = this.deps.resolveClaudeToken ?? ((ch: string) => resolveClaudeCredential(ch));
|
|
428
|
+
let claudeOauthToken: string;
|
|
429
|
+
try {
|
|
430
|
+
claudeOauthToken = resolveToken(channel);
|
|
431
|
+
} catch (err) {
|
|
432
|
+
return { ok: false, error: (err as Error).message };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Per-channel env injection (GH_TOKEN, CLOUDFLARE_API_TOKEN, …), read at turn time.
|
|
436
|
+
const resolveEnv = this.deps.resolveChannelEnv ?? ((ch: string) => resolveChannelEnv(ch));
|
|
437
|
+
const channelEnv = resolveEnv(channel);
|
|
438
|
+
|
|
439
|
+
// Mint the VAULT token only — no channel MCP in this backend (the daemon
|
|
440
|
+
// mediates messaging). A spec with no vault gets an EMPTY mcpServers config
|
|
441
|
+
// (the agent still runs; it just has no vault tools this turn).
|
|
442
|
+
//
|
|
443
|
+
// FIX 2 (PR #3) — mid-turn token expiry, ASSESSED + DEFERRED (no re-mint added).
|
|
444
|
+
// The vault write token is MINTED FRESH per turn here (no `expiresIn` override → the
|
|
445
|
+
// hub default ~90d non-ephemeral TTL), so it CANNOT expire during a single `claude -p`
|
|
446
|
+
// turn (which lasts minutes). And the vault WRITES are made by the OPAQUE `claude -p`
|
|
447
|
+
// subprocess via the token baked into its 0600 `.mcp.json` (below) — the backend has
|
|
448
|
+
// NO in-process seam to observe a 401 from those writes and re-inject a new token
|
|
449
|
+
// mid-turn. A re-mint-on-401 would require the MCP-client-in-subprocess to surface
|
|
450
|
+
// 401s back here, which the architecture doesn't provide. So a re-mint is INFEASIBLE
|
|
451
|
+
// (and unnecessary given the fresh-per-turn ~90d mint). If a future long-running /
|
|
452
|
+
// multi-day single turn or a short operator-pinned TTL ever makes mid-turn expiry
|
|
453
|
+
// real, the fix is at the MCP-client layer (refresh-on-401), tracked as a follow-up
|
|
454
|
+
// — NOT a forced backend re-mint that can't see the failure.
|
|
455
|
+
let vaultArg: { url: string; entry: { name: string; token: string } } | undefined;
|
|
456
|
+
if (spec.vault) {
|
|
457
|
+
const v = spec.vault;
|
|
458
|
+
const mintDeps: MintTokenDeps = {
|
|
459
|
+
hubOrigin: this.deps.hubOrigin,
|
|
460
|
+
managerBearer: this.deps.managerBearer,
|
|
461
|
+
...(this.deps.fetchFn ? { fetchFn: this.deps.fetchFn } : {}),
|
|
462
|
+
};
|
|
463
|
+
try {
|
|
464
|
+
const minted = await mintScopedToken(
|
|
465
|
+
{
|
|
466
|
+
scope: vaultScope(v.name, v.access),
|
|
467
|
+
audience: `vault.${v.name}`,
|
|
468
|
+
...(v.tags && v.tags.length > 0 ? { permissions: { scoped_tags: v.tags } } : {}),
|
|
469
|
+
},
|
|
470
|
+
mintDeps,
|
|
471
|
+
);
|
|
472
|
+
vaultArg = {
|
|
473
|
+
url: this.deps.vaultUrl ?? this.deps.hubOrigin,
|
|
474
|
+
entry: { name: v.name, token: minted.token },
|
|
475
|
+
};
|
|
476
|
+
} catch (err) {
|
|
477
|
+
// A refused/over-broad mint aborts the turn with a clean error — no spawn.
|
|
478
|
+
return { ok: false, error: (err as Error).message };
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// 4b: resolve the agent's APPROVED cross-resource grants FRESH this turn (design
|
|
483
|
+
// 2026-06-17-agent-connectors-4b.md §3). Granted-vault material → extra MCP
|
|
484
|
+
// servers (the agent reaches OTHER vaults alongside its own); granted-service
|
|
485
|
+
// material → env vars (GITHUB_TOKEN, …) and/or the service's MCP server. Fetched
|
|
486
|
+
// per-turn (never cached) so a revocation takes effect next spawn. Best-effort: a
|
|
487
|
+
// grants-list failure logs + the turn runs WITHOUT the extra grants — own-vault is
|
|
488
|
+
// unaffected. The secret material lands ONLY in the ephemeral 0600 .mcp.json + the
|
|
489
|
+
// child env below; NEVER in a vault note. mcp-kind grants stay pending server-side
|
|
490
|
+
// in 4b-1 (no OAuth) → getMaterial returns null for them → never injected.
|
|
491
|
+
let grantMcpEntries: { name: string; url: string; token: string }[] = [];
|
|
492
|
+
let grantEnv: Record<string, string> = {};
|
|
493
|
+
if (this.deps.grants) {
|
|
494
|
+
// The grants are keyed on the hub by the AGENT name, which for vault-native defs
|
|
495
|
+
// is `spec.name`. `grantsAgentName` is an explicit override reserved for a future
|
|
496
|
+
// channel-name≠agent-name split; today no caller sets it, so it falls through to
|
|
497
|
+
// `spec.name` — do NOT set it unless that split lands (else you'd fetch the wrong
|
|
498
|
+
// agent's grants).
|
|
499
|
+
const agentName = this.deps.grantsAgentName ?? spec.name;
|
|
500
|
+
try {
|
|
501
|
+
const injected = await resolveInjectedGrants(this.deps.grants, agentName);
|
|
502
|
+
grantMcpEntries = injected.mcpEntries;
|
|
503
|
+
grantEnv = injected.env;
|
|
504
|
+
} catch (err) {
|
|
505
|
+
// A failed grant LIST aborts only the cross-resource injection — the turn
|
|
506
|
+
// still runs with own-vault. (A revoked-mid-list / hub blip class.)
|
|
507
|
+
console.warn(
|
|
508
|
+
`parachute-agent: resolving grants for "${agentName}" failed (running this turn ` +
|
|
509
|
+
`WITHOUT cross-resource grants — own-vault unaffected): ${(err as Error).message}`,
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Write the strict MCP config 0600 — it inlines the vault token + any granted-
|
|
515
|
+
// resource tokens. No channels[] entry: messaging is the daemon's job, not the
|
|
516
|
+
// agent's. With an empty `channels`, `channelUrl` is never read (it only builds
|
|
517
|
+
// `/mcp/<channel>` entry URLs), so we pass "" rather than thread an unrelated
|
|
518
|
+
// origin into a slot that goes nowhere. The granted MCP servers are added as
|
|
519
|
+
// `otherMcps` (each with its own Bearer) — additive to the own-vault entry.
|
|
520
|
+
const mcpConfigJson = buildAgentMcpConfigJson({
|
|
521
|
+
channelUrl: "",
|
|
522
|
+
channels: [],
|
|
523
|
+
...(vaultArg ? { vault: vaultArg } : {}),
|
|
524
|
+
...(grantMcpEntries.length > 0 ? { otherMcps: grantMcpEntries } : {}),
|
|
525
|
+
});
|
|
526
|
+
mkdirSync(workspace, { recursive: true });
|
|
527
|
+
const mcpConfigPath = join(workspace, ".mcp.json");
|
|
528
|
+
writeFileSync(mcpConfigPath, mcpConfigJson, { mode: 0o600 });
|
|
529
|
+
|
|
530
|
+
// ── INBOUND FILE ATTACHMENTS (Phase 1) ─────────────────────────────────────────
|
|
531
|
+
// Stage each attached file into the agent's PRIVATE session workspace (under a SAFE
|
|
532
|
+
// basename — NO path traversal; the path/filename come from VAULT DATA), then append a
|
|
533
|
+
// pointer line to the turn message so the `claude -p` turn can `Read` them. The private
|
|
534
|
+
// workspace is already in the sandbox read scope (composeFilesystemView always allows
|
|
535
|
+
// `base.workspace`), so NO sandbox-policy change is needed. Staged into the PRIVATE dir
|
|
536
|
+
// (NEVER a shared `spec.workspace`) — mirroring how `.mcp.json`/`system-prompt.txt` stay
|
|
537
|
+
// per-agent even when the working dir is shared. Best-effort + isolated: a single
|
|
538
|
+
// attachment's fetch/stage failure logs + is SKIPPED (the turn still runs with the rest
|
|
539
|
+
// + the text). Absent/empty → no staging, no prompt change (today's behavior exactly).
|
|
540
|
+
let turnMessage = message;
|
|
541
|
+
if (attachments && attachments.length > 0) {
|
|
542
|
+
const staged = await this.stageAttachments(workspace, attachments, vaultArg);
|
|
543
|
+
if (staged.length > 0) {
|
|
544
|
+
const lines = staged.map((s) => `- ${s.absPath} (${s.mimeType})`);
|
|
545
|
+
turnMessage =
|
|
546
|
+
`${message}\n\n[Attached files — read them as needed:\n${lines.join("\n")}\n]`;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// System prompt (design 2026-06-16-channel-system-prompt.md). When the spec
|
|
551
|
+
// carries one, write it to a per-session file (0600) and pass the `-file` flag.
|
|
552
|
+
// The flag is PER-INVOCATION (not persistent), so we (re)write the file + pass
|
|
553
|
+
// it EVERY turn — including a `--resume` turn — so the role is always applied.
|
|
554
|
+
// Unset → no flag, no file (today's behavior unchanged). The `-file` form is
|
|
555
|
+
// robust to long/multiline prompts and keeps the prompt visible-on-disk. Its
|
|
556
|
+
// lifecycle is tied to the workspace (like .mcp.json) — it disappears with it.
|
|
557
|
+
let systemPromptFile: string | undefined;
|
|
558
|
+
if (typeof spec.systemPrompt === "string" && spec.systemPrompt.length > 0) {
|
|
559
|
+
systemPromptFile = join(workspace, "system-prompt.txt");
|
|
560
|
+
writeFileSync(systemPromptFile, spec.systemPrompt, { mode: 0o600 });
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// The agent's WORKING dir (design 2026-06-16-agent-filesystem-and-sharing.md):
|
|
564
|
+
// the spec's `workspace` (a shared real dir) when set, else the private session
|
|
565
|
+
// dir (today's behavior). The cwd is decoupled from the private dir —
|
|
566
|
+
// `.mcp.json`/`system-prompt.txt`/seeded home stay PRIVATE under the session
|
|
567
|
+
// dir (passed by absolute path), so a shared workspace never receives the
|
|
568
|
+
// agent's secrets even when two agents point at the same dir.
|
|
569
|
+
const cwd = resolveAgentCwd(spec, workspace);
|
|
570
|
+
|
|
571
|
+
// The agent's private, writable, pre-seeded HOME + temp dirs (stability
|
|
572
|
+
// keystone) — always UNDER the private workspace, regardless of the cwd. The
|
|
573
|
+
// vault MCP server name is pre-approved so claude doesn't prompt; the pre-trusted
|
|
574
|
+
// project is the agent's actual cwd (the shared working dir when set).
|
|
575
|
+
const mcpServerNames = Object.keys(
|
|
576
|
+
(JSON.parse(mcpConfigJson) as { mcpServers?: Record<string, unknown> }).mcpServers ?? {},
|
|
577
|
+
);
|
|
578
|
+
const homeEnv = seedAgentHome(workspace, { mcpServers: mcpServerNames, projectRoot: cwd });
|
|
579
|
+
|
|
580
|
+
// Merge the granted-service env (GITHUB_TOKEN, …) with the operator-scoped
|
|
581
|
+
// per-channel env. The per-channel store wins on a key collision (it's the
|
|
582
|
+
// explicit operator override); both go in at the SAME (lowest) precedence layer of
|
|
583
|
+
// buildAgentChildEnv — which then applies its denylist (ANTHROPIC_API_KEY /
|
|
584
|
+
// CLAUDE_API_KEY / CLAUDE_CODE_OAUTH_TOKEN can NEVER be set from either source) and
|
|
585
|
+
// sets CLAUDE_CODE_OAUTH_TOKEN LAST, so a granted var can never clobber the
|
|
586
|
+
// session's managed auth or the subscription-billing guarantee.
|
|
587
|
+
const mergedChannelEnv: Record<string, string> = { ...grantEnv, ...channelEnv };
|
|
588
|
+
|
|
589
|
+
// Layer the scrubbed agent env UNDER the sandbox wrapper's env; the HOME/config/
|
|
590
|
+
// temp vars layer LAST so they win. CLAUDE_CODE_OAUTH_TOKEN injected;
|
|
591
|
+
// ANTHROPIC_API_KEY/CLAUDE_API_KEY absent (the subscription-billing guarantee).
|
|
592
|
+
// (Session-INDEPENDENT — computed ONCE; the per-turn `wrapped.env` layers on top
|
|
593
|
+
// inside `attemptTurn` below.)
|
|
594
|
+
const childEnv = buildAgentChildEnv(
|
|
595
|
+
this.deps.parentEnv ?? process.env,
|
|
596
|
+
claudeOauthToken,
|
|
597
|
+
mergedChannelEnv,
|
|
598
|
+
);
|
|
599
|
+
|
|
600
|
+
// The interim sink is best-effort + must not throw (session-INDEPENDENT). A push
|
|
601
|
+
// to a closed SSE stream / a sink fault must never break the turn.
|
|
602
|
+
const safeInterim: InterimSink = (e) => {
|
|
603
|
+
if (!onInterim) return;
|
|
604
|
+
try {
|
|
605
|
+
onInterim(e);
|
|
606
|
+
} catch {
|
|
607
|
+
// A push to a closed SSE stream / a sink fault must never break the turn.
|
|
608
|
+
}
|
|
609
|
+
};
|
|
610
|
+
const sleepFn = this.deps.sleepFn ?? realSleep;
|
|
611
|
+
|
|
612
|
+
// Run ONE turn for the given session — build the session-DEPENDENT argv
|
|
613
|
+
// (`--resume <id>` vs `--session-id <id>`), sandbox-wrap it, then run the bounded
|
|
614
|
+
// transient-retry loop. Relocated into a closure so a session-expiry FALLBACK can
|
|
615
|
+
// run it a SECOND time with a fresh create session (#132). The session-independent
|
|
616
|
+
// setup above (workspace, .mcp.json, system-prompt file, cwd, home/child env,
|
|
617
|
+
// interim sink) is shared across both attempts.
|
|
618
|
+
const attemptTurn = async (turnSession: TurnSession): Promise<DeliverResult> => {
|
|
619
|
+
// The DAEMON owns the session uuid (the caller resolved it from the durable
|
|
620
|
+
// `#agent/thread` note — single-threaded resumes its persisted session, multi-
|
|
621
|
+
// threaded gets a fresh uuid every fire). The backend reads no session store: it
|
|
622
|
+
// just runs the turn with the supplied {@link TurnSession} — `--resume <id>` to
|
|
623
|
+
// continue, `--session-id <id>` to create.
|
|
624
|
+
const argv = buildProgrammaticClaudeArgs({
|
|
625
|
+
message: turnMessage,
|
|
626
|
+
mcpConfigPath,
|
|
627
|
+
sessionId: turnSession.id,
|
|
628
|
+
resumeSession: turnSession.resume,
|
|
629
|
+
...(this.deps.claudeBin ? { claudeBin: this.deps.claudeBin } : {}),
|
|
630
|
+
...(systemPromptFile
|
|
631
|
+
? { systemPromptFile, systemPromptMode: spec.systemPromptMode ?? "append" }
|
|
632
|
+
: {}),
|
|
633
|
+
...(spec.model ? { model: spec.model } : {}),
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
// Sandbox-wrap via the SHARED seam — same egress floor + scoped-read
|
|
637
|
+
// confinement the interactive spawn gets. The wrapped argv carries the policy.
|
|
638
|
+
const wrapped = await wrapArgvInSandbox({
|
|
639
|
+
spec,
|
|
640
|
+
workspace,
|
|
641
|
+
runtimeReadOnly: this.deps.runtimeReadOnly,
|
|
642
|
+
hubOrigin: this.deps.hubOrigin,
|
|
643
|
+
...(this.deps.vaultUrl ? { vaultUrl: this.deps.vaultUrl } : {}),
|
|
644
|
+
argv,
|
|
645
|
+
...(this.deps.sandboxEngine ? { sandboxEngine: this.deps.sandboxEngine } : {}),
|
|
646
|
+
...(this.deps.ripgrep ? { ripgrep: this.deps.ripgrep } : {}),
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
// Compose the launch env so the SCRUB WINS: the scrubbed `childEnv` is
|
|
650
|
+
// authoritative; only the ALLOWLISTED sandbox/proxy keys from `wrapped.env`
|
|
651
|
+
// (NOT the whole daemon `process.env` the engine returns) + the home overrides
|
|
652
|
+
// layer on top. A bare `...wrapped.env` spread would re-admit the daemon's
|
|
653
|
+
// ambient ANTHROPIC_API_KEY/secrets and defeat buildAgentChildEnv's scrub —
|
|
654
|
+
// an isolation/billing leak. See mergeSandboxLaunchEnv.
|
|
655
|
+
const launchEnv = mergeSandboxLaunchEnv(childEnv, wrapped.env, homeEnv);
|
|
656
|
+
|
|
657
|
+
// Run the turn, with a bounded retry on TRANSIENT upstream errors (API 529/overload,
|
|
658
|
+
// 5xx, rate-limit, network). The argv is fixed (built above for THIS turn's session),
|
|
659
|
+
// so each attempt re-runs the SAME turn. STREAM stdout incrementally (interim events
|
|
660
|
+
// for the live view) while draining stderr in parallel; the interim sink is best-effort
|
|
661
|
+
// + must not throw. A spawn/IO fault is a value (not a throw); a non-transient failure
|
|
662
|
+
// or exhausted retries returns the failure for the daemon to learn.
|
|
663
|
+
for (let attempt = 1; attempt <= TURN_MAX_ATTEMPTS; attempt++) {
|
|
664
|
+
let parsed;
|
|
665
|
+
let stderr: string;
|
|
666
|
+
let code: number;
|
|
667
|
+
try {
|
|
668
|
+
const proc = this.deps.spawnFn(wrapped.argv, { env: launchEnv, cwd });
|
|
669
|
+
[parsed, stderr] = await Promise.all([
|
|
670
|
+
parseStreamJsonStream(proc.stdout, safeInterim),
|
|
671
|
+
drainStream(proc.stderr),
|
|
672
|
+
]);
|
|
673
|
+
code = await proc.exited;
|
|
674
|
+
} catch (err) {
|
|
675
|
+
// A spawn/IO fault (ENOENT, resource) is a config/permanent class — not retried.
|
|
676
|
+
return { ok: false, error: `claude -p spawn failed: ${(err as Error).message}` };
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (parsed.success === true) {
|
|
680
|
+
// The captured session id is RETURNED (below) for the caller to persist onto
|
|
681
|
+
// the thread note — the backend no longer owns a session store. The id we
|
|
682
|
+
// passed in (turnSession.id) and Claude's echoed parsed.sessionId are normally
|
|
683
|
+
// the same; the registry prefers the echoed one and falls back to turnSession.id.
|
|
684
|
+
|
|
685
|
+
const usage: DeliverUsage | undefined = parsed.usage
|
|
686
|
+
? {
|
|
687
|
+
...(typeof parsed.usage.input_tokens === "number" ? { inputTokens: parsed.usage.input_tokens } : {}),
|
|
688
|
+
...(typeof parsed.usage.output_tokens === "number" ? { outputTokens: parsed.usage.output_tokens } : {}),
|
|
689
|
+
...(typeof parsed.totalCostUsd === "number" ? { totalCostUsd: parsed.totalCostUsd } : {}),
|
|
690
|
+
}
|
|
691
|
+
: typeof parsed.totalCostUsd === "number"
|
|
692
|
+
? { totalCostUsd: parsed.totalCostUsd }
|
|
693
|
+
: undefined;
|
|
694
|
+
|
|
695
|
+
return {
|
|
696
|
+
ok: true,
|
|
697
|
+
reply: parsed.reply ?? "",
|
|
698
|
+
...(parsed.sessionId ? { sessionId: parsed.sessionId } : {}),
|
|
699
|
+
...(usage ? { usage } : {}),
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// FAILURE — compute the reason (non-zero exit / is_error / non-success subtype /
|
|
704
|
+
// no result event), same precedence as before.
|
|
705
|
+
const reason =
|
|
706
|
+
parsed.errorMessage ??
|
|
707
|
+
(parsed.subtype ? `claude -p turn failed (subtype: ${parsed.subtype})` : undefined) ??
|
|
708
|
+
(code !== 0
|
|
709
|
+
? `claude -p exited ${code}${stderr.trim() ? `: ${stderr.trim().slice(0, 500)}` : ""}`
|
|
710
|
+
: "claude -p produced no success result (no result event in output)");
|
|
711
|
+
|
|
712
|
+
// Retry ONLY a transient error, and only while attempts remain (incremental backoff).
|
|
713
|
+
if (attempt < TURN_MAX_ATTEMPTS && isTransientTurnError(reason)) {
|
|
714
|
+
const backoff =
|
|
715
|
+
TURN_RETRY_BACKOFF_MS[attempt - 1] ?? TURN_RETRY_BACKOFF_MS[TURN_RETRY_BACKOFF_MS.length - 1] ?? 5_000;
|
|
716
|
+
console.warn(
|
|
717
|
+
`parachute-agent: transient turn error for channel "${channel}" ` +
|
|
718
|
+
`(attempt ${attempt}/${TURN_MAX_ATTEMPTS}, retrying in ${backoff}ms): ${reason}`,
|
|
719
|
+
);
|
|
720
|
+
await sleepFn(backoff);
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
// RETURN the session id even on a FINAL failure — a turn can fail AFTER
|
|
724
|
+
// establishing a session; the id is still the continuation handle for the next
|
|
725
|
+
// turn. The registry persists it onto the thread note (`result.sessionId ??
|
|
726
|
+
// turnSession.id`), so the next turn resumes the conversation.
|
|
727
|
+
// Non-transient, or out of attempts → return the failure (the daemon records
|
|
728
|
+
// status:error AND posts a user-facing failure note to the channel).
|
|
729
|
+
return {
|
|
730
|
+
ok: false,
|
|
731
|
+
error: reason,
|
|
732
|
+
...(parsed.sessionId ? { sessionId: parsed.sessionId } : {}),
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
// Unreachable — every loop path returns — but satisfies the type checker.
|
|
736
|
+
return { ok: false, error: "claude -p: retries exhausted" };
|
|
737
|
+
};
|
|
738
|
+
|
|
739
|
+
let result = await attemptTurn(session);
|
|
740
|
+
// Session-expiry recovery (#132): a --resume turn whose session no longer exists is
|
|
741
|
+
// NOT transient (no retry would help) and would otherwise brick the thread on every
|
|
742
|
+
// future turn (the stale id stays on the note). Fall back ONCE to a fresh create so
|
|
743
|
+
// continuity self-heals from here — the new turn's echoed id flows out for the
|
|
744
|
+
// registry to persist. Only on a RESUME turn; the create itself is never retried this
|
|
745
|
+
// way (the fallback session has resume:false → a not-found on it can't re-trigger).
|
|
746
|
+
if (!result.ok && session.resume && isSessionNotFoundError(result.error)) {
|
|
747
|
+
const fresh = crypto.randomUUID();
|
|
748
|
+
console.warn(
|
|
749
|
+
`parachute-agent: resume session for channel "${channel}" not found (expired?) — ` +
|
|
750
|
+
`starting a fresh session ${fresh}: ${result.error}`,
|
|
751
|
+
);
|
|
752
|
+
result = await attemptTurn({ id: fresh, resume: false });
|
|
753
|
+
}
|
|
754
|
+
return result;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Stage inbound file attachments into the agent's PRIVATE session workspace so the turn
|
|
759
|
+
* can `Read` them (Phase 1). For each attachment: FETCH the blob from the vault storage
|
|
760
|
+
* REST endpoint (`GET <vaultUrl>/vault/<name>/api/storage/<path>`, Bearer the per-turn
|
|
761
|
+
* minted vault token), then WRITE it to `<workspace>/attachments/<safeBasename>`. Returns
|
|
762
|
+
* the staged files' ABSOLUTE paths + mime types (for the prompt pointer line).
|
|
763
|
+
*
|
|
764
|
+
* SECURITY:
|
|
765
|
+
* - The staged filename is a SAFE BASENAME ({@link safeAttachmentBasename}) — the vault
|
|
766
|
+
* `path`/`filename` are UNTRUSTED data, so a malicious `../../etc/passwd` collapses to a
|
|
767
|
+
* plain basename inside the staging dir. As defense in depth we ALSO verify the resolved
|
|
768
|
+
* write target stays UNDER the staging dir and skip it otherwise.
|
|
769
|
+
* - Staged ONLY into the PRIVATE session dir (`workspace`), NEVER a shared `spec.workspace`
|
|
770
|
+
* — mirroring `.mcp.json`/`system-prompt.txt`.
|
|
771
|
+
* - Per-attachment size cap ({@link ATTACHMENT_MAX_BYTES}, = the vault's 100MB upload
|
|
772
|
+
* ceiling) + a total count cap ({@link ATTACHMENT_MAX_COUNT}).
|
|
773
|
+
*
|
|
774
|
+
* Best-effort + ISOLATED: a single attachment's fetch/write failure logs + is SKIPPED (the
|
|
775
|
+
* turn still runs with the rest + the text). When the spec binds NO vault, there is no
|
|
776
|
+
* per-turn vault token to authenticate the storage fetch → ALL are skipped with one log.
|
|
777
|
+
*/
|
|
778
|
+
private async stageAttachments(
|
|
779
|
+
workspace: string,
|
|
780
|
+
attachments: InboundAttachment[],
|
|
781
|
+
vaultArg: { url: string; entry: { name: string; token: string } } | undefined,
|
|
782
|
+
): Promise<Array<{ absPath: string; mimeType: string }>> {
|
|
783
|
+
if (!vaultArg) {
|
|
784
|
+
console.warn(
|
|
785
|
+
`parachute-agent: ${attachments.length} inbound attachment(s) but this agent binds no ` +
|
|
786
|
+
`vault — cannot fetch the bytes; running the turn with text only.`,
|
|
787
|
+
);
|
|
788
|
+
return [];
|
|
789
|
+
}
|
|
790
|
+
const fetchFn = this.deps.fetchFn ?? fetch;
|
|
791
|
+
const stagingDir = join(workspace, ATTACHMENT_STAGING_DIR);
|
|
792
|
+
// The canonical staging-dir prefix the write target must stay under (defense in depth).
|
|
793
|
+
const stagingPrefix = stagingDir.endsWith("/") ? stagingDir : `${stagingDir}/`;
|
|
794
|
+
// Create the staging dir LAZILY — only just before the first real write. So a turn where
|
|
795
|
+
// every attachment fails/skips leaves NO empty `attachments/` dir behind (the "no staging
|
|
796
|
+
// side effects unless a file actually staged" contract).
|
|
797
|
+
let stagingDirReady = false;
|
|
798
|
+
const ensureStagingDir = (): void => {
|
|
799
|
+
if (!stagingDirReady) {
|
|
800
|
+
mkdirSync(stagingDir, { recursive: true });
|
|
801
|
+
stagingDirReady = true;
|
|
802
|
+
}
|
|
803
|
+
};
|
|
804
|
+
|
|
805
|
+
const staged: Array<{ absPath: string; mimeType: string }> = [];
|
|
806
|
+
const usedNames = new Set<string>();
|
|
807
|
+
const capped = attachments.slice(0, ATTACHMENT_MAX_COUNT);
|
|
808
|
+
if (attachments.length > ATTACHMENT_MAX_COUNT) {
|
|
809
|
+
console.warn(
|
|
810
|
+
`parachute-agent: ${attachments.length} inbound attachments exceeds the cap ` +
|
|
811
|
+
`(${ATTACHMENT_MAX_COUNT}); staging the first ${ATTACHMENT_MAX_COUNT}.`,
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
for (const att of capped) {
|
|
816
|
+
if (typeof att.path !== "string" || att.path.length === 0) continue;
|
|
817
|
+
// SAFE basename — defeats path traversal (the vault path/filename are untrusted).
|
|
818
|
+
let base = safeAttachmentBasename(att.filename || att.path);
|
|
819
|
+
// De-dup colliding basenames so a second `report.png` doesn't clobber the first.
|
|
820
|
+
if (usedNames.has(base)) {
|
|
821
|
+
let n = 2;
|
|
822
|
+
const dot = base.lastIndexOf(".");
|
|
823
|
+
const stem = dot > 0 ? base.slice(0, dot) : base;
|
|
824
|
+
const ext = dot > 0 ? base.slice(dot) : "";
|
|
825
|
+
while (usedNames.has(`${stem}-${n}${ext}`)) n++;
|
|
826
|
+
base = `${stem}-${n}${ext}`;
|
|
827
|
+
}
|
|
828
|
+
const target = join(stagingDir, base);
|
|
829
|
+
// Defense in depth: the join MUST stay inside the staging dir.
|
|
830
|
+
if (target !== stagingDir.replace(/\/$/, "") && !target.startsWith(stagingPrefix)) {
|
|
831
|
+
console.warn(
|
|
832
|
+
`parachute-agent: refusing to stage attachment "${att.path}" — resolved path ` +
|
|
833
|
+
`"${target}" escapes the staging dir; skipping.`,
|
|
834
|
+
);
|
|
835
|
+
continue;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
try {
|
|
839
|
+
// The storage path is `date/filename` — `encodeURIComponent` percent-encodes the
|
|
840
|
+
// slash to `%2F`; the vault storage route `decodeURIComponent`s it back before
|
|
841
|
+
// matching (vault routes.ts), so a single encoded segment is the correct form.
|
|
842
|
+
const url = `${vaultArg.url}/vault/${vaultArg.entry.name}/api/storage/${encodeURIComponent(att.path)}`;
|
|
843
|
+
const res = await fetchFn(url, {
|
|
844
|
+
headers: { authorization: `Bearer ${vaultArg.entry.token}` },
|
|
845
|
+
});
|
|
846
|
+
if (!res.ok) {
|
|
847
|
+
const detail = await res.text().catch(() => "");
|
|
848
|
+
console.warn(
|
|
849
|
+
`parachute-agent: fetch attachment blob "${att.path}" failed (${res.status}) ` +
|
|
850
|
+
`${detail} — skipping this file`.trim(),
|
|
851
|
+
);
|
|
852
|
+
continue;
|
|
853
|
+
}
|
|
854
|
+
// Pre-flight on Content-Length so an over-cap blob is skipped WITHOUT buffering its
|
|
855
|
+
// whole body. Best-effort: a missing/garbage header falls through to the post-read
|
|
856
|
+
// check below (the real guard).
|
|
857
|
+
const declared = Number(res.headers.get("content-length"));
|
|
858
|
+
if (Number.isFinite(declared) && declared > ATTACHMENT_MAX_BYTES) {
|
|
859
|
+
console.warn(
|
|
860
|
+
`parachute-agent: attachment "${att.path}" declares ${declared} bytes, over the ` +
|
|
861
|
+
`${ATTACHMENT_MAX_BYTES}-byte cap — skipping this file.`,
|
|
862
|
+
);
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
865
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
866
|
+
if (buf.byteLength > ATTACHMENT_MAX_BYTES) {
|
|
867
|
+
console.warn(
|
|
868
|
+
`parachute-agent: attachment "${att.path}" is ${buf.byteLength} bytes, over the ` +
|
|
869
|
+
`${ATTACHMENT_MAX_BYTES}-byte cap — skipping this file.`,
|
|
870
|
+
);
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
ensureStagingDir();
|
|
874
|
+
writeFileSync(target, buf, { mode: 0o600 });
|
|
875
|
+
usedNames.add(base);
|
|
876
|
+
staged.push({ absPath: target, mimeType: att.mimeType || "application/octet-stream" });
|
|
877
|
+
} catch (err) {
|
|
878
|
+
console.warn(
|
|
879
|
+
`parachute-agent: staging attachment "${att.path}" errored (skipping this file): ` +
|
|
880
|
+
`${(err as Error).message}`,
|
|
881
|
+
);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
return staged;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* Tear the agent down. A NO-OP for the programmatic backend: there is no resident
|
|
889
|
+
* process to kill, and no session store to clear — the session now lives on the
|
|
890
|
+
* durable `#agent/thread` note (`metadata.session`). So `stop` no longer resets
|
|
891
|
+
* conversation continuity; a single-threaded agent's next turn still resumes its
|
|
892
|
+
* persisted session. Starting a genuinely FRESH conversation is a separate operation
|
|
893
|
+
* (deleting the thread note), not a side effect of stop/deregister.
|
|
894
|
+
*/
|
|
895
|
+
async stop(_handle: AgentHandle): Promise<void> {
|
|
896
|
+
// Intentionally empty — see the doc comment.
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* The programmatic backend has no resident process to keep alive — it is always
|
|
901
|
+
* available to run the next turn, so `live` is true.
|
|
902
|
+
*/
|
|
903
|
+
async status(_handle: AgentHandle): Promise<AgentStatus> {
|
|
904
|
+
return { live: true };
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* The real `Bun.spawn` adapter for the programmatic backend — pipes stdout/stderr
|
|
910
|
+
* so the runner can drain the stream-json, applies the launch env + cwd. Used by
|
|
911
|
+
* the daemon; tests inject a fake `spawnFn` instead.
|
|
912
|
+
*/
|
|
913
|
+
export function realProgrammaticSpawn(spawnFn: typeof Bun.spawn = Bun.spawn): ProgrammaticSpawnFn {
|
|
914
|
+
return (argv, opts) => {
|
|
915
|
+
const proc = spawnFn(argv, {
|
|
916
|
+
env: opts.env,
|
|
917
|
+
cwd: opts.cwd,
|
|
918
|
+
stdout: "pipe",
|
|
919
|
+
stderr: "pipe",
|
|
920
|
+
});
|
|
921
|
+
return {
|
|
922
|
+
stdout: proc.stdout as ReadableStream<Uint8Array> | null,
|
|
923
|
+
stderr: proc.stderr as ReadableStream<Uint8Array> | null,
|
|
924
|
+
exited: proc.exited,
|
|
925
|
+
};
|
|
926
|
+
};
|
|
927
|
+
}
|