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.
- package/bundle/server/dist/adapters/agent-deck-bind.js +443 -0
- package/bundle/server/dist/adapters/agent-deck-bind.test.js +454 -0
- package/bundle/server/dist/adapters/agent-deck.js +38 -50
- package/bundle/server/dist/adapters/agent-deck.test.js +63 -0
- package/bundle/server/dist/adapters/agent-health.js +230 -33
- package/bundle/server/dist/adapters/agent-health.test.js +237 -0
- package/bundle/server/dist/adapters/authority-lifecycle.js +289 -0
- package/bundle/server/dist/adapters/codex-scoped-config.js +103 -0
- package/bundle/server/dist/adapters/execution-authority.js +103 -0
- package/bundle/server/dist/adapters/execution-authority.test.js +166 -0
- package/bundle/server/dist/adapters/external.js +0 -1
- package/bundle/server/dist/adapters/git-worktree.js +514 -0
- package/bundle/server/dist/adapters/git-worktree.test.js +502 -0
- package/bundle/server/dist/adapters/github.js +170 -0
- package/bundle/server/dist/adapters/github.test.js +205 -0
- package/bundle/server/dist/adapters/linear-inbox.js +199 -30
- package/bundle/server/dist/adapters/linear-inbox.test.js +35 -0
- package/bundle/server/dist/adapters/linear-sync.js +3 -101
- package/bundle/server/dist/adapters/managed-repo.js +168 -0
- package/bundle/server/dist/adapters/managed-repo.test.js +63 -0
- package/bundle/server/dist/adapters/outbound-delivery.js +83 -0
- package/bundle/server/dist/adapters/outbound-delivery.test.js +53 -0
- package/bundle/server/dist/adapters/reflect-authority.js +43 -0
- package/bundle/server/dist/cli-env.js +26 -0
- package/bundle/server/dist/cli-env.test.js +14 -1
- package/bundle/server/dist/coordinator/abort.integration.test.js +74 -0
- package/bundle/server/dist/coordinator/admission.js +330 -0
- package/bundle/server/dist/coordinator/admission.test.js +338 -0
- package/bundle/server/dist/coordinator/args.js +128 -0
- package/bundle/server/dist/coordinator/args.test.js +132 -0
- package/bundle/server/dist/coordinator/authority-recovery.integration.test.js +661 -0
- package/bundle/server/dist/coordinator/auto-merge.integration.test.js +363 -0
- package/bundle/server/dist/coordinator/auto-merge.js +286 -0
- package/bundle/server/dist/coordinator/auto-merge.timeout.test.js +14 -0
- package/bundle/server/dist/coordinator/branch-progress.js +81 -0
- package/bundle/server/dist/coordinator/clock-jump.js +98 -0
- package/bundle/server/dist/coordinator/commands.js +1088 -0
- package/bundle/server/dist/coordinator/commands.test.js +554 -0
- package/bundle/server/dist/coordinator/deck-outage-config.js +32 -0
- package/bundle/server/dist/coordinator/deck-outage.integration.test.js +200 -0
- package/bundle/server/dist/coordinator/dependencies.js +242 -0
- package/bundle/server/dist/coordinator/dependency-readiness.integration.test.js +535 -0
- package/bundle/server/dist/coordinator/developer-effect.js +959 -0
- package/bundle/server/dist/coordinator/developer-effect.test.js +1143 -0
- package/bundle/server/dist/coordinator/dispatcher.js +58 -0
- package/bundle/server/dist/coordinator/dispatcher.test.js +81 -0
- package/bundle/server/dist/coordinator/effect-registry.js +17 -0
- package/bundle/server/dist/coordinator/execution-env.integration.test.js +122 -0
- package/bundle/server/dist/coordinator/failure-reason.js +273 -0
- package/bundle/server/dist/coordinator/failure-reason.test.js +234 -0
- package/bundle/server/dist/coordinator/guidance.js +33 -0
- package/bundle/server/dist/coordinator/guidance.test.js +110 -0
- package/bundle/server/dist/coordinator/human-resolution.js +88 -0
- package/bundle/server/dist/coordinator/human-resolution.test.js +72 -0
- package/bundle/server/dist/coordinator/intent-forecast.js +13 -0
- package/bundle/server/dist/coordinator/intent-forecast.test.js +34 -0
- package/bundle/server/dist/coordinator/latest-failure.js +100 -0
- package/bundle/server/dist/coordinator/latest-failure.test.js +125 -0
- package/bundle/server/dist/coordinator/metrics.js +35 -0
- package/bundle/server/dist/coordinator/metrics.test.js +52 -0
- package/bundle/server/dist/coordinator/permissions.js +127 -0
- package/bundle/server/dist/coordinator/permissions.test.js +184 -0
- package/bundle/server/dist/coordinator/presumed-dead-budget.integration.test.js +146 -0
- package/bundle/server/dist/coordinator/process-liveness.js +191 -0
- package/bundle/server/dist/coordinator/profile-snapshot.js +20 -0
- package/bundle/server/dist/coordinator/profile-snapshot.test.js +118 -0
- package/bundle/server/dist/coordinator/projection.js +147 -0
- package/bundle/server/dist/coordinator/projection.test.js +121 -0
- package/bundle/server/dist/coordinator/prompts.js +164 -0
- package/bundle/server/dist/coordinator/prompts.test.js +177 -0
- package/bundle/server/dist/coordinator/queue-by-default.integration.test.js +271 -0
- package/bundle/server/dist/coordinator/reclaim-republish.integration.test.js +372 -0
- package/bundle/server/dist/coordinator/recovery.js +466 -0
- package/bundle/server/dist/coordinator/recovery.test.js +164 -0
- package/bundle/server/dist/coordinator/reflect-trigger.js +218 -0
- package/bundle/server/dist/coordinator/reflect-trigger.test.js +324 -0
- package/bundle/server/dist/coordinator/repair-cycle.integration.test.js +234 -0
- package/bundle/server/dist/coordinator/restart-recovery.integration.test.js +363 -0
- package/bundle/server/dist/coordinator/reviewer-effect.js +507 -0
- package/bundle/server/dist/coordinator/reviewer-effect.test.js +612 -0
- package/bundle/server/dist/coordinator/reviewer-result.js +38 -0
- package/bundle/server/dist/coordinator/reviewer-result.test.js +45 -0
- package/bundle/server/dist/coordinator/routing.js +160 -0
- package/bundle/server/dist/coordinator/routing.test.js +336 -0
- package/bundle/server/dist/coordinator/session-lifecycle.integration.test.js +127 -0
- package/bundle/server/dist/coordinator/session-lifecycle.js +280 -0
- package/bundle/server/dist/coordinator/session-lifecycle.test.js +121 -0
- package/bundle/server/dist/coordinator/session-progress.js +351 -0
- package/bundle/server/dist/coordinator/session-progress.test.js +190 -0
- package/bundle/server/dist/coordinator/session-timeouts.js +33 -0
- package/bundle/server/dist/coordinator/sleep-recovery.integration.test.js +233 -0
- package/bundle/server/dist/coordinator/spawn.js +49 -0
- package/bundle/server/dist/coordinator/sync-issue-base-branch.js +18 -0
- package/bundle/server/dist/coordinator/usage-cap-config.js +10 -0
- package/bundle/server/dist/coordinator/usage-cap-defer.js +189 -0
- package/bundle/server/dist/coordinator/usage-cap.integration.test.js +193 -0
- package/bundle/server/dist/coordinator/usage.js +40 -0
- package/bundle/server/dist/coordinator/usage.test.js +69 -0
- package/bundle/server/dist/coordinator/verification-receipt.js +333 -0
- package/bundle/server/dist/coordinator/verification-receipt.test.js +382 -0
- package/bundle/server/dist/coordinator/worker-loop.js +363 -0
- package/bundle/server/dist/coordinator/worker-loop.test.js +327 -0
- package/bundle/server/dist/coordinator/workflow-instances.test.js +45 -0
- package/bundle/server/dist/coordinator/worktree-owner-liveness.js +82 -0
- package/bundle/server/dist/coordinator/worktree-owner-liveness.test.js +199 -0
- package/bundle/server/dist/db/artifacts-issue-columns.test.js +18 -0
- package/bundle/server/dist/db/authority-attempts-stale-at-migration.test.js +65 -0
- package/bundle/server/dist/db/idempotency-index-migration.test.js +57 -0
- package/bundle/server/dist/db/index.js +359 -7
- package/bundle/server/dist/db/linear-inbox-unlock.test.js +67 -0
- package/bundle/server/dist/db/migrate-to-issues.js +824 -0
- package/bundle/server/dist/db/migrate-to-issues.test.js +842 -0
- package/bundle/server/dist/db/schema.sql +311 -1
- package/bundle/server/dist/db/schema.test.js +66 -0
- package/bundle/server/dist/db/worker-session-pid-migration.test.js +112 -0
- package/bundle/server/dist/dev-review-cli-happy-path.integration.test.js +338 -0
- package/bundle/server/dist/direct-start-liveness.integration.test.js +350 -0
- package/bundle/server/dist/index.js +58 -5
- package/bundle/server/dist/paths.js +40 -4
- package/bundle/server/dist/paths.test.js +42 -0
- package/bundle/server/dist/pid-marker-claim-child.js +18 -0
- package/bundle/server/dist/pid-marker-hold-arbiter-child.js +24 -0
- package/bundle/server/dist/pid-marker.js +141 -0
- package/bundle/server/dist/pid-marker.test.js +208 -0
- package/bundle/server/dist/queue/approve-deliver.js +128 -28
- package/bundle/server/dist/queue/reflect-schedule.js +25 -0
- package/bundle/server/dist/queue/result-qa.js +1 -1
- package/bundle/server/dist/queue/send-gate.test.js +172 -36
- package/bundle/server/dist/repository/agents.js +55 -18
- package/bundle/server/dist/repository/artifacts-for-issue.js +42 -0
- package/bundle/server/dist/repository/artifacts-for-issue.test.js +48 -0
- package/bundle/server/dist/repository/artifacts.js +47 -0
- package/bundle/server/dist/repository/artifacts.test.js +48 -0
- package/bundle/server/dist/repository/authority-attempts.js +189 -0
- package/bundle/server/dist/repository/findings.js +70 -0
- package/bundle/server/dist/repository/findings.test.js +68 -0
- package/bundle/server/dist/repository/human-actions.js +142 -0
- package/bundle/server/dist/repository/human-actions.test.js +123 -0
- package/bundle/server/dist/repository/intake-settings.js +5 -59
- package/bundle/server/dist/repository/issues.js +305 -0
- package/bundle/server/dist/repository/issues.test.js +69 -0
- package/bundle/server/dist/repository/outbound-drafts.js +28 -0
- package/bundle/server/dist/repository/queue-entries.js +162 -0
- package/bundle/server/dist/repository/review-publications.js +153 -0
- package/bundle/server/dist/repository/review-publications.test.js +78 -0
- package/bundle/server/dist/repository/runs-external-id.test.js +5 -3
- package/bundle/server/dist/repository/runs.js +4 -211
- package/bundle/server/dist/repository/runtime-availability.js +54 -0
- package/bundle/server/dist/repository/usage-events.js +60 -0
- package/bundle/server/dist/repository/usage-events.test.js +56 -0
- package/bundle/server/dist/repository/work-items.js +305 -0
- package/bundle/server/dist/repository/work-items.test.js +142 -0
- package/bundle/server/dist/repository/worker-sessions.js +219 -0
- package/bundle/server/dist/repository/worker-sessions.test.js +60 -0
- package/bundle/server/dist/repository/workflow-events.js +193 -0
- package/bundle/server/dist/repository/workflow-events.test.js +89 -0
- package/bundle/server/dist/resolve-tsx-bin.js +19 -0
- package/bundle/server/dist/routes/human-actions.js +68 -0
- package/bundle/server/dist/routes/human-actions.test.js +284 -0
- package/bundle/server/dist/routes/index.js +29 -496
- package/bundle/server/dist/routes/issues.js +240 -0
- package/bundle/server/dist/routes/issues.test.js +451 -0
- package/bundle/server/dist/routes/queue.js +41 -0
- package/bundle/server/dist/routes/queue.test.js +78 -0
- package/bundle/server/dist/runners/claude-args.js +18 -30
- package/bundle/server/dist/runners/claude.js +19 -163
- package/bundle/server/dist/runners/claude.test.js +15 -70
- package/bundle/server/dist/runners/codex-args.js +59 -0
- package/bundle/server/dist/runners/codex-args.test.js +72 -0
- package/bundle/server/dist/runners/codex-jsonl.js +150 -0
- package/bundle/server/dist/runners/codex-jsonl.test.js +36 -0
- package/bundle/server/dist/runners/codex-resume.test.js +19 -0
- package/bundle/server/dist/runners/codex.js +46 -0
- package/bundle/server/dist/runners/models-codex.test.js +14 -0
- package/bundle/server/dist/runners/models.js +53 -1
- package/bundle/server/dist/runners/persist.js +4 -1
- package/bundle/server/dist/runners/process-registry.js +11 -0
- package/bundle/server/dist/runners/prompts.js +13 -280
- package/bundle/server/dist/runners/prompts.test.js +22 -153
- package/bundle/server/dist/runners/qa.js +17 -1
- package/bundle/server/dist/runners/reflect.js +4 -16
- package/bundle/server/dist/runners/spawn-cli.js +197 -0
- package/bundle/server/dist/runners/spawn-cli.test.js +130 -0
- package/bundle/server/dist/runners/stream-json.js +0 -141
- package/bundle/server/dist/runners/usage-cap.js +242 -0
- package/bundle/server/dist/runners/usage-cap.test.js +97 -0
- package/bundle/server/dist/server-liveness.js +37 -0
- package/bundle/server/dist/server-liveness.test.js +82 -0
- package/bundle/server/dist/static-ui.test.js +44 -0
- package/bundle/server/package.json +4 -2
- package/bundle/server/static-ui/assets/index-BTtPGYpY.js +60 -0
- package/bundle/server/static-ui/assets/index-BxTx4b1d.css +1 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/agents.d.ts +262 -133
- package/bundle/shared/dist/agents.js +85 -19
- package/bundle/shared/dist/cursor-auth-health.d.ts +12 -0
- package/bundle/shared/dist/cursor-auth-health.js +36 -0
- package/bundle/shared/dist/cursor-auth-health.test.d.ts +1 -0
- package/bundle/shared/dist/cursor-auth-health.test.js +40 -0
- package/bundle/shared/dist/findings.d.ts +46 -0
- package/bundle/shared/dist/findings.js +17 -0
- package/bundle/shared/dist/findings.test.d.ts +1 -0
- package/bundle/shared/dist/findings.test.js +20 -0
- package/bundle/shared/dist/github-repo.d.ts +28 -0
- package/bundle/shared/dist/github-repo.js +98 -0
- package/bundle/shared/dist/github-repo.test.d.ts +1 -0
- package/bundle/shared/dist/github-repo.test.js +32 -0
- package/bundle/shared/dist/human-actions.d.ts +66 -0
- package/bundle/shared/dist/human-actions.js +51 -0
- package/bundle/shared/dist/human-actions.test.d.ts +1 -0
- package/bundle/shared/dist/human-actions.test.js +57 -0
- package/bundle/shared/dist/index.d.ts +567 -757
- package/bundle/shared/dist/index.js +22 -36
- package/bundle/shared/dist/issues.d.ts +385 -0
- package/bundle/shared/dist/issues.js +169 -0
- package/bundle/shared/dist/issues.test.d.ts +1 -0
- package/bundle/shared/dist/issues.test.js +72 -0
- package/bundle/shared/dist/outbound-draft.d.ts +6 -0
- package/bundle/shared/dist/outbound-draft.js +4 -0
- package/bundle/shared/dist/plan-triage.d.ts +6 -6
- package/bundle/shared/dist/profile-snapshot.d.ts +154 -0
- package/bundle/shared/dist/profile-snapshot.js +145 -0
- package/bundle/shared/dist/profile-snapshot.test.d.ts +1 -0
- package/bundle/shared/dist/profile-snapshot.test.js +67 -0
- package/bundle/shared/dist/queue-entries.d.ts +89 -0
- package/bundle/shared/dist/queue-entries.js +19 -0
- package/bundle/shared/dist/result-qa.d.ts +2 -2
- package/bundle/shared/dist/runtime-auth-health.d.ts +66 -0
- package/bundle/shared/dist/runtime-auth-health.js +186 -0
- package/bundle/shared/dist/runtime-auth-health.test.d.ts +1 -0
- package/bundle/shared/dist/runtime-auth-health.test.js +200 -0
- package/bundle/shared/dist/runtime-availability.d.ts +51 -0
- package/bundle/shared/dist/runtime-availability.js +13 -0
- package/bundle/shared/dist/runtime.d.ts +1 -1
- package/bundle/shared/dist/runtime.js +1 -1
- package/bundle/shared/dist/usage-events.d.ts +36 -0
- package/bundle/shared/dist/usage-events.js +13 -0
- package/bundle/shared/dist/worker-sessions.d.ts +140 -0
- package/bundle/shared/dist/worker-sessions.js +70 -0
- package/bundle/shared/dist/worker-sessions.test.d.ts +1 -0
- package/bundle/shared/dist/worker-sessions.test.js +39 -0
- package/bundle/shared/dist/workflow.d.ts +78 -0
- package/bundle/shared/dist/workflow.js +59 -0
- package/bundle/shared/dist/workflow.test.d.ts +1 -0
- package/bundle/shared/dist/workflow.test.js +61 -0
- package/bundle/shared/package.json +1 -1
- package/dist/action.d.ts +1 -0
- package/dist/action.js +50 -0
- package/dist/action.test.d.ts +1 -0
- package/dist/action.test.js +70 -0
- package/dist/agent.d.ts +1 -0
- package/dist/agent.js +17 -0
- package/dist/agent.test.d.ts +1 -0
- package/dist/agent.test.js +19 -0
- package/dist/cli-check.d.ts +7 -0
- package/dist/cli-check.js +40 -0
- package/dist/doctor.js +59 -1
- package/dist/http.d.ts +5 -0
- package/dist/http.js +19 -0
- package/dist/index.js +28 -0
- package/dist/issue.d.ts +35 -0
- package/dist/issue.js +143 -0
- package/dist/issue.test.d.ts +1 -0
- package/dist/issue.test.js +240 -0
- package/dist/lifecycle.contract.test.d.ts +1 -0
- package/dist/lifecycle.contract.test.js +135 -0
- package/dist/queue.d.ts +11 -0
- package/dist/queue.js +60 -0
- package/dist/queue.test.d.ts +1 -0
- package/dist/queue.test.js +46 -0
- package/dist/test-fetch-stub.d.ts +6 -0
- package/dist/test-fetch-stub.js +31 -0
- package/package.json +2 -1
- package/templates/prod.env.example +1 -1
- package/bundle/server/static-ui/assets/index-B_7S7xJb.js +0 -79
- 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");
|