agent-dealer 0.2.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 (276) 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 +230 -33
  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 +26 -0
  25. package/bundle/server/dist/cli-env.test.js +14 -1
  26. package/bundle/server/dist/coordinator/abort.integration.test.js +74 -0
  27. package/bundle/server/dist/coordinator/admission.js +330 -0
  28. package/bundle/server/dist/coordinator/admission.test.js +338 -0
  29. package/bundle/server/dist/coordinator/args.js +128 -0
  30. package/bundle/server/dist/coordinator/args.test.js +132 -0
  31. package/bundle/server/dist/coordinator/authority-recovery.integration.test.js +661 -0
  32. package/bundle/server/dist/coordinator/auto-merge.integration.test.js +363 -0
  33. package/bundle/server/dist/coordinator/auto-merge.js +286 -0
  34. package/bundle/server/dist/coordinator/auto-merge.timeout.test.js +14 -0
  35. package/bundle/server/dist/coordinator/branch-progress.js +81 -0
  36. package/bundle/server/dist/coordinator/clock-jump.js +98 -0
  37. package/bundle/server/dist/coordinator/commands.js +1088 -0
  38. package/bundle/server/dist/coordinator/commands.test.js +554 -0
  39. package/bundle/server/dist/coordinator/deck-outage-config.js +32 -0
  40. package/bundle/server/dist/coordinator/deck-outage.integration.test.js +200 -0
  41. package/bundle/server/dist/coordinator/dependencies.js +242 -0
  42. package/bundle/server/dist/coordinator/dependency-readiness.integration.test.js +535 -0
  43. package/bundle/server/dist/coordinator/developer-effect.js +959 -0
  44. package/bundle/server/dist/coordinator/developer-effect.test.js +1143 -0
  45. package/bundle/server/dist/coordinator/dispatcher.js +58 -0
  46. package/bundle/server/dist/coordinator/dispatcher.test.js +81 -0
  47. package/bundle/server/dist/coordinator/effect-registry.js +17 -0
  48. package/bundle/server/dist/coordinator/execution-env.integration.test.js +122 -0
  49. package/bundle/server/dist/coordinator/failure-reason.js +273 -0
  50. package/bundle/server/dist/coordinator/failure-reason.test.js +234 -0
  51. package/bundle/server/dist/coordinator/guidance.js +33 -0
  52. package/bundle/server/dist/coordinator/guidance.test.js +110 -0
  53. package/bundle/server/dist/coordinator/human-resolution.js +88 -0
  54. package/bundle/server/dist/coordinator/human-resolution.test.js +72 -0
  55. package/bundle/server/dist/coordinator/intent-forecast.js +13 -0
  56. package/bundle/server/dist/coordinator/intent-forecast.test.js +34 -0
  57. package/bundle/server/dist/coordinator/latest-failure.js +100 -0
  58. package/bundle/server/dist/coordinator/latest-failure.test.js +125 -0
  59. package/bundle/server/dist/coordinator/metrics.js +35 -0
  60. package/bundle/server/dist/coordinator/metrics.test.js +52 -0
  61. package/bundle/server/dist/coordinator/permissions.js +127 -0
  62. package/bundle/server/dist/coordinator/permissions.test.js +184 -0
  63. package/bundle/server/dist/coordinator/presumed-dead-budget.integration.test.js +146 -0
  64. package/bundle/server/dist/coordinator/process-liveness.js +191 -0
  65. package/bundle/server/dist/coordinator/profile-snapshot.js +20 -0
  66. package/bundle/server/dist/coordinator/profile-snapshot.test.js +118 -0
  67. package/bundle/server/dist/coordinator/projection.js +147 -0
  68. package/bundle/server/dist/coordinator/projection.test.js +121 -0
  69. package/bundle/server/dist/coordinator/prompts.js +164 -0
  70. package/bundle/server/dist/coordinator/prompts.test.js +177 -0
  71. package/bundle/server/dist/coordinator/queue-by-default.integration.test.js +271 -0
  72. package/bundle/server/dist/coordinator/reclaim-republish.integration.test.js +372 -0
  73. package/bundle/server/dist/coordinator/recovery.js +466 -0
  74. package/bundle/server/dist/coordinator/recovery.test.js +164 -0
  75. package/bundle/server/dist/coordinator/reflect-trigger.js +218 -0
  76. package/bundle/server/dist/coordinator/reflect-trigger.test.js +324 -0
  77. package/bundle/server/dist/coordinator/repair-cycle.integration.test.js +234 -0
  78. package/bundle/server/dist/coordinator/restart-recovery.integration.test.js +363 -0
  79. package/bundle/server/dist/coordinator/reviewer-effect.js +507 -0
  80. package/bundle/server/dist/coordinator/reviewer-effect.test.js +612 -0
  81. package/bundle/server/dist/coordinator/reviewer-result.js +38 -0
  82. package/bundle/server/dist/coordinator/reviewer-result.test.js +45 -0
  83. package/bundle/server/dist/coordinator/routing.js +160 -0
  84. package/bundle/server/dist/coordinator/routing.test.js +336 -0
  85. package/bundle/server/dist/coordinator/session-lifecycle.integration.test.js +127 -0
  86. package/bundle/server/dist/coordinator/session-lifecycle.js +280 -0
  87. package/bundle/server/dist/coordinator/session-lifecycle.test.js +121 -0
  88. package/bundle/server/dist/coordinator/session-progress.js +351 -0
  89. package/bundle/server/dist/coordinator/session-progress.test.js +190 -0
  90. package/bundle/server/dist/coordinator/session-timeouts.js +33 -0
  91. package/bundle/server/dist/coordinator/sleep-recovery.integration.test.js +233 -0
  92. package/bundle/server/dist/coordinator/spawn.js +49 -0
  93. package/bundle/server/dist/coordinator/sync-issue-base-branch.js +18 -0
  94. package/bundle/server/dist/coordinator/usage-cap-config.js +10 -0
  95. package/bundle/server/dist/coordinator/usage-cap-defer.js +189 -0
  96. package/bundle/server/dist/coordinator/usage-cap.integration.test.js +193 -0
  97. package/bundle/server/dist/coordinator/usage.js +40 -0
  98. package/bundle/server/dist/coordinator/usage.test.js +69 -0
  99. package/bundle/server/dist/coordinator/verification-receipt.js +333 -0
  100. package/bundle/server/dist/coordinator/verification-receipt.test.js +382 -0
  101. package/bundle/server/dist/coordinator/worker-loop.js +363 -0
  102. package/bundle/server/dist/coordinator/worker-loop.test.js +327 -0
  103. package/bundle/server/dist/coordinator/workflow-instances.test.js +45 -0
  104. package/bundle/server/dist/coordinator/worktree-owner-liveness.js +82 -0
  105. package/bundle/server/dist/coordinator/worktree-owner-liveness.test.js +199 -0
  106. package/bundle/server/dist/db/artifacts-issue-columns.test.js +18 -0
  107. package/bundle/server/dist/db/authority-attempts-stale-at-migration.test.js +65 -0
  108. package/bundle/server/dist/db/idempotency-index-migration.test.js +57 -0
  109. package/bundle/server/dist/db/index.js +359 -7
  110. package/bundle/server/dist/db/linear-inbox-unlock.test.js +67 -0
  111. package/bundle/server/dist/db/migrate-to-issues.js +824 -0
  112. package/bundle/server/dist/db/migrate-to-issues.test.js +842 -0
  113. package/bundle/server/dist/db/schema.sql +311 -1
  114. package/bundle/server/dist/db/schema.test.js +66 -0
  115. package/bundle/server/dist/db/worker-session-pid-migration.test.js +112 -0
  116. package/bundle/server/dist/dev-review-cli-happy-path.integration.test.js +338 -0
  117. package/bundle/server/dist/direct-start-liveness.integration.test.js +350 -0
  118. package/bundle/server/dist/index.js +58 -5
  119. package/bundle/server/dist/paths.js +40 -4
  120. package/bundle/server/dist/paths.test.js +42 -0
  121. package/bundle/server/dist/pid-marker-claim-child.js +18 -0
  122. package/bundle/server/dist/pid-marker-hold-arbiter-child.js +24 -0
  123. package/bundle/server/dist/pid-marker.js +141 -0
  124. package/bundle/server/dist/pid-marker.test.js +208 -0
  125. package/bundle/server/dist/queue/approve-deliver.js +128 -28
  126. package/bundle/server/dist/queue/reflect-schedule.js +25 -0
  127. package/bundle/server/dist/queue/result-qa.js +1 -1
  128. package/bundle/server/dist/queue/send-gate.test.js +172 -36
  129. package/bundle/server/dist/repository/agents.js +55 -18
  130. package/bundle/server/dist/repository/artifacts-for-issue.js +42 -0
  131. package/bundle/server/dist/repository/artifacts-for-issue.test.js +48 -0
  132. package/bundle/server/dist/repository/artifacts.js +47 -0
  133. package/bundle/server/dist/repository/artifacts.test.js +48 -0
  134. package/bundle/server/dist/repository/authority-attempts.js +189 -0
  135. package/bundle/server/dist/repository/findings.js +70 -0
  136. package/bundle/server/dist/repository/findings.test.js +68 -0
  137. package/bundle/server/dist/repository/human-actions.js +142 -0
  138. package/bundle/server/dist/repository/human-actions.test.js +123 -0
  139. package/bundle/server/dist/repository/intake-settings.js +5 -59
  140. package/bundle/server/dist/repository/issues.js +305 -0
  141. package/bundle/server/dist/repository/issues.test.js +69 -0
  142. package/bundle/server/dist/repository/outbound-drafts.js +28 -0
  143. package/bundle/server/dist/repository/queue-entries.js +162 -0
  144. package/bundle/server/dist/repository/review-publications.js +153 -0
  145. package/bundle/server/dist/repository/review-publications.test.js +78 -0
  146. package/bundle/server/dist/repository/runs-external-id.test.js +5 -3
  147. package/bundle/server/dist/repository/runs.js +4 -211
  148. package/bundle/server/dist/repository/runtime-availability.js +54 -0
  149. package/bundle/server/dist/repository/usage-events.js +60 -0
  150. package/bundle/server/dist/repository/usage-events.test.js +56 -0
  151. package/bundle/server/dist/repository/work-items.js +305 -0
  152. package/bundle/server/dist/repository/work-items.test.js +142 -0
  153. package/bundle/server/dist/repository/worker-sessions.js +219 -0
  154. package/bundle/server/dist/repository/worker-sessions.test.js +60 -0
  155. package/bundle/server/dist/repository/workflow-events.js +193 -0
  156. package/bundle/server/dist/repository/workflow-events.test.js +89 -0
  157. package/bundle/server/dist/resolve-tsx-bin.js +19 -0
  158. package/bundle/server/dist/routes/human-actions.js +68 -0
  159. package/bundle/server/dist/routes/human-actions.test.js +284 -0
  160. package/bundle/server/dist/routes/index.js +29 -496
  161. package/bundle/server/dist/routes/issues.js +240 -0
  162. package/bundle/server/dist/routes/issues.test.js +451 -0
  163. package/bundle/server/dist/routes/queue.js +41 -0
  164. package/bundle/server/dist/routes/queue.test.js +78 -0
  165. package/bundle/server/dist/runners/claude-args.js +18 -30
  166. package/bundle/server/dist/runners/claude.js +19 -163
  167. package/bundle/server/dist/runners/claude.test.js +15 -70
  168. package/bundle/server/dist/runners/codex-args.js +59 -0
  169. package/bundle/server/dist/runners/codex-args.test.js +72 -0
  170. package/bundle/server/dist/runners/codex-jsonl.js +150 -0
  171. package/bundle/server/dist/runners/codex-jsonl.test.js +36 -0
  172. package/bundle/server/dist/runners/codex-resume.test.js +19 -0
  173. package/bundle/server/dist/runners/codex.js +46 -0
  174. package/bundle/server/dist/runners/models-codex.test.js +14 -0
  175. package/bundle/server/dist/runners/models.js +53 -1
  176. package/bundle/server/dist/runners/persist.js +4 -1
  177. package/bundle/server/dist/runners/process-registry.js +11 -0
  178. package/bundle/server/dist/runners/prompts.js +13 -280
  179. package/bundle/server/dist/runners/prompts.test.js +22 -153
  180. package/bundle/server/dist/runners/qa.js +17 -1
  181. package/bundle/server/dist/runners/reflect.js +4 -16
  182. package/bundle/server/dist/runners/spawn-cli.js +197 -0
  183. package/bundle/server/dist/runners/spawn-cli.test.js +130 -0
  184. package/bundle/server/dist/runners/stream-json.js +0 -141
  185. package/bundle/server/dist/runners/usage-cap.js +242 -0
  186. package/bundle/server/dist/runners/usage-cap.test.js +97 -0
  187. package/bundle/server/dist/server-liveness.js +37 -0
  188. package/bundle/server/dist/server-liveness.test.js +82 -0
  189. package/bundle/server/dist/static-ui.test.js +44 -0
  190. package/bundle/server/package.json +4 -2
  191. package/bundle/server/static-ui/assets/index-BTtPGYpY.js +60 -0
  192. package/bundle/server/static-ui/assets/index-BxTx4b1d.css +1 -0
  193. package/bundle/server/static-ui/index.html +2 -2
  194. package/bundle/shared/dist/agents.d.ts +262 -133
  195. package/bundle/shared/dist/agents.js +85 -19
  196. package/bundle/shared/dist/cursor-auth-health.d.ts +12 -0
  197. package/bundle/shared/dist/cursor-auth-health.js +36 -0
  198. package/bundle/shared/dist/cursor-auth-health.test.d.ts +1 -0
  199. package/bundle/shared/dist/cursor-auth-health.test.js +40 -0
  200. package/bundle/shared/dist/findings.d.ts +46 -0
  201. package/bundle/shared/dist/findings.js +17 -0
  202. package/bundle/shared/dist/findings.test.d.ts +1 -0
  203. package/bundle/shared/dist/findings.test.js +20 -0
  204. package/bundle/shared/dist/github-repo.d.ts +28 -0
  205. package/bundle/shared/dist/github-repo.js +98 -0
  206. package/bundle/shared/dist/github-repo.test.d.ts +1 -0
  207. package/bundle/shared/dist/github-repo.test.js +32 -0
  208. package/bundle/shared/dist/human-actions.d.ts +66 -0
  209. package/bundle/shared/dist/human-actions.js +51 -0
  210. package/bundle/shared/dist/human-actions.test.d.ts +1 -0
  211. package/bundle/shared/dist/human-actions.test.js +57 -0
  212. package/bundle/shared/dist/index.d.ts +567 -757
  213. package/bundle/shared/dist/index.js +22 -36
  214. package/bundle/shared/dist/issues.d.ts +385 -0
  215. package/bundle/shared/dist/issues.js +169 -0
  216. package/bundle/shared/dist/issues.test.d.ts +1 -0
  217. package/bundle/shared/dist/issues.test.js +72 -0
  218. package/bundle/shared/dist/outbound-draft.d.ts +6 -0
  219. package/bundle/shared/dist/outbound-draft.js +4 -0
  220. package/bundle/shared/dist/plan-triage.d.ts +6 -6
  221. package/bundle/shared/dist/profile-snapshot.d.ts +154 -0
  222. package/bundle/shared/dist/profile-snapshot.js +145 -0
  223. package/bundle/shared/dist/profile-snapshot.test.d.ts +1 -0
  224. package/bundle/shared/dist/profile-snapshot.test.js +67 -0
  225. package/bundle/shared/dist/queue-entries.d.ts +89 -0
  226. package/bundle/shared/dist/queue-entries.js +19 -0
  227. package/bundle/shared/dist/result-qa.d.ts +2 -2
  228. package/bundle/shared/dist/runtime-auth-health.d.ts +66 -0
  229. package/bundle/shared/dist/runtime-auth-health.js +186 -0
  230. package/bundle/shared/dist/runtime-auth-health.test.d.ts +1 -0
  231. package/bundle/shared/dist/runtime-auth-health.test.js +200 -0
  232. package/bundle/shared/dist/runtime-availability.d.ts +51 -0
  233. package/bundle/shared/dist/runtime-availability.js +13 -0
  234. package/bundle/shared/dist/runtime.d.ts +1 -1
  235. package/bundle/shared/dist/runtime.js +1 -1
  236. package/bundle/shared/dist/usage-events.d.ts +36 -0
  237. package/bundle/shared/dist/usage-events.js +13 -0
  238. package/bundle/shared/dist/worker-sessions.d.ts +140 -0
  239. package/bundle/shared/dist/worker-sessions.js +70 -0
  240. package/bundle/shared/dist/worker-sessions.test.d.ts +1 -0
  241. package/bundle/shared/dist/worker-sessions.test.js +39 -0
  242. package/bundle/shared/dist/workflow.d.ts +78 -0
  243. package/bundle/shared/dist/workflow.js +59 -0
  244. package/bundle/shared/dist/workflow.test.d.ts +1 -0
  245. package/bundle/shared/dist/workflow.test.js +61 -0
  246. package/bundle/shared/package.json +1 -1
  247. package/dist/action.d.ts +1 -0
  248. package/dist/action.js +50 -0
  249. package/dist/action.test.d.ts +1 -0
  250. package/dist/action.test.js +70 -0
  251. package/dist/agent.d.ts +1 -0
  252. package/dist/agent.js +17 -0
  253. package/dist/agent.test.d.ts +1 -0
  254. package/dist/agent.test.js +19 -0
  255. package/dist/cli-check.d.ts +7 -0
  256. package/dist/cli-check.js +40 -0
  257. package/dist/doctor.js +59 -1
  258. package/dist/http.d.ts +5 -0
  259. package/dist/http.js +19 -0
  260. package/dist/index.js +28 -0
  261. package/dist/issue.d.ts +35 -0
  262. package/dist/issue.js +143 -0
  263. package/dist/issue.test.d.ts +1 -0
  264. package/dist/issue.test.js +240 -0
  265. package/dist/lifecycle.contract.test.d.ts +1 -0
  266. package/dist/lifecycle.contract.test.js +135 -0
  267. package/dist/queue.d.ts +11 -0
  268. package/dist/queue.js +60 -0
  269. package/dist/queue.test.d.ts +1 -0
  270. package/dist/queue.test.js +46 -0
  271. package/dist/test-fetch-stub.d.ts +6 -0
  272. package/dist/test-fetch-stub.js +31 -0
  273. package/package.json +2 -1
  274. package/templates/prod.env.example +1 -1
  275. package/bundle/server/static-ui/assets/index-B_7S7xJb.js +0 -79
  276. package/bundle/server/static-ui/assets/index-QYv3zmE6.css +0 -1
@@ -0,0 +1,289 @@
1
+ // packages/server/src/adapters/authority-lifecycle.ts
2
+ //
3
+ // Shared mint/reuse/revoke lifecycle for every caller of execution-authority.ts (NOT-91):
4
+ // agent-deck-bind.ts (developer/reviewer worker attempts), reflect-trigger.ts (post-review
5
+ // reflection), and queue/approve-deliver.ts (outbound-draft delivery). Each of those used to
6
+ // call mintAuthority directly and hold the result only in a local variable for the duration
7
+ // of one async function — a crash anywhere in that window left a live, unrevoked authority
8
+ // with nothing durable to reconcile against. This module makes `authority_attempts`
9
+ // (repository/authority-attempts.ts) the one place that happens, so:
10
+ //
11
+ // - a same-owner retry always revokes its predecessor's authority first ("revoke-before-
12
+ // new-attempt" — a superseded attempt has no legitimate further use);
13
+ // - an idempotent remint (Deck reuses a still-live authority for a repeated key and
14
+ // returns no new secret, NOT-85 §7) is recovered from automatically — revoke the
15
+ // orphaned authority, mint again under a derived key — instead of failing the caller's
16
+ // whole attempt, as agent-deck-bind.ts / reflect-trigger.ts / approve-deliver.ts each
17
+ // used to;
18
+ // - AUTHORITY_EXPIRED (benign — the referenced authority merely aged out) auto-retries
19
+ // under a fresh key, while AUTHORITY_REVOKED (an operator/policy decision) is never
20
+ // silently retried — both fold into the same `interaction_required` outcome every
21
+ // caller already parks on, so an explicit revoke gets a human decision before another
22
+ // attempt, exactly like Deck's own INTERACTION_REQUIRED;
23
+ // - recovery.ts / commands.ts's abortIssue can revoke whatever authority a dead/cancelled
24
+ // owner still holds, and reconcileAuthoritiesAtStartup can sweep every attempt a crashed
25
+ // coordinator left open, because the ledger — not a local variable — is authoritative.
26
+ import { activateAuthorityAttempt, closeAuthorityAttempt, createAuthorityAttempt, listAllOpenAuthorityAttempts, listOpenAcquiringAuthorityAttempts, listStaleAcquiringAuthorityAttempts, markAuthorityAttemptFailed, markAuthorityAttemptRevoked, markAuthorityAttemptStale, revokeOpenActiveAuthorityAttempts, } from "../repository/authority-attempts.js";
27
+ import { listWorkItemsForIssue } from "../repository/work-items.js";
28
+ import { mintAuthority as defaultMintAuthority, revokeAuthority as defaultRevokeAuthority, } from "./execution-authority.js";
29
+ /** One bounded auto-retry (AUTHORITY_EXPIRED or a secret-less idempotent remint) beyond the
30
+ * genuine first attempt — enough to recover from a benign, single stale-key collision
31
+ * without looping forever against a persistently misbehaving Deck. */
32
+ const MAX_MINT_ATTEMPTS = 2;
33
+ export async function acquireAuthorityForAttempt(input) {
34
+ const mint = input.mint ?? defaultMintAuthority;
35
+ const revoke = input.revoke ?? defaultRevokeAuthority;
36
+ // Revoke-before-new-attempt: this idempotencyKey is a fresh attempt for this owner, so any
37
+ // authority still open under a *different* key belonged to a superseded one.
38
+ const closed = await closeOpenAuthorityAttemptsForOwner(input.ownerKind, input.ownerId, { mint, revoke });
39
+ if (closed.unresolved.length > 0) {
40
+ // A predecessor's live/dead state is genuinely unknown (Deck unreachable, an
41
+ // enrollment/policy error, ...) — minting a new authority beside it would risk two live
42
+ // authorities for the same owner if the predecessor turns out to have been live all
43
+ // along. Fail closed: the caller's own infra-retry policy tries again once the
44
+ // predecessor is reconciled (by this same check on the next attempt, the startup sweep,
45
+ // or the periodic retry), instead of proceeding beside an authority of unknown state
46
+ // (NOT-91 review, round 4).
47
+ return {
48
+ ok: false,
49
+ kind: "infra_failure",
50
+ reason: "a predecessor execution-authority attempt for this owner could not be resolved (still acquiring, Deck unreachable or an enrollment/policy error) — refusing to mint a new one until it is reconciled",
51
+ };
52
+ }
53
+ let idempotencyKey = input.idempotencyKey;
54
+ for (let attempt = 1; attempt <= MAX_MINT_ATTEMPTS; attempt++) {
55
+ const row = createAuthorityAttempt({
56
+ ownerKind: input.ownerKind,
57
+ ownerId: input.ownerId,
58
+ idempotencyKey,
59
+ deckId: input.deckId,
60
+ runId: input.runId,
61
+ attemptId: input.attemptId,
62
+ ttlMs: input.ttlMs,
63
+ toolScopeHint: input.toolScopeHint,
64
+ });
65
+ const minted = await mint({
66
+ runId: input.runId,
67
+ attemptId: input.attemptId,
68
+ deckId: input.deckId,
69
+ ttlMs: input.ttlMs,
70
+ idempotencyKey,
71
+ ...(input.toolScopeHint ? { toolScopeHint: input.toolScopeHint } : {}),
72
+ });
73
+ if (!minted.ok) {
74
+ markAuthorityAttemptFailed(row.id);
75
+ if (minted.code === "AUTHORITY_EXPIRED" && attempt < MAX_MINT_ATTEMPTS) {
76
+ // Benign — the authority this key used to reference simply aged out. A fresh key
77
+ // mints a genuinely new one; never surfaced to the caller as a failure.
78
+ idempotencyKey = `${input.idempotencyKey}::retry${attempt}`;
79
+ continue;
80
+ }
81
+ if (minted.code === "INTERACTION_REQUIRED" || minted.code === "AUTHORITY_REVOKED") {
82
+ return {
83
+ ok: false,
84
+ kind: "interaction_required",
85
+ reason: minted.message || "Agent Deck requires a control-plane decision before this attempt can continue.",
86
+ requestId: minted.requestId,
87
+ };
88
+ }
89
+ return { ok: false, kind: "infra_failure", reason: `${minted.code}: ${minted.message}` };
90
+ }
91
+ const { authority } = minted;
92
+ if (!authority.authoritySecret) {
93
+ // Idempotent remint of a still-live authority under a reused key (NOT-85 §7) — this
94
+ // caller has no secret to use it with. Revoke the orphaned authority and mint again
95
+ // under a derived key rather than fail the whole attempt.
96
+ await revoke(authority.authorityId);
97
+ markAuthorityAttemptFailed(row.id);
98
+ if (attempt < MAX_MINT_ATTEMPTS) {
99
+ idempotencyKey = `${input.idempotencyKey}::retry${attempt}`;
100
+ continue;
101
+ }
102
+ return {
103
+ ok: false,
104
+ kind: "infra_failure",
105
+ reason: `authority ${authority.authorityId} minted without a secret (idempotent remint) twice in a row`,
106
+ };
107
+ }
108
+ const activated = activateAuthorityAttempt(row.id, {
109
+ authorityId: authority.authorityId,
110
+ expiresAt: authority.expiresAt,
111
+ });
112
+ if (!activated) {
113
+ // Revoked out from under us between mint and activate (e.g. a concurrent abort) —
114
+ // never hand the caller an authority the ledger no longer considers open.
115
+ await revoke(authority.authorityId);
116
+ return { ok: false, kind: "infra_failure", reason: "attempt was cancelled while its authority was being minted" };
117
+ }
118
+ return { ok: true, attemptRowId: row.id, authority };
119
+ }
120
+ /* istanbul ignore next -- the loop always returns from within its body */
121
+ return { ok: false, kind: "infra_failure", reason: "authority acquisition exhausted its bounded retry" };
122
+ }
123
+ /** Normal end-of-use — one mint, one use, one revoke. */
124
+ export async function releaseAuthority(attemptRowId, authorityId, revoke = defaultRevokeAuthority) {
125
+ await revoke(authorityId);
126
+ closeAuthorityAttempt(attemptRowId);
127
+ }
128
+ /** Verify/materialize/delivery failed after a successful mint — the authority has no
129
+ * further legitimate use. */
130
+ export async function failAuthority(attemptRowId, authorityId, revoke = defaultRevokeAuthority) {
131
+ if (authorityId)
132
+ await revoke(authorityId);
133
+ markAuthorityAttemptFailed(attemptRowId);
134
+ }
135
+ /** Mint failure codes that *positively prove* no live authority remains for the replayed key
136
+ * — safe to terminalize with nothing left to revoke. Every other code (DECK_UNAVAILABLE, an
137
+ * enrollment/policy/scope error, ...) proves nothing about whether Deck actually committed the
138
+ * original mint, so it must never be treated as "safe to drop" (NOT-91 review, round 3). */
139
+ const RESOLVED_NO_LIVE_AUTHORITY_CODES = new Set(["AUTHORITY_EXPIRED", "AUTHORITY_REVOKED"]);
140
+ /**
141
+ * Closes one `acquiring` row that has no ledger `authorityId` — the one ambiguous case in this
142
+ * whole lifecycle: a coordinator can crash after Deck committed a mint but before that row was
143
+ * activated with the resulting id, so the ledger alone can't tell "nothing was ever minted"
144
+ * from "a live authority exists that only this row's idempotencyKey can still find." Resolves
145
+ * it the same way a live caller recovers from a secret-less idempotent remint (NOT-85 §7):
146
+ * replay the original mint request under its stored idempotencyKey/runId/attemptId/ttlMs/
147
+ * toolScopeHint. A mint success means a live authority exists (the original, or one this
148
+ * replay just freshly minted) — revoke it. Only `AUTHORITY_EXPIRED`/`AUTHORITY_REVOKED`
149
+ * positively prove nothing is left to revoke; every other failure leaves the row open.
150
+ */
151
+ async function resolveAcquiringAttempt(row, mint, revoke) {
152
+ // Durably flags the row as needing resolution — see schema.sql's `stale_at` comment. If
153
+ // this replay doesn't manage to resolve it (returns "unresolved" below), the stamp is what
154
+ // makes it visible to `retryStaleAuthorityAttempts`'s next periodic tick, regardless of
155
+ // which of the four call sites (startup sweep, cancellation, worker-death reclaim, revoke-
156
+ // before-new-attempt) got here first (NOT-91 review, round 5).
157
+ markAuthorityAttemptStale(row.id);
158
+ const resolved = await mint({
159
+ runId: row.runId,
160
+ attemptId: row.attemptId,
161
+ deckId: row.deckId,
162
+ ttlMs: row.ttlMs,
163
+ idempotencyKey: row.idempotencyKey,
164
+ ...(row.toolScopeHint ? { toolScopeHint: row.toolScopeHint } : {}),
165
+ });
166
+ if (resolved.ok) {
167
+ await revoke(resolved.authority.authorityId);
168
+ markAuthorityAttemptRevoked(row.id);
169
+ return "revoked";
170
+ }
171
+ if (RESOLVED_NO_LIVE_AUTHORITY_CODES.has(resolved.code)) {
172
+ markAuthorityAttemptRevoked(row.id);
173
+ return "revoked";
174
+ }
175
+ return "unresolved";
176
+ }
177
+ /** Resolves a batch of `acquiring`/no-`authorityId` rows (e.g. gathered inside a cancellation
178
+ * or worker-death-reclaim transaction, before that transaction's synchronous DB work commits)
179
+ * — the async counterpart callers run afterward, outside any surrounding transaction. */
180
+ export async function resolveAcquiringAttempts(rows, deps) {
181
+ const mint = deps?.mint ?? defaultMintAuthority;
182
+ const revoke = deps?.revoke ?? defaultRevokeAuthority;
183
+ const revoked = [];
184
+ const unresolved = [];
185
+ for (const row of rows) {
186
+ const outcome = await resolveAcquiringAttempt(row, mint, revoke);
187
+ (outcome === "revoked" ? revoked : unresolved).push(row.id);
188
+ }
189
+ return { revoked, unresolved };
190
+ }
191
+ /**
192
+ * Closes every open `authority_attempts` row for one owner: an `active` row (known
193
+ * `authorityId`) is revoked immediately; an `acquiring` row with none yet is resolved via
194
+ * `resolveAcquiringAttempts` instead of being terminalized on a guess. Used by
195
+ * `acquireAuthorityForAttempt`'s revoke-before-new-attempt step, which isn't itself inside a
196
+ * surrounding DB transaction, so it can freely await both steps in one call.
197
+ */
198
+ export async function closeOpenAuthorityAttemptsForOwner(ownerKind, ownerId, deps) {
199
+ const revoke = deps?.revoke ?? defaultRevokeAuthority;
200
+ const revoked = [];
201
+ for (const row of revokeOpenActiveAuthorityAttempts(ownerKind, ownerId)) {
202
+ if (row.authorityId)
203
+ await revoke(row.authorityId);
204
+ revoked.push(row.id);
205
+ }
206
+ const acquiring = listOpenAcquiringAuthorityAttempts(ownerKind, ownerId);
207
+ const resolvedAcquiring = await resolveAcquiringAttempts(acquiring, deps);
208
+ return { revoked: [...revoked, ...resolvedAcquiring.revoked], unresolved: resolvedAcquiring.unresolved };
209
+ }
210
+ /** A developer/reviewer owner_id is `${issueId}:${ownerKind}` — stable across that role's own
211
+ * work-item retry rollover (NOT-91 review, round 5), never a work-item row's own UUID. Parses
212
+ * it back out to look up whether *any* work item currently filling that role for that issue is
213
+ * `leased` — a live lease means recovery.ts's own reclaim/dead-letter path (which already
214
+ * revokes on its own trigger) is the one to eventually close this row, not a sweep here. */
215
+ function defaultIsOwnerLeased(ownerKind, ownerId) {
216
+ if (ownerKind !== "developer" && ownerKind !== "reviewer")
217
+ return false;
218
+ const suffix = `:${ownerKind}`;
219
+ if (!ownerId.endsWith(suffix))
220
+ return false;
221
+ const issueId = ownerId.slice(0, -suffix.length);
222
+ return listWorkItemsForIssue(issueId).some((item) => item.kind === ownerKind && item.status === "leased");
223
+ }
224
+ /**
225
+ * Startup-only (index.ts calls this exactly once, alongside recoverCoordinator(), before
226
+ * anything else can create a new `authority_attempts` row): revokes every row a crashed
227
+ * coordinator left open.
228
+ *
229
+ * This whole sweep's premise — "every `acquiring` row and every `reflect`/`outbound_delivery`
230
+ * row is stale" — is true only at a genuine process boundary. Calling this again while the
231
+ * *same* process keeps running would revoke a legitimately in-flight mint or a live
232
+ * reflect/outbound-delivery call out from under it (NOT-91 review, round 4) — that is exactly
233
+ * why the periodic path is a separate function, `retryStaleAuthorityAttempts`, that never
234
+ * rescans every open row and instead only ever retries rows durably flagged `stale_at`.
235
+ *
236
+ * - `acquiring` never legitimately survives a process boundary (it is a sub-second
237
+ * pre-mint state) — always stale here.
238
+ * - `developer`/`reviewer` rows are stale unless their owning role is still `leased` for that
239
+ * issue (`defaultIsOwnerLeased`) — closing one here too would race a still-legitimately-
240
+ * running worker.
241
+ * - `reflect`/`outbound_delivery` rows are always one-shot, coordinator-process-local calls
242
+ * with no lease of their own — at a genuine process boundary, if one is still open, the
243
+ * process that opened it is definitionally gone.
244
+ *
245
+ * A stale `acquiring` row with no `authorityId` goes through `resolveAcquiringAttempts`
246
+ * rather than being terminalized directly — see its doc comment for why.
247
+ */
248
+ export async function reconcileAuthoritiesAtStartup(deps) {
249
+ const revoke = deps?.revoke ?? defaultRevokeAuthority;
250
+ const mint = deps?.mint ?? defaultMintAuthority;
251
+ const isOwnerLeased = deps?.isOwnerLeased ?? defaultIsOwnerLeased;
252
+ const revoked = [];
253
+ const acquiringToResolve = [];
254
+ for (const row of listAllOpenAuthorityAttempts()) {
255
+ const stale = row.status === "acquiring" ||
256
+ (row.ownerKind === "developer" || row.ownerKind === "reviewer" ? !isOwnerLeased(row.ownerKind, row.ownerId) : true);
257
+ if (!stale)
258
+ continue;
259
+ if (row.status === "acquiring" && !row.authorityId) {
260
+ acquiringToResolve.push(row);
261
+ continue;
262
+ }
263
+ if (row.authorityId)
264
+ await revoke(row.authorityId);
265
+ markAuthorityAttemptRevoked(row.id);
266
+ revoked.push(row.id);
267
+ }
268
+ const resolvedAcquiring = await resolveAcquiringAttempts(acquiringToResolve, { mint, revoke });
269
+ return { revoked: [...revoked, ...resolvedAcquiring.revoked], unresolved: resolvedAcquiring.unresolved };
270
+ }
271
+ /**
272
+ * The periodic counterpart to `reconcileAuthoritiesAtStartup` (index.ts calls this on a
273
+ * bounded interval — no state threaded between ticks). Never rescans
274
+ * `listAllOpenAuthorityAttempts()`: unlike the one-time startup sweep, this runs while the
275
+ * process is still live, so re-deriving "every acquiring/process-local row is stale" would
276
+ * revoke a legitimately in-flight mint or a live reflect/outbound-delivery call (NOT-91
277
+ * review, round 4). Instead queries `listStaleAcquiringAuthorityAttempts()` — rows durably
278
+ * flagged `stale_at` by `resolveAcquiringAttempt`, from *any* of the four call sites (startup
279
+ * sweep, cancellation, worker-death reclaim, revoke-before-new-attempt), not just what the
280
+ * startup sweep itself found (NOT-91 review, round 5: a row that only became stale after
281
+ * startup, e.g. via a later cancellation, used to have no path back into the periodic retry
282
+ * at all). A defensive lease check still applies before resolving any of them, in case a row
283
+ * were ever (incorrectly) flagged stale while still genuinely live.
284
+ */
285
+ export async function retryStaleAuthorityAttempts(deps) {
286
+ const isOwnerLeased = deps?.isOwnerLeased ?? defaultIsOwnerLeased;
287
+ const rows = listStaleAcquiringAuthorityAttempts().filter((row) => !((row.ownerKind === "developer" || row.ownerKind === "reviewer") && isOwnerLeased(row.ownerKind, row.ownerId)));
288
+ return resolveAcquiringAttempts(rows, deps);
289
+ }
@@ -0,0 +1,103 @@
1
+ // packages/server/src/adapters/codex-scoped-config.ts
2
+ //
3
+ // The per-attempt codex `config.toml`: the one spelling of the send-gate tool name, the
4
+ // builder that writes the scoped `mcp_servers` table, and the reader the spawn preflight
5
+ // uses to check it.
6
+ //
7
+ // These live together, and apart from `agent-deck-bind.ts`, for two reasons. Together,
8
+ // because the writer and the reader must agree on one tool name — codex's `disabled_tools`
9
+ // is a per-server list, so the entry is the server-side name, NOT claude's namespaced
10
+ // `mcp__agent-deck__call_service_tool` (args.ts). Two spellings of one fact is how this
11
+ // stack's original bug happened. Apart, because `coordinator/permissions.ts` needs the
12
+ // reader to enforce a read-only invariant, and importing the adapter for it would drag the
13
+ // deck client, the repository layer, the DB module and the MCP SDK into the invariant
14
+ // module and all of its unit tests — for ~15 lines of fs + TOML (NOT-134 review).
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import { parse as parseToml } from "smol-toml";
18
+ /**
19
+ * The send gate (docs/PRD_SEND_GATE.md) as *codex* names it — server-side, unnamespaced.
20
+ */
21
+ export const CODEX_SEND_GATE_TOOL = "call_service_tool";
22
+ /**
23
+ * The deck reads a worker legitimately needs, in codex's unnamespaced spelling. Mirrors
24
+ * claude's `DECK_READ_TOOLS` (args.ts) one-for-one; the send gate is deliberately absent.
25
+ *
26
+ * These need an explicit `approval_mode` because a non-interactive `codex exec` runs with
27
+ * an approval policy of `never` — there is no human to answer a prompt, so any MCP call
28
+ * that requires approval is refused outright ("MCP tool call requires approval, but
29
+ * approval policy is never"). Without this the deck is configured, reachable, and unusable.
30
+ */
31
+ export const CODEX_DECK_READ_TOOLS = [
32
+ "bind_workspace",
33
+ "get_bound_deck",
34
+ "get_playbook",
35
+ "list_service_tools",
36
+ ];
37
+ /**
38
+ * The scoped `mcp_servers` table for one attempt.
39
+ *
40
+ * `disabled_tools` is the only place a codex session's tool surface can be narrowed —
41
+ * codex has no `--disallowedTools` equivalent — so a policy-blind config silently grants
42
+ * whatever the deck exposes. Verified against codex 0.154.0: with the key present the
43
+ * tool is not exposed to the session at all; without it the tool is exposed and reachable.
44
+ * It denies one tool, not the server, so deck reads are unaffected by it.
45
+ *
46
+ * `allowOutboundMutation` is currently unreachable in the `true` direction, and that is
47
+ * deliberate rather than an oversight. Both role ceilings set `outboundMutation: false`
48
+ * (shared/profile-snapshot.ts:50,57) and `resolvePermissionPolicy` can only turn a
49
+ * capability off, never raise the ceiling — so today NO worker session, either role, any
50
+ * runtime, can reach the send gate, and no profile setting re-enables it. The gated send
51
+ * is the only route out (docs/PRD_SEND_GATE.md). claude carries the identical dead branch
52
+ * (`claudeDisallowedTools`, args.ts), so this is parity, not a special case.
53
+ *
54
+ * The parameter exists so the ceiling stays the one place that decides — flip
55
+ * `outboundMutation` there and this follows, rather than needing a second edit here. If
56
+ * you came looking for the profile toggle that turns outbound writes on: there isn't one,
57
+ * by design.
58
+ */
59
+ export function codexMcpServersTable(opts) {
60
+ return {
61
+ "agent-deck": {
62
+ url: opts.mcpUrl,
63
+ http_headers: opts.headers,
64
+ ...(opts.allowOutboundMutation ? {} : { disabled_tools: [CODEX_SEND_GATE_TOOL] }),
65
+ // Pre-approve exactly the reads, and nothing else. Verified against codex 0.154.0:
66
+ // with these the deck read returns data; without them it is refused by the
67
+ // approval policy even though the server is configured and connected. The send gate
68
+ // is never listed here — it is removed by `disabled_tools` above, and listing it
69
+ // would be the one entry that could undo that.
70
+ tools: Object.fromEntries(CODEX_DECK_READ_TOOLS.map((t) => [t, { approval_mode: "approve" }])),
71
+ },
72
+ };
73
+ }
74
+ /**
75
+ * Does this scoped CODEX_HOME's config.toml deny the send gate on every configured server?
76
+ *
77
+ * Reads the file codex itself will read, rather than trusting whoever wrote it — the spawn
78
+ * preflight needs the fact, not a flag passed alongside it. A missing or unparseable
79
+ * config reads as "not denied": the strict answer, since an assertion must only ever
80
+ * over-reject. No servers at all is denial by absence — there is nothing to call.
81
+ */
82
+ export function codexScopedConfigDeniesSendGate(codexHome) {
83
+ try {
84
+ const parsed = parseToml(fs.readFileSync(path.join(codexHome, "config.toml"), "utf8"));
85
+ const servers = parsed.mcp_servers ?? {};
86
+ const names = Object.keys(servers);
87
+ if (names.length === 0)
88
+ return true;
89
+ return names.every((n) => {
90
+ const server = servers[n];
91
+ const denied = server?.disabled_tools;
92
+ if (!Array.isArray(denied) || !denied.includes(CODEX_SEND_GATE_TOOL))
93
+ return false;
94
+ // A per-tool approval entry for the send gate would be the one line that could
95
+ // undo the removal above, so it is rejected rather than merely not written —
96
+ // the denial must not depend on nobody having added it.
97
+ return server?.tools?.[CODEX_SEND_GATE_TOOL] === undefined;
98
+ });
99
+ }
100
+ catch {
101
+ return false;
102
+ }
103
+ }
@@ -0,0 +1,103 @@
1
+ // packages/server/src/adapters/execution-authority.ts
2
+ //
3
+ // Mint/revoke short-lived Agent Deck execution authority for one run attempt (NOT-85 §5.2,
4
+ // §11; NOT-87). Replaces the legacy, spoofable `x-agent-deck-client` header paths and the
5
+ // unauthenticated MCP connections in agent-deck-bind.ts / agent-deck.ts's
6
+ // deliverOutboundDraft: every unattended Deck call must now carry a freshly minted
7
+ // authority, never a persistent grant copied into a worktree or prompt.
8
+ //
9
+ // A minted authority is used for exactly one attempt (one mint, one worker spawn or one
10
+ // coordinator-side call, then revoke) — callers never reuse an authority across attempts;
11
+ // a new attempt always mints again with a new idempotency key (NOT-85 §6.2, §8).
12
+ import { getAgentDeckApiUrl } from "./agent-deck.js";
13
+ /** Enrollment bearer for legacy mint/revoke (kept for PR2 / authority-lifecycle). */
14
+ function getAgentDeckEnrollmentBearer() {
15
+ const enrollmentId = process.env.AGENT_DECK_ENROLLMENT_ID;
16
+ const secret = process.env.AGENT_DECK_ENROLLMENT_SECRET;
17
+ if (!enrollmentId || !secret)
18
+ return null;
19
+ return `${enrollmentId}:${secret}`;
20
+ }
21
+ const KNOWN_CODES = new Set([
22
+ "COORDINATOR_NOT_ENROLLED",
23
+ "ENROLLMENT_REVOKED",
24
+ "RESOURCE_OUT_OF_SCOPE",
25
+ "INTERACTION_REQUIRED",
26
+ "AUTHORITY_EXPIRED",
27
+ "AUTHORITY_REVOKED",
28
+ "INVALID_MINT_REQUEST",
29
+ ]);
30
+ function mapErrorCode(code) {
31
+ if (code && KNOWN_CODES.has(code))
32
+ return code;
33
+ return "DECK_UNAVAILABLE";
34
+ }
35
+ /** Mint execution authority for one run attempt. Never throws — every failure is a typed result. */
36
+ export async function mintAuthority(input) {
37
+ const bearer = getAgentDeckEnrollmentBearer();
38
+ if (!bearer) {
39
+ return {
40
+ ok: false,
41
+ code: "COORDINATOR_NOT_ENROLLED",
42
+ message: "Agent Deck coordinator is not enrolled — run `agent-deck coordinator enroll` and set " +
43
+ "AGENT_DECK_COORDINATOR_ID / AGENT_DECK_ENROLLMENT_ID / AGENT_DECK_ENROLLMENT_SECRET.",
44
+ };
45
+ }
46
+ try {
47
+ const res = await fetch(`${getAgentDeckApiUrl()}/api/execution-authority/authorities`, {
48
+ method: "POST",
49
+ headers: { Authorization: `Bearer ${bearer}`, "Content-Type": "application/json" },
50
+ body: JSON.stringify({
51
+ runId: input.runId,
52
+ attemptId: input.attemptId,
53
+ deckId: input.deckId,
54
+ audience: "dealer-worker",
55
+ idempotencyKey: input.idempotencyKey,
56
+ ttlMs: input.ttlMs,
57
+ ...(input.toolScopeHint ? { toolScopeHint: input.toolScopeHint } : {}),
58
+ }),
59
+ signal: AbortSignal.timeout(8000),
60
+ });
61
+ const json = (await res.json().catch(() => null));
62
+ if (!res.ok || !json || json.ok === false || !json.data) {
63
+ return {
64
+ ok: false,
65
+ code: mapErrorCode(json?.error_code),
66
+ message: json?.message ?? `mint failed: HTTP ${res.status}`,
67
+ requestId: json?.correlation?.requestId,
68
+ };
69
+ }
70
+ const { authority, authoritySecret } = json.data;
71
+ return {
72
+ ok: true,
73
+ authority: {
74
+ authorityId: authority.authorityId,
75
+ authoritySecret,
76
+ deckId: authority.deckId,
77
+ audience: authority.audience,
78
+ allowedServices: authority.allowedServices,
79
+ allowedTools: authority.allowedTools,
80
+ expiresAt: authority.expiresAt,
81
+ },
82
+ };
83
+ }
84
+ catch (e) {
85
+ return { ok: false, code: "DECK_UNAVAILABLE", message: String(e) };
86
+ }
87
+ }
88
+ /** Best-effort — an authority that fails to revoke still expires by TTL (NOT-85 §9). */
89
+ export async function revokeAuthority(authorityId) {
90
+ const bearer = getAgentDeckEnrollmentBearer();
91
+ if (!bearer)
92
+ return;
93
+ try {
94
+ await fetch(`${getAgentDeckApiUrl()}/api/execution-authority/authorities/${encodeURIComponent(authorityId)}/revoke`, {
95
+ method: "POST",
96
+ headers: { Authorization: `Bearer ${bearer}` },
97
+ signal: AbortSignal.timeout(5000),
98
+ });
99
+ }
100
+ catch {
101
+ // best-effort — TTL still bounds exposure
102
+ }
103
+ }
@@ -0,0 +1,166 @@
1
+ // packages/server/src/adapters/execution-authority.test.ts
2
+ //
3
+ // mintAuthority/revokeAuthority never throw — every Deck failure (missing enrollment,
4
+ // a typed contract error, or the backend being unreachable) comes back as a typed result
5
+ // the caller branches on (NOT-87).
6
+ import { test, mock } from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-execauth-"));
12
+ const { migrate } = await import("../db/index.js");
13
+ const { mintAuthority, revokeAuthority } = await import("./execution-authority.js");
14
+ migrate();
15
+ const ENV_KEYS = ["AGENT_DECK_COORDINATOR_ID", "AGENT_DECK_ENROLLMENT_ID", "AGENT_DECK_ENROLLMENT_SECRET"];
16
+ function withEnv(vars, fn) {
17
+ const saved = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
18
+ for (const k of ENV_KEYS) {
19
+ if (vars[k] === undefined)
20
+ delete process.env[k];
21
+ else
22
+ process.env[k] = vars[k];
23
+ }
24
+ return fn().finally(() => {
25
+ for (const k of ENV_KEYS) {
26
+ if (saved[k] === undefined)
27
+ delete process.env[k];
28
+ else
29
+ process.env[k] = saved[k];
30
+ }
31
+ });
32
+ }
33
+ const ENROLLED = {
34
+ AGENT_DECK_COORDINATOR_ID: "coord-1",
35
+ AGENT_DECK_ENROLLMENT_ID: "enr_abc",
36
+ AGENT_DECK_ENROLLMENT_SECRET: "enrs_secret",
37
+ };
38
+ const MINT_INPUT = {
39
+ runId: "run-1",
40
+ attemptId: "attempt-1",
41
+ deckId: "deck-1",
42
+ ttlMs: 60_000,
43
+ idempotencyKey: "attempt-1",
44
+ };
45
+ test("mintAuthority returns COORDINATOR_NOT_ENROLLED without calling fetch when no enrollment env is set", async () => {
46
+ await withEnv({}, async () => {
47
+ const fetchMock = mock.method(globalThis, "fetch", async () => {
48
+ throw new Error("fetch should not be called");
49
+ });
50
+ try {
51
+ const result = await mintAuthority(MINT_INPUT);
52
+ assert.equal(result.ok, false);
53
+ if (!result.ok)
54
+ assert.equal(result.code, "COORDINATOR_NOT_ENROLLED");
55
+ assert.equal(fetchMock.mock.callCount(), 0);
56
+ }
57
+ finally {
58
+ fetchMock.mock.restore();
59
+ }
60
+ });
61
+ });
62
+ test("mintAuthority sends the enrollment bearer and returns the minted authority on success", async () => {
63
+ await withEnv(ENROLLED, async () => {
64
+ let capturedUrl = "";
65
+ let capturedAuth = "";
66
+ let capturedBody = {};
67
+ const fetchMock = mock.method(globalThis, "fetch", async (url, init) => {
68
+ capturedUrl = String(url);
69
+ capturedAuth = init?.headers?.Authorization ?? "";
70
+ capturedBody = JSON.parse(String(init?.body ?? "{}"));
71
+ return new Response(JSON.stringify({
72
+ ok: true,
73
+ data: {
74
+ authority: {
75
+ authorityId: "authz_1",
76
+ deckId: "deck-1",
77
+ audience: "dealer-worker",
78
+ allowedServices: ["slack"],
79
+ allowedTools: [{ serviceId: "slack", toolName: "send_message" }],
80
+ expiresAt: "2026-01-01T00:01:00Z",
81
+ },
82
+ authoritySecret: "authzs_secret",
83
+ secretIssued: true,
84
+ },
85
+ }), { status: 200 });
86
+ });
87
+ try {
88
+ const result = await mintAuthority(MINT_INPUT);
89
+ assert.equal(result.ok, true);
90
+ if (result.ok) {
91
+ assert.equal(result.authority.authorityId, "authz_1");
92
+ assert.equal(result.authority.authoritySecret, "authzs_secret");
93
+ assert.deepEqual(result.authority.allowedTools, [{ serviceId: "slack", toolName: "send_message" }]);
94
+ }
95
+ assert.match(capturedUrl, /\/api\/execution-authority\/authorities$/);
96
+ assert.equal(capturedAuth, "Bearer enr_abc:enrs_secret");
97
+ assert.equal(capturedBody.runId, "run-1");
98
+ assert.equal(capturedBody.idempotencyKey, "attempt-1");
99
+ assert.equal(capturedBody.audience, "dealer-worker");
100
+ }
101
+ finally {
102
+ fetchMock.mock.restore();
103
+ }
104
+ });
105
+ });
106
+ test("mintAuthority maps a typed contract error (INTERACTION_REQUIRED) instead of throwing", async () => {
107
+ await withEnv(ENROLLED, async () => {
108
+ const fetchMock = mock.method(globalThis, "fetch", async () => {
109
+ return new Response(JSON.stringify({ ok: false, error_code: "INTERACTION_REQUIRED", message: "Control-plane decision required" }), { status: 409 });
110
+ });
111
+ try {
112
+ const result = await mintAuthority(MINT_INPUT);
113
+ assert.equal(result.ok, false);
114
+ if (!result.ok) {
115
+ assert.equal(result.code, "INTERACTION_REQUIRED");
116
+ assert.equal(result.message, "Control-plane decision required");
117
+ }
118
+ }
119
+ finally {
120
+ fetchMock.mock.restore();
121
+ }
122
+ });
123
+ });
124
+ test("mintAuthority maps a network failure to DECK_UNAVAILABLE", async () => {
125
+ await withEnv(ENROLLED, async () => {
126
+ const fetchMock = mock.method(globalThis, "fetch", async () => {
127
+ throw new Error("ECONNREFUSED");
128
+ });
129
+ try {
130
+ const result = await mintAuthority(MINT_INPUT);
131
+ assert.equal(result.ok, false);
132
+ if (!result.ok)
133
+ assert.equal(result.code, "DECK_UNAVAILABLE");
134
+ }
135
+ finally {
136
+ fetchMock.mock.restore();
137
+ }
138
+ });
139
+ });
140
+ test("revokeAuthority is a best-effort no-op when unreachable (never throws)", async () => {
141
+ await withEnv(ENROLLED, async () => {
142
+ const fetchMock = mock.method(globalThis, "fetch", async () => {
143
+ throw new Error("ECONNREFUSED");
144
+ });
145
+ try {
146
+ await assert.doesNotReject(revokeAuthority("authz_1"));
147
+ }
148
+ finally {
149
+ fetchMock.mock.restore();
150
+ }
151
+ });
152
+ });
153
+ test("revokeAuthority skips the call entirely when not enrolled", async () => {
154
+ await withEnv({}, async () => {
155
+ const fetchMock = mock.method(globalThis, "fetch", async () => {
156
+ throw new Error("fetch should not be called");
157
+ });
158
+ try {
159
+ await revokeAuthority("authz_1");
160
+ assert.equal(fetchMock.mock.callCount(), 0);
161
+ }
162
+ finally {
163
+ fetchMock.mock.restore();
164
+ }
165
+ });
166
+ });
@@ -1,5 +1,4 @@
1
1
  import { listLinearCandidates } from "./linear-inbox.js";
2
- export { checkAgentDeckHealth, fetchAgentDeckDecks } from "./agent-deck.js";
3
2
  export async function pollLinearIssues() {
4
3
  if (process.env.LINEAR_AUTO_ENQUEUE === "1") {
5
4
  console.warn("[linear] LINEAR_AUTO_ENQUEUE=1 is deprecated — use Intake promote");