deepdebug-local-agent 1.8.0 → 1.10.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/AGENTS.md +1 -1
- package/CLAUDE.md +1 -1
- package/docs/la-jobs-and-llm.md +56 -0
- package/docs/la-protocol-v2.md +19 -2
- package/env.example +11 -0
- package/package.json +1 -1
- package/src/incident-detector/README.md +2 -1
- package/src/incident-detector/aggregator.js +8 -3
- package/src/incident-detector/detector.js +2 -1
- package/src/incident-detector/emitter.js +16 -6
- package/src/incident-detector/index.js +3 -3
- package/src/incident-detector/sources/webhook-source.js +109 -0
- package/src/la-protocol-v2/content-handlers/install.js +27 -1
- package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +26 -18
- package/src/la-protocol-v2/contract/README.md +127 -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/job.assign.schema.json +31 -0
- 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/jobs/config.js +60 -0
- package/src/la-protocol-v2/jobs/events.js +106 -0
- package/src/la-protocol-v2/jobs/install.js +37 -0
- package/src/la-protocol-v2/jobs/manager.js +276 -0
- package/src/la-protocol-v2/jobs/options.js +56 -0
- package/src/la-protocol-v2/jobs/smoke-runner.js +39 -0
- package/src/la-protocol-v2/llm/client.js +311 -0
- package/src/la-protocol-v2/llm/config.js +61 -0
- package/src/la-protocol-v2/llm/install.js +17 -0
- 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 +183 -0
- package/src/la-protocol-v2/runtime.js +71 -4
- 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/job.assign.vectors.json +1538 -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/size-model.test.js +127 -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/fake-llm.js +64 -0
- package/test/la-protocol-v2/helpers/reference-cp.js +8 -4
- package/test/la-protocol-v2/jobs-e2e.test.js +405 -0
- package/test/la-protocol-v2/jobs-manager.test.js +370 -0
- package/test/la-protocol-v2/jobs-options.test.js +288 -0
- package/test/la-protocol-v2/llm-client.test.js +380 -0
- 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
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
// F3-12 consumer (contract 2.1 (b)): the webhook.forward receiver of the Local Agent. Chunks in order / out of order / duplicated / wrong
|
|
2
|
+
// sha / over every limit, the ack AFTER the local delivery, the 8 traces of the contract through the REAL session, the flag off, and an
|
|
3
|
+
// end-to-end through buildV2Link + the incident detector with the canaries searched in everything the CP receives.
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import crypto from "node:crypto";
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { ReferenceCp, TENANT, AGENT, waitFor, uuid } from "./helpers/reference-cp.js";
|
|
12
|
+
import { inMemory, signingFor } from "./helpers/la-rig.js";
|
|
13
|
+
import { loadContract } from "../../src/la-protocol-v2/contract.js";
|
|
14
|
+
import { buildV2Link } from "../../src/la-protocol-v2/runtime.js";
|
|
15
|
+
import { resolveWebhookConfig } from "../../src/la-protocol-v2/webhooks/config.js";
|
|
16
|
+
import { createWebhookAssembler } from "../../src/la-protocol-v2/webhooks/assembler.js";
|
|
17
|
+
import { createWebhookHandler } from "../../src/la-protocol-v2/webhooks/handler.js";
|
|
18
|
+
import { entriesFromWebhook } from "../../src/incident-detector/sources/webhook-source.js";
|
|
19
|
+
|
|
20
|
+
const LIMITS = loadContract().limits;
|
|
21
|
+
const RAW = LIMITS.webhookChunkRawBytes;
|
|
22
|
+
const noop = () => {};
|
|
23
|
+
const cfg = (over = {}) => ({ ...resolveWebhookConfig({ LOCAL_AGENT_WEBHOOK_FORWARD: "true" }, noop, LIMITS), ...over });
|
|
24
|
+
const sha = (b) => crypto.createHash("sha256").update(b).digest("hex");
|
|
25
|
+
const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), "la-wh-"));
|
|
26
|
+
const TRACES = path.join(path.dirname(fileURLToPath(import.meta.url)), "../contract/la-protocol-v2/vectors/traces");
|
|
27
|
+
|
|
28
|
+
/** The chunks of a body exactly as the contract has them: 16384 raw bytes each, the last one the rest. */
|
|
29
|
+
function chunksOf(body, { webhookId = uuid(), source = "sentry", contentType = "application/json" } = {}) {
|
|
30
|
+
const total = Math.max(1, Math.ceil(body.length / RAW));
|
|
31
|
+
const bodySha256 = sha(body);
|
|
32
|
+
return Array.from({ length: total }, (_, index) => ({
|
|
33
|
+
params: { webhookId, source, contentType, bodyB64: body.subarray(index * RAW, (index + 1) * RAW).toString("base64"), bodyBytes: body.length, bodySha256 },
|
|
34
|
+
chunk: { index, total, last: index === total - 1 },
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
const bodyOf = (n) => Buffer.from(Array.from({ length: n }, (_, i) => (i * 31 + 7) & 0xff));
|
|
38
|
+
|
|
39
|
+
// ------------------------------------------------------------------------------------------ the assembler
|
|
40
|
+
|
|
41
|
+
test("assembler: chunks in order rebuild the body, the sha256 is verified, the webhook is handed over once", () => {
|
|
42
|
+
const a = createWebhookAssembler({ limits: LIMITS, config: cfg() });
|
|
43
|
+
const body = bodyOf(RAW * 2 + 100);
|
|
44
|
+
const fr = chunksOf(body);
|
|
45
|
+
assert.equal(fr.length, 3);
|
|
46
|
+
assert.deepEqual(a.accept(fr[0]), { ok: true, done: false });
|
|
47
|
+
assert.equal(a.openCount, 1);
|
|
48
|
+
assert.deepEqual(a.accept(fr[1]), { ok: true, done: false });
|
|
49
|
+
const r = a.accept(fr[2]);
|
|
50
|
+
assert.equal(r.ok && r.done, true);
|
|
51
|
+
assert.ok(r.webhook.body.equals(body));
|
|
52
|
+
assert.equal(r.webhook.source, "sentry");
|
|
53
|
+
assert.equal(a.openCount, 0);
|
|
54
|
+
assert.equal(a.stats.bytesHeld, 0);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("assembler: a 1 MiB body in 64 chunks, and a body of exactly 1 chunk (total 1)", () => {
|
|
58
|
+
const a = createWebhookAssembler({ limits: LIMITS, config: cfg() });
|
|
59
|
+
const big = bodyOf(1048576);
|
|
60
|
+
const fr = chunksOf(big);
|
|
61
|
+
assert.equal(fr.length, 64);
|
|
62
|
+
let last;
|
|
63
|
+
for (const f of fr) last = a.accept(f);
|
|
64
|
+
assert.ok(last.done && last.webhook.body.equals(big));
|
|
65
|
+
const small = chunksOf(bodyOf(100));
|
|
66
|
+
assert.equal(small.length, 1);
|
|
67
|
+
assert.ok(a.accept(small[0]).done);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("assembler: out of order (the first chunk seen is not index 0; index skipped; index repeated) -> chunk_out_of_order, the assembly is dropped", () => {
|
|
71
|
+
const body = bodyOf(RAW * 3);
|
|
72
|
+
const mk = () => createWebhookAssembler({ limits: LIMITS, config: cfg() });
|
|
73
|
+
let a = mk();
|
|
74
|
+
let fr = chunksOf(body);
|
|
75
|
+
assert.deepEqual(a.accept(fr[1]), { ok: false, code: "chunk_out_of_order" });
|
|
76
|
+
a = mk(); fr = chunksOf(body);
|
|
77
|
+
a.accept(fr[0]);
|
|
78
|
+
assert.deepEqual(a.accept(fr[2]), { ok: false, code: "chunk_out_of_order" });
|
|
79
|
+
assert.equal(a.openCount, 0, "dropped");
|
|
80
|
+
a = mk(); fr = chunksOf(body);
|
|
81
|
+
a.accept(fr[0]); a.accept(fr[1]);
|
|
82
|
+
assert.deepEqual(a.accept(fr[1]), { ok: false, code: "chunk_out_of_order" }, "the same index twice (duplicated with a new seq)");
|
|
83
|
+
assert.equal(a.accept(fr[2]).code, "chunk_out_of_order", "and then the assembly is gone");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("assembler: a chunk after the one flagged last -> chunk_out_of_order (the assembly is already delivered)", () => {
|
|
87
|
+
const a = createWebhookAssembler({ limits: LIMITS, config: cfg() });
|
|
88
|
+
const fr = chunksOf(bodyOf(40));
|
|
89
|
+
assert.ok(a.accept(fr[0]).done);
|
|
90
|
+
assert.deepEqual(a.accept({ params: fr[0].params, chunk: { index: 1, total: 1, last: true } }), { ok: false, code: "chunk_out_of_order" });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("assembler: sha256 that is not the hash of the rebuilt body -> chunk_inconsistent on the last chunk", () => {
|
|
94
|
+
const a = createWebhookAssembler({ limits: LIMITS, config: cfg() });
|
|
95
|
+
const body = bodyOf(RAW + 50);
|
|
96
|
+
const fr = chunksOf(body);
|
|
97
|
+
for (const f of fr) f.params.bodySha256 = sha(Buffer.from("something else"));
|
|
98
|
+
assert.deepEqual(a.accept(fr[0]), { ok: true, done: false });
|
|
99
|
+
assert.deepEqual(a.accept(fr[1]), { ok: false, code: "chunk_inconsistent" });
|
|
100
|
+
assert.equal(a.openCount, 0);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("assembler: what must not change between chunks (total, source, contentType, bodyBytes, bodySha256) -> chunk_inconsistent", () => {
|
|
104
|
+
const body = bodyOf(RAW * 2 + 5);
|
|
105
|
+
for (const mutate of [
|
|
106
|
+
(f) => { f.chunk.total = 4; },
|
|
107
|
+
(f) => { f.params.source = "sonar"; },
|
|
108
|
+
(f) => { f.params.contentType = "text/plain"; },
|
|
109
|
+
(f) => { f.params.bodySha256 = "0".repeat(64); },
|
|
110
|
+
(f) => { f.params.bodyBytes = body.length + 1; },
|
|
111
|
+
]) {
|
|
112
|
+
const a = createWebhookAssembler({ limits: LIMITS, config: cfg() });
|
|
113
|
+
const fr = chunksOf(body);
|
|
114
|
+
a.accept(fr[0]);
|
|
115
|
+
mutate(fr[1]);
|
|
116
|
+
assert.equal(a.accept(fr[1]).code, "chunk_inconsistent", mutate.toString());
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("assembler: last flag wrong, and the size rule (non-last too short, last of the wrong size) -> chunk_inconsistent", () => {
|
|
121
|
+
const body = bodyOf(RAW + 10);
|
|
122
|
+
let a = createWebhookAssembler({ limits: LIMITS, config: cfg() });
|
|
123
|
+
let fr = chunksOf(body);
|
|
124
|
+
a.accept(fr[0]);
|
|
125
|
+
fr[1].chunk.last = false;
|
|
126
|
+
assert.equal(a.accept(fr[1]).code, "chunk_inconsistent");
|
|
127
|
+
a = createWebhookAssembler({ limits: LIMITS, config: cfg() });
|
|
128
|
+
fr = chunksOf(body);
|
|
129
|
+
fr[0].params.bodyB64 = Buffer.alloc(RAW - 1).toString("base64");
|
|
130
|
+
assert.equal(a.accept(fr[0]).code, "chunk_inconsistent", "a non-last chunk must be exactly 16384 bytes");
|
|
131
|
+
a = createWebhookAssembler({ limits: LIMITS, config: cfg() });
|
|
132
|
+
fr = chunksOf(body);
|
|
133
|
+
a.accept(fr[0]);
|
|
134
|
+
fr[1].params.bodyB64 = Buffer.alloc(11).toString("base64");
|
|
135
|
+
assert.equal(a.accept(fr[1]).code, "chunk_inconsistent", "the last chunk is the rest");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("assembler limits: open assemblies, bytes buffered, body above the cap, 30 s timeout -> rate_limited / payload_too_large / dropped and zeroed", () => {
|
|
139
|
+
// open assemblies
|
|
140
|
+
let a = createWebhookAssembler({ limits: LIMITS, config: cfg({ maxOpen: 2 }) });
|
|
141
|
+
const big = bodyOf(RAW * 2);
|
|
142
|
+
for (let i = 0; i < 2; i++) assert.ok(a.accept(chunksOf(big)[0]).ok);
|
|
143
|
+
assert.deepEqual(a.accept(chunksOf(big)[0]), { ok: false, code: "rate_limited" });
|
|
144
|
+
assert.equal(a.openCount, 2, "the refused one is not kept and the open ones survive");
|
|
145
|
+
// bytes buffered
|
|
146
|
+
a = createWebhookAssembler({ limits: LIMITS, config: cfg({ maxBufferedBytes: RAW * 3 }) });
|
|
147
|
+
assert.ok(a.accept(chunksOf(big)[0]).ok);
|
|
148
|
+
assert.equal(a.accept(chunksOf(big)[0]).code, "rate_limited");
|
|
149
|
+
assert.equal(a.stats.bytesHeld, RAW * 2);
|
|
150
|
+
// body above the configured cap
|
|
151
|
+
a = createWebhookAssembler({ limits: LIMITS, config: cfg({ maxBodyBytes: RAW }) });
|
|
152
|
+
assert.equal(a.accept(chunksOf(big)[0]).code, "payload_too_large");
|
|
153
|
+
// timeout (fake clock + fake timers)
|
|
154
|
+
let t = 1_000_000;
|
|
155
|
+
const pending = [];
|
|
156
|
+
const timers = { setTimeout: (fn, ms) => { const h = { fn, ms, cleared: false }; pending.push(h); return h; }, clearTimeout: (h) => { if (h) h.cleared = true; } };
|
|
157
|
+
a = createWebhookAssembler({ limits: LIMITS, config: cfg(), now: () => t, timers });
|
|
158
|
+
const fr = chunksOf(big);
|
|
159
|
+
a.accept(fr[0]);
|
|
160
|
+
assert.equal(pending[0].ms, 30_000);
|
|
161
|
+
t += 30_001;
|
|
162
|
+
pending[0].fn();
|
|
163
|
+
assert.equal(a.openCount, 0);
|
|
164
|
+
assert.equal(a.stats.expired, 1);
|
|
165
|
+
assert.equal(a.accept(fr[1]).code, "chunk_out_of_order", "a late chunk finds nothing");
|
|
166
|
+
// lazy expiry even if the timer never ran (clock check at accept time)
|
|
167
|
+
a = createWebhookAssembler({ limits: LIMITS, config: cfg(), now: () => t, timers });
|
|
168
|
+
const g = chunksOf(big);
|
|
169
|
+
a.accept(g[0]);
|
|
170
|
+
t += 31_000;
|
|
171
|
+
assert.equal(a.accept(g[1]).code, "chunk_out_of_order");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("assembler: a refusal and a clear() zero the buffer (nothing of the body stays in the dropped assembly)", () => {
|
|
175
|
+
const a = createWebhookAssembler({ limits: LIMITS, config: cfg() });
|
|
176
|
+
const body = Buffer.alloc(RAW * 2, 0x41);
|
|
177
|
+
const fr = chunksOf(body);
|
|
178
|
+
a.accept(fr[0]);
|
|
179
|
+
assert.equal(a.openCount, 1);
|
|
180
|
+
a.clear();
|
|
181
|
+
assert.equal(a.openCount, 0);
|
|
182
|
+
assert.equal(a.stats.bytesHeld, 0);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// ------------------------------------------------------------------------------------------ the handler on the real session
|
|
186
|
+
|
|
187
|
+
async function rig({ sink, config = cfg(), sessionOptions = {}, mode = "enforce" } = {}) {
|
|
188
|
+
const cp = new ReferenceCp({ capabilities: ["incident.candidate.size-model"] });
|
|
189
|
+
const env = inMemory(cp, { mode, options: { handlerAckMs: 5000, ...sessionOptions } }).start();
|
|
190
|
+
await waitFor(() => env.session.state === "ready");
|
|
191
|
+
const delivered = [];
|
|
192
|
+
const handler = createWebhookHandler({ config, getSink: () => (sink === undefined ? async (w) => { delivered.push({ ...w, body: Buffer.from(w.body) }); } : sink) });
|
|
193
|
+
env.session.setHandler("webhook.forward", (ctx) => handler.handle(ctx));
|
|
194
|
+
return { cp, env, handler, delivered, session: env.session };
|
|
195
|
+
}
|
|
196
|
+
const send = (cp, f) => cp.sendData("webhook.forward", f.params, f.chunk ? { chunk: f.chunk } : {});
|
|
197
|
+
|
|
198
|
+
test("a webhook of at most 9216 bytes in ONE frame (no chunk) is delivered and acked accepted", async () => {
|
|
199
|
+
const { cp, delivered, session } = await rig();
|
|
200
|
+
const body = Buffer.from(JSON.stringify({ hello: "world" }));
|
|
201
|
+
send(cp, { params: { webhookId: uuid(), source: "other", contentType: "application/json", bodyB64: body.toString("base64") } });
|
|
202
|
+
await waitFor(() => cp.acksFromLa.length === 1);
|
|
203
|
+
assert.deepEqual(cp.acksFromLa[0], { ackSeq: 1, status: "accepted", errorCode: undefined });
|
|
204
|
+
assert.equal(delivered.length, 1);
|
|
205
|
+
assert.ok(delivered[0].body.equals(body));
|
|
206
|
+
session.close();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("chunked: every chunk is acked accepted in order, the LAST ack only after the sink returned (ack AFTER the local delivery)", async () => {
|
|
210
|
+
let release;
|
|
211
|
+
const gate = new Promise((r) => { release = r; });
|
|
212
|
+
let sinkSaw = null;
|
|
213
|
+
const { cp, handler, session } = await rig({ sink: async (w) => { sinkSaw = Buffer.from(w.body); await gate; } });
|
|
214
|
+
const body = bodyOf(RAW * 2 + 77);
|
|
215
|
+
const fr = chunksOf(body);
|
|
216
|
+
for (const f of fr) send(cp, f);
|
|
217
|
+
await waitFor(() => cp.acksFromLa.length === 2);
|
|
218
|
+
assert.deepEqual(cp.acksFromLa.map((a) => [a.ackSeq, a.status]), [[1, "accepted"], [2, "accepted"]]);
|
|
219
|
+
await waitFor(() => sinkSaw !== null);
|
|
220
|
+
await new Promise((r) => setTimeout(r, 120));
|
|
221
|
+
assert.equal(cp.acksFromLa.length, 2, "the ack of the last chunk is held while the local processing runs");
|
|
222
|
+
release();
|
|
223
|
+
await waitFor(() => cp.acksFromLa.length === 3);
|
|
224
|
+
assert.deepEqual(cp.acksFromLa[2], { ackSeq: 3, status: "accepted", errorCode: undefined });
|
|
225
|
+
assert.ok(sinkSaw.equals(body));
|
|
226
|
+
assert.equal(handler.stats.delivered, 1);
|
|
227
|
+
session.close();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("the body is zeroed after delivery (the sink cannot keep it) and nothing is left open", async () => {
|
|
231
|
+
let kept = null;
|
|
232
|
+
const { cp, handler, session } = await rig({ sink: async (w) => { kept = w.body; } });
|
|
233
|
+
for (const f of chunksOf(Buffer.alloc(RAW + 5, 0x42))) send(cp, f);
|
|
234
|
+
await waitFor(() => cp.acksFromLa.length === 2);
|
|
235
|
+
assert.ok(kept.every((b) => b === 0));
|
|
236
|
+
assert.equal(handler.assembler.openCount, 0);
|
|
237
|
+
session.close();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("refusals are non-fatal `rejected` acks with the wire code (the session goes on): out of order, wrong sha, no sink, a sink that throws", async () => {
|
|
241
|
+
const { cp, session, handler } = await rig();
|
|
242
|
+
const body = bodyOf(RAW + 9);
|
|
243
|
+
const fr = chunksOf(body);
|
|
244
|
+
send(cp, fr[1]); // first chunk seen is index 1
|
|
245
|
+
await waitFor(() => cp.acksFromLa.length === 1);
|
|
246
|
+
assert.deepEqual(cp.acksFromLa[0], { ackSeq: 1, status: "rejected", errorCode: "chunk_out_of_order" });
|
|
247
|
+
const bad = chunksOf(body);
|
|
248
|
+
for (const f of bad) f.params.bodySha256 = "1".repeat(64);
|
|
249
|
+
send(cp, bad[0]); send(cp, bad[1]);
|
|
250
|
+
await waitFor(() => cp.acksFromLa.length === 3);
|
|
251
|
+
assert.deepEqual(cp.acksFromLa[2], { ackSeq: 3, status: "rejected", errorCode: "chunk_inconsistent" });
|
|
252
|
+
assert.equal(session.state, "ready", "not fatal: the connection stays");
|
|
253
|
+
assert.deepEqual(cp.errorsSent, []);
|
|
254
|
+
assert.equal(handler.assembler.openCount, 0);
|
|
255
|
+
session.close();
|
|
256
|
+
// no sink: protocol_unsupported (the LA never says "delivered" when nothing took it)
|
|
257
|
+
const r2 = await rig({ sink: null });
|
|
258
|
+
send(r2.cp, { params: { webhookId: uuid(), source: "other", contentType: "text/plain", bodyB64: Buffer.from("x").toString("base64") } });
|
|
259
|
+
await waitFor(() => r2.cp.acksFromLa.length === 1);
|
|
260
|
+
assert.equal(r2.cp.acksFromLa[0].errorCode, "protocol_unsupported");
|
|
261
|
+
r2.session.close();
|
|
262
|
+
// a sink that throws: internal; a sink that says rate_limited: rate_limited
|
|
263
|
+
const r3 = await rig({ sink: async () => { throw new Error("boom with CANARY-TEXT"); } });
|
|
264
|
+
send(r3.cp, { params: { webhookId: uuid(), source: "other", contentType: "text/plain", bodyB64: Buffer.from("x").toString("base64") } });
|
|
265
|
+
await waitFor(() => r3.cp.acksFromLa.length === 1);
|
|
266
|
+
assert.deepEqual(r3.cp.acksFromLa[0], { ackSeq: 1, status: "rejected", errorCode: "internal" });
|
|
267
|
+
assert.ok(!JSON.stringify(r3.env.wire.toCp).includes("CANARY-TEXT"), "the error text never goes out");
|
|
268
|
+
r3.session.close();
|
|
269
|
+
const r4 = await rig({ sink: async () => { const e = new Error("full"); e.code = "rate_limited"; throw e; } });
|
|
270
|
+
send(r4.cp, { params: { webhookId: uuid(), source: "other", contentType: "text/plain", bodyB64: Buffer.from("x").toString("base64") } });
|
|
271
|
+
await waitFor(() => r4.cp.acksFromLa.length === 1);
|
|
272
|
+
assert.equal(r4.cp.acksFromLa[0].errorCode, "rate_limited");
|
|
273
|
+
r4.session.close();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("a retransmitted chunk (same seq and msgId: the ack was lost) is the session's idempotent duplicate: delivered once, re-acked", async () => {
|
|
277
|
+
// mode off: the gateway re-signs a retransmission (new nonce); a byte-identical replay would be refused by the nonce cache before the dedupe
|
|
278
|
+
const { cp, env, session, delivered } = await rig({ mode: "off" });
|
|
279
|
+
const fr = chunksOf(Buffer.from('{"a":1}'));
|
|
280
|
+
send(cp, fr[0]);
|
|
281
|
+
await waitFor(() => cp.acksFromLa.length === 1);
|
|
282
|
+
const original = env.wire.toLa.find((t) => JSON.parse(t).type === "webhook.forward");
|
|
283
|
+
session.handleText(original, session.transport); // byte for byte the same frame again
|
|
284
|
+
await waitFor(() => cp.acksFromLa.length === 2);
|
|
285
|
+
assert.equal(delivered.length, 1, "the second copy of the same seq+msgId did not run the handler again");
|
|
286
|
+
assert.equal(cp.acksFromLa[1].status, "duplicate");
|
|
287
|
+
session.close();
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("a chunk of the wrong size is refused by the session as chunk_inconsistent only AFTER the state rules (the handler answers, non-fatal)", async () => {
|
|
291
|
+
const { cp, session } = await rig();
|
|
292
|
+
const fr = chunksOf(bodyOf(RAW * 2));
|
|
293
|
+
fr[0].params.bodyB64 = Buffer.alloc(100).toString("base64"); // a non-last chunk of 100 bytes
|
|
294
|
+
send(cp, fr[0]);
|
|
295
|
+
await waitFor(() => cp.acksFromLa.length === 1);
|
|
296
|
+
assert.deepEqual(cp.acksFromLa[0], { ackSeq: 1, status: "rejected", errorCode: "chunk_inconsistent" });
|
|
297
|
+
assert.equal(session.state, "ready");
|
|
298
|
+
session.close();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("another tenant's chunk never reaches the receiver: tenant_mismatch before any handler (cross-tenant leak)", async () => {
|
|
302
|
+
const { cp, session, delivered, handler } = await rig();
|
|
303
|
+
const f = chunksOf(Buffer.from("secret"))[0];
|
|
304
|
+
const frame = cp.frameWith("webhook.forward", f.params, { frame: { chunk: f.chunk } });
|
|
305
|
+
frame.tenantId = "99999999-5a4d-4e7b-9c21-0a1b2c3d4e5f";
|
|
306
|
+
session.handleText(JSON.stringify(frame), session.transport);
|
|
307
|
+
await waitFor(() => cp.peerErrors.length > 0);
|
|
308
|
+
assert.ok(["tenant_mismatch", "unauthorized", "params_mismatch"].includes(cp.peerErrors[0]), cp.peerErrors[0]);
|
|
309
|
+
assert.equal(delivered.length, 0);
|
|
310
|
+
assert.equal(handler.stats.frames, 0);
|
|
311
|
+
session.close();
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("two links (two tenants) never share an assembly: the same webhookId in two handlers builds two separate bodies", async () => {
|
|
315
|
+
const a = await rig();
|
|
316
|
+
const b = await rig();
|
|
317
|
+
const id = uuid();
|
|
318
|
+
const bodyA = bodyOf(RAW + 1);
|
|
319
|
+
const bodyB = bodyOf(RAW + 2);
|
|
320
|
+
const fa = chunksOf(bodyA, { webhookId: id });
|
|
321
|
+
const fb = chunksOf(bodyB, { webhookId: id });
|
|
322
|
+
send(a.cp, fa[0]); send(b.cp, fb[0]); send(b.cp, fb[1]); send(a.cp, fa[1]);
|
|
323
|
+
await waitFor(() => a.delivered.length === 1 && b.delivered.length === 1);
|
|
324
|
+
assert.ok(a.delivered[0].body.equals(bodyA));
|
|
325
|
+
assert.ok(b.delivered[0].body.equals(bodyB));
|
|
326
|
+
a.session.close(); b.session.close();
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// ------------------------------------------------------------------------------------------ the 8 traces of the contract
|
|
330
|
+
|
|
331
|
+
const traceFiles = fs.readdirSync(TRACES).filter((f) => /webhook-chunk/.test(f)).sort();
|
|
332
|
+
test("the contract has the 8 webhook traces (1 valid, 7 invalid)", () => {
|
|
333
|
+
assert.equal(traceFiles.length, 8);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
for (const file of traceFiles) {
|
|
337
|
+
const t = JSON.parse(fs.readFileSync(path.join(TRACES, file), "utf8"));
|
|
338
|
+
test(`trace ${t.name}: the REAL session + receiver ${t.expect.ok ? "accept every chunk and deliver a body with the declared sha256" : `answer ${t.expect.error} at frame ${t.expect.atFrame}`}`, async () => {
|
|
339
|
+
const cp = new ReferenceCp({ tenantId: t.session.tenantId, agentId: t.session.agentId });
|
|
340
|
+
const env = inMemory(cp, { mode: "off", tenantId: t.session.tenantId, agentId: t.session.agentId, options: { handlerAckMs: 5000 } }).start(); // the trace signatures are placeholders
|
|
341
|
+
await waitFor(() => env.session.state === "ready");
|
|
342
|
+
const delivered = [];
|
|
343
|
+
const handler = createWebhookHandler({ config: cfg(), getSink: () => async (w) => { delivered.push(Buffer.from(w.body)); } });
|
|
344
|
+
env.session.setHandler("webhook.forward", (ctx) => handler.handle(ctx));
|
|
345
|
+
const cpFrames = t.frames.map((f, i) => ({ ...f, i })).filter((f) => f.dir === "cp->la" && f.frame.type === "webhook.forward");
|
|
346
|
+
for (const fr of cpFrames) env.session.handleText(JSON.stringify(fr.frame), env.transport);
|
|
347
|
+
await waitFor(() => cp.acksFromLa.length >= cpFrames.length || cp.peerErrors.length > 0);
|
|
348
|
+
if (t.expect.ok) {
|
|
349
|
+
assert.deepEqual(cp.acksFromLa.map((a) => a.status), cpFrames.map(() => "accepted"));
|
|
350
|
+
assert.equal(delivered.length, 1);
|
|
351
|
+
assert.equal(sha(delivered[0]), cpFrames[0].frame.payload.bodySha256);
|
|
352
|
+
assert.equal(delivered[0].length, cpFrames[0].frame.payload.bodyBytes);
|
|
353
|
+
assert.deepEqual(cp.peerErrors, []);
|
|
354
|
+
} else {
|
|
355
|
+
const at = cpFrames.findIndex((f) => f.i === t.expect.atFrame);
|
|
356
|
+
assert.ok(at >= 0);
|
|
357
|
+
assert.deepEqual(cp.acksFromLa.slice(0, at).map((a) => a.status), cpFrames.slice(0, at).map(() => "accepted"), "everything before the bad frame is accepted");
|
|
358
|
+
assert.deepEqual(cp.acksFromLa[at], { ackSeq: cpFrames[at].frame.seq, status: "rejected", errorCode: t.expect.error });
|
|
359
|
+
// only "a chunk after the one flagged last" has a complete webhook before the bad frame (delivered once, legitimately)
|
|
360
|
+
assert.equal(delivered.length, t.name === "invalid-webhook-chunk-after-last" ? 1 : 0, "an invalid chunked webhook is never delivered");
|
|
361
|
+
assert.equal(env.session.state, "ready");
|
|
362
|
+
}
|
|
363
|
+
env.session.close();
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ------------------------------------------------------------------------------------------ the mappers of the local consumer
|
|
368
|
+
|
|
369
|
+
test("local consumer: text/plain lines, an Alertmanager group, a Sentry event and an unmapped body", () => {
|
|
370
|
+
const text = entriesFromWebhook({ source: "other", contentType: "text/plain", body: Buffer.from("2026-11-03 10:00:00 ERROR java.lang.IllegalStateException: boom\n\tat a.B.c(B.java:1)\n\tat a.B.d(B.java:2)\n") });
|
|
371
|
+
assert.equal(text.entries.length, 1);
|
|
372
|
+
assert.match(text.entries[0].stacktrace, /B\.java:1/);
|
|
373
|
+
const am = entriesFromWebhook({ source: "alertmanager", contentType: "application/json", body: Buffer.from(JSON.stringify({ alerts: [
|
|
374
|
+
{ status: "firing", labels: { alertname: "HighErrorRate", severity: "critical", service: "billing" }, annotations: { summary: "5xx above 5%" }, startsAt: "2026-11-03T10:00:00Z" },
|
|
375
|
+
{ status: "resolved", labels: { alertname: "Old" } },
|
|
376
|
+
{ status: "firing", labels: { alertname: "Warn", severity: "warning" } },
|
|
377
|
+
] })) });
|
|
378
|
+
assert.equal(am.entries.length, 2);
|
|
379
|
+
assert.deepEqual([am.entries[0].severity, am.entries[0].service, am.entries[0].message], ["CRITICAL", "billing", "HighErrorRate: 5xx above 5%"]);
|
|
380
|
+
assert.equal(am.entries[1].severity, "WARN", "a warning alert is not an incident for the detector");
|
|
381
|
+
const se = entriesFromWebhook({ source: "sentry", contentType: "application/json", body: Buffer.from(JSON.stringify({ project_name: "checkout", data: { event: { level: "error", timestamp: 1793268000, exception: { values: [{ type: "TypeError", value: "x is undefined", stacktrace: { frames: [{ function: "a", filename: "a.js", lineno: 3 }, { function: "b", filename: "b.js", lineno: 9 }] } }] } } } })) });
|
|
382
|
+
assert.equal(se.entries.length, 1);
|
|
383
|
+
assert.equal(se.entries[0].message, "TypeError: x is undefined");
|
|
384
|
+
assert.equal(se.entries[0].service, "checkout");
|
|
385
|
+
assert.match(se.entries[0].stacktrace, /at b \(b\.js:9\)\n\s+at a \(a\.js:3\)/, "frames are listed innermost first");
|
|
386
|
+
assert.deepEqual(entriesFromWebhook({ source: "snyk", contentType: "application/json", body: Buffer.from("[1,2]") }), { entries: [], unmapped: true });
|
|
387
|
+
assert.deepEqual(entriesFromWebhook({ source: "other", contentType: "application/json", body: Buffer.from("not json") }), { entries: [], unmapped: true });
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// ------------------------------------------------------------------------------------------ flags, capabilities, end to end
|
|
391
|
+
|
|
392
|
+
async function linkFor(cp, env, root = tmp()) {
|
|
393
|
+
const logs = [];
|
|
394
|
+
const log = { info: (m) => logs.push(m), log: (m) => logs.push(m), warn: (m) => logs.push(m), error: (m) => logs.push(m) };
|
|
395
|
+
const wsManager = { list: () => [{ id: "ws-1", root, tenantId: TENANT }] };
|
|
396
|
+
const link = await buildV2Link({ tenantId: TENANT, agentId: AGENT, signing: signingFor({ keyId: cp.keyId, publicKeyRaw: cp.publicKeyRaw }, "enforce"), wsManager, getWorkspaceRoot: () => root, log, env });
|
|
397
|
+
const transport = { send: (t) => cp.feed(t), close() {}, bufferedAmount: () => 0 };
|
|
398
|
+
cp.attach((t) => queueMicrotask(() => link.session.handleText(t, transport)), () => {});
|
|
399
|
+
link.session.attach(transport);
|
|
400
|
+
await waitFor(() => link.session.state === "ready");
|
|
401
|
+
return { link, logs, transport };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
test("flag off (unset or any non-true value): no capability, no handler (webhook.forward acked protocol_unsupported), register is exactly the baseline", async () => {
|
|
405
|
+
for (const v of [undefined, "false", "maybe"]) {
|
|
406
|
+
const cp = new ReferenceCp({ capabilities: ["incident.candidate.size-model"] });
|
|
407
|
+
const { link } = await linkFor(cp, { LOCAL_AGENT_WEBHOOK_FORWARD: v });
|
|
408
|
+
assert.deepEqual([...cp.registers[0].capabilities].sort(), ["chunk.base64", "protocol.v2", "v1.commands"]);
|
|
409
|
+
assert.equal(link.webhooks, undefined);
|
|
410
|
+
cp.sendData("webhook.forward", { webhookId: uuid(), source: "other", contentType: "text/plain", bodyB64: Buffer.from("x").toString("base64") });
|
|
411
|
+
await waitFor(() => cp.acksFromLa.length === 1);
|
|
412
|
+
assert.deepEqual(cp.acksFromLa[0], { ackSeq: 1, status: "rejected", errorCode: "protocol_unsupported" });
|
|
413
|
+
link.close();
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test("flag on WITHOUT the incident detector: there is no local consumer, the receiver is not registered and no webhook capability is announced", async () => {
|
|
418
|
+
const cp = new ReferenceCp();
|
|
419
|
+
const { link, logs } = await linkFor(cp, { LOCAL_AGENT_WEBHOOK_FORWARD: "true" });
|
|
420
|
+
assert.ok(!cp.registers[0].capabilities.some((c) => c.startsWith("webhook.")));
|
|
421
|
+
assert.ok(!cp.registers[0].capabilities.includes("contract.v2.1"));
|
|
422
|
+
assert.equal(link.webhooks, undefined);
|
|
423
|
+
assert.ok(logs.some((l) => /no local consumer/.test(l)));
|
|
424
|
+
link.close();
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
const GH = "ghp_" + "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8";
|
|
428
|
+
const CANARIES = ["CANARY-WH-5521", "CanaryWebhookVault", "/home/customer/wh-secret", "order-7777-detail", GH];
|
|
429
|
+
|
|
430
|
+
test("end to end (flags on): a 40 KB chunked webhook -> reassembled -> detector -> a candidate with metadata only (+ stackBytes and model); the ack of the last chunk comes after; canaries never on the wire", async () => {
|
|
431
|
+
const dir = tmp();
|
|
432
|
+
const cp = new ReferenceCp({ capabilities: ["incident.candidate.size-model"] });
|
|
433
|
+
const { link } = await linkFor(cp, {
|
|
434
|
+
LOCAL_AGENT_PROTOCOL_V2: "true", LOCAL_AGENT_INCIDENT_DETECTOR: "true", LOCAL_AGENT_WEBHOOK_FORWARD: "true", LOCAL_AGENT_DLP_MODE: "enforce",
|
|
435
|
+
LOCAL_AGENT_INCIDENT_STORE_DIR: path.join(dir, "store"), LOCAL_AGENT_INCIDENT_SERVICE: "billing-api", LOCAL_AGENT_INCIDENT_FLUSH_MS: "300",
|
|
436
|
+
});
|
|
437
|
+
for (const c of ["webhook.forward", "webhook.chunked", "incident.candidate", "contract.v2.1"]) assert.ok(cp.registers[0].capabilities.includes(c), c);
|
|
438
|
+
assert.ok(!cp.registers[0].capabilities.includes("llm.key"));
|
|
439
|
+
let stack = "";
|
|
440
|
+
for (let i = 0; i < 180; i++) stack += `\tat com.acme.CanaryWebhookVault.step${i}(CanaryWebhookVault.java:${i}) /home/customer/wh-secret order-7777-detail\n`;
|
|
441
|
+
const text = `2026-11-03 10:00:00 ERROR c.a.Order : java.lang.IllegalStateException: CANARY-WH-5521 token ${GH}\n${stack}`;
|
|
442
|
+
const body = Buffer.from(text, "utf8");
|
|
443
|
+
assert.ok(body.length > 9216 && body.length < 60_000, `the body must need chunks: ${body.length}`);
|
|
444
|
+
const fr = chunksOf(body, { source: "other", contentType: "text/plain" });
|
|
445
|
+
assert.ok(fr.length >= 2);
|
|
446
|
+
for (const f of fr) send(cp, f);
|
|
447
|
+
await waitFor(() => cp.acksFromLa.length >= fr.length);
|
|
448
|
+
assert.deepEqual(cp.acksFromLa.slice(0, fr.length).map((a) => a.status), fr.map(() => "accepted"));
|
|
449
|
+
const cands = () => cp.received.filter((f) => f.type === "incident.candidate");
|
|
450
|
+
await waitFor(() => cands().length >= 1, 5000);
|
|
451
|
+
const c = cands()[0].payload;
|
|
452
|
+
assert.deepEqual(Object.keys(c).sort(), ["count", "environment", "errorHash", "fingerprint", "firstSeen", "lastSeen", "logCategory", "model", "service", "severity", "stackBytes"]);
|
|
453
|
+
assert.equal(c.service, "billing-api");
|
|
454
|
+
assert.match(c.model, /^stack-detector-\d+\.\d+\.\d+$/);
|
|
455
|
+
assert.equal(c.stackBytes, Buffer.byteLength(stack.replace(/\n$/, "")), "the size of the stack as received");
|
|
456
|
+
const wire = cp.rawIn.join("\n");
|
|
457
|
+
for (const w of CANARIES) assert.ok(!wire.includes(w), `the wire must not carry ${w.slice(0, 12)}...`);
|
|
458
|
+
const detail = await link.incidents.getDetail(c.errorHash);
|
|
459
|
+
assert.ok(detail.length >= 1 && detail[0].message.includes("CANARY-WH-5521"), "the text stayed local");
|
|
460
|
+
assert.ok(!JSON.stringify(detail).includes(GH), "the DLP masked the token in the local store");
|
|
461
|
+
assert.ok(!JSON.stringify(link.incidents.audit.tail(100)).includes("CANARY"));
|
|
462
|
+
assert.equal(link.webhooks.handler.assembler.openCount, 0);
|
|
463
|
+
// nothing of the body was written anywhere but the (masked) local store: no temp file in the store dir other than the jsonl
|
|
464
|
+
assert.deepEqual(fs.readdirSync(path.join(dir, "store")).sort(), ["incident-detail.jsonl"]);
|
|
465
|
+
link.close();
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
test("end to end: a small Alertmanager webhook in ONE frame goes through the same pipeline", async () => {
|
|
469
|
+
const dir = tmp();
|
|
470
|
+
const cp = new ReferenceCp({ capabilities: ["incident.candidate.size-model"] });
|
|
471
|
+
const { link } = await linkFor(cp, {
|
|
472
|
+
LOCAL_AGENT_INCIDENT_DETECTOR: "true", LOCAL_AGENT_WEBHOOK_FORWARD: "true", LOCAL_AGENT_DLP_MODE: "enforce",
|
|
473
|
+
LOCAL_AGENT_INCIDENT_STORE_DIR: path.join(dir, "store"), LOCAL_AGENT_INCIDENT_FLUSH_MS: "300",
|
|
474
|
+
});
|
|
475
|
+
const body = Buffer.from(JSON.stringify({ alerts: [{ status: "firing", labels: { alertname: "HighLatencyException", severity: "critical", service: "checkout" }, annotations: { summary: "java.lang.IllegalStateException: pool exhausted" }, startsAt: "2026-11-03T10:00:00Z" }] }));
|
|
476
|
+
cp.sendData("webhook.forward", { webhookId: uuid(), source: "alertmanager", contentType: "application/json", bodyB64: body.toString("base64") });
|
|
477
|
+
await waitFor(() => cp.acksFromLa.length === 1);
|
|
478
|
+
assert.equal(cp.acksFromLa[0].status, "accepted");
|
|
479
|
+
await waitFor(() => cp.received.some((f) => f.type === "incident.candidate"), 5000);
|
|
480
|
+
const c = cp.received.find((f) => f.type === "incident.candidate").payload;
|
|
481
|
+
assert.equal(c.service, "checkout");
|
|
482
|
+
assert.equal(c.severity, "critical");
|
|
483
|
+
link.close();
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
// ------------------------------------------------------------------------------------------ L2: final ack after the local delivery, resends
|
|
487
|
+
|
|
488
|
+
import { installWebhookReceiver } from "../../src/la-protocol-v2/webhooks/install.js";
|
|
489
|
+
|
|
490
|
+
/** The receiver installed the way runtime.js does it (setHandler with the delivery deadline), on a session whose OWN handlerAckMs is tiny. */
|
|
491
|
+
async function installedRig({ sink, config = cfg({ deliveryMs: 1500 }), now } = {}) {
|
|
492
|
+
const cp = new ReferenceCp({ capabilities: ["incident.candidate.size-model"] });
|
|
493
|
+
const env = inMemory(cp, { mode: "enforce", options: { handlerAckMs: 100 } }).start();
|
|
494
|
+
await waitFor(() => env.session.state === "ready");
|
|
495
|
+
const link = { session: env.session, close() {} };
|
|
496
|
+
const rx = installWebhookReceiver({ link, config: now ? { ...config } : config });
|
|
497
|
+
const calls = [];
|
|
498
|
+
rx.setSink(sink || (async (w) => { calls.push({ id: w.webhookId, body: Buffer.from(w.body) }); }));
|
|
499
|
+
return { cp, env, rx, calls, session: env.session };
|
|
500
|
+
}
|
|
501
|
+
const one = (body, webhookId = uuid()) => ({ params: { webhookId, source: "other", contentType: "text/plain", bodyB64: Buffer.from(body).toString("base64") } });
|
|
502
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
503
|
+
|
|
504
|
+
test("L2: the ack of the LAST chunk waits for a slow local delivery (longer than the session's handlerAckMs) and says `accepted` only after it", async () => {
|
|
505
|
+
let finished = false;
|
|
506
|
+
const { cp, session, rx } = await installedRig({ sink: async () => { await sleep(450); finished = true; } });
|
|
507
|
+
const fr = chunksOf(bodyOf(RAW + 50));
|
|
508
|
+
for (const f of fr) send(cp, f);
|
|
509
|
+
await waitFor(() => cp.acksFromLa.length === 1); // chunk 0: stored
|
|
510
|
+
await sleep(300); // > handlerAckMs (100) and < the sink (450)
|
|
511
|
+
assert.equal(cp.acksFromLa.length, 1, "no ack for the last chunk while the sink runs, although the session's own limit passed");
|
|
512
|
+
await waitFor(() => cp.acksFromLa.length === 2);
|
|
513
|
+
assert.equal(finished, true, "the ack came after the delivery, not before");
|
|
514
|
+
assert.deepEqual(cp.acksFromLa[1], { ackSeq: 2, status: "accepted", errorCode: undefined });
|
|
515
|
+
assert.equal(rx.handler.stats.delivered, 1);
|
|
516
|
+
session.close();
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test("L2: a single-frame webhook is acked after the sink too", async () => {
|
|
520
|
+
let finished = false;
|
|
521
|
+
const { cp, session } = await installedRig({ sink: async () => { await sleep(300); finished = true; } });
|
|
522
|
+
send(cp, one("hello"));
|
|
523
|
+
await sleep(200);
|
|
524
|
+
assert.equal(cp.acksFromLa.length, 0);
|
|
525
|
+
await waitFor(() => cp.acksFromLa.length === 1);
|
|
526
|
+
assert.ok(finished);
|
|
527
|
+
assert.equal(cp.acksFromLa[0].status, "accepted");
|
|
528
|
+
session.close();
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
test("L2: a sink slower than the deadline: the last chunk is `rejected timeout` (clear error), the body is NOT zeroed under the running sink, zeroed once it settles, and a late success is remembered", async () => {
|
|
532
|
+
let release; let kept = null; let runs = 0;
|
|
533
|
+
const gate = new Promise((r) => { release = r; });
|
|
534
|
+
const { cp, session, rx } = await installedRig({ config: cfg({ deliveryMs: 250 }), sink: async (w) => { runs++; kept = w.body; await gate; } });
|
|
535
|
+
const body = bodyOf(RAW + 9);
|
|
536
|
+
const fr = chunksOf(body);
|
|
537
|
+
for (const f of fr) send(cp, f);
|
|
538
|
+
await waitFor(() => cp.acksFromLa.length === 2, 3000);
|
|
539
|
+
assert.deepEqual(cp.acksFromLa[1], { ackSeq: 2, status: "rejected", errorCode: "timeout" });
|
|
540
|
+
assert.equal(session.state, "ready", "non-fatal");
|
|
541
|
+
assert.deepEqual(cp.errorsSent, []);
|
|
542
|
+
assert.ok(kept.equals(body), "the running sink still sees its body");
|
|
543
|
+
assert.equal(rx.handler.stats.deliveryTimeouts, 1);
|
|
544
|
+
// a retry while the first attempt is still running is told to come back later, not processed twice
|
|
545
|
+
for (const f of chunksOf(body, { webhookId: fr[0].params.webhookId })) send(cp, f);
|
|
546
|
+
await waitFor(() => cp.acksFromLa.length === 4);
|
|
547
|
+
assert.deepEqual(cp.acksFromLa[3], { ackSeq: 4, status: "rejected", errorCode: "rate_limited" });
|
|
548
|
+
assert.equal(runs, 1);
|
|
549
|
+
release();
|
|
550
|
+
await waitFor(() => kept.every((b) => b === 0));
|
|
551
|
+
// the first attempt finished late: the webhook WAS delivered, so a retry now is acked without running the sink again
|
|
552
|
+
for (const f of chunksOf(body, { webhookId: fr[0].params.webhookId })) send(cp, f);
|
|
553
|
+
await waitFor(() => cp.acksFromLa.length === 6);
|
|
554
|
+
assert.deepEqual(cp.acksFromLa.slice(4).map((a) => a.status), ["accepted", "accepted"]);
|
|
555
|
+
assert.equal(runs, 1, "not processed again");
|
|
556
|
+
session.close();
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
test("L2: a single frame past the deadline is `rejected timeout` too; the deadline can only be tuned inside 100 ms .. 14 s", () => {
|
|
560
|
+
assert.equal(cfg().deliveryMs, 10_000);
|
|
561
|
+
for (const [v, want] of [["50", 10_000], ["15000", 10_000], ["abc", 10_000], ["100", 100], ["14000", 14_000], ["2500", 2500]]) {
|
|
562
|
+
assert.equal(resolveWebhookConfig({ LOCAL_AGENT_WEBHOOK_FORWARD: "true", LOCAL_AGENT_WEBHOOK_DELIVERY_MS: v }, noop, LIMITS).deliveryMs, want, v);
|
|
563
|
+
}
|
|
564
|
+
assert.deepEqual([cfg().dedupeMs, cfg().dedupeMax], [600_000, 1024]);
|
|
565
|
+
assert.equal(resolveWebhookConfig({ LOCAL_AGENT_WEBHOOK_FORWARD: "true", LOCAL_AGENT_WEBHOOK_DEDUPE_MAX: "3" }, noop, LIMITS).dedupeMax, 1024, "below the minimum: the default");
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
test("L2: single-frame deadline", async () => {
|
|
569
|
+
const { cp, session } = await installedRig({ config: cfg({ deliveryMs: 200 }), sink: () => new Promise(() => {}) });
|
|
570
|
+
send(cp, one("never done"));
|
|
571
|
+
await waitFor(() => cp.acksFromLa.length === 1, 3000);
|
|
572
|
+
assert.deepEqual(cp.acksFromLa[0], { ackSeq: 1, status: "rejected", errorCode: "timeout" });
|
|
573
|
+
session.close();
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
test("L2: a resend of a delivered single-frame webhook (same id, same body) is acked accepted WITHOUT reaching the sink; another body under the same id is a new webhook", async () => {
|
|
577
|
+
const { cp, session, rx, calls } = await installedRig();
|
|
578
|
+
const id = uuid();
|
|
579
|
+
send(cp, one("first body", id));
|
|
580
|
+
send(cp, one("first body", id));
|
|
581
|
+
send(cp, one("another body", id));
|
|
582
|
+
send(cp, one("first body", uuid()));
|
|
583
|
+
await waitFor(() => cp.acksFromLa.length === 4);
|
|
584
|
+
assert.deepEqual(cp.acksFromLa.map((a) => a.status), ["accepted", "accepted", "accepted", "accepted"]);
|
|
585
|
+
assert.equal(calls.length, 3, "the second copy was not processed");
|
|
586
|
+
assert.equal(rx.handler.stats.deduped, 1);
|
|
587
|
+
session.close();
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
test("L2: a resend of a delivered CHUNKED webhook: every chunk acked in order, the sink is not called again; the count of the detector is not inflated", async () => {
|
|
591
|
+
const { cp, session, rx, calls } = await installedRig();
|
|
592
|
+
const body = bodyOf(RAW * 2 + 33);
|
|
593
|
+
const id = uuid();
|
|
594
|
+
for (const f of chunksOf(body, { webhookId: id })) send(cp, f);
|
|
595
|
+
await waitFor(() => cp.acksFromLa.length === 3);
|
|
596
|
+
assert.equal(calls.length, 1);
|
|
597
|
+
for (const f of chunksOf(body, { webhookId: id })) send(cp, f);
|
|
598
|
+
await waitFor(() => cp.acksFromLa.length === 6);
|
|
599
|
+
assert.deepEqual(cp.acksFromLa.slice(3).map((a) => a.status), ["accepted", "accepted", "accepted"]);
|
|
600
|
+
assert.equal(calls.length, 1, "still one delivery");
|
|
601
|
+
assert.equal(rx.handler.stats.deduped, 1);
|
|
602
|
+
assert.equal(rx.handler.assembler.openCount, 0, "a resend is never buffered");
|
|
603
|
+
// and the same id with a different body is processed
|
|
604
|
+
for (const f of chunksOf(bodyOf(RAW + 1), { webhookId: id })) send(cp, f);
|
|
605
|
+
await waitFor(() => cp.acksFromLa.length === 8);
|
|
606
|
+
assert.equal(calls.length, 2);
|
|
607
|
+
session.close();
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
test("L2: the resend path keeps the contract's order rules: a chunk after the last one, a skipped index, a wrong size are refused as before", async () => {
|
|
611
|
+
const { cp, session, calls } = await installedRig();
|
|
612
|
+
const body = bodyOf(RAW * 2 + 5);
|
|
613
|
+
const id = uuid();
|
|
614
|
+
const fr = chunksOf(body, { webhookId: id });
|
|
615
|
+
for (const f of fr) send(cp, f);
|
|
616
|
+
await waitFor(() => cp.acksFromLa.length === 3);
|
|
617
|
+
send(cp, fr[2]); // a chunk after the last one (not a resend: it does not start at 0)
|
|
618
|
+
await waitFor(() => cp.acksFromLa.length === 4);
|
|
619
|
+
assert.deepEqual(cp.acksFromLa[3], { ackSeq: 4, status: "rejected", errorCode: "chunk_out_of_order" });
|
|
620
|
+
send(cp, fr[0]); send(cp, fr[2]); // a resend that skips index 1
|
|
621
|
+
await waitFor(() => cp.acksFromLa.length === 6);
|
|
622
|
+
assert.deepEqual(cp.acksFromLa.slice(4).map((a) => [a.status, a.errorCode]), [["accepted", undefined], ["rejected", "chunk_out_of_order"]]);
|
|
623
|
+
const bad = chunksOf(body, { webhookId: id });
|
|
624
|
+
bad[0].params.bodyB64 = Buffer.alloc(100).toString("base64"); // a resend whose first chunk has the wrong size
|
|
625
|
+
send(cp, bad[0]);
|
|
626
|
+
await waitFor(() => cp.acksFromLa.length === 7);
|
|
627
|
+
assert.deepEqual(cp.acksFromLa[6], { ackSeq: 7, status: "rejected", errorCode: "chunk_inconsistent" });
|
|
628
|
+
assert.equal(calls.length, 1);
|
|
629
|
+
assert.equal(session.state, "ready");
|
|
630
|
+
session.close();
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
test("L2: only a SUCCESSFUL delivery is remembered (a sink that threw is retried), the window is 10 minutes, and the memory is bounded", async () => {
|
|
634
|
+
let t = 5_000_000;
|
|
635
|
+
let fail = true;
|
|
636
|
+
const calls = [];
|
|
637
|
+
const handler = createWebhookHandler({ config: cfg({ dedupeMs: 600_000, dedupeMax: 3 }), now: () => t, getSink: () => async (w) => { calls.push(w.webhookId); if (fail) throw new Error("boom"); } });
|
|
638
|
+
const ctx = (f) => ({ params: f.params, chunk: undefined });
|
|
639
|
+
const a = one("x", uuid());
|
|
640
|
+
assert.deepEqual(await handler.handle(ctx(a)), { reject: "internal" });
|
|
641
|
+
fail = false;
|
|
642
|
+
assert.deepEqual(await handler.handle(ctx(a)), { accept: true });
|
|
643
|
+
assert.equal(calls.length, 2, "the failed delivery was retried");
|
|
644
|
+
assert.deepEqual(await handler.handle(ctx(a)), { accept: true });
|
|
645
|
+
assert.equal(calls.length, 2, "now remembered");
|
|
646
|
+
t += 599_000;
|
|
647
|
+
await handler.handle(ctx(a));
|
|
648
|
+
assert.equal(calls.length, 2, "inside the window");
|
|
649
|
+
t += 2_000;
|
|
650
|
+
await handler.handle(ctx(a));
|
|
651
|
+
assert.equal(calls.length, 3, "10 minutes later it is processed again");
|
|
652
|
+
// bounded: dedupeMax 3 -> the oldest id is forgotten
|
|
653
|
+
const ids = [one("1", uuid()), one("2", uuid()), one("3", uuid()), one("4", uuid())];
|
|
654
|
+
for (const f of ids) await handler.handle(ctx(f));
|
|
655
|
+
const before = calls.length;
|
|
656
|
+
await handler.handle(ctx(ids[3]));
|
|
657
|
+
assert.equal(calls.length, before, "the newest is still remembered");
|
|
658
|
+
await handler.handle(ctx(ids[0]));
|
|
659
|
+
assert.equal(calls.length, before + 1, "the oldest was evicted");
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
test("L2: a new session (not resumed) forgets the chunks in progress AND the resends in progress, but not the delivered ids (they are the point)", async () => {
|
|
663
|
+
const { cp, session, rx, calls } = await installedRig();
|
|
664
|
+
const body = bodyOf(RAW * 2 + 5);
|
|
665
|
+
const id = uuid();
|
|
666
|
+
const fr = chunksOf(body, { webhookId: id });
|
|
667
|
+
for (const f of fr) send(cp, f);
|
|
668
|
+
await waitFor(() => cp.acksFromLa.length === 3);
|
|
669
|
+
send(cp, fr[0]); // a resend starts...
|
|
670
|
+
await waitFor(() => cp.acksFromLa.length === 4);
|
|
671
|
+
rx.handler.clear(); // ...and a new session clears it
|
|
672
|
+
send(cp, fr[1]); // chunk 1 of that resend: nothing in progress, not index 0
|
|
673
|
+
await waitFor(() => cp.acksFromLa.length === 5);
|
|
674
|
+
assert.equal(cp.acksFromLa[4].errorCode, "chunk_out_of_order");
|
|
675
|
+
send(cp, fr[0]);
|
|
676
|
+
await waitFor(() => cp.acksFromLa.length === 6);
|
|
677
|
+
assert.equal(cp.acksFromLa[5].status, "accepted", "the delivered id is still remembered");
|
|
678
|
+
assert.equal(calls.length, 1);
|
|
679
|
+
session.close();
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
test("L2: end to end with the incident detector: the same Alertmanager webhook sent twice produces ONE count", async () => {
|
|
683
|
+
const dir = tmp();
|
|
684
|
+
const cp = new ReferenceCp({ capabilities: ["incident.candidate.size-model"] });
|
|
685
|
+
const { link } = await linkFor(cp, {
|
|
686
|
+
LOCAL_AGENT_INCIDENT_DETECTOR: "true", LOCAL_AGENT_WEBHOOK_FORWARD: "true", LOCAL_AGENT_DLP_MODE: "enforce",
|
|
687
|
+
LOCAL_AGENT_INCIDENT_STORE_DIR: path.join(dir, "store"), LOCAL_AGENT_INCIDENT_FLUSH_MS: "300",
|
|
688
|
+
});
|
|
689
|
+
const body = Buffer.from(JSON.stringify({ alerts: [{ status: "firing", labels: { alertname: "Dup", severity: "critical", service: "checkout" }, annotations: { summary: "java.lang.IllegalStateException: dup pool" }, startsAt: "2026-11-03T10:00:00Z" }] }));
|
|
690
|
+
const id = uuid();
|
|
691
|
+
const frame = { webhookId: id, source: "alertmanager", contentType: "application/json", bodyB64: body.toString("base64") };
|
|
692
|
+
cp.sendData("webhook.forward", frame);
|
|
693
|
+
cp.sendData("webhook.forward", frame);
|
|
694
|
+
await waitFor(() => cp.acksFromLa.length === 2);
|
|
695
|
+
assert.deepEqual(cp.acksFromLa.map((a) => a.status), ["accepted", "accepted"]);
|
|
696
|
+
assert.equal(link.webhooks.handler.stats.deduped, 1);
|
|
697
|
+
await waitFor(() => cp.received.some((f) => f.type === "incident.candidate"), 5000);
|
|
698
|
+
await sleep(700);
|
|
699
|
+
const total = cp.received.filter((f) => f.type === "incident.candidate").reduce((n, f) => n + f.payload.count, 0);
|
|
700
|
+
assert.equal(total, 1, "one occurrence, not two");
|
|
701
|
+
link.close();
|
|
702
|
+
});
|