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,94 @@
1
+ // F3-07 test rig: a real V2Session against the ReferenceCp (in memory), the REAL content handler, and temp workspaces on disk.
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { execFileSync } from "node:child_process";
6
+ import { ReferenceCp, TENANT, uuid, waitFor } from "./reference-cp.js";
7
+ import { inMemory } from "./la-rig.js";
8
+ import { resolveContentHandlersConfig } from "../../../src/la-protocol-v2/content-handlers/config.js";
9
+ import { ContentRefRegistry } from "../../../src/la-protocol-v2/content-handlers/ref-registry.js";
10
+ import { createContentHandler } from "../../../src/la-protocol-v2/content-handlers/handler.js";
11
+
12
+ export const ACTOR = { userId: "u-1", role: "member" };
13
+
14
+ export function tmpDir(prefix = "f307-") {
15
+ return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
16
+ }
17
+
18
+ export function write(root, rel, data) {
19
+ const abs = path.join(root, rel);
20
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
21
+ fs.writeFileSync(abs, data);
22
+ return abs;
23
+ }
24
+
25
+ export function git(root, ...args) {
26
+ return execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "-c", "commit.gpgsign=false", ...args], { cwd: root, stdio: ["ignore", "pipe", "pipe"] }).toString();
27
+ }
28
+
29
+ export function initRepo(root) {
30
+ git(root, "init", "-q", "-b", "main");
31
+ git(root, "add", "-A");
32
+ git(root, "commit", "-q", "-m", "init");
33
+ }
34
+
35
+ /**
36
+ * @param {object} o
37
+ * @param {string} [o.dlp] LOCAL_AGENT_DLP_MODE
38
+ * @param {object} [o.env] extra env for the content config and the DLP (LOCAL_AGENT_CONTENT_*, LOCAL_AGENT_DLP_*)
39
+ * @param {object} [o.workspaces] { id: {root, tenantId?} } (default: ws-1 -> a fresh temp dir, ws-2 -> another)
40
+ * @param {object} [o.cp] [o.la] options of ReferenceCp / inMemory
41
+ */
42
+ export async function setup({ dlp = "enforce", env = {}, workspaces, cp: cpOpts, la: laOpts, now } = {}) {
43
+ const cp = new ReferenceCp(cpOpts);
44
+ const lenv = inMemory(cp, laOpts).start();
45
+ await waitFor(() => lenv.session.state === "ready");
46
+ const dirs = [];
47
+ const mk = () => { const d = tmpDir(); dirs.push(d); return d; };
48
+ const wsMap = new Map(Object.entries(workspaces || { "ws-1": { root: mk() }, "ws-2": { root: mk() } }));
49
+ const fullEnv = { LOCAL_AGENT_CONTENT_HANDLERS: "true", LOCAL_AGENT_DLP_MODE: dlp, ...env };
50
+ const config = resolveContentHandlersConfig(fullEnv, () => {});
51
+ const registry = new ContentRefRegistry({ tenantId: TENANT, ttlMs: config.refTtlMs, maxEntries: config.maxRefs, denyGlobs: config.extraDenyGlobs, ...(now ? { now } : {}) });
52
+ const logs = [];
53
+ const log = { info: (m) => logs.push(m), log: (m) => logs.push(m), warn: (m) => logs.push(m) };
54
+ const resolveWorkspace = (id) => {
55
+ const w = wsMap.get(id);
56
+ if (!w || (w.tenantId && w.tenantId !== TENANT)) return null;
57
+ return { root: w.root };
58
+ };
59
+ const handler = createContentHandler({ session: lenv.session, tenantId: TENANT, resolveWorkspace, registry, config, env: fullEnv, log, ...(now ? { now } : {}) });
60
+ lenv.session.setHandler("content.request", (ctx) => handler.handle(ctx));
61
+ const rig = {
62
+ cp, ...lenv, handler, registry, config, logs, env: fullEnv, wsMap,
63
+ root: (id = "ws-1") => wsMap.get(id).root,
64
+ mint(kind, rel, workspaceId = "ws-1") {
65
+ const m = registry.mint({ workspaceId, root: wsMap.get(workspaceId).root, kind, rel });
66
+ return m && m.id;
67
+ },
68
+ /** Sends a content.request and waits for the whole answer. */
69
+ async request({ kind = "file", ref, workspaceId = "ws-1", range, limit, waitMs = 4000 } = {}) {
70
+ const requestId = uuid();
71
+ cp.sendData("content.request", { kind, ref, ...(range ? { range } : {}), ...(limit ? { limit } : {}), actor: ACTOR }, { workspaceId, requestId });
72
+ await waitFor(() => cp.contentRequests.get(requestId)?.done, waitMs);
73
+ return rig.answer(requestId);
74
+ },
75
+ answer(requestId) {
76
+ const frames = cp.received.filter((f) => f.type === "content.response" && f.requestId === requestId);
77
+ const first = frames[0];
78
+ const body = Buffer.concat((cp.contentRequests.get(requestId)?.chunks) || []);
79
+ return { requestId, frames, status: first?.payload.status, errorCode: first?.payload.errorCode, contentType: first?.payload.contentType,
80
+ body, text: body.toString("utf8"), chunks: frames.length };
81
+ },
82
+ /** Everything the LA ever put on the wire, the audit and the logs, as one string (to prove what never appears in it). */
83
+ everything() {
84
+ return JSON.stringify([lenv.wire.toCp, handler.audit.tail(1000), logs]);
85
+ },
86
+ async stop() {
87
+ handler.cancelAll();
88
+ lenv.session.close();
89
+ for (const d of dirs) fs.rmSync(d, { recursive: true, force: true });
90
+ },
91
+ dirs,
92
+ };
93
+ return rig;
94
+ }
@@ -0,0 +1,83 @@
1
+ // A miniature of the v1 tunnel in server.js, around the REAL v2 link: opens a ws to a ReferenceCpServer,
2
+ // hands the socket to the link exactly like server.js does, and records what the v1 handler would get.
3
+ import WebSocket from "ws";
4
+ import { createV2Link } from "../../../src/la-protocol-v2/link.js";
5
+ import { resolveSigningConfig } from "../../../src/utils/request-signing.js";
6
+ import { TENANT, AGENT } from "./reference-cp.js";
7
+
8
+ export function signingFor(server, mode = "enforce", extraEnv = {}) {
9
+ return resolveSigningConfig({
10
+ LOCAL_AGENT_SIGNATURE_MODE: mode,
11
+ LOCAL_AGENT_SIGNING_PUBLIC_KEYS: `${server.keyId}=${server.publicKeyRaw}`,
12
+ LOCAL_AGENT_TENANT_ID: TENANT,
13
+ ...extraEnv,
14
+ }, { defaultFile: null, fileCfg: {} });
15
+ }
16
+
17
+ export function startRig(server, { mode = "enforce", sessionOptions = {}, options = {}, fallbackMs = 600_000, tenantId = TENANT, agentId = AGENT } = {}) {
18
+ const logs = [];
19
+ const log = { info: (m) => logs.push(m), log: (m) => logs.push(m), warn: (m) => logs.push(m) };
20
+ const signing = signingFor(server, mode);
21
+ const link = createV2Link({
22
+ log, fallbackMs,
23
+ sessionOptions: {
24
+ tenantId, agentId, signing, laVersion: "1.3.0", capabilities: ["protocol.v2", "v1.commands", "chunk.base64"],
25
+ getWorkspaces: () => [{ workspaceId: "ws-1", pathSha256: "ab".repeat(32) }],
26
+ getHealth: () => ({ diskFreeBytes: 1234 }),
27
+ options: { handshakeTimeoutMs: 400, ...options },
28
+ ...sessionOptions,
29
+ },
30
+ });
31
+ const rig = {
32
+ link, session: link.session, logs, signing,
33
+ v1Messages: [], // texts that reached the v1 handler (the tunnel's existing code)
34
+ inbound: [], // every text received from the CP, in order
35
+ legacyRegisters: 0,
36
+ ws: null,
37
+ connect() {
38
+ return new Promise((resolve) => {
39
+ const ws = new WebSocket(`ws://127.0.0.1:${server.port}/api/v1/agent/ws`, { headers: { Authorization: `Bearer ${server.token}` } });
40
+ rig.ws = ws;
41
+ ws.on("open", () => {
42
+ const legacy = () => { rig.legacyRegisters++; ws.send(JSON.stringify({ type: "register", tenantId, port: 5055, workspacePath: null })); };
43
+ if (!link.begin(ws, legacy)) legacy();
44
+ resolve(ws);
45
+ });
46
+ ws.on("message", (data, isBinary) => {
47
+ const text = data.toString();
48
+ if (text === "pong") return;
49
+ rig.inbound.push(text);
50
+ if (link.handleMessage(ws, text, isBinary)) return;
51
+ rig.v1Messages.push(text);
52
+ });
53
+ ws.on("close", () => link.onClose(ws));
54
+ ws.on("error", () => {});
55
+ });
56
+ },
57
+ drop() { rig.ws && rig.ws.terminate(); },
58
+ async stop() { rig.ws && rig.ws.terminate(); link.close(); },
59
+ };
60
+ return rig;
61
+ }
62
+
63
+ /** No sockets: the session's transport writes straight into a ReferenceCp and the CP's frames straight into the session. */
64
+ import { V2Session } from "../../../src/la-protocol-v2/session.js";
65
+ export function inMemory(cp, { mode = "enforce", tenantId = TENANT, agentId = AGENT, options = {}, sessionOptions = {}, signing } = {}) {
66
+ const logs = [];
67
+ const session = new V2Session({
68
+ tenantId, agentId, signing: signing || signingFor({ keyId: cp.keyId, publicKeyRaw: cp.publicKeyRaw }, mode),
69
+ laVersion: "1.3.0", capabilities: ["protocol.v2", "v1.commands", "chunk.base64"],
70
+ getWorkspaces: () => [{ workspaceId: "ws-1", pathSha256: "ab".repeat(32) }],
71
+ getHealth: () => ({ diskFreeBytes: 1234 }),
72
+ log: { info: (m) => logs.push(m), warn: (m) => logs.push(m) },
73
+ options: { handshakeTimeoutMs: 400, ...options }, ...sessionOptions,
74
+ });
75
+ const wire = { toLa: [], toCp: [], closed: [], cpClosed: [] };
76
+ const transport = {
77
+ send: (t) => { wire.toCp.push(t); cp.feed(t); },
78
+ close: (c, r) => { wire.closed.push([c, r]); },
79
+ bufferedAmount: () => 0,
80
+ };
81
+ cp.attach((t) => { wire.toLa.push(t); queueMicrotask(() => session.handleText(t, transport)); }, (c, r) => wire.cpClosed.push([c, r]));
82
+ return { session, transport, wire, logs, start() { session.attach(transport); return this; } };
83
+ }
@@ -0,0 +1,349 @@
1
+ // Reference control plane for the tests of protocol v2 (F3-05b). NOT product code and NOT the gateway: a
2
+ // small Node model of the CP side that applies the rules of the contract README in the same order as the
3
+ // gateway's LaV2Session (size, parse, allowlist, schema, tenant, agent, direction, state, seq, chunks),
4
+ // signs its frames like AgentRequestSigner (VH-AGENT-SIG-V1, method WS, path = type) and keeps a session
5
+ // across connections so resume can be exercised. What it proves is limited by that: see the note
6
+ // "Fase 3 F3-05b" (interoperability with the real LaV2Session is a separate test, test/interop).
7
+ import crypto from "node:crypto";
8
+ import { WebSocketServer } from "ws";
9
+ import { loadContract } from "../../../src/la-protocol-v2/contract.js";
10
+ import { canonicalString, sha256Hex } from "../../../src/utils/request-signing.js";
11
+ import { parseStrict } from "../../../src/la-protocol-v2/strict-json.js";
12
+ import { closeCodeOf, isFatal, isRetryable } from "../../../src/la-protocol-v2/errors.js";
13
+
14
+ export const TENANT = "3f2b8c1e-5a4d-4e7b-9c21-0a1b2c3d4e5f";
15
+ export const AGENT = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d";
16
+ export const uuid = () => crypto.randomUUID();
17
+
18
+ export function newKeyPair() {
19
+ const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
20
+ const raw = publicKey.export({ format: "der", type: "spki" }).subarray(-32).toString("base64");
21
+ return { privateKey, publicKeyRaw: raw };
22
+ }
23
+
24
+ const DEFAULT_LIMITS = { maxFrameBytes: 49152, chunkRawBytes: 32768, maxChunks: 64, ackTimeoutMs: 5000, maxRetries: 3,
25
+ heartbeatSeconds: 30, contentFirstByteMs: 10000, contentTotalMs: 30000 };
26
+
27
+ export class ReferenceCp {
28
+ constructor({ tenantId = TENANT, agentId = AGENT, privateKey, publicKeyRaw, keyId = "vh-cp-test", requireEnforce = false,
29
+ now = Date.now, limits = {}, workspaceId = "", skewSeconds = 60 } = {}) {
30
+ this.publicKeyRaw = publicKeyRaw;
31
+ this.contract = loadContract();
32
+ if (!privateKey) ({ privateKey, publicKeyRaw: this.publicKeyRaw } = newKeyPair());
33
+ Object.assign(this, { tenantId, agentId, privateKey, keyId, requireEnforce, now, skewSeconds, workspaceId });
34
+ this.limits = { ...DEFAULT_LIMITS, ...limits };
35
+ this.sessionId = uuid();
36
+ this.laSeq = 0; this.cpSeq = 0;
37
+ this.ring = new Map();
38
+ this.unacked = new Map();
39
+ this.chunkStates = new Map();
40
+ this.state = "await_register";
41
+ this.reattached = false;
42
+ this.resume = null;
43
+ this.sendFn = () => {}; this.closeFn = () => {};
44
+ // observations
45
+ this.received = []; // LA data frames accepted, in order
46
+ this.duplicates = []; // seq of duplicate frames re-acked
47
+ this.errorsSent = []; // codes sent to the LA
48
+ this.peerErrors = []; // error frames the LA sent us
49
+ this.acksFromLa = [];
50
+ this.registers = [];
51
+ this.hellos = [];
52
+ this.rawIn = [];
53
+ this.dropAcks = 0; // do not ack the next n accepted LA data frames (simulates lost acks)
54
+ this.silent = false; // do not ack anything
55
+ this.ackDelayMs = 0; // answer acks late (slow consumer)
56
+ this.onReceived = () => {};
57
+ this.contentRequests = new Map(); // requestId -> {chunks:[], done}
58
+ }
59
+
60
+ /** A new connection of the same agent (resume candidate). */
61
+ attach(send, close) {
62
+ this.sendFn = send; this.closeFn = close;
63
+ for (const u of this.unacked.values()) clearTimeout(u.timer);
64
+ this.state = "await_register";
65
+ this.reattached = this.sessionStarted === true;
66
+ this.resume = null;
67
+ }
68
+
69
+ detach() {
70
+ for (const u of this.unacked.values()) { clearTimeout(u.timer); u.timer = null; }
71
+ this.sendFn = () => {};
72
+ }
73
+
74
+ // ------------------------------------------------------------------ inbound (LA -> CP)
75
+
76
+ /** @returns {null | {code:string}} the refusal (error frame already sent, close done when fatal) */
77
+ feed(text) {
78
+ this.rawIn.push(text);
79
+ const r = this.#process(text);
80
+ if (r) {
81
+ this.errorsSent.push(r.code);
82
+ this.#sendError(r.code, r.refSeq, r.refMsgId);
83
+ if (isFatal(r.code)) { this.closeFn(closeCodeOf(r.code), r.code); this.detach(); }
84
+ }
85
+ return r;
86
+ }
87
+
88
+ #process(text) {
89
+ const L = this.limits;
90
+ if (Buffer.byteLength(text, "utf8") > L.maxFrameBytes) return { code: "frame_too_large" };
91
+ let m;
92
+ try { m = parseStrict(text); } catch { return { code: "bad_frame" }; }
93
+ if (m === null || typeof m !== "object" || Array.isArray(m)) return { code: "bad_frame" };
94
+ const info = this.contract.typeInfo(m.type);
95
+ if (!info) return { code: "unknown_type" };
96
+ const v = this.contract.validateFrame(m);
97
+ if (!v.ok) return { code: v.code };
98
+ if (m.tenantId !== this.tenantId) return { code: "tenant_mismatch" };
99
+ if (m.agentId !== this.agentId) return { code: "agent_mismatch" };
100
+ if (info.direction === "cp->la") return { code: "wrong_direction" };
101
+ if (this.state === "await_register") return m.type === "register" ? this.#onRegister(m) : { code: "handshake_violation" };
102
+ if (this.state === "await_hello") return m.type === "hello" ? this.#onHello(m) : { code: "not_ready" };
103
+ if (m.type === "register" || m.type === "hello") return { code: "handshake_violation" };
104
+ if (info.control) {
105
+ if (m.type === "ack") this.#onAck(m);
106
+ else if (m.type === "error") this.peerErrors.push(m.payload.code);
107
+ return null;
108
+ }
109
+ return this.#onData(m);
110
+ }
111
+
112
+ #onRegister(m) {
113
+ const skew = Math.abs(Date.parse(m.ts) - this.now()) / 1000;
114
+ if (skew > this.skewSeconds) return { code: "clock_skew" };
115
+ this.registers.push(m.payload);
116
+ this.resume = m.payload.resume || null;
117
+ this.state = "await_hello";
118
+ return null;
119
+ }
120
+
121
+ #onHello(m) {
122
+ if (this.requireEnforce && m.payload.signatureMode !== "enforce") return { code: "signature_mode_insufficient" };
123
+ this.hellos.push(m.payload);
124
+ const resumed = !!(this.reattached && this.resume && this.resume.sessionId === this.sessionId && this.resume.lastRecvSeq <= this.cpSeq);
125
+ if (this.reattached && !resumed) this.#reset();
126
+ this.reattached = false;
127
+ this.sessionStarted = true;
128
+ if (resumed) this.#ackUpTo(this.resume.lastRecvSeq);
129
+ this.#sendReady(resumed);
130
+ this.state = "ready";
131
+ if (resumed) for (const u of this.unacked.values()) { u.retries = 0; this.#transmit(u); }
132
+ return null;
133
+ }
134
+
135
+ #reset() {
136
+ for (const u of this.unacked.values()) clearTimeout(u.timer);
137
+ this.unacked.clear(); this.ring.clear(); this.chunkStates.clear();
138
+ this.laSeq = 0; this.cpSeq = 0; this.sessionId = uuid();
139
+ }
140
+
141
+ #onAck(m) {
142
+ this.acksFromLa.push({ ackSeq: m.payload.ackSeq, status: m.payload.status, errorCode: m.payload.errorCode });
143
+ if (m.payload.ackSeq > this.cpSeq) return;
144
+ if (m.payload.status === "rejected") {
145
+ this.#ackUpTo(m.payload.ackSeq - 1);
146
+ const u = this.unacked.get(m.payload.ackSeq);
147
+ if (u) { clearTimeout(u.timer); this.unacked.delete(m.payload.ackSeq); }
148
+ } else this.#ackUpTo(m.payload.ackSeq);
149
+ }
150
+
151
+ #ackUpTo(n) {
152
+ for (const [seq, u] of this.unacked) { if (seq > n) break; clearTimeout(u.timer); this.unacked.delete(seq); }
153
+ }
154
+
155
+ #onData(m) {
156
+ const { seq, msgId } = m;
157
+ if (seq <= this.laSeq) {
158
+ if (this.ring.get(seq) === msgId) { this.duplicates.push(seq); if (!this.silent) this.#sendAck(seq, "duplicate"); return null; }
159
+ return { code: "seq_replayed", refSeq: seq, refMsgId: msgId };
160
+ }
161
+ if (seq !== this.laSeq + 1) return { code: "seq_gap", refSeq: seq, refMsgId: msgId };
162
+ if (m.type === "content.response") {
163
+ const bad = this.#chunkRule(m);
164
+ if (bad) return { code: bad, refSeq: seq, refMsgId: msgId };
165
+ }
166
+ this.laSeq = seq;
167
+ this.ring.set(seq, msgId);
168
+ while (this.ring.size > 512) this.ring.delete(this.ring.keys().next().value);
169
+ if (m.ack !== undefined) this.#ackUpTo(m.ack);
170
+ this.received.push(m);
171
+ if (m.type === "content.response") {
172
+ const c = this.contentRequests.get(m.requestId) || { chunks: [], done: false };
173
+ c.chunks.push(Buffer.from(m.payload.data, "base64"));
174
+ if (m.chunk.last) c.done = true;
175
+ this.contentRequests.set(m.requestId, c);
176
+ }
177
+ if (this.dropAcks > 0) this.dropAcks--;
178
+ else if (!this.silent) {
179
+ if (this.ackDelayMs > 0) setTimeout(() => this.#sendAck(seq, "accepted"), this.ackDelayMs);
180
+ else this.#sendAck(seq, "accepted");
181
+ }
182
+ this.onReceived(m);
183
+ return null;
184
+ }
185
+
186
+ #chunkRule(m) {
187
+ const { index, total, last } = m.chunk;
188
+ const st = this.chunkStates.get(m.requestId);
189
+ if (st && st.done) return "chunk_out_of_order";
190
+ if (st && st.total !== total) return "chunk_inconsistent";
191
+ if (index !== (st ? st.next : 0)) return "chunk_out_of_order";
192
+ if (last !== (index === total - 1)) return "chunk_inconsistent";
193
+ this.chunkStates.set(m.requestId, { total, next: index + 1, done: last });
194
+ return null;
195
+ }
196
+
197
+ // ------------------------------------------------------------------ outbound (CP -> LA)
198
+
199
+ #envelope(type, seq, requestId, workspaceId) {
200
+ return { schemaVersion: 2, type, msgId: uuid(), seq, tenantId: this.tenantId, agentId: this.agentId, workspaceId,
201
+ ts: new Date(this.now()).toISOString(), ...(requestId ? { requestId } : {}) };
202
+ }
203
+
204
+ #sign(frame, type, payload, workspaceId) {
205
+ const raw = JSON.stringify(payload);
206
+ const timestamp = String(Math.floor(this.now() / 1000));
207
+ const nonce = crypto.randomBytes(18).toString("base64url");
208
+ const canonical = canonicalString({ method: "WS", pathAndQuery: type, bodySha256: sha256Hex(Buffer.from(raw, "utf8")),
209
+ tenantId: this.tenantId, workspaceId, timestamp, nonce });
210
+ const signature = crypto.sign(null, Buffer.from(canonical, "utf8"), this.privateKey).toString("base64");
211
+ frame.paramsRaw = raw;
212
+ frame.sig = { keyId: this.keyId, timestamp, nonce, tenantId: this.tenantId, workspaceId, signature };
213
+ frame.payload = payload;
214
+ }
215
+
216
+ #sendReady(resumed) {
217
+ const f = this.#envelope("ready", 0, null, this.workspaceId);
218
+ this.#sign(f, "ready", { protocol: 2, sessionId: this.sessionId, resumed, peerLastSeq: this.laSeq, limits: { ...this.limits } }, this.workspaceId);
219
+ this.sendFn(JSON.stringify(f));
220
+ }
221
+
222
+ #sendAck(ackSeq, status, errorCode) {
223
+ const f = this.#envelope("ack", 0, null, "");
224
+ f.payload = { ackSeq, status, ...(errorCode ? { errorCode } : {}) };
225
+ this.sendFn(JSON.stringify(f));
226
+ }
227
+
228
+ #sendError(code, refSeq, refMsgId) {
229
+ const f = this.#envelope("error", 0, null, "");
230
+ f.payload = { code, retryable: isRetryable(code), ...(refSeq ? { refSeq } : {}), ...(refMsgId ? { refMsgId } : {}), ...(isFatal(code) ? { closeCode: closeCodeOf(code) } : {}) };
231
+ this.sendFn(JSON.stringify(f));
232
+ }
233
+
234
+ /** Sends a CP->LA data frame (job.assign, content.request, ...). Retransmitted (re-signed) until acked. */
235
+ sendData(type, payload, { workspaceId = this.workspaceId, requestId } = {}) {
236
+ const seq = ++this.cpSeq;
237
+ const u = { seq, type, payload, workspaceId, requestId, msgId: uuid(), retries: 0, timer: null };
238
+ this.unacked.set(seq, u);
239
+ this.#transmit(u);
240
+ return seq;
241
+ }
242
+
243
+ #transmit(u) {
244
+ const f = this.#envelope(u.type, u.seq, u.requestId, u.workspaceId);
245
+ f.msgId = u.msgId;
246
+ if (this.laSeq > 0) f.ack = this.laSeq;
247
+ this.#sign(f, u.type, u.payload, u.workspaceId);
248
+ this.sendFn(JSON.stringify(f));
249
+ clearTimeout(u.timer);
250
+ u.timer = setTimeout(() => {
251
+ if (!this.unacked.has(u.seq)) return;
252
+ if (u.retries >= this.limits.maxRetries) { this.timedOut = true; this.closeFn(closeCodeOf("timeout"), "ack_timeout"); this.detach(); return; }
253
+ u.retries++; this.retransmits = (this.retransmits || 0) + 1;
254
+ this.#transmit(u);
255
+ }, this.limits.ackTimeoutMs);
256
+ u.timer.unref?.();
257
+ }
258
+
259
+ /** A signed v1 command envelope ({requestId, command, params, paramsRaw, sig}) as the v1 gateway sends it. */
260
+ v1Command(command, params = {}) {
261
+ const f = { requestId: uuid(), command, params };
262
+ const raw = JSON.stringify(params);
263
+ const timestamp = String(Math.floor(this.now() / 1000));
264
+ const nonce = crypto.randomBytes(18).toString("base64url");
265
+ const canonical = canonicalString({ method: "WS", pathAndQuery: command, bodySha256: sha256Hex(Buffer.from(raw, "utf8")),
266
+ tenantId: this.tenantId, workspaceId: this.workspaceId, timestamp, nonce });
267
+ f.paramsRaw = raw;
268
+ f.sig = { keyId: this.keyId, timestamp, nonce, tenantId: this.tenantId, workspaceId: this.workspaceId,
269
+ signature: crypto.sign(null, Buffer.from(canonical, "utf8"), this.privateKey).toString("base64") };
270
+ return JSON.stringify(f);
271
+ }
272
+
273
+ /** Sends exactly this frame (for the tests that need an invalid CP frame). */
274
+ sendRaw(text) { this.sendFn(text); }
275
+ frameWith(type, payload, over = {}) {
276
+ const seq = ++this.cpSeq;
277
+ const f = this.#envelope(type, seq, over.requestId, this.workspaceId);
278
+ this.#sign(f, type, payload, this.workspaceId);
279
+ return Object.assign(f, over.frame || {});
280
+ }
281
+ }
282
+
283
+ /** A WebSocket server in front of one ReferenceCp (one agent). Authorization: Bearer <token>. */
284
+ export class ReferenceCpServer {
285
+ constructor(opts = {}) {
286
+ this.cp = new ReferenceCp(opts);
287
+ this.privateKey = this.cp.privateKey;
288
+ this.publicKeyRaw = this.cp.publicKeyRaw;
289
+ this.keyId = this.cp.keyId;
290
+ this.token = opts.token || "tok";
291
+ this.connections = 0;
292
+ this.textsFromLa = []; // everything the LA sent that was not a v2 frame (pings, legacy register, v1 answers)
293
+ this.legacy = []; // parsed JSON frames of v1 shape
294
+ this.v2Disabled = !!opts.v2Disabled; // behave like a gateway without v2: ignore v2 frames
295
+ this.refuseV2 = !!opts.refuseV2; // answer the v2 register with error protocol_unsupported
296
+ this.closes = [];
297
+ }
298
+
299
+ async start() {
300
+ this.wss = new WebSocketServer({ host: "127.0.0.1", port: 0 });
301
+ await new Promise((r) => this.wss.on("listening", r));
302
+ this.port = this.wss.address().port;
303
+ this.wss.on("connection", (ws, req) => {
304
+ if (req.headers.authorization !== `Bearer ${this.token}`) { ws.close(1008, "unauthorized"); return; }
305
+ this.connections++;
306
+ this.ws = ws;
307
+ if (!this.v2Disabled) this.cp.attach((t) => { if (ws.readyState === 1) ws.send(t); }, (code, reason) => ws.close(code, reason));
308
+ ws.on("message", (d, isBinary) => {
309
+ const text = d.toString();
310
+ if (text === "ping") { ws.send("pong"); return; }
311
+ let o = null;
312
+ try { o = JSON.parse(text); } catch { /* not json */ }
313
+ if (o && o.schemaVersion !== undefined) {
314
+ if (this.v2Disabled) return;
315
+ if (this.refuseV2) { // like the gateway: the connection is v1 from now on; the v2 frames after the register are ignored
316
+ if (o.type === "register") ws.send(JSON.stringify({ schemaVersion: 2, type: "error", msgId: uuid(), seq: 0, tenantId: this.cp.tenantId, agentId: this.cp.agentId,
317
+ workspaceId: "", ts: new Date().toISOString(), payload: { code: "protocol_unsupported", retryable: false } }));
318
+ return;
319
+ }
320
+ this.cp.feed(text);
321
+ } else {
322
+ this.textsFromLa.push(text);
323
+ if (o) this.legacy.push(o);
324
+ }
325
+ void isBinary;
326
+ });
327
+ ws.on("close", (code) => { this.closes.push(code); this.cp.detach(); });
328
+ });
329
+ return this;
330
+ }
331
+
332
+ get url() { return `http://127.0.0.1:${this.port}`; }
333
+ kill() { this.ws && this.ws.terminate(); }
334
+ async stop() {
335
+ for (const c of this.wss.clients) c.terminate();
336
+ this.cp.detach();
337
+ await new Promise((r) => this.wss.close(r));
338
+ }
339
+ }
340
+
341
+ export async function waitFor(cond, ms = 4000, step = 10) {
342
+ const t0 = Date.now();
343
+ while (Date.now() - t0 < ms) {
344
+ const v = await cond();
345
+ if (v) return v;
346
+ await new Promise((r) => setTimeout(r, step));
347
+ }
348
+ throw new Error(`waitFor timed out after ${ms} ms: ${cond.toString().slice(0, 120)}`);
349
+ }
@@ -0,0 +1,36 @@
1
+ // Boots the REAL agent (src/server.js of `root`) against a ReferenceCpServer and returns what it did.
2
+ // Used by the flag-off characterization and by the end-to-end test of the v2 tunnel.
3
+ import net from "node:net";
4
+ import os from "node:os";
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { spawn } from "node:child_process";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ export const DEFAULT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
11
+ export const freePort = () => new Promise((res) => { const s = net.createServer().listen(0, "127.0.0.1", () => { const p = s.address().port; s.close(() => res(p)); }); });
12
+
13
+ export async function runAgent({ srv, root = DEFAULT_ROOT, env = {}, cfg, until, ms = 20000, prepare }) {
14
+ const ws = fs.mkdtempSync(path.join(os.tmpdir(), "v2e2e-ws-"));
15
+ if (prepare) await prepare(ws); // F3-07: put files / a git repo in the workspace before the agent starts
16
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "v2e2e-home-"));
17
+ if (cfg) {
18
+ fs.mkdirSync(path.join(home, ".deepdebug"));
19
+ fs.writeFileSync(path.join(home, ".deepdebug", "config.json"), JSON.stringify(cfg));
20
+ }
21
+ const full = { ...process.env, PORT: String(await freePort()), GATEWAY_URL: srv.url, AUTH_TOKEN: srv.token, WORKSPACE_ROOT: ws, HOME: home, ...env };
22
+ delete full.LOCAL_AGENT_TOKEN;
23
+ for (const k of Object.keys(full)) if (full[k] === undefined) delete full[k];
24
+ const child = spawn(process.execPath, ["src/server.js"], { cwd: root, env: full, stdio: "ignore" });
25
+ try {
26
+ const deadline = Date.now() + ms;
27
+ while (Date.now() < deadline) {
28
+ if (until && (await until())) break;
29
+ await new Promise((r) => setTimeout(r, 50));
30
+ }
31
+ } finally {
32
+ child.kill("SIGKILL");
33
+ fs.rmSync(ws, { recursive: true, force: true });
34
+ fs.rmSync(home, { recursive: true, force: true });
35
+ }
36
+ }