agent-dealer 0.3.0 → 1.0.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 (262) hide show
  1. package/bundle/server/dist/adapters/agent-deck-bind.js +443 -0
  2. package/bundle/server/dist/adapters/agent-deck-bind.test.js +454 -0
  3. package/bundle/server/dist/adapters/agent-deck.js +38 -50
  4. package/bundle/server/dist/adapters/agent-deck.test.js +63 -0
  5. package/bundle/server/dist/adapters/agent-health.js +215 -45
  6. package/bundle/server/dist/adapters/agent-health.test.js +237 -0
  7. package/bundle/server/dist/adapters/authority-lifecycle.js +289 -0
  8. package/bundle/server/dist/adapters/codex-scoped-config.js +103 -0
  9. package/bundle/server/dist/adapters/execution-authority.js +103 -0
  10. package/bundle/server/dist/adapters/execution-authority.test.js +166 -0
  11. package/bundle/server/dist/adapters/external.js +0 -1
  12. package/bundle/server/dist/adapters/git-worktree.js +514 -0
  13. package/bundle/server/dist/adapters/git-worktree.test.js +502 -0
  14. package/bundle/server/dist/adapters/github.js +170 -0
  15. package/bundle/server/dist/adapters/github.test.js +205 -0
  16. package/bundle/server/dist/adapters/linear-inbox.js +199 -30
  17. package/bundle/server/dist/adapters/linear-inbox.test.js +35 -0
  18. package/bundle/server/dist/adapters/linear-sync.js +3 -101
  19. package/bundle/server/dist/adapters/managed-repo.js +168 -0
  20. package/bundle/server/dist/adapters/managed-repo.test.js +63 -0
  21. package/bundle/server/dist/adapters/outbound-delivery.js +83 -0
  22. package/bundle/server/dist/adapters/outbound-delivery.test.js +53 -0
  23. package/bundle/server/dist/adapters/reflect-authority.js +43 -0
  24. package/bundle/server/dist/cli-env.js +10 -0
  25. package/bundle/server/dist/coordinator/abort.integration.test.js +74 -0
  26. package/bundle/server/dist/coordinator/admission.js +330 -0
  27. package/bundle/server/dist/coordinator/admission.test.js +338 -0
  28. package/bundle/server/dist/coordinator/args.js +128 -0
  29. package/bundle/server/dist/coordinator/args.test.js +132 -0
  30. package/bundle/server/dist/coordinator/authority-recovery.integration.test.js +661 -0
  31. package/bundle/server/dist/coordinator/auto-merge.integration.test.js +363 -0
  32. package/bundle/server/dist/coordinator/auto-merge.js +286 -0
  33. package/bundle/server/dist/coordinator/auto-merge.timeout.test.js +14 -0
  34. package/bundle/server/dist/coordinator/branch-progress.js +81 -0
  35. package/bundle/server/dist/coordinator/clock-jump.js +98 -0
  36. package/bundle/server/dist/coordinator/commands.js +1088 -0
  37. package/bundle/server/dist/coordinator/commands.test.js +554 -0
  38. package/bundle/server/dist/coordinator/deck-outage-config.js +32 -0
  39. package/bundle/server/dist/coordinator/deck-outage.integration.test.js +200 -0
  40. package/bundle/server/dist/coordinator/dependencies.js +242 -0
  41. package/bundle/server/dist/coordinator/dependency-readiness.integration.test.js +535 -0
  42. package/bundle/server/dist/coordinator/developer-effect.js +959 -0
  43. package/bundle/server/dist/coordinator/developer-effect.test.js +1143 -0
  44. package/bundle/server/dist/coordinator/dispatcher.js +58 -0
  45. package/bundle/server/dist/coordinator/dispatcher.test.js +81 -0
  46. package/bundle/server/dist/coordinator/effect-registry.js +17 -0
  47. package/bundle/server/dist/coordinator/execution-env.integration.test.js +122 -0
  48. package/bundle/server/dist/coordinator/failure-reason.js +273 -0
  49. package/bundle/server/dist/coordinator/failure-reason.test.js +234 -0
  50. package/bundle/server/dist/coordinator/guidance.js +33 -0
  51. package/bundle/server/dist/coordinator/guidance.test.js +110 -0
  52. package/bundle/server/dist/coordinator/human-resolution.js +88 -0
  53. package/bundle/server/dist/coordinator/human-resolution.test.js +72 -0
  54. package/bundle/server/dist/coordinator/intent-forecast.js +13 -0
  55. package/bundle/server/dist/coordinator/intent-forecast.test.js +34 -0
  56. package/bundle/server/dist/coordinator/latest-failure.js +100 -0
  57. package/bundle/server/dist/coordinator/latest-failure.test.js +125 -0
  58. package/bundle/server/dist/coordinator/metrics.js +35 -0
  59. package/bundle/server/dist/coordinator/metrics.test.js +52 -0
  60. package/bundle/server/dist/coordinator/permissions.js +127 -0
  61. package/bundle/server/dist/coordinator/permissions.test.js +184 -0
  62. package/bundle/server/dist/coordinator/presumed-dead-budget.integration.test.js +146 -0
  63. package/bundle/server/dist/coordinator/process-liveness.js +191 -0
  64. package/bundle/server/dist/coordinator/profile-snapshot.js +20 -0
  65. package/bundle/server/dist/coordinator/profile-snapshot.test.js +118 -0
  66. package/bundle/server/dist/coordinator/projection.js +147 -0
  67. package/bundle/server/dist/coordinator/projection.test.js +121 -0
  68. package/bundle/server/dist/coordinator/prompts.js +164 -0
  69. package/bundle/server/dist/coordinator/prompts.test.js +177 -0
  70. package/bundle/server/dist/coordinator/queue-by-default.integration.test.js +271 -0
  71. package/bundle/server/dist/coordinator/reclaim-republish.integration.test.js +372 -0
  72. package/bundle/server/dist/coordinator/recovery.js +466 -0
  73. package/bundle/server/dist/coordinator/recovery.test.js +164 -0
  74. package/bundle/server/dist/coordinator/reflect-trigger.js +218 -0
  75. package/bundle/server/dist/coordinator/reflect-trigger.test.js +324 -0
  76. package/bundle/server/dist/coordinator/repair-cycle.integration.test.js +234 -0
  77. package/bundle/server/dist/coordinator/restart-recovery.integration.test.js +363 -0
  78. package/bundle/server/dist/coordinator/reviewer-effect.js +507 -0
  79. package/bundle/server/dist/coordinator/reviewer-effect.test.js +612 -0
  80. package/bundle/server/dist/coordinator/reviewer-result.js +38 -0
  81. package/bundle/server/dist/coordinator/reviewer-result.test.js +45 -0
  82. package/bundle/server/dist/coordinator/routing.js +160 -0
  83. package/bundle/server/dist/coordinator/routing.test.js +336 -0
  84. package/bundle/server/dist/coordinator/session-lifecycle.integration.test.js +127 -0
  85. package/bundle/server/dist/coordinator/session-lifecycle.js +280 -0
  86. package/bundle/server/dist/coordinator/session-lifecycle.test.js +121 -0
  87. package/bundle/server/dist/coordinator/session-progress.js +351 -0
  88. package/bundle/server/dist/coordinator/session-progress.test.js +190 -0
  89. package/bundle/server/dist/coordinator/session-timeouts.js +33 -0
  90. package/bundle/server/dist/coordinator/sleep-recovery.integration.test.js +233 -0
  91. package/bundle/server/dist/coordinator/spawn.js +49 -0
  92. package/bundle/server/dist/coordinator/sync-issue-base-branch.js +18 -0
  93. package/bundle/server/dist/coordinator/usage-cap-config.js +10 -0
  94. package/bundle/server/dist/coordinator/usage-cap-defer.js +189 -0
  95. package/bundle/server/dist/coordinator/usage-cap.integration.test.js +193 -0
  96. package/bundle/server/dist/coordinator/usage.js +40 -0
  97. package/bundle/server/dist/coordinator/usage.test.js +69 -0
  98. package/bundle/server/dist/coordinator/verification-receipt.js +333 -0
  99. package/bundle/server/dist/coordinator/verification-receipt.test.js +382 -0
  100. package/bundle/server/dist/coordinator/worker-loop.js +363 -0
  101. package/bundle/server/dist/coordinator/worker-loop.test.js +327 -0
  102. package/bundle/server/dist/coordinator/workflow-instances.test.js +45 -0
  103. package/bundle/server/dist/coordinator/worktree-owner-liveness.js +82 -0
  104. package/bundle/server/dist/coordinator/worktree-owner-liveness.test.js +199 -0
  105. package/bundle/server/dist/db/artifacts-issue-columns.test.js +18 -0
  106. package/bundle/server/dist/db/authority-attempts-stale-at-migration.test.js +65 -0
  107. package/bundle/server/dist/db/idempotency-index-migration.test.js +57 -0
  108. package/bundle/server/dist/db/index.js +357 -6
  109. package/bundle/server/dist/db/linear-inbox-unlock.test.js +67 -0
  110. package/bundle/server/dist/db/migrate-to-issues.js +824 -0
  111. package/bundle/server/dist/db/migrate-to-issues.test.js +842 -0
  112. package/bundle/server/dist/db/schema.sql +311 -1
  113. package/bundle/server/dist/db/schema.test.js +66 -0
  114. package/bundle/server/dist/db/worker-session-pid-migration.test.js +112 -0
  115. package/bundle/server/dist/dev-review-cli-happy-path.integration.test.js +338 -0
  116. package/bundle/server/dist/direct-start-liveness.integration.test.js +350 -0
  117. package/bundle/server/dist/index.js +58 -5
  118. package/bundle/server/dist/paths.js +40 -4
  119. package/bundle/server/dist/paths.test.js +42 -0
  120. package/bundle/server/dist/pid-marker-claim-child.js +18 -0
  121. package/bundle/server/dist/pid-marker-hold-arbiter-child.js +24 -0
  122. package/bundle/server/dist/pid-marker.js +141 -0
  123. package/bundle/server/dist/pid-marker.test.js +208 -0
  124. package/bundle/server/dist/queue/approve-deliver.js +128 -28
  125. package/bundle/server/dist/queue/reflect-schedule.js +25 -0
  126. package/bundle/server/dist/queue/send-gate.test.js +172 -36
  127. package/bundle/server/dist/repository/agents.js +55 -18
  128. package/bundle/server/dist/repository/artifacts-for-issue.js +42 -0
  129. package/bundle/server/dist/repository/artifacts-for-issue.test.js +48 -0
  130. package/bundle/server/dist/repository/artifacts.js +47 -0
  131. package/bundle/server/dist/repository/artifacts.test.js +48 -0
  132. package/bundle/server/dist/repository/authority-attempts.js +189 -0
  133. package/bundle/server/dist/repository/findings.js +70 -0
  134. package/bundle/server/dist/repository/findings.test.js +68 -0
  135. package/bundle/server/dist/repository/human-actions.js +142 -0
  136. package/bundle/server/dist/repository/human-actions.test.js +123 -0
  137. package/bundle/server/dist/repository/intake-settings.js +5 -59
  138. package/bundle/server/dist/repository/issues.js +305 -0
  139. package/bundle/server/dist/repository/issues.test.js +69 -0
  140. package/bundle/server/dist/repository/outbound-drafts.js +28 -0
  141. package/bundle/server/dist/repository/queue-entries.js +162 -0
  142. package/bundle/server/dist/repository/review-publications.js +153 -0
  143. package/bundle/server/dist/repository/review-publications.test.js +78 -0
  144. package/bundle/server/dist/repository/runs-external-id.test.js +5 -3
  145. package/bundle/server/dist/repository/runs.js +4 -211
  146. package/bundle/server/dist/repository/runtime-availability.js +54 -0
  147. package/bundle/server/dist/repository/usage-events.js +60 -0
  148. package/bundle/server/dist/repository/usage-events.test.js +56 -0
  149. package/bundle/server/dist/repository/work-items.js +305 -0
  150. package/bundle/server/dist/repository/work-items.test.js +142 -0
  151. package/bundle/server/dist/repository/worker-sessions.js +219 -0
  152. package/bundle/server/dist/repository/worker-sessions.test.js +60 -0
  153. package/bundle/server/dist/repository/workflow-events.js +193 -0
  154. package/bundle/server/dist/repository/workflow-events.test.js +89 -0
  155. package/bundle/server/dist/resolve-tsx-bin.js +19 -0
  156. package/bundle/server/dist/routes/human-actions.js +68 -0
  157. package/bundle/server/dist/routes/human-actions.test.js +284 -0
  158. package/bundle/server/dist/routes/index.js +29 -496
  159. package/bundle/server/dist/routes/issues.js +240 -0
  160. package/bundle/server/dist/routes/issues.test.js +451 -0
  161. package/bundle/server/dist/routes/queue.js +41 -0
  162. package/bundle/server/dist/routes/queue.test.js +78 -0
  163. package/bundle/server/dist/runners/claude-args.js +18 -30
  164. package/bundle/server/dist/runners/claude.js +19 -82
  165. package/bundle/server/dist/runners/claude.test.js +15 -70
  166. package/bundle/server/dist/runners/process-registry.js +11 -0
  167. package/bundle/server/dist/runners/prompts.js +13 -280
  168. package/bundle/server/dist/runners/prompts.test.js +22 -153
  169. package/bundle/server/dist/runners/reflect.js +4 -16
  170. package/bundle/server/dist/runners/spawn-cli.js +116 -9
  171. package/bundle/server/dist/runners/spawn-cli.test.js +130 -0
  172. package/bundle/server/dist/runners/stream-json.js +0 -141
  173. package/bundle/server/dist/runners/usage-cap.js +242 -0
  174. package/bundle/server/dist/runners/usage-cap.test.js +97 -0
  175. package/bundle/server/dist/server-liveness.js +37 -0
  176. package/bundle/server/dist/server-liveness.test.js +82 -0
  177. package/bundle/server/dist/static-ui.test.js +44 -0
  178. package/bundle/server/package.json +4 -2
  179. package/bundle/server/static-ui/assets/index-BTtPGYpY.js +60 -0
  180. package/bundle/server/static-ui/assets/index-BxTx4b1d.css +1 -0
  181. package/bundle/server/static-ui/index.html +2 -2
  182. package/bundle/shared/dist/agents.d.ts +254 -126
  183. package/bundle/shared/dist/agents.js +84 -19
  184. package/bundle/shared/dist/cursor-auth-health.d.ts +12 -0
  185. package/bundle/shared/dist/cursor-auth-health.js +36 -0
  186. package/bundle/shared/dist/cursor-auth-health.test.d.ts +1 -0
  187. package/bundle/shared/dist/cursor-auth-health.test.js +40 -0
  188. package/bundle/shared/dist/findings.d.ts +46 -0
  189. package/bundle/shared/dist/findings.js +17 -0
  190. package/bundle/shared/dist/findings.test.d.ts +1 -0
  191. package/bundle/shared/dist/findings.test.js +20 -0
  192. package/bundle/shared/dist/github-repo.d.ts +28 -0
  193. package/bundle/shared/dist/github-repo.js +98 -0
  194. package/bundle/shared/dist/github-repo.test.d.ts +1 -0
  195. package/bundle/shared/dist/github-repo.test.js +32 -0
  196. package/bundle/shared/dist/human-actions.d.ts +66 -0
  197. package/bundle/shared/dist/human-actions.js +51 -0
  198. package/bundle/shared/dist/human-actions.test.d.ts +1 -0
  199. package/bundle/shared/dist/human-actions.test.js +57 -0
  200. package/bundle/shared/dist/index.d.ts +488 -678
  201. package/bundle/shared/dist/index.js +22 -36
  202. package/bundle/shared/dist/issues.d.ts +385 -0
  203. package/bundle/shared/dist/issues.js +169 -0
  204. package/bundle/shared/dist/issues.test.d.ts +1 -0
  205. package/bundle/shared/dist/issues.test.js +72 -0
  206. package/bundle/shared/dist/outbound-draft.d.ts +6 -0
  207. package/bundle/shared/dist/outbound-draft.js +4 -0
  208. package/bundle/shared/dist/plan-triage.d.ts +6 -6
  209. package/bundle/shared/dist/profile-snapshot.d.ts +154 -0
  210. package/bundle/shared/dist/profile-snapshot.js +145 -0
  211. package/bundle/shared/dist/profile-snapshot.test.d.ts +1 -0
  212. package/bundle/shared/dist/profile-snapshot.test.js +67 -0
  213. package/bundle/shared/dist/queue-entries.d.ts +89 -0
  214. package/bundle/shared/dist/queue-entries.js +19 -0
  215. package/bundle/shared/dist/result-qa.d.ts +2 -2
  216. package/bundle/shared/dist/runtime-auth-health.d.ts +66 -0
  217. package/bundle/shared/dist/runtime-auth-health.js +186 -0
  218. package/bundle/shared/dist/runtime-auth-health.test.d.ts +1 -0
  219. package/bundle/shared/dist/runtime-auth-health.test.js +200 -0
  220. package/bundle/shared/dist/runtime-availability.d.ts +51 -0
  221. package/bundle/shared/dist/runtime-availability.js +13 -0
  222. package/bundle/shared/dist/usage-events.d.ts +36 -0
  223. package/bundle/shared/dist/usage-events.js +13 -0
  224. package/bundle/shared/dist/worker-sessions.d.ts +140 -0
  225. package/bundle/shared/dist/worker-sessions.js +70 -0
  226. package/bundle/shared/dist/worker-sessions.test.d.ts +1 -0
  227. package/bundle/shared/dist/worker-sessions.test.js +39 -0
  228. package/bundle/shared/dist/workflow.d.ts +78 -0
  229. package/bundle/shared/dist/workflow.js +59 -0
  230. package/bundle/shared/dist/workflow.test.d.ts +1 -0
  231. package/bundle/shared/dist/workflow.test.js +61 -0
  232. package/bundle/shared/package.json +1 -1
  233. package/dist/action.d.ts +1 -0
  234. package/dist/action.js +50 -0
  235. package/dist/action.test.d.ts +1 -0
  236. package/dist/action.test.js +70 -0
  237. package/dist/agent.d.ts +1 -0
  238. package/dist/agent.js +17 -0
  239. package/dist/agent.test.d.ts +1 -0
  240. package/dist/agent.test.js +19 -0
  241. package/dist/cli-check.d.ts +7 -0
  242. package/dist/cli-check.js +40 -0
  243. package/dist/doctor.js +59 -1
  244. package/dist/http.d.ts +5 -0
  245. package/dist/http.js +19 -0
  246. package/dist/index.js +28 -0
  247. package/dist/issue.d.ts +35 -0
  248. package/dist/issue.js +143 -0
  249. package/dist/issue.test.d.ts +1 -0
  250. package/dist/issue.test.js +240 -0
  251. package/dist/lifecycle.contract.test.d.ts +1 -0
  252. package/dist/lifecycle.contract.test.js +135 -0
  253. package/dist/queue.d.ts +11 -0
  254. package/dist/queue.js +60 -0
  255. package/dist/queue.test.d.ts +1 -0
  256. package/dist/queue.test.js +46 -0
  257. package/dist/test-fetch-stub.d.ts +6 -0
  258. package/dist/test-fetch-stub.js +31 -0
  259. package/package.json +2 -1
  260. package/templates/prod.env.example +1 -1
  261. package/bundle/server/static-ui/assets/index-DTcianAH.js +0 -79
  262. package/bundle/server/static-ui/assets/index-QYv3zmE6.css +0 -1
@@ -0,0 +1,454 @@
1
+ // packages/server/src/adapters/agent-deck-bind.test.ts
2
+ //
3
+ // prepareWorkerDeckConnection materializes a per-attempt MCP config with deck launch
4
+ // headers (no Authorization) and verifies with get_bound_deck (NOT-106).
5
+ import { test } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import fs from "node:fs";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+ import { execFileSync } from "node:child_process";
11
+ import { parse as parseToml } from "smol-toml";
12
+ import { StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
13
+ process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-deckbind-"));
14
+ const { migrate } = await import("../db/index.js");
15
+ const { prepareWorkerDeckConnection, releaseWorkerDeckConnection, parseDeckToolResult, isDeckUnreachableError } = await import("./agent-deck-bind.js");
16
+ const { codexScopedConfigDeniesSendGate } = await import("./codex-scoped-config.js");
17
+ const { roleCeiling } = await import("@agent-dealer/shared");
18
+ /** Default worker policy: outboundMutation is false for BOTH roles (NOT-134). */
19
+ const DENIED = roleCeiling("reviewer");
20
+ migrate();
21
+ const DECK = "7eb62206-f3a3-44d1-99e5-8f40b39be084";
22
+ const WT = "/tmp/worktrees/session-a-developer";
23
+ function textResult(obj) {
24
+ return { content: [{ type: "text", text: JSON.stringify(obj) }] };
25
+ }
26
+ function errorResult(msg) {
27
+ return { isError: true, content: [{ type: "text", text: msg }] };
28
+ }
29
+ const BASE_OPTS = {
30
+ deckId: DECK,
31
+ worktreePath: WT,
32
+ runtime: "claude_code",
33
+ };
34
+ test("parseDeckToolResult extracts and parses the text payload", () => {
35
+ assert.deepEqual(parseDeckToolResult(textResult({ a: 1 })), { a: 1 });
36
+ assert.throws(() => parseDeckToolResult({ content: [] }));
37
+ });
38
+ test("prepareWorkerDeckConnection verifies and writes a claude MCP config with deck headers, no Auth", async () => {
39
+ let verifyCalled = false;
40
+ const result = await prepareWorkerDeckConnection({
41
+ policy: DENIED,
42
+ ...BASE_OPTS,
43
+ verifyCallTool: async (name) => {
44
+ verifyCalled = true;
45
+ assert.equal(name, "get_bound_deck");
46
+ return textResult({ id: DECK, name: "personal-dev" });
47
+ },
48
+ });
49
+ assert.equal(result.ok, true);
50
+ assert.equal(verifyCalled, true);
51
+ if (result.ok) {
52
+ assert.ok(fs.existsSync(result.mcpConfigPath));
53
+ const written = JSON.parse(fs.readFileSync(result.mcpConfigPath, "utf8"));
54
+ const headers = written.mcpServers["agent-deck"].headers;
55
+ assert.equal(headers["x-agent-deck-deck-id"], DECK);
56
+ assert.equal(headers["x-agent-deck-workspace"], WT);
57
+ assert.equal(headers.Authorization, undefined);
58
+ await releaseWorkerDeckConnection({ mcpConfigPath: result.mcpConfigPath });
59
+ assert.equal(fs.existsSync(result.mcpConfigPath), false);
60
+ }
61
+ });
62
+ test("prepareWorkerDeckConnection rejects when get_bound_deck reports a different deck", async () => {
63
+ const result = await prepareWorkerDeckConnection({
64
+ policy: DENIED,
65
+ ...BASE_OPTS,
66
+ verifyCallTool: async () => textResult({ id: "some-other-deck-id" }),
67
+ });
68
+ assert.equal(result.ok, false);
69
+ if (!result.ok) {
70
+ assert.equal(result.kind, "infra_failure");
71
+ assert.match(result.reason, /returned deck some-other-deck-id, expected/);
72
+ }
73
+ });
74
+ test("prepareWorkerDeckConnection returns infra_failure when verify fails", async () => {
75
+ const result = await prepareWorkerDeckConnection({
76
+ policy: DENIED,
77
+ ...BASE_OPTS,
78
+ verifyCallTool: async () => errorResult("no session"),
79
+ });
80
+ assert.equal(result.ok, false);
81
+ if (!result.ok) {
82
+ assert.equal(result.kind, "infra_failure");
83
+ assert.match(result.reason, /get_bound_deck returned an error/);
84
+ }
85
+ });
86
+ test("prepareWorkerDeckConnection preflights every configured playbook before spawn", async () => {
87
+ const calls = [];
88
+ const result = await prepareWorkerDeckConnection({
89
+ policy: DENIED,
90
+ ...BASE_OPTS,
91
+ playbookIds: ["pb-review", "pb-security", "pb-review"],
92
+ verifyCallTool: async (name, args) => {
93
+ calls.push({ name, args });
94
+ if (name === "get_bound_deck")
95
+ return textResult({ id: DECK });
96
+ return textResult({ id: args.playbook_id });
97
+ },
98
+ });
99
+ assert.equal(result.ok, true);
100
+ assert.deepEqual(calls, [
101
+ { name: "get_bound_deck", args: {} },
102
+ { name: "get_playbook", args: { playbook_id: "pb-review" } },
103
+ { name: "get_playbook", args: { playbook_id: "pb-security" } },
104
+ ]);
105
+ if (result.ok)
106
+ await releaseWorkerDeckConnection({ mcpConfigPath: result.mcpConfigPath });
107
+ });
108
+ test("prepareWorkerDeckConnection fails closed when a configured playbook is unavailable", async () => {
109
+ const result = await prepareWorkerDeckConnection({
110
+ policy: DENIED,
111
+ ...BASE_OPTS,
112
+ playbookIds: ["pb-required"],
113
+ verifyCallTool: async (name) => name === "get_bound_deck" ? textResult({ id: DECK }) : errorResult("playbook missing"),
114
+ });
115
+ assert.equal(result.ok, false);
116
+ if (!result.ok) {
117
+ assert.equal(result.kind, "infra_failure");
118
+ assert.match(result.reason, /get_playbook\(pb-required\) returned an error/);
119
+ }
120
+ });
121
+ test("prepareWorkerDeckConnection shares one timeout budget across all preflight calls", async () => {
122
+ const result = await prepareWorkerDeckConnection({
123
+ policy: DENIED,
124
+ ...BASE_OPTS,
125
+ playbookIds: ["pb-one", "pb-two"],
126
+ timeoutMs: 40,
127
+ verifyCallTool: async (name, args) => {
128
+ if (name === "get_bound_deck")
129
+ return textResult({ id: DECK });
130
+ await new Promise((resolve) => setTimeout(resolve, 25));
131
+ return textResult({ id: args.playbook_id });
132
+ },
133
+ });
134
+ assert.equal(result.ok, false, "the second playbook must not receive a fresh timeout budget");
135
+ if (!result.ok)
136
+ assert.match(result.reason, /40ms total preflight budget/);
137
+ });
138
+ test("prepareWorkerDeckConnection for codex_local writes http_headers (no bearer) and CODEX_HOME env", async () => {
139
+ const result = await prepareWorkerDeckConnection({
140
+ policy: DENIED,
141
+ ...BASE_OPTS,
142
+ runtime: "codex_local",
143
+ verifyCallTool: async () => textResult({ id: DECK }),
144
+ });
145
+ assert.equal(result.ok, true);
146
+ if (result.ok) {
147
+ assert.ok(fs.statSync(result.mcpConfigPath).isDirectory());
148
+ const toml = fs.readFileSync(path.join(result.mcpConfigPath, "config.toml"), "utf8");
149
+ assert.match(toml, /\[mcp_servers\.agent-deck\]/);
150
+ assert.match(toml, /http_headers/);
151
+ assert.doesNotMatch(toml, /bearer_token_env_var/);
152
+ assert.doesNotMatch(toml, /Authorization/);
153
+ assert.equal(result.mcpEnv?.CODEX_HOME, result.mcpConfigPath);
154
+ assert.equal(result.mcpEnv?.AGENT_DECK_AUTHORITY_BEARER, undefined);
155
+ await releaseWorkerDeckConnection({ mcpConfigPath: result.mcpConfigPath });
156
+ assert.equal(fs.existsSync(result.mcpConfigPath), false);
157
+ }
158
+ });
159
+ test("the codex scoped config carries the send-gate denial, and drops it only when policy allows", async () => {
160
+ // NOT-134: codex has no --disallowedTools, so this file is the only place the denial
161
+ // can exist. Both roles default to outboundMutation:false, so both get it.
162
+ const denied = await prepareWorkerDeckConnection({
163
+ policy: DENIED,
164
+ ...BASE_OPTS,
165
+ runtime: "codex_local",
166
+ verifyCallTool: async () => textResult({ id: DECK }),
167
+ });
168
+ assert.equal(denied.ok, true);
169
+ if (denied.ok) {
170
+ const cfg = parseToml(fs.readFileSync(path.join(denied.mcpConfigPath, "config.toml"), "utf8"));
171
+ assert.deepEqual(cfg.mcp_servers["agent-deck"].disabled_tools, ["call_service_tool"]);
172
+ // A non-interactive `codex exec` has approval policy `never`, so the reads need an
173
+ // explicit approval_mode or the deck is configured, connected and unusable. Exactly
174
+ // the reads — the send gate must never appear here, since that entry could undo the
175
+ // removal above.
176
+ const tools = cfg.mcp_servers["agent-deck"].tools ?? {};
177
+ assert.deepEqual(Object.keys(tools).sort(), [
178
+ "bind_workspace",
179
+ "get_bound_deck",
180
+ "get_playbook",
181
+ "list_service_tools",
182
+ ]);
183
+ for (const t of Object.keys(tools))
184
+ assert.equal(tools[t].approval_mode, "approve");
185
+ assert.equal(tools["call_service_tool"], undefined);
186
+ assert.equal(codexScopedConfigDeniesSendGate(denied.mcpConfigPath), true);
187
+ await releaseWorkerDeckConnection({ mcpConfigPath: denied.mcpConfigPath });
188
+ }
189
+ const allowed = await prepareWorkerDeckConnection({
190
+ policy: { ...DENIED, outboundMutation: true },
191
+ ...BASE_OPTS,
192
+ runtime: "codex_local",
193
+ verifyCallTool: async () => textResult({ id: DECK }),
194
+ });
195
+ assert.equal(allowed.ok, true);
196
+ if (allowed.ok) {
197
+ const toml = fs.readFileSync(path.join(allowed.mcpConfigPath, "config.toml"), "utf8");
198
+ assert.doesNotMatch(toml, /disabled_tools/);
199
+ assert.equal(codexScopedConfigDeniesSendGate(allowed.mcpConfigPath), false);
200
+ await releaseWorkerDeckConnection({ mcpConfigPath: allowed.mcpConfigPath });
201
+ }
202
+ });
203
+ test("prepareWorkerDeckConnection for codex_local symlinks ambient auth.json and preserves auth policy", async () => {
204
+ const ambientCodexHome = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-ambient-codex-"));
205
+ const ambientAuthPath = path.join(ambientCodexHome, "auth.json");
206
+ fs.writeFileSync(ambientAuthPath, JSON.stringify({ tokens: { access_token: "real-secret-token" } }));
207
+ fs.writeFileSync(path.join(ambientCodexHome, "config.toml"), [
208
+ 'cli_auth_credentials_store = "file"',
209
+ 'chatgpt_base_url = "https://chatgpt.example.com"',
210
+ 'forced_login_method = "chatgpt"',
211
+ 'forced_chatgpt_workspace_id = "ws_123"',
212
+ 'model = "should-not-be-copied"',
213
+ "",
214
+ ].join("\n"));
215
+ const prevCodexHome = process.env.CODEX_HOME;
216
+ process.env.CODEX_HOME = ambientCodexHome;
217
+ try {
218
+ const result = await prepareWorkerDeckConnection({
219
+ policy: DENIED,
220
+ ...BASE_OPTS,
221
+ runtime: "codex_local",
222
+ verifyCallTool: async () => textResult({ id: DECK }),
223
+ });
224
+ assert.equal(result.ok, true);
225
+ if (result.ok) {
226
+ const isolatedAuthPath = path.join(result.mcpConfigPath, "auth.json");
227
+ assert.ok(fs.lstatSync(isolatedAuthPath).isSymbolicLink());
228
+ assert.equal(fs.readlinkSync(isolatedAuthPath), ambientAuthPath);
229
+ const isolated = parseToml(fs.readFileSync(path.join(result.mcpConfigPath, "config.toml"), "utf8"));
230
+ assert.equal(isolated.cli_auth_credentials_store, "file");
231
+ assert.equal(isolated.chatgpt_base_url, "https://chatgpt.example.com");
232
+ assert.equal(isolated.forced_login_method, "chatgpt");
233
+ assert.equal(isolated.forced_chatgpt_workspace_id, "ws_123");
234
+ assert.equal(isolated.model, undefined);
235
+ const mcpServers = isolated.mcp_servers;
236
+ assert.equal(mcpServers["agent-deck"].http_headers?.["x-agent-deck-deck-id"], DECK);
237
+ assert.equal(mcpServers["agent-deck"].http_headers?.Authorization, undefined);
238
+ await releaseWorkerDeckConnection({ mcpConfigPath: result.mcpConfigPath });
239
+ assert.equal(fs.existsSync(result.mcpConfigPath), false);
240
+ assert.ok(fs.existsSync(ambientAuthPath));
241
+ }
242
+ }
243
+ finally {
244
+ if (prevCodexHome === undefined)
245
+ delete process.env.CODEX_HOME;
246
+ else
247
+ process.env.CODEX_HOME = prevCodexHome;
248
+ fs.rmSync(ambientCodexHome, { recursive: true, force: true });
249
+ }
250
+ });
251
+ test("prepareWorkerDeckConnection for codex_local tolerates no ambient auth.json", async () => {
252
+ const ambientCodexHome = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-ambient-codex-nokey-"));
253
+ const prevCodexHome = process.env.CODEX_HOME;
254
+ process.env.CODEX_HOME = ambientCodexHome;
255
+ try {
256
+ const result = await prepareWorkerDeckConnection({
257
+ policy: DENIED,
258
+ ...BASE_OPTS,
259
+ runtime: "codex_local",
260
+ verifyCallTool: async () => textResult({ id: DECK }),
261
+ });
262
+ assert.equal(result.ok, true);
263
+ if (result.ok) {
264
+ assert.equal(fs.existsSync(path.join(result.mcpConfigPath, "auth.json")), false);
265
+ await releaseWorkerDeckConnection({ mcpConfigPath: result.mcpConfigPath });
266
+ }
267
+ }
268
+ finally {
269
+ if (prevCodexHome === undefined)
270
+ delete process.env.CODEX_HOME;
271
+ else
272
+ process.env.CODEX_HOME = prevCodexHome;
273
+ fs.rmSync(ambientCodexHome, { recursive: true, force: true });
274
+ }
275
+ });
276
+ function initGitRepo(dir) {
277
+ fs.mkdirSync(dir, { recursive: true });
278
+ execFileSync("git", ["init"], { cwd: dir, stdio: "ignore" });
279
+ execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: dir, stdio: "ignore" });
280
+ execFileSync("git", ["config", "user.name", "test"], { cwd: dir, stdio: "ignore" });
281
+ fs.writeFileSync(path.join(dir, "README"), "x\n");
282
+ execFileSync("git", ["add", "README"], { cwd: dir, stdio: "ignore" });
283
+ execFileSync("git", ["commit", "-m", "init"], { cwd: dir, stdio: "ignore" });
284
+ }
285
+ test("prepareWorkerDeckConnection for cursor_local writes mcp.json and excludes it", async () => {
286
+ const repo = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-cursor-wt-"));
287
+ initGitRepo(repo);
288
+ try {
289
+ const result = await prepareWorkerDeckConnection({
290
+ policy: DENIED,
291
+ deckId: DECK,
292
+ worktreePath: repo,
293
+ runtime: "cursor_local",
294
+ verifyCallTool: async () => textResult({ id: DECK }),
295
+ });
296
+ assert.equal(result.ok, true);
297
+ if (result.ok) {
298
+ assert.equal(result.mcpConfigPath, path.join(repo, ".cursor", "mcp.json"));
299
+ assert.ok(fs.existsSync(result.mcpConfigPath));
300
+ const written = JSON.parse(fs.readFileSync(result.mcpConfigPath, "utf8"));
301
+ const headers = written.mcpServers["agent-deck"].headers;
302
+ assert.equal(headers["x-agent-deck-deck-id"], DECK);
303
+ assert.equal(headers.Authorization, undefined);
304
+ const exclude = fs.readFileSync(path.join(repo, ".git", "info", "exclude"), "utf8");
305
+ assert.match(exclude, /\/\.cursor\/mcp\.json/);
306
+ await releaseWorkerDeckConnection({ mcpConfigPath: result.mcpConfigPath });
307
+ assert.equal(fs.existsSync(result.mcpConfigPath), false);
308
+ }
309
+ }
310
+ finally {
311
+ fs.rmSync(repo, { recursive: true, force: true });
312
+ }
313
+ });
314
+ test("prepareWorkerDeckConnection refuses cursor_local when .cursor/mcp.json is tracked", async () => {
315
+ const repo = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-cursor-tracked-"));
316
+ initGitRepo(repo);
317
+ try {
318
+ fs.mkdirSync(path.join(repo, ".cursor"), { recursive: true });
319
+ fs.writeFileSync(path.join(repo, ".cursor", "mcp.json"), "{}\n");
320
+ execFileSync("git", ["add", ".cursor/mcp.json"], { cwd: repo, stdio: "ignore" });
321
+ execFileSync("git", ["commit", "-m", "track mcp"], { cwd: repo, stdio: "ignore" });
322
+ const result = await prepareWorkerDeckConnection({
323
+ policy: DENIED,
324
+ deckId: DECK,
325
+ worktreePath: repo,
326
+ runtime: "cursor_local",
327
+ verifyCallTool: async () => textResult({ id: DECK }),
328
+ });
329
+ assert.equal(result.ok, false);
330
+ if (!result.ok) {
331
+ assert.equal(result.kind, "infra_failure");
332
+ assert.match(result.reason, /tracked/);
333
+ }
334
+ }
335
+ finally {
336
+ fs.rmSync(repo, { recursive: true, force: true });
337
+ }
338
+ });
339
+ // ---------------------------------------------------------------------------
340
+ // NOT-136: unreachable deck vs. a deck that answered with an error.
341
+ // ---------------------------------------------------------------------------
342
+ /** What node's fetch actually throws when nothing is listening on the deck port. */
343
+ function fetchFailed(code) {
344
+ const err = new TypeError("fetch failed");
345
+ err.cause = Object.assign(new Error(`connect ${code} 127.0.0.1:1110`), { code });
346
+ return err;
347
+ }
348
+ test("isDeckUnreachableError separates transport death from a deck-reported error", () => {
349
+ assert.equal(isDeckUnreachableError(fetchFailed("ECONNREFUSED")), true);
350
+ assert.equal(isDeckUnreachableError(Object.assign(new Error("boom"), { code: "ENOTFOUND" })), true);
351
+ assert.equal(isDeckUnreachableError(new AggregateError([Object.assign(new Error("x"), { code: "ECONNREFUSED" })], "all failed")), true);
352
+ // Anything the deck itself produced must stay a real failure.
353
+ assert.equal(isDeckUnreachableError(new Error("Error POSTing to endpoint (HTTP 500): boom")), false);
354
+ assert.equal(isDeckUnreachableError(new Error("get_bound_deck returned an error: no session")), false);
355
+ assert.equal(isDeckUnreachableError(new Error("invalid_token")), false);
356
+ assert.equal(isDeckUnreachableError(null), false);
357
+ });
358
+ test("an HTTP status beats a transport-looking message — the deck answered", () => {
359
+ // The SDK pastes the response body into the error message, so a deck that is up and
360
+ // returns 500 with the body `fetch failed` (NOT-101's shape, reported over HTTP) throws
361
+ // something that reads exactly like a dead port. The status proves bytes came back.
362
+ assert.equal(isDeckUnreachableError(new StreamableHTTPError(500, "Error POSTing to endpoint: fetch failed")), false);
363
+ assert.equal(isDeckUnreachableError(new StreamableHTTPError(502, "Error POSTing to endpoint: ECONNREFUSED upstream")), false);
364
+ // Structural, not instanceof-dependent: a numeric status anywhere in the chain is enough.
365
+ assert.equal(isDeckUnreachableError(Object.assign(new Error("fetch failed"), { status: 503 })), false);
366
+ assert.equal(isDeckUnreachableError(new Error("connect failed", { cause: Object.assign(new Error("fetch failed"), { code: 500 }) })), false);
367
+ // …and it does not swallow the real thing: a string code is a transport code, not a status.
368
+ assert.equal(isDeckUnreachableError(fetchFailed("ECONNREFUSED")), true);
369
+ });
370
+ test("preflight defers only when nothing answered, never on an HTTP error body", async () => {
371
+ const result = await prepareWorkerDeckConnection({
372
+ policy: DENIED,
373
+ ...BASE_OPTS,
374
+ verifyCallTool: async () => {
375
+ throw new StreamableHTTPError(500, "Error POSTing to endpoint: fetch failed");
376
+ },
377
+ });
378
+ assert.equal(result.ok, false);
379
+ if (!result.ok) {
380
+ assert.equal(result.kind, "infra_failure");
381
+ assert.match(result.reason, /preflight failed: Streamable HTTP error/);
382
+ }
383
+ });
384
+ test("preflight against a dead deck returns deck_unavailable, not infra_failure", async () => {
385
+ const result = await prepareWorkerDeckConnection({
386
+ policy: DENIED,
387
+ ...BASE_OPTS,
388
+ verifyCallTool: async () => {
389
+ throw fetchFailed("ECONNREFUSED");
390
+ },
391
+ });
392
+ assert.equal(result.ok, false);
393
+ if (!result.ok) {
394
+ assert.equal(result.kind, "deck_unavailable");
395
+ assert.match(result.reason, /Agent Deck is unreachable — fetch failed/);
396
+ }
397
+ });
398
+ test("preflight that exhausts its budget with no answer is deck_unavailable", async () => {
399
+ const result = await prepareWorkerDeckConnection({
400
+ policy: DENIED,
401
+ ...BASE_OPTS,
402
+ timeoutMs: 20,
403
+ verifyCallTool: () => new Promise(() => { }),
404
+ });
405
+ assert.equal(result.ok, false);
406
+ if (!result.ok) {
407
+ assert.equal(result.kind, "deck_unavailable");
408
+ assert.match(result.reason, /total preflight budget/);
409
+ }
410
+ });
411
+ test("a reachable deck that answers with an HTTP error still fails the attempt", async () => {
412
+ const result = await prepareWorkerDeckConnection({
413
+ policy: DENIED,
414
+ ...BASE_OPTS,
415
+ verifyCallTool: async () => {
416
+ throw new Error("Error POSTing to endpoint (HTTP 500): internal error");
417
+ },
418
+ });
419
+ assert.equal(result.ok, false);
420
+ if (!result.ok) {
421
+ assert.equal(result.kind, "infra_failure");
422
+ assert.match(result.reason, /preflight failed: Error POSTing/);
423
+ }
424
+ });
425
+ test("a deck that answers, reporting its own upstream as dead, is not mistaken for a dead deck", async () => {
426
+ // NOT-101's shape: the deck is up and replies, but a proxied MCP service behind it is
427
+ // down, so its `isError` text is the literal string a dead port produces. Classifying on
428
+ // prose would defer this forever; it is a real deck error and must spend the attempt.
429
+ const result = await prepareWorkerDeckConnection({
430
+ policy: DENIED,
431
+ ...BASE_OPTS,
432
+ verifyCallTool: async () => errorResult("fetch failed"),
433
+ });
434
+ assert.equal(result.ok, false);
435
+ if (!result.ok) {
436
+ assert.equal(result.kind, "infra_failure");
437
+ assert.match(result.reason, /preflight failed: get_bound_deck returned an error: fetch failed/);
438
+ }
439
+ });
440
+ test("a deck that loses the connection mid-playbook is deck_unavailable", async () => {
441
+ const result = await prepareWorkerDeckConnection({
442
+ policy: DENIED,
443
+ ...BASE_OPTS,
444
+ playbookIds: ["pb-review"],
445
+ verifyCallTool: async (name) => {
446
+ if (name === "get_bound_deck")
447
+ return textResult({ id: DECK });
448
+ throw fetchFailed("ECONNRESET");
449
+ },
450
+ });
451
+ assert.equal(result.ok, false);
452
+ if (!result.ok)
453
+ assert.equal(result.kind, "deck_unavailable");
454
+ });
@@ -1,7 +1,5 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4
- import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
5
3
  import { getAgentDeckConfig } from "../repository/intake-settings.js";
6
4
  export function getAgentDeckApiUrl() {
7
5
  if (process.env.AGENT_DECK_API_URL) {
@@ -49,18 +47,18 @@ export async function testAgentDeckConnection() {
49
47
  error: `HTTP ${health.status}`,
50
48
  };
51
49
  }
52
- let deckCount;
53
- try {
54
- const decksRes = await fetch(`${apiUrl}/api/decks`, { signal: AbortSignal.timeout(5000) });
55
- if (decksRes.ok) {
56
- const json = (await decksRes.json());
57
- deckCount = json.data?.length;
58
- }
59
- }
60
- catch {
61
- /* decks optional */
50
+ const decksResult = await fetchDecks();
51
+ if (decksResult.ok) {
52
+ return { connected: true, apiUrl, mcpUrl, deckCount: decksResult.decks.length, envOverride };
62
53
  }
63
- return { connected: true, apiUrl, mcpUrl, deckCount, envOverride };
54
+ return {
55
+ connected: true,
56
+ apiUrl,
57
+ mcpUrl,
58
+ envOverride,
59
+ deckAccessError: decksResult.code,
60
+ deckAccessErrorMessage: decksResult.message,
61
+ };
64
62
  }
65
63
  catch (e) {
66
64
  return {
@@ -72,12 +70,31 @@ export async function testAgentDeckConnection() {
72
70
  };
73
71
  }
74
72
  }
75
- export async function fetchAgentDeckDecks() {
76
- const res = await fetch(`${getAgentDeckApiUrl()}/api/decks`, { signal: AbortSignal.timeout(5000) });
77
- if (!res.ok)
78
- throw new Error(`Agent Deck API error: ${res.status}`);
79
- return res.json();
73
+ /**
74
+ * Unauthenticated launch deck-metadata discovery (NOT-106) — `GET /api/launch/decks`.
75
+ * A network/parse failure is reported as a typed outcome, never as an empty deck list.
76
+ */
77
+ export async function fetchDecks() {
78
+ try {
79
+ const res = await fetch(`${getAgentDeckApiUrl()}/api/launch/decks`, {
80
+ signal: AbortSignal.timeout(5000),
81
+ });
82
+ const json = (await res.json().catch(() => null));
83
+ if (!res.ok || !json || json.success === false) {
84
+ return {
85
+ ok: false,
86
+ code: "DECK_UNAVAILABLE",
87
+ message: json?.message ?? `Agent Deck API error: ${res.status}`,
88
+ };
89
+ }
90
+ return { ok: true, decks: json.data?.decks ?? [] };
91
+ }
92
+ catch (e) {
93
+ return { ok: false, code: "DECK_UNAVAILABLE", message: String(e) };
94
+ }
80
95
  }
96
+ /** @deprecated Prefer `fetchDecks` — alias kept for existing call sites. */
97
+ export const fetchAuthorizedDecks = fetchDecks;
81
98
  const DASHBOARD_HEADERS = { "x-agent-deck-client": "dashboard" };
82
99
  const DEALER_HEADERS = { "x-agent-deck-client": "dealer" };
83
100
  export async function proposePlaybookPatch(deckId, runId, proposal) {
@@ -173,7 +190,7 @@ export function toCallServiceToolPayload(toolCall) {
173
190
  arguments: toolCall.arguments,
174
191
  };
175
192
  }
176
- function extractPermalink(toolResult) {
193
+ export function extractPermalink(toolResult) {
177
194
  if (!toolResult || typeof toolResult !== "object")
178
195
  return undefined;
179
196
  const content = toolResult.content;
@@ -279,34 +296,5 @@ function formatToolResultText(tr) {
279
296
  .join("\n")
280
297
  .trim() || undefined;
281
298
  }
282
- export async function deliverOutboundDraft(deckId, toolCall, opts) {
283
- const payload = toCallServiceToolPayload(toolCall);
284
- const workspaceRoot = opts?.workspaceRoot ?? process.cwd();
285
- const timeoutMs = opts?.timeoutMs ?? Number(process.env.DELIVER_TIMEOUT_MS ?? 60_000);
286
- const callTool = opts?.callTool ??
287
- (async (p) => {
288
- const mcpBase = getAgentDeckMcpUrl().replace(/\/mcp\/?$/, "");
289
- const transport = new StreamableHTTPClientTransport(new URL(`${mcpBase}/mcp`));
290
- const client = new Client({ name: "agent-dealer-deliver", version: "0.0.1" });
291
- const connectAndCall = async () => {
292
- await client.connect(transport);
293
- try {
294
- await client.callTool({
295
- name: "bind_workspace",
296
- arguments: { deckId, workspaceRoot },
297
- });
298
- return await client.callTool({ name: "call_service_tool", arguments: p });
299
- }
300
- finally {
301
- await client.close();
302
- }
303
- };
304
- return await Promise.race([
305
- connectAndCall(),
306
- new Promise((_, reject) => setTimeout(() => reject(new Error(`Outbound deliver timed out after ${timeoutMs}ms`)), timeoutMs)),
307
- ]);
308
- });
309
- const toolResult = await callTool(payload);
310
- assertCallServiceToolSuccess(toolResult);
311
- return { toolResult, permalink: extractPermalink(toolResult) };
312
- }
299
+ // `deliverOutboundDraft` (deck-header MCP call for approved outbound delivery) has moved to
300
+ // ./outbound-delivery.ts (NOT-106 / NOT-95).
@@ -0,0 +1,63 @@
1
+ // packages/server/src/adapters/agent-deck.test.ts
2
+ //
3
+ // NOT-106: fetchDecks hits unauthenticated GET /api/launch/decks.
4
+ import { test, mock } from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import fs from "node:fs";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+ process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-agentdeck-"));
10
+ const { migrate } = await import("../db/index.js");
11
+ const { fetchDecks, fetchAuthorizedDecks } = await import("./agent-deck.js");
12
+ migrate();
13
+ test("fetchDecks returns decks on success from /api/launch/decks", async () => {
14
+ let capturedUrl = "";
15
+ const fetchMock = mock.method(globalThis, "fetch", async (url, init) => {
16
+ capturedUrl = String(url);
17
+ assert.equal(init?.headers?.Authorization, undefined);
18
+ return new Response(JSON.stringify({ success: true, data: { decks: [{ id: "d1", name: "personal-dev" }] } }), { status: 200 });
19
+ });
20
+ try {
21
+ const result = await fetchDecks();
22
+ assert.equal(result.ok, true);
23
+ if (result.ok)
24
+ assert.deepEqual(result.decks, [{ id: "d1", name: "personal-dev" }]);
25
+ assert.match(capturedUrl, /\/api\/launch\/decks$/);
26
+ }
27
+ finally {
28
+ fetchMock.mock.restore();
29
+ }
30
+ });
31
+ test("fetchDecks reports DECK_UNAVAILABLE on HTTP error, never an empty list", async () => {
32
+ const fetchMock = mock.method(globalThis, "fetch", async () => {
33
+ return new Response(JSON.stringify({ success: false, message: "boom" }), { status: 500 });
34
+ });
35
+ try {
36
+ const result = await fetchDecks();
37
+ assert.equal(result.ok, false);
38
+ if (!result.ok) {
39
+ assert.equal(result.code, "DECK_UNAVAILABLE");
40
+ assert.match(result.message, /boom|500/);
41
+ }
42
+ }
43
+ finally {
44
+ fetchMock.mock.restore();
45
+ }
46
+ });
47
+ test("fetchDecks reports DECK_UNAVAILABLE on a network error, never an empty list", async () => {
48
+ const fetchMock = mock.method(globalThis, "fetch", async () => {
49
+ throw new Error("ECONNREFUSED");
50
+ });
51
+ try {
52
+ const result = await fetchDecks();
53
+ assert.equal(result.ok, false);
54
+ if (!result.ok)
55
+ assert.equal(result.code, "DECK_UNAVAILABLE");
56
+ }
57
+ finally {
58
+ fetchMock.mock.restore();
59
+ }
60
+ });
61
+ test("fetchAuthorizedDecks is an alias of fetchDecks", () => {
62
+ assert.equal(fetchAuthorizedDecks, fetchDecks);
63
+ });