deepdebug-local-agent 1.2.0 → 1.4.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 +13 -0
  3. package/docs/la-protocol-v2.md +68 -0
  4. package/docs/ws-metadata-only.md +44 -0
  5. package/env.example +7 -0
  6. package/package.json +2 -1
  7. package/scripts/sync-la-protocol-v2-contract.mjs +58 -0
  8. package/src/la-protocol-v2/chunks.js +45 -0
  9. package/src/la-protocol-v2/config.js +27 -0
  10. package/src/la-protocol-v2/content.js +57 -0
  11. package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +81 -0
  12. package/src/la-protocol-v2/contract/README.md +103 -0
  13. package/src/la-protocol-v2/contract/ack.schema.json +51 -0
  14. package/src/la-protocol-v2/contract/alert.schema.json +62 -0
  15. package/src/la-protocol-v2/contract/approval.decision.schema.json +57 -0
  16. package/src/la-protocol-v2/contract/audit.event.schema.json +86 -0
  17. package/src/la-protocol-v2/contract/config.update.schema.json +73 -0
  18. package/src/la-protocol-v2/contract/content.request.schema.json +110 -0
  19. package/src/la-protocol-v2/contract/content.response.schema.json +90 -0
  20. package/src/la-protocol-v2/contract/defs.schema.json +191 -0
  21. package/src/la-protocol-v2/contract/envelope.schema.json +89 -0
  22. package/src/la-protocol-v2/contract/error.schema.json +59 -0
  23. package/src/la-protocol-v2/contract/heartbeat.schema.json +100 -0
  24. package/src/la-protocol-v2/contract/hello.schema.json +111 -0
  25. package/src/la-protocol-v2/contract/incident.candidate.schema.json +102 -0
  26. package/src/la-protocol-v2/contract/index.json +166 -0
  27. package/src/la-protocol-v2/contract/job.assign.schema.json +212 -0
  28. package/src/la-protocol-v2/contract/job.cancel.schema.json +52 -0
  29. package/src/la-protocol-v2/contract/job.event.schema.json +115 -0
  30. package/src/la-protocol-v2/contract/job.result.schema.json +100 -0
  31. package/src/la-protocol-v2/contract/log.stats.schema.json +79 -0
  32. package/src/la-protocol-v2/contract/ready.schema.json +106 -0
  33. package/src/la-protocol-v2/contract/register.schema.json +92 -0
  34. package/src/la-protocol-v2/contract/scan.upload.schema.json +59 -0
  35. package/src/la-protocol-v2/contract/scan.uploaded.schema.json +51 -0
  36. package/src/la-protocol-v2/contract/webhook.forward.schema.json +69 -0
  37. package/src/la-protocol-v2/contract.js +90 -0
  38. package/src/la-protocol-v2/errors.js +34 -0
  39. package/src/la-protocol-v2/index.js +8 -0
  40. package/src/la-protocol-v2/link.js +93 -0
  41. package/src/la-protocol-v2/runtime.js +72 -0
  42. package/src/la-protocol-v2/session.js +650 -0
  43. package/src/la-protocol-v2/strict-json.js +47 -0
  44. package/src/mcp-http-server.js +5 -3
  45. package/src/server.js +70 -9
  46. package/src/utils/exec-policy.js +8 -1
  47. package/src/ws-channel/allowlist.js +143 -0
  48. package/src/ws-channel/config.js +22 -0
  49. package/src/ws-channel/connection-state.js +18 -0
  50. package/src/ws-channel/index.js +6 -0
  51. package/src/ws-channel/metadata-guard.js +78 -0
  52. package/src/ws-channel/policy-offline.js +40 -0
  53. package/src/ws-channel/transit-guard.js +55 -0
  54. package/test/contract/la-protocol-v2/vectors/ack.vectors.json +149 -0
  55. package/test/contract/la-protocol-v2/vectors/alert.vectors.json +101 -0
  56. package/test/contract/la-protocol-v2/vectors/approval.decision.vectors.json +165 -0
  57. package/test/contract/la-protocol-v2/vectors/audit.event.vectors.json +156 -0
  58. package/test/contract/la-protocol-v2/vectors/config.update.vectors.json +189 -0
  59. package/test/contract/la-protocol-v2/vectors/content.request.vectors.json +325 -0
  60. package/test/contract/la-protocol-v2/vectors/content.response.vectors.json +413 -0
  61. package/test/contract/la-protocol-v2/vectors/cross-type.vectors.json +260 -0
  62. package/test/contract/la-protocol-v2/vectors/error.vectors.json +179 -0
  63. package/test/contract/la-protocol-v2/vectors/heartbeat.vectors.json +109 -0
  64. package/test/contract/la-protocol-v2/vectors/hello.vectors.json +224 -0
  65. package/test/contract/la-protocol-v2/vectors/incident.candidate.vectors.json +186 -0
  66. package/test/contract/la-protocol-v2/vectors/job.assign.vectors.json +631 -0
  67. package/test/contract/la-protocol-v2/vectors/job.cancel.vectors.json +133 -0
  68. package/test/contract/la-protocol-v2/vectors/job.event.vectors.json +233 -0
  69. package/test/contract/la-protocol-v2/vectors/job.result.vectors.json +137 -0
  70. package/test/contract/la-protocol-v2/vectors/log.stats.vectors.json +137 -0
  71. package/test/contract/la-protocol-v2/vectors/ready.vectors.json +177 -0
  72. package/test/contract/la-protocol-v2/vectors/register.vectors.json +399 -0
  73. package/test/contract/la-protocol-v2/vectors/scan.upload.vectors.json +137 -0
  74. package/test/contract/la-protocol-v2/vectors/scan.uploaded.vectors.json +101 -0
  75. package/test/contract/la-protocol-v2/vectors/traces/invalid-agent-mismatch.json +140 -0
  76. package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-after-last.json +200 -0
  77. package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-byte-length-mismatch.json +174 -0
  78. package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-last-flag.json +200 -0
  79. package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-out-of-order.json +200 -0
  80. package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-total-changes.json +200 -0
  81. package/test/contract/la-protocol-v2/vectors/traces/invalid-clock-skew.json +46 -0
  82. package/test/contract/la-protocol-v2/vectors/traces/invalid-event-before-ready.json +72 -0
  83. package/test/contract/la-protocol-v2/vectors/traces/invalid-first-frame-not-register.json +48 -0
  84. package/test/contract/la-protocol-v2/vectors/traces/invalid-frame-over-48kib.json +141 -0
  85. package/test/contract/la-protocol-v2/vectors/traces/invalid-la-sends-job-assign.json +173 -0
  86. package/test/contract/la-protocol-v2/vectors/traces/invalid-params-raw-differs-from-payload.json +172 -0
  87. package/test/contract/la-protocol-v2/vectors/traces/invalid-payload-above-limit.json +174 -0
  88. package/test/contract/la-protocol-v2/vectors/traces/invalid-resume-restarts-seq.json +144 -0
  89. package/test/contract/la-protocol-v2/vectors/traces/invalid-seq-gap.json +166 -0
  90. package/test/contract/la-protocol-v2/vectors/traces/invalid-seq-repeated.json +166 -0
  91. package/test/contract/la-protocol-v2/vectors/traces/invalid-sig-tenant-mismatch.json +172 -0
  92. package/test/contract/la-protocol-v2/vectors/traces/invalid-signature-warn-with-enforce-required.json +77 -0
  93. package/test/contract/la-protocol-v2/vectors/traces/invalid-tenant-mismatch.json +140 -0
  94. package/test/contract/la-protocol-v2/vectors/traces/invalid-type-outside-allowlist.json +140 -0
  95. package/test/contract/la-protocol-v2/vectors/traces/valid-content-chunks-in-order.json +224 -0
  96. package/test/contract/la-protocol-v2/vectors/traces/valid-handshake-and-job.json +266 -0
  97. package/test/contract/la-protocol-v2/vectors/traces/valid-resume-continues-seq.json +168 -0
  98. package/test/contract/la-protocol-v2/vectors/traces/valid-retransmit-is-idempotent.json +193 -0
  99. package/test/contract/la-protocol-v2/vectors/webhook.forward.vectors.json +170 -0
  100. package/test/la-protocol-v2/contract.test.js +131 -0
  101. package/test/la-protocol-v2/helpers/la-rig.js +83 -0
  102. package/test/la-protocol-v2/helpers/reference-cp.js +349 -0
  103. package/test/la-protocol-v2/helpers/spawn-agent.js +35 -0
  104. package/test/la-protocol-v2/link.test.js +141 -0
  105. package/test/la-protocol-v2/session-inbound.test.js +279 -0
  106. package/test/la-protocol-v2/session-outbound.test.js +382 -0
  107. package/test/la-protocol-v2/traces.test.js +270 -0
  108. package/test/la-protocol-v2/tunnel-e2e.test.js +124 -0
  109. package/test/ws-channel/allowlist.test.js +67 -0
  110. package/test/ws-channel/metadata-guard.test.js +104 -0
  111. package/test/ws-channel/policy-offline.test.js +99 -0
  112. package/test/ws-channel/tunnel-e2e.test.js +88 -0
  113. package/tools/graphify-validation/extras/README.md +19 -0
  114. package/tools/graphify-validation/extras/bymethod.py +35 -0
  115. package/tools/graphify-validation/extras/docker/Dockerfile +16 -0
  116. package/tools/graphify-validation/extras/docker/inner.sh +7 -0
  117. package/tools/graphify-validation/extras/g10.py +59 -0
  118. package/tools/graphify-validation/extras/g3aug.py +49 -0
  119. package/tools/graphify-validation/extras/hybrid.py +34 -0
  120. package/tools/graphify-validation/extras/truth2.py +44 -0
  121. package/tools/interop/LaV2NodeInteropTest.java +266 -0
  122. package/tools/interop/README.md +21 -0
  123. package/tools/interop/la-interop-client.mjs +97 -0
@@ -0,0 +1,650 @@
1
+ /**
2
+ * session.js - the Local Agent side of protocol v2 (F3-05b). Contract: docs/contracts/la-protocol-v2 of
3
+ * vh-api-gateway, copied to ./contract (CONTRACT_SOURCE.json says which commit). Design: vault
4
+ * "Fase 3 Desenho Tecnico 2026-10-28" sec. 4.2. Gateway side: LaV2Session (F3-05a).
5
+ *
6
+ * One V2Session lives as long as the tunnel and survives dropped sockets (resume, 90 s on the gateway):
7
+ *
8
+ * attach(transport) -> register (+ resume{sessionId,lastRecvSeq}) then hello; CP answers `ready`
9
+ * handleText(text) -> inbound pipeline, in the SAME order as the gateway's rules:
10
+ * size, parse (duplicate keys refused), allowlist, schema, tenant, agent,
11
+ * direction, handshake state, signature, seq, handler
12
+ * enqueue/send -> outbound: allowlist + direction + schema (Ajv, strict) BEFORE a byte is written,
13
+ * seq per direction, piggy-backed ack, at most `maxInFlight` frames unacked
14
+ * (the rest wait in a bounded queue = backpressure), retransmission after
15
+ * ackTimeoutMs (same seq and msgId, at most maxRetries), dedupe by seq+msgId
16
+ *
17
+ * What it never does: put frame content in a log, a metric, an error frame or an exception message;
18
+ * execute anything itself (handlers are registered by type; with none the frame is acked `rejected`);
19
+ * read the tenant from a payload (tenantId/agentId are the configured ones).
20
+ */
21
+ import crypto from "node:crypto";
22
+ import { EventEmitter } from "node:events";
23
+ import { loadContract } from "./contract.js";
24
+ import { ProtocolError, closeCodeOf, isFatal, isRetryable } from "./errors.js";
25
+ import { parseStrict } from "./strict-json.js";
26
+ import { ChunkStreamTracker } from "./chunks.js";
27
+ import { verifyWsEnvelope } from "../utils/request-signing.js";
28
+
29
+ export const STATE = Object.freeze({ IDLE: "idle", AWAIT_READY: "await_ready", READY: "ready", DETACHED: "detached", CLOSED: "closed" });
30
+
31
+ const WORKSPACE_ID = /^[A-Za-z0-9_.-]{0,64}$/;
32
+ const SHA256 = /^[0-9a-f]{64}$/;
33
+ const CAPABILITY = /^[a-z0-9]+([._-][a-z0-9]+)*$/;
34
+ const DEDUPE_ENTRIES = 512;
35
+ const noop = () => {};
36
+
37
+ const DEFAULTS = Object.freeze({
38
+ handshakeTimeoutMs: 10_000,
39
+ maxInFlight: 8, // LA->CP data frames not yet acked
40
+ maxQueued: 256, // frames waiting for a slot; beyond it enqueue() refuses (rate_limited)
41
+ maxInboundQueue: 128, // CP->LA frames waiting to be processed (the CP never has more than 32 unacked)
42
+ resumeWindowMs: 120_000, // the gateway keeps a detached session 90 s
43
+ handlerAckMs: 2_000, // a handler that has not decided by then is acked `accepted` (it goes on)
44
+ bufferHighWater: 512 * 1024,
45
+ heartbeatMs: null, // tests only: overrides ready.limits.heartbeatSeconds
46
+ });
47
+
48
+ const isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
49
+
50
+ function deepEqual(a, b) {
51
+ if (a === b) return true;
52
+ if (typeof a !== typeof b || a === null || b === null || typeof a !== "object") return false;
53
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
54
+ if (Array.isArray(a)) return a.length === b.length && a.every((x, i) => deepEqual(x, b[i]));
55
+ const ka = Object.keys(a);
56
+ const kb = Object.keys(b);
57
+ return ka.length === kb.length && ka.every((k) => Object.hasOwn(b, k) && deepEqual(a[k], b[k]));
58
+ }
59
+
60
+ export class V2Session extends EventEmitter {
61
+ /**
62
+ * @param {object} o
63
+ * @param {string} o.tenantId lower-case UUID of the tenant (configured, never read from a payload)
64
+ * @param {string} o.agentId lower-case UUID of the agent authorization
65
+ * @param {string} [o.workspaceId] workspace of this agent for the envelope ("" when unknown)
66
+ * @param {{mode:string}} o.signing the signing config of request-signing.js (mode + trusted keys + nonce cache)
67
+ * @param {string} o.laVersion semver
68
+ * @param {string[]} [o.capabilities]
69
+ * @param {() => Array<{workspaceId:string,pathSha256?:string}>} [o.getWorkspaces]
70
+ * @param {() => {diskFreeBytes?:number, graphify?:object}} [o.getHealth]
71
+ * @param {{info:Function,warn:Function}} [o.log]
72
+ * @param {() => number} [o.now]
73
+ * @param {object} [o.options] overrides of DEFAULTS
74
+ */
75
+ constructor(o) {
76
+ super();
77
+ this.contract = loadContract();
78
+ this.tenantId = o.tenantId;
79
+ this.agentId = o.agentId;
80
+ this.workspaceId = o.workspaceId || "";
81
+ this.signing = o.signing;
82
+ this.laVersion = o.laVersion;
83
+ this.capabilities = [...new Set(o.capabilities || [])].filter((c) => CAPABILITY.test(c) && c.length <= 48).sort();
84
+ this.getWorkspaces = o.getWorkspaces || (() => []);
85
+ this.getHealth = o.getHealth || (() => ({}));
86
+ this.log = o.log || { info: noop, warn: noop };
87
+ this.now = o.now || Date.now;
88
+ this.opts = { ...DEFAULTS, ...(o.options || {}) };
89
+
90
+ const L = this.contract.limits;
91
+ this.limits = {
92
+ maxFrameBytes: L.maxFrameBytes, chunkRawBytes: L.chunkRawBytes, maxChunks: L.maxChunks,
93
+ ackTimeoutMs: L.ackTimeoutMs, maxRetries: L.maxRetries, heartbeatSeconds: L.heartbeatSeconds,
94
+ };
95
+ this.state = STATE.IDLE;
96
+ this.sessionId = null; // set by `ready`
97
+ this.everReady = false;
98
+ this.sendSeq = 0; // last LA->CP data seq assigned
99
+ this.lastRecvSeq = 0; // highest contiguous CP->LA data seq received
100
+ this.recvRing = new Map(); // seq -> msgId of the last DEDUPE_ENTRIES received
101
+ this.unacked = new Map(); // seq -> outbound record, in seq order
102
+ this.waiters = []; // frames waiting for a window slot (FIFO)
103
+ this.handlers = new Map(); // type -> fn(ctx) -> {accept:true}|{reject:code}
104
+ this.tracker = new ChunkStreamTracker(this.limits);
105
+ this.transport = null;
106
+ this.inbound = Promise.resolve();
107
+ this.inboundDepth = 0;
108
+ this.timers = { handshake: null, heartbeat: null, expire: null, drain: null };
109
+ this.stats = {
110
+ framesIn: 0, framesOut: 0, dataOut: 0, retransmits: 0, duplicatesIn: 0, rejectedIn: {}, ackedOut: 0,
111
+ nonText: 0, unverified: 0,
112
+ };
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------------------- public API
116
+
117
+ /** Registers the handler of a CP->LA data type. Without one the frame is acked `rejected` (protocol_unsupported). */
118
+ setHandler(type, fn) {
119
+ if (!this.contract.canReceive(type) || this.contract.typeInfo(type).control) throw new ProtocolError("unknown_type");
120
+ this.handlers.set(type, fn);
121
+ }
122
+
123
+ /** A new socket is open: starts (or resumes) the handshake. */
124
+ attach(transport) {
125
+ if (this.state === STATE.CLOSED) throw new ProtocolError("agent_offline", "session closed");
126
+ this.#cancelTimer("expire");
127
+ this.#cancelTimer("handshake");
128
+ this.#stopHeartbeat();
129
+ this.transport = transport;
130
+ this.state = STATE.AWAIT_READY;
131
+ for (const rec of this.unacked.values()) this.#cancelRecTimer(rec);
132
+ const payload = {
133
+ protocol: 2,
134
+ laVersion: this.laVersion,
135
+ capabilities: this.capabilities,
136
+ signatureMode: this.signing.mode,
137
+ workspaces: this.#workspaces(),
138
+ ...(this.everReady && this.sessionId ? { resume: { sessionId: this.sessionId, lastRecvSeq: this.lastRecvSeq } } : {}),
139
+ };
140
+ this.#sendControl("register", payload);
141
+ const health = this.getHealth() || {};
142
+ const hello = {
143
+ laVersion: this.laVersion,
144
+ capabilities: this.capabilities,
145
+ signatureMode: this.signing.mode,
146
+ clockEpochMs: this.now(),
147
+ diskFreeBytes: Number.isSafeInteger(health.diskFreeBytes) && health.diskFreeBytes >= 0 ? health.diskFreeBytes : 0,
148
+ queueDepth: Math.min(this.unacked.size + this.waiters.length, 100000),
149
+ inflight: Math.min(this.unacked.size, 10000),
150
+ ...(health.graphify ? { graphify: health.graphify } : {}),
151
+ };
152
+ this.#sendControl("hello", hello);
153
+ this.timers.handshake = this.#timer(() => {
154
+ this.timers.handshake = null;
155
+ if (this.state === STATE.AWAIT_READY && this.transport === transport) this.emit("handshake-timeout");
156
+ }, this.opts.handshakeTimeoutMs);
157
+ }
158
+
159
+ /** The socket is gone. A session that was ready stays resumable for resumeWindowMs. */
160
+ detach(transport) {
161
+ if (transport !== this.transport || this.state === STATE.CLOSED) return;
162
+ this.#onTransportGone();
163
+ }
164
+
165
+ /** Gives up v2 on the current connection (fallback to v1): nothing of this session is resumable. */
166
+ abandon() {
167
+ this.#cancelTimer("handshake");
168
+ this.#teardown("agent_offline");
169
+ this.transport = null;
170
+ this.state = STATE.IDLE;
171
+ }
172
+
173
+ /** Process shutdown. */
174
+ close() {
175
+ this.#teardown("agent_offline");
176
+ this.transport = null;
177
+ this.state = STATE.CLOSED;
178
+ this.removeAllListeners();
179
+ }
180
+
181
+ /** One CP->LA text frame (call from the socket 'message' event). Never throws. */
182
+ handleText(text, transport = this.transport, isBinary = false) {
183
+ if (this.state === STATE.CLOSED || transport !== this.transport || !this.transport) return;
184
+ if (isBinary) { this.stats.nonText++; return; }
185
+ if (this.inboundDepth >= this.opts.maxInboundQueue) { this.#refuse("rate_limited"); return; }
186
+ this.inboundDepth++;
187
+ this.inbound = this.inbound
188
+ .then(() => this.#process(text, transport))
189
+ .catch(() => this.#refuse("internal"))
190
+ .finally(() => { this.inboundDepth--; });
191
+ }
192
+
193
+ /**
194
+ * Queues one LA->CP data frame. Resolves, once the frame has been written, with {seq, msgId, acked};
195
+ * `acked` settles when the CP acked it (rejects with ProtocolError: rejected ack, timeout, reset).
196
+ * The promise itself rejects BEFORE anything is written when the frame is not allowed
197
+ * (unknown_type, wrong_direction, schema_violation, frame_too_large, chunk_*, not_ready, rate_limited).
198
+ */
199
+ enqueue(type, payload, { requestId, workspaceId, chunk } = {}) {
200
+ let prepared;
201
+ try {
202
+ prepared = this.#prepare(type, payload, { requestId, workspaceId, chunk });
203
+ } catch (e) {
204
+ return Promise.reject(e);
205
+ }
206
+ return new Promise((resolve, reject) => {
207
+ this.waiters.push({ ...prepared, resolve, reject });
208
+ this.#pump();
209
+ });
210
+ }
211
+
212
+ /** enqueue() + wait for the ack. */
213
+ async send(type, payload, opts) {
214
+ const h = await this.enqueue(type, payload, opts);
215
+ await h.acked;
216
+ }
217
+
218
+ /** Drops the frames of a request that are still waiting for a slot (cancellation of a chunk stream). */
219
+ cancelWaiting(requestId) {
220
+ const keep = [];
221
+ for (const w of this.waiters) {
222
+ if (w.requestId === requestId) {
223
+ if (w.type === "content.response") this.tracker.forget(requestId);
224
+ w.reject(new ProtocolError("agent_offline", "cancelled"));
225
+ } else keep.push(w);
226
+ }
227
+ this.waiters = keep;
228
+ }
229
+
230
+ get snapshot() {
231
+ return { state: this.state, sessionId: this.sessionId, sendSeq: this.sendSeq, lastRecvSeq: this.lastRecvSeq,
232
+ unacked: this.unacked.size, waiting: this.waiters.length, limits: { ...this.limits }, stats: { ...this.stats } };
233
+ }
234
+
235
+ // ---------------------------------------------------------------------------------------- outbound
236
+
237
+ #workspaces() {
238
+ let list = [];
239
+ try { list = this.getWorkspaces() || []; } catch { list = []; }
240
+ const out = [];
241
+ for (const w of list) {
242
+ if (!w || typeof w.workspaceId !== "string" || !WORKSPACE_ID.test(w.workspaceId)) continue;
243
+ out.push({ workspaceId: w.workspaceId, ...(typeof w.pathSha256 === "string" && SHA256.test(w.pathSha256) ? { pathSha256: w.pathSha256 } : {}) });
244
+ if (out.length >= 64) break;
245
+ }
246
+ return out;
247
+ }
248
+
249
+ #envelope(type, seq, { requestId, workspaceId, chunk, ack } = {}) {
250
+ return {
251
+ schemaVersion: 2,
252
+ type,
253
+ msgId: crypto.randomUUID(),
254
+ seq,
255
+ ...(ack ? { ack } : {}),
256
+ ...(requestId ? { requestId } : {}),
257
+ tenantId: this.tenantId,
258
+ agentId: this.agentId,
259
+ workspaceId: workspaceId ?? this.workspaceId,
260
+ ts: new Date(this.now()).toISOString(),
261
+ ...(chunk ? { chunk } : {}),
262
+ };
263
+ }
264
+
265
+ /** Control frames (seq 0): register hello ack error. Validated like everything else; a bug here is an `internal` error. */
266
+ #sendControl(type, payload, extra) {
267
+ const frame = { ...this.#envelope(type, 0, extra), payload };
268
+ const v = this.contract.validateFrame(frame);
269
+ if (!v.ok) {
270
+ this.log.warn(`[la-v2] internal: control frame ${type} failed the contract (${v.code})`);
271
+ throw new ProtocolError("internal", `control frame ${type}`);
272
+ }
273
+ this.#write(JSON.stringify(frame));
274
+ return frame;
275
+ }
276
+
277
+ #write(text) {
278
+ if (!this.transport) return false;
279
+ this.stats.framesOut++;
280
+ try { this.transport.send(text); return true; } catch { return false; }
281
+ }
282
+
283
+ /** Everything that can be refused before a frame is queued (so the caller gets the error, nothing is written). */
284
+ #prepare(type, payload, { requestId, workspaceId, chunk }) {
285
+ if (this.state === STATE.CLOSED) throw new ProtocolError("agent_offline");
286
+ if (!this.contract.typeInfo(type)) throw new ProtocolError("unknown_type");
287
+ if (!this.contract.canSend(type)) throw new ProtocolError("wrong_direction");
288
+ if (this.contract.typeInfo(type).control) throw new ProtocolError("wrong_direction", "control frames belong to the session");
289
+ if (this.state !== STATE.READY && !(this.everReady && (this.state === STATE.DETACHED || this.state === STATE.AWAIT_READY))) {
290
+ throw new ProtocolError("not_ready");
291
+ }
292
+ if (this.waiters.length >= this.opts.maxQueued) throw new ProtocolError("rate_limited", "send queue full");
293
+ // dry-run with a placeholder seq: the real one is assigned when the frame leaves the queue
294
+ const probe = { ...this.#envelope(type, 1, { requestId, workspaceId, chunk, ack: this.lastRecvSeq }), payload };
295
+ const text = JSON.stringify(probe);
296
+ if (Buffer.byteLength(text, "utf8") + 24 > this.limits.maxFrameBytes) throw new ProtocolError("frame_too_large");
297
+ const v = this.contract.validateFrame(probe);
298
+ if (!v.ok) throw new ProtocolError(v.code);
299
+ if (type === "content.response") {
300
+ const decoded = Buffer.from(payload.data, "base64").length;
301
+ const bad = this.tracker.accept(requestId, chunk, decoded);
302
+ if (bad) throw new ProtocolError(bad);
303
+ }
304
+ return { type, payload, requestId, workspaceId, chunk };
305
+ }
306
+
307
+ #canTransmit() {
308
+ if (this.state !== STATE.READY || !this.transport) return false;
309
+ if (this.unacked.size >= this.opts.maxInFlight) return false;
310
+ const buffered = typeof this.transport.bufferedAmount === "function" ? this.transport.bufferedAmount() : 0;
311
+ if (buffered > this.opts.bufferHighWater) {
312
+ if (!this.timers.drain) this.timers.drain = this.#timer(() => { this.timers.drain = null; this.#pump(); }, 20);
313
+ return false;
314
+ }
315
+ return true;
316
+ }
317
+
318
+ #pump() {
319
+ while (this.waiters.length > 0 && this.#canTransmit()) {
320
+ const w = this.waiters.shift();
321
+ const seq = ++this.sendSeq;
322
+ const frame = { ...this.#envelope(w.type, seq, { requestId: w.requestId, workspaceId: w.workspaceId, chunk: w.chunk, ack: this.lastRecvSeq }), payload: w.payload };
323
+ const text = JSON.stringify(frame);
324
+ let settle;
325
+ const acked = new Promise((resolve, reject) => { settle = { resolve, reject }; });
326
+ acked.catch(noop); // the caller may ignore it; a rejection must never be "unhandled"
327
+ const rec = { seq, msgId: frame.msgId, type: w.type, requestId: w.requestId, text, retries: 0, timer: null, settle };
328
+ this.unacked.set(seq, rec);
329
+ this.stats.dataOut++;
330
+ this.#write(text);
331
+ this.#armRecTimer(rec);
332
+ w.resolve({ seq, msgId: frame.msgId, acked });
333
+ }
334
+ }
335
+
336
+ #armRecTimer(rec) {
337
+ this.#cancelRecTimer(rec);
338
+ rec.timer = this.#timer(() => this.#onAckTimeout(rec), this.limits.ackTimeoutMs);
339
+ }
340
+
341
+ #cancelRecTimer(rec) {
342
+ if (rec.timer) { clearTimeout(rec.timer); rec.timer = null; }
343
+ }
344
+
345
+ #onAckTimeout(rec) {
346
+ rec.timer = null;
347
+ if (this.state !== STATE.READY || this.unacked.get(rec.seq) !== rec) return;
348
+ if (rec.retries >= this.limits.maxRetries) {
349
+ // The CP never confirmed: the caller gets `timeout`, the frame stays for a resume, the connection drops.
350
+ rec.settle.reject(new ProtocolError("timeout", "no ack"));
351
+ this.emit("timeout");
352
+ this.#closeTransport(closeCodeOf("timeout"), "ack_timeout");
353
+ return;
354
+ }
355
+ rec.retries++;
356
+ this.stats.retransmits++;
357
+ this.#write(rec.text); // identical bytes: same seq, same msgId
358
+ this.#armRecTimer(rec);
359
+ }
360
+
361
+ #ackUpTo(n) {
362
+ if (!Number.isSafeInteger(n) || n <= 0) return;
363
+ for (const [seq, rec] of this.unacked) {
364
+ if (seq > n) break;
365
+ this.#cancelRecTimer(rec);
366
+ this.unacked.delete(seq);
367
+ this.stats.ackedOut++;
368
+ rec.settle.resolve();
369
+ }
370
+ this.#pump();
371
+ }
372
+
373
+ #rejectAcked(seq, code) {
374
+ const rec = this.unacked.get(seq);
375
+ if (!rec) return;
376
+ this.#cancelRecTimer(rec);
377
+ this.unacked.delete(seq);
378
+ rec.settle.reject(new ProtocolError(code, "rejected by the control plane"));
379
+ this.#pump();
380
+ }
381
+
382
+ // ---------------------------------------------------------------------------------------- inbound
383
+
384
+ async #process(text, transport) {
385
+ if (this.state === STATE.CLOSED || transport !== this.transport) return;
386
+ this.stats.framesIn++;
387
+ // 1. size (before any parsing)
388
+ if (typeof text !== "string" || Buffer.byteLength(text, "utf8") > this.limits.maxFrameBytes) return this.#refuse("frame_too_large");
389
+ // 2. parse, duplicate keys refused
390
+ let f;
391
+ try { f = parseStrict(text); } catch { return this.#refuse("bad_frame"); }
392
+ if (!isPlainObject(f)) return this.#refuse("bad_frame");
393
+ const ref = { refSeq: Number.isSafeInteger(f.seq) && f.seq > 0 ? f.seq : undefined, refMsgId: typeof f.msgId === "string" && f.msgId.length === 36 ? f.msgId : undefined };
394
+ // 3-4. allowlist + closed schema
395
+ const v = this.contract.validateFrame(f);
396
+ if (!v.ok) return this.#refuse(v.code, ref);
397
+ // 5-6. tenant and agent are the configured ones
398
+ if (f.tenantId !== this.tenantId) return this.#refuse("tenant_mismatch", ref);
399
+ if (f.agentId !== undefined && f.agentId !== this.agentId) return this.#refuse("agent_mismatch", ref);
400
+ // 7. direction
401
+ if (!this.contract.canReceive(f.type)) return this.#refuse("wrong_direction", ref);
402
+ // 8. handshake state
403
+ if (this.state === STATE.AWAIT_READY) {
404
+ if (f.type !== "ready" && f.type !== "error" && f.type !== "ack") return this.#refuse("not_ready", ref);
405
+ } else if (this.state === STATE.READY && f.type === "ready") {
406
+ return this.#refuse("handshake_violation", ref);
407
+ } else if (this.state !== STATE.READY) {
408
+ return;
409
+ }
410
+ // 9. signature of the signed types (ready included)
411
+ const info = this.contract.typeInfo(f.type);
412
+ let params = f.payload;
413
+ if (info.signed) {
414
+ const bad = this.#verifySigned(f);
415
+ if (bad) return this.#refuse(bad, ref);
416
+ if (this.signing.mode !== "enforce") this.stats.unverified++;
417
+ }
418
+ if (f.type === "ready") return this.#onReady(f);
419
+ if (f.type === "ack") return this.#onAck(f);
420
+ if (f.type === "error") return this.#onPeerError(f);
421
+ return this.#onData(f, params, ref);
422
+ }
423
+
424
+ /**
425
+ * paramsRaw must equal payload (what is executed is the signed text), sig must be bound to this
426
+ * frame's tenant and workspace, and the Ed25519 signature must verify (same canonical string as v1:
427
+ * method WS, path = type). Mode off skips the cryptography, warn logs and goes on, enforce refuses.
428
+ */
429
+ #verifySigned(f) {
430
+ let signed;
431
+ try { signed = parseStrict(f.paramsRaw); } catch { return "params_mismatch"; }
432
+ if (!deepEqual(signed, f.payload)) return "params_mismatch";
433
+ if (f.sig.tenantId !== f.tenantId || f.sig.workspaceId !== f.workspaceId) return "tenant_mismatch";
434
+ const out = verifyWsEnvelope({ command: f.type, paramsRaw: f.paramsRaw, sig: f.sig }, this.signing);
435
+ return out.ok ? null : "unauthorized";
436
+ }
437
+
438
+ #onReady(f) {
439
+ const p = f.payload;
440
+ if (p.resumed) {
441
+ // only a session we offered to resume, and only a position we can have reached
442
+ if (!this.everReady || p.sessionId !== this.sessionId || p.peerLastSeq > this.sendSeq) return this.#refuse("handshake_violation");
443
+ }
444
+ const lim = p.limits;
445
+ const L = this.contract.limits;
446
+ this.limits = {
447
+ maxFrameBytes: Math.min(lim.maxFrameBytes, L.maxFrameBytes),
448
+ chunkRawBytes: Math.min(lim.chunkRawBytes, L.chunkRawBytes),
449
+ maxChunks: Math.min(lim.maxChunks, L.maxChunks),
450
+ ackTimeoutMs: lim.ackTimeoutMs,
451
+ maxRetries: Math.min(lim.maxRetries, L.maxRetries),
452
+ heartbeatSeconds: lim.heartbeatSeconds,
453
+ };
454
+ this.tracker.chunkRawBytes = this.limits.chunkRawBytes;
455
+ this.tracker.maxChunks = this.limits.maxChunks;
456
+ this.#cancelTimer("handshake");
457
+ if (!p.resumed) this.#resetSession();
458
+ this.sessionId = p.sessionId;
459
+ this.state = STATE.READY;
460
+ this.everReady = true;
461
+ if (p.resumed) {
462
+ this.#ackUpTo(p.peerLastSeq);
463
+ for (const rec of this.unacked.values()) { // what the CP never saw goes again, same seq and msgId
464
+ rec.retries = 0;
465
+ this.stats.retransmits++;
466
+ this.#write(rec.text);
467
+ this.#armRecTimer(rec);
468
+ }
469
+ }
470
+ this.#startHeartbeat();
471
+ this.log.info(`[la-v2] ready resumed=${p.resumed} session=${p.sessionId.slice(0, 8)} peerLastSeq=${p.peerLastSeq}`);
472
+ this.emit("ready", { resumed: p.resumed, sessionId: p.sessionId, limits: { ...this.limits } });
473
+ this.#pump();
474
+ }
475
+
476
+ #onAck(f) {
477
+ if (this.state !== STATE.READY) return;
478
+ const { ackSeq, status, errorCode } = f.payload;
479
+ if (ackSeq > this.sendSeq) return; // acks a frame we never sent: ignored
480
+ if (status === "rejected") {
481
+ this.#ackUpTo(ackSeq - 1);
482
+ this.#rejectAcked(ackSeq, errorCode || "internal");
483
+ } else {
484
+ this.#ackUpTo(ackSeq);
485
+ }
486
+ }
487
+
488
+ #onPeerError(f) {
489
+ const p = f.payload;
490
+ this.stats.rejectedIn[`peer_${p.code}`] = (this.stats.rejectedIn[`peer_${p.code}`] || 0) + 1;
491
+ this.log.warn(`[la-v2] control plane refused a frame: ${p.code}`);
492
+ if (this.state === STATE.AWAIT_READY) {
493
+ if (p.code === "protocol_unsupported") this.emit("unsupported");
494
+ else this.emit("rejected", p.code);
495
+ return;
496
+ }
497
+ if (p.refSeq !== undefined) this.#rejectAcked(p.refSeq, p.code);
498
+ this.emit("peer-error", p.code);
499
+ }
500
+
501
+ async #onData(f, params, ref) {
502
+ const { seq, msgId } = f;
503
+ if (seq <= this.lastRecvSeq) {
504
+ if (this.recvRing.get(seq) === msgId) { // a retransmission (our ack was lost): re-ack, never execute twice
505
+ this.stats.duplicatesIn++;
506
+ this.#sendAck(seq, "duplicate");
507
+ return;
508
+ }
509
+ return this.#refuse("seq_replayed", ref);
510
+ }
511
+ if (seq !== this.lastRecvSeq + 1) return this.#refuse("seq_gap", ref);
512
+ this.lastRecvSeq = seq;
513
+ this.recvRing.set(seq, msgId);
514
+ while (this.recvRing.size > DEDUPE_ENTRIES) this.recvRing.delete(this.recvRing.keys().next().value);
515
+ if (f.ack !== undefined) this.#ackUpTo(f.ack);
516
+
517
+ const handler = this.handlers.get(f.type);
518
+ if (!handler) { this.#sendAck(seq, "rejected", "protocol_unsupported"); return; }
519
+ const ctx = Object.freeze({ type: f.type, seq, msgId, requestId: f.requestId, workspaceId: f.workspaceId, params });
520
+ let verdict;
521
+ let slow;
522
+ try {
523
+ const run = Promise.resolve().then(() => handler(ctx));
524
+ run.catch(noop);
525
+ // a handler that has not decided in handlerAckMs is acked `accepted` (it goes on in the background)
526
+ verdict = await Promise.race([run, new Promise((r) => { slow = this.#timer(() => r({ accept: true }), this.opts.handlerAckMs); })]);
527
+ } catch {
528
+ verdict = { reject: "internal" };
529
+ } finally {
530
+ clearTimeout(slow);
531
+ }
532
+ if (verdict && verdict.reject) this.#sendAck(seq, "rejected", verdict.reject);
533
+ else this.#sendAck(seq, "accepted");
534
+ }
535
+
536
+ #sendAck(ackSeq, status, errorCode) {
537
+ if (this.state !== STATE.READY || !this.transport) return;
538
+ const code = errorCode && this.contract.errorCodes.includes(errorCode) ? errorCode : undefined;
539
+ try {
540
+ this.#sendControl("ack", { ackSeq, status, ...(status === "rejected" ? { errorCode: code || "internal" } : {}) });
541
+ } catch { /* already logged */ }
542
+ }
543
+
544
+ // ---------------------------------------------------------------------------------------- errors, close
545
+
546
+ /** Refuses a frame: error frame with the wire code only (never an echo), close when the code is fatal. */
547
+ #refuse(code, ref = {}) {
548
+ this.stats.rejectedIn[code] = (this.stats.rejectedIn[code] || 0) + 1;
549
+ this.log.warn(`[la-v2] refused an inbound frame: ${code}`);
550
+ const fatal = isFatal(code);
551
+ const payload = {
552
+ code,
553
+ retryable: isRetryable(code),
554
+ ...(ref.refSeq !== undefined ? { refSeq: ref.refSeq } : {}),
555
+ ...(ref.refMsgId ? { refMsgId: ref.refMsgId } : {}),
556
+ ...(fatal ? { closeCode: closeCodeOf(code) } : {}),
557
+ };
558
+ try { this.#sendControl("error", payload); } catch { /* logged */ }
559
+ this.emit("refused", code);
560
+ if (fatal) this.#closeTransport(closeCodeOf(code), code);
561
+ }
562
+
563
+ #closeTransport(code, reason) {
564
+ const t = this.transport;
565
+ if (t) { try { t.close(code, reason); } catch { /* socket already gone */ } }
566
+ this.#onTransportGone();
567
+ }
568
+
569
+ #onTransportGone() {
570
+ this.#cancelTimer("handshake");
571
+ this.#stopHeartbeat();
572
+ for (const rec of this.unacked.values()) this.#cancelRecTimer(rec);
573
+ this.transport = null;
574
+ if (!this.everReady) { this.state = STATE.IDLE; this.#failAll("agent_offline"); return; }
575
+ this.state = STATE.DETACHED;
576
+ this.#cancelTimer("expire");
577
+ this.timers.expire = this.#timer(() => { this.timers.expire = null; this.#teardown("agent_offline"); this.state = STATE.IDLE; }, this.opts.resumeWindowMs);
578
+ this.emit("detached");
579
+ }
580
+
581
+ /** Nothing of the session survives: every pending frame fails and the next register will not resume. */
582
+ #teardown(code) {
583
+ this.#cancelTimer("expire");
584
+ this.#cancelTimer("handshake");
585
+ this.#cancelTimer("drain");
586
+ this.#stopHeartbeat();
587
+ this.#failAll(code);
588
+ this.sessionId = null;
589
+ this.everReady = false;
590
+ this.sendSeq = 0;
591
+ this.lastRecvSeq = 0;
592
+ this.recvRing.clear();
593
+ this.tracker.clear();
594
+ }
595
+
596
+ /** The CP did not resume us: restart both numberings and fail what was in flight (README: "resumed:false"). */
597
+ #resetSession() {
598
+ this.#failAll("agent_offline", { keepWaiting: true });
599
+ this.sendSeq = 0;
600
+ this.lastRecvSeq = 0;
601
+ this.recvRing.clear();
602
+ this.tracker.clear();
603
+ }
604
+
605
+ #failAll(code, { keepWaiting = false } = {}) {
606
+ for (const rec of this.unacked.values()) { this.#cancelRecTimer(rec); rec.settle.reject(new ProtocolError(code)); }
607
+ this.unacked.clear();
608
+ const keep = [];
609
+ for (const w of this.waiters) {
610
+ // a content stream of the old session cannot continue in a new one (the CP would see chunk n without chunk 0)
611
+ if (!keepWaiting || w.type === "content.response") w.reject(new ProtocolError(code));
612
+ else keep.push(w);
613
+ }
614
+ this.waiters = keep;
615
+ }
616
+
617
+ // ---------------------------------------------------------------------------------------- heartbeat, timers
618
+
619
+ #startHeartbeat() {
620
+ this.#stopHeartbeat();
621
+ const ms = this.opts.heartbeatMs || Math.max(1000, this.limits.heartbeatSeconds * 1000);
622
+ this.timers.heartbeat = setInterval(() => {
623
+ if (this.state !== STATE.READY || this.unacked.size >= this.opts.maxInFlight) return;
624
+ const h = this.getHealth() || {};
625
+ this.enqueue("heartbeat", {
626
+ signatureMode: this.signing.mode,
627
+ clockEpochMs: this.now(),
628
+ diskFreeBytes: Number.isSafeInteger(h.diskFreeBytes) && h.diskFreeBytes >= 0 ? h.diskFreeBytes : 0,
629
+ queueDepth: Math.min(this.unacked.size + this.waiters.length, 100000),
630
+ inflight: Math.min(this.unacked.size, 10000),
631
+ ...(h.graphify ? { graphify: h.graphify } : {}),
632
+ }).catch(noop);
633
+ }, ms);
634
+ this.timers.heartbeat.unref?.();
635
+ }
636
+
637
+ #stopHeartbeat() {
638
+ if (this.timers.heartbeat) { clearInterval(this.timers.heartbeat); this.timers.heartbeat = null; }
639
+ }
640
+
641
+ // Not unref'd: an unacked frame, a pending handshake and a resume window are work the process is waiting for
642
+ // (a closed or torn-down session clears every one of them, so none outlives close()).
643
+ #timer(fn, ms) {
644
+ return setTimeout(fn, ms);
645
+ }
646
+
647
+ #cancelTimer(name) {
648
+ if (this.timers[name]) { clearTimeout(this.timers[name]); this.timers[name] = null; }
649
+ }
650
+ }