deepdebug-local-agent 1.9.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +5 -0
  3. package/CLAUDE.md +1 -1
  4. package/docs/examples/agent2-runner/docker-compose.yml +53 -0
  5. package/docs/la-agent2-runner.md +115 -0
  6. package/docs/la-jobs-and-llm.md +62 -0
  7. package/docs/la-protocol-v2.md +7 -5
  8. package/env.example +12 -0
  9. package/package.json +1 -1
  10. package/src/la-protocol-v2/content-handlers/install.js +27 -1
  11. package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +10 -6
  12. package/src/la-protocol-v2/contract/README.md +75 -2
  13. package/src/la-protocol-v2/contract/envelope.schema.json +3 -1
  14. package/src/la-protocol-v2/contract/index.json +18 -2
  15. package/src/la-protocol-v2/contract/job.assign.schema.json +31 -0
  16. package/src/la-protocol-v2/contract/policy.check.schema.json +97 -0
  17. package/src/la-protocol-v2/contract/policy.decision.schema.json +119 -0
  18. package/src/la-protocol-v2/jobs/agent2/api-client.js +150 -0
  19. package/src/la-protocol-v2/jobs/agent2/approvals.js +114 -0
  20. package/src/la-protocol-v2/jobs/agent2/bridge.js +299 -0
  21. package/src/la-protocol-v2/jobs/agent2/config.js +128 -0
  22. package/src/la-protocol-v2/jobs/agent2/frames.js +61 -0
  23. package/src/la-protocol-v2/jobs/agent2/policy-check.js +170 -0
  24. package/src/la-protocol-v2/jobs/agent2/runner.js +258 -0
  25. package/src/la-protocol-v2/jobs/agent2/supervisor.js +269 -0
  26. package/src/la-protocol-v2/jobs/config.js +60 -0
  27. package/src/la-protocol-v2/jobs/events.js +106 -0
  28. package/src/la-protocol-v2/jobs/install.js +43 -0
  29. package/src/la-protocol-v2/jobs/manager.js +281 -0
  30. package/src/la-protocol-v2/jobs/options.js +56 -0
  31. package/src/la-protocol-v2/jobs/smoke-runner.js +39 -0
  32. package/src/la-protocol-v2/llm/client.js +432 -0
  33. package/src/la-protocol-v2/llm/config.js +63 -0
  34. package/src/la-protocol-v2/llm/install.js +17 -0
  35. package/src/la-protocol-v2/llm-key/store.js +10 -0
  36. package/src/la-protocol-v2/runtime.js +69 -3
  37. package/src/server.js +4 -0
  38. package/src/utils/approval-token.js +3 -2
  39. package/test/contract/la-protocol-v2/vectors/job.assign.vectors.json +1538 -0
  40. package/test/contract/la-protocol-v2/vectors/policy.check.vectors.json +908 -0
  41. package/test/contract/la-protocol-v2/vectors/policy.decision.vectors.json +826 -0
  42. package/test/la-protocol-v2/agent2-approval-discovery.test.js +114 -0
  43. package/test/la-protocol-v2/agent2-config.test.js +103 -0
  44. package/test/la-protocol-v2/agent2-orphan.test.js +118 -0
  45. package/test/la-protocol-v2/agent2-policy-check.test.js +273 -0
  46. package/test/la-protocol-v2/agent2-real-e2e.test.js +89 -0
  47. package/test/la-protocol-v2/agent2-runner.test.js +510 -0
  48. package/test/la-protocol-v2/agent2-supervisor.test.js +185 -0
  49. package/test/la-protocol-v2/agent2-workspace-door.test.js +142 -0
  50. package/test/la-protocol-v2/agent2-workspace-real-server.test.js +137 -0
  51. package/test/la-protocol-v2/contract-v21.test.js +2 -2
  52. package/test/la-protocol-v2/contract.test.js +8 -7
  53. package/test/la-protocol-v2/helpers/agent2-rig.js +96 -0
  54. package/test/la-protocol-v2/helpers/fake-agent2-cli.js +22 -0
  55. package/test/la-protocol-v2/helpers/fake-agent2.js +280 -0
  56. package/test/la-protocol-v2/helpers/fake-la-api.js +29 -0
  57. package/test/la-protocol-v2/helpers/fake-llm.js +75 -0
  58. package/test/la-protocol-v2/helpers/reference-cp.js +6 -3
  59. package/test/la-protocol-v2/helpers/spawn-agent.js +4 -3
  60. package/test/la-protocol-v2/helpers/supervisor-exit-script.js +15 -0
  61. package/test/la-protocol-v2/jobs-e2e.test.js +405 -0
  62. package/test/la-protocol-v2/jobs-manager.test.js +370 -0
  63. package/test/la-protocol-v2/jobs-options.test.js +288 -0
  64. package/test/la-protocol-v2/llm-client.test.js +380 -0
  65. package/test/la-protocol-v2/llm-messages.test.js +132 -0
@@ -184,6 +184,37 @@
184
184
  }
185
185
  ]
186
186
  }
187
+ },
188
+ "options": {
189
+ "type": "object",
190
+ "additionalProperties": false,
191
+ "description": "Optional (revision 2.1, capability job.options): how to run the job. Absent = the Local Agent's own defaults. Sent only to a Local Agent that announced job.options; none of these fields is content (a ticket id, a branch name, flags and a counter).",
192
+ "properties": {
193
+ "ticketKey": {
194
+ "$ref": "defs.schema.json#/$defs/shortId"
195
+ },
196
+ "branchName": {
197
+ "type": "string",
198
+ "minLength": 1,
199
+ "maxLength": 255,
200
+ "pattern": "^(?!.*\\.\\.)(?!.*//)(?!.*/\\.)(?!.*[./]$)(?!.*\\.lock(/|$))[A-Za-z0-9_][A-Za-z0-9_./-]{0,254}$"
201
+ },
202
+ "createBranch": {
203
+ "type": "boolean"
204
+ },
205
+ "createPullRequest": {
206
+ "type": "boolean"
207
+ },
208
+ "dryRun": {
209
+ "type": "boolean"
210
+ },
211
+ "maxIterations": {
212
+ "type": "integer",
213
+ "minimum": 1,
214
+ "maximum": 100
215
+ }
216
+ },
217
+ "minProperties": 1
187
218
  }
188
219
  },
189
220
  "required": [
@@ -0,0 +1,97 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://schemas.visionhammer.io/la-protocol-v2/policy.check.schema.json",
4
+ "title": "LA protocol v2: policy.check (la->cp)",
5
+ "type": "object",
6
+ "allOf": [
7
+ {
8
+ "$ref": "envelope.schema.json"
9
+ }
10
+ ],
11
+ "properties": {
12
+ "type": {
13
+ "const": "policy.check"
14
+ },
15
+ "payload": {
16
+ "type": "object",
17
+ "additionalProperties": false,
18
+ "properties": {
19
+ "actionType": {
20
+ "$ref": "defs.schema.json#/$defs/shortId"
21
+ },
22
+ "toolName": {
23
+ "$ref": "defs.schema.json#/$defs/shortId"
24
+ },
25
+ "requestHash": {
26
+ "$ref": "defs.schema.json#/$defs/sha256"
27
+ },
28
+ "repoRefs": {
29
+ "type": "array",
30
+ "minItems": 1,
31
+ "maxItems": 8,
32
+ "items": {
33
+ "type": "object",
34
+ "additionalProperties": false,
35
+ "properties": {
36
+ "provider": {
37
+ "type": "string",
38
+ "enum": [
39
+ "github",
40
+ "gitlab",
41
+ "bitbucket",
42
+ "azure",
43
+ "other"
44
+ ]
45
+ },
46
+ "org": {
47
+ "type": "string",
48
+ "minLength": 1,
49
+ "maxLength": 100,
50
+ "pattern": "^[A-Za-z0-9_.-]{1,100}$"
51
+ },
52
+ "repo": {
53
+ "type": "string",
54
+ "minLength": 1,
55
+ "maxLength": 100,
56
+ "pattern": "^[A-Za-z0-9_.-]{1,100}$"
57
+ }
58
+ },
59
+ "required": [
60
+ "provider",
61
+ "org",
62
+ "repo"
63
+ ]
64
+ }
65
+ },
66
+ "incidentId": {
67
+ "$ref": "defs.schema.json#/$defs/shortId"
68
+ },
69
+ "jobId": {
70
+ "$ref": "defs.schema.json#/$defs/uuid"
71
+ },
72
+ "modelAlias": {
73
+ "$ref": "defs.schema.json#/$defs/shortId"
74
+ }
75
+ },
76
+ "required": [
77
+ "actionType",
78
+ "requestHash"
79
+ ]
80
+ },
81
+ "seq": {
82
+ "type": "integer",
83
+ "minimum": 1,
84
+ "maximum": 9007199254740991
85
+ },
86
+ "chunk": false,
87
+ "sig": false,
88
+ "paramsRaw": false,
89
+ "requestId": true,
90
+ "agentId": true
91
+ },
92
+ "required": [
93
+ "payload",
94
+ "requestId",
95
+ "agentId"
96
+ ]
97
+ }
@@ -0,0 +1,119 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://schemas.visionhammer.io/la-protocol-v2/policy.decision.schema.json",
4
+ "title": "LA protocol v2: policy.decision (cp->la)",
5
+ "type": "object",
6
+ "allOf": [
7
+ {
8
+ "$ref": "envelope.schema.json"
9
+ }
10
+ ],
11
+ "properties": {
12
+ "type": {
13
+ "const": "policy.decision"
14
+ },
15
+ "payload": {
16
+ "type": "object",
17
+ "additionalProperties": false,
18
+ "properties": {
19
+ "decision": {
20
+ "type": "string",
21
+ "enum": [
22
+ "ALLOW",
23
+ "DENY",
24
+ "REQUIRE_HUMAN"
25
+ ]
26
+ },
27
+ "reasonCode": {
28
+ "type": "string",
29
+ "minLength": 2,
30
+ "maxLength": 48,
31
+ "pattern": "^[a-z][a-z0-9_]{1,47}$"
32
+ },
33
+ "shadow": {
34
+ "type": "boolean"
35
+ },
36
+ "approvalRequired": {
37
+ "type": "boolean"
38
+ },
39
+ "actionType": {
40
+ "$ref": "defs.schema.json#/$defs/shortId"
41
+ },
42
+ "effectiveMode": {
43
+ "$ref": "defs.schema.json#/$defs/operationMode"
44
+ },
45
+ "requestHash": {
46
+ "$ref": "defs.schema.json#/$defs/sha256"
47
+ },
48
+ "policySnapshot": {
49
+ "type": "object",
50
+ "additionalProperties": false,
51
+ "properties": {
52
+ "hash": {
53
+ "$ref": "defs.schema.json#/$defs/sha256"
54
+ }
55
+ },
56
+ "required": [
57
+ "hash"
58
+ ]
59
+ }
60
+ },
61
+ "required": [
62
+ "decision",
63
+ "reasonCode",
64
+ "shadow",
65
+ "approvalRequired",
66
+ "actionType",
67
+ "requestHash",
68
+ "policySnapshot"
69
+ ],
70
+ "allOf": [
71
+ {
72
+ "if": {
73
+ "properties": {
74
+ "decision": {
75
+ "const": "REQUIRE_HUMAN"
76
+ },
77
+ "shadow": {
78
+ "const": false
79
+ }
80
+ },
81
+ "required": [
82
+ "decision",
83
+ "shadow"
84
+ ]
85
+ },
86
+ "then": {
87
+ "properties": {
88
+ "approvalRequired": {
89
+ "const": true
90
+ }
91
+ }
92
+ },
93
+ "else": {
94
+ "properties": {
95
+ "approvalRequired": {
96
+ "const": false
97
+ }
98
+ }
99
+ }
100
+ }
101
+ ]
102
+ },
103
+ "seq": {
104
+ "type": "integer",
105
+ "minimum": 1,
106
+ "maximum": 9007199254740991
107
+ },
108
+ "chunk": false,
109
+ "sig": true,
110
+ "paramsRaw": true,
111
+ "requestId": true
112
+ },
113
+ "required": [
114
+ "payload",
115
+ "sig",
116
+ "paramsRaw",
117
+ "requestId"
118
+ ]
119
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * api-client.js - the Local Agent's client of agent2's local API (`/local/v1`, visionhammer-agents docs/runbooks/local-agent-api.md, PR #308).
3
+ *
4
+ * Loopback + `Authorization: Bearer <AGENT2_LOCAL_API_TOKEN>`. Errors are CODES (and, for a validation error, the NAME of the field): the body of a
5
+ * response is parsed, never echoed, never logged. The event stream (`GET /jobs/{id}/events`) is Server-Sent Events whose `frame` events carry the closed
6
+ * F3-08b frames (job.event / job.result / audit.event: ids, enums, counters).
7
+ */
8
+ export class Agent2ApiError extends Error {
9
+ constructor(code, { status = 0, field } = {}) {
10
+ super(code); // the message is the code, never free text from a response
11
+ this.name = "Agent2ApiError";
12
+ this.code = code;
13
+ this.status = status;
14
+ if (field) this.field = field;
15
+ }
16
+ }
17
+
18
+ const CODE = /^[a-z][a-z0-9_]{0,39}$/;
19
+ const FIELD = /^[A-Za-z][A-Za-z0-9_.[\]]{0,59}$/;
20
+ const MAX_JSON_BYTES = 1024 * 1024;
21
+ const MAX_SSE_LINE_BYTES = 256 * 1024;
22
+
23
+ async function readJson(res, maxBytes = MAX_JSON_BYTES) {
24
+ if (!res.body || typeof res.body.getReader !== "function") return null;
25
+ const reader = res.body.getReader();
26
+ const chunks = [];
27
+ let total = 0;
28
+ for (;;) {
29
+ const { done, value } = await reader.read();
30
+ if (done) break;
31
+ total += value.byteLength;
32
+ if (total > maxBytes) { try { await reader.cancel(); } catch { /* gone */ } throw new Agent2ApiError("response_too_large", { status: res.status }); }
33
+ chunks.push(Buffer.from(value.buffer, value.byteOffset, value.byteLength));
34
+ }
35
+ if (total === 0) return null;
36
+ try { return JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch { return undefined; }
37
+ }
38
+
39
+ export function createAgent2Api({ getTarget, requestTimeoutMs = 10_000, fetchImpl = (...a) => globalThis.fetch(...a) }) {
40
+ /** getTarget() -> { baseUrl, token } of the agent2 that is up now (the token changes at every start of a supervised process). */
41
+ async function call(method, path, { body, signal, timeoutMs = requestTimeoutMs, auth = true } = {}) {
42
+ const { baseUrl, token } = getTarget();
43
+ const ac = new AbortController();
44
+ let timedOut = false;
45
+ const timer = setTimeout(() => { timedOut = true; ac.abort(); }, timeoutMs);
46
+ const onAbort = () => ac.abort();
47
+ if (signal) { if (signal.aborted) { clearTimeout(timer); throw new Agent2ApiError("cancelled"); } signal.addEventListener("abort", onAbort, { once: true }); }
48
+ try {
49
+ const res = await fetchImpl(`${baseUrl}/local/v1${path}`, {
50
+ method,
51
+ headers: { accept: "application/json", ...(auth ? { authorization: `Bearer ${token}` } : {}), ...(body !== undefined ? { "content-type": "application/json" } : {}) },
52
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
53
+ signal: ac.signal,
54
+ });
55
+ const json = await readJson(res);
56
+ if (res.status >= 200 && res.status < 300) return { status: res.status, json: json ?? {} };
57
+ const code = json && typeof json.error === "string" && CODE.test(json.error) ? json.error : res.status === 401 ? "unauthorized" : res.status === 404 ? "not_found" : res.status === 409 ? "conflict" : "http_error";
58
+ const field = json && typeof json.field === "string" && FIELD.test(json.field) ? json.field : undefined;
59
+ throw new Agent2ApiError(code, { status: res.status, field });
60
+ } catch (e) {
61
+ if (e instanceof Agent2ApiError) throw e;
62
+ throw new Agent2ApiError(signal?.aborted ? "cancelled" : timedOut ? "timeout" : "unreachable");
63
+ } finally {
64
+ clearTimeout(timer);
65
+ signal?.removeEventListener("abort", onAbort);
66
+ }
67
+ }
68
+
69
+ return {
70
+ /** GET /health (no token). true when agent2 answers `status: up`. */
71
+ async health(signal) {
72
+ try { const r = await call("GET", "/health", { auth: false, timeoutMs: Math.min(requestTimeoutMs, 3_000), signal }); return !!r.json && r.json.status === "up"; } catch { return false; }
73
+ },
74
+ async postJob(body, signal) { return (await call("POST", "/jobs", { body, signal })).json; },
75
+ async cancel(jobId, reason = "user", signal) { return (await call("POST", `/jobs/${encodeURIComponent(jobId)}/cancel`, { body: { reason }, signal })).json; },
76
+ async jobState(jobId, signal) { return (await call("GET", `/jobs/${encodeURIComponent(jobId)}`, { signal })).json; },
77
+ /**
78
+ * Pending approvals of agent2 for ONE job: `GET /approvals?jobId=` -> [{approvalId, jobId, requestHash, actionType, toolName, expiresAt}] (an array, or
79
+ * `{approvals:[...]}`). The way this agent learns the id agent2 gave an approval it announced in `audit.event approval_requested` (which carries no id).
80
+ * Not in PR #308 as first merged (404 there): the caller falls back to the detail route.
81
+ */
82
+ async listApprovals({ jobId, signal } = {}) {
83
+ const q = jobId ? `?jobId=${encodeURIComponent(jobId)}` : "";
84
+ const j = (await call("GET", `/approvals${q}`, { signal })).json;
85
+ return Array.isArray(j) ? j : Array.isArray(j?.approvals) ? j.approvals : [];
86
+ },
87
+ async approvalDetail(approvalId, signal) { return (await call("GET", `/approvals/${encodeURIComponent(approvalId)}`, { signal })).json; },
88
+ async decide(approvalId, decision, decidedBy, signal) {
89
+ return (await call("POST", `/approvals/${encodeURIComponent(approvalId)}`, { body: { decision, ...(decidedBy ? { decidedBy } : {}) }, signal })).json;
90
+ },
91
+
92
+ /**
93
+ * Reads the SSE of a job: `onFrame(frame)` for every `frame` event (already JSON-parsed). Resolves `{ ended: true }` when the server ends the stream,
94
+ * rejects with Agent2ApiError on a non-200, a broken stream or an abort. The stream replays from the start: the caller skips what it already handled.
95
+ */
96
+ async events(jobId, { signal, onFrame, idleMs = 0 }) {
97
+ const { baseUrl, token } = getTarget();
98
+ const ac = new AbortController();
99
+ let idle = null;
100
+ const arm = () => { if (!idleMs) return; clearTimeout(idle); idle = setTimeout(() => ac.abort(), idleMs); };
101
+ const onAbort = () => ac.abort();
102
+ if (signal) { if (signal.aborted) throw new Agent2ApiError("cancelled"); signal.addEventListener("abort", onAbort, { once: true }); }
103
+ let res;
104
+ try {
105
+ arm();
106
+ res = await fetchImpl(`${baseUrl}/local/v1/jobs/${encodeURIComponent(jobId)}/events`, { method: "GET", headers: { accept: "text/event-stream", authorization: `Bearer ${token}` }, signal: ac.signal });
107
+ if (res.status !== 200) { try { await res.body?.cancel(); } catch { /* gone */ } throw new Agent2ApiError(res.status === 404 ? "not_found" : res.status === 401 ? "unauthorized" : "http_error", { status: res.status }); }
108
+ if (!res.body || typeof res.body.getReader !== "function") throw new Agent2ApiError("bad_response");
109
+ const reader = res.body.getReader();
110
+ const dec = new TextDecoder("utf-8");
111
+ let buf = "";
112
+ let event = "message";
113
+ let data = [];
114
+ const dispatch = () => {
115
+ if (event === "frame" && data.length) {
116
+ let frame;
117
+ try { frame = JSON.parse(data.join("\n")); } catch { frame = undefined; }
118
+ if (frame !== undefined) onFrame(frame);
119
+ }
120
+ event = "message"; data = [];
121
+ };
122
+ for (;;) {
123
+ const { done, value } = await reader.read();
124
+ if (done) break;
125
+ arm();
126
+ buf += dec.decode(value, { stream: true });
127
+ if (buf.length > MAX_SSE_LINE_BYTES * 2) throw new Agent2ApiError("bad_response");
128
+ let nl;
129
+ while ((nl = buf.search(/\r?\n/)) >= 0) {
130
+ const line = buf.slice(0, nl);
131
+ buf = buf.slice(buf[nl] === "\r" ? nl + 2 : nl + 1);
132
+ if (line === "") dispatch();
133
+ else if (line.startsWith("event:")) event = line.slice(6).trim();
134
+ else if (line.startsWith("data:")) data.push(line.slice(5).replace(/^ /, ""));
135
+ // comments (`:`), `id:` and `retry:` are ignored
136
+ }
137
+ }
138
+ if (buf.trim() === "" ) dispatch();
139
+ return { ended: true };
140
+ } catch (e) {
141
+ if (e instanceof Agent2ApiError) throw e;
142
+ throw new Agent2ApiError(signal?.aborted ? "cancelled" : "stream_broken");
143
+ } finally {
144
+ clearTimeout(idle);
145
+ signal?.removeEventListener("abort", onAbort);
146
+ try { ac.abort(); } catch { /* done */ }
147
+ }
148
+ },
149
+ };
150
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * approvals.js - `approval.decision` (CP -> LA, signed frame) to `POST /local/v1/approvals/{id}` of agent2 (F3-08c, ADR-0010, Local Agent half).
3
+ *
4
+ * The control plane decided (a person clicked); this agent only passes the decision on AFTER checking, on top of the session's signature check of the
5
+ * frame, the `vhapr1` token of the payload (src/utils/approval-token.js: Ed25519 over the platform key, tenant, workspace, <= 15 min, single use):
6
+ * - the token must be bound to THIS approval: its `sha256` is the SHA-256 of the approval's `requestHash` (the hash agent2 announced in
7
+ * `audit.event approval_requested`) - a token minted for another action does not approve this one;
8
+ * - the approval must belong to the job running now;
9
+ * - the token is consumed only after agent2 took the decision (a transient failure can be retried by the control plane with the same token).
10
+ * Nothing is decided on a doubt: an unknown approval, a bad token or an unreachable agent2 leaves agent2 waiting, and agent2's own window then refuses
11
+ * (no decision = refusal, the tool never runs).
12
+ *
13
+ * How the approval is found: the id in `approval.decision` is the control plane's, and `audit.event approval_requested` carries no id. agent2's pending list FOR THIS JOB
14
+ * (`GET /local/v1/approvals?jobId=`) gives the candidates ({approvalId, requestHash, ...}) and the token picks the one it is bound to (its `sha256` is the SHA-256 of the
15
+ * candidate's requestHash). An agent2 without the list (404) is asked for the CP's id directly (`GET /approvals/{id}`: the case where the two ids are the same). Without
16
+ * either, the decision cannot be delivered and the approval expires as a refusal.
17
+ */
18
+ import crypto from "node:crypto";
19
+ import { verifyApprovalToken } from "../../../utils/approval-token.js";
20
+
21
+ const SHORT_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/;
22
+ const DIFFERENT_COMMAND = "approval token is for a different command";
23
+ const REMEMBER = 4096;
24
+
25
+ /** What the token's `sha256` must equal: the SHA-256 of this string. (Proposal for the gateway: mint with `command = requestHash`.) */
26
+ export const approvalSubject = (requestHash) => String(requestHash ?? "");
27
+
28
+ export function createApprovalDecider({ api, signing, tenantId, now = Date.now, audit = null, log = console }) {
29
+ const hints = new Map(); // jobId -> Map(approvalId -> requestHash): what agent2's own `approval_requested` frames told us (this agent only; the control plane never sees the id)
30
+ const decided = new Map(); // approvalId -> true (bounded): a repeated frame is acked and not delivered twice
31
+ const usedJti = new Set();
32
+ const stats = { received: 0, delivered: 0, duplicate: 0, noJob: 0, unmatched: 0, unauthorized: 0, deliveryFailed: 0 };
33
+ const note = (event, extra) => { try { audit?.record({ event, ...extra }); } catch { /* the audit never breaks the data path */ } };
34
+ const remember = (set, v) => { set.add ? set.add(v) : set.set(v, true); const m = set; if (m.size > REMEMBER) m.delete(m.keys().next().value); };
35
+ const sh = (v) => crypto.createHash("sha256").update(String(v ?? "")).digest("hex").slice(0, 16);
36
+
37
+ return {
38
+ stats,
39
+ /** agent2 announced an approval (frame `audit.event approval_requested` with its id): remembered for this job, bounded. Only a fallback: the pending list stays the authority. */
40
+ noteRequested(jobId, { approvalId, requestHash }) {
41
+ let m = hints.get(jobId);
42
+ if (!m) { m = new Map(); hints.set(jobId, m); if (hints.size > 8) hints.delete(hints.keys().next().value); }
43
+ m.set(approvalId, requestHash);
44
+ if (m.size > 32) m.delete(m.keys().next().value);
45
+ },
46
+ forgetJob(jobId) { hints.delete(jobId); },
47
+ /**
48
+ * @param {{approvalId:string, decision:"approved"|"denied", token:string}} p the validated payload (the session checked schema and signature)
49
+ * @param {{jobId:string, workspaceId:string}|null} active the job running now
50
+ * @returns {Promise<{accept:true}|{reject:string}>}
51
+ */
52
+ async decide(p, active) {
53
+ stats.received++;
54
+ const idShort = sh(p.approvalId);
55
+ if (decided.has(p.approvalId)) { stats.duplicate++; note("approval_decision_duplicate", { approvalShort: idShort }); return { accept: true }; }
56
+ if (!active) { stats.noJob++; note("approval_decision_no_job", { approvalShort: idShort }); return { accept: true }; } // nothing waits for it: acked, no oracle
57
+ // The id in `approval.decision` is the control plane's; agent2 gave the approval its own. The pending list of THIS job (`GET /approvals?jobId=`) is how this agent
58
+ // learns the candidates; the vhapr1 token then picks the one it is bound to. An agent2 without the list (404) is asked for the CP's id directly (the case where
59
+ // the two ids are the same).
60
+ let candidates = [];
61
+ let listed = false; // agent2 told us what is pending (an empty answer is "nothing waits": the approval is gone)
62
+ try {
63
+ candidates = await api.listApprovals({ jobId: active.jobId });
64
+ listed = true;
65
+ } catch (e) {
66
+ if (e && (e.status === 404 || e.code === "not_found" || e.code === "http_error")) {
67
+ const told = hints.get(active.jobId);
68
+ if (told && told.size > 0) {
69
+ // no list in this agent2: the approvals it announced for this job (its own ids); the token below still has to match one, and agent2 refuses a decision on one that is gone
70
+ candidates = [...told].map(([approvalId, requestHash]) => ({ approvalId, requestHash, jobId: active.jobId }));
71
+ listed = true;
72
+ } else try {
73
+ const d = await api.approvalDetail(p.approvalId);
74
+ if (d && typeof d === "object") { candidates = [d]; listed = true; }
75
+ } catch (e2) {
76
+ if (!(e2 && (e2.status === 404 || e2.code === "not_found"))) { stats.deliveryFailed++; note("approval_decision_failed", { approvalShort: idShort, code: e2 && e2.code }); return { reject: "rate_limited" }; }
77
+ }
78
+ } else { stats.deliveryFailed++; note("approval_decision_failed", { approvalShort: idShort, code: e && e.code }); return { reject: "rate_limited" }; }
79
+ }
80
+ candidates = candidates.filter((c) => c && typeof c.approvalId === "string" && typeof c.requestHash === "string" && (c.jobId === undefined || c.jobId === active.jobId));
81
+ if (candidates.length === 0) {
82
+ stats.unmatched++;
83
+ note("approval_decision_unmatched", { approvalShort: idShort, listed });
84
+ // listed and empty: nothing waits any more (decided, expired, job over) - acked. Not listed: this agent could not even ask agent2
85
+ // (no `GET /approvals` there): the decision is NOT delivered and the control plane is told, instead of a silent "accepted"
86
+ return listed ? { accept: true } : { reject: "internal" };
87
+ }
88
+ let match = null;
89
+ let verdict = null;
90
+ for (const c of candidates) {
91
+ const v = verifyApprovalToken(p.token, { subject: approvalSubject(c.requestHash), keys: signing.keys, identity: { tenantId, workspaceId: active.workspaceId }, requestTenantId: tenantId, nonces: null, now });
92
+ if (v.ok) { match = c; verdict = v; break; }
93
+ if (v.reason !== DIFFERENT_COMMAND) { stats.unauthorized++; note("approval_decision_unauthorized", { approvalShort: idShort, reason: String(v.reason).replace(/'[^']*'/g, "'?'").slice(0, 60) }); return { reject: "unauthorized" }; }
94
+ }
95
+ if (!match) { stats.unauthorized++; note("approval_decision_unauthorized", { approvalShort: idShort, reason: "different command" }); return { reject: "unauthorized" }; }
96
+ if (usedJti.has(verdict.jti)) { stats.unauthorized++; note("approval_decision_unauthorized", { approvalShort: idShort, reason: "already used" }); return { reject: "unauthorized" }; }
97
+ const by = typeof verdict.approvedBy === "string" && SHORT_ID.test(verdict.approvedBy) ? verdict.approvedBy : undefined;
98
+ try {
99
+ await api.decide(match.approvalId, p.decision, by);
100
+ } catch (e) {
101
+ // already decided / gone (the window ran out): nothing more to do, and the token is spent either way
102
+ if (e && (e.status === 404 || e.status === 409 || e.code === "already_decided" || e.code === "unknown_approval")) { remember(usedJti, verdict.jti); remember(decided, p.approvalId); note("approval_decision_late", { approvalShort: idShort, code: e.code }); return { accept: true }; }
103
+ stats.deliveryFailed++;
104
+ note("approval_decision_failed", { approvalShort: idShort, code: e && e.code });
105
+ return { reject: "rate_limited" }; // the token was not consumed: the control plane may send it again
106
+ }
107
+ remember(usedJti, verdict.jti);
108
+ remember(decided, p.approvalId);
109
+ stats.delivered++;
110
+ note("approval_decision_delivered", { approvalShort: idShort, decision: p.decision });
111
+ return { accept: true };
112
+ },
113
+ };
114
+ }