deepdebug-local-agent 1.3.0 → 1.5.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 (123) hide show
  1. package/.github/workflows/deploy-gcp.yml +58 -26
  2. package/CHANGELOG.md +6 -0
  3. package/docs/la-protocol-v2.md +102 -0
  4. package/package.json +2 -1
  5. package/scripts/sync-la-protocol-v2-contract.mjs +58 -0
  6. package/src/la-protocol-v2/chunks.js +45 -0
  7. package/src/la-protocol-v2/config.js +27 -0
  8. package/src/la-protocol-v2/content-handlers/audit.js +36 -0
  9. package/src/la-protocol-v2/content-handlers/config.js +48 -0
  10. package/src/la-protocol-v2/content-handlers/egress.js +65 -0
  11. package/src/la-protocol-v2/content-handlers/git-diff.js +92 -0
  12. package/src/la-protocol-v2/content-handlers/handler.js +170 -0
  13. package/src/la-protocol-v2/content-handlers/index.js +6 -0
  14. package/src/la-protocol-v2/content-handlers/install.js +50 -0
  15. package/src/la-protocol-v2/content-handlers/path-guard.js +142 -0
  16. package/src/la-protocol-v2/content-handlers/ref-registry.js +87 -0
  17. package/src/la-protocol-v2/content.js +63 -0
  18. package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +81 -0
  19. package/src/la-protocol-v2/contract/README.md +103 -0
  20. package/src/la-protocol-v2/contract/ack.schema.json +51 -0
  21. package/src/la-protocol-v2/contract/alert.schema.json +62 -0
  22. package/src/la-protocol-v2/contract/approval.decision.schema.json +57 -0
  23. package/src/la-protocol-v2/contract/audit.event.schema.json +86 -0
  24. package/src/la-protocol-v2/contract/config.update.schema.json +73 -0
  25. package/src/la-protocol-v2/contract/content.request.schema.json +110 -0
  26. package/src/la-protocol-v2/contract/content.response.schema.json +90 -0
  27. package/src/la-protocol-v2/contract/defs.schema.json +191 -0
  28. package/src/la-protocol-v2/contract/envelope.schema.json +89 -0
  29. package/src/la-protocol-v2/contract/error.schema.json +59 -0
  30. package/src/la-protocol-v2/contract/heartbeat.schema.json +100 -0
  31. package/src/la-protocol-v2/contract/hello.schema.json +111 -0
  32. package/src/la-protocol-v2/contract/incident.candidate.schema.json +102 -0
  33. package/src/la-protocol-v2/contract/index.json +166 -0
  34. package/src/la-protocol-v2/contract/job.assign.schema.json +212 -0
  35. package/src/la-protocol-v2/contract/job.cancel.schema.json +52 -0
  36. package/src/la-protocol-v2/contract/job.event.schema.json +115 -0
  37. package/src/la-protocol-v2/contract/job.result.schema.json +100 -0
  38. package/src/la-protocol-v2/contract/log.stats.schema.json +79 -0
  39. package/src/la-protocol-v2/contract/ready.schema.json +106 -0
  40. package/src/la-protocol-v2/contract/register.schema.json +92 -0
  41. package/src/la-protocol-v2/contract/scan.upload.schema.json +59 -0
  42. package/src/la-protocol-v2/contract/scan.uploaded.schema.json +51 -0
  43. package/src/la-protocol-v2/contract/webhook.forward.schema.json +69 -0
  44. package/src/la-protocol-v2/contract.js +90 -0
  45. package/src/la-protocol-v2/errors.js +34 -0
  46. package/src/la-protocol-v2/index.js +8 -0
  47. package/src/la-protocol-v2/link.js +93 -0
  48. package/src/la-protocol-v2/runtime.js +81 -0
  49. package/src/la-protocol-v2/session.js +650 -0
  50. package/src/la-protocol-v2/strict-json.js +47 -0
  51. package/src/server.js +25 -4
  52. package/test/contract/la-protocol-v2/vectors/ack.vectors.json +149 -0
  53. package/test/contract/la-protocol-v2/vectors/alert.vectors.json +101 -0
  54. package/test/contract/la-protocol-v2/vectors/approval.decision.vectors.json +165 -0
  55. package/test/contract/la-protocol-v2/vectors/audit.event.vectors.json +156 -0
  56. package/test/contract/la-protocol-v2/vectors/config.update.vectors.json +189 -0
  57. package/test/contract/la-protocol-v2/vectors/content.request.vectors.json +325 -0
  58. package/test/contract/la-protocol-v2/vectors/content.response.vectors.json +413 -0
  59. package/test/contract/la-protocol-v2/vectors/cross-type.vectors.json +260 -0
  60. package/test/contract/la-protocol-v2/vectors/error.vectors.json +179 -0
  61. package/test/contract/la-protocol-v2/vectors/heartbeat.vectors.json +109 -0
  62. package/test/contract/la-protocol-v2/vectors/hello.vectors.json +224 -0
  63. package/test/contract/la-protocol-v2/vectors/incident.candidate.vectors.json +186 -0
  64. package/test/contract/la-protocol-v2/vectors/job.assign.vectors.json +631 -0
  65. package/test/contract/la-protocol-v2/vectors/job.cancel.vectors.json +133 -0
  66. package/test/contract/la-protocol-v2/vectors/job.event.vectors.json +233 -0
  67. package/test/contract/la-protocol-v2/vectors/job.result.vectors.json +137 -0
  68. package/test/contract/la-protocol-v2/vectors/log.stats.vectors.json +137 -0
  69. package/test/contract/la-protocol-v2/vectors/ready.vectors.json +177 -0
  70. package/test/contract/la-protocol-v2/vectors/register.vectors.json +399 -0
  71. package/test/contract/la-protocol-v2/vectors/scan.upload.vectors.json +137 -0
  72. package/test/contract/la-protocol-v2/vectors/scan.uploaded.vectors.json +101 -0
  73. package/test/contract/la-protocol-v2/vectors/traces/invalid-agent-mismatch.json +140 -0
  74. package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-after-last.json +200 -0
  75. package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-byte-length-mismatch.json +174 -0
  76. package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-last-flag.json +200 -0
  77. package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-out-of-order.json +200 -0
  78. package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-total-changes.json +200 -0
  79. package/test/contract/la-protocol-v2/vectors/traces/invalid-clock-skew.json +46 -0
  80. package/test/contract/la-protocol-v2/vectors/traces/invalid-event-before-ready.json +72 -0
  81. package/test/contract/la-protocol-v2/vectors/traces/invalid-first-frame-not-register.json +48 -0
  82. package/test/contract/la-protocol-v2/vectors/traces/invalid-frame-over-48kib.json +141 -0
  83. package/test/contract/la-protocol-v2/vectors/traces/invalid-la-sends-job-assign.json +173 -0
  84. package/test/contract/la-protocol-v2/vectors/traces/invalid-params-raw-differs-from-payload.json +172 -0
  85. package/test/contract/la-protocol-v2/vectors/traces/invalid-payload-above-limit.json +174 -0
  86. package/test/contract/la-protocol-v2/vectors/traces/invalid-resume-restarts-seq.json +144 -0
  87. package/test/contract/la-protocol-v2/vectors/traces/invalid-seq-gap.json +166 -0
  88. package/test/contract/la-protocol-v2/vectors/traces/invalid-seq-repeated.json +166 -0
  89. package/test/contract/la-protocol-v2/vectors/traces/invalid-sig-tenant-mismatch.json +172 -0
  90. package/test/contract/la-protocol-v2/vectors/traces/invalid-signature-warn-with-enforce-required.json +77 -0
  91. package/test/contract/la-protocol-v2/vectors/traces/invalid-tenant-mismatch.json +140 -0
  92. package/test/contract/la-protocol-v2/vectors/traces/invalid-type-outside-allowlist.json +140 -0
  93. package/test/contract/la-protocol-v2/vectors/traces/valid-content-chunks-in-order.json +224 -0
  94. package/test/contract/la-protocol-v2/vectors/traces/valid-handshake-and-job.json +266 -0
  95. package/test/contract/la-protocol-v2/vectors/traces/valid-resume-continues-seq.json +168 -0
  96. package/test/contract/la-protocol-v2/vectors/traces/valid-retransmit-is-idempotent.json +193 -0
  97. package/test/contract/la-protocol-v2/vectors/webhook.forward.vectors.json +170 -0
  98. package/test/la-protocol-v2/content-handlers.test.js +633 -0
  99. package/test/la-protocol-v2/content-path-guard.test.js +214 -0
  100. package/test/la-protocol-v2/content-wiring.test.js +225 -0
  101. package/test/la-protocol-v2/contract.test.js +131 -0
  102. package/test/la-protocol-v2/helpers/content-rig.js +94 -0
  103. package/test/la-protocol-v2/helpers/la-rig.js +83 -0
  104. package/test/la-protocol-v2/helpers/reference-cp.js +349 -0
  105. package/test/la-protocol-v2/helpers/spawn-agent.js +36 -0
  106. package/test/la-protocol-v2/link.test.js +141 -0
  107. package/test/la-protocol-v2/session-inbound.test.js +279 -0
  108. package/test/la-protocol-v2/session-outbound.test.js +382 -0
  109. package/test/la-protocol-v2/traces.test.js +270 -0
  110. package/test/la-protocol-v2/tunnel-e2e.test.js +124 -0
  111. package/tools/graphify-validation/extras/README.md +19 -0
  112. package/tools/graphify-validation/extras/bymethod.py +35 -0
  113. package/tools/graphify-validation/extras/docker/Dockerfile +16 -0
  114. package/tools/graphify-validation/extras/docker/inner.sh +7 -0
  115. package/tools/graphify-validation/extras/g10.py +59 -0
  116. package/tools/graphify-validation/extras/g3aug.py +49 -0
  117. package/tools/graphify-validation/extras/hybrid.py +34 -0
  118. package/tools/graphify-validation/extras/truth2.py +44 -0
  119. package/tools/interop/LaV2NodeContentInteropTest.java +248 -0
  120. package/tools/interop/LaV2NodeInteropTest.java +266 -0
  121. package/tools/interop/README.md +38 -0
  122. package/tools/interop/la-interop-client.mjs +97 -0
  123. package/tools/interop/la-interop-content-client.mjs +91 -0
@@ -0,0 +1,141 @@
1
+ // F3-05b: the glue between the v1 tunnel and the v2 session, on real sockets: negotiation, fallback to v1 on the
2
+ // same connection, v1 commands on a v2 connection, and what is (not) routed to the v2 pipeline.
3
+ import { test } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { ReferenceCpServer, uuid, waitFor } from "./helpers/reference-cp.js";
6
+ import { startRig } from "./helpers/la-rig.js";
7
+ import { isV2Frame } from "../../src/la-protocol-v2/link.js";
8
+
9
+ async function withRig(srvOpts, rigOpts, body) {
10
+ const srv = await new ReferenceCpServer(srvOpts).start();
11
+ const rig = startRig(srv, rigOpts);
12
+ try { await body(srv, rig); } finally { await rig.stop(); await srv.stop(); }
13
+ }
14
+
15
+ test("negotiation: v2 register+hello first; the legacy register is NOT sent when the gateway speaks v2", async () => {
16
+ await withRig({ requireEnforce: true }, {}, async (srv, rig) => {
17
+ await rig.connect();
18
+ await waitFor(() => rig.link.mode === "v2");
19
+ assert.equal(rig.legacyRegisters, 0);
20
+ assert.equal(srv.legacy.length, 0, "no v1 frame was sent");
21
+ assert.deepEqual(srv.cp.registers.map((r) => r.protocol), [2]);
22
+ });
23
+ });
24
+
25
+ test("fallback: a gateway that ignores v2 gets the v1 register on the SAME connection after the handshake timeout", async () => {
26
+ await withRig({ v2Disabled: true }, { fallbackMs: 60_000 }, async (srv, rig) => {
27
+ await rig.connect();
28
+ assert.equal(rig.legacyRegisters, 0, "not yet: v2 owns the registration while it negotiates");
29
+ await waitFor(() => rig.legacyRegisters === 1, 2000);
30
+ assert.equal(rig.link.mode, "v1");
31
+ assert.equal(srv.connections, 1, "same connection");
32
+ await waitFor(() => srv.legacy.some((f) => f.type === "register" && "workspacePath" in f));
33
+ // v1 keeps working: a command reaches the v1 handler
34
+ srv.ws.send(JSON.stringify({ requestId: "r1", command: "health", params: {} }));
35
+ await waitFor(() => rig.v1Messages.length === 1);
36
+ });
37
+ });
38
+
39
+ test("fallback is remembered: the next connection goes straight to v1 (no 10 s wait per reconnect), until fallbackMs passes", async () => {
40
+ await withRig({ v2Disabled: true }, { fallbackMs: 150 }, async (srv, rig) => {
41
+ await rig.connect();
42
+ await waitFor(() => rig.legacyRegisters === 1, 2000);
43
+ rig.drop();
44
+ await waitFor(() => srv.closes.length === 1);
45
+ await rig.connect();
46
+ assert.equal(rig.legacyRegisters, 2, "legacy register immediately on the second connection");
47
+ rig.drop();
48
+ await waitFor(() => srv.closes.length === 2);
49
+ await new Promise((r) => setTimeout(r, 200)); // fallbackMs elapsed
50
+ await rig.connect();
51
+ assert.equal(rig.legacyRegisters, 2, "v2 is offered again");
52
+ await waitFor(() => rig.legacyRegisters === 3, 2000); // ... and falls back again after the timeout
53
+ });
54
+ });
55
+
56
+ test("fallback: error protocol_unsupported falls back at once, without waiting for the timeout", async () => {
57
+ await withRig({ refuseV2: true }, { options: { handshakeTimeoutMs: 5000 } }, async (srv, rig) => {
58
+ const t0 = Date.now();
59
+ await rig.connect();
60
+ await waitFor(() => rig.legacyRegisters === 1, 1500);
61
+ assert.ok(Date.now() - t0 < 1200, `fell back after ${Date.now() - t0} ms`);
62
+ assert.equal(rig.link.mode, "v1");
63
+ assert.equal(srv.connections, 1);
64
+ });
65
+ });
66
+
67
+ test("no fallback when the gateway refuses the handshake for a reason that v1 would not fix (signature_mode_insufficient)", async () => {
68
+ await withRig({ requireEnforce: true }, { mode: "warn", options: { handshakeTimeoutMs: 300 } }, async (srv, rig) => {
69
+ await rig.connect();
70
+ await waitFor(() => srv.cp.errorsSent.includes("signature_mode_insufficient"));
71
+ await new Promise((r) => setTimeout(r, 450));
72
+ assert.equal(rig.legacyRegisters, 0);
73
+ });
74
+ });
75
+
76
+ test("v1 commands keep working on a v2 connection: they reach the existing handler, not the v2 pipeline", async () => {
77
+ await withRig({ requireEnforce: true }, {}, async (srv, rig) => {
78
+ await rig.connect();
79
+ await waitFor(() => rig.link.mode === "v2");
80
+ const cmd = srv.cp.v1Command("health", {});
81
+ srv.ws.send(cmd);
82
+ await waitFor(() => rig.v1Messages.length === 1);
83
+ assert.equal(rig.v1Messages[0], cmd);
84
+ assert.deepEqual(srv.cp.peerErrors, []);
85
+ assert.equal(rig.session.stats.framesIn, 1, "only the ready went through the v2 pipeline");
86
+ // and v2 is unaffected
87
+ await rig.session.send("job.event", { jobId: "c3d4e5f6-a7b8-4c9d-8e0f-2a3b4c5d6e7f", state: "running" });
88
+ });
89
+ });
90
+
91
+ test("routing: anything that claims schemaVersion goes to the v2 pipeline (and is refused if wrong); everything else is v1's business", async () => {
92
+ await withRig({ requireEnforce: true }, {}, async (srv, rig) => {
93
+ await rig.connect();
94
+ await waitFor(() => rig.link.mode === "v2");
95
+ srv.ws.send("not json at all");
96
+ srv.ws.send(JSON.stringify({ type: "something-v1-only" }));
97
+ await waitFor(() => rig.v1Messages.length === 2);
98
+ assert.deepEqual(srv.cp.peerErrors, []);
99
+ srv.ws.send(JSON.stringify({ schemaVersion: 2, type: "shell.exec" }));
100
+ await waitFor(() => srv.cp.peerErrors.length === 1);
101
+ assert.deepEqual(srv.cp.peerErrors, ["unknown_type"]);
102
+ await waitFor(() => srv.closes.includes(4401));
103
+ });
104
+ });
105
+
106
+ test("routing: a binary frame is dropped by the v2 pipeline, never handed to the v1 handler", async () => {
107
+ await withRig({ requireEnforce: true }, {}, async (srv, rig) => {
108
+ await rig.connect();
109
+ await waitFor(() => rig.link.mode === "v2");
110
+ srv.ws.send(Buffer.from([0, 1, 2]), { binary: true });
111
+ await waitFor(() => rig.session.stats.nonText === 1);
112
+ assert.equal(rig.v1Messages.length, 0);
113
+ });
114
+ });
115
+
116
+ test("isV2Frame: schemaVersion present = v2; a v1 command, text and arrays are not; an oversized frame is judged by its head", () => {
117
+ assert.equal(isV2Frame('{"schemaVersion":2,"type":"x"}', 49152), true);
118
+ assert.equal(isV2Frame('{"schemaVersion":1}', 49152), true, "a wrong version is still a v2 attempt (and gets refused)");
119
+ assert.equal(isV2Frame('{"requestId":"r","command":"health","params":{}}', 49152), false);
120
+ assert.equal(isV2Frame('["schemaVersion"]', 49152), false);
121
+ assert.equal(isV2Frame("pong", 49152), false);
122
+ assert.equal(isV2Frame(`{"schemaVersion":2,"pad":"${"x".repeat(60_000)}"}`, 49152), true);
123
+ assert.equal(isV2Frame(`{"requestId":"r","command":"workspace.safe-patch","params":{"diff":"${"x".repeat(60_000)}"}}`, 49152), false);
124
+ });
125
+
126
+ test("the link survives a reconnect with the session intact (resume offered on the new socket)", async () => {
127
+ await withRig({ requireEnforce: true }, {}, async (srv, rig) => {
128
+ await rig.connect();
129
+ await waitFor(() => rig.link.mode === "v2");
130
+ await rig.session.send("job.event", { jobId: "c3d4e5f6-a7b8-4c9d-8e0f-2a3b4c5d6e7f", state: "running" });
131
+ const sid = rig.session.sessionId;
132
+ rig.drop();
133
+ await waitFor(() => rig.session.state === "detached");
134
+ await rig.connect();
135
+ await waitFor(() => rig.link.mode === "v2" && rig.session.state === "ready");
136
+ assert.equal(rig.session.sessionId, sid);
137
+ assert.equal(srv.cp.registers.at(-1).resume.sessionId, sid);
138
+ assert.equal(rig.legacyRegisters, 0);
139
+ assert.ok(uuid());
140
+ });
141
+ });
@@ -0,0 +1,279 @@
1
+ // F3-05b: what the Local Agent does with a frame from the control plane. No sockets: the session is wired to a
2
+ // ReferenceCp in memory, so the order of events is deterministic. Every refusal must carry only the wire code.
3
+ import { test } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import crypto from "node:crypto";
6
+ import { ReferenceCp, TENANT, AGENT, uuid, waitFor, newKeyPair } from "./helpers/reference-cp.js";
7
+ import { inMemory, signingFor } from "./helpers/la-rig.js";
8
+ import { closeCodeOf } from "../../src/la-protocol-v2/errors.js";
9
+
10
+ const SECRET = "CLIENT_SECRET_MARKER_5521";
11
+ const JOB = "c3d4e5f6-a7b8-4c9d-8e0f-2a3b4c5d6e7f";
12
+
13
+ async function ready(opts = {}) {
14
+ const cp = new ReferenceCp(opts.cp);
15
+ const env = inMemory(cp, opts.la).start();
16
+ await waitFor(() => env.session.state === "ready");
17
+ return { cp, ...env };
18
+ }
19
+ const errorsFromLa = (env) => env.wire.toCp.map((t) => JSON.parse(t)).filter((f) => f.type === "error").map((f) => f.payload);
20
+ const sentText = (env) => env.wire.toCp.join("\n");
21
+ const close = (env) => env.session.close();
22
+
23
+ test("handshake: register then hello, a signed ready is verified (enforce) and the session is ready", async () => {
24
+ const env = await ready({ cp: { requireEnforce: true } });
25
+ assert.equal(env.session.state, "ready");
26
+ assert.equal(env.cp.registers[0].signatureMode, "enforce");
27
+ assert.equal(env.cp.hellos[0].signatureMode, "enforce");
28
+ assert.equal(env.session.sessionId, env.cp.sessionId);
29
+ close(env);
30
+ });
31
+
32
+ const BAD_FRAMES = [
33
+ // [name, build(cp) -> text, expected code]
34
+ ["a type outside the allowlist", (cp) => JSON.stringify({ ...cp.frameWith("job.cancel", { jobId: JOB, reason: "user" }), type: "shell.exec" }), "unknown_type"],
35
+ ["a v1 command name used as a type", (cp) => JSON.stringify({ ...cp.frameWith("job.cancel", { jobId: JOB, reason: "user" }), type: "mcp.execute-command" }), "unknown_type"],
36
+ ["a la->cp type sent by the CP", (cp) => JSON.stringify({ schemaVersion: 2, type: "job.event", msgId: uuid(), seq: 1, tenantId: TENANT, agentId: AGENT, workspaceId: "", ts: new Date().toISOString(), payload: { jobId: JOB, state: "running" } }), "wrong_direction"],
37
+ ["JSON that does not parse", () => "{not json " + SECRET, "bad_frame"],
38
+ ["a duplicate key", () => `{"schemaVersion":2,"schemaVersion":2,"note":"${SECRET}"}`, "bad_frame"],
39
+ ["a JSON array", () => `["${SECRET}"]`, "bad_frame"],
40
+ ["a frame above 48 KiB", (cp) => { const f = cp.frameWith("job.cancel", { jobId: JOB, reason: "user" }); return JSON.stringify(f) + " ".repeat(50_000); }, "frame_too_large"],
41
+ ["an extra top-level field", (cp) => JSON.stringify({ ...cp.frameWith("job.cancel", { jobId: JOB, reason: "user" }), extra: SECRET }), "schema_violation"],
42
+ ["an extra payload field", (cp) => { const f = cp.frameWith("job.cancel", { jobId: JOB, reason: "user" }); f.payload.note = SECRET; return JSON.stringify(f); }, "schema_violation"],
43
+ ["schemaVersion 1", (cp) => JSON.stringify({ ...cp.frameWith("job.cancel", { jobId: JOB, reason: "user" }), schemaVersion: 1 }), "schema_violation"],
44
+ ["another tenant", (cp) => JSON.stringify({ ...cp.frameWith("job.cancel", { jobId: JOB, reason: "user" }), tenantId: "99999999-5a4d-4e7b-9c21-0a1b2c3d4e5f" }), "tenant_mismatch"],
45
+ ["another agent", (cp) => JSON.stringify({ ...cp.frameWith("job.cancel", { jobId: JOB, reason: "user" }), agentId: "99999999-e5f6-4a7b-8c9d-0e1f2a3b4c5d" }), "agent_mismatch"],
46
+ ["paramsRaw different from payload", (cp) => { const f = cp.frameWith("job.cancel", { jobId: JOB, reason: "user" }); f.paramsRaw = JSON.stringify({ jobId: JOB, reason: "ops" }); return JSON.stringify(f); }, "params_mismatch"],
47
+ ["paramsRaw with a duplicate key", (cp) => { const f = cp.frameWith("job.cancel", { jobId: JOB, reason: "user" }); f.paramsRaw = `{"jobId":"${JOB}","reason":"user","reason":"user"}`; return JSON.stringify(f); }, "params_mismatch"],
48
+ ["sig bound to another tenant", (cp) => { const f = cp.frameWith("job.cancel", { jobId: JOB, reason: "user" }); f.sig.tenantId = "99999999-5a4d-4e7b-9c21-0a1b2c3d4e5f"; return JSON.stringify(f); }, "tenant_mismatch"],
49
+ ["sig bound to another workspace", (cp) => { const f = cp.frameWith("job.cancel", { jobId: JOB, reason: "user" }); f.sig.workspaceId = "other-ws"; return JSON.stringify(f); }, "tenant_mismatch"],
50
+ ["a bad signature (enforce)", (cp) => { const f = cp.frameWith("job.cancel", { jobId: JOB, reason: "user" }); f.sig.signature = Buffer.alloc(64, 7).toString("base64"); return JSON.stringify(f); }, "unauthorized"],
51
+ ["a signature of another key id", (cp) => { const f = cp.frameWith("job.cancel", { jobId: JOB, reason: "user" }); f.sig.keyId = "unknown-key"; return JSON.stringify(f); }, "unauthorized"],
52
+ ["a seq gap", (cp) => { cp.cpSeq += 4; return JSON.stringify(cp.frameWith("job.cancel", { jobId: JOB, reason: "user" })); }, "seq_gap"],
53
+ ["a second ready", (cp) => { const f = cp.frameWith("config.update", { retentionDays: 7 }); f.type = "ready"; f.seq = 0; f.payload = { protocol: 2, sessionId: uuid(), resumed: false, peerLastSeq: 0, limits: { maxFrameBytes: 49152, chunkRawBytes: 32768, maxChunks: 64, ackTimeoutMs: 5000, maxRetries: 3, heartbeatSeconds: 30 } }; f.paramsRaw = JSON.stringify(f.payload); return JSON.stringify(f); }, "handshake_violation"],
54
+ ];
55
+
56
+ for (const [name, build, code] of BAD_FRAMES) {
57
+ test(`inbound: ${name} -> error ${code}, close ${closeCodeOf(code)}, nothing of the content comes back`, async () => {
58
+ const env = await ready();
59
+ env.cp.sendRaw(build(env.cp));
60
+ await waitFor(() => errorsFromLa(env).length > 0);
61
+ const err = errorsFromLa(env)[0];
62
+ assert.equal(err.code, code);
63
+ assert.equal(err.closeCode, closeCodeOf(code));
64
+ assert.deepEqual(Object.keys(err).filter((k) => !["code", "retryable", "closeCode", "refSeq", "refMsgId"].includes(k)), []);
65
+ assert.equal(sentText(env).includes(SECRET), false, "the error frame echoed content");
66
+ assert.equal(env.logs.join("\n").includes(SECRET), false, "the log carries content");
67
+ await waitFor(() => env.wire.closed.length > 0);
68
+ assert.equal(env.wire.closed[0][0], closeCodeOf(code));
69
+ close(env);
70
+ });
71
+ }
72
+
73
+ test("inbound: a replayed nonce is refused (unauthorized): the same signed bytes cannot be sent twice", async () => {
74
+ const env = await ready();
75
+ const f = env.cp.frameWith("job.cancel", { jobId: JOB, reason: "user" });
76
+ env.session.setHandler("job.cancel", () => ({ accept: true }));
77
+ env.cp.sendRaw(JSON.stringify(f));
78
+ await waitFor(() => env.cp.acksFromLa.length >= 1);
79
+ // same signature/nonce on a NEW seq: the nonce was already consumed
80
+ const g = { ...f, seq: f.seq + 1, msgId: uuid() };
81
+ env.cp.sendRaw(JSON.stringify(g));
82
+ await waitFor(() => errorsFromLa(env).length > 0);
83
+ assert.equal(errorsFromLa(env)[0].code, "unauthorized");
84
+ close(env);
85
+ });
86
+
87
+ test("inbound: seq_replayed (same seq, another msgId) carries refSeq and refMsgId", async () => {
88
+ const env = await ready();
89
+ env.session.setHandler("job.cancel", () => ({ accept: true }));
90
+ const f = env.cp.frameWith("job.cancel", { jobId: JOB, reason: "user" });
91
+ env.cp.sendRaw(JSON.stringify(f));
92
+ await waitFor(() => env.cp.acksFromLa.length >= 1);
93
+ const other = env.cp.frameWith("job.cancel", { jobId: JOB, reason: "user" });
94
+ other.seq = f.seq; // same seq, another msgId
95
+ env.cp.sendRaw(JSON.stringify(other));
96
+ await waitFor(() => errorsFromLa(env).length > 0);
97
+ const e = errorsFromLa(env)[0];
98
+ assert.equal(e.code, "seq_replayed");
99
+ assert.equal(e.refSeq, f.seq);
100
+ assert.equal(e.refMsgId, other.msgId);
101
+ close(env);
102
+ });
103
+
104
+ test("inbound: before ready only ready/error/ack are accepted (not_ready)", async () => {
105
+ const cp = new ReferenceCp();
106
+ const env = inMemory(cp);
107
+ // attach without letting the CP answer: use a transport that swallows the register/hello
108
+ const swallow = { send() {}, close() {}, bufferedAmount: () => 0 };
109
+ env.session.attach(swallow);
110
+ env.session.handleText(JSON.stringify(cp.frameWith("job.cancel", { jobId: JOB, reason: "user" })), swallow);
111
+ await waitFor(() => env.session.stats.rejectedIn.not_ready === 1);
112
+ env.session.close();
113
+ });
114
+
115
+ // ------------------------------------------------------------------------------------------ signature modes
116
+
117
+ test("signature modes: warn and off accept a bad signature, enforce refuses it; hello reports the mode", async () => {
118
+ for (const [mode, accepted] of [["warn", true], ["off", true], ["enforce", false]]) {
119
+ const env = await ready({ la: { mode } });
120
+ assert.equal(env.cp.hellos[0].signatureMode, mode);
121
+ let ran = 0;
122
+ env.session.setHandler("job.cancel", () => { ran++; return { accept: true }; });
123
+ const f = env.cp.frameWith("job.cancel", { jobId: JOB, reason: "user" });
124
+ f.sig.signature = Buffer.alloc(64, 9).toString("base64");
125
+ env.cp.sendRaw(JSON.stringify(f));
126
+ if (accepted) {
127
+ await waitFor(() => ran === 1);
128
+ assert.equal(errorsFromLa(env).length, 0, mode);
129
+ } else {
130
+ await waitFor(() => errorsFromLa(env).length === 1);
131
+ assert.equal(ran, 0);
132
+ }
133
+ close(env);
134
+ }
135
+ });
136
+
137
+ test("signature: the signed text is what runs (handler gets the parsed paramsRaw)", async () => {
138
+ const env = await ready();
139
+ let got;
140
+ env.session.setHandler("job.cancel", (ctx) => { got = ctx; return { accept: true }; });
141
+ env.cp.sendRaw(JSON.stringify(env.cp.frameWith("job.cancel", { jobId: JOB, reason: "user" }, { requestId: uuid() })));
142
+ await waitFor(() => got);
143
+ assert.deepEqual(got.params, { jobId: JOB, reason: "user" });
144
+ assert.equal(got.type, "job.cancel");
145
+ assert.equal(got.seq, 1);
146
+ assert.throws(() => { got.type = "x"; }, TypeError, "the context handed to a handler is frozen");
147
+ close(env);
148
+ });
149
+
150
+ test("signature: a key the LA does not trust (rotation) is refused in the ready itself", async () => {
151
+ const cp = new ReferenceCp();
152
+ const other = newKeyPair();
153
+ const env = inMemory(cp, { signing: signingFor({ keyId: cp.keyId, publicKeyRaw: other.publicKeyRaw }, "enforce") }).start();
154
+ await waitFor(() => cp.peerErrors.length > 0);
155
+ assert.deepEqual(cp.peerErrors, ["unauthorized"]);
156
+ assert.notEqual(env.session.state, "ready");
157
+ env.session.close();
158
+ });
159
+
160
+ // ------------------------------------------------------------------------------------------ acks and handlers
161
+
162
+ test("ack: no handler -> rejected protocol_unsupported; the seq is still consumed", async () => {
163
+ const env = await ready();
164
+ env.cp.sendData("job.assign", { jobId: JOB, kind: "fix", repoRef: { provider: "github", org: "acme", repo: "billing", branch: "main" },
165
+ policy: { operationMode: "assist", automationLevel: 2, ceiling: "autonomous" }, budget: { tokens: 1000, seconds: 60 }, llm: { alias: "vh-coder", keyRef: "key-la-1" } });
166
+ env.cp.sendData("job.cancel", { jobId: JOB, reason: "user" });
167
+ await waitFor(() => env.cp.acksFromLa.length >= 2);
168
+ assert.deepEqual(env.cp.acksFromLa.map((a) => [a.ackSeq, a.status, a.errorCode]), [[1, "rejected", "protocol_unsupported"], [2, "rejected", "protocol_unsupported"]]);
169
+ assert.equal(env.session.lastRecvSeq, 2);
170
+ assert.equal(env.cp.unacked.size, 0, "a rejected ack settles the frame at the CP");
171
+ close(env);
172
+ });
173
+
174
+ test("ack: a handler decides accepted / rejected / throws / is slow; every verdict is one ack", async () => {
175
+ const env = await ready({ la: { options: { handlerAckMs: 80 } } });
176
+ const verdicts = [{ accept: true }, { reject: "rate_limited" }, "throw", "slow"];
177
+ let i = 0;
178
+ env.session.setHandler("job.cancel", () => {
179
+ const v = verdicts[i++];
180
+ if (v === "throw") throw new Error("boom " + SECRET);
181
+ if (v === "slow") return new Promise((r) => setTimeout(() => r({ reject: "internal" }), 400));
182
+ return v;
183
+ });
184
+ for (let n = 0; n < 4; n++) env.cp.sendData("job.cancel", { jobId: JOB, reason: "user" });
185
+ await waitFor(() => env.cp.acksFromLa.length >= 4);
186
+ assert.deepEqual(env.cp.acksFromLa.map((a) => [a.ackSeq, a.status, a.errorCode]),
187
+ [[1, "accepted", undefined], [2, "rejected", "rate_limited"], [3, "rejected", "internal"], [4, "accepted", undefined]]);
188
+ assert.equal(sentText(env).includes(SECRET), false);
189
+ close(env);
190
+ });
191
+
192
+ test("dedupe: a retransmission (same seq and msgId, fresh signature) is re-acked as duplicate and NEVER runs twice", async () => {
193
+ const env = await ready();
194
+ let ran = 0;
195
+ env.session.setHandler("job.cancel", () => { ran++; return { accept: true }; });
196
+ const seq = env.cp.sendData("job.cancel", { jobId: JOB, reason: "user" });
197
+ await waitFor(() => env.cp.acksFromLa.length >= 1);
198
+ // the CP never saw the ack: it sends the frame again (what its retransmission timer does, re-signed)
199
+ const u = [...env.wire.toLa].map((t) => JSON.parse(t)).find((f) => f.type === "job.cancel");
200
+ env.cp.cpSeq = seq; // keep numbering; re-render the same seq/msgId with a fresh nonce
201
+ const again = env.cp.frameWith("job.cancel", { jobId: JOB, reason: "user" });
202
+ again.seq = u.seq; again.msgId = u.msgId;
203
+ env.cp.sendRaw(JSON.stringify(again));
204
+ await waitFor(() => env.cp.acksFromLa.length >= 2);
205
+ assert.equal(ran, 1);
206
+ assert.deepEqual(env.cp.acksFromLa.map((a) => a.status), ["accepted", "duplicate"]);
207
+ assert.equal(env.session.stats.duplicatesIn, 1);
208
+ assert.equal(errorsFromLa(env).length, 0);
209
+ close(env);
210
+ });
211
+
212
+ test("dedupe: the last 512 seq->msgId pairs are kept (the 513th old one is a replay)", async () => {
213
+ const env = await ready();
214
+ env.session.setHandler("job.cancel", () => ({ accept: true }));
215
+ for (let n = 0; n < 520; n++) env.cp.sendData("job.cancel", { jobId: JOB, reason: "user" });
216
+ await waitFor(() => env.session.lastRecvSeq === 520, 8000);
217
+ assert.equal(env.session.recvRing.size, 512);
218
+ close(env);
219
+ });
220
+
221
+ test("a piggy-backed ack field on a CP frame settles our frames", async () => {
222
+ const env = await ready();
223
+ env.cp.silent = true; // the CP does not send ack frames
224
+ env.session.setHandler("job.cancel", () => ({ accept: true }));
225
+ const h = await env.session.enqueue("job.event", { jobId: JOB, state: "running" });
226
+ assert.equal(env.session.unacked.size, 1);
227
+ env.cp.laSeq = 1; // CP has the frame
228
+ env.cp.sendData("job.cancel", { jobId: JOB, reason: "user" }); // carries ack:1
229
+ await h.acked;
230
+ assert.equal(env.session.unacked.size, 0);
231
+ close(env);
232
+ });
233
+
234
+ test("a rejected ack from the CP fails that frame with the code and releases the others", async () => {
235
+ const env = await ready();
236
+ env.cp.silent = true;
237
+ const a = await env.session.enqueue("job.event", { jobId: JOB, state: "running" });
238
+ const b = await env.session.enqueue("job.event", { jobId: JOB, state: "running" });
239
+ const c = await env.session.enqueue("job.event", { jobId: JOB, state: "succeeded" });
240
+ const f = env.cp.frameWith("ack", { ackSeq: 2, status: "rejected", errorCode: "rate_limited" });
241
+ f.type = "ack"; f.seq = 0; delete f.paramsRaw; delete f.sig; f.workspaceId = "";
242
+ env.cp.sendRaw(JSON.stringify(f));
243
+ await a.acked;
244
+ await assert.rejects(b.acked, (e) => e.code === "rate_limited");
245
+ assert.equal(env.session.unacked.has(3), true);
246
+ void c;
247
+ close(env);
248
+ });
249
+
250
+ test("an ack for a frame we never sent is ignored", async () => {
251
+ const env = await ready();
252
+ const f = { schemaVersion: 2, type: "ack", msgId: uuid(), seq: 0, tenantId: TENANT, agentId: AGENT, workspaceId: "", ts: new Date().toISOString(), payload: { ackSeq: 99, status: "accepted" } };
253
+ env.cp.sendRaw(JSON.stringify(f));
254
+ await new Promise((r) => setTimeout(r, 30));
255
+ assert.equal(errorsFromLa(env).length, 0);
256
+ assert.equal(env.session.state, "ready");
257
+ close(env);
258
+ });
259
+
260
+ test("inbound queue is bounded (maxInboundQueue): the overflow is answered rate_limited, the session survives", async () => {
261
+ const env = await ready({ la: { options: { maxInboundQueue: 2 } } });
262
+ env.session.setHandler("job.cancel", () => new Promise((r) => setTimeout(() => r({ accept: true }), 50)));
263
+ for (let n = 0; n < 6; n++) env.cp.sendRaw(JSON.stringify(env.cp.frameWith("job.cancel", { jobId: JOB, reason: "user" })));
264
+ await waitFor(() => errorsFromLa(env).some((e) => e.code === "rate_limited"));
265
+ assert.equal(env.wire.closed.length, 0, "rate_limited is not fatal");
266
+ close(env);
267
+ });
268
+
269
+ test("binary frames from the CP are dropped and counted, never parsed", async () => {
270
+ const env = await ready();
271
+ env.session.handleText("\u0000\u0001", env.transport, true);
272
+ assert.equal(env.session.stats.nonText, 1);
273
+ assert.equal(errorsFromLa(env).length, 0);
274
+ close(env);
275
+ });
276
+
277
+ test("sanity: the helper crypto is real (an Ed25519 signature over the v1 canonical string)", () => {
278
+ assert.equal(crypto.getHashes().includes("sha256"), true);
279
+ });