deepdebug-local-agent 1.7.0 → 1.9.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 (71) hide show
  1. package/.claude/context.md +2 -1
  2. package/CLAUDE.md +1 -1
  3. package/docs/la-protocol-v2.md +27 -4
  4. package/env.example +12 -0
  5. package/package.json +1 -1
  6. package/scripts/incident-parity/Gen.java +256 -0
  7. package/scripts/incident-parity/LogEntry.java +17 -0
  8. package/scripts/incident-parity/extract.py +51 -0
  9. package/scripts/incident-parity/gen.sh +32 -0
  10. package/src/incident-detector/README.md +52 -0
  11. package/src/incident-detector/aggregator.js +78 -0
  12. package/src/incident-detector/categorize.js +48 -0
  13. package/src/incident-detector/config.js +95 -0
  14. package/src/incident-detector/detector.js +93 -0
  15. package/src/incident-detector/emitter.js +164 -0
  16. package/src/incident-detector/index.js +44 -0
  17. package/src/incident-detector/java-compat.js +66 -0
  18. package/src/incident-detector/normalize.js +209 -0
  19. package/src/incident-detector/parse.js +114 -0
  20. package/src/incident-detector/sources/file-source.js +113 -0
  21. package/src/incident-detector/sources/loki-source.js +101 -0
  22. package/src/incident-detector/sources/stream-source.js +39 -0
  23. package/src/incident-detector/sources/webhook-source.js +109 -0
  24. package/src/incident-detector/store.js +119 -0
  25. package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +24 -16
  26. package/src/la-protocol-v2/contract/README.md +103 -3
  27. package/src/la-protocol-v2/contract/config.update.schema.json +85 -1
  28. package/src/la-protocol-v2/contract/hello.schema.json +26 -1
  29. package/src/la-protocol-v2/contract/incident.candidate.schema.json +11 -0
  30. package/src/la-protocol-v2/contract/index.json +9 -2
  31. package/src/la-protocol-v2/contract/ready.schema.json +8 -0
  32. package/src/la-protocol-v2/contract/register.schema.json +26 -1
  33. package/src/la-protocol-v2/contract/webhook.forward.schema.json +72 -3
  34. package/src/la-protocol-v2/contract.js +45 -7
  35. package/src/la-protocol-v2/llm-key/config.js +29 -0
  36. package/src/la-protocol-v2/llm-key/handler.js +33 -0
  37. package/src/la-protocol-v2/llm-key/install.js +21 -0
  38. package/src/la-protocol-v2/llm-key/store.js +173 -0
  39. package/src/la-protocol-v2/runtime.js +44 -1
  40. package/src/la-protocol-v2/session.js +47 -13
  41. package/src/la-protocol-v2/webhooks/assembler.js +85 -0
  42. package/src/la-protocol-v2/webhooks/config.js +51 -0
  43. package/src/la-protocol-v2/webhooks/handler.js +125 -0
  44. package/src/la-protocol-v2/webhooks/install.js +22 -0
  45. package/test/contract/la-protocol-v2/vectors/ack.vectors.json +91 -0
  46. package/test/contract/la-protocol-v2/vectors/config.update.vectors.json +1339 -0
  47. package/test/contract/la-protocol-v2/vectors/hello.vectors.json +261 -0
  48. package/test/contract/la-protocol-v2/vectors/incident.candidate.vectors.json +623 -0
  49. package/test/contract/la-protocol-v2/vectors/ready.vectors.json +599 -0
  50. package/test/contract/la-protocol-v2/vectors/register.vectors.json +275 -0
  51. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-after-last.json +186 -0
  52. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-body-hash-changes.json +186 -0
  53. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-last-flag-wrong.json +186 -0
  54. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-non-last-too-short.json +152 -0
  55. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-out-of-order.json +152 -0
  56. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-sha-mismatch.json +186 -0
  57. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-total-changes.json +186 -0
  58. package/test/contract/la-protocol-v2/vectors/traces/valid-webhook-chunks-in-order.json +218 -0
  59. package/test/contract/la-protocol-v2/vectors/webhook.forward.vectors.json +1263 -0
  60. package/test/incident-detector/parity.test.js +66 -0
  61. package/test/incident-detector/parity.vectors.json +2619 -0
  62. package/test/incident-detector/size-model.test.js +127 -0
  63. package/test/incident-detector/sources.test.js +179 -0
  64. package/test/incident-detector/units.test.js +430 -0
  65. package/test/incident-detector/wiring.test.js +165 -0
  66. package/test/la-protocol-v2/contract-v21.test.js +107 -0
  67. package/test/la-protocol-v2/contract.test.js +6 -4
  68. package/test/la-protocol-v2/helpers/reference-cp.js +8 -4
  69. package/test/la-protocol-v2/llm-key.test.js +509 -0
  70. package/test/la-protocol-v2/traces.test.js +3 -3
  71. package/test/la-protocol-v2/webhook-receiver.test.js +702 -0
@@ -32,6 +32,8 @@ const WORKSPACE_ID = /^[A-Za-z0-9_.-]{0,64}$/;
32
32
  const SHA256 = /^[0-9a-f]{64}$/;
33
33
  const CAPABILITY = /^[a-z0-9]+([._-][a-z0-9]+)*$/;
34
34
  const DEDUPE_ENTRIES = 512;
35
+ // Contract 2.1: two stateless rules the handlers apply themselves (non-fatal `rejected` ack, state checked before size, as in the reference model)
36
+ const DEFERRED_RULES = Object.freeze(["webhook-chunk-size", "llmKey-id-sha256"]);
35
37
  const noop = () => {};
36
38
 
37
39
  const DEFAULTS = Object.freeze({
@@ -58,6 +60,8 @@ function deepEqual(a, b) {
58
60
  }
59
61
 
60
62
  export class V2Session extends EventEmitter {
63
+ #peerCaps = new Set();
64
+
61
65
  /**
62
66
  * @param {object} o
63
67
  * @param {string} o.tenantId lower-case UUID of the tenant (configured, never read from a payload)
@@ -66,6 +70,8 @@ export class V2Session extends EventEmitter {
66
70
  * @param {{mode:string}} o.signing the signing config of request-signing.js (mode + trusted keys + nonce cache)
67
71
  * @param {string} o.laVersion semver
68
72
  * @param {string[]} [o.capabilities]
73
+ * @param {() => string[]} [o.dynamicCapabilities] announced in addition at EVERY attach (register and hello of that attach carry the same
74
+ * snapshot): what can change between connections, e.g. `llm.key.held` (contract 2.1 (e)) only while a valid key is held
69
75
  * @param {() => Array<{workspaceId:string,pathSha256?:string}>} [o.getWorkspaces]
70
76
  * @param {() => {diskFreeBytes?:number, graphify?:object}} [o.getHealth]
71
77
  * @param {{info:Function,warn:Function}} [o.log]
@@ -81,6 +87,7 @@ export class V2Session extends EventEmitter {
81
87
  this.signing = o.signing;
82
88
  this.laVersion = o.laVersion;
83
89
  this.capabilities = [...new Set(o.capabilities || [])].filter((c) => CAPABILITY.test(c) && c.length <= 48).sort();
90
+ this.dynamicCapabilities = typeof o.dynamicCapabilities === "function" ? o.dynamicCapabilities : null;
84
91
  this.getWorkspaces = o.getWorkspaces || (() => []);
85
92
  this.getHealth = o.getHealth || (() => ({}));
86
93
  this.log = o.log || { info: noop, warn: noop };
@@ -95,12 +102,13 @@ export class V2Session extends EventEmitter {
95
102
  this.state = STATE.IDLE;
96
103
  this.sessionId = null; // set by `ready`
97
104
  this.everReady = false;
105
+ this.#peerCaps = new Set(); // what the CP announced in `ready.capabilities` (2.1); empty = a 2.0 CP
98
106
  this.sendSeq = 0; // last LA->CP data seq assigned
99
107
  this.lastRecvSeq = 0; // highest contiguous CP->LA data seq received
100
108
  this.recvRing = new Map(); // seq -> msgId of the last DEDUPE_ENTRIES received
101
109
  this.unacked = new Map(); // seq -> outbound record, in seq order
102
110
  this.waiters = []; // frames waiting for a window slot (FIFO)
103
- this.handlers = new Map(); // type -> fn(ctx) -> {accept:true}|{reject:code}
111
+ this.handlers = new Map(); // type -> { fn(ctx) -> {accept:true}|{reject:code}, ackMs }
104
112
  this.tracker = new ChunkStreamTracker(this.limits);
105
113
  this.transport = null;
106
114
  this.inbound = Promise.resolve();
@@ -114,10 +122,25 @@ export class V2Session extends EventEmitter {
114
122
 
115
123
  // ---------------------------------------------------------------------------------------- public API
116
124
 
117
- /** Registers the handler of a CP->LA data type. Without one the frame is acked `rejected` (protocol_unsupported). */
118
- setHandler(type, fn) {
125
+ /**
126
+ * Registers the handler of a CP->LA data type. Without one the frame is acked `rejected` (protocol_unsupported).
127
+ * `opts.ackMs`: how long this handler may take before the session acks `accepted` on its own (default `handlerAckMs`, 2 s). A handler whose ack
128
+ * MEANS something (webhook.forward: the last chunk is acked only after the local delivery) raises it and enforces its own, shorter timeout.
129
+ * Inbound frames are processed one at a time, so a slow handler delays the frames behind it: keep this small (the CP retransmits after 5 s).
130
+ */
131
+ setHandler(type, fn, opts = {}) {
119
132
  if (!this.contract.canReceive(type) || this.contract.typeInfo(type).control) throw new ProtocolError("unknown_type");
120
- this.handlers.set(type, fn);
133
+ const ackMs = Number.isFinite(opts.ackMs) ? Math.min(Math.max(opts.ackMs, 1), 14_000) : null;
134
+ this.handlers.set(type, { fn, ackMs });
135
+ }
136
+
137
+ /** The capabilities of THIS attach: the static ones plus the dynamic ones, sorted, deduplicated, valid names only. */
138
+ #announced() {
139
+ let extra = [];
140
+ try { extra = this.dynamicCapabilities ? this.dynamicCapabilities() : []; } catch { extra = []; }
141
+ const all = new Set([...this.capabilities, ...(Array.isArray(extra) ? extra : [])].filter((c) => typeof c === "string" && CAPABILITY.test(c) && c.length <= 48));
142
+ if (!all.has("llm.key")) all.delete("llm.key.held"); // contract 2.1 (e): `llm.key.held` alone is a schema violation, so it is never sent alone
143
+ return [...all].sort();
121
144
  }
122
145
 
123
146
  /** A new socket is open: starts (or resumes) the handshake. */
@@ -129,10 +152,11 @@ export class V2Session extends EventEmitter {
129
152
  this.transport = transport;
130
153
  this.state = STATE.AWAIT_READY;
131
154
  for (const rec of this.unacked.values()) this.#cancelRecTimer(rec);
155
+ const capabilities = this.#announced();
132
156
  const payload = {
133
157
  protocol: 2,
134
158
  laVersion: this.laVersion,
135
- capabilities: this.capabilities,
159
+ capabilities,
136
160
  signatureMode: this.signing.mode,
137
161
  workspaces: this.#workspaces(),
138
162
  ...(this.everReady && this.sessionId ? { resume: { sessionId: this.sessionId, lastRecvSeq: this.lastRecvSeq } } : {}),
@@ -141,7 +165,7 @@ export class V2Session extends EventEmitter {
141
165
  const health = this.getHealth() || {};
142
166
  const hello = {
143
167
  laVersion: this.laVersion,
144
- capabilities: this.capabilities,
168
+ capabilities,
145
169
  signatureMode: this.signing.mode,
146
170
  clockEpochMs: this.now(),
147
171
  diskFreeBytes: Number.isSafeInteger(health.diskFreeBytes) && health.diskFreeBytes >= 0 ? health.diskFreeBytes : 0,
@@ -227,6 +251,12 @@ export class V2Session extends EventEmitter {
227
251
  this.waiters = keep;
228
252
  }
229
253
 
254
+ /** Did the CP announce this capability in its last `ready` (contract 2.1)? Absent = 2.0 behaviour. */
255
+ peerHas(capability) { return this.#peerCaps.has(capability); }
256
+
257
+ /** A copy of what the CP announced in its last `ready` (changing it changes nothing). */
258
+ get peerCapabilities() { return [...this.#peerCaps]; }
259
+
230
260
  get snapshot() {
231
261
  return { state: this.state, sessionId: this.sessionId, sendSeq: this.sendSeq, lastRecvSeq: this.lastRecvSeq,
232
262
  unacked: this.unacked.size, waiting: this.waiters.length, limits: { ...this.limits }, stats: { ...this.stats } };
@@ -392,7 +422,7 @@ export class V2Session extends EventEmitter {
392
422
  if (!isPlainObject(f)) return this.#refuse("bad_frame");
393
423
  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
424
  // 3-4. allowlist + closed schema
395
- const v = this.contract.validateFrame(f);
425
+ const v = this.contract.validateFrame(f, { skipRules: DEFERRED_RULES });
396
426
  if (!v.ok) return this.#refuse(v.code, ref);
397
427
  // 5-6. tenant and agent are the configured ones
398
428
  if (f.tenantId !== this.tenantId) return this.#refuse("tenant_mismatch", ref);
@@ -453,6 +483,7 @@ export class V2Session extends EventEmitter {
453
483
  };
454
484
  this.tracker.chunkRawBytes = this.limits.chunkRawBytes;
455
485
  this.tracker.maxChunks = this.limits.maxChunks;
486
+ this.#peerCaps = new Set(Array.isArray(p.capabilities) ? p.capabilities : []);
456
487
  this.#cancelTimer("handshake");
457
488
  if (!p.resumed) this.#resetSession();
458
489
  this.sessionId = p.sessionId;
@@ -469,7 +500,7 @@ export class V2Session extends EventEmitter {
469
500
  }
470
501
  this.#startHeartbeat();
471
502
  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 } });
503
+ this.emit("ready", { resumed: p.resumed, sessionId: p.sessionId, limits: { ...this.limits }, capabilities: [...this.#peerCaps] });
473
504
  this.#pump();
474
505
  }
475
506
 
@@ -514,16 +545,18 @@ export class V2Session extends EventEmitter {
514
545
  while (this.recvRing.size > DEDUPE_ENTRIES) this.recvRing.delete(this.recvRing.keys().next().value);
515
546
  if (f.ack !== undefined) this.#ackUpTo(f.ack);
516
547
 
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 });
548
+ const entry = this.handlers.get(f.type);
549
+ if (!entry) { this.#sendAck(seq, "rejected", "protocol_unsupported"); return; }
550
+ const handler = entry.fn;
551
+ const ackMs = entry.ackMs ?? this.opts.handlerAckMs;
552
+ const ctx = Object.freeze({ type: f.type, seq, msgId, requestId: f.requestId, workspaceId: f.workspaceId, params, chunk: f.chunk });
520
553
  let verdict;
521
554
  let slow;
522
555
  try {
523
556
  const run = Promise.resolve().then(() => handler(ctx));
524
557
  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); })]);
558
+ // a handler that has not decided in its ackMs (default handlerAckMs) is acked `accepted` (it goes on in the background)
559
+ verdict = await Promise.race([run, new Promise((r) => { slow = this.#timer(() => r({ accept: true }), ackMs); })]);
527
560
  } catch {
528
561
  verdict = { reject: "internal" };
529
562
  } finally {
@@ -587,6 +620,7 @@ export class V2Session extends EventEmitter {
587
620
  this.#failAll(code);
588
621
  this.sessionId = null;
589
622
  this.everReady = false;
623
+ this.#peerCaps = new Set();
590
624
  this.sendSeq = 0;
591
625
  this.lastRecvSeq = 0;
592
626
  this.recvRing.clear();
@@ -0,0 +1,85 @@
1
+ /**
2
+ * assembler.js - rebuilds a chunked `webhook.forward` in memory (F3-12 consumer, contract 2.1 (b)).
3
+ *
4
+ * The CP sends a body above 9216 bytes as chunks of exactly 16384 raw bytes (the last one is the rest), up to 64 chunks = 1 MiB. Every
5
+ * chunk repeats webhookId, source, contentType, bodyBytes and bodySha256. This class keeps ONE assembly per webhookId, in memory only,
6
+ * with the limits of the contract (index.json) and applies the rules in the order of the reference model (state first, size after):
7
+ *
8
+ * no assembly yet index must be 0 (chunk_out_of_order, also for "a chunk after the one flagged last": that assembly is gone)
9
+ * last flag last must be true only on the last index (chunk_inconsistent)
10
+ * size rule `webhook-chunk-size`: total, and the decoded size of this chunk (chunk_inconsistent)
11
+ * capacity open assemblies <= maxOpen, buffered bytes <= maxBufferedBytes, bodyBytes <= maxBodyBytes (rate_limited | payload_too_large)
12
+ * later chunks total/source/contentType/bodyBytes/bodySha256 unchanged (chunk_inconsistent); index == next (chunk_out_of_order)
13
+ * last chunk the body has bodyBytes bytes and hashes to bodySha256 (chunk_inconsistent)
14
+ * Any refusal DROPS that assembly (the buffer is zeroed). An assembly older than assemblyMs without its last chunk is dropped too.
15
+ * Never logs, counts by content, or keeps anything after delivery: the caller gets the body once and zeroes it.
16
+ */
17
+ import crypto from "node:crypto";
18
+ import { isCanonicalBase64, webhookChunkSizeViolation } from "../contract.js";
19
+
20
+ export function createWebhookAssembler({ limits, config, now = Date.now, timers = { setTimeout, clearTimeout } }) {
21
+ const chunkRaw = limits.webhookChunkRawBytes;
22
+ const open = new Map(); // webhookId -> assembly
23
+ const stats = { started: 0, completed: 0, expired: 0, refused: {}, bytesHeld: 0 };
24
+
25
+ function drop(id, why) {
26
+ const a = open.get(id);
27
+ if (!a) return;
28
+ open.delete(id);
29
+ if (a.timer) timers.clearTimeout(a.timer);
30
+ a.buffer.fill(0);
31
+ stats.bytesHeld -= a.buffer.length;
32
+ if (why === "expired") stats.expired++;
33
+ }
34
+ const refuse = (id, code) => { drop(id, "refused"); stats.refused[code] = (stats.refused[code] || 0) + 1; return { ok: false, code }; };
35
+
36
+ return {
37
+ stats,
38
+ get openCount() { return open.size; },
39
+ /** Is there an assembly in progress for this webhookId? */
40
+ has(id) { return open.has(id); },
41
+ /**
42
+ * @param {{params:object, chunk:{index:number,total:number,last:boolean}}} o params = the signed payload of the frame
43
+ * @returns {{ok:false, code:string} | {ok:true, done:false} | {ok:true, done:true, webhook:{webhookId:string, source:string, contentType:string, body:Buffer}}}
44
+ */
45
+ accept({ params, chunk }) {
46
+ const { webhookId, source, contentType, bodyBytes, bodySha256 } = params;
47
+ const { index, total, last } = chunk;
48
+ let a = open.get(webhookId);
49
+ if (a && now() - a.startedAt >= config.assemblyMs) { drop(webhookId, "expired"); a = undefined; }
50
+ if (!a) {
51
+ if (index !== 0) return refuse(webhookId, "chunk_out_of_order");
52
+ if (last !== (index === total - 1)) return refuse(webhookId, "chunk_inconsistent");
53
+ if (webhookChunkSizeViolation(params, chunk, chunkRaw)) return refuse(webhookId, "chunk_inconsistent");
54
+ if (bodyBytes > config.maxBodyBytes) return refuse(webhookId, "payload_too_large");
55
+ if (open.size >= config.maxOpen || stats.bytesHeld + bodyBytes > config.maxBufferedBytes) return refuse(webhookId, "rate_limited");
56
+ a = { total, next: 0, source, contentType, bodyBytes, bodySha256, buffer: Buffer.alloc(bodyBytes), startedAt: now(), timer: null };
57
+ a.timer = timers.setTimeout(() => drop(webhookId, "expired"), config.assemblyMs);
58
+ if (a.timer && typeof a.timer.unref === "function") a.timer.unref();
59
+ open.set(webhookId, a);
60
+ stats.bytesHeld += bodyBytes;
61
+ stats.started++;
62
+ } else {
63
+ if (a.total !== total || a.source !== source || a.contentType !== contentType || a.bodyBytes !== bodyBytes || a.bodySha256 !== bodySha256) return refuse(webhookId, "chunk_inconsistent");
64
+ if (index !== a.next) return refuse(webhookId, "chunk_out_of_order");
65
+ if (last !== (index === total - 1)) return refuse(webhookId, "chunk_inconsistent");
66
+ if (webhookChunkSizeViolation(params, chunk, chunkRaw)) return refuse(webhookId, "chunk_inconsistent");
67
+ }
68
+ if (!isCanonicalBase64(params.bodyB64)) return refuse(webhookId, "chunk_inconsistent");
69
+ const piece = Buffer.from(params.bodyB64, "base64");
70
+ piece.copy(a.buffer, index * chunkRaw);
71
+ a.next = index + 1;
72
+ if (!last) return { ok: true, done: false };
73
+ const digest = crypto.createHash("sha256").update(a.buffer).digest("hex");
74
+ if (digest !== a.bodySha256) return refuse(webhookId, "chunk_inconsistent");
75
+ // hand over the buffer: the assembly stops being ours (it is not zeroed here: the caller does it after delivery)
76
+ open.delete(webhookId);
77
+ if (a.timer) timers.clearTimeout(a.timer);
78
+ stats.bytesHeld -= a.buffer.length;
79
+ stats.completed++;
80
+ return { ok: true, done: true, webhook: { webhookId, source: a.source, contentType: a.contentType, body: a.buffer } };
81
+ },
82
+ /** Drops every assembly (a new session, shutdown). */
83
+ clear() { for (const id of [...open.keys()]) drop(id, "cleared"); },
84
+ };
85
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * config.js - LOCAL_AGENT_WEBHOOK_FORWARD flag and limits of the webhook receiver (F3-12 consumer, contract 2.1 (b)).
3
+ *
4
+ * false (default) `webhook.forward` keeps being acked `rejected protocol_unsupported`, the capabilities `webhook.forward` and
5
+ * `webhook.chunked` are NOT announced (so the control plane keeps its own behaviour for this agent): nothing of this
6
+ * folder is loaded.
7
+ * true the agent receives the webhook bodies the control plane strips and forwards (one frame <= 9216 bytes, or chunks of
8
+ * 16 KiB up to 1 MiB), rebuilds them IN MEMORY, verifies the sha256 and hands them to the local incident detector.
9
+ * Needs LOCAL_AGENT_PROTOCOL_V2=true AND LOCAL_AGENT_INCIDENT_DETECTOR=true (the detector is the local consumer: with
10
+ * no consumer the handler is not registered and no capability is announced).
11
+ *
12
+ * LOCAL_AGENT_WEBHOOK_DELIVERY_MS how long the ack of a webhook (a single frame or the LAST chunk) waits for the local delivery (default 10 s,
13
+ * max 14 s: the control plane retransmits after 5 s and gives up on a webhook at 15 s). Past it: `rejected timeout`.
14
+ * LOCAL_AGENT_WEBHOOK_DEDUPE_MS / _MAX how long (default 10 min) and how many (default 1024) delivered webhookIds are remembered in memory
15
+ * so that a resend is acked `accepted` without being processed again.
16
+ *
17
+ * The limits default to the contract (index.json) and can only be tightened, never raised above it: a bad value falls back to the default.
18
+ */
19
+ export const WEBHOOK_FLAG = "LOCAL_AGENT_WEBHOOK_FORWARD";
20
+
21
+ const TRUE = new Set(["true", "1", "yes", "on"]);
22
+ const FALSE = new Set(["", "false", "0", "no", "off"]);
23
+
24
+ function int(raw, def, min, max) {
25
+ const n = Number.parseInt(String(raw ?? ""), 10);
26
+ return Number.isFinite(n) && n >= min && n <= max ? n : def;
27
+ }
28
+
29
+ /** @param {{webhookMaxBytes:number, webhookMaxChunks:number, webhookAssemblySeconds:number, webhookMaxOpenAssemblies:number}} limits the contract limits */
30
+ export function resolveWebhookConfig(env = process.env, warn = console.warn, limits = { webhookMaxBytes: 1048576, webhookMaxChunks: 64, webhookAssemblySeconds: 30, webhookMaxOpenAssemblies: 4 }) {
31
+ const raw = String(env[WEBHOOK_FLAG] ?? "").trim().toLowerCase();
32
+ if (!TRUE.has(raw)) {
33
+ if (!FALSE.has(raw)) warn(`[la-webhook] unrecognised ${WEBHOOK_FLAG} value; the webhook receiver stays off`);
34
+ return { enabled: false };
35
+ }
36
+ const maxOpen = int(env.LOCAL_AGENT_WEBHOOK_MAX_OPEN, limits.webhookMaxOpenAssemblies, 1, limits.webhookMaxOpenAssemblies);
37
+ const maxBodyBytes = int(env.LOCAL_AGENT_WEBHOOK_MAX_BYTES, limits.webhookMaxBytes, 1, limits.webhookMaxBytes);
38
+ return {
39
+ enabled: true,
40
+ maxBodyBytes,
41
+ maxOpen,
42
+ // bytes held by assemblies in progress (the README of the contract: 4 MiB)
43
+ maxBufferedBytes: int(env.LOCAL_AGENT_WEBHOOK_MAX_BUFFERED_BYTES, 4 * 1024 * 1024, maxBodyBytes, 16 * 1024 * 1024),
44
+ assemblyMs: int(env.LOCAL_AGENT_WEBHOOK_ASSEMBLY_MS, limits.webhookAssemblySeconds * 1000, 100, limits.webhookAssemblySeconds * 1000),
45
+ // at most this many entries of one webhook go to the detector (an Alertmanager group can carry hundreds of alerts)
46
+ maxEntries: int(env.LOCAL_AGENT_WEBHOOK_MAX_ENTRIES, 200, 1, 5000),
47
+ deliveryMs: int(env.LOCAL_AGENT_WEBHOOK_DELIVERY_MS, 10_000, 100, 14_000),
48
+ dedupeMs: int(env.LOCAL_AGENT_WEBHOOK_DEDUPE_MS, 600_000, 1_000, 3_600_000),
49
+ dedupeMax: int(env.LOCAL_AGENT_WEBHOOK_DEDUPE_MAX, 1024, 16, 65_536),
50
+ };
51
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * handler.js - `webhook.forward` (F3-12 consumer, contract 2.1 (b)). Registered by install.js only with LOCAL_AGENT_WEBHOOK_FORWARD=true.
3
+ *
4
+ * one frame (no `chunk`, body <= 9216 bytes) decoded here, handed to the sink, acked after the sink returned
5
+ * chunks every chunk acked `accepted` as it is stored; the ack of the LAST one means "rebuilt, hash
6
+ * verified AND delivered to the local consumer" (the CP answers the webhook only then)
7
+ * refusals non-fatal `rejected` acks with the wire code (chunk_out_of_order, chunk_inconsistent,
8
+ * payload_too_large, rate_limited, schema_violation for a body that is not canonical base64);
9
+ * the assembly is dropped. A sink that throws is `internal`; no sink = protocol_unsupported.
10
+ * delivery deadline the final ack waits for the sink up to config.deliveryMs (default 10 s); past it the ack is
11
+ * `rejected timeout` (the CP treats the webhook as not delivered). install.js tells the session to
12
+ * wait for this handler, so the ack never goes out as `accepted` before the delivery happened.
13
+ * resends the ids delivered in the last config.dedupeMs (10 min, at most config.dedupeMax) are remembered in
14
+ * memory with the sha256 of their body: the same id AND the same body again is acked `accepted`
15
+ * without being processed again (a CP fallback or a retry must not inflate the detector's count). Same
16
+ * id with another body is a new webhook. Nothing but the id and the hash is kept; nothing is logged.
17
+ * The body is in memory only: it is zeroed once the sink has settled (also after a timeout); nothing is written, logged or counted by content
18
+ * (counters by code). The sink is `async ({webhookId, source, contentType, body:Buffer}) => void`: it must finish its work with the body
19
+ * before it resolves.
20
+ */
21
+ import crypto from "node:crypto";
22
+ import { isCanonicalBase64, loadContract, webhookChunkSizeViolation } from "../contract.js";
23
+ import { createWebhookAssembler } from "./assembler.js";
24
+
25
+ const DEFAULT_TIMERS = { setTimeout, clearTimeout };
26
+
27
+ export function createWebhookHandler({ config, getSink, now = Date.now, timers }) {
28
+ const limits = loadContract().limits;
29
+ const T = timers || DEFAULT_TIMERS;
30
+ const assembler = createWebhookAssembler({ limits, config, now, ...(timers ? { timers } : {}) });
31
+ const deliveryMs = config.deliveryMs ?? 10_000;
32
+ const dedupeMs = config.dedupeMs ?? 600_000;
33
+ const dedupeMax = config.dedupeMax ?? 1024;
34
+ const stats = { frames: 0, single: 0, chunks: 0, delivered: 0, refused: {}, deliveryFailed: 0, deliveryTimeouts: 0, deduped: 0 };
35
+ const refused = (code) => { stats.refused[code] = (stats.refused[code] || 0) + 1; return { reject: code }; };
36
+
37
+ const seen = new Map(); // webhookId -> { sha, until } (insertion order = expiry order: the window is constant)
38
+ const inflight = new Set(); // webhookIds whose sink has not settled yet (only matters after a timeout)
39
+ const prune = () => {
40
+ const t = now();
41
+ for (const [id, e] of seen) { if (e.until > t && seen.size <= dedupeMax) break; seen.delete(id); }
42
+ };
43
+ const remember = (id, sha) => { seen.delete(id); seen.set(id, { sha, until: now() + dedupeMs }); prune(); };
44
+ const isResend = (id, sha) => { prune(); const e = seen.get(id); return !!e && e.sha === sha; };
45
+
46
+ const resends = new Map(); // webhookId -> { next, total, sha, startedAt }: a resend of an already delivered chunked webhook in progress
47
+ function resendChunk(p, chunk, rs) {
48
+ const { index, total, last } = chunk;
49
+ const t = now();
50
+ const fail = (code) => { resends.delete(p.webhookId); return refused(code); };
51
+ if (rs && t - rs.startedAt >= config.assemblyMs) { resends.delete(p.webhookId); rs = undefined; }
52
+ if (!rs) {
53
+ if (index !== 0) return refused("chunk_out_of_order");
54
+ if (resends.size >= config.maxOpen) return refused("rate_limited");
55
+ rs = { next: 0, total, sha: p.bodySha256, startedAt: t };
56
+ resends.set(p.webhookId, rs);
57
+ } else if (rs.total !== total || rs.sha !== p.bodySha256) return fail("chunk_inconsistent");
58
+ else if (index !== rs.next) return fail("chunk_out_of_order");
59
+ if (last !== (index === total - 1) || webhookChunkSizeViolation(p, chunk, limits.webhookChunkRawBytes) || !isCanonicalBase64(p.bodyB64)) return fail("chunk_inconsistent");
60
+ rs.next = index + 1;
61
+ if (last) { resends.delete(p.webhookId); stats.deduped++; }
62
+ return { accept: true };
63
+ }
64
+
65
+ async function deliver(webhook, sha) {
66
+ const sink = getSink();
67
+ if (!sink) { webhook.body.fill(0); return refused("protocol_unsupported"); }
68
+ const id = webhook.webhookId;
69
+ if (inflight.has(id)) { webhook.body.fill(0); return refused("rate_limited"); } // a timed-out attempt of this id is still running: try again later
70
+ inflight.add(id);
71
+ let timedOut = false;
72
+ let timer = null;
73
+ const work = Promise.resolve().then(() => sink(webhook));
74
+ // the body is zeroed when the sink settles, not before (after a timeout the sink may still be reading it)
75
+ work.then(() => { inflight.delete(id); if (timedOut) { stats.delivered++; remember(id, sha); } webhook.body.fill(0); },
76
+ () => { inflight.delete(id); webhook.body.fill(0); });
77
+ const deadline = new Promise((_, reject) => {
78
+ timer = T.setTimeout(() => { timedOut = true; const e = new Error("local delivery deadline"); e.code = "timeout"; reject(e); }, deliveryMs);
79
+ if (timer && typeof timer.unref === "function") timer.unref();
80
+ });
81
+ try {
82
+ await Promise.race([work, deadline]);
83
+ stats.delivered++;
84
+ remember(id, sha);
85
+ return { accept: true };
86
+ } catch (e) {
87
+ if (timedOut) { stats.deliveryTimeouts++; return refused("timeout"); }
88
+ stats.deliveryFailed++;
89
+ return refused(e && e.code === "rate_limited" ? "rate_limited" : "internal");
90
+ } finally {
91
+ if (timer) T.clearTimeout(timer);
92
+ }
93
+ }
94
+
95
+ return {
96
+ stats,
97
+ assembler,
98
+ async handle(ctx) {
99
+ stats.frames++;
100
+ const p = ctx.params;
101
+ if (!ctx.chunk) {
102
+ stats.single++;
103
+ if (!isCanonicalBase64(p.bodyB64)) return refused("schema_violation");
104
+ const body = Buffer.from(p.bodyB64, "base64");
105
+ if (body.length > limits.webhookSingleFrameRawBytes) return refused("payload_too_large");
106
+ const sha = crypto.createHash("sha256").update(body).digest("hex");
107
+ if (isResend(p.webhookId, sha)) { stats.deduped++; body.fill(0); return { accept: true }; }
108
+ return deliver({ webhookId: p.webhookId, source: p.source, contentType: p.contentType, body }, sha);
109
+ }
110
+ stats.chunks++;
111
+ // a resend of a webhook already delivered (same id, same body hash) starts at chunk 0: every chunk of it is acked in order, nothing is
112
+ // stored or processed again. A chunk with index > 0 that does not belong to an assembly or to a resend in progress stays what it was:
113
+ // chunk_out_of_order (also for "a chunk after the last one": the contract's invalid trace).
114
+ if (!assembler.has(p.webhookId)) {
115
+ const rs = resends.get(p.webhookId);
116
+ if (rs || (ctx.chunk.index === 0 && isResend(p.webhookId, p.bodySha256))) return resendChunk(p, ctx.chunk, rs);
117
+ }
118
+ const r = assembler.accept({ params: p, chunk: ctx.chunk });
119
+ if (!r.ok) return refused(r.code);
120
+ if (!r.done) return { accept: true };
121
+ return deliver(r.webhook, p.bodySha256);
122
+ },
123
+ clear() { assembler.clear(); resends.clear(); },
124
+ };
125
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * install.js - puts the webhook receiver on a v2 link (F3-12 consumer). Called by runtime.js only when LOCAL_AGENT_WEBHOOK_FORWARD=true,
3
+ * v2 is on AND the incident detector is on (the local consumer). Returns link.webhooks = { handler, setSink(fn) }.
4
+ */
5
+ import { createWebhookHandler } from "./handler.js";
6
+
7
+ export const WEBHOOK_CAPABILITIES = Object.freeze(["webhook.forward", "webhook.chunked"]);
8
+
9
+ export function installWebhookReceiver({ link, config }) {
10
+ let sink = null;
11
+ const handler = createWebhookHandler({ config, getSink: () => sink });
12
+ // the ack of the last chunk means "delivered to the local consumer": the session must wait for the handler (which has its own, shorter
13
+ // deadline: deliveryMs) instead of acking `accepted` after handlerAckMs (2 s). +1 s so the handler's own `rejected timeout` always wins.
14
+ link.session.setHandler("webhook.forward", (ctx) => handler.handle(ctx), { ackMs: (config.deliveryMs ?? 10_000) + 1_000 });
15
+ // a new session (not resumed) forgets the chunks of the old one: the CP restarts its numbering and its in-flight webhooks fail
16
+ const onReady = (e) => { if (!e.resumed) handler.clear(); };
17
+ link.session.on("ready", onReady);
18
+ const closeLink = link.close.bind(link);
19
+ link.close = () => { link.session.off("ready", onReady); handler.clear(); closeLink(); };
20
+ link.webhooks = { handler, setSink: (fn) => { sink = typeof fn === "function" ? fn : null; } };
21
+ return link.webhooks;
22
+ }
@@ -144,6 +144,97 @@
144
144
  }
145
145
  },
146
146
  "why": "agentId is required on ack/error (the gateway refuses a frame without it: agent_mismatch)"
147
+ },
148
+ {
149
+ "name": "llm-key-ack-accepted",
150
+ "why": "the LA answers a set or a revoke with the plain ack",
151
+ "expect": {
152
+ "valid": true
153
+ },
154
+ "message": {
155
+ "schemaVersion": 2,
156
+ "type": "ack",
157
+ "msgId": "00000000-0000-4000-8000-000000000001",
158
+ "seq": 0,
159
+ "tenantId": "3f2b8c1e-5a4d-4e7b-9c21-0a1b2c3d4e5f",
160
+ "workspaceId": "ws-1",
161
+ "ts": "2026-10-29T10:00:00Z",
162
+ "payload": {
163
+ "ackSeq": 5,
164
+ "status": "accepted"
165
+ },
166
+ "agentId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"
167
+ }
168
+ },
169
+ {
170
+ "name": "llm-key-ack-rejected-schema-violation",
171
+ "why": "the LA refuses an expired key or an id that does not match: rejected + schema_violation",
172
+ "expect": {
173
+ "valid": true
174
+ },
175
+ "message": {
176
+ "schemaVersion": 2,
177
+ "type": "ack",
178
+ "msgId": "00000000-0000-4000-8000-000000000001",
179
+ "seq": 0,
180
+ "tenantId": "3f2b8c1e-5a4d-4e7b-9c21-0a1b2c3d4e5f",
181
+ "workspaceId": "ws-1",
182
+ "ts": "2026-10-29T10:00:00Z",
183
+ "payload": {
184
+ "ackSeq": 5,
185
+ "status": "rejected",
186
+ "errorCode": "schema_violation"
187
+ },
188
+ "agentId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"
189
+ }
190
+ },
191
+ {
192
+ "name": "llm-key-ack-with-key-field",
193
+ "why": "an ack has no field that could carry the key",
194
+ "expect": {
195
+ "valid": false,
196
+ "code": "schema_violation",
197
+ "keyword": "additionalProperties"
198
+ },
199
+ "message": {
200
+ "schemaVersion": 2,
201
+ "type": "ack",
202
+ "msgId": "00000000-0000-4000-8000-000000000001",
203
+ "seq": 0,
204
+ "tenantId": "3f2b8c1e-5a4d-4e7b-9c21-0a1b2c3d4e5f",
205
+ "workspaceId": "ws-1",
206
+ "ts": "2026-10-29T10:00:00Z",
207
+ "payload": {
208
+ "ackSeq": 1,
209
+ "status": "accepted",
210
+ "key": "sk-vector-not-a-real-key-0001"
211
+ },
212
+ "agentId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"
213
+ }
214
+ },
215
+ {
216
+ "name": "llm-key-ack-with-key-id-field",
217
+ "why": "nor the key id",
218
+ "expect": {
219
+ "valid": false,
220
+ "code": "schema_violation",
221
+ "keyword": "additionalProperties"
222
+ },
223
+ "message": {
224
+ "schemaVersion": 2,
225
+ "type": "ack",
226
+ "msgId": "00000000-0000-4000-8000-000000000001",
227
+ "seq": 0,
228
+ "tenantId": "3f2b8c1e-5a4d-4e7b-9c21-0a1b2c3d4e5f",
229
+ "workspaceId": "ws-1",
230
+ "ts": "2026-10-29T10:00:00Z",
231
+ "payload": {
232
+ "ackSeq": 1,
233
+ "status": "accepted",
234
+ "keyId": "6ff70dd0f5a066910d2c77d289584f4774569fb9216f4a371c0efe1968526f48"
235
+ },
236
+ "agentId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"
237
+ }
147
238
  }
148
239
  ]
149
240
  }