@openparachute/agent 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (598) hide show
  1. package/.parachute/module.json +124 -8
  2. package/LICENSE +2 -16
  3. package/README.md +118 -166
  4. package/package.json +32 -43
  5. package/scripts/spawn-agent.ts +371 -0
  6. package/src/_parked/interactive-spawn.test.ts +324 -0
  7. package/src/_parked/interactive-spawn.ts +701 -0
  8. package/src/agent-defs.test.ts +1504 -0
  9. package/src/agent-defs.ts +1702 -0
  10. package/src/agent-mcp-config.test.ts +115 -0
  11. package/src/agent-mcp-config.ts +115 -0
  12. package/src/agents.test.ts +360 -0
  13. package/src/agents.ts +379 -0
  14. package/src/auth.test.ts +46 -0
  15. package/src/auth.ts +140 -0
  16. package/src/backends/attached-queue.test.ts +376 -0
  17. package/src/backends/attached-queue.ts +372 -0
  18. package/src/backends/programmatic.test.ts +1715 -0
  19. package/src/backends/programmatic.ts +927 -0
  20. package/src/backends/registry.test.ts +1494 -0
  21. package/src/backends/registry.ts +1202 -0
  22. package/src/backends/stream-json.test.ts +570 -0
  23. package/src/backends/stream-json.ts +392 -0
  24. package/src/backends/types.ts +223 -0
  25. package/src/bridge.ts +417 -0
  26. package/src/channel-backend-wiring.test.ts +237 -0
  27. package/src/credentials.test.ts +274 -0
  28. package/src/credentials.ts +380 -0
  29. package/src/cron.test.ts +342 -0
  30. package/src/cron.ts +380 -0
  31. package/src/daemon-agent-def-api.test.ts +166 -0
  32. package/src/daemon-agent-defs-api.test.ts +953 -0
  33. package/src/daemon-agent-env-api.test.ts +338 -0
  34. package/src/daemon-attached-queue-store.test.ts +65 -0
  35. package/src/daemon-config-api.test.ts +962 -0
  36. package/src/daemon-jobs-api.test.ts +271 -0
  37. package/src/daemon-vault-chat.test.ts +250 -0
  38. package/src/daemon.test.ts +746 -0
  39. package/src/daemon.ts +3314 -0
  40. package/src/def-vaults.test.ts +136 -0
  41. package/src/def-vaults.ts +165 -0
  42. package/src/delivery-state.test.ts +110 -0
  43. package/src/delivery-state.ts +154 -0
  44. package/src/effective-env.test.ts +114 -0
  45. package/src/effective-env.ts +184 -0
  46. package/src/env-compat.ts +39 -0
  47. package/src/grants.test.ts +638 -0
  48. package/src/grants.ts +675 -0
  49. package/src/hub-jwt.test.ts +161 -0
  50. package/src/hub-jwt.ts +182 -0
  51. package/src/jobs.test.ts +245 -0
  52. package/src/jobs.ts +266 -0
  53. package/src/mcp-http.test.ts +265 -0
  54. package/src/mcp-http.ts +771 -0
  55. package/src/mint-token.test.ts +152 -0
  56. package/src/mint-token.ts +139 -0
  57. package/src/module-manifest.test.ts +158 -0
  58. package/src/oauth-discovery.ts +134 -0
  59. package/src/programmatic-wiring.test.ts +838 -0
  60. package/src/registry.test.ts +227 -0
  61. package/src/registry.ts +228 -0
  62. package/src/resolve-port.test.ts +64 -0
  63. package/src/routing.test.ts +184 -0
  64. package/src/routing.ts +76 -0
  65. package/src/runner.test.ts +506 -0
  66. package/src/runner.ts +255 -0
  67. package/src/sandbox/config.test.ts +150 -0
  68. package/src/sandbox/config.ts +102 -0
  69. package/src/sandbox/egress.test.ts +113 -0
  70. package/src/sandbox/egress.ts +123 -0
  71. package/src/sandbox/index.ts +180 -0
  72. package/src/sandbox/live-seatbelt.test.ts +277 -0
  73. package/src/sandbox/mounts.test.ts +154 -0
  74. package/src/sandbox/mounts.ts +133 -0
  75. package/src/sandbox/sandbox.test.ts +168 -0
  76. package/src/sandbox/types.ts +382 -0
  77. package/src/services-manifest.test.ts +106 -0
  78. package/src/services-manifest.ts +95 -0
  79. package/src/spa-serve.test.ts +116 -0
  80. package/src/spa-serve.ts +116 -0
  81. package/src/spawn-agent-cli.test.ts +172 -0
  82. package/src/spawn-agent.test.ts +1218 -0
  83. package/src/spawn-agent.ts +569 -0
  84. package/src/spawn-deps.test.ts +54 -0
  85. package/src/spawn-deps.ts +166 -0
  86. package/src/telegram/api.ts +153 -0
  87. package/src/terminal-assets.test.ts +50 -0
  88. package/src/terminal-assets.ts +79 -0
  89. package/src/terminal-ui.ts +305 -0
  90. package/src/terminal.test.ts +530 -0
  91. package/src/terminal.ts +458 -0
  92. package/src/transport.ts +270 -0
  93. package/src/transports/http-ui.test.ts +455 -0
  94. package/src/transports/http-ui.ts +201 -0
  95. package/src/transports/telegram.test.ts +174 -0
  96. package/src/transports/telegram.ts +426 -0
  97. package/src/transports/vault.test.ts +2011 -0
  98. package/src/transports/vault.ts +1790 -0
  99. package/src/ui-kit.test.ts +178 -0
  100. package/src/ui-kit.ts +402 -0
  101. package/tsconfig.json +8 -14
  102. package/web/ui/tsconfig.json +2 -1
  103. package/.claude/scheduled_tasks.lock +0 -1
  104. package/.claude/settings.json +0 -5
  105. package/.claude/skills/add-atomic-chat-tool/SKILL.md +0 -243
  106. package/.claude/skills/add-atomic-chat-tool/atomic-chat-mcp-stdio.ts +0 -229
  107. package/.claude/skills/add-codex/SKILL.md +0 -161
  108. package/.claude/skills/add-dashboard/SKILL.md +0 -138
  109. package/.claude/skills/add-dashboard/resources/dashboard-pusher.ts +0 -495
  110. package/.claude/skills/add-emacs/SKILL.md +0 -296
  111. package/.claude/skills/add-gcal-tool/SKILL.md +0 -210
  112. package/.claude/skills/add-gchat/REMOVE.md +0 -6
  113. package/.claude/skills/add-gchat/SKILL.md +0 -92
  114. package/.claude/skills/add-gchat/VERIFY.md +0 -3
  115. package/.claude/skills/add-github/REMOVE.md +0 -6
  116. package/.claude/skills/add-github/SKILL.md +0 -148
  117. package/.claude/skills/add-github/VERIFY.md +0 -3
  118. package/.claude/skills/add-gmail-tool/SKILL.md +0 -229
  119. package/.claude/skills/add-imessage/REMOVE.md +0 -6
  120. package/.claude/skills/add-imessage/SKILL.md +0 -113
  121. package/.claude/skills/add-imessage/VERIFY.md +0 -3
  122. package/.claude/skills/add-karpathy-llm-wiki/SKILL.md +0 -110
  123. package/.claude/skills/add-karpathy-llm-wiki/llm-wiki.md +0 -75
  124. package/.claude/skills/add-linear/REMOVE.md +0 -6
  125. package/.claude/skills/add-linear/SKILL.md +0 -168
  126. package/.claude/skills/add-linear/VERIFY.md +0 -3
  127. package/.claude/skills/add-macos-statusbar/SKILL.md +0 -133
  128. package/.claude/skills/add-macos-statusbar/add/src/statusbar.swift +0 -147
  129. package/.claude/skills/add-matrix/REMOVE.md +0 -6
  130. package/.claude/skills/add-matrix/SKILL.md +0 -148
  131. package/.claude/skills/add-matrix/VERIFY.md +0 -3
  132. package/.claude/skills/add-ollama-provider/SKILL.md +0 -179
  133. package/.claude/skills/add-ollama-tool/SKILL.md +0 -193
  134. package/.claude/skills/add-opencode/SKILL.md +0 -229
  135. package/.claude/skills/add-parallel/SKILL.md +0 -290
  136. package/.claude/skills/add-resend/REMOVE.md +0 -6
  137. package/.claude/skills/add-resend/SKILL.md +0 -93
  138. package/.claude/skills/add-resend/VERIFY.md +0 -3
  139. package/.claude/skills/add-signal/REMOVE.md +0 -13
  140. package/.claude/skills/add-signal/SKILL.md +0 -318
  141. package/.claude/skills/add-signal/VERIFY.md +0 -5
  142. package/.claude/skills/add-slack/REMOVE.md +0 -6
  143. package/.claude/skills/add-slack/SKILL.md +0 -112
  144. package/.claude/skills/add-slack/VERIFY.md +0 -3
  145. package/.claude/skills/add-teams/REMOVE.md +0 -6
  146. package/.claude/skills/add-teams/SKILL.md +0 -207
  147. package/.claude/skills/add-teams/VERIFY.md +0 -3
  148. package/.claude/skills/add-vercel/SKILL.md +0 -147
  149. package/.claude/skills/add-vercel/container-skills/vercel-cli/SKILL.md +0 -103
  150. package/.claude/skills/add-webex/REMOVE.md +0 -6
  151. package/.claude/skills/add-webex/SKILL.md +0 -88
  152. package/.claude/skills/add-webex/VERIFY.md +0 -3
  153. package/.claude/skills/add-wechat/REMOVE.md +0 -49
  154. package/.claude/skills/add-wechat/SKILL.md +0 -170
  155. package/.claude/skills/add-wechat/scripts/wire-dm.ts +0 -172
  156. package/.claude/skills/add-whatsapp/SKILL.md +0 -264
  157. package/.claude/skills/add-whatsapp-cloud/REMOVE.md +0 -6
  158. package/.claude/skills/add-whatsapp-cloud/SKILL.md +0 -95
  159. package/.claude/skills/add-whatsapp-cloud/VERIFY.md +0 -3
  160. package/.claude/skills/claw/SKILL.md +0 -131
  161. package/.claude/skills/claw/scripts/claw +0 -374
  162. package/.claude/skills/convert-to-apple-container/SKILL.md +0 -212
  163. package/.claude/skills/customize/SKILL.md +0 -110
  164. package/.claude/skills/debug/SKILL.md +0 -349
  165. package/.claude/skills/get-qodo-rules/SKILL.md +0 -122
  166. package/.claude/skills/get-qodo-rules/references/output-format.md +0 -41
  167. package/.claude/skills/get-qodo-rules/references/pagination.md +0 -33
  168. package/.claude/skills/get-qodo-rules/references/repository-scope.md +0 -26
  169. package/.claude/skills/init-first-agent/SKILL.md +0 -120
  170. package/.claude/skills/init-onecli/SKILL.md +0 -270
  171. package/.claude/skills/manage-channels/SKILL.md +0 -87
  172. package/.claude/skills/manage-mounts/SKILL.md +0 -47
  173. package/.claude/skills/migrate-from-openclaw/MIGRATE_CRONS.md +0 -100
  174. package/.claude/skills/migrate-from-openclaw/SKILL.md +0 -447
  175. package/.claude/skills/migrate-from-openclaw/scripts/discover-openclaw.ts +0 -734
  176. package/.claude/skills/migrate-from-openclaw/scripts/extract-channel-credentials.ts +0 -476
  177. package/.claude/skills/migrate-nanoclaw/SKILL.md +0 -484
  178. package/.claude/skills/migrate-nanoclaw/diagnostics.md +0 -51
  179. package/.claude/skills/qodo-pr-resolver/SKILL.md +0 -326
  180. package/.claude/skills/qodo-pr-resolver/resources/providers.md +0 -329
  181. package/.claude/skills/update-nanoclaw/SKILL.md +0 -243
  182. package/.claude/skills/update-nanoclaw/diagnostics.md +0 -48
  183. package/.claude/skills/update-skills/SKILL.md +0 -130
  184. package/.claude/skills/use-native-credential-proxy/SKILL.md +0 -167
  185. package/.claude/skills/x-integration/SKILL.md +0 -417
  186. package/.claude/skills/x-integration/agent.ts +0 -243
  187. package/.claude/skills/x-integration/host.ts +0 -155
  188. package/.claude/skills/x-integration/lib/browser.ts +0 -148
  189. package/.claude/skills/x-integration/lib/config.ts +0 -62
  190. package/.claude/skills/x-integration/scripts/like.ts +0 -56
  191. package/.claude/skills/x-integration/scripts/post.ts +0 -66
  192. package/.claude/skills/x-integration/scripts/quote.ts +0 -80
  193. package/.claude/skills/x-integration/scripts/reply.ts +0 -74
  194. package/.claude/skills/x-integration/scripts/retweet.ts +0 -62
  195. package/.claude/skills/x-integration/scripts/setup.ts +0 -87
  196. package/.github/CODEOWNERS +0 -10
  197. package/.github/PULL_REQUEST_TEMPLATE.md +0 -18
  198. package/.github/workflows/bump-version.yml +0 -35
  199. package/.github/workflows/ci.yml +0 -39
  200. package/.github/workflows/label-pr.yml +0 -40
  201. package/.github/workflows/update-tokens.yml +0 -43
  202. package/.husky/pre-commit +0 -1
  203. package/.mcp.json +0 -3
  204. package/.nvmrc +0 -1
  205. package/.prettierrc +0 -4
  206. package/CHANGELOG.md +0 -221
  207. package/CLAUDE.md +0 -307
  208. package/CODE_OF_CONDUCT.md +0 -128
  209. package/CONTRIBUTING.md +0 -159
  210. package/CONTRIBUTORS.md +0 -26
  211. package/LICENSE-NANOCLAW-MIT +0 -21
  212. package/README_ja.md +0 -194
  213. package/README_zh.md +0 -194
  214. package/assets/nanoclaw-favicon.png +0 -0
  215. package/assets/nanoclaw-icon.png +0 -0
  216. package/assets/nanoclaw-logo-dark.png +0 -0
  217. package/assets/nanoclaw-logo.png +0 -0
  218. package/assets/nanoclaw-profile.jpeg +0 -0
  219. package/assets/nanoclaw-sales.png +0 -0
  220. package/assets/social-preview.jpg +0 -0
  221. package/config-examples/mount-allowlist.json +0 -25
  222. package/container/.dockerignore +0 -2
  223. package/container/CLAUDE.md +0 -21
  224. package/container/Dockerfile +0 -121
  225. package/container/agent-runner/bun.lock +0 -243
  226. package/container/agent-runner/package.json +0 -22
  227. package/container/agent-runner/scripts/sdk-signal-probe.ts +0 -169
  228. package/container/agent-runner/src/config.ts +0 -55
  229. package/container/agent-runner/src/db/connection.ts +0 -267
  230. package/container/agent-runner/src/db/index.ts +0 -20
  231. package/container/agent-runner/src/db/messages-in.ts +0 -138
  232. package/container/agent-runner/src/db/messages-out.ts +0 -143
  233. package/container/agent-runner/src/db/session-routing.ts +0 -30
  234. package/container/agent-runner/src/db/session-state.test.ts +0 -100
  235. package/container/agent-runner/src/db/session-state.ts +0 -79
  236. package/container/agent-runner/src/destinations.ts +0 -135
  237. package/container/agent-runner/src/formatter.test.ts +0 -167
  238. package/container/agent-runner/src/formatter.ts +0 -260
  239. package/container/agent-runner/src/index.ts +0 -110
  240. package/container/agent-runner/src/integration.test.ts +0 -121
  241. package/container/agent-runner/src/mcp-tools/agents.instructions.md +0 -26
  242. package/container/agent-runner/src/mcp-tools/agents.ts +0 -66
  243. package/container/agent-runner/src/mcp-tools/core.instructions.md +0 -27
  244. package/container/agent-runner/src/mcp-tools/core.ts +0 -262
  245. package/container/agent-runner/src/mcp-tools/index.ts +0 -22
  246. package/container/agent-runner/src/mcp-tools/interactive.instructions.md +0 -22
  247. package/container/agent-runner/src/mcp-tools/interactive.ts +0 -169
  248. package/container/agent-runner/src/mcp-tools/scheduling.instructions.md +0 -40
  249. package/container/agent-runner/src/mcp-tools/scheduling.ts +0 -299
  250. package/container/agent-runner/src/mcp-tools/self-mod.instructions.md +0 -25
  251. package/container/agent-runner/src/mcp-tools/self-mod.ts +0 -120
  252. package/container/agent-runner/src/mcp-tools/server.ts +0 -54
  253. package/container/agent-runner/src/mcp-tools/types.ts +0 -6
  254. package/container/agent-runner/src/poll-loop.test.ts +0 -248
  255. package/container/agent-runner/src/poll-loop.ts +0 -437
  256. package/container/agent-runner/src/providers/claude.ts +0 -379
  257. package/container/agent-runner/src/providers/factory.test.ts +0 -19
  258. package/container/agent-runner/src/providers/factory.ts +0 -13
  259. package/container/agent-runner/src/providers/index.ts +0 -6
  260. package/container/agent-runner/src/providers/mock.ts +0 -77
  261. package/container/agent-runner/src/providers/provider-registry.ts +0 -33
  262. package/container/agent-runner/src/providers/types.ts +0 -82
  263. package/container/agent-runner/src/scheduling/task-script.ts +0 -121
  264. package/container/agent-runner/src/timezone.test.ts +0 -93
  265. package/container/agent-runner/src/timezone.ts +0 -107
  266. package/container/agent-runner/tsconfig.json +0 -14
  267. package/container/build.sh +0 -48
  268. package/container/entrypoint.sh +0 -16
  269. package/container/skills/agent-browser/SKILL.md +0 -159
  270. package/container/skills/frontend-engineer/SKILL.md +0 -157
  271. package/container/skills/self-customize/SKILL.md +0 -87
  272. package/container/skills/slack-formatting/SKILL.md +0 -94
  273. package/container/skills/vercel-cli/SKILL.md +0 -111
  274. package/container/skills/welcome/SKILL.md +0 -85
  275. package/docs/APPLE-CONTAINER-NETWORKING.md +0 -90
  276. package/docs/BRANCH-FORK-MAINTENANCE.md +0 -81
  277. package/docs/README.md +0 -25
  278. package/docs/SDK_DEEP_DIVE.md +0 -643
  279. package/docs/SECURITY.md +0 -162
  280. package/docs/agent-runner-details.md +0 -749
  281. package/docs/api-details.md +0 -365
  282. package/docs/architecture-diagram.html +0 -422
  283. package/docs/architecture-diagram.md +0 -215
  284. package/docs/architecture.md +0 -751
  285. package/docs/audit/2026-04-30-channel-endpoint-audit.md +0 -36
  286. package/docs/build-and-runtime.md +0 -80
  287. package/docs/cross-mount-stress/README.md +0 -112
  288. package/docs/cross-mount-stress/container-writer-retry.mjs +0 -55
  289. package/docs/cross-mount-stress/container-writer-slow.mjs +0 -42
  290. package/docs/cross-mount-stress/container-writer.mjs +0 -47
  291. package/docs/cross-mount-stress/host-writer-retry.mjs +0 -55
  292. package/docs/cross-mount-stress/host-writer-slow.mjs +0 -43
  293. package/docs/cross-mount-stress/host-writer.mjs +0 -47
  294. package/docs/db-central.md +0 -316
  295. package/docs/db-session.md +0 -183
  296. package/docs/db.md +0 -119
  297. package/docs/design/2026-04-29-vault-management-ui.md +0 -231
  298. package/docs/design/2026-04-30-channel-wiring-rework.md +0 -234
  299. package/docs/design/2026-05-01-channel-wiring-approvals-deep-dive.md +0 -272
  300. package/docs/design/2026-05-02-channel-policy-and-approval-routing.md +0 -250
  301. package/docs/docker-sandboxes.md +0 -359
  302. package/docs/isolation-model.md +0 -88
  303. package/docs/ollama.md +0 -79
  304. package/docs/parachute-integration.md +0 -109
  305. package/docs/post-night-rebirth-reflections.md +0 -151
  306. package/eslint.config.js +0 -32
  307. package/pnpm-workspace.yaml +0 -8
  308. package/repo-tokens/README.md +0 -113
  309. package/repo-tokens/action.yml +0 -186
  310. package/repo-tokens/badge.svg +0 -23
  311. package/repo-tokens/examples/green.svg +0 -14
  312. package/repo-tokens/examples/red.svg +0 -14
  313. package/repo-tokens/examples/yellow-green.svg +0 -14
  314. package/repo-tokens/examples/yellow.svg +0 -14
  315. package/scripts/chat.ts +0 -101
  316. package/scripts/cleanup-sessions.sh +0 -150
  317. package/scripts/init-cli-agent.ts +0 -171
  318. package/scripts/init-first-agent.ts +0 -377
  319. package/scripts/parachute.ts +0 -158
  320. package/scripts/run-migrations.ts +0 -105
  321. package/scripts/sanity-live-poll.ts +0 -95
  322. package/scripts/seed-discord.ts +0 -79
  323. package/scripts/test-v2-agent.ts +0 -106
  324. package/scripts/test-v2-channel-e2e.ts +0 -265
  325. package/scripts/test-v2-host.ts +0 -184
  326. package/src/channels/adapter.ts +0 -214
  327. package/src/channels/ask-question.ts +0 -46
  328. package/src/channels/channel-registry.test.ts +0 -421
  329. package/src/channels/channel-registry.ts +0 -313
  330. package/src/channels/chat-sdk-bridge.test.ts +0 -84
  331. package/src/channels/chat-sdk-bridge.ts +0 -652
  332. package/src/channels/cli.ts +0 -276
  333. package/src/channels/discord.ts +0 -90
  334. package/src/channels/index.ts +0 -17
  335. package/src/channels/telegram-markdown-sanitize.test.ts +0 -78
  336. package/src/channels/telegram-markdown-sanitize.ts +0 -55
  337. package/src/channels/telegram-pairing.test.ts +0 -254
  338. package/src/channels/telegram-pairing.ts +0 -339
  339. package/src/channels/telegram.ts +0 -279
  340. package/src/channels/trust-hint.test.ts +0 -48
  341. package/src/channels/trust-hint.ts +0 -75
  342. package/src/claude-md-compose.migrate.test.ts +0 -64
  343. package/src/claude-md-compose.ts +0 -205
  344. package/src/command-gate.ts +0 -63
  345. package/src/config.test.ts +0 -93
  346. package/src/config.ts +0 -108
  347. package/src/container-config.ts +0 -167
  348. package/src/container-runner.test.ts +0 -32
  349. package/src/container-runner.ts +0 -576
  350. package/src/container-runtime.test.ts +0 -169
  351. package/src/container-runtime.ts +0 -92
  352. package/src/db/_bun-sqlite-shim.ts +0 -88
  353. package/src/db/agent-activity.test.ts +0 -155
  354. package/src/db/agent-activity.ts +0 -121
  355. package/src/db/agent-groups.ts +0 -77
  356. package/src/db/connection.migrate.test.ts +0 -143
  357. package/src/db/connection.ts +0 -224
  358. package/src/db/db-v2.test.ts +0 -440
  359. package/src/db/dropped-messages.ts +0 -44
  360. package/src/db/index.ts +0 -40
  361. package/src/db/messaging-groups.ts +0 -252
  362. package/src/db/migrations/001-initial.ts +0 -112
  363. package/src/db/migrations/002-chat-sdk-state.ts +0 -36
  364. package/src/db/migrations/008-dropped-messages.ts +0 -27
  365. package/src/db/migrations/009-drop-pending-credentials.ts +0 -13
  366. package/src/db/migrations/010-engage-modes.ts +0 -103
  367. package/src/db/migrations/011-pending-sender-approvals.ts +0 -40
  368. package/src/db/migrations/012-channel-registration.ts +0 -48
  369. package/src/db/migrations/013-approval-render-metadata.ts +0 -27
  370. package/src/db/migrations/014-secrets.ts +0 -44
  371. package/src/db/migrations/015-secrets-drop-host-pattern.ts +0 -18
  372. package/src/db/migrations/016-secret-assignments.ts +0 -30
  373. package/src/db/migrations/017-agent-activity.ts +0 -40
  374. package/src/db/migrations/018-oauth-app-configs.ts +0 -34
  375. package/src/db/migrations/019-oauth-app-connections.ts +0 -48
  376. package/src/db/migrations/020-agent-app-connections.ts +0 -28
  377. package/src/db/migrations/021-pending-oauth-states.ts +0 -35
  378. package/src/db/migrations/022-app-connections-provider.ts +0 -25
  379. package/src/db/migrations/023-agent-group-secret-mode.test.ts +0 -124
  380. package/src/db/migrations/023-agent-group-secret-mode.ts +0 -65
  381. package/src/db/migrations/024-collapse-approvals.test.ts +0 -249
  382. package/src/db/migrations/024-collapse-approvals.ts +0 -182
  383. package/src/db/migrations/025-secret-mode-check.test.ts +0 -155
  384. package/src/db/migrations/025-secret-mode-check.ts +0 -49
  385. package/src/db/migrations/026-user-dms-bot-id.test.ts +0 -116
  386. package/src/db/migrations/026-user-dms-bot-id.ts +0 -54
  387. package/src/db/migrations/027-provider-credentials.ts +0 -41
  388. package/src/db/migrations/_test-helpers.ts +0 -41
  389. package/src/db/migrations/index.ts +0 -127
  390. package/src/db/migrations/module-agent-to-agent-destinations.ts +0 -84
  391. package/src/db/migrations/module-approvals-pending-approvals.ts +0 -42
  392. package/src/db/migrations/module-approvals-title-options.ts +0 -40
  393. package/src/db/schema.ts +0 -258
  394. package/src/db/session-db.test.ts +0 -93
  395. package/src/db/session-db.ts +0 -325
  396. package/src/db/sessions.ts +0 -241
  397. package/src/delivery.test.ts +0 -148
  398. package/src/delivery.ts +0 -445
  399. package/src/env.ts +0 -74
  400. package/src/group-folder.test.ts +0 -35
  401. package/src/group-folder.ts +0 -44
  402. package/src/group-init.ts +0 -92
  403. package/src/host-core.test.ts +0 -456
  404. package/src/host-sweep.test.ts +0 -146
  405. package/src/host-sweep.ts +0 -287
  406. package/src/index.ts +0 -227
  407. package/src/install-slug.ts +0 -33
  408. package/src/log.test.ts +0 -81
  409. package/src/log.ts +0 -117
  410. package/src/mcp/http.ts +0 -72
  411. package/src/mcp/server.ts +0 -92
  412. package/src/mcp/stdio.ts +0 -51
  413. package/src/mcp/tools/activity.ts +0 -88
  414. package/src/mcp/tools/agent-groups.ts +0 -183
  415. package/src/mcp/tools/approvals.ts +0 -122
  416. package/src/mcp/tools/channels.ts +0 -199
  417. package/src/mcp/tools/index.ts +0 -27
  418. package/src/mcp/tools/oauth.ts +0 -48
  419. package/src/mcp/tools/secrets.ts +0 -169
  420. package/src/mcp/tools/sessions.ts +0 -135
  421. package/src/mcp/types.ts +0 -51
  422. package/src/modules/agent-to-agent/agent-route.test.ts +0 -46
  423. package/src/modules/agent-to-agent/agent-route.ts +0 -223
  424. package/src/modules/agent-to-agent/create-agent.ts +0 -127
  425. package/src/modules/agent-to-agent/db/agent-destinations.ts +0 -135
  426. package/src/modules/agent-to-agent/index.ts +0 -22
  427. package/src/modules/agent-to-agent/write-destinations.ts +0 -59
  428. package/src/modules/approvals/agent.md +0 -45
  429. package/src/modules/approvals/index.ts +0 -21
  430. package/src/modules/approvals/picks.test.ts +0 -291
  431. package/src/modules/approvals/primitive.ts +0 -279
  432. package/src/modules/approvals/project.md +0 -27
  433. package/src/modules/approvals/response-handler.ts +0 -87
  434. package/src/modules/index.ts +0 -24
  435. package/src/modules/interactive/agent.md +0 -21
  436. package/src/modules/interactive/index.ts +0 -69
  437. package/src/modules/interactive/project.md +0 -12
  438. package/src/modules/mount-security/index.ts +0 -448
  439. package/src/modules/mount-security/migrate.test.ts +0 -91
  440. package/src/modules/permissions/access.ts +0 -28
  441. package/src/modules/permissions/channel-approval.test.ts +0 -389
  442. package/src/modules/permissions/channel-approval.ts +0 -188
  443. package/src/modules/permissions/db/agent-group-members.ts +0 -44
  444. package/src/modules/permissions/db/pending-channel-approvals.test.ts +0 -86
  445. package/src/modules/permissions/db/pending-channel-approvals.ts +0 -66
  446. package/src/modules/permissions/db/pending-sender-approvals.ts +0 -60
  447. package/src/modules/permissions/db/user-dms.ts +0 -58
  448. package/src/modules/permissions/db/user-roles.ts +0 -85
  449. package/src/modules/permissions/db/users.ts +0 -38
  450. package/src/modules/permissions/index.ts +0 -421
  451. package/src/modules/permissions/permissions.test.ts +0 -358
  452. package/src/modules/permissions/sender-approval.test.ts +0 -470
  453. package/src/modules/permissions/sender-approval.ts +0 -165
  454. package/src/modules/permissions/user-dm.ts +0 -200
  455. package/src/modules/provider-credentials/db.ts +0 -121
  456. package/src/modules/provider-credentials/index.ts +0 -12
  457. package/src/modules/provider-credentials/spawn.test.ts +0 -206
  458. package/src/modules/provider-credentials/spawn.ts +0 -114
  459. package/src/modules/scheduling/actions.ts +0 -113
  460. package/src/modules/scheduling/db.test.ts +0 -282
  461. package/src/modules/scheduling/db.ts +0 -148
  462. package/src/modules/scheduling/index.ts +0 -34
  463. package/src/modules/scheduling/recurrence.test.ts +0 -98
  464. package/src/modules/scheduling/recurrence.ts +0 -54
  465. package/src/modules/self-mod/agent.md +0 -30
  466. package/src/modules/self-mod/apply.ts +0 -85
  467. package/src/modules/self-mod/index.ts +0 -30
  468. package/src/modules/self-mod/project.md +0 -39
  469. package/src/modules/self-mod/request.ts +0 -91
  470. package/src/modules/typing/index.ts +0 -165
  471. package/src/oauth/agent-app-connections.ts +0 -103
  472. package/src/oauth/app-configs.test.ts +0 -64
  473. package/src/oauth/app-configs.ts +0 -114
  474. package/src/oauth/app-connections.test.ts +0 -109
  475. package/src/oauth/app-connections.ts +0 -178
  476. package/src/oauth/crypto.ts +0 -56
  477. package/src/oauth/flow.ts +0 -104
  478. package/src/oauth/providers/google.test.ts +0 -38
  479. package/src/oauth/providers/google.ts +0 -46
  480. package/src/oauth/providers/index.ts +0 -48
  481. package/src/oauth/state-store.test.ts +0 -54
  482. package/src/oauth/state-store.ts +0 -93
  483. package/src/parachute/README.md +0 -27
  484. package/src/parachute/create-agent.test.ts +0 -83
  485. package/src/parachute/create-agent.ts +0 -122
  486. package/src/parachute/group-status.test.ts +0 -165
  487. package/src/parachute/group-status.ts +0 -136
  488. package/src/parachute/types.ts +0 -41
  489. package/src/parachute/vault-mcp.test.ts +0 -251
  490. package/src/parachute/vault-mcp.ts +0 -232
  491. package/src/platform-id.test.ts +0 -104
  492. package/src/platform-id.ts +0 -109
  493. package/src/providers/index.ts +0 -6
  494. package/src/providers/provider-container-registry.ts +0 -58
  495. package/src/response-registry.ts +0 -45
  496. package/src/router.ts +0 -530
  497. package/src/secrets/crypto.test.ts +0 -45
  498. package/src/secrets/crypto.ts +0 -55
  499. package/src/secrets/index.ts +0 -355
  500. package/src/secrets/master-key.ts +0 -70
  501. package/src/secrets/secrets.test.ts +0 -354
  502. package/src/session-manager.migrate.test.ts +0 -59
  503. package/src/session-manager.ts +0 -433
  504. package/src/startup-bootstrap.test.ts +0 -226
  505. package/src/startup-bootstrap.ts +0 -207
  506. package/src/state-sqlite.ts +0 -182
  507. package/src/timezone.test.ts +0 -64
  508. package/src/timezone.ts +0 -37
  509. package/src/types.ts +0 -230
  510. package/src/web/auth.test.ts +0 -335
  511. package/src/web/auth.ts +0 -214
  512. package/src/web/discord-validate.test.ts +0 -77
  513. package/src/web/discord-validate.ts +0 -88
  514. package/src/web/hub-discovery.test.ts +0 -98
  515. package/src/web/hub-discovery.ts +0 -69
  516. package/src/web/routes/activity.ts +0 -106
  517. package/src/web/routes/agent-provider.test.ts +0 -282
  518. package/src/web/routes/agent-provider.ts +0 -309
  519. package/src/web/routes/approvals.ts +0 -185
  520. package/src/web/routes/apps.ts +0 -434
  521. package/src/web/routes/channels-mg-detail.test.ts +0 -324
  522. package/src/web/routes/channels-mga-detail.test.ts +0 -425
  523. package/src/web/routes/channels.ts +0 -489
  524. package/src/web/routes/oauth-providers.ts +0 -42
  525. package/src/web/routes/secrets.test.ts +0 -175
  526. package/src/web/routes/secrets.ts +0 -282
  527. package/src/web/routes/sessions.ts +0 -123
  528. package/src/web/routes/settings.test.ts +0 -106
  529. package/src/web/routes/settings.ts +0 -247
  530. package/src/web/routes/setup-status.ts +0 -205
  531. package/src/web/routes/vaults.test.ts +0 -389
  532. package/src/web/routes/vaults.ts +0 -225
  533. package/src/web/server-version.test.ts +0 -16
  534. package/src/web/server.ts +0 -1003
  535. package/src/web/services-manifest.test.ts +0 -120
  536. package/src/web/services-manifest.ts +0 -61
  537. package/src/web/static-serve.test.ts +0 -255
  538. package/src/web/static-serve.ts +0 -104
  539. package/src/web/telegram-validate.test.ts +0 -116
  540. package/src/web/telegram-validate.ts +0 -107
  541. package/src/web/vault-proxy.test.ts +0 -214
  542. package/src/web/vault-proxy.ts +0 -120
  543. package/src/web/wire-channel.ts +0 -181
  544. package/src/webhook-server.ts +0 -134
  545. package/vitest.config.ts +0 -18
  546. package/web/README.md +0 -63
  547. package/web/ui/index.html +0 -13
  548. package/web/ui/package.json +0 -35
  549. package/web/ui/pnpm-lock.yaml +0 -2164
  550. package/web/ui/scripts/verify-base.mjs +0 -31
  551. package/web/ui/src/App.tsx +0 -88
  552. package/web/ui/src/components/ActivityFeed.tsx +0 -444
  553. package/web/ui/src/components/AgentGroupPicker.tsx +0 -263
  554. package/web/ui/src/components/AgentProviderCards.tsx +0 -220
  555. package/web/ui/src/components/CredentialForm.tsx +0 -214
  556. package/web/ui/src/components/ScopeGrants.tsx +0 -74
  557. package/web/ui/src/components/StatusDot.tsx +0 -43
  558. package/web/ui/src/components/VaultPicker.tsx +0 -127
  559. package/web/ui/src/components/setup/AdapterInstallStep.tsx +0 -178
  560. package/web/ui/src/components/setup/AgentGroupStep.tsx +0 -43
  561. package/web/ui/src/components/setup/ChannelPickStep.tsx +0 -74
  562. package/web/ui/src/components/setup/DoneStep.tsx +0 -49
  563. package/web/ui/src/components/setup/PrereqStep.tsx +0 -129
  564. package/web/ui/src/components/setup/TestConnectionStep.tsx +0 -108
  565. package/web/ui/src/components/setup/TestMessageStep.tsx +0 -104
  566. package/web/ui/src/components/setup/WireChannelStep.tsx +0 -166
  567. package/web/ui/src/components/setup/types.ts +0 -105
  568. package/web/ui/src/lib/api.test.ts +0 -410
  569. package/web/ui/src/lib/api.ts +0 -1210
  570. package/web/ui/src/lib/auth.test.ts +0 -139
  571. package/web/ui/src/lib/auth.ts +0 -348
  572. package/web/ui/src/lib/channel-adapters.ts +0 -136
  573. package/web/ui/src/main.tsx +0 -19
  574. package/web/ui/src/routes/ApprovalsList.tsx +0 -294
  575. package/web/ui/src/routes/Apps.tsx +0 -613
  576. package/web/ui/src/routes/ChannelWireDetail.test.tsx +0 -233
  577. package/web/ui/src/routes/ChannelWireDetail.tsx +0 -403
  578. package/web/ui/src/routes/ChannelsList.tsx +0 -158
  579. package/web/ui/src/routes/GroupDetail.tsx +0 -755
  580. package/web/ui/src/routes/GroupList.tsx +0 -187
  581. package/web/ui/src/routes/MessagingGroupDetail.test.tsx +0 -233
  582. package/web/ui/src/routes/MessagingGroupDetail.tsx +0 -306
  583. package/web/ui/src/routes/NewGroupWizard.tsx +0 -390
  584. package/web/ui/src/routes/OAuthCallback.tsx +0 -56
  585. package/web/ui/src/routes/SecretsList.tsx +0 -921
  586. package/web/ui/src/routes/SessionsList.tsx +0 -220
  587. package/web/ui/src/routes/SettingsAgentProvider.tsx +0 -109
  588. package/web/ui/src/routes/SettingsApprovals.tsx +0 -234
  589. package/web/ui/src/routes/SetupWizard.tsx +0 -219
  590. package/web/ui/src/routes/VaultDetail.test.tsx +0 -361
  591. package/web/ui/src/routes/VaultDetail.tsx +0 -960
  592. package/web/ui/src/routes/VaultsList.tsx +0 -295
  593. package/web/ui/src/routes/WireChannelPage.tsx +0 -413
  594. package/web/ui/src/styles.css +0 -608
  595. package/web/ui/src/test/setup.ts +0 -23
  596. package/web/ui/src/vite-env.d.ts +0 -10
  597. package/web/ui/vite.config.ts +0 -34
  598. package/web/ui/vitest.config.ts +0 -25
@@ -0,0 +1,1218 @@
1
+ import { describe, test, expect, afterEach } from "bun:test";
2
+ import { mkdtempSync, rmSync, readFileSync, statSync, writeFileSync, existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ // SHARED spawn helpers (live tree).
6
+ import {
7
+ buildAgentChildEnv,
8
+ mergeSandboxLaunchEnv,
9
+ SANDBOX_ENV_ALLOWLIST,
10
+ resolveAgentCwd,
11
+ seedAgentHome,
12
+ sessionWorkspace,
13
+ shellJoin,
14
+ persistSpec,
15
+ readPersistedSpec,
16
+ specFilePath,
17
+ } from "./spawn-agent.ts";
18
+ // PARKED interactive spawner (the interactive backend retired 2026-06-19; its
19
+ // spawner + tmux launcher live in src/_parked/interactive-spawn.ts now — these tests
20
+ // still exercise that parked code so it stays buildable for the future revival).
21
+ import {
22
+ spawnAgent,
23
+ buildAgentClaudeArgs,
24
+ buildLaunchScript,
25
+ confirmDevChannelsPrompt,
26
+ DEV_CHANNELS_PROMPT_MARKER,
27
+ DEV_CHANNELS_READY_MARKER,
28
+ realTmuxLauncher,
29
+ sessionName,
30
+ type SpawnAgentDeps,
31
+ type TmuxLauncher,
32
+ } from "./_parked/interactive-spawn.ts";
33
+ import type { SandboxEngine } from "./sandbox/index.ts";
34
+ import type { SandboxRuntimeConfig } from "@anthropic-ai/sandbox-runtime";
35
+ import type { AgentSpec } from "./sandbox/types.ts";
36
+ import { channelEntryKey, vaultEntryKey } from "./agent-mcp-config.ts";
37
+ import {
38
+ setDefaultClaudeCredential,
39
+ setChannelClaudeCredential,
40
+ } from "./credentials.ts";
41
+
42
+ let sessionsDir: string;
43
+ afterEach(() => {
44
+ if (sessionsDir) rmSync(sessionsDir, { recursive: true, force: true });
45
+ });
46
+
47
+ // ---- fakes -----------------------------------------------------------------
48
+
49
+ /** A recording tmux launcher. */
50
+ function recordingTmux(existing = new Set<string>()): TmuxLauncher & {
51
+ launched: Array<{
52
+ name: string;
53
+ argv: string[];
54
+ env: Record<string, string | undefined>;
55
+ cwd: string;
56
+ scriptDir?: string;
57
+ }>;
58
+ confirmed: string[];
59
+ } {
60
+ const launched: Array<{
61
+ name: string;
62
+ argv: string[];
63
+ env: Record<string, string | undefined>;
64
+ cwd: string;
65
+ scriptDir?: string;
66
+ }> = [];
67
+ const confirmed: string[] = [];
68
+ return {
69
+ launched,
70
+ confirmed,
71
+ async hasSession(name) {
72
+ return existing.has(name);
73
+ },
74
+ async newSession(opts) {
75
+ launched.push(opts);
76
+ },
77
+ async confirmDevChannelsPrompt(session) {
78
+ confirmed.push(session);
79
+ return "confirmed";
80
+ },
81
+ };
82
+ }
83
+
84
+ /** A fake sandbox engine — records config, returns a deterministic wrap. */
85
+ function fakeEngine(): SandboxEngine & { initializedWith: SandboxRuntimeConfig | null } {
86
+ const rec = {
87
+ initializedWith: null as SandboxRuntimeConfig | null,
88
+ isSupportedPlatform: () => true,
89
+ isSandboxingEnabled: () => true,
90
+ async initialize(cfg: SandboxRuntimeConfig) {
91
+ rec.initializedWith = cfg;
92
+ },
93
+ async wrapWithSandboxArgv(command: string) {
94
+ // Emulate the real shape: a bash -c wrapper carrying the command + proxy env.
95
+ return {
96
+ argv: ["/bin/bash", "-c", `SBX ${command}`],
97
+ // Include a TMPDIR the engine would set — spawnAgent must OVERRIDE it with
98
+ // a workspace-writable path (the override regression guard below).
99
+ env: { SANDBOX_RUNTIME: "1", HTTPS_PROXY: "http://localhost:5555", TMPDIR: "/tmp/claude" },
100
+ };
101
+ },
102
+ async reset() {},
103
+ };
104
+ return rec;
105
+ }
106
+
107
+ /** A fake mint hub: returns a distinct token per scope so we can tell them apart. */
108
+ function fakeMintFetch(): typeof fetch {
109
+ let n = 0;
110
+ return (async (_url: string | URL | Request, init?: RequestInit) => {
111
+ const body = JSON.parse(String(init?.body ?? "{}")) as { scope: string };
112
+ n += 1;
113
+ const token = `TOK-${n}-${body.scope.replace(/[^a-z]/gi, "").slice(0, 6)}`;
114
+ return new Response(
115
+ JSON.stringify({ jti: `j${n}`, token, expires_at: "2026-09-01T00:00:00Z", scope: body.scope }),
116
+ { status: 200, headers: { "content-type": "application/json" } },
117
+ );
118
+ }) as unknown as typeof fetch;
119
+ }
120
+
121
+ function baseDeps(over: Partial<SpawnAgentDeps> = {}): SpawnAgentDeps {
122
+ return {
123
+ hubOrigin: "https://hub.example.com",
124
+ managerBearer: "MANAGER",
125
+ channelUrl: "http://127.0.0.1:1941",
126
+ vaultUrl: "http://127.0.0.1:1940",
127
+ sessionsDir,
128
+ runtimeReadOnly: ["/cfg/.claude"],
129
+ // Stub the credential resolver so the test never touches a real store; the
130
+ // assertion below checks this exact token lands in CLAUDE_CODE_OAUTH_TOKEN.
131
+ resolveClaudeToken: () => "OAUTH-CRED-PLACEHOLDER",
132
+ sandboxEngine: fakeEngine(),
133
+ tmux: recordingTmux(),
134
+ fetchFn: fakeMintFetch(),
135
+ parentEnv: {
136
+ PATH: "/usr/bin",
137
+ HOME: "/home/op",
138
+ ANTHROPIC_API_KEY: "sk-ant-SHOULD-NOT-LEAK",
139
+ CLAUDE_API_KEY: "also-should-not-leak",
140
+ SECRET_THING: "do-not-pass",
141
+ },
142
+ claudeBin: "claude",
143
+ ...over,
144
+ };
145
+ }
146
+
147
+ // ---- pure-helper tests -----------------------------------------------------
148
+
149
+ describe("buildAgentChildEnv — scrub, inject OAuth, NEVER ANTHROPIC_API_KEY", () => {
150
+ test("injects CLAUDE_CODE_OAUTH_TOKEN as the session auth", () => {
151
+ const env = buildAgentChildEnv({ PATH: "/usr/bin", HOME: "/h" }, "THE-OAUTH-TOKEN");
152
+ expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBe("THE-OAUTH-TOKEN");
153
+ });
154
+
155
+ test("SECURITY: ANTHROPIC_API_KEY is NOT passed through (would route to API billing)", () => {
156
+ const env = buildAgentChildEnv(
157
+ { PATH: "/usr/bin", HOME: "/h", ANTHROPIC_API_KEY: "sk-ant-x", CLAUDE_API_KEY: "y" },
158
+ "tok",
159
+ );
160
+ expect(env.ANTHROPIC_API_KEY).toBeUndefined();
161
+ expect(env.CLAUDE_API_KEY).toBeUndefined();
162
+ });
163
+
164
+ test("scrubs unrelated parent env (only the allowlist + locale pass)", () => {
165
+ const env = buildAgentChildEnv(
166
+ { PATH: "/usr/bin", HOME: "/h", SECRET_THING: "nope", LC_ALL: "en_US.UTF-8" },
167
+ "tok",
168
+ );
169
+ expect(env.SECRET_THING).toBeUndefined();
170
+ expect(env.PATH).toBe("/usr/bin");
171
+ expect(env.HOME).toBe("/h");
172
+ expect(env.LC_ALL).toBe("en_US.UTF-8");
173
+ });
174
+
175
+ test("provides a default PATH if the parent had none", () => {
176
+ const env = buildAgentChildEnv({}, "tok");
177
+ expect(env.PATH).toBe("/usr/local/bin:/usr/bin:/bin");
178
+ });
179
+
180
+ test("INJECTION: the per-channel env reaches the child (gh/git see the token)", () => {
181
+ const env = buildAgentChildEnv(
182
+ { PATH: "/usr/bin", HOME: "/h" },
183
+ "tok",
184
+ { GH_TOKEN: "ghp_X", CLOUDFLARE_API_TOKEN: "cf_Y" },
185
+ );
186
+ expect(env.GH_TOKEN).toBe("ghp_X");
187
+ expect(env.CLOUDFLARE_API_TOKEN).toBe("cf_Y");
188
+ });
189
+
190
+ test("INJECTION: a channel-set var can NOT clobber CLAUDE_CODE_OAUTH_TOKEN (auth wins)", () => {
191
+ // Even if the store somehow carried CLAUDE_CODE_OAUTH_TOKEN, the managed token
192
+ // set last must win — and the denylist drop means it never even lands.
193
+ const env = buildAgentChildEnv(
194
+ { PATH: "/usr/bin" },
195
+ "THE-REAL-OAUTH",
196
+ { CLAUDE_CODE_OAUTH_TOKEN: "ATTACKER-SWAP", GH_TOKEN: "ghp_X" },
197
+ );
198
+ expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBe("THE-REAL-OAUTH");
199
+ expect(env.GH_TOKEN).toBe("ghp_X");
200
+ });
201
+
202
+ test("INJECTION: a channel-set var can NOT clobber a structural passthrough (PATH/HOME)", () => {
203
+ const env = buildAgentChildEnv(
204
+ { PATH: "/real/path", HOME: "/real/home" },
205
+ "tok",
206
+ { PATH: "/evil", HOME: "/evil" },
207
+ );
208
+ expect(env.PATH).toBe("/real/path");
209
+ expect(env.HOME).toBe("/real/home");
210
+ });
211
+
212
+ test("INJECTION: denylisted keys (API keys) are dropped defensively with a warning", () => {
213
+ const warnings: string[] = [];
214
+ const orig = console.warn;
215
+ console.warn = (...a: unknown[]) => warnings.push(a.map(String).join(" "));
216
+ try {
217
+ const env = buildAgentChildEnv(
218
+ { PATH: "/usr/bin" },
219
+ "tok",
220
+ { ANTHROPIC_API_KEY: "sk-ant-SMUGGLED", CLAUDE_API_KEY: "y", GH_TOKEN: "ghp_X" },
221
+ );
222
+ expect(env.ANTHROPIC_API_KEY).toBeUndefined();
223
+ expect(env.CLAUDE_API_KEY).toBeUndefined();
224
+ expect(env.GH_TOKEN).toBe("ghp_X"); // the legit var still passes
225
+ expect(warnings.some((w) => w.includes("ANTHROPIC_API_KEY") && w.includes("denylisted"))).toBe(true);
226
+ } finally {
227
+ console.warn = orig;
228
+ }
229
+ });
230
+
231
+ test("INJECTION: an empty channel env is a no-op (back-compat default arg)", () => {
232
+ const env = buildAgentChildEnv({ PATH: "/usr/bin" }, "tok");
233
+ expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBe("tok");
234
+ expect(env.PATH).toBe("/usr/bin");
235
+ });
236
+ });
237
+
238
+ describe("mergeSandboxLaunchEnv — the scrub WINS over the engine's returned env", () => {
239
+ // The REAL `wrapWithSandboxArgv` returns `env: process.env` (the FULL daemon env) on
240
+ // macOS/Linux; on Windows it returns `{...process.env, ...proxy}`. So `wrapped.env` is
241
+ // essentially the whole daemon env. The old `{ ...childEnv, ...wrapped.env, ...homeEnv }`
242
+ // spread let that OVERRIDE the scrubbed childEnv — re-admitting the daemon's ambient
243
+ // ANTHROPIC_API_KEY/secrets into the sandboxed turn (isolation/billing leak).
244
+
245
+ const childEnv = buildAgentChildEnv({ PATH: "/usr/bin", HOME: "/h" }, "THE-OAUTH-TOKEN");
246
+ // A representative `wrapped.env` = the daemon's process.env + the sandbox/proxy vars.
247
+ const wrappedEnv = {
248
+ ANTHROPIC_API_KEY: "sk-ant-DAEMON-AMBIENT",
249
+ CLAUDE_API_KEY: "daemon-ambient",
250
+ CLAUDE_CODE_OAUTH_TOKEN: "WRONG-DAEMON-TOKEN",
251
+ SECRET_THING: "daemon-secret",
252
+ PATH: "/daemon/bin",
253
+ SANDBOX_RUNTIME: "1",
254
+ HTTP_PROXY: "http://localhost:5555",
255
+ HTTPS_PROXY: "http://localhost:5555",
256
+ NO_PROXY: "localhost,127.0.0.1",
257
+ NODE_EXTRA_CA_CERTS: "/tmp/claude/ca.pem",
258
+ TMPDIR: "/tmp/claude",
259
+ };
260
+ const homeEnv: Record<string, string> = { CLAUDE_CONFIG_DIR: "/sess/.claude" };
261
+
262
+ test("LEAK CLOSED: the daemon's ambient secrets in wrapped.env never reach the launch env", () => {
263
+ const env = mergeSandboxLaunchEnv(childEnv, wrappedEnv, homeEnv);
264
+ expect(env.ANTHROPIC_API_KEY).toBeUndefined();
265
+ expect(env.CLAUDE_API_KEY).toBeUndefined();
266
+ expect(env.SECRET_THING).toBeUndefined();
267
+ });
268
+
269
+ test("MANAGED AUTH WINS: CLAUDE_CODE_OAUTH_TOKEN is the scrubbed value, not the engine env's wrong one", () => {
270
+ const env = mergeSandboxLaunchEnv(childEnv, wrappedEnv, homeEnv);
271
+ expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBe("THE-OAUTH-TOKEN");
272
+ });
273
+
274
+ test("EGRESS PRESERVED: the allowlisted sandbox/proxy vars survive (the proxy keeps working)", () => {
275
+ const env = mergeSandboxLaunchEnv(childEnv, wrappedEnv, homeEnv);
276
+ expect(env.SANDBOX_RUNTIME).toBe("1");
277
+ expect(env.HTTP_PROXY).toBe("http://localhost:5555");
278
+ expect(env.HTTPS_PROXY).toBe("http://localhost:5555");
279
+ expect(env.NO_PROXY).toBe("localhost,127.0.0.1");
280
+ expect(env.NODE_EXTRA_CA_CERTS).toBe("/tmp/claude/ca.pem");
281
+ });
282
+
283
+ test("the scrubbed PATH wins (PATH is not in the sandbox allowlist)", () => {
284
+ const env = mergeSandboxLaunchEnv(childEnv, wrappedEnv, homeEnv);
285
+ expect(env.PATH).toBe("/usr/bin"); // childEnv's, not the engine env's /daemon/bin
286
+ });
287
+
288
+ test("homeEnv wins last (CLAUDE_CONFIG_DIR/XDG/TMP overrides)", () => {
289
+ const env = mergeSandboxLaunchEnv(childEnv, wrappedEnv, homeEnv);
290
+ expect(env.CLAUDE_CONFIG_DIR).toBe("/sess/.claude");
291
+ });
292
+
293
+ test("the allowlist never contains the Claude-auth trio (defense-in-depth)", () => {
294
+ expect(SANDBOX_ENV_ALLOWLIST.has("ANTHROPIC_API_KEY")).toBe(false);
295
+ expect(SANDBOX_ENV_ALLOWLIST.has("CLAUDE_API_KEY")).toBe(false);
296
+ expect(SANDBOX_ENV_ALLOWLIST.has("CLAUDE_CODE_OAUTH_TOKEN")).toBe(false);
297
+ });
298
+ });
299
+
300
+ describe("buildAgentClaudeArgs", () => {
301
+ test("interactive claude (no -p) with strict MCP config + dev-channels for the first channel", () => {
302
+ const argv = buildAgentClaudeArgs({
303
+ mcpConfigPath: "/ws/.mcp.json",
304
+ firstChannelEntryKey: "agent-aaron-dev",
305
+ });
306
+ expect(argv).toContain("--strict-mcp-config");
307
+ expect(argv).toContain("--mcp-config");
308
+ expect(argv).toContain("/ws/.mcp.json");
309
+ expect(argv).toContain("--dangerously-load-development-channels=server:agent-aaron-dev");
310
+ // Autonomous: no human answers tool prompts; the sandbox is the containment.
311
+ expect(argv).toContain("--dangerously-skip-permissions");
312
+ // NOT headless: no `-p`.
313
+ expect(argv).not.toContain("-p");
314
+ });
315
+ test("no systemPromptFile → neither system-prompt flag (today's behavior)", () => {
316
+ const argv = buildAgentClaudeArgs({ mcpConfigPath: "/ws/.mcp.json", firstChannelEntryKey: "agent-c" });
317
+ expect(argv).not.toContain("--append-system-prompt-file");
318
+ expect(argv).not.toContain("--system-prompt-file");
319
+ });
320
+ test("systemPromptFile (append, default) → --append-system-prompt-file <path>", () => {
321
+ const argv = buildAgentClaudeArgs({
322
+ mcpConfigPath: "/ws/.mcp.json",
323
+ firstChannelEntryKey: "agent-c",
324
+ systemPromptFile: "/ws/system-prompt.txt",
325
+ systemPromptMode: "append",
326
+ });
327
+ expect(argv).toContain("--append-system-prompt-file");
328
+ expect(argv[argv.indexOf("--append-system-prompt-file") + 1]).toBe("/ws/system-prompt.txt");
329
+ expect(argv).not.toContain("--system-prompt-file");
330
+ });
331
+ test("systemPromptFile (replace) → --system-prompt-file <path>", () => {
332
+ const argv = buildAgentClaudeArgs({
333
+ mcpConfigPath: "/ws/.mcp.json",
334
+ firstChannelEntryKey: "agent-c",
335
+ systemPromptFile: "/ws/system-prompt.txt",
336
+ systemPromptMode: "replace",
337
+ });
338
+ expect(argv).toContain("--system-prompt-file");
339
+ expect(argv).not.toContain("--append-system-prompt-file");
340
+ });
341
+ });
342
+
343
+ describe("shellJoin", () => {
344
+ test("leaves safe args bare, quotes args with spaces", () => {
345
+ expect(shellJoin(["claude", "--mcp-config", "/a/b.json"])).toBe("claude --mcp-config /a/b.json");
346
+ expect(shellJoin(["echo", "a b"])).toBe("echo 'a b'");
347
+ });
348
+ });
349
+
350
+ describe("seedAgentHome — the per-session writable HOME (stability keystone)", () => {
351
+ test("seeds from the operator config (inherits first-run state), strips projects+oauthAccount, trusts the workspace", () => {
352
+ const ws = mkdtempSync(join(tmpdir(), "seed-home-"));
353
+ const opDir = mkdtempSync(join(tmpdir(), "seed-op-"));
354
+ const opPath = join(opDir, ".claude.json");
355
+ // A realistic operator config: completed first-run flags + history + account.
356
+ writeFileSync(opPath, JSON.stringify({
357
+ hasCompletedOnboarding: true,
358
+ theme: "dark",
359
+ numStartups: 536,
360
+ sonnet45MigrationComplete: true,
361
+ oauthAccount: { email: "op@example.com", secret: "DO-NOT-COPY" },
362
+ projects: { "/some/other/proj": { hasTrustDialogAccepted: true } },
363
+ }));
364
+ try {
365
+ const env = seedAgentHome(ws, { mcpServers: ["agent-uni-dev", "vault-default"], operatorConfigPath: opPath });
366
+ // Config + temp are redirected to per-session dirs INSIDE the workspace.
367
+ // (HOME is deliberately NOT overridden — claude finds its real install there.)
368
+ expect(env.HOME).toBeUndefined();
369
+ expect(env.CLAUDE_CONFIG_DIR).toBe(join(ws, "home", ".claude"));
370
+ expect(env.TMPDIR).toBe(join(ws, "tmp"));
371
+ expect(env.CLAUDE_CODE_TMPDIR).toBe(join(ws, "tmp"));
372
+ const seed = JSON.parse(readFileSync(join(ws, "home", ".claude", ".claude.json"), "utf8")) as Record<string, unknown>;
373
+ // Inherits the operator's completed first-run state (onboarding, theme, migrations).
374
+ expect(seed.hasCompletedOnboarding).toBe(true);
375
+ expect(seed.theme).toBe("dark");
376
+ expect(seed.sonnet45MigrationComplete).toBe(true);
377
+ // Strips the account; replaces project history with ONLY this workspace, trusted.
378
+ expect(seed.oauthAccount).toBeUndefined();
379
+ const projects = seed.projects as Record<string, { hasTrustDialogAccepted: boolean; hasCompletedProjectOnboarding: boolean }>;
380
+ expect(Object.keys(projects)).toEqual([ws]);
381
+ expect(projects[ws]!.hasTrustDialogAccepted).toBe(true);
382
+ expect(projects[ws]!.hasCompletedProjectOnboarding).toBe(true);
383
+ // Our configured MCP servers are pre-approved (no "trust this MCP server" prompt).
384
+ expect((projects[ws] as { enabledMcpjsonServers?: string[] }).enabledMcpjsonServers).toEqual([
385
+ "agent-uni-dev",
386
+ "vault-default",
387
+ ]);
388
+ } finally {
389
+ rmSync(ws, { recursive: true, force: true });
390
+ rmSync(opDir, { recursive: true, force: true });
391
+ }
392
+ });
393
+
394
+ test("falls back to the minimal seed when the operator has no config", () => {
395
+ const ws = mkdtempSync(join(tmpdir(), "seed-home-noop-"));
396
+ try {
397
+ seedAgentHome(ws, { operatorConfigPath: join(ws, "does-not-exist.json") });
398
+ const seed = JSON.parse(readFileSync(join(ws, "home", ".claude", ".claude.json"), "utf8")) as {
399
+ hasCompletedOnboarding: boolean;
400
+ projects: Record<string, { hasTrustDialogAccepted: boolean }>;
401
+ };
402
+ expect(seed.hasCompletedOnboarding).toBe(true);
403
+ expect(seed.projects[ws]!.hasTrustDialogAccepted).toBe(true);
404
+ } finally {
405
+ rmSync(ws, { recursive: true, force: true });
406
+ }
407
+ });
408
+
409
+ test("projectRoot override → the SHARED working dir is the pre-trusted project, not the private home", () => {
410
+ const ws = mkdtempSync(join(tmpdir(), "seed-home-projroot-"));
411
+ const noop = join(ws, "no-operator.json");
412
+ try {
413
+ // The cwd (a shared working dir) is pre-trusted; the seed still lives UNDER ws.
414
+ seedAgentHome(ws, { operatorConfigPath: noop, projectRoot: "/Users/op/Code/repo", mcpServers: ["vault-default"] });
415
+ const seed = JSON.parse(readFileSync(join(ws, "home", ".claude", ".claude.json"), "utf8")) as {
416
+ projects: Record<string, { hasTrustDialogAccepted: boolean }>;
417
+ };
418
+ // The PROJECT (pre-trusted) is the shared working dir, NOT the private ws.
419
+ expect(Object.keys(seed.projects)).toEqual(["/Users/op/Code/repo"]);
420
+ expect(seed.projects["/Users/op/Code/repo"]!.hasTrustDialogAccepted).toBe(true);
421
+ } finally {
422
+ rmSync(ws, { recursive: true, force: true });
423
+ }
424
+ });
425
+
426
+ test("idempotent — an existing seed is left as-is (claude owns it after first boot)", () => {
427
+ const ws = mkdtempSync(join(tmpdir(), "seed-home-idem-"));
428
+ const noop = join(ws, "no-operator.json");
429
+ try {
430
+ seedAgentHome(ws, { operatorConfigPath: noop });
431
+ const path = join(ws, "home", ".claude", ".claude.json");
432
+ writeFileSync(path, JSON.stringify({ hasCompletedOnboarding: true, mine: true }));
433
+ seedAgentHome(ws, { operatorConfigPath: noop }); // second call must not clobber
434
+ expect(JSON.parse(readFileSync(path, "utf8")).mine).toBe(true);
435
+ } finally {
436
+ rmSync(ws, { recursive: true, force: true });
437
+ }
438
+ });
439
+ });
440
+
441
+ // ---- full wiring tests -----------------------------------------------------
442
+
443
+ describe("spawnAgent — full wiring with stubs (no real token)", () => {
444
+ test("creates the tmux session, writes a strict MCP config, injects OAuth, omits ANTHROPIC_API_KEY", async () => {
445
+ sessionsDir = mkdtempSync(join(tmpdir(), "spawn-agent-"));
446
+ const tmux = recordingTmux();
447
+ const engine = fakeEngine();
448
+ const spec: AgentSpec = {
449
+ name: "aaron-dev",
450
+ channels: ["aaron-dev"],
451
+ vault: { name: "default", access: "read", tags: ["#agent/message"] },
452
+ network: "restricted", // exercise the egress floor; scoped reads are the default (step 6)
453
+ };
454
+ const res = await spawnAgent(spec, baseDeps({ tmux, sandboxEngine: engine }));
455
+
456
+ // 1. tmux session created with the spec's name.
457
+ expect(res.alreadyRunning).toBe(false);
458
+ expect(res.session).toBe(sessionName("aaron-dev"));
459
+ expect(tmux.launched).toHaveLength(1);
460
+ const launch = tmux.launched[0]!;
461
+ expect(launch.name).toBe("aaron-dev-agent");
462
+
463
+ // 1b. The dev-channels consent gate is auto-answered for THIS session after the
464
+ // launch (channel#70) — otherwise the headless spawn hangs at the prompt forever.
465
+ expect(tmux.confirmed).toEqual(["aaron-dev-agent"]);
466
+ expect(res.devChannelsPrompt).toBe("confirmed");
467
+
468
+ // 2. The launched argv is the sandbox wrapper carrying the claude command.
469
+ expect(launch.argv[0]).toBe("/bin/bash");
470
+ expect(launch.argv[2]).toContain("SBX claude");
471
+ expect(launch.argv[2]).toContain("--strict-mcp-config");
472
+
473
+ // 3. The injected env has CLAUDE_CODE_OAUTH_TOKEN and NO ANTHROPIC_API_KEY.
474
+ expect(launch.env.CLAUDE_CODE_OAUTH_TOKEN).toBe("OAUTH-CRED-PLACEHOLDER");
475
+ expect(launch.env.ANTHROPIC_API_KEY).toBeUndefined();
476
+ expect(launch.env.CLAUDE_API_KEY).toBeUndefined();
477
+ // ...and the sandbox proxy env layered on top.
478
+ expect(launch.env.SANDBOX_RUNTIME).toBe("1");
479
+ expect(launch.env.HTTPS_PROXY).toBe("http://localhost:5555");
480
+
481
+ // 3b. TMPDIR (+ claude-specific + generic) point at a WRITABLE dir inside the
482
+ // workspace, OVERRIDING the sandbox engine's own TMPDIR — without this claude
483
+ // can't create its scratch dir and dies "Claude Code could not start: EPERM".
484
+ const wsTmp = join(res.workspace, "tmp");
485
+ expect(launch.env.TMPDIR).toBe(wsTmp);
486
+ expect(launch.env.CLAUDE_CODE_TMPDIR).toBe(wsTmp);
487
+ expect(launch.env.TMP).toBe(wsTmp);
488
+ expect(launch.env.TEMP).toBe(wsTmp);
489
+ // ...and the dir is actually created on disk (writable, where the child looks).
490
+ expect(statSync(wsTmp).isDirectory()).toBe(true);
491
+
492
+ // 4. The MCP config has the right entries with DISTINCT tokens (one per aud).
493
+ const parsed = JSON.parse(res.mcpConfigJson) as {
494
+ mcpServers: Record<string, { url: string; headers?: { Authorization: string } }>;
495
+ };
496
+ const chKey = channelEntryKey("aaron-dev");
497
+ const vKey = vaultEntryKey("default");
498
+ expect(parsed.mcpServers[chKey]!.url).toBe("http://127.0.0.1:1941/mcp/aaron-dev");
499
+ expect(parsed.mcpServers[vKey]!.url).toBe("http://127.0.0.1:1940/vault/default/mcp");
500
+ const chAuth = parsed.mcpServers[chKey]!.headers!.Authorization;
501
+ const vAuth = parsed.mcpServers[vKey]!.headers!.Authorization;
502
+ expect(chAuth).toMatch(/^Bearer TOK-/);
503
+ expect(vAuth).toMatch(/^Bearer TOK-/);
504
+ expect(chAuth).not.toBe(vAuth); // distinct tokens, distinct auds
505
+
506
+ // 5. The on-disk config is 0600 (it inlines tokens).
507
+ const mcpPath = join(res.workspace, ".mcp.json");
508
+ expect(statSync(mcpPath).mode & 0o777).toBe(0o600);
509
+ expect(readFileSync(mcpPath, "utf8")).toBe(res.mcpConfigJson);
510
+
511
+ // 6. The sandbox config carried the egress floor + scoped reads.
512
+ expect(engine.initializedWith!.network.allowedDomains).toContain("api.anthropic.com");
513
+ expect(engine.initializedWith!.network.allowedDomains).toContain("hub.example.com");
514
+ expect(engine.initializedWith!.filesystem.allowWrite).toContain(res.workspace);
515
+ });
516
+
517
+ test("a spec with systemPrompt writes system-prompt.txt 0600 + passes the -file flag in the launch argv", async () => {
518
+ sessionsDir = mkdtempSync(join(tmpdir(), "spawn-agent-sysprompt-"));
519
+ const tmux = recordingTmux();
520
+ const spec: AgentSpec = {
521
+ name: "eng",
522
+ channels: ["eng"],
523
+ systemPrompt: "You are the eng channel's assistant.",
524
+ systemPromptMode: "append",
525
+ };
526
+ const res = await spawnAgent(spec, baseDeps({ tmux }));
527
+
528
+ // The prompt file is written 0600 with the exact text.
529
+ const promptPath = join(res.workspace, "system-prompt.txt");
530
+ expect(statSync(promptPath).mode & 0o777).toBe(0o600);
531
+ expect(readFileSync(promptPath, "utf8")).toBe("You are the eng channel's assistant.");
532
+ // The launched claude command carries --append-system-prompt-file <path>.
533
+ const cmd = tmux.launched[0]!.argv[2]!;
534
+ expect(cmd).toContain("--append-system-prompt-file");
535
+ expect(cmd).toContain(promptPath);
536
+ });
537
+
538
+ test("a spec with NO systemPrompt writes no prompt file + no system-prompt flag", async () => {
539
+ sessionsDir = mkdtempSync(join(tmpdir(), "spawn-agent-nosysprompt-"));
540
+ const tmux = recordingTmux();
541
+ const res = await spawnAgent({ name: "bare", channels: ["bare"] }, baseDeps({ tmux }));
542
+ expect(existsSync(join(res.workspace, "system-prompt.txt"))).toBe(false);
543
+ expect(tmux.launched[0]!.argv[2]!).not.toContain("system-prompt-file");
544
+ });
545
+
546
+ test("mints ONE token per channel for a multi-channel spec", async () => {
547
+ sessionsDir = mkdtempSync(join(tmpdir(), "spawn-agent-multi-"));
548
+ const spec: AgentSpec = { name: "multi", channels: ["a", "b"] };
549
+ const res = await spawnAgent(spec, baseDeps());
550
+ expect(Object.keys(res.tokens)).toContain("a");
551
+ expect(Object.keys(res.tokens)).toContain("b");
552
+ expect(res.tokens.a!.token).not.toBe(res.tokens.b!.token);
553
+ const parsed = JSON.parse(res.mcpConfigJson) as { mcpServers: Record<string, unknown> };
554
+ expect(parsed.mcpServers[channelEntryKey("a")]).toBeDefined();
555
+ expect(parsed.mcpServers[channelEntryKey("b")]).toBeDefined();
556
+ });
557
+
558
+ test("tag-scoped vault: the scoped_tags permission rides the vault mint request", async () => {
559
+ sessionsDir = mkdtempSync(join(tmpdir(), "spawn-agent-vault-"));
560
+ const calls: Array<Record<string, unknown>> = [];
561
+ const fetchFn = (async (_u: string | URL | Request, init?: RequestInit) => {
562
+ const body = JSON.parse(String(init?.body ?? "{}")) as Record<string, unknown>;
563
+ calls.push(body);
564
+ return new Response(
565
+ JSON.stringify({ jti: "j", token: `T-${calls.length}`, expires_at: "", scope: body.scope }),
566
+ { status: 200, headers: { "content-type": "application/json" } },
567
+ );
568
+ }) as unknown as typeof fetch;
569
+
570
+ const spec: AgentSpec = {
571
+ name: "weaver",
572
+ channels: ["c"],
573
+ vault: { name: "default", access: "read", tags: ["#agent/message"] },
574
+ };
575
+ await spawnAgent(spec, baseDeps({ fetchFn }));
576
+ const vaultCall = calls.find((c) => String(c.scope).startsWith("vault:"));
577
+ expect(vaultCall).toBeDefined();
578
+ expect(vaultCall!.scope).toBe("vault:default:read");
579
+ expect(vaultCall!.permissions).toEqual({ scoped_tags: ["#agent/message"] });
580
+ });
581
+
582
+ test("idempotent: an already-running session is a no-op", async () => {
583
+ sessionsDir = mkdtempSync(join(tmpdir(), "spawn-agent-idem-"));
584
+ const tmux = recordingTmux(new Set(["arm-agent"]));
585
+ const res = await spawnAgent({ name: "arm", channels: ["c"] }, baseDeps({ tmux }));
586
+ expect(res.alreadyRunning).toBe(true);
587
+ expect(tmux.launched).toHaveLength(0);
588
+ // No launch → the dev-channels gate is NOT touched (guards against someone
589
+ // moving the confirm call above the early-return — channel#70).
590
+ expect(tmux.confirmed).toHaveLength(0);
591
+ expect(res.devChannelsPrompt).toBeUndefined();
592
+ });
593
+
594
+ test("a spec with no channels is rejected", async () => {
595
+ sessionsDir = mkdtempSync(join(tmpdir(), "spawn-agent-noch-"));
596
+ await expect(spawnAgent({ name: "x", channels: [] }, baseDeps())).rejects.toThrow(/no channels/);
597
+ });
598
+
599
+ test("SECURITY: an over-broad mint (hub 400) aborts the launch — no tmux session created", async () => {
600
+ sessionsDir = mkdtempSync(join(tmpdir(), "spawn-agent-deny-"));
601
+ const tmux = recordingTmux();
602
+ const fetchFn = (async () =>
603
+ new Response(
604
+ JSON.stringify({ error: "invalid_scope", error_description: "not grantable by this bearer" }),
605
+ { status: 400, headers: { "content-type": "application/json" } },
606
+ )) as unknown as typeof fetch;
607
+ await expect(
608
+ spawnAgent({ name: "x", channels: ["c"] }, baseDeps({ tmux, fetchFn })),
609
+ ).rejects.toThrow(/mint refused/);
610
+ // The attenuation failure happened BEFORE any tmux launch.
611
+ expect(tmux.launched).toHaveLength(0);
612
+ });
613
+
614
+ test("SECURITY: an adversarial spec.name is rejected BEFORE any fs/tmux/mint side effect", async () => {
615
+ sessionsDir = mkdtempSync(join(tmpdir(), "spawn-agent-name-"));
616
+ for (const bad of ["..", "a/b", "a b", "../escape", ".", "a..b", "x;rm", ""]) {
617
+ const tmux = recordingTmux();
618
+ let minted = false;
619
+ const fetchFn = (async () => {
620
+ minted = true;
621
+ return new Response("{}", { status: 200 });
622
+ }) as unknown as typeof fetch;
623
+ await expect(
624
+ spawnAgent({ name: bad, channels: ["c"] }, baseDeps({ tmux, fetchFn })),
625
+ ).rejects.toThrow(/slug/);
626
+ // No side effects: no tmux launch, no mint attempt.
627
+ expect(tmux.launched).toHaveLength(0);
628
+ expect(minted).toBe(false);
629
+ }
630
+ });
631
+
632
+ test("a valid slug name is accepted (dashes + underscores ok)", async () => {
633
+ sessionsDir = mkdtempSync(join(tmpdir(), "spawn-agent-okname-"));
634
+ const res = await spawnAgent({ name: "aaron_dev-2", channels: ["c"] }, baseDeps());
635
+ expect(res.alreadyRunning).toBe(false);
636
+ expect(res.session).toBe("aaron_dev-2-agent");
637
+ });
638
+
639
+ test("ENV INJECTION: the resolved per-channel env reaches the tmux launch env (Claude auth intact)", async () => {
640
+ sessionsDir = mkdtempSync(join(tmpdir(), "spawn-agent-env-"));
641
+ const tmux = recordingTmux();
642
+ const deps = baseDeps({
643
+ tmux,
644
+ // The wake channel is the first channel ("aaron-dev") — env resolves on it.
645
+ resolveChannelEnv: (ch): Record<string, string> =>
646
+ ch === "aaron-dev" ? { GH_TOKEN: "ghp_INJECTED", CLOUDFLARE_API_TOKEN: "cf_INJECTED" } : {},
647
+ });
648
+ await spawnAgent({ name: "aaron-dev", channels: ["aaron-dev"] }, deps);
649
+ expect(tmux.launched).toHaveLength(1);
650
+ const env = tmux.launched[0]!.env;
651
+ // The injected vars reach the child…
652
+ expect(env.GH_TOKEN).toBe("ghp_INJECTED");
653
+ expect(env.CLOUDFLARE_API_TOKEN).toBe("cf_INJECTED");
654
+ // …Claude auth is the stub placeholder (not clobbered), and no API key leaked.
655
+ expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBe("OAUTH-CRED-PLACEHOLDER");
656
+ expect(env.ANTHROPIC_API_KEY).toBeUndefined();
657
+ });
658
+
659
+ test("ENV INJECTION: a denylisted key planted in the resolver is dropped at launch", async () => {
660
+ sessionsDir = mkdtempSync(join(tmpdir(), "spawn-agent-env-deny-"));
661
+ const tmux = recordingTmux();
662
+ const deps = baseDeps({
663
+ tmux,
664
+ resolveChannelEnv: () => ({ ANTHROPIC_API_KEY: "sk-ant-SMUGGLED", GH_TOKEN: "ghp_OK" }),
665
+ });
666
+ await spawnAgent({ name: "x", channels: ["c"] }, deps);
667
+ const env = tmux.launched[0]!.env;
668
+ expect(env.GH_TOKEN).toBe("ghp_OK");
669
+ expect(env.ANTHROPIC_API_KEY).toBeUndefined(); // dropped defensively in buildAgentChildEnv
670
+ });
671
+
672
+ test("SPEC PERSISTENCE: spawn writes spec.json so a restart can reproduce the launch", async () => {
673
+ sessionsDir = mkdtempSync(join(tmpdir(), "spawn-agent-spec-"));
674
+ const spec: AgentSpec = {
675
+ name: "weaver",
676
+ channels: [{ name: "weave", access: "read" }],
677
+ vault: { name: "default", access: "read", tags: ["#agent/message"] },
678
+ network: "restricted",
679
+ egress: ["registry.npmjs.org"],
680
+ };
681
+ const res = await spawnAgent(spec, baseDeps());
682
+ // The persisted spec round-trips to the exact spec the launch used.
683
+ const recovered = readPersistedSpec(res.workspace);
684
+ expect(recovered).toEqual(spec);
685
+ // And it's at the conventional path.
686
+ expect(specFilePath(res.workspace)).toBe(join(res.workspace, "spec.json"));
687
+ });
688
+
689
+ test("read-only channel mints agent:read ONLY (not read+write)", async () => {
690
+ sessionsDir = mkdtempSync(join(tmpdir(), "spawn-agent-roch-"));
691
+ const scopes: string[] = [];
692
+ const fetchFn = (async (_u: string | URL | Request, init?: RequestInit) => {
693
+ const body = JSON.parse(String(init?.body ?? "{}")) as { scope: string };
694
+ scopes.push(body.scope);
695
+ return new Response(
696
+ JSON.stringify({ jti: "j", token: `T-${scopes.length}`, expires_at: "", scope: body.scope }),
697
+ { status: 200, headers: { "content-type": "application/json" } },
698
+ );
699
+ }) as unknown as typeof fetch;
700
+
701
+ const spec: AgentSpec = {
702
+ name: "watcher",
703
+ channels: [
704
+ { name: "readonly-ch", access: "read" },
705
+ { name: "rw-ch", access: "write" },
706
+ "bare-ch", // bare string = write (back-compat)
707
+ ],
708
+ };
709
+ await spawnAgent(spec, baseDeps({ fetchFn }));
710
+ expect(scopes).toContain("agent:read"); // the read-only channel
711
+ expect(scopes.filter((s) => s === "agent:read")).toHaveLength(1);
712
+ expect(scopes.filter((s) => s === "agent:read agent:write")).toHaveLength(2); // rw + bare
713
+ });
714
+
715
+ test("CONCURRENCY: two concurrent spawnAgent calls produce correct, independent MCP configs + wrapping", async () => {
716
+ sessionsDir = mkdtempSync(join(tmpdir(), "spawn-agent-conc-"));
717
+ // Independent engines/tmux per call so we can assert no cross-clobber. Each
718
+ // mint hub returns a token namespaced to the spec so configs are tellable apart.
719
+ function depsForArm(arm: string) {
720
+ let n = 0;
721
+ const fetchFn = (async (_u: string | URL | Request, init?: RequestInit) => {
722
+ const body = JSON.parse(String(init?.body ?? "{}")) as { scope: string };
723
+ n += 1;
724
+ return new Response(
725
+ JSON.stringify({ jti: `${arm}-${n}`, token: `${arm}-TOK-${n}`, expires_at: "", scope: body.scope }),
726
+ { status: 200, headers: { "content-type": "application/json" } },
727
+ );
728
+ }) as unknown as typeof fetch;
729
+ return baseDeps({ tmux: recordingTmux(), sandboxEngine: fakeEngine(), fetchFn });
730
+ }
731
+
732
+ const [a, b] = await Promise.all([
733
+ spawnAgent({ name: "arm-a", channels: ["ca"] }, depsForArm("A")),
734
+ spawnAgent({ name: "arm-b", channels: ["cb"] }, depsForArm("B")),
735
+ ]);
736
+
737
+ // Each got its OWN channel entry + token — no clobber across the race.
738
+ const pa = JSON.parse(a.mcpConfigJson) as { mcpServers: Record<string, { url: string; headers?: { Authorization: string } }> };
739
+ const pb = JSON.parse(b.mcpConfigJson) as { mcpServers: Record<string, { url: string; headers?: { Authorization: string } }> };
740
+ expect(pa.mcpServers[channelEntryKey("ca")]!.url).toBe("http://127.0.0.1:1941/mcp/ca");
741
+ expect(pb.mcpServers[channelEntryKey("cb")]!.url).toBe("http://127.0.0.1:1941/mcp/cb");
742
+ expect(pa.mcpServers[channelEntryKey("ca")]!.headers!.Authorization).toBe("Bearer A-TOK-1");
743
+ expect(pb.mcpServers[channelEntryKey("cb")]!.headers!.Authorization).toBe("Bearer B-TOK-1");
744
+ // Independent sandbox configs (each carries its own workspace allowWrite).
745
+ expect(a.wrapped.config.filesystem.allowWrite).toContain(a.workspace);
746
+ expect(b.wrapped.config.filesystem.allowWrite).toContain(b.workspace);
747
+ expect(a.workspace).not.toBe(b.workspace);
748
+ });
749
+
750
+ test("CONCURRENCY: the init→wrap window is serialized (never two engines in it at once)", async () => {
751
+ sessionsDir = mkdtempSync(join(tmpdir(), "spawn-agent-serial-"));
752
+ // An engine whose initialize overlaps wrap by an await; if the lock didn't
753
+ // hold, two would be "in the window" simultaneously and maxActive would be >1.
754
+ let active = 0;
755
+ let maxActive = 0;
756
+ function slowEngine(): SandboxEngine {
757
+ return {
758
+ isSupportedPlatform: () => true,
759
+ isSandboxingEnabled: () => true,
760
+ async initialize() {
761
+ active += 1;
762
+ maxActive = Math.max(maxActive, active);
763
+ await Bun.sleep(15);
764
+ },
765
+ async wrapWithSandboxArgv(command: string) {
766
+ await Bun.sleep(15);
767
+ active -= 1;
768
+ return { argv: ["/bin/bash", "-c", command], env: {} };
769
+ },
770
+ async reset() {},
771
+ };
772
+ }
773
+ await Promise.all([
774
+ spawnAgent({ name: "s-a", channels: ["c"] }, baseDeps({ sandboxEngine: slowEngine(), tmux: recordingTmux() })),
775
+ spawnAgent({ name: "s-b", channels: ["c"] }, baseDeps({ sandboxEngine: slowEngine(), tmux: recordingTmux() })),
776
+ spawnAgent({ name: "s-c", channels: ["c"] }, baseDeps({ sandboxEngine: slowEngine(), tmux: recordingTmux() })),
777
+ ]);
778
+ expect(maxActive).toBe(1);
779
+ });
780
+ });
781
+
782
+ // ---- the workspace seam (working-directory axis) ---------------------------
783
+ // design 2026-06-16-agent-filesystem-and-sharing.md — a `workspace` host path is
784
+ // the agent's cwd + an rw working-root; the credential-bearing private home
785
+ // (.mcp.json / system-prompt.txt / spec.json / seeded CLAUDE_CONFIG_DIR) STAYS in
786
+ // the per-agent sessions/<name> dir, never written into the shared workspace.
787
+
788
+ describe("resolveAgentCwd — cwd is the workspace when set, else the private dir", () => {
789
+ test("workspace set → that path; the private dir is untouched as the cwd", () => {
790
+ expect(resolveAgentCwd({ name: "a", channels: ["c"], workspace: "/ws/repo" }, "/private/a")).toBe("/ws/repo");
791
+ });
792
+ test("workspace unset → the private dir (today's behavior)", () => {
793
+ expect(resolveAgentCwd({ name: "a", channels: ["c"] }, "/private/a")).toBe("/private/a");
794
+ });
795
+ test("a blank workspace falls back to the private dir", () => {
796
+ expect(resolveAgentCwd({ name: "a", channels: ["c"], workspace: "" }, "/private/a")).toBe("/private/a");
797
+ });
798
+ });
799
+
800
+ describe("spawnAgent — workspace seam (interactive): cwd = workspace, secrets stay private", () => {
801
+ test("workspace SET → tmux cwd is the workspace; .mcp.json/system-prompt/spec/home stay in the PRIVATE dir; workspace is in the sandbox rw set", async () => {
802
+ sessionsDir = mkdtempSync(join(tmpdir(), "spawn-ws-set-"));
803
+ const workspaceDir = mkdtempSync(join(tmpdir(), "shared-workdir-"));
804
+ const tmux = recordingTmux();
805
+ const engine = fakeEngine();
806
+ try {
807
+ const spec: AgentSpec = {
808
+ name: "worker",
809
+ channels: ["worker"],
810
+ workspace: workspaceDir,
811
+ systemPrompt: "You work in the repo.",
812
+ };
813
+ const res = await spawnAgent(spec, baseDeps({ tmux, sandboxEngine: engine }));
814
+ const privateDir = sessionWorkspace(sessionsDir, "worker");
815
+ // res.workspace is still the PRIVATE session dir (the home of secrets).
816
+ expect(res.workspace).toBe(privateDir);
817
+
818
+ // 1. The tmux session's cwd is the SHARED workspace, NOT the private dir.
819
+ const launch = tmux.launched[0]!;
820
+ expect(launch.cwd).toBe(workspaceDir);
821
+ // …and the launch script (private) is written to the PRIVATE dir, never the shared one.
822
+ expect(launch.scriptDir).toBe(privateDir);
823
+
824
+ // 2. SECRETS-STAY-PRIVATE invariant: .mcp.json / system-prompt.txt / spec.json /
825
+ // the seeded home all live UNDER the private dir, and NONE under the workspace.
826
+ expect(existsSync(join(privateDir, ".mcp.json"))).toBe(true);
827
+ expect(existsSync(join(privateDir, "system-prompt.txt"))).toBe(true);
828
+ expect(existsSync(join(privateDir, "spec.json"))).toBe(true);
829
+ expect(existsSync(join(privateDir, "home", ".claude", ".claude.json"))).toBe(true);
830
+ // The workspace dir is NOT littered with any private artifact.
831
+ expect(existsSync(join(workspaceDir, ".mcp.json"))).toBe(false);
832
+ expect(existsSync(join(workspaceDir, "system-prompt.txt"))).toBe(false);
833
+ expect(existsSync(join(workspaceDir, "spec.json"))).toBe(false);
834
+ expect(existsSync(join(workspaceDir, ".launch.sh"))).toBe(false);
835
+ expect(existsSync(join(workspaceDir, "home"))).toBe(false);
836
+
837
+ // 3. --mcp-config / --append-system-prompt-file point at the PRIVATE absolute
838
+ // paths (unaffected by the cwd change).
839
+ const cmd = launch.argv[2]!;
840
+ expect(cmd).toContain(join(privateDir, ".mcp.json"));
841
+ expect(cmd).toContain(join(privateDir, "system-prompt.txt"));
842
+
843
+ // 4. The workspace IS an rw working-root in the sandbox (read + write).
844
+ expect(engine.initializedWith!.filesystem.allowWrite).toContain(workspaceDir);
845
+ expect(engine.initializedWith!.filesystem.allowWrite).toContain(privateDir);
846
+ expect(engine.initializedWith!.filesystem.allowRead).toContain(workspaceDir);
847
+
848
+ // 5. CLAUDE_CONFIG_DIR / TMPDIR still point at the PRIVATE home (not the workspace).
849
+ expect(launch.env.CLAUDE_CONFIG_DIR).toBe(join(privateDir, "home", ".claude"));
850
+ expect(launch.env.TMPDIR).toBe(join(privateDir, "tmp"));
851
+
852
+ // 6. The seeded project (pre-trusted) is the agent's CWD (the shared workspace).
853
+ const seed = JSON.parse(
854
+ readFileSync(join(privateDir, "home", ".claude", ".claude.json"), "utf8"),
855
+ ) as { projects: Record<string, unknown> };
856
+ expect(Object.keys(seed.projects)).toEqual([workspaceDir]);
857
+ } finally {
858
+ rmSync(workspaceDir, { recursive: true, force: true });
859
+ }
860
+ });
861
+
862
+ test("workspace UNSET → cwd is the private dir (unchanged); workspace not in the rw set beyond the private dir", async () => {
863
+ sessionsDir = mkdtempSync(join(tmpdir(), "spawn-ws-unset-"));
864
+ const tmux = recordingTmux();
865
+ const engine = fakeEngine();
866
+ const res = await spawnAgent({ name: "plain", channels: ["plain"] }, baseDeps({ tmux, sandboxEngine: engine }));
867
+ const launch = tmux.launched[0]!;
868
+ // The cwd is the private session dir (today's behavior, exactly).
869
+ expect(launch.cwd).toBe(res.workspace);
870
+ expect(launch.scriptDir).toBe(res.workspace);
871
+ // The only writable dir is the private session dir.
872
+ expect(engine.initializedWith!.filesystem.allowWrite).toEqual([res.workspace]);
873
+ // The pre-trusted project is the private dir (no shared working dir).
874
+ const seed = JSON.parse(
875
+ readFileSync(join(res.workspace, "home", ".claude", ".claude.json"), "utf8"),
876
+ ) as { projects: Record<string, unknown> };
877
+ expect(Object.keys(seed.projects)).toEqual([res.workspace]);
878
+ });
879
+
880
+ test("SECRETS-STAY-PRIVATE: .mcp.json (scoped tokens) is NEVER written into a shared workspace dir", async () => {
881
+ sessionsDir = mkdtempSync(join(tmpdir(), "spawn-ws-secrets-"));
882
+ const workspaceDir = mkdtempSync(join(tmpdir(), "shared-secrets-"));
883
+ try {
884
+ const spec: AgentSpec = {
885
+ name: "secretkeeper",
886
+ channels: ["secretkeeper"],
887
+ vault: { name: "default", access: "read" },
888
+ workspace: workspaceDir,
889
+ };
890
+ await spawnAgent(spec, baseDeps({ tmux: recordingTmux() }));
891
+ // The shared workspace holds NO .mcp.json (the file that inlines the minted
892
+ // vault/channel tokens). It only ever lives in the per-agent private dir.
893
+ expect(existsSync(join(workspaceDir, ".mcp.json"))).toBe(false);
894
+ // Belt-and-suspenders: no file under the shared workspace contains the minted
895
+ // token marker the fake hub stamps (TOK-).
896
+ const privateMcp = readFileSync(join(sessionWorkspace(sessionsDir, "secretkeeper"), ".mcp.json"), "utf8");
897
+ expect(privateMcp).toContain("Bearer TOK-"); // the secret IS in the private file…
898
+ // …and the shared dir has no such file at all (asserted above) — so the token
899
+ // never crosses into the shareable dir.
900
+ } finally {
901
+ rmSync(workspaceDir, { recursive: true, force: true });
902
+ }
903
+ });
904
+
905
+ test("two agents can SHARE one workspace dir (allowed, not solved) — each keeps its OWN private home", async () => {
906
+ sessionsDir = mkdtempSync(join(tmpdir(), "spawn-ws-shared-"));
907
+ const shared = mkdtempSync(join(tmpdir(), "shared-by-two-"));
908
+ try {
909
+ const tmuxA = recordingTmux();
910
+ const tmuxB = recordingTmux();
911
+ await spawnAgent({ name: "agent-a", channels: ["a"], workspace: shared }, baseDeps({ tmux: tmuxA }));
912
+ await spawnAgent({ name: "agent-b", channels: ["b"], workspace: shared }, baseDeps({ tmux: tmuxB }));
913
+ // Both cwd into the SAME shared dir…
914
+ expect(tmuxA.launched[0]!.cwd).toBe(shared);
915
+ expect(tmuxB.launched[0]!.cwd).toBe(shared);
916
+ // …but each has its OWN private home (distinct .mcp.json under distinct dirs).
917
+ const aPriv = sessionWorkspace(sessionsDir, "agent-a");
918
+ const bPriv = sessionWorkspace(sessionsDir, "agent-b");
919
+ expect(aPriv).not.toBe(bPriv);
920
+ expect(existsSync(join(aPriv, ".mcp.json"))).toBe(true);
921
+ expect(existsSync(join(bPriv, ".mcp.json"))).toBe(true);
922
+ // The shared dir holds NEITHER agent's secrets.
923
+ expect(existsSync(join(shared, ".mcp.json"))).toBe(false);
924
+ } finally {
925
+ rmSync(shared, { recursive: true, force: true });
926
+ }
927
+ });
928
+ });
929
+
930
+ // ---- credential wiring (Stream 3 — resolve from the per-channel store) -------
931
+
932
+ describe("spawnAgent — resolves the Claude credential from the per-channel store", () => {
933
+ let storeDir: string;
934
+ afterEach(() => {
935
+ if (storeDir) rmSync(storeDir, { recursive: true, force: true });
936
+ });
937
+
938
+ // The wiring under test reads `credentials.ts` keyed on the WAKE channel (the
939
+ // first channel). These tests use the REAL resolver (no `resolveClaudeToken`
940
+ // stub) against a throwaway store, proving the end-to-end resolve→inject path.
941
+ function depsWithRealResolver(): SpawnAgentDeps {
942
+ const d = baseDeps();
943
+ delete (d as { resolveClaudeToken?: unknown }).resolveClaudeToken;
944
+ return d;
945
+ }
946
+
947
+ test("injects the PER-CHANNEL override when the wake channel has one", async () => {
948
+ sessionsDir = mkdtempSync(join(tmpdir(), "spawn-agent-cred-ovr-"));
949
+ storeDir = mkdtempSync(join(tmpdir(), "channel-creds-ovr-"));
950
+ setDefaultClaudeCredential("oat_DEFAULT", storeDir);
951
+ setChannelClaudeCredential("aaron-dev", "oat_AARON-OVERRIDE", storeDir);
952
+
953
+ const tmux = recordingTmux();
954
+ const deps = { ...depsWithRealResolver(), tmux, resolveClaudeToken: (ch: string) => resolveAgainst(storeDir, ch) };
955
+ const res = await spawnAgent({ name: "aaron-dev", channels: ["aaron-dev"] }, deps);
956
+ expect(res.alreadyRunning).toBe(false);
957
+ // The override (not the default) lands in CLAUDE_CODE_OAUTH_TOKEN.
958
+ expect(tmux.launched[0]!.env.CLAUDE_CODE_OAUTH_TOKEN).toBe("oat_AARON-OVERRIDE");
959
+ expect(tmux.launched[0]!.env.ANTHROPIC_API_KEY).toBeUndefined();
960
+ });
961
+
962
+ test("falls back to the DEFAULT/operator token when the wake channel has no override", async () => {
963
+ sessionsDir = mkdtempSync(join(tmpdir(), "spawn-agent-cred-def-"));
964
+ storeDir = mkdtempSync(join(tmpdir(), "channel-creds-def-"));
965
+ setDefaultClaudeCredential("oat_DEFAULT", storeDir);
966
+
967
+ const tmux = recordingTmux();
968
+ const deps = { ...baseDeps(), tmux, resolveClaudeToken: (ch: string) => resolveAgainst(storeDir, ch) };
969
+ const res = await spawnAgent({ name: "other", channels: ["unconfigured-ch"] }, deps);
970
+ expect(res.alreadyRunning).toBe(false);
971
+ expect(tmux.launched[0]!.env.CLAUDE_CODE_OAUTH_TOKEN).toBe("oat_DEFAULT");
972
+ });
973
+
974
+ test("resolves on the WAKE channel (first), not a later one", async () => {
975
+ sessionsDir = mkdtempSync(join(tmpdir(), "spawn-agent-cred-wake-"));
976
+ storeDir = mkdtempSync(join(tmpdir(), "channel-creds-wake-"));
977
+ setDefaultClaudeCredential("oat_DEFAULT", storeDir);
978
+ setChannelClaudeCredential("first", "oat_FIRST", storeDir);
979
+ setChannelClaudeCredential("second", "oat_SECOND", storeDir);
980
+
981
+ const tmux = recordingTmux();
982
+ const deps = { ...baseDeps(), tmux, resolveClaudeToken: (ch: string) => resolveAgainst(storeDir, ch) };
983
+ await spawnAgent({ name: "multi", channels: ["first", "second"] }, deps);
984
+ // The wake channel is the first → its override is the session's auth.
985
+ expect(tmux.launched[0]!.env.CLAUDE_CODE_OAUTH_TOKEN).toBe("oat_FIRST");
986
+ });
987
+
988
+ test("SECURITY: an unconfigured store ABORTS the launch BEFORE any mint/tmux side effect", async () => {
989
+ sessionsDir = mkdtempSync(join(tmpdir(), "spawn-agent-cred-none-"));
990
+ storeDir = mkdtempSync(join(tmpdir(), "channel-creds-none-")); // empty store
991
+ const tmux = recordingTmux();
992
+ let minted = false;
993
+ const fetchFn = (async () => {
994
+ minted = true;
995
+ return new Response("{}", { status: 200 });
996
+ }) as unknown as typeof fetch;
997
+ const deps = { ...baseDeps(), tmux, fetchFn, resolveClaudeToken: (ch: string) => resolveAgainst(storeDir, ch) };
998
+ await expect(
999
+ spawnAgent({ name: "x", channels: ["ghost"] }, deps),
1000
+ ).rejects.toThrow(/no Claude credential/);
1001
+ // No session launched, no token minted.
1002
+ expect(tmux.launched).toHaveLength(0);
1003
+ expect(minted).toBe(false);
1004
+ });
1005
+ });
1006
+
1007
+ // Resolve against a specific store dir (the real resolver hard-wires the default
1008
+ // state dir; this test helper threads the throwaway dir through, exercising the
1009
+ // SAME `resolveClaudeCredential` the production resolver calls).
1010
+ function resolveAgainst(storeDir: string, channel: string): string {
1011
+ const { resolveClaudeCredential } = require("./credentials.ts") as typeof import("./credentials.ts");
1012
+ return resolveClaudeCredential(channel, storeDir);
1013
+ }
1014
+
1015
+ // ---- buildLaunchScript (the tmux-buffer fix) -------------------------------
1016
+
1017
+ describe("buildLaunchScript — script body per argv shape, token-free", () => {
1018
+ test("macOS `/bin/bash -c <cmd>` shape: the body IS the command", () => {
1019
+ const script = buildLaunchScript(["/bin/bash", "-c", "sandbox-exec -p '...' claude --foo"]);
1020
+ expect(script.startsWith("#!/bin/bash\nset -euo pipefail\n")).toBe(true);
1021
+ expect(script).toContain("sandbox-exec -p '...' claude --foo");
1022
+ // No `exec <bash> -c` re-wrapping for this canonical shape.
1023
+ expect(script).not.toContain("exec /bin/bash");
1024
+ });
1025
+
1026
+ test("general argv (Linux bubblewrap shape): exec's the quoted argv", () => {
1027
+ const script = buildLaunchScript(["bwrap", "--ro-bind", "/usr", "/usr", "claude", "--mcp-config", "/ws/.mcp.json"]);
1028
+ expect(script.startsWith("#!/bin/bash\nset -euo pipefail\n")).toBe(true);
1029
+ expect(script).toContain("exec bwrap --ro-bind /usr /usr claude --mcp-config /ws/.mcp.json");
1030
+ });
1031
+ });
1032
+
1033
+ describe("realTmuxLauncher — launch-script indirection (tmux can't take the ~84KB profile inline)", () => {
1034
+ /** A recording spawnFn matching the `Bun.spawn` shape the launcher awaits. */
1035
+ function recordingSpawn(): {
1036
+ fn: typeof Bun.spawn;
1037
+ calls: string[][];
1038
+ } {
1039
+ const calls: string[][] = [];
1040
+ const fn = ((argv: string[]) => {
1041
+ calls.push(argv);
1042
+ return {
1043
+ exited: Promise.resolve(0),
1044
+ stderr: new Response("").body,
1045
+ };
1046
+ }) as unknown as typeof Bun.spawn;
1047
+ return { fn, calls };
1048
+ }
1049
+
1050
+ test("a >100KB wrapped command is NOT passed inline — tmux gets a short script-path argv; the script is written 0600 with the command; token rides env via -e", async () => {
1051
+ const workspace = mkdtempSync(join(tmpdir(), "launch-script-"));
1052
+ try {
1053
+ // A wrapped argv whose command embeds a giant (>100KB) profile inline — the
1054
+ // exact shape that overran tmux's buffer in the integration smoke.
1055
+ const bigProfile = "X".repeat(100_000);
1056
+ const bigCommand = `sandbox-exec -p '${bigProfile}' claude --strict-mcp-config --mcp-config ${join(workspace, ".mcp.json")}`;
1057
+ const wrappedArgv = ["/bin/bash", "-c", bigCommand];
1058
+ expect(bigCommand.length).toBeGreaterThan(100_000);
1059
+
1060
+ const { fn, calls } = recordingSpawn();
1061
+ const launcher = realTmuxLauncher(fn);
1062
+ await launcher.newSession({
1063
+ name: "big-agent",
1064
+ argv: wrappedArgv,
1065
+ env: { CLAUDE_CODE_OAUTH_TOKEN: "OAUTH-SECRET", SANDBOX_RUNTIME: "1" },
1066
+ cwd: workspace,
1067
+ });
1068
+
1069
+ // (a) the argv handed to tmux is SHORT — a script path, not the 100KB inline.
1070
+ expect(calls).toHaveLength(1);
1071
+ const tmuxArgv = calls[0]!;
1072
+ const scriptPath = join(workspace, ".launch.sh");
1073
+ expect(tmuxArgv[tmuxArgv.length - 2]).toBe("/bin/bash");
1074
+ expect(tmuxArgv[tmuxArgv.length - 1]).toBe(scriptPath);
1075
+ // The 100KB profile is NOWHERE on the tmux command line.
1076
+ expect(tmuxArgv.some((a) => a.length > 50_000)).toBe(false);
1077
+ expect(tmuxArgv.join(" ")).not.toContain(bigProfile);
1078
+
1079
+ // (b) the launch script is written, mode 0600, and contains the wrapped command.
1080
+ expect(statSync(scriptPath).mode & 0o777).toBe(0o600);
1081
+ const body = readFileSync(scriptPath, "utf8");
1082
+ expect(body.startsWith("#!/bin/bash\nset -euo pipefail\n")).toBe(true);
1083
+ expect(body).toContain(bigCommand);
1084
+
1085
+ // (c) env still passed via `-e KEY=VAL`.
1086
+ expect(tmuxArgv).toContain("-e");
1087
+ expect(tmuxArgv).toContain("CLAUDE_CODE_OAUTH_TOKEN=OAUTH-SECRET");
1088
+ expect(tmuxArgv).toContain("SANDBOX_RUNTIME=1");
1089
+
1090
+ // SECURITY: the secret rides the ENV, never the script body.
1091
+ expect(body).not.toContain("OAUTH-SECRET");
1092
+ } finally {
1093
+ rmSync(workspace, { recursive: true, force: true });
1094
+ }
1095
+ });
1096
+ });
1097
+
1098
+ describe("confirmDevChannelsPrompt — auto-answer the dev-channels consent gate (channel#70)", () => {
1099
+ /**
1100
+ * A recording spawnFn whose `tmux capture-pane` returns configurable pane text and
1101
+ * whose `tmux send-keys` is recorded. Mirrors the `recordingSpawn` shape above but
1102
+ * with a per-argv stdout (capture must return the pane content).
1103
+ */
1104
+ function recordingSpawn(paneText: string): {
1105
+ fn: typeof Bun.spawn;
1106
+ calls: string[][];
1107
+ } {
1108
+ const calls: string[][] = [];
1109
+ const fn = ((argv: string[]) => {
1110
+ calls.push(argv);
1111
+ const isCapture = argv.includes("capture-pane");
1112
+ return {
1113
+ exited: Promise.resolve(0),
1114
+ stdout: new Response(isCapture ? paneText : "").body,
1115
+ stderr: new Response("").body,
1116
+ };
1117
+ }) as unknown as typeof Bun.spawn;
1118
+ return { fn, calls };
1119
+ }
1120
+
1121
+ const noSleep = async () => {};
1122
+
1123
+ test("prompt marker present → returns 'confirmed' AND sends Enter to the pane", async () => {
1124
+ const pane = `WARNING: Loading development channels\n❯ 1. ${DEV_CHANNELS_PROMPT_MARKER}\n 2. Exit`;
1125
+ const { fn, calls } = recordingSpawn(pane);
1126
+ const result = await confirmDevChannelsPrompt("aaron-agent", {
1127
+ spawnFn: fn,
1128
+ timeoutMs: 5_000,
1129
+ intervalMs: 10,
1130
+ sleepFn: noSleep,
1131
+ });
1132
+ expect(result).toBe("confirmed");
1133
+ // A `tmux send-keys -t aaron-agent Enter` call was recorded.
1134
+ const sendKeys = calls.find((c) => c.includes("send-keys"));
1135
+ expect(sendKeys).toBeDefined();
1136
+ expect(sendKeys).toEqual(["tmux", "send-keys", "-t", "aaron-agent", "Enter"]);
1137
+ });
1138
+
1139
+ test("ready marker present (no prompt) → returns 'already-running', NO send-keys", async () => {
1140
+ const pane = `Welcome to Claude Code\n ${DEV_CHANNELS_READY_MARKER} · /help for help`;
1141
+ const { fn, calls } = recordingSpawn(pane);
1142
+ const result = await confirmDevChannelsPrompt("aaron-agent", {
1143
+ spawnFn: fn,
1144
+ timeoutMs: 5_000,
1145
+ intervalMs: 10,
1146
+ sleepFn: noSleep,
1147
+ });
1148
+ expect(result).toBe("already-running");
1149
+ expect(calls.some((c) => c.includes("send-keys"))).toBe(false);
1150
+ });
1151
+
1152
+ test("neither marker, tiny timeout + no-op sleep → returns 'timeout', NO throw, NO send-keys", async () => {
1153
+ const { fn, calls } = recordingSpawn("just some unrelated pane output\n$ ");
1154
+ const result = await confirmDevChannelsPrompt("aaron-agent", {
1155
+ spawnFn: fn,
1156
+ timeoutMs: 1,
1157
+ intervalMs: 1,
1158
+ sleepFn: noSleep,
1159
+ });
1160
+ expect(result).toBe("timeout");
1161
+ expect(calls.some((c) => c.includes("send-keys"))).toBe(false);
1162
+ // It DID poll at least once (the do-while guarantees a capture even at timeoutMs<=interval).
1163
+ expect(calls.some((c) => c.includes("capture-pane"))).toBe(true);
1164
+ });
1165
+
1166
+ test("a capture subprocess that throws degrades to timeout, never throws", async () => {
1167
+ const fn = (() => {
1168
+ throw new Error("tmux not found");
1169
+ }) as unknown as typeof Bun.spawn;
1170
+ const result = await confirmDevChannelsPrompt("aaron-agent", {
1171
+ spawnFn: fn,
1172
+ timeoutMs: 1,
1173
+ intervalMs: 1,
1174
+ sleepFn: noSleep,
1175
+ });
1176
+ expect(result).toBe("timeout");
1177
+ });
1178
+
1179
+ test("prompt seen but send-keys throws → degrades to timeout (does NOT lie 'confirmed'), never throws", async () => {
1180
+ const pane = `❯ 1. ${DEV_CHANNELS_PROMPT_MARKER}\n 2. Exit`;
1181
+ // capture-pane succeeds (returns the prompt); send-keys throws.
1182
+ const fn = ((argv: string[]) => {
1183
+ if (argv.includes("send-keys")) throw new Error("send-keys failed");
1184
+ return {
1185
+ exited: Promise.resolve(0),
1186
+ stdout: new Response(pane).body,
1187
+ stderr: new Response("").body,
1188
+ };
1189
+ }) as unknown as typeof Bun.spawn;
1190
+ const result = await confirmDevChannelsPrompt("aaron-agent", {
1191
+ spawnFn: fn,
1192
+ timeoutMs: 1,
1193
+ intervalMs: 1,
1194
+ sleepFn: noSleep,
1195
+ });
1196
+ expect(result).toBe("timeout");
1197
+ });
1198
+ });
1199
+
1200
+ describe("persistSpec / readPersistedSpec — spawn-spec recovery for restart", () => {
1201
+ test("round-trips a spec; readPersistedSpec returns null for a missing/garbage file", () => {
1202
+ const ws = mkdtempSync(join(tmpdir(), "spec-rt-"));
1203
+ try {
1204
+ expect(readPersistedSpec(ws)).toBeNull(); // nothing written yet
1205
+ const spec: AgentSpec = { name: "a", channels: ["c"], filesystem: "full" };
1206
+ persistSpec(ws, spec);
1207
+ expect(readPersistedSpec(ws)).toEqual(spec);
1208
+ // Written 0600 (matches the secret-bearing .mcp.json discipline; the workspace
1209
+ // dir is only umask-tight, so the file perm is the real guard).
1210
+ expect(statSync(specFilePath(ws)).mode & 0o777).toBe(0o600);
1211
+ // Corrupt it -> null (the restart path treats this as "no spec").
1212
+ writeFileSync(specFilePath(ws), "{not json");
1213
+ expect(readPersistedSpec(ws)).toBeNull();
1214
+ } finally {
1215
+ rmSync(ws, { recursive: true, force: true });
1216
+ }
1217
+ });
1218
+ });