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,100 @@
|
|
|
1
|
+
import { getLatestFailedWorkerSessionForIssue, getWorkerSession, } from "../repository/worker-sessions.js";
|
|
2
|
+
import { eventCursor, listWorkflowEventsForIssue, workerStartedEventCursor, } from "../repository/workflow-events.js";
|
|
3
|
+
import { parseErrorJsonReason } from "./failure-reason.js";
|
|
4
|
+
function parsePayload(json) {
|
|
5
|
+
if (!json)
|
|
6
|
+
return null;
|
|
7
|
+
try {
|
|
8
|
+
return JSON.parse(json);
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function fromFailedEvent(event, issue, session) {
|
|
15
|
+
const payload = parsePayload(event.payloadJson);
|
|
16
|
+
const reason = (payload?.reason && payload.reason.trim()) ||
|
|
17
|
+
parseErrorJsonReason(session?.errorJson) ||
|
|
18
|
+
null;
|
|
19
|
+
if (!reason)
|
|
20
|
+
return null;
|
|
21
|
+
return {
|
|
22
|
+
reason,
|
|
23
|
+
when: event.ts,
|
|
24
|
+
role: event.actorType === "developer" || event.actorType === "reviewer" ? event.actorType : null,
|
|
25
|
+
outcome: payload?.outcome ?? null,
|
|
26
|
+
sessionId: payload?.sessionId ?? session?.id ?? event.workerSessionId,
|
|
27
|
+
logPath: session?.logPath ?? null,
|
|
28
|
+
infraAttempts: issue.infraAttempts,
|
|
29
|
+
maxInfraAttempts: issue.maxInfraAttempts,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function fromSession(session, issue) {
|
|
33
|
+
const reason = parseErrorJsonReason(session.errorJson);
|
|
34
|
+
if (!reason)
|
|
35
|
+
return null;
|
|
36
|
+
return {
|
|
37
|
+
reason,
|
|
38
|
+
when: session.completedAt ?? session.updatedAt,
|
|
39
|
+
role: session.role === "developer" || session.role === "reviewer" ? session.role : null,
|
|
40
|
+
outcome: null,
|
|
41
|
+
sessionId: session.id,
|
|
42
|
+
logPath: session.logPath,
|
|
43
|
+
infraAttempts: issue.infraAttempts,
|
|
44
|
+
maxInfraAttempts: issue.maxInfraAttempts,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** True when a worker.completed was inserted after `afterCursor` (exclusive). Uses rowid, not ts. */
|
|
48
|
+
function hasCompletedAfterCursor(events, afterCursor) {
|
|
49
|
+
for (const e of events) {
|
|
50
|
+
if (e.type !== "worker.completed")
|
|
51
|
+
continue;
|
|
52
|
+
const c = eventCursor(e.id);
|
|
53
|
+
if (c != null && c > afterCursor)
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Prefer the latest worker.failed timeline reason; fall back to the latest session
|
|
60
|
+
* errorJson so soft-recovery / incomplete emits still surface something.
|
|
61
|
+
* Suppressed when a later worker.completed exists after that failure (stale strip).
|
|
62
|
+
*
|
|
63
|
+
* Ordering uses workflow_events.rowid via `eventCursor` / `workerStartedEventCursor` —
|
|
64
|
+
* never raw `ts` comparisons (ms collisions; see workflow-events.ts).
|
|
65
|
+
*/
|
|
66
|
+
export function latestSessionFailureForIssue(issue) {
|
|
67
|
+
const events = listWorkflowEventsForIssue(issue.id);
|
|
68
|
+
let lastFailed = null;
|
|
69
|
+
let lastFailedCursor = -1;
|
|
70
|
+
for (const e of events) {
|
|
71
|
+
if (e.type !== "worker.failed")
|
|
72
|
+
continue;
|
|
73
|
+
const c = eventCursor(e.id);
|
|
74
|
+
if (c == null)
|
|
75
|
+
continue;
|
|
76
|
+
if (c >= lastFailedCursor) {
|
|
77
|
+
lastFailed = e;
|
|
78
|
+
lastFailedCursor = c;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (lastFailed && lastFailedCursor >= 0) {
|
|
82
|
+
if (hasCompletedAfterCursor(events, lastFailedCursor))
|
|
83
|
+
return null;
|
|
84
|
+
const payload = parsePayload(lastFailed.payloadJson);
|
|
85
|
+
const sessionId = payload?.sessionId ?? lastFailed.workerSessionId ?? null;
|
|
86
|
+
const session = sessionId ? getWorkerSession(sessionId) : null;
|
|
87
|
+
const built = fromFailedEvent(lastFailed, issue, session);
|
|
88
|
+
if (built)
|
|
89
|
+
return built;
|
|
90
|
+
}
|
|
91
|
+
const failedSession = getLatestFailedWorkerSessionForIssue(issue.id);
|
|
92
|
+
if (!failedSession)
|
|
93
|
+
return null;
|
|
94
|
+
// Anchor at this session's worker.started rowid (same convention as guidance windows).
|
|
95
|
+
// A later worker.completed (any session) clears the strip — including same-ms inserts.
|
|
96
|
+
const startedCursor = workerStartedEventCursor(failedSession.id);
|
|
97
|
+
if (startedCursor != null && hasCompletedAfterCursor(events, startedCursor))
|
|
98
|
+
return null;
|
|
99
|
+
return fromSession(failedSession, issue);
|
|
100
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// packages/server/src/coordinator/latest-failure.test.ts
|
|
2
|
+
import { test, before, beforeEach } from "node:test";
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CURSOR_ID } from "@agent-dealer/shared";
|
|
8
|
+
process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-latest-failure-"));
|
|
9
|
+
const { migrate, getDb } = await import("../db/index.js");
|
|
10
|
+
const { createIssue, getIssue } = await import("../repository/issues.js");
|
|
11
|
+
const { startWorkflowInstance, appendWorkflowEvent } = await import("../repository/workflow-events.js");
|
|
12
|
+
const { createWorkerSession, startSession, completeSession } = await import("../repository/worker-sessions.js");
|
|
13
|
+
const { latestSessionFailureForIssue } = await import("./latest-failure.js");
|
|
14
|
+
const { PRESUMED_DEAD_REASON } = await import("./failure-reason.js");
|
|
15
|
+
before(() => migrate());
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
getDb().exec(`
|
|
18
|
+
DELETE FROM workflow_events;
|
|
19
|
+
DELETE FROM worker_sessions;
|
|
20
|
+
DELETE FROM workflow_instances;
|
|
21
|
+
DELETE FROM issues;
|
|
22
|
+
`);
|
|
23
|
+
});
|
|
24
|
+
function makeIssue() {
|
|
25
|
+
return createIssue({
|
|
26
|
+
title: "Latest failure strip",
|
|
27
|
+
acceptanceCriteria: "Strip works",
|
|
28
|
+
repo: "acme/app",
|
|
29
|
+
baseBranch: "main",
|
|
30
|
+
developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
31
|
+
reviewerAgentId: BUILTIN_AGENT_CURSOR_ID
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
test("session-fallback strip clears when worker.completed shares the same ms timestamp (rowid > ts)", () => {
|
|
35
|
+
const issue = makeIssue();
|
|
36
|
+
const instance = startWorkflowInstance(issue.id, "dev_reviewer_v1");
|
|
37
|
+
const session = createWorkerSession({
|
|
38
|
+
issueId: issue.id,
|
|
39
|
+
role: "developer",
|
|
40
|
+
round: 1,
|
|
41
|
+
agentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
42
|
+
runtime: "cursor_local"
|
|
43
|
+
});
|
|
44
|
+
startSession(session.id);
|
|
45
|
+
appendWorkflowEvent({
|
|
46
|
+
issueId: issue.id,
|
|
47
|
+
workflowInstanceId: instance.id,
|
|
48
|
+
workerSessionId: session.id,
|
|
49
|
+
type: "worker.started",
|
|
50
|
+
actorType: "developer",
|
|
51
|
+
stage: "developing",
|
|
52
|
+
round: 1,
|
|
53
|
+
payload: {}
|
|
54
|
+
});
|
|
55
|
+
// Force identical wall-clock stamps across session completion and a later handoff event —
|
|
56
|
+
// the exact collision that made `e.ts > when` leave a stale strip.
|
|
57
|
+
const stamped = "2026-09-15T12:00:00.000Z";
|
|
58
|
+
completeSession(session.id, {
|
|
59
|
+
status: "failed",
|
|
60
|
+
errorJson: JSON.stringify({ reason: PRESUMED_DEAD_REASON }),
|
|
61
|
+
logPath: "/tmp/stale-strip.log"
|
|
62
|
+
});
|
|
63
|
+
getDb()
|
|
64
|
+
.prepare("UPDATE worker_sessions SET completed_at = ?, updated_at = ? WHERE id = ?")
|
|
65
|
+
.run(stamped, stamped, session.id);
|
|
66
|
+
// No worker.failed event → session-fallback path.
|
|
67
|
+
const completed = appendWorkflowEvent({
|
|
68
|
+
issueId: issue.id,
|
|
69
|
+
workflowInstanceId: instance.id,
|
|
70
|
+
workerSessionId: session.id,
|
|
71
|
+
type: "worker.completed",
|
|
72
|
+
actorType: "developer",
|
|
73
|
+
stage: "reviewing",
|
|
74
|
+
round: 1,
|
|
75
|
+
payload: { outcome: "clean_handoff" }
|
|
76
|
+
});
|
|
77
|
+
getDb().prepare("UPDATE workflow_events SET ts = ? WHERE id = ?").run(stamped, completed.id);
|
|
78
|
+
const fresh = getIssue(issue.id);
|
|
79
|
+
// With ts-only comparison this would still show the failure (completed.ts === when).
|
|
80
|
+
assert.equal(latestSessionFailureForIssue(fresh), null);
|
|
81
|
+
});
|
|
82
|
+
test("worker.failed strip clears via rowid when a later completed shares the same ts", () => {
|
|
83
|
+
const issue = makeIssue();
|
|
84
|
+
const instance = startWorkflowInstance(issue.id, "dev_reviewer_v1");
|
|
85
|
+
const session = createWorkerSession({
|
|
86
|
+
issueId: issue.id,
|
|
87
|
+
role: "developer",
|
|
88
|
+
round: 1,
|
|
89
|
+
agentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
90
|
+
runtime: "cursor_local"
|
|
91
|
+
});
|
|
92
|
+
startSession(session.id);
|
|
93
|
+
const stamped = "2026-09-15T12:00:00.000Z";
|
|
94
|
+
const failed = appendWorkflowEvent({
|
|
95
|
+
issueId: issue.id,
|
|
96
|
+
workflowInstanceId: instance.id,
|
|
97
|
+
workerSessionId: session.id,
|
|
98
|
+
type: "worker.failed",
|
|
99
|
+
actorType: "developer",
|
|
100
|
+
stage: "developing",
|
|
101
|
+
round: 1,
|
|
102
|
+
payload: {
|
|
103
|
+
sessionId: session.id,
|
|
104
|
+
outcome: "session_failed",
|
|
105
|
+
reason: PRESUMED_DEAD_REASON
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
const completed = appendWorkflowEvent({
|
|
109
|
+
issueId: issue.id,
|
|
110
|
+
workflowInstanceId: instance.id,
|
|
111
|
+
workerSessionId: session.id,
|
|
112
|
+
type: "worker.completed",
|
|
113
|
+
actorType: "developer",
|
|
114
|
+
stage: "reviewing",
|
|
115
|
+
round: 1,
|
|
116
|
+
payload: { outcome: "clean_handoff" }
|
|
117
|
+
});
|
|
118
|
+
getDb().prepare("UPDATE workflow_events SET ts = ? WHERE id IN (?, ?)").run(stamped, failed.id, completed.id);
|
|
119
|
+
completeSession(session.id, {
|
|
120
|
+
status: "failed",
|
|
121
|
+
errorJson: JSON.stringify({ reason: PRESUMED_DEAD_REASON }),
|
|
122
|
+
logPath: "/tmp/stale-strip.log"
|
|
123
|
+
});
|
|
124
|
+
assert.equal(latestSessionFailureForIssue(getIssue(issue.id)), null);
|
|
125
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human wait is the union of `[requestedAt, resolvedAt ?? now]` intervals — not a sum —
|
|
3
|
+
* so two overlapping open actions on the same issue don't double-count the overlap
|
|
4
|
+
* (design doc §"usage_events": "avoiding double-counting overlapping actions").
|
|
5
|
+
*/
|
|
6
|
+
export function computeHumanWaitMs(actions, now = Date.now()) {
|
|
7
|
+
const intervals = actions
|
|
8
|
+
.map((a) => ({
|
|
9
|
+
start: new Date(a.requestedAt).getTime(),
|
|
10
|
+
end: a.resolvedAt ? new Date(a.resolvedAt).getTime() : now,
|
|
11
|
+
}))
|
|
12
|
+
.filter((i) => Number.isFinite(i.start) && Number.isFinite(i.end) && i.end > i.start)
|
|
13
|
+
.sort((a, b) => a.start - b.start);
|
|
14
|
+
let totalMs = 0;
|
|
15
|
+
let curStart = -Infinity;
|
|
16
|
+
let curEnd = -Infinity;
|
|
17
|
+
for (const { start, end } of intervals) {
|
|
18
|
+
if (start > curEnd) {
|
|
19
|
+
if (curEnd > curStart)
|
|
20
|
+
totalMs += curEnd - curStart;
|
|
21
|
+
curStart = start;
|
|
22
|
+
curEnd = end;
|
|
23
|
+
}
|
|
24
|
+
else if (end > curEnd) {
|
|
25
|
+
curEnd = end;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (curEnd > curStart)
|
|
29
|
+
totalMs += curEnd - curStart;
|
|
30
|
+
return totalMs;
|
|
31
|
+
}
|
|
32
|
+
/** Every human action ever raised for the issue — resolved or still open. */
|
|
33
|
+
export function computeInterventionCount(actions) {
|
|
34
|
+
return actions.length;
|
|
35
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// packages/server/src/coordinator/metrics.test.ts
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { computeHumanWaitMs, computeInterventionCount } from "./metrics.js";
|
|
5
|
+
function action(requestedAt, resolvedAt) {
|
|
6
|
+
return {
|
|
7
|
+
id: "a",
|
|
8
|
+
issueId: "i",
|
|
9
|
+
runId: null,
|
|
10
|
+
workflowInstanceId: null,
|
|
11
|
+
actionType: "policy_escalation",
|
|
12
|
+
reason: "r",
|
|
13
|
+
question: "q",
|
|
14
|
+
evidenceJson: null,
|
|
15
|
+
responseOptionsJson: null,
|
|
16
|
+
continuationPreviewJson: null,
|
|
17
|
+
requestId: null,
|
|
18
|
+
status: resolvedAt ? "resolved" : "open",
|
|
19
|
+
resolutionJson: null,
|
|
20
|
+
resolvedBy: null,
|
|
21
|
+
requestedAt,
|
|
22
|
+
resolvedAt,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
test("computeHumanWaitMs sums a single resolved interval", () => {
|
|
26
|
+
const ms = computeHumanWaitMs([action("2026-01-01T00:00:00.000Z", "2026-01-01T00:10:00.000Z")]);
|
|
27
|
+
assert.equal(ms, 10 * 60_000);
|
|
28
|
+
});
|
|
29
|
+
test("computeHumanWaitMs does not double-count overlapping intervals", () => {
|
|
30
|
+
const actions = [
|
|
31
|
+
action("2026-01-01T00:00:00.000Z", "2026-01-01T00:10:00.000Z"),
|
|
32
|
+
action("2026-01-01T00:05:00.000Z", "2026-01-01T00:15:00.000Z"),
|
|
33
|
+
];
|
|
34
|
+
// Union is [00:00, 00:15] = 15 minutes, not 10+10=20.
|
|
35
|
+
assert.equal(computeHumanWaitMs(actions), 15 * 60_000);
|
|
36
|
+
});
|
|
37
|
+
test("computeHumanWaitMs treats a still-open action as waiting until `now`", () => {
|
|
38
|
+
const now = new Date("2026-01-01T00:20:00.000Z").getTime();
|
|
39
|
+
const ms = computeHumanWaitMs([action("2026-01-01T00:00:00.000Z", null)], now);
|
|
40
|
+
assert.equal(ms, 20 * 60_000);
|
|
41
|
+
});
|
|
42
|
+
test("computeHumanWaitMs sums disjoint intervals", () => {
|
|
43
|
+
const actions = [
|
|
44
|
+
action("2026-01-01T00:00:00.000Z", "2026-01-01T00:05:00.000Z"),
|
|
45
|
+
action("2026-01-01T01:00:00.000Z", "2026-01-01T01:05:00.000Z"),
|
|
46
|
+
];
|
|
47
|
+
assert.equal(computeHumanWaitMs(actions), 10 * 60_000);
|
|
48
|
+
});
|
|
49
|
+
test("computeInterventionCount counts every action raised, resolved or not", () => {
|
|
50
|
+
const actions = [action("2026-01-01T00:00:00.000Z", "2026-01-01T00:05:00.000Z"), action("2026-01-01T01:00:00.000Z", null)];
|
|
51
|
+
assert.equal(computeInterventionCount(actions), 2);
|
|
52
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { parsePermissionPolicyOverride, resolvePermissionPolicy } from "@agent-dealer/shared";
|
|
2
|
+
import { codexScopedConfigDeniesSendGate } from "../adapters/codex-scoped-config.js";
|
|
3
|
+
export { resolvePermissionPolicy };
|
|
4
|
+
/** Resolve from the serialized profile override stored on the agent / snapshot. */
|
|
5
|
+
export function resolveSessionPermissionPolicy(role, permissionPolicyJson) {
|
|
6
|
+
return resolvePermissionPolicy(role, parsePermissionPolicyOverride(permissionPolicyJson));
|
|
7
|
+
}
|
|
8
|
+
const WRITE_TOOL_NAMES = ["Write", "Edit", "Bash", "MultiEdit", "NotebookEdit"];
|
|
9
|
+
const OUTBOUND_MUTATION_TOOL = "mcp__agent-deck__call_service_tool";
|
|
10
|
+
function flagValue(args, flag) {
|
|
11
|
+
const i = args.indexOf(flag);
|
|
12
|
+
return i >= 0 ? (args[i + 1] ?? null) : null;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Invariant check on generated CLI args: throws if a reviewer invocation could write,
|
|
16
|
+
* run shell, or reach an outbound-mutation tool. Used as a spawn preflight and asserted
|
|
17
|
+
* directly in tests so the read-only guarantee cannot silently regress.
|
|
18
|
+
*
|
|
19
|
+
* `ctx` carries the spawn's env-level isolation. Omitting it is the strict reading (no
|
|
20
|
+
* scoped CODEX_HOME), so a caller that forgets it can only ever over-reject, never
|
|
21
|
+
* under-reject.
|
|
22
|
+
*/
|
|
23
|
+
export function assertReviewerReadOnly(args, ctx = {}) {
|
|
24
|
+
// Dangerous permission bypasses are never allowed for a reviewer.
|
|
25
|
+
for (const arg of args) {
|
|
26
|
+
if (arg.startsWith("--dangerously")) {
|
|
27
|
+
throw new Error(`reviewer args contain a permission bypass: ${arg}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// Claude: --allowedTools alone is NOT a restriction — it only auto-approves matching
|
|
31
|
+
// calls; a tool it omits is still reachable if the active --permission-mode or any
|
|
32
|
+
// ambient .claude/settings.json would otherwise grant it (confirmed against
|
|
33
|
+
// https://code.claude.com/docs/en/agent-sdk/permissions after a PR review round found
|
|
34
|
+
// exactly this gap). So the load-bearing checks are: --tools (a hard availability
|
|
35
|
+
// list) excludes every write tool, --restricted is present (ignores ambient settings
|
|
36
|
+
// files, refuses bypassPermissions), and --permission-mode is dontAsk (auto-denies
|
|
37
|
+
// anything not pre-approved instead of falling through). --allowedTools/
|
|
38
|
+
// --disallowedTools are checked too, but only as defense-in-depth on top of those.
|
|
39
|
+
const allowedTools = (flagValue(args, "--allowedTools") ?? "").split(",").filter(Boolean);
|
|
40
|
+
const isClaude = allowedTools.length > 0;
|
|
41
|
+
if (isClaude) {
|
|
42
|
+
const hardTools = (flagValue(args, "--tools") ?? "").split(",").filter(Boolean);
|
|
43
|
+
for (const tool of WRITE_TOOL_NAMES) {
|
|
44
|
+
if (hardTools.includes(tool))
|
|
45
|
+
throw new Error(`reviewer args make a write tool available: ${tool}`);
|
|
46
|
+
if (allowedTools.includes(tool))
|
|
47
|
+
throw new Error(`reviewer args pre-approve a write tool: ${tool}`);
|
|
48
|
+
}
|
|
49
|
+
if (!args.includes("--restricted")) {
|
|
50
|
+
throw new Error("reviewer claude args do not isolate ambient settings (missing --restricted)");
|
|
51
|
+
}
|
|
52
|
+
if (flagValue(args, "--permission-mode") !== "dontAsk") {
|
|
53
|
+
throw new Error("reviewer claude args do not auto-deny un-pre-approved tools (missing --permission-mode dontAsk)");
|
|
54
|
+
}
|
|
55
|
+
const denied = (flagValue(args, "--disallowedTools") ?? "").split(",").filter(Boolean);
|
|
56
|
+
if (allowedTools.includes(OUTBOUND_MUTATION_TOOL) || !denied.includes(OUTBOUND_MUTATION_TOOL)) {
|
|
57
|
+
throw new Error("reviewer args do not deny the outbound-mutation tool");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// Codex: never a writable sandbox. Cursor: never without ask mode.
|
|
61
|
+
if (args.includes("workspace-write")) {
|
|
62
|
+
throw new Error("reviewer args request a writable sandbox");
|
|
63
|
+
}
|
|
64
|
+
const isCursor = args.includes("--stream-partial-output");
|
|
65
|
+
if (isCursor && !(flagValue(args, "--mode") === "ask")) {
|
|
66
|
+
throw new Error("reviewer cursor args are not constrained to ask mode");
|
|
67
|
+
}
|
|
68
|
+
// KNOWN GAP (NOT-134): cursor's outbound-mutation denial is NOT enforced here, and this
|
|
69
|
+
// comment exists so the header's promise is not silently false for this runtime.
|
|
70
|
+
// `--force` is load-bearing — headless cursor-agent rejects every MCP tool call without
|
|
71
|
+
// it, since there is no human to approve — and its own help says it force-allows
|
|
72
|
+
// "unless explicitly denied". The only deny list found is `permissions.deny` in the
|
|
73
|
+
// user-global `~/.cursor/cli-config.json` (entries shaped like `Shell(ls)`): global,
|
|
74
|
+
// shared by concurrent attempts, and the operator's own file, so a per-attempt worker
|
|
75
|
+
// must not write it. No per-invocation override flag exists (`cursor-agent --help`).
|
|
76
|
+
// Until a per-attempt mechanism is found, a cursor reviewer can reach the send gate.
|
|
77
|
+
const isCodex = args[0] === "exec";
|
|
78
|
+
if (isCodex) {
|
|
79
|
+
if (flagValue(args, "-s") !== "read-only") {
|
|
80
|
+
throw new Error("reviewer codex args are not constrained to a read-only sandbox");
|
|
81
|
+
}
|
|
82
|
+
// codex's read-only sandbox does not gate configured MCP/plugin calls, and merging a
|
|
83
|
+
// `-c mcp_servers={}` override does not clear them (verified against the installed
|
|
84
|
+
// CLI). There are exactly two ways to keep the ambient `~/.codex/config.toml`
|
|
85
|
+
// mcp_servers table out of the session, and this invariant must accept both or it
|
|
86
|
+
// rejects the one shape production actually builds (NOT-132):
|
|
87
|
+
//
|
|
88
|
+
// * `--ignore-user-config` — skips config.toml entirely. What a deckless reviewer
|
|
89
|
+
// gets, since it needs no MCP server at all.
|
|
90
|
+
// * a scoped `CODEX_HOME` — codex resolves config from `$CODEX_HOME`, else `~/.codex`
|
|
91
|
+
// (cli-env.ts), so pointing it at the per-attempt directory means the only
|
|
92
|
+
// config.toml codex can load is the one defining the single deck-header server.
|
|
93
|
+
// `--ignore-user-config` is deliberately NOT passed here (args.ts), because it
|
|
94
|
+
// would skip that scoped file too and leave the reviewer with no deck at all.
|
|
95
|
+
//
|
|
96
|
+
// The second arm checks the env, not `mcpConfigPath`: the env var is what codex reads,
|
|
97
|
+
// so a future config route returning a path without exporting CODEX_HOME would leave
|
|
98
|
+
// `~/.codex/config.toml` live while looking isolated here.
|
|
99
|
+
//
|
|
100
|
+
// Neither present means the ambient table is live: reject.
|
|
101
|
+
const codexHome = ctx.mcpEnv?.CODEX_HOME;
|
|
102
|
+
const scopedCodexHome = codexHome !== undefined && codexHome === ctx.mcpConfigPath;
|
|
103
|
+
if (!args.includes("--ignore-user-config") && !scopedCodexHome) {
|
|
104
|
+
throw new Error("reviewer codex args do not isolate configured MCP servers");
|
|
105
|
+
}
|
|
106
|
+
// NOT-134. codex has no `--disallowedTools`, so the outbound-mutation denial this
|
|
107
|
+
// function's header promises can only be expressed in the scoped config.toml. While
|
|
108
|
+
// `--ignore-user-config` was in play it was satisfied vacuously (no servers loaded at
|
|
109
|
+
// all); once a deck is attached, the server is loaded and the denial must be real.
|
|
110
|
+
// Read the file codex will read rather than trusting a flag passed alongside it.
|
|
111
|
+
if (scopedCodexHome && !codexScopedConfigDeniesSendGate(codexHome)) {
|
|
112
|
+
throw new Error("reviewer codex scoped config does not deny the outbound-mutation tool");
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (!isClaude && !isCursor && !isCodex) {
|
|
116
|
+
throw new Error("reviewer args do not constrain the runtime to a read-only mode");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
export function isReviewerReadOnly(args, ctx = {}) {
|
|
120
|
+
try {
|
|
121
|
+
assertReviewerReadOnly(args, ctx);
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// packages/server/src/coordinator/permissions.test.ts
|
|
2
|
+
import { after, test } from "node:test";
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { serializePermissionPolicyOverride } from "@agent-dealer/shared";
|
|
8
|
+
import { buildDeveloperArgs, buildReviewerArgs } from "./args.js";
|
|
9
|
+
import { assertReviewerReadOnly, isReviewerReadOnly, resolveSessionPermissionPolicy, } from "./permissions.js";
|
|
10
|
+
test("reviewer role resolves to an all-off policy", () => {
|
|
11
|
+
const p = resolveSessionPermissionPolicy("reviewer", null);
|
|
12
|
+
assert.deepEqual(p, {
|
|
13
|
+
worktreeWrite: false,
|
|
14
|
+
publishReview: false,
|
|
15
|
+
outboundMutation: false,
|
|
16
|
+
resolveHumanAction: false,
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
test("developer role grants worktree write by default", () => {
|
|
20
|
+
const p = resolveSessionPermissionPolicy("developer", null);
|
|
21
|
+
assert.equal(p.worktreeWrite, true);
|
|
22
|
+
assert.equal(p.publishReview, false);
|
|
23
|
+
});
|
|
24
|
+
test("a profile override can only tighten a developer policy, never loosen a reviewer one", () => {
|
|
25
|
+
const tightened = resolveSessionPermissionPolicy("developer", serializePermissionPolicyOverride({ worktreeWrite: false }));
|
|
26
|
+
assert.equal(tightened.worktreeWrite, false);
|
|
27
|
+
const loosened = resolveSessionPermissionPolicy("reviewer", serializePermissionPolicyOverride({ worktreeWrite: true }));
|
|
28
|
+
assert.equal(loosened.worktreeWrite, false, "override cannot raise the reviewer ceiling");
|
|
29
|
+
});
|
|
30
|
+
test("PermissionPolicy has no push/openPr field — see profile-snapshot.ts for why", () => {
|
|
31
|
+
// A PR review round proved every push/openPr control we tried (a Bash-argv denylist,
|
|
32
|
+
// then a git-config pushurl redirect) bypassable by the very worker it restricted, once
|
|
33
|
+
// that worker already holds an unrestricted write/Bash grant. Rather than ship a policy
|
|
34
|
+
// field that promises a restriction this layer cannot enforce, it isn't modeled here.
|
|
35
|
+
const p = resolveSessionPermissionPolicy("developer", null);
|
|
36
|
+
assert.ok(!("push" in p));
|
|
37
|
+
assert.ok(!("openPr" in p));
|
|
38
|
+
});
|
|
39
|
+
// NOT-132: the deck-attached column is the one production always takes, and it was the
|
|
40
|
+
// one never asserted. args.ts omits codex's --ignore-user-config once a scoped CODEX_HOME
|
|
41
|
+
// exists; permissions.ts demanded it unconditionally. Each module's own test passed while
|
|
42
|
+
// the composition threw on every real spawn, so this matrix — not a per-runtime one-off —
|
|
43
|
+
// is the shape that holds the contract.
|
|
44
|
+
const RUNTIMES = ["claude_code", "codex_local", "cursor_local"];
|
|
45
|
+
const deckConfigFor = (runtime) => runtime === "codex_local"
|
|
46
|
+
? "/tmp/authz/codex-home"
|
|
47
|
+
: runtime === "cursor_local"
|
|
48
|
+
? "/tmp/wt/.cursor/mcp.json"
|
|
49
|
+
: "/tmp/authz/claude-mcp.json";
|
|
50
|
+
/** Mirrors what materializeWorkerMcpConfig hands realReviewerSpawn (agent-deck-bind.ts). */
|
|
51
|
+
const deckContextFor = (runtime) => {
|
|
52
|
+
if (runtime === "codex_local") {
|
|
53
|
+
// A real directory: the codex branch reads this config to check the send-gate denial,
|
|
54
|
+
// the way codex itself will (NOT-134).
|
|
55
|
+
const home = scopedCodexHome(["call_service_tool"]);
|
|
56
|
+
return { mcpConfigPath: home, mcpEnv: { CODEX_HOME: home } };
|
|
57
|
+
}
|
|
58
|
+
return { mcpConfigPath: deckConfigFor(runtime) };
|
|
59
|
+
};
|
|
60
|
+
test("assertReviewerReadOnly passes for generated reviewer args across every runtime, deck-attached or not", () => {
|
|
61
|
+
for (const runtime of RUNTIMES) {
|
|
62
|
+
assert.doesNotThrow(() => assertReviewerReadOnly(buildReviewerArgs(runtime, "review")), `${runtime} without a deck`);
|
|
63
|
+
const ctx = deckContextFor(runtime);
|
|
64
|
+
const args = buildReviewerArgs(runtime, "review", undefined, undefined, ctx.mcpConfigPath);
|
|
65
|
+
assert.doesNotThrow(() => assertReviewerReadOnly(args, ctx), `${runtime} with a deck`);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
test("a codex reviewer with neither --ignore-user-config nor a scoped CODEX_HOME is rejected", () => {
|
|
69
|
+
// The ambient ~/.codex/config.toml mcp_servers table would be live. Passing the args
|
|
70
|
+
// without their spawn context is the strict reading, so it must still throw.
|
|
71
|
+
const codexHome = scopedCodexHome(["call_service_tool"]);
|
|
72
|
+
const args = buildReviewerArgs("codex_local", "review", undefined, undefined, codexHome);
|
|
73
|
+
assert.ok(!args.includes("--ignore-user-config"));
|
|
74
|
+
assert.throws(() => assertReviewerReadOnly(args), /isolate configured MCP servers/);
|
|
75
|
+
assert.equal(isReviewerReadOnly(args), false);
|
|
76
|
+
assert.equal(isReviewerReadOnly(args, { mcpConfigPath: codexHome, mcpEnv: { CODEX_HOME: codexHome } }), true);
|
|
77
|
+
});
|
|
78
|
+
test("a config path that never reached the spawn env does not count as codex isolation", () => {
|
|
79
|
+
// The whole point of taking a context: CODEX_HOME in the child env is what makes codex
|
|
80
|
+
// skip ~/.codex, so a path alone — or one that disagrees with the env — is not evidence.
|
|
81
|
+
const codexHome = "/tmp/authz/codex-home";
|
|
82
|
+
const args = buildReviewerArgs("codex_local", "review", undefined, undefined, codexHome);
|
|
83
|
+
assert.equal(isReviewerReadOnly(args, { mcpConfigPath: codexHome }), false);
|
|
84
|
+
assert.equal(isReviewerReadOnly(args, { mcpConfigPath: codexHome, mcpEnv: { CODEX_HOME: "/tmp/somewhere-else" } }), false);
|
|
85
|
+
assert.equal(isReviewerReadOnly(args, { mcpConfigPath: codexHome, mcpEnv: { SOMETHING_ELSE: codexHome } }), false);
|
|
86
|
+
});
|
|
87
|
+
// NOT-134. codex has no --disallowedTools, so the send-gate denial lives in the scoped
|
|
88
|
+
// config.toml. Verified live against codex 0.154.0: with `disabled_tools` present the
|
|
89
|
+
// session reports NOTOOL and emits no mcp_tool_call; without it the tool is exposed and
|
|
90
|
+
// reachable (the call was then refused by codex's own approval policy — an accident of
|
|
91
|
+
// that default, not something this codebase expresses, which is exactly why it is
|
|
92
|
+
// asserted here).
|
|
93
|
+
const tempHomes = [];
|
|
94
|
+
after(() => {
|
|
95
|
+
for (const dir of tempHomes)
|
|
96
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
97
|
+
});
|
|
98
|
+
/** A scoped home whose config auto-approves the send gate — the one entry that could
|
|
99
|
+
* undo `disabled_tools`. Must be rejected even though the denial is present. */
|
|
100
|
+
function scopedCodexHomeApprovingSendGate() {
|
|
101
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "codex-home-approve-"));
|
|
102
|
+
tempHomes.push(dir);
|
|
103
|
+
fs.writeFileSync(path.join(dir, "config.toml"), '[mcp_servers.agent-deck]\nurl = "http://127.0.0.1:1110/mcp"\n' +
|
|
104
|
+
'disabled_tools = ["call_service_tool"]\n\n' +
|
|
105
|
+
'[mcp_servers.agent-deck.tools.call_service_tool]\napproval_mode = "approve"\n');
|
|
106
|
+
return dir;
|
|
107
|
+
}
|
|
108
|
+
function scopedCodexHome(disabledTools) {
|
|
109
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "codex-home-"));
|
|
110
|
+
tempHomes.push(dir);
|
|
111
|
+
const denial = disabledTools ? `disabled_tools = ${JSON.stringify(disabledTools)}\n` : "";
|
|
112
|
+
fs.writeFileSync(path.join(dir, "config.toml"), `[mcp_servers.agent-deck]\nurl = "http://127.0.0.1:1110/mcp"\n${denial}`);
|
|
113
|
+
return dir;
|
|
114
|
+
}
|
|
115
|
+
test("a deck-attached codex reviewer must carry the send-gate denial in its scoped config", () => {
|
|
116
|
+
const args = buildReviewerArgs("codex_local", "review", undefined, undefined, "placeholder");
|
|
117
|
+
const denied = scopedCodexHome(["call_service_tool"]);
|
|
118
|
+
assert.doesNotThrow(() => assertReviewerReadOnly(args, { mcpConfigPath: denied, mcpEnv: { CODEX_HOME: denied } }));
|
|
119
|
+
const undenied = scopedCodexHome(null);
|
|
120
|
+
assert.throws(() => assertReviewerReadOnly(args, { mcpConfigPath: undenied, mcpEnv: { CODEX_HOME: undenied } }), /deny the outbound-mutation tool/);
|
|
121
|
+
const wrongTool = scopedCodexHome(["some_other_tool"]);
|
|
122
|
+
assert.throws(() => assertReviewerReadOnly(args, { mcpConfigPath: wrongTool, mcpEnv: { CODEX_HOME: wrongTool } }), /deny the outbound-mutation tool/);
|
|
123
|
+
});
|
|
124
|
+
test("a scoped config that auto-approves the send gate is rejected despite the denial", () => {
|
|
125
|
+
const home = scopedCodexHomeApprovingSendGate();
|
|
126
|
+
const args = buildReviewerArgs("codex_local", "review", undefined, undefined, "placeholder");
|
|
127
|
+
assert.throws(() => assertReviewerReadOnly(args, { mcpConfigPath: home, mcpEnv: { CODEX_HOME: home } }), /deny the outbound-mutation tool/);
|
|
128
|
+
});
|
|
129
|
+
test("an unreadable scoped codex config is treated as not denied", () => {
|
|
130
|
+
// An assertion must only ever over-reject: a config we cannot read is not evidence.
|
|
131
|
+
const missing = fs.mkdtempSync(path.join(os.tmpdir(), "codex-home-empty-"));
|
|
132
|
+
tempHomes.push(missing);
|
|
133
|
+
const args = buildReviewerArgs("codex_local", "review", undefined, undefined, "placeholder");
|
|
134
|
+
assert.throws(() => assertReviewerReadOnly(args, { mcpConfigPath: missing, mcpEnv: { CODEX_HOME: missing } }), /deny the outbound-mutation tool/);
|
|
135
|
+
});
|
|
136
|
+
test("a scoped CODEX_HOME does not excuse a non-read-only codex sandbox", () => {
|
|
137
|
+
// Reaches the codex branch itself: `workspace-write` would trip the earlier generic
|
|
138
|
+
// sandbox check before ever getting here, so use a value only the codex branch rejects.
|
|
139
|
+
const codexHome = "/tmp/authz/codex-home";
|
|
140
|
+
const ctx = { mcpConfigPath: codexHome, mcpEnv: { CODEX_HOME: codexHome } };
|
|
141
|
+
assert.throws(() => assertReviewerReadOnly(["exec", "--json", "-s", "danger-full-access", "review"], ctx), /read-only sandbox/);
|
|
142
|
+
// and the generic writable-sandbox check still fires first for workspace-write
|
|
143
|
+
assert.throws(() => assertReviewerReadOnly(["exec", "--json", "-s", "workspace-write", "review"], ctx), /writable sandbox/);
|
|
144
|
+
});
|
|
145
|
+
test("assertReviewerReadOnly rejects developer args (write tools present)", () => {
|
|
146
|
+
assert.throws(() => assertReviewerReadOnly(buildDeveloperArgs("claude_code", "implement")));
|
|
147
|
+
assert.equal(isReviewerReadOnly(buildDeveloperArgs("codex_local", "implement")), false);
|
|
148
|
+
});
|
|
149
|
+
test("assertReviewerReadOnly rejects a claude invocation that merely omits write tools from --allowedTools", () => {
|
|
150
|
+
// This is the exact shape NOT-60 shipped before this review round: --allowedTools
|
|
151
|
+
// omits Write/Edit/Bash, but nothing stops the active permission mode / an ambient
|
|
152
|
+
// .claude/settings.json from granting them anyway. The invariant must catch it even
|
|
153
|
+
// though --allowedTools itself "looks" read-only.
|
|
154
|
+
const looksReadOnlyButIsnt = [
|
|
155
|
+
"-p",
|
|
156
|
+
"review the diff",
|
|
157
|
+
"--output-format",
|
|
158
|
+
"stream-json",
|
|
159
|
+
"--verbose",
|
|
160
|
+
"--allowedTools",
|
|
161
|
+
"Read,Glob,Grep,Skill",
|
|
162
|
+
"--disallowedTools",
|
|
163
|
+
"mcp__agent-deck__call_service_tool",
|
|
164
|
+
];
|
|
165
|
+
assert.throws(() => assertReviewerReadOnly(looksReadOnlyButIsnt), /--tools|--restricted|dontAsk/);
|
|
166
|
+
});
|
|
167
|
+
test("a tightened developer policy drops the write tools from claude args", () => {
|
|
168
|
+
const policy = resolveSessionPermissionPolicy("developer", serializePermissionPolicyOverride({ worktreeWrite: false }));
|
|
169
|
+
const args = buildDeveloperArgs("claude_code", "prompt", undefined, policy);
|
|
170
|
+
const tools = args[args.indexOf("--allowedTools") + 1].split(",");
|
|
171
|
+
assert.ok(!tools.includes("Write"));
|
|
172
|
+
assert.ok(!tools.includes("Bash"));
|
|
173
|
+
});
|
|
174
|
+
test("a codex reviewer invocation isolates configured MCP servers via --ignore-user-config", () => {
|
|
175
|
+
// `-c mcp_servers={}` is a no-op (codex merges overrides into config.toml rather than
|
|
176
|
+
// replacing it — verified against the installed CLI); --ignore-user-config skips
|
|
177
|
+
// config.toml, where mcp_servers is defined, entirely.
|
|
178
|
+
const args = buildReviewerArgs("codex_local", "review");
|
|
179
|
+
assert.ok(args.includes("--ignore-user-config"));
|
|
180
|
+
assert.ok(!args.includes("-c"), "no longer relies on the disproven -c mcp_servers={} override");
|
|
181
|
+
// a codex developer keeps MCP (deck reads)
|
|
182
|
+
const dev = buildDeveloperArgs("codex_local", "impl");
|
|
183
|
+
assert.ok(!dev.includes("--ignore-user-config"));
|
|
184
|
+
});
|