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.
- package/.claude/context.md +2 -1
- package/CLAUDE.md +1 -1
- package/docs/la-protocol-v2.md +27 -4
- package/env.example +12 -0
- package/package.json +1 -1
- package/scripts/incident-parity/Gen.java +256 -0
- package/scripts/incident-parity/LogEntry.java +17 -0
- package/scripts/incident-parity/extract.py +51 -0
- package/scripts/incident-parity/gen.sh +32 -0
- package/src/incident-detector/README.md +52 -0
- package/src/incident-detector/aggregator.js +78 -0
- package/src/incident-detector/categorize.js +48 -0
- package/src/incident-detector/config.js +95 -0
- package/src/incident-detector/detector.js +93 -0
- package/src/incident-detector/emitter.js +164 -0
- package/src/incident-detector/index.js +44 -0
- package/src/incident-detector/java-compat.js +66 -0
- package/src/incident-detector/normalize.js +209 -0
- package/src/incident-detector/parse.js +114 -0
- package/src/incident-detector/sources/file-source.js +113 -0
- package/src/incident-detector/sources/loki-source.js +101 -0
- package/src/incident-detector/sources/stream-source.js +39 -0
- package/src/incident-detector/sources/webhook-source.js +109 -0
- package/src/incident-detector/store.js +119 -0
- package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +24 -16
- package/src/la-protocol-v2/contract/README.md +103 -3
- package/src/la-protocol-v2/contract/config.update.schema.json +85 -1
- package/src/la-protocol-v2/contract/hello.schema.json +26 -1
- package/src/la-protocol-v2/contract/incident.candidate.schema.json +11 -0
- package/src/la-protocol-v2/contract/index.json +9 -2
- package/src/la-protocol-v2/contract/ready.schema.json +8 -0
- package/src/la-protocol-v2/contract/register.schema.json +26 -1
- package/src/la-protocol-v2/contract/webhook.forward.schema.json +72 -3
- package/src/la-protocol-v2/contract.js +45 -7
- package/src/la-protocol-v2/llm-key/config.js +29 -0
- package/src/la-protocol-v2/llm-key/handler.js +33 -0
- package/src/la-protocol-v2/llm-key/install.js +21 -0
- package/src/la-protocol-v2/llm-key/store.js +173 -0
- package/src/la-protocol-v2/runtime.js +44 -1
- package/src/la-protocol-v2/session.js +47 -13
- package/src/la-protocol-v2/webhooks/assembler.js +85 -0
- package/src/la-protocol-v2/webhooks/config.js +51 -0
- package/src/la-protocol-v2/webhooks/handler.js +125 -0
- package/src/la-protocol-v2/webhooks/install.js +22 -0
- package/test/contract/la-protocol-v2/vectors/ack.vectors.json +91 -0
- package/test/contract/la-protocol-v2/vectors/config.update.vectors.json +1339 -0
- package/test/contract/la-protocol-v2/vectors/hello.vectors.json +261 -0
- package/test/contract/la-protocol-v2/vectors/incident.candidate.vectors.json +623 -0
- package/test/contract/la-protocol-v2/vectors/ready.vectors.json +599 -0
- package/test/contract/la-protocol-v2/vectors/register.vectors.json +275 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-after-last.json +186 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-body-hash-changes.json +186 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-last-flag-wrong.json +186 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-non-last-too-short.json +152 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-out-of-order.json +152 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-sha-mismatch.json +186 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-total-changes.json +186 -0
- package/test/contract/la-protocol-v2/vectors/traces/valid-webhook-chunks-in-order.json +218 -0
- package/test/contract/la-protocol-v2/vectors/webhook.forward.vectors.json +1263 -0
- package/test/incident-detector/parity.test.js +66 -0
- package/test/incident-detector/parity.vectors.json +2619 -0
- package/test/incident-detector/size-model.test.js +127 -0
- package/test/incident-detector/sources.test.js +179 -0
- package/test/incident-detector/units.test.js +430 -0
- package/test/incident-detector/wiring.test.js +165 -0
- package/test/la-protocol-v2/contract-v21.test.js +107 -0
- package/test/la-protocol-v2/contract.test.js +6 -4
- package/test/la-protocol-v2/helpers/reference-cp.js +8 -4
- package/test/la-protocol-v2/llm-key.test.js +509 -0
- package/test/la-protocol-v2/traces.test.js +3 -3
- 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
|
-
/**
|
|
118
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
518
|
-
if (!
|
|
519
|
-
const
|
|
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 }),
|
|
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
|
}
|