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.
Files changed (72) hide show
  1. package/.claude/context.md +2 -1
  2. package/AGENTS.md +1 -1
  3. package/CLAUDE.md +1 -1
  4. package/docs/la-jobs-and-llm.md +56 -0
  5. package/docs/la-protocol-v2.md +19 -2
  6. package/env.example +11 -0
  7. package/package.json +1 -1
  8. package/src/incident-detector/README.md +2 -1
  9. package/src/incident-detector/aggregator.js +8 -3
  10. package/src/incident-detector/detector.js +2 -1
  11. package/src/incident-detector/emitter.js +16 -6
  12. package/src/incident-detector/index.js +3 -3
  13. package/src/incident-detector/sources/webhook-source.js +109 -0
  14. package/src/la-protocol-v2/content-handlers/install.js +27 -1
  15. package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +26 -18
  16. package/src/la-protocol-v2/contract/README.md +127 -3
  17. package/src/la-protocol-v2/contract/config.update.schema.json +85 -1
  18. package/src/la-protocol-v2/contract/hello.schema.json +26 -1
  19. package/src/la-protocol-v2/contract/incident.candidate.schema.json +11 -0
  20. package/src/la-protocol-v2/contract/index.json +9 -2
  21. package/src/la-protocol-v2/contract/job.assign.schema.json +31 -0
  22. package/src/la-protocol-v2/contract/ready.schema.json +8 -0
  23. package/src/la-protocol-v2/contract/register.schema.json +26 -1
  24. package/src/la-protocol-v2/contract/webhook.forward.schema.json +72 -3
  25. package/src/la-protocol-v2/contract.js +45 -7
  26. package/src/la-protocol-v2/jobs/config.js +60 -0
  27. package/src/la-protocol-v2/jobs/events.js +106 -0
  28. package/src/la-protocol-v2/jobs/install.js +37 -0
  29. package/src/la-protocol-v2/jobs/manager.js +276 -0
  30. package/src/la-protocol-v2/jobs/options.js +56 -0
  31. package/src/la-protocol-v2/jobs/smoke-runner.js +39 -0
  32. package/src/la-protocol-v2/llm/client.js +311 -0
  33. package/src/la-protocol-v2/llm/config.js +61 -0
  34. package/src/la-protocol-v2/llm/install.js +17 -0
  35. package/src/la-protocol-v2/llm-key/config.js +29 -0
  36. package/src/la-protocol-v2/llm-key/handler.js +33 -0
  37. package/src/la-protocol-v2/llm-key/install.js +21 -0
  38. package/src/la-protocol-v2/llm-key/store.js +183 -0
  39. package/src/la-protocol-v2/runtime.js +71 -4
  40. package/src/la-protocol-v2/session.js +47 -13
  41. package/src/la-protocol-v2/webhooks/assembler.js +85 -0
  42. package/src/la-protocol-v2/webhooks/config.js +51 -0
  43. package/src/la-protocol-v2/webhooks/handler.js +125 -0
  44. package/src/la-protocol-v2/webhooks/install.js +22 -0
  45. package/test/contract/la-protocol-v2/vectors/ack.vectors.json +91 -0
  46. package/test/contract/la-protocol-v2/vectors/config.update.vectors.json +1339 -0
  47. package/test/contract/la-protocol-v2/vectors/hello.vectors.json +261 -0
  48. package/test/contract/la-protocol-v2/vectors/incident.candidate.vectors.json +623 -0
  49. package/test/contract/la-protocol-v2/vectors/job.assign.vectors.json +1538 -0
  50. package/test/contract/la-protocol-v2/vectors/ready.vectors.json +599 -0
  51. package/test/contract/la-protocol-v2/vectors/register.vectors.json +275 -0
  52. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-after-last.json +186 -0
  53. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-body-hash-changes.json +186 -0
  54. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-last-flag-wrong.json +186 -0
  55. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-non-last-too-short.json +152 -0
  56. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-out-of-order.json +152 -0
  57. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-sha-mismatch.json +186 -0
  58. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-total-changes.json +186 -0
  59. package/test/contract/la-protocol-v2/vectors/traces/valid-webhook-chunks-in-order.json +218 -0
  60. package/test/contract/la-protocol-v2/vectors/webhook.forward.vectors.json +1263 -0
  61. package/test/incident-detector/size-model.test.js +127 -0
  62. package/test/la-protocol-v2/contract-v21.test.js +107 -0
  63. package/test/la-protocol-v2/contract.test.js +6 -4
  64. package/test/la-protocol-v2/helpers/fake-llm.js +64 -0
  65. package/test/la-protocol-v2/helpers/reference-cp.js +8 -4
  66. package/test/la-protocol-v2/jobs-e2e.test.js +405 -0
  67. package/test/la-protocol-v2/jobs-manager.test.js +370 -0
  68. package/test/la-protocol-v2/jobs-options.test.js +288 -0
  69. package/test/la-protocol-v2/llm-client.test.js +380 -0
  70. package/test/la-protocol-v2/llm-key.test.js +509 -0
  71. package/test/la-protocol-v2/traces.test.js +3 -3
  72. package/test/la-protocol-v2/webhook-receiver.test.js +702 -0
@@ -0,0 +1,183 @@
1
+ /**
2
+ * store.js - the virtual LLM key of the AI gateway, as the Local Agent holds it (F3-09 consumer, contract 2.1 (a)).
3
+ *
4
+ * ONE key per agent (= per tenant: the holder belongs to one link). `set` with another keyId replaces it atomically, the same keyId
5
+ * again is idempotent, `revoke` drops the copy (an unknown id is a no-op: it never tells which key is held). The key is dropped by itself
6
+ * at `expiresAt`.
7
+ *
8
+ * Where it lives: in a private field of this object (never a property: JSON.stringify, util.inspect, structuredClone and a heap
9
+ * snapshot of enumerable state do not show it) and, when `file` is configured, in a file mode 0600 inside a 0700 directory that is NOT
10
+ * under any workspace root. Revocation overwrites the file with zeros before unlinking it.
11
+ * Never: logged, put in an ack, an audit record, a metric, an error message, a command line or a URL. `describe()` returns metadata only
12
+ * (models, expiry, whether a key is held); the key leaves this object only through `request()`, as an Authorization header.
13
+ *
14
+ * There is NO LLM client in the Local Agent today (the thinking happens in vh-agents via vh-litellm). `request()` is the hook a future
15
+ * client uses: same https endpoint the control plane gave, redirects refused, the caller cannot choose another host or header.
16
+ */
17
+ import crypto from "node:crypto";
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+
21
+ const ENDPOINT = /^https:\/\/[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?(:[0-9]{1,5})?(\/[A-Za-z0-9._~/-]{0,200})?$/;
22
+ const KEY = /^[A-Za-z0-9._~+/=-]{16,256}$/;
23
+ const SHA256 = /^[0-9a-f]{64}$/;
24
+ const PATH_AND_QUERY = /^\/(?!\/)[A-Za-z0-9._~!$&'()*+,;=:@%/?-]{0,2000}$/;
25
+ const MAX_TIMER_MS = 2 ** 31 - 1;
26
+
27
+ export class LlmKeyError extends Error {
28
+ constructor(code) { super(code); this.name = "LlmKeyError"; this.code = code; } // codes only: "no_key" | "expired" | "bad_path"
29
+ }
30
+
31
+ const sha256Hex = (s) => crypto.createHash("sha256").update(Buffer.from(s, "utf8")).digest("hex");
32
+
33
+ /** realpath of the deepest existing ancestor + the part that does not exist yet (symlinks of the existing part are resolved). */
34
+ function realish(p) {
35
+ const rest = [];
36
+ let cur = path.resolve(p);
37
+ for (;;) {
38
+ try { return path.join(fs.realpathSync(cur), ...rest.reverse()); } catch { /* does not exist: go up */ }
39
+ const up = path.dirname(cur);
40
+ if (up === cur) return path.join(cur, ...rest.reverse());
41
+ rest.push(path.basename(cur));
42
+ cur = up;
43
+ }
44
+ }
45
+
46
+ /** Is `file` inside any of `roots`? (both sides resolved through symlinks, also when the file's directory does not exist yet) */
47
+ export function insideAny(file, roots) {
48
+ const f = realish(path.dirname(file));
49
+ return roots.some((r) => {
50
+ if (!r) return false;
51
+ const rel = path.relative(realish(r), f);
52
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
53
+ });
54
+ }
55
+
56
+ export function createLlmKeyHolder({ file = null, workspaceRoots = () => [], now = Date.now, timers = { setTimeout, clearTimeout }, audit = null, log = console } = {}) {
57
+ let held = null; // { keyId, endpoint, models, expiresAt, expiresMs, key }
58
+ let timer = null;
59
+ let fileOk = !!file;
60
+ const stats = { set: 0, replaced: 0, revoked: 0, revokeUnknown: 0, expired: 0, refused: 0, requests: 0, persistErrors: 0 };
61
+ const warn = (m) => (log.warn || log.log).call(log, m);
62
+ const note = (event, extra = {}) => { try { audit?.record({ event, ...extra }); } catch { /* the audit never breaks the data path */ } };
63
+
64
+ if (file && insideAny(file, workspaceRoots())) {
65
+ fileOk = false;
66
+ warn("[la-llm-key] LOCAL_AGENT_LLM_KEY_FILE is inside a workspace: the key stays in memory only");
67
+ }
68
+
69
+ function disarm() { if (timer) { timers.clearTimeout(timer); timer = null; } }
70
+ function arm() {
71
+ disarm();
72
+ if (!held) return;
73
+ const left = held.expiresMs - now();
74
+ timer = timers.setTimeout(() => { timer = null; if (held && held.expiresMs <= now()) { drop(); stats.expired++; note("llm_key_expired"); } else arm(); }, Math.max(0, Math.min(left, MAX_TIMER_MS)));
75
+ if (timer && typeof timer.unref === "function") timer.unref();
76
+ }
77
+
78
+ function wipeFile() {
79
+ if (!file || !fileOk) return;
80
+ try {
81
+ const size = fs.statSync(file).size;
82
+ const fd = fs.openSync(file, "r+");
83
+ try { fs.writeSync(fd, Buffer.alloc(size)); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
84
+ } catch { /* nothing to overwrite */ }
85
+ try { fs.unlinkSync(file); } catch { /* already gone */ }
86
+ }
87
+
88
+ function persist(entry) {
89
+ if (!file || !fileOk) return;
90
+ try {
91
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
92
+ const tmp = `${file}.${process.pid}.tmp`;
93
+ const body = JSON.stringify({ v: 1, keyId: entry.keyId, endpoint: entry.endpoint, models: entry.models, expiresAt: entry.expiresAt, key: entry.key });
94
+ fs.writeFileSync(tmp, body, { mode: 0o600 });
95
+ fs.chmodSync(tmp, 0o600);
96
+ fs.renameSync(tmp, file);
97
+ } catch {
98
+ stats.persistErrors++;
99
+ warn("[la-llm-key] the key could not be written to its file; it stays in memory only"); // never the path, never the error text
100
+ }
101
+ }
102
+
103
+ function drop() { held = null; disarm(); wipeFile(); }
104
+
105
+ /** @returns {boolean} true = a usable key is held (also drops an expired one). */
106
+ function active() {
107
+ if (!held) return false;
108
+ if (held.expiresMs <= now()) { drop(); stats.expired++; note("llm_key_expired"); return false; }
109
+ return true;
110
+ }
111
+
112
+ const holder = {
113
+ stats,
114
+ /**
115
+ * The 2.1 `llmKey.set` already validated by the schema; this re-checks what the schema cannot (id = sha256(key), expiry in the future)
116
+ * and what a bug could break (shapes), so the holder is safe on its own.
117
+ * @returns {{ok:true, replaced:boolean} | {ok:false, code:"schema_violation"}}
118
+ */
119
+ set({ keyId, endpoint, key, models, expiresAt }) {
120
+ const expiresMs = Date.parse(expiresAt);
121
+ const shapesOk = SHA256.test(keyId) && typeof endpoint === "string" && ENDPOINT.test(endpoint) && typeof key === "string" && KEY.test(key)
122
+ && Array.isArray(models) && models.length >= 1 && models.length <= 32 && models.every((m) => typeof m === "string" && m.length > 0 && m.length <= 96) && Number.isFinite(expiresMs);
123
+ if (!shapesOk || sha256Hex(key) !== keyId || expiresMs <= now()) { stats.refused++; note("llm_key_refused"); return { ok: false, code: "schema_violation" }; }
124
+ const replaced = !!held && held.keyId !== keyId;
125
+ held = { keyId, endpoint: endpoint.replace(/\/+$/, ""), models: Object.freeze([...models]), expiresAt, expiresMs, key };
126
+ persist(held);
127
+ arm();
128
+ if (replaced) stats.replaced++;
129
+ stats.set++;
130
+ note("llm_key_set", { models: models.length, replaced });
131
+ return { ok: true, replaced };
132
+ },
133
+ /** @returns {{ok:true, dropped:boolean}} an id the holder does not have is a no-op (acked `accepted` all the same). */
134
+ revoke(keyId) {
135
+ if (held && typeof keyId === "string" && held.keyId === keyId) { drop(); stats.revoked++; note("llm_key_revoked"); return { ok: true, dropped: true }; }
136
+ stats.revokeUnknown++;
137
+ return { ok: true, dropped: false };
138
+ },
139
+ /** Loads the key file left by a previous run (when `file` is set): kept only when it is private, valid and not expired. */
140
+ load() {
141
+ if (!file || !fileOk) return false;
142
+ try {
143
+ const st = fs.statSync(file);
144
+ if (!st.isFile() || (st.mode & 0o077) !== 0 || st.size > 4096) { wipeFile(); return false; } // readable by others or odd: never trusted
145
+ const o = JSON.parse(fs.readFileSync(file, "utf8"));
146
+ if (!o || o.v !== 1) { wipeFile(); return false; }
147
+ const r = holder.set({ keyId: o.keyId, endpoint: o.endpoint, key: o.key, models: o.models, expiresAt: o.expiresAt });
148
+ if (!r.ok) { wipeFile(); return false; }
149
+ stats.set--; // a reload is not a new delivery
150
+ return true;
151
+ } catch { return false; }
152
+ },
153
+ /**
154
+ * F3-10: does this reference (`job.assign.llm.keyRef`) name the key held right now? Compares in constant time and answers a boolean only:
155
+ * the id never leaves the holder. A reference is the key id or its first 8..64 hex characters (the `key_short` the control plane logs).
156
+ */
157
+ matches(ref) {
158
+ if (!active() || typeof ref !== "string" || !/^[0-9a-f]{8,64}$/.test(ref)) return false;
159
+ const a = Buffer.from(held.keyId.slice(0, ref.length));
160
+ const b = Buffer.from(ref);
161
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
162
+ },
163
+ /** Metadata only. */
164
+ describe() { return active() ? { held: true, models: [...held.models], expiresAt: held.expiresAt } : { held: false }; },
165
+ get held() { return active(); },
166
+ /**
167
+ * The one way out for the key: an authorized request to the AI gateway the control plane named. `pathAndQuery` starts with one "/",
168
+ * the host is never the caller's choice, `authorization` from the caller is replaced, redirects are refused (a 3xx would carry the header elsewhere).
169
+ */
170
+ async request(pathAndQuery, init = {}, { fetchImpl = globalThis.fetch } = {}) {
171
+ if (!active()) throw new LlmKeyError(held ? "expired" : "no_key");
172
+ if (typeof pathAndQuery !== "string" || !PATH_AND_QUERY.test(pathAndQuery) || pathAndQuery.split("?")[0].split("/").includes("..")) throw new LlmKeyError("bad_path");
173
+ const url = held.endpoint + pathAndQuery;
174
+ if (new URL(url).origin !== new URL(held.endpoint).origin) throw new LlmKeyError("bad_path");
175
+ const headers = new Headers(init.headers || {});
176
+ headers.set("authorization", `Bearer ${held.key}`);
177
+ stats.requests++;
178
+ return fetchImpl(url, { ...init, headers, redirect: "error" });
179
+ },
180
+ close() { disarm(); held = null; }, // process shutdown: memory only; a key FILE is kept for the next start
181
+ };
182
+ return holder;
183
+ }
@@ -8,6 +8,11 @@ import path from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { createV2Link } from "./link.js";
10
10
  import { resolveContentHandlersConfig } from "./content-handlers/config.js";
11
+ import { resolveLlmKeyConfig } from "./llm-key/config.js";
12
+ import { resolveWebhookConfig } from "./webhooks/config.js";
13
+ import { resolveLlmClientConfig } from "./llm/config.js";
14
+ import { resolveJobsConfig } from "./jobs/config.js";
15
+ import { loadContract } from "./contract.js";
11
16
 
12
17
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
13
18
  const WORKSPACE_ID = /^[A-Za-z0-9_.-]{0,64}$/;
@@ -35,7 +40,7 @@ async function diskFreeBytes(dir) {
35
40
  /**
36
41
  * @returns {Promise} the link, or null (with a warning) when v2 cannot run here: then the tunnel is the plain v1 client.
37
42
  */
38
- export async function buildV2Link({ tenantId, agentId, signing, wsManager, getWorkspaceRoot, log = console, env = process.env }) {
43
+ export async function buildV2Link({ tenantId, agentId, signing, wsManager, getWorkspaceRoot, log = console, env = process.env, jobRunner = null, onJobResult = null, llmFetch = undefined }) {
39
44
  const tenant = String(tenantId || "").toLowerCase();
40
45
  if (!UUID.test(tenant)) { log.warn("[la-v2] off: the tenant id is not a UUID"); return null; }
41
46
  if (!agentId) { log.warn("[la-v2] off: no agent id (authorizationId in ~/.deepdebug/config.json or LOCAL_AGENT_ID)"); return null; }
@@ -59,8 +64,44 @@ export async function buildV2Link({ tenantId, agentId, signing, wsManager, getWo
59
64
  // F3-11b: incident detector (logs -> incident.candidate, metadata only), behind LOCAL_AGENT_INCIDENT_DETECTOR (default off: nothing below changes anything)
60
65
  const { resolveIncidentDetectorConfig } = await import("../incident-detector/config.js");
61
66
  const incidentCfg = resolveIncidentDetectorConfig(env, (m) => log.warn(m));
62
- const hasIncidentSource = incidentCfg.enabled && (incidentCfg.files.length > 0 || incidentCfg.stdin || !!incidentCfg.loki);
67
+ // F3-12 consumer: webhook.forward receiver, behind LOCAL_AGENT_WEBHOOK_FORWARD (default off). The incident detector is its local consumer:
68
+ // without it there is nobody to hand a webhook to, so the handler is NOT registered and no webhook capability is announced.
69
+ let webhookCfg = resolveWebhookConfig(env, (m) => log.warn(m), loadContract().limits);
70
+ if (webhookCfg.enabled && !incidentCfg.enabled) {
71
+ (log.error || log.warn).call(log, "[la-webhook] ERROR: LOCAL_AGENT_WEBHOOK_FORWARD=true but LOCAL_AGENT_INCIDENT_DETECTOR is not true: there is no local consumer, the webhook receiver is NOT registered and no webhook capability is announced");
72
+ webhookCfg = { enabled: false };
73
+ }
74
+ // F3-09 consumer: virtual LLM key of the AI gateway (config.update.llmKey), behind LOCAL_AGENT_LLM_KEY (default off)
75
+ const llmCfg = resolveLlmKeyConfig(env, (m) => log.warn(m));
76
+ // F3-10: LLM client (LOCAL_AGENT_LLM_CLIENT) and job.assign receiver (LOCAL_AGENT_JOBS), both default off. Both need DLP enforce (no opt-out);
77
+ // the client needs the key holder; the receiver needs a runner (and the client unless the runner says it does not think).
78
+ const dlpEnforce = async () => (await import("../dlp/index.js")).dlpConfigFromEnv(env).mode === "enforce";
79
+ const err = (m) => (log.error || log.warn).call(log, m);
80
+ let llmClientCfg = resolveLlmClientConfig(env, (m) => log.warn(m));
81
+ if (llmClientCfg.enabled && !llmCfg.enabled) { err("[la-llm] ERROR: LOCAL_AGENT_LLM_CLIENT=true but LOCAL_AGENT_LLM_KEY is not true: there is no key holder, the LLM client is NOT installed"); llmClientCfg = { enabled: false }; }
82
+ if (llmClientCfg.enabled && !(await dlpEnforce())) { err("[la-llm] ERROR: LOCAL_AGENT_LLM_CLIENT=true but LOCAL_AGENT_DLP_MODE is not enforce: prompts would leave this machine unmasked, the LLM client is NOT installed"); llmClientCfg = { enabled: false }; }
83
+ let jobsCfg = resolveJobsConfig(env, (m) => log.warn(m));
84
+ let runner = null;
85
+ if (jobsCfg.enabled) {
86
+ const { validateRunner } = await import("./jobs/manager.js");
87
+ const given = jobRunner && validateRunner(jobRunner) ? jobRunner : null;
88
+ const needsLlm = given ? given.needsLlm !== false : jobsCfg.runner === "smoke";
89
+ if (jobRunner && !given) err("[la-jobs] ERROR: the runner passed to buildV2Link does not implement start()/cancel(); it is ignored");
90
+ if (!given && !jobsCfg.runner) { err("[la-jobs] ERROR: LOCAL_AGENT_JOBS=true but there is no runner (LOCAL_AGENT_JOBS_RUNNER=smoke or an injected one): job.assign is NOT registered"); jobsCfg = { enabled: false }; }
91
+ else if (!(await dlpEnforce())) { err("[la-jobs] ERROR: LOCAL_AGENT_JOBS=true but LOCAL_AGENT_DLP_MODE is not enforce: job.assign is NOT registered and no job capability is announced"); jobsCfg = { enabled: false }; }
92
+ else if (needsLlm && !llmClientCfg.enabled) { err("[la-jobs] ERROR: LOCAL_AGENT_JOBS=true with a runner that needs the LLM client, but the client is not installed (LOCAL_AGENT_LLM_CLIENT, LOCAL_AGENT_LLM_KEY, DLP enforce): job.assign is NOT registered"); jobsCfg = { enabled: false }; }
93
+ else runner = given;
94
+ }
95
+ const hasIncidentSource = incidentCfg.enabled && (incidentCfg.files.length > 0 || incidentCfg.stdin || !!incidentCfg.loki || webhookCfg.enabled);
63
96
  const incidentCaps = hasIncidentSource ? ["incident.candidate"] : [];
97
+ const webhookCaps = webhookCfg.enabled ? ["webhook.forward", "webhook.chunked"] : [];
98
+ const llmCaps = llmCfg.enabled ? ["llm.key"] : [];
99
+ const jobCaps = jobsCfg.enabled ? ["job.assign"] : []; // F3-10: this agent runs jobs (the CP sends job.assign only to a session that announced it)
100
+ // F3-10 (contract 2.1): `job.options` = the runner understands job.assign `options` (dryRun, createPullRequest, ...). Only a runner that declared supportsOptions
101
+ // (the smoke runner does); an injected one that did not never gets options, and the CP, without this capability, refuses a dry run instead of sending it.
102
+ const jobOptionCaps = jobsCfg.enabled && (runner ? runner.supportsOptions === true : jobsCfg.runner === "smoke") ? ["job.options"] : [];
103
+ // contract 2.1: announced only when a 2.1 feature is on, so with every flag off register/hello are exactly what they were
104
+ const v21Caps = webhookCaps.length || llmCaps.length || incidentCaps.length ? ["contract.v2.1"] : [];
64
105
  const link = createV2Link({
65
106
  log,
66
107
  sessionOptions: {
@@ -70,7 +111,10 @@ export async function buildV2Link({ tenantId, agentId, signing, wsManager, getWo
70
111
  signing,
71
112
  laVersion: agentVersion(),
72
113
  // only what this build implements: the transport, the handshake and the v1 commands on the same connection
73
- capabilities: ["protocol.v2", "v1.commands", "chunk.base64", ...contentCaps, ...incidentCaps],
114
+ capabilities: ["protocol.v2", "v1.commands", "chunk.base64", ...contentCaps, ...incidentCaps, ...webhookCaps, ...llmCaps, ...jobCaps, ...jobOptionCaps, ...v21Caps],
115
+ // contract 2.1 (e): `llm.key.held` is a snapshot taken at every connect (register and hello alike): announced only while the holder has a
116
+ // valid, unexpired key (a revoked or expired one is not announced from the next connection on). `link` is assigned below, before any attach.
117
+ dynamicCapabilities: () => (llmCfg.enabled && link.llm && link.llm.holder.held ? ["llm.key.held"] : []),
74
118
  getWorkspaces: () => {
75
119
  const open = (wsManager?.list?.() || []).map((w) => ({
76
120
  workspaceId: String(w.id || ""),
@@ -92,9 +136,32 @@ export async function buildV2Link({ tenantId, agentId, signing, wsManager, getWo
92
136
  const { installContentHandlers } = await import("./content-handlers/install.js");
93
137
  installContentHandlers({ link, tenantId: tenant, wsManager, getWorkspaceRoot, config: contentCfg, env, log });
94
138
  }
139
+ if (llmCfg.enabled) {
140
+ const { installLlmKey } = await import("./llm-key/install.js");
141
+ const roots = () => [getWorkspaceRoot(), ...(wsManager?.list?.() || []).map((w) => w && w.root)].filter(Boolean);
142
+ installLlmKey({ link, config: llmCfg, workspaceRoots: roots, log });
143
+ }
144
+ if (llmClientCfg.enabled) {
145
+ const { installLlmClient } = await import("./llm/install.js");
146
+ installLlmClient({ link, config: llmClientCfg, tenantId: tenant, agentId, env, ...(llmFetch ? { fetchImpl: llmFetch } : {}) });
147
+ }
148
+ if (jobsCfg.enabled) {
149
+ const { installJobs } = await import("./jobs/install.js");
150
+ installJobs({ link, tenantId: tenant, wsManager, getWorkspaceRoot, config: jobsCfg, env, log, runner, onResult: onJobResult });
151
+ }
152
+ let webhookSource = null;
153
+ if (webhookCfg.enabled) {
154
+ const { createWebhookSource } = await import("../incident-detector/sources/webhook-source.js");
155
+ webhookSource = createWebhookSource({ maxEntries: webhookCfg.maxEntries, maxBytes: webhookCfg.maxBodyBytes, maxEntryBytes: incidentCfg.maxEntryBytes, maxStackLines: incidentCfg.maxStackLines });
156
+ }
95
157
  if (incidentCfg.enabled) {
96
158
  const { installIncidentDetector } = await import("../incident-detector/index.js");
97
- installIncidentDetector({ link, workspaceId: WORKSPACE_ID.test(identityWs) ? identityWs : "", config: incidentCfg, env, log });
159
+ installIncidentDetector({ link, workspaceId: WORKSPACE_ID.test(identityWs) ? identityWs : "", config: incidentCfg, env, log, extraSources: webhookSource ? [webhookSource] : [], model: `stack-detector-${agentVersion()}` });
160
+ }
161
+ if (webhookSource) {
162
+ const { installWebhookReceiver } = await import("./webhooks/install.js");
163
+ const receiver = installWebhookReceiver({ link, config: webhookCfg });
164
+ receiver.setSink((webhook) => webhookSource.deliver(webhook));
98
165
  }
99
166
  return link;
100
167
  }
@@ -32,6 +32,8 @@ const WORKSPACE_ID = /^[A-Za-z0-9_.-]{0,64}$/;
32
32
  const SHA256 = /^[0-9a-f]{64}$/;
33
33
  const CAPABILITY = /^[a-z0-9]+([._-][a-z0-9]+)*$/;
34
34
  const DEDUPE_ENTRIES = 512;
35
+ // Contract 2.1: two stateless rules the handlers apply themselves (non-fatal `rejected` ack, state checked before size, as in the reference model)
36
+ const DEFERRED_RULES = Object.freeze(["webhook-chunk-size", "llmKey-id-sha256"]);
35
37
  const noop = () => {};
36
38
 
37
39
  const DEFAULTS = Object.freeze({
@@ -58,6 +60,8 @@ function deepEqual(a, b) {
58
60
  }
59
61
 
60
62
  export class V2Session extends EventEmitter {
63
+ #peerCaps = new Set();
64
+
61
65
  /**
62
66
  * @param {object} o
63
67
  * @param {string} o.tenantId lower-case UUID of the tenant (configured, never read from a payload)
@@ -66,6 +70,8 @@ export class V2Session extends EventEmitter {
66
70
  * @param {{mode:string}} o.signing the signing config of request-signing.js (mode + trusted keys + nonce cache)
67
71
  * @param {string} o.laVersion semver
68
72
  * @param {string[]} [o.capabilities]
73
+ * @param {() => string[]} [o.dynamicCapabilities] announced in addition at EVERY attach (register and hello of that attach carry the same
74
+ * snapshot): what can change between connections, e.g. `llm.key.held` (contract 2.1 (e)) only while a valid key is held
69
75
  * @param {() => Array<{workspaceId:string,pathSha256?:string}>} [o.getWorkspaces]
70
76
  * @param {() => {diskFreeBytes?:number, graphify?:object}} [o.getHealth]
71
77
  * @param {{info:Function,warn:Function}} [o.log]
@@ -81,6 +87,7 @@ export class V2Session extends EventEmitter {
81
87
  this.signing = o.signing;
82
88
  this.laVersion = o.laVersion;
83
89
  this.capabilities = [...new Set(o.capabilities || [])].filter((c) => CAPABILITY.test(c) && c.length <= 48).sort();
90
+ this.dynamicCapabilities = typeof o.dynamicCapabilities === "function" ? o.dynamicCapabilities : null;
84
91
  this.getWorkspaces = o.getWorkspaces || (() => []);
85
92
  this.getHealth = o.getHealth || (() => ({}));
86
93
  this.log = o.log || { info: noop, warn: noop };
@@ -95,12 +102,13 @@ export class V2Session extends EventEmitter {
95
102
  this.state = STATE.IDLE;
96
103
  this.sessionId = null; // set by `ready`
97
104
  this.everReady = false;
105
+ this.#peerCaps = new Set(); // what the CP announced in `ready.capabilities` (2.1); empty = a 2.0 CP
98
106
  this.sendSeq = 0; // last LA->CP data seq assigned
99
107
  this.lastRecvSeq = 0; // highest contiguous CP->LA data seq received
100
108
  this.recvRing = new Map(); // seq -> msgId of the last DEDUPE_ENTRIES received
101
109
  this.unacked = new Map(); // seq -> outbound record, in seq order
102
110
  this.waiters = []; // frames waiting for a window slot (FIFO)
103
- this.handlers = new Map(); // type -> fn(ctx) -> {accept:true}|{reject:code}
111
+ this.handlers = new Map(); // type -> { fn(ctx) -> {accept:true}|{reject:code}, ackMs }
104
112
  this.tracker = new ChunkStreamTracker(this.limits);
105
113
  this.transport = null;
106
114
  this.inbound = Promise.resolve();
@@ -114,10 +122,25 @@ export class V2Session extends EventEmitter {
114
122
 
115
123
  // ---------------------------------------------------------------------------------------- public API
116
124
 
117
- /** Registers the handler of a CP->LA data type. Without one the frame is acked `rejected` (protocol_unsupported). */
118
- setHandler(type, fn) {
125
+ /**
126
+ * Registers the handler of a CP->LA data type. Without one the frame is acked `rejected` (protocol_unsupported).
127
+ * `opts.ackMs`: how long this handler may take before the session acks `accepted` on its own (default `handlerAckMs`, 2 s). A handler whose ack
128
+ * MEANS something (webhook.forward: the last chunk is acked only after the local delivery) raises it and enforces its own, shorter timeout.
129
+ * Inbound frames are processed one at a time, so a slow handler delays the frames behind it: keep this small (the CP retransmits after 5 s).
130
+ */
131
+ setHandler(type, fn, opts = {}) {
119
132
  if (!this.contract.canReceive(type) || this.contract.typeInfo(type).control) throw new ProtocolError("unknown_type");
120
- this.handlers.set(type, fn);
133
+ const ackMs = Number.isFinite(opts.ackMs) ? Math.min(Math.max(opts.ackMs, 1), 14_000) : null;
134
+ this.handlers.set(type, { fn, ackMs });
135
+ }
136
+
137
+ /** The capabilities of THIS attach: the static ones plus the dynamic ones, sorted, deduplicated, valid names only. */
138
+ #announced() {
139
+ let extra = [];
140
+ try { extra = this.dynamicCapabilities ? this.dynamicCapabilities() : []; } catch { extra = []; }
141
+ const all = new Set([...this.capabilities, ...(Array.isArray(extra) ? extra : [])].filter((c) => typeof c === "string" && CAPABILITY.test(c) && c.length <= 48));
142
+ if (!all.has("llm.key")) all.delete("llm.key.held"); // contract 2.1 (e): `llm.key.held` alone is a schema violation, so it is never sent alone
143
+ return [...all].sort();
121
144
  }
122
145
 
123
146
  /** A new socket is open: starts (or resumes) the handshake. */
@@ -129,10 +152,11 @@ export class V2Session extends EventEmitter {
129
152
  this.transport = transport;
130
153
  this.state = STATE.AWAIT_READY;
131
154
  for (const rec of this.unacked.values()) this.#cancelRecTimer(rec);
155
+ const capabilities = this.#announced();
132
156
  const payload = {
133
157
  protocol: 2,
134
158
  laVersion: this.laVersion,
135
- capabilities: this.capabilities,
159
+ capabilities,
136
160
  signatureMode: this.signing.mode,
137
161
  workspaces: this.#workspaces(),
138
162
  ...(this.everReady && this.sessionId ? { resume: { sessionId: this.sessionId, lastRecvSeq: this.lastRecvSeq } } : {}),
@@ -141,7 +165,7 @@ export class V2Session extends EventEmitter {
141
165
  const health = this.getHealth() || {};
142
166
  const hello = {
143
167
  laVersion: this.laVersion,
144
- capabilities: this.capabilities,
168
+ capabilities,
145
169
  signatureMode: this.signing.mode,
146
170
  clockEpochMs: this.now(),
147
171
  diskFreeBytes: Number.isSafeInteger(health.diskFreeBytes) && health.diskFreeBytes >= 0 ? health.diskFreeBytes : 0,
@@ -227,6 +251,12 @@ export class V2Session extends EventEmitter {
227
251
  this.waiters = keep;
228
252
  }
229
253
 
254
+ /** Did the CP announce this capability in its last `ready` (contract 2.1)? Absent = 2.0 behaviour. */
255
+ peerHas(capability) { return this.#peerCaps.has(capability); }
256
+
257
+ /** A copy of what the CP announced in its last `ready` (changing it changes nothing). */
258
+ get peerCapabilities() { return [...this.#peerCaps]; }
259
+
230
260
  get snapshot() {
231
261
  return { state: this.state, sessionId: this.sessionId, sendSeq: this.sendSeq, lastRecvSeq: this.lastRecvSeq,
232
262
  unacked: this.unacked.size, waiting: this.waiters.length, limits: { ...this.limits }, stats: { ...this.stats } };
@@ -392,7 +422,7 @@ export class V2Session extends EventEmitter {
392
422
  if (!isPlainObject(f)) return this.#refuse("bad_frame");
393
423
  const ref = { refSeq: Number.isSafeInteger(f.seq) && f.seq > 0 ? f.seq : undefined, refMsgId: typeof f.msgId === "string" && f.msgId.length === 36 ? f.msgId : undefined };
394
424
  // 3-4. allowlist + closed schema
395
- const v = this.contract.validateFrame(f);
425
+ const v = this.contract.validateFrame(f, { skipRules: DEFERRED_RULES });
396
426
  if (!v.ok) return this.#refuse(v.code, ref);
397
427
  // 5-6. tenant and agent are the configured ones
398
428
  if (f.tenantId !== this.tenantId) return this.#refuse("tenant_mismatch", ref);
@@ -453,6 +483,7 @@ export class V2Session extends EventEmitter {
453
483
  };
454
484
  this.tracker.chunkRawBytes = this.limits.chunkRawBytes;
455
485
  this.tracker.maxChunks = this.limits.maxChunks;
486
+ this.#peerCaps = new Set(Array.isArray(p.capabilities) ? p.capabilities : []);
456
487
  this.#cancelTimer("handshake");
457
488
  if (!p.resumed) this.#resetSession();
458
489
  this.sessionId = p.sessionId;
@@ -469,7 +500,7 @@ export class V2Session extends EventEmitter {
469
500
  }
470
501
  this.#startHeartbeat();
471
502
  this.log.info(`[la-v2] ready resumed=${p.resumed} session=${p.sessionId.slice(0, 8)} peerLastSeq=${p.peerLastSeq}`);
472
- this.emit("ready", { resumed: p.resumed, sessionId: p.sessionId, limits: { ...this.limits } });
503
+ this.emit("ready", { resumed: p.resumed, sessionId: p.sessionId, limits: { ...this.limits }, capabilities: [...this.#peerCaps] });
473
504
  this.#pump();
474
505
  }
475
506
 
@@ -514,16 +545,18 @@ export class V2Session extends EventEmitter {
514
545
  while (this.recvRing.size > DEDUPE_ENTRIES) this.recvRing.delete(this.recvRing.keys().next().value);
515
546
  if (f.ack !== undefined) this.#ackUpTo(f.ack);
516
547
 
517
- const handler = this.handlers.get(f.type);
518
- if (!handler) { this.#sendAck(seq, "rejected", "protocol_unsupported"); return; }
519
- const ctx = Object.freeze({ type: f.type, seq, msgId, requestId: f.requestId, workspaceId: f.workspaceId, params });
548
+ const entry = this.handlers.get(f.type);
549
+ if (!entry) { this.#sendAck(seq, "rejected", "protocol_unsupported"); return; }
550
+ const handler = entry.fn;
551
+ const ackMs = entry.ackMs ?? this.opts.handlerAckMs;
552
+ const ctx = Object.freeze({ type: f.type, seq, msgId, requestId: f.requestId, workspaceId: f.workspaceId, params, chunk: f.chunk });
520
553
  let verdict;
521
554
  let slow;
522
555
  try {
523
556
  const run = Promise.resolve().then(() => handler(ctx));
524
557
  run.catch(noop);
525
- // a handler that has not decided in handlerAckMs is acked `accepted` (it goes on in the background)
526
- verdict = await Promise.race([run, new Promise((r) => { slow = this.#timer(() => r({ accept: true }), this.opts.handlerAckMs); })]);
558
+ // a handler that has not decided in its ackMs (default handlerAckMs) is acked `accepted` (it goes on in the background)
559
+ verdict = await Promise.race([run, new Promise((r) => { slow = this.#timer(() => r({ accept: true }), ackMs); })]);
527
560
  } catch {
528
561
  verdict = { reject: "internal" };
529
562
  } finally {
@@ -587,6 +620,7 @@ export class V2Session extends EventEmitter {
587
620
  this.#failAll(code);
588
621
  this.sessionId = null;
589
622
  this.everReady = false;
623
+ this.#peerCaps = new Set();
590
624
  this.sendSeq = 0;
591
625
  this.lastRecvSeq = 0;
592
626
  this.recvRing.clear();
@@ -0,0 +1,85 @@
1
+ /**
2
+ * assembler.js - rebuilds a chunked `webhook.forward` in memory (F3-12 consumer, contract 2.1 (b)).
3
+ *
4
+ * The CP sends a body above 9216 bytes as chunks of exactly 16384 raw bytes (the last one is the rest), up to 64 chunks = 1 MiB. Every
5
+ * chunk repeats webhookId, source, contentType, bodyBytes and bodySha256. This class keeps ONE assembly per webhookId, in memory only,
6
+ * with the limits of the contract (index.json) and applies the rules in the order of the reference model (state first, size after):
7
+ *
8
+ * no assembly yet index must be 0 (chunk_out_of_order, also for "a chunk after the one flagged last": that assembly is gone)
9
+ * last flag last must be true only on the last index (chunk_inconsistent)
10
+ * size rule `webhook-chunk-size`: total, and the decoded size of this chunk (chunk_inconsistent)
11
+ * capacity open assemblies <= maxOpen, buffered bytes <= maxBufferedBytes, bodyBytes <= maxBodyBytes (rate_limited | payload_too_large)
12
+ * later chunks total/source/contentType/bodyBytes/bodySha256 unchanged (chunk_inconsistent); index == next (chunk_out_of_order)
13
+ * last chunk the body has bodyBytes bytes and hashes to bodySha256 (chunk_inconsistent)
14
+ * Any refusal DROPS that assembly (the buffer is zeroed). An assembly older than assemblyMs without its last chunk is dropped too.
15
+ * Never logs, counts by content, or keeps anything after delivery: the caller gets the body once and zeroes it.
16
+ */
17
+ import crypto from "node:crypto";
18
+ import { isCanonicalBase64, webhookChunkSizeViolation } from "../contract.js";
19
+
20
+ export function createWebhookAssembler({ limits, config, now = Date.now, timers = { setTimeout, clearTimeout } }) {
21
+ const chunkRaw = limits.webhookChunkRawBytes;
22
+ const open = new Map(); // webhookId -> assembly
23
+ const stats = { started: 0, completed: 0, expired: 0, refused: {}, bytesHeld: 0 };
24
+
25
+ function drop(id, why) {
26
+ const a = open.get(id);
27
+ if (!a) return;
28
+ open.delete(id);
29
+ if (a.timer) timers.clearTimeout(a.timer);
30
+ a.buffer.fill(0);
31
+ stats.bytesHeld -= a.buffer.length;
32
+ if (why === "expired") stats.expired++;
33
+ }
34
+ const refuse = (id, code) => { drop(id, "refused"); stats.refused[code] = (stats.refused[code] || 0) + 1; return { ok: false, code }; };
35
+
36
+ return {
37
+ stats,
38
+ get openCount() { return open.size; },
39
+ /** Is there an assembly in progress for this webhookId? */
40
+ has(id) { return open.has(id); },
41
+ /**
42
+ * @param {{params:object, chunk:{index:number,total:number,last:boolean}}} o params = the signed payload of the frame
43
+ * @returns {{ok:false, code:string} | {ok:true, done:false} | {ok:true, done:true, webhook:{webhookId:string, source:string, contentType:string, body:Buffer}}}
44
+ */
45
+ accept({ params, chunk }) {
46
+ const { webhookId, source, contentType, bodyBytes, bodySha256 } = params;
47
+ const { index, total, last } = chunk;
48
+ let a = open.get(webhookId);
49
+ if (a && now() - a.startedAt >= config.assemblyMs) { drop(webhookId, "expired"); a = undefined; }
50
+ if (!a) {
51
+ if (index !== 0) return refuse(webhookId, "chunk_out_of_order");
52
+ if (last !== (index === total - 1)) return refuse(webhookId, "chunk_inconsistent");
53
+ if (webhookChunkSizeViolation(params, chunk, chunkRaw)) return refuse(webhookId, "chunk_inconsistent");
54
+ if (bodyBytes > config.maxBodyBytes) return refuse(webhookId, "payload_too_large");
55
+ if (open.size >= config.maxOpen || stats.bytesHeld + bodyBytes > config.maxBufferedBytes) return refuse(webhookId, "rate_limited");
56
+ a = { total, next: 0, source, contentType, bodyBytes, bodySha256, buffer: Buffer.alloc(bodyBytes), startedAt: now(), timer: null };
57
+ a.timer = timers.setTimeout(() => drop(webhookId, "expired"), config.assemblyMs);
58
+ if (a.timer && typeof a.timer.unref === "function") a.timer.unref();
59
+ open.set(webhookId, a);
60
+ stats.bytesHeld += bodyBytes;
61
+ stats.started++;
62
+ } else {
63
+ if (a.total !== total || a.source !== source || a.contentType !== contentType || a.bodyBytes !== bodyBytes || a.bodySha256 !== bodySha256) return refuse(webhookId, "chunk_inconsistent");
64
+ if (index !== a.next) return refuse(webhookId, "chunk_out_of_order");
65
+ if (last !== (index === total - 1)) return refuse(webhookId, "chunk_inconsistent");
66
+ if (webhookChunkSizeViolation(params, chunk, chunkRaw)) return refuse(webhookId, "chunk_inconsistent");
67
+ }
68
+ if (!isCanonicalBase64(params.bodyB64)) return refuse(webhookId, "chunk_inconsistent");
69
+ const piece = Buffer.from(params.bodyB64, "base64");
70
+ piece.copy(a.buffer, index * chunkRaw);
71
+ a.next = index + 1;
72
+ if (!last) return { ok: true, done: false };
73
+ const digest = crypto.createHash("sha256").update(a.buffer).digest("hex");
74
+ if (digest !== a.bodySha256) return refuse(webhookId, "chunk_inconsistent");
75
+ // hand over the buffer: the assembly stops being ours (it is not zeroed here: the caller does it after delivery)
76
+ open.delete(webhookId);
77
+ if (a.timer) timers.clearTimeout(a.timer);
78
+ stats.bytesHeld -= a.buffer.length;
79
+ stats.completed++;
80
+ return { ok: true, done: true, webhook: { webhookId, source: a.source, contentType: a.contentType, body: a.buffer } };
81
+ },
82
+ /** Drops every assembly (a new session, shutdown). */
83
+ clear() { for (const id of [...open.keys()]) drop(id, "cleared"); },
84
+ };
85
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * config.js - LOCAL_AGENT_WEBHOOK_FORWARD flag and limits of the webhook receiver (F3-12 consumer, contract 2.1 (b)).
3
+ *
4
+ * false (default) `webhook.forward` keeps being acked `rejected protocol_unsupported`, the capabilities `webhook.forward` and
5
+ * `webhook.chunked` are NOT announced (so the control plane keeps its own behaviour for this agent): nothing of this
6
+ * folder is loaded.
7
+ * true the agent receives the webhook bodies the control plane strips and forwards (one frame <= 9216 bytes, or chunks of
8
+ * 16 KiB up to 1 MiB), rebuilds them IN MEMORY, verifies the sha256 and hands them to the local incident detector.
9
+ * Needs LOCAL_AGENT_PROTOCOL_V2=true AND LOCAL_AGENT_INCIDENT_DETECTOR=true (the detector is the local consumer: with
10
+ * no consumer the handler is not registered and no capability is announced).
11
+ *
12
+ * LOCAL_AGENT_WEBHOOK_DELIVERY_MS how long the ack of a webhook (a single frame or the LAST chunk) waits for the local delivery (default 10 s,
13
+ * max 14 s: the control plane retransmits after 5 s and gives up on a webhook at 15 s). Past it: `rejected timeout`.
14
+ * LOCAL_AGENT_WEBHOOK_DEDUPE_MS / _MAX how long (default 10 min) and how many (default 1024) delivered webhookIds are remembered in memory
15
+ * so that a resend is acked `accepted` without being processed again.
16
+ *
17
+ * The limits default to the contract (index.json) and can only be tightened, never raised above it: a bad value falls back to the default.
18
+ */
19
+ export const WEBHOOK_FLAG = "LOCAL_AGENT_WEBHOOK_FORWARD";
20
+
21
+ const TRUE = new Set(["true", "1", "yes", "on"]);
22
+ const FALSE = new Set(["", "false", "0", "no", "off"]);
23
+
24
+ function int(raw, def, min, max) {
25
+ const n = Number.parseInt(String(raw ?? ""), 10);
26
+ return Number.isFinite(n) && n >= min && n <= max ? n : def;
27
+ }
28
+
29
+ /** @param {{webhookMaxBytes:number, webhookMaxChunks:number, webhookAssemblySeconds:number, webhookMaxOpenAssemblies:number}} limits the contract limits */
30
+ export function resolveWebhookConfig(env = process.env, warn = console.warn, limits = { webhookMaxBytes: 1048576, webhookMaxChunks: 64, webhookAssemblySeconds: 30, webhookMaxOpenAssemblies: 4 }) {
31
+ const raw = String(env[WEBHOOK_FLAG] ?? "").trim().toLowerCase();
32
+ if (!TRUE.has(raw)) {
33
+ if (!FALSE.has(raw)) warn(`[la-webhook] unrecognised ${WEBHOOK_FLAG} value; the webhook receiver stays off`);
34
+ return { enabled: false };
35
+ }
36
+ const maxOpen = int(env.LOCAL_AGENT_WEBHOOK_MAX_OPEN, limits.webhookMaxOpenAssemblies, 1, limits.webhookMaxOpenAssemblies);
37
+ const maxBodyBytes = int(env.LOCAL_AGENT_WEBHOOK_MAX_BYTES, limits.webhookMaxBytes, 1, limits.webhookMaxBytes);
38
+ return {
39
+ enabled: true,
40
+ maxBodyBytes,
41
+ maxOpen,
42
+ // bytes held by assemblies in progress (the README of the contract: 4 MiB)
43
+ maxBufferedBytes: int(env.LOCAL_AGENT_WEBHOOK_MAX_BUFFERED_BYTES, 4 * 1024 * 1024, maxBodyBytes, 16 * 1024 * 1024),
44
+ assemblyMs: int(env.LOCAL_AGENT_WEBHOOK_ASSEMBLY_MS, limits.webhookAssemblySeconds * 1000, 100, limits.webhookAssemblySeconds * 1000),
45
+ // at most this many entries of one webhook go to the detector (an Alertmanager group can carry hundreds of alerts)
46
+ maxEntries: int(env.LOCAL_AGENT_WEBHOOK_MAX_ENTRIES, 200, 1, 5000),
47
+ deliveryMs: int(env.LOCAL_AGENT_WEBHOOK_DELIVERY_MS, 10_000, 100, 14_000),
48
+ dedupeMs: int(env.LOCAL_AGENT_WEBHOOK_DEDUPE_MS, 600_000, 1_000, 3_600_000),
49
+ dedupeMax: int(env.LOCAL_AGENT_WEBHOOK_DEDUPE_MAX, 1024, 16, 65_536),
50
+ };
51
+ }