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,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
|
-
|
|
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
|
-
/**
|
|
118
|
-
|
|
125
|
+
/**
|
|
126
|
+
* Registers the handler of a CP->LA data type. Without one the frame is acked `rejected` (protocol_unsupported).
|
|
127
|
+
* `opts.ackMs`: how long this handler may take before the session acks `accepted` on its own (default `handlerAckMs`, 2 s). A handler whose ack
|
|
128
|
+
* MEANS something (webhook.forward: the last chunk is acked only after the local delivery) raises it and enforces its own, shorter timeout.
|
|
129
|
+
* Inbound frames are processed one at a time, so a slow handler delays the frames behind it: keep this small (the CP retransmits after 5 s).
|
|
130
|
+
*/
|
|
131
|
+
setHandler(type, fn, opts = {}) {
|
|
119
132
|
if (!this.contract.canReceive(type) || this.contract.typeInfo(type).control) throw new ProtocolError("unknown_type");
|
|
120
|
-
|
|
133
|
+
const ackMs = Number.isFinite(opts.ackMs) ? Math.min(Math.max(opts.ackMs, 1), 14_000) : null;
|
|
134
|
+
this.handlers.set(type, { fn, ackMs });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The capabilities of THIS attach: the static ones plus the dynamic ones, sorted, deduplicated, valid names only. */
|
|
138
|
+
#announced() {
|
|
139
|
+
let extra = [];
|
|
140
|
+
try { extra = this.dynamicCapabilities ? this.dynamicCapabilities() : []; } catch { extra = []; }
|
|
141
|
+
const all = new Set([...this.capabilities, ...(Array.isArray(extra) ? extra : [])].filter((c) => typeof c === "string" && CAPABILITY.test(c) && c.length <= 48));
|
|
142
|
+
if (!all.has("llm.key")) all.delete("llm.key.held"); // contract 2.1 (e): `llm.key.held` alone is a schema violation, so it is never sent alone
|
|
143
|
+
return [...all].sort();
|
|
121
144
|
}
|
|
122
145
|
|
|
123
146
|
/** A new socket is open: starts (or resumes) the handshake. */
|
|
@@ -129,10 +152,11 @@ export class V2Session extends EventEmitter {
|
|
|
129
152
|
this.transport = transport;
|
|
130
153
|
this.state = STATE.AWAIT_READY;
|
|
131
154
|
for (const rec of this.unacked.values()) this.#cancelRecTimer(rec);
|
|
155
|
+
const capabilities = this.#announced();
|
|
132
156
|
const payload = {
|
|
133
157
|
protocol: 2,
|
|
134
158
|
laVersion: this.laVersion,
|
|
135
|
-
capabilities
|
|
159
|
+
capabilities,
|
|
136
160
|
signatureMode: this.signing.mode,
|
|
137
161
|
workspaces: this.#workspaces(),
|
|
138
162
|
...(this.everReady && this.sessionId ? { resume: { sessionId: this.sessionId, lastRecvSeq: this.lastRecvSeq } } : {}),
|
|
@@ -141,7 +165,7 @@ export class V2Session extends EventEmitter {
|
|
|
141
165
|
const health = this.getHealth() || {};
|
|
142
166
|
const hello = {
|
|
143
167
|
laVersion: this.laVersion,
|
|
144
|
-
capabilities
|
|
168
|
+
capabilities,
|
|
145
169
|
signatureMode: this.signing.mode,
|
|
146
170
|
clockEpochMs: this.now(),
|
|
147
171
|
diskFreeBytes: Number.isSafeInteger(health.diskFreeBytes) && health.diskFreeBytes >= 0 ? health.diskFreeBytes : 0,
|
|
@@ -227,6 +251,12 @@ export class V2Session extends EventEmitter {
|
|
|
227
251
|
this.waiters = keep;
|
|
228
252
|
}
|
|
229
253
|
|
|
254
|
+
/** Did the CP announce this capability in its last `ready` (contract 2.1)? Absent = 2.0 behaviour. */
|
|
255
|
+
peerHas(capability) { return this.#peerCaps.has(capability); }
|
|
256
|
+
|
|
257
|
+
/** A copy of what the CP announced in its last `ready` (changing it changes nothing). */
|
|
258
|
+
get peerCapabilities() { return [...this.#peerCaps]; }
|
|
259
|
+
|
|
230
260
|
get snapshot() {
|
|
231
261
|
return { state: this.state, sessionId: this.sessionId, sendSeq: this.sendSeq, lastRecvSeq: this.lastRecvSeq,
|
|
232
262
|
unacked: this.unacked.size, waiting: this.waiters.length, limits: { ...this.limits }, stats: { ...this.stats } };
|
|
@@ -392,7 +422,7 @@ export class V2Session extends EventEmitter {
|
|
|
392
422
|
if (!isPlainObject(f)) return this.#refuse("bad_frame");
|
|
393
423
|
const ref = { refSeq: Number.isSafeInteger(f.seq) && f.seq > 0 ? f.seq : undefined, refMsgId: typeof f.msgId === "string" && f.msgId.length === 36 ? f.msgId : undefined };
|
|
394
424
|
// 3-4. allowlist + closed schema
|
|
395
|
-
const v = this.contract.validateFrame(f);
|
|
425
|
+
const v = this.contract.validateFrame(f, { skipRules: DEFERRED_RULES });
|
|
396
426
|
if (!v.ok) return this.#refuse(v.code, ref);
|
|
397
427
|
// 5-6. tenant and agent are the configured ones
|
|
398
428
|
if (f.tenantId !== this.tenantId) return this.#refuse("tenant_mismatch", ref);
|
|
@@ -453,6 +483,7 @@ export class V2Session extends EventEmitter {
|
|
|
453
483
|
};
|
|
454
484
|
this.tracker.chunkRawBytes = this.limits.chunkRawBytes;
|
|
455
485
|
this.tracker.maxChunks = this.limits.maxChunks;
|
|
486
|
+
this.#peerCaps = new Set(Array.isArray(p.capabilities) ? p.capabilities : []);
|
|
456
487
|
this.#cancelTimer("handshake");
|
|
457
488
|
if (!p.resumed) this.#resetSession();
|
|
458
489
|
this.sessionId = p.sessionId;
|
|
@@ -469,7 +500,7 @@ export class V2Session extends EventEmitter {
|
|
|
469
500
|
}
|
|
470
501
|
this.#startHeartbeat();
|
|
471
502
|
this.log.info(`[la-v2] ready resumed=${p.resumed} session=${p.sessionId.slice(0, 8)} peerLastSeq=${p.peerLastSeq}`);
|
|
472
|
-
this.emit("ready", { resumed: p.resumed, sessionId: p.sessionId, limits: { ...this.limits } });
|
|
503
|
+
this.emit("ready", { resumed: p.resumed, sessionId: p.sessionId, limits: { ...this.limits }, capabilities: [...this.#peerCaps] });
|
|
473
504
|
this.#pump();
|
|
474
505
|
}
|
|
475
506
|
|
|
@@ -514,16 +545,18 @@ export class V2Session extends EventEmitter {
|
|
|
514
545
|
while (this.recvRing.size > DEDUPE_ENTRIES) this.recvRing.delete(this.recvRing.keys().next().value);
|
|
515
546
|
if (f.ack !== undefined) this.#ackUpTo(f.ack);
|
|
516
547
|
|
|
517
|
-
const
|
|
518
|
-
if (!
|
|
519
|
-
const
|
|
548
|
+
const entry = this.handlers.get(f.type);
|
|
549
|
+
if (!entry) { this.#sendAck(seq, "rejected", "protocol_unsupported"); return; }
|
|
550
|
+
const handler = entry.fn;
|
|
551
|
+
const ackMs = entry.ackMs ?? this.opts.handlerAckMs;
|
|
552
|
+
const ctx = Object.freeze({ type: f.type, seq, msgId, requestId: f.requestId, workspaceId: f.workspaceId, params, chunk: f.chunk });
|
|
520
553
|
let verdict;
|
|
521
554
|
let slow;
|
|
522
555
|
try {
|
|
523
556
|
const run = Promise.resolve().then(() => handler(ctx));
|
|
524
557
|
run.catch(noop);
|
|
525
|
-
// a handler that has not decided in handlerAckMs is acked `accepted` (it goes on in the background)
|
|
526
|
-
verdict = await Promise.race([run, new Promise((r) => { slow = this.#timer(() => r({ accept: true }),
|
|
558
|
+
// a handler that has not decided in its ackMs (default handlerAckMs) is acked `accepted` (it goes on in the background)
|
|
559
|
+
verdict = await Promise.race([run, new Promise((r) => { slow = this.#timer(() => r({ accept: true }), ackMs); })]);
|
|
527
560
|
} catch {
|
|
528
561
|
verdict = { reject: "internal" };
|
|
529
562
|
} finally {
|
|
@@ -587,6 +620,7 @@ export class V2Session extends EventEmitter {
|
|
|
587
620
|
this.#failAll(code);
|
|
588
621
|
this.sessionId = null;
|
|
589
622
|
this.everReady = false;
|
|
623
|
+
this.#peerCaps = new Set();
|
|
590
624
|
this.sendSeq = 0;
|
|
591
625
|
this.lastRecvSeq = 0;
|
|
592
626
|
this.recvRing.clear();
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* assembler.js - rebuilds a chunked `webhook.forward` in memory (F3-12 consumer, contract 2.1 (b)).
|
|
3
|
+
*
|
|
4
|
+
* The CP sends a body above 9216 bytes as chunks of exactly 16384 raw bytes (the last one is the rest), up to 64 chunks = 1 MiB. Every
|
|
5
|
+
* chunk repeats webhookId, source, contentType, bodyBytes and bodySha256. This class keeps ONE assembly per webhookId, in memory only,
|
|
6
|
+
* with the limits of the contract (index.json) and applies the rules in the order of the reference model (state first, size after):
|
|
7
|
+
*
|
|
8
|
+
* no assembly yet index must be 0 (chunk_out_of_order, also for "a chunk after the one flagged last": that assembly is gone)
|
|
9
|
+
* last flag last must be true only on the last index (chunk_inconsistent)
|
|
10
|
+
* size rule `webhook-chunk-size`: total, and the decoded size of this chunk (chunk_inconsistent)
|
|
11
|
+
* capacity open assemblies <= maxOpen, buffered bytes <= maxBufferedBytes, bodyBytes <= maxBodyBytes (rate_limited | payload_too_large)
|
|
12
|
+
* later chunks total/source/contentType/bodyBytes/bodySha256 unchanged (chunk_inconsistent); index == next (chunk_out_of_order)
|
|
13
|
+
* last chunk the body has bodyBytes bytes and hashes to bodySha256 (chunk_inconsistent)
|
|
14
|
+
* Any refusal DROPS that assembly (the buffer is zeroed). An assembly older than assemblyMs without its last chunk is dropped too.
|
|
15
|
+
* Never logs, counts by content, or keeps anything after delivery: the caller gets the body once and zeroes it.
|
|
16
|
+
*/
|
|
17
|
+
import crypto from "node:crypto";
|
|
18
|
+
import { isCanonicalBase64, webhookChunkSizeViolation } from "../contract.js";
|
|
19
|
+
|
|
20
|
+
export function createWebhookAssembler({ limits, config, now = Date.now, timers = { setTimeout, clearTimeout } }) {
|
|
21
|
+
const chunkRaw = limits.webhookChunkRawBytes;
|
|
22
|
+
const open = new Map(); // webhookId -> assembly
|
|
23
|
+
const stats = { started: 0, completed: 0, expired: 0, refused: {}, bytesHeld: 0 };
|
|
24
|
+
|
|
25
|
+
function drop(id, why) {
|
|
26
|
+
const a = open.get(id);
|
|
27
|
+
if (!a) return;
|
|
28
|
+
open.delete(id);
|
|
29
|
+
if (a.timer) timers.clearTimeout(a.timer);
|
|
30
|
+
a.buffer.fill(0);
|
|
31
|
+
stats.bytesHeld -= a.buffer.length;
|
|
32
|
+
if (why === "expired") stats.expired++;
|
|
33
|
+
}
|
|
34
|
+
const refuse = (id, code) => { drop(id, "refused"); stats.refused[code] = (stats.refused[code] || 0) + 1; return { ok: false, code }; };
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
stats,
|
|
38
|
+
get openCount() { return open.size; },
|
|
39
|
+
/** Is there an assembly in progress for this webhookId? */
|
|
40
|
+
has(id) { return open.has(id); },
|
|
41
|
+
/**
|
|
42
|
+
* @param {{params:object, chunk:{index:number,total:number,last:boolean}}} o params = the signed payload of the frame
|
|
43
|
+
* @returns {{ok:false, code:string} | {ok:true, done:false} | {ok:true, done:true, webhook:{webhookId:string, source:string, contentType:string, body:Buffer}}}
|
|
44
|
+
*/
|
|
45
|
+
accept({ params, chunk }) {
|
|
46
|
+
const { webhookId, source, contentType, bodyBytes, bodySha256 } = params;
|
|
47
|
+
const { index, total, last } = chunk;
|
|
48
|
+
let a = open.get(webhookId);
|
|
49
|
+
if (a && now() - a.startedAt >= config.assemblyMs) { drop(webhookId, "expired"); a = undefined; }
|
|
50
|
+
if (!a) {
|
|
51
|
+
if (index !== 0) return refuse(webhookId, "chunk_out_of_order");
|
|
52
|
+
if (last !== (index === total - 1)) return refuse(webhookId, "chunk_inconsistent");
|
|
53
|
+
if (webhookChunkSizeViolation(params, chunk, chunkRaw)) return refuse(webhookId, "chunk_inconsistent");
|
|
54
|
+
if (bodyBytes > config.maxBodyBytes) return refuse(webhookId, "payload_too_large");
|
|
55
|
+
if (open.size >= config.maxOpen || stats.bytesHeld + bodyBytes > config.maxBufferedBytes) return refuse(webhookId, "rate_limited");
|
|
56
|
+
a = { total, next: 0, source, contentType, bodyBytes, bodySha256, buffer: Buffer.alloc(bodyBytes), startedAt: now(), timer: null };
|
|
57
|
+
a.timer = timers.setTimeout(() => drop(webhookId, "expired"), config.assemblyMs);
|
|
58
|
+
if (a.timer && typeof a.timer.unref === "function") a.timer.unref();
|
|
59
|
+
open.set(webhookId, a);
|
|
60
|
+
stats.bytesHeld += bodyBytes;
|
|
61
|
+
stats.started++;
|
|
62
|
+
} else {
|
|
63
|
+
if (a.total !== total || a.source !== source || a.contentType !== contentType || a.bodyBytes !== bodyBytes || a.bodySha256 !== bodySha256) return refuse(webhookId, "chunk_inconsistent");
|
|
64
|
+
if (index !== a.next) return refuse(webhookId, "chunk_out_of_order");
|
|
65
|
+
if (last !== (index === total - 1)) return refuse(webhookId, "chunk_inconsistent");
|
|
66
|
+
if (webhookChunkSizeViolation(params, chunk, chunkRaw)) return refuse(webhookId, "chunk_inconsistent");
|
|
67
|
+
}
|
|
68
|
+
if (!isCanonicalBase64(params.bodyB64)) return refuse(webhookId, "chunk_inconsistent");
|
|
69
|
+
const piece = Buffer.from(params.bodyB64, "base64");
|
|
70
|
+
piece.copy(a.buffer, index * chunkRaw);
|
|
71
|
+
a.next = index + 1;
|
|
72
|
+
if (!last) return { ok: true, done: false };
|
|
73
|
+
const digest = crypto.createHash("sha256").update(a.buffer).digest("hex");
|
|
74
|
+
if (digest !== a.bodySha256) return refuse(webhookId, "chunk_inconsistent");
|
|
75
|
+
// hand over the buffer: the assembly stops being ours (it is not zeroed here: the caller does it after delivery)
|
|
76
|
+
open.delete(webhookId);
|
|
77
|
+
if (a.timer) timers.clearTimeout(a.timer);
|
|
78
|
+
stats.bytesHeld -= a.buffer.length;
|
|
79
|
+
stats.completed++;
|
|
80
|
+
return { ok: true, done: true, webhook: { webhookId, source: a.source, contentType: a.contentType, body: a.buffer } };
|
|
81
|
+
},
|
|
82
|
+
/** Drops every assembly (a new session, shutdown). */
|
|
83
|
+
clear() { for (const id of [...open.keys()]) drop(id, "cleared"); },
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config.js - LOCAL_AGENT_WEBHOOK_FORWARD flag and limits of the webhook receiver (F3-12 consumer, contract 2.1 (b)).
|
|
3
|
+
*
|
|
4
|
+
* false (default) `webhook.forward` keeps being acked `rejected protocol_unsupported`, the capabilities `webhook.forward` and
|
|
5
|
+
* `webhook.chunked` are NOT announced (so the control plane keeps its own behaviour for this agent): nothing of this
|
|
6
|
+
* folder is loaded.
|
|
7
|
+
* true the agent receives the webhook bodies the control plane strips and forwards (one frame <= 9216 bytes, or chunks of
|
|
8
|
+
* 16 KiB up to 1 MiB), rebuilds them IN MEMORY, verifies the sha256 and hands them to the local incident detector.
|
|
9
|
+
* Needs LOCAL_AGENT_PROTOCOL_V2=true AND LOCAL_AGENT_INCIDENT_DETECTOR=true (the detector is the local consumer: with
|
|
10
|
+
* no consumer the handler is not registered and no capability is announced).
|
|
11
|
+
*
|
|
12
|
+
* LOCAL_AGENT_WEBHOOK_DELIVERY_MS how long the ack of a webhook (a single frame or the LAST chunk) waits for the local delivery (default 10 s,
|
|
13
|
+
* max 14 s: the control plane retransmits after 5 s and gives up on a webhook at 15 s). Past it: `rejected timeout`.
|
|
14
|
+
* LOCAL_AGENT_WEBHOOK_DEDUPE_MS / _MAX how long (default 10 min) and how many (default 1024) delivered webhookIds are remembered in memory
|
|
15
|
+
* so that a resend is acked `accepted` without being processed again.
|
|
16
|
+
*
|
|
17
|
+
* The limits default to the contract (index.json) and can only be tightened, never raised above it: a bad value falls back to the default.
|
|
18
|
+
*/
|
|
19
|
+
export const WEBHOOK_FLAG = "LOCAL_AGENT_WEBHOOK_FORWARD";
|
|
20
|
+
|
|
21
|
+
const TRUE = new Set(["true", "1", "yes", "on"]);
|
|
22
|
+
const FALSE = new Set(["", "false", "0", "no", "off"]);
|
|
23
|
+
|
|
24
|
+
function int(raw, def, min, max) {
|
|
25
|
+
const n = Number.parseInt(String(raw ?? ""), 10);
|
|
26
|
+
return Number.isFinite(n) && n >= min && n <= max ? n : def;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** @param {{webhookMaxBytes:number, webhookMaxChunks:number, webhookAssemblySeconds:number, webhookMaxOpenAssemblies:number}} limits the contract limits */
|
|
30
|
+
export function resolveWebhookConfig(env = process.env, warn = console.warn, limits = { webhookMaxBytes: 1048576, webhookMaxChunks: 64, webhookAssemblySeconds: 30, webhookMaxOpenAssemblies: 4 }) {
|
|
31
|
+
const raw = String(env[WEBHOOK_FLAG] ?? "").trim().toLowerCase();
|
|
32
|
+
if (!TRUE.has(raw)) {
|
|
33
|
+
if (!FALSE.has(raw)) warn(`[la-webhook] unrecognised ${WEBHOOK_FLAG} value; the webhook receiver stays off`);
|
|
34
|
+
return { enabled: false };
|
|
35
|
+
}
|
|
36
|
+
const maxOpen = int(env.LOCAL_AGENT_WEBHOOK_MAX_OPEN, limits.webhookMaxOpenAssemblies, 1, limits.webhookMaxOpenAssemblies);
|
|
37
|
+
const maxBodyBytes = int(env.LOCAL_AGENT_WEBHOOK_MAX_BYTES, limits.webhookMaxBytes, 1, limits.webhookMaxBytes);
|
|
38
|
+
return {
|
|
39
|
+
enabled: true,
|
|
40
|
+
maxBodyBytes,
|
|
41
|
+
maxOpen,
|
|
42
|
+
// bytes held by assemblies in progress (the README of the contract: 4 MiB)
|
|
43
|
+
maxBufferedBytes: int(env.LOCAL_AGENT_WEBHOOK_MAX_BUFFERED_BYTES, 4 * 1024 * 1024, maxBodyBytes, 16 * 1024 * 1024),
|
|
44
|
+
assemblyMs: int(env.LOCAL_AGENT_WEBHOOK_ASSEMBLY_MS, limits.webhookAssemblySeconds * 1000, 100, limits.webhookAssemblySeconds * 1000),
|
|
45
|
+
// at most this many entries of one webhook go to the detector (an Alertmanager group can carry hundreds of alerts)
|
|
46
|
+
maxEntries: int(env.LOCAL_AGENT_WEBHOOK_MAX_ENTRIES, 200, 1, 5000),
|
|
47
|
+
deliveryMs: int(env.LOCAL_AGENT_WEBHOOK_DELIVERY_MS, 10_000, 100, 14_000),
|
|
48
|
+
dedupeMs: int(env.LOCAL_AGENT_WEBHOOK_DEDUPE_MS, 600_000, 1_000, 3_600_000),
|
|
49
|
+
dedupeMax: int(env.LOCAL_AGENT_WEBHOOK_DEDUPE_MAX, 1024, 16, 65_536),
|
|
50
|
+
};
|
|
51
|
+
}
|