deepdebug-local-agent 1.7.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/.claude/context.md +2 -1
  2. package/CLAUDE.md +1 -1
  3. package/docs/la-protocol-v2.md +27 -4
  4. package/env.example +12 -0
  5. package/package.json +1 -1
  6. package/scripts/incident-parity/Gen.java +256 -0
  7. package/scripts/incident-parity/LogEntry.java +17 -0
  8. package/scripts/incident-parity/extract.py +51 -0
  9. package/scripts/incident-parity/gen.sh +32 -0
  10. package/src/incident-detector/README.md +52 -0
  11. package/src/incident-detector/aggregator.js +78 -0
  12. package/src/incident-detector/categorize.js +48 -0
  13. package/src/incident-detector/config.js +95 -0
  14. package/src/incident-detector/detector.js +93 -0
  15. package/src/incident-detector/emitter.js +164 -0
  16. package/src/incident-detector/index.js +44 -0
  17. package/src/incident-detector/java-compat.js +66 -0
  18. package/src/incident-detector/normalize.js +209 -0
  19. package/src/incident-detector/parse.js +114 -0
  20. package/src/incident-detector/sources/file-source.js +113 -0
  21. package/src/incident-detector/sources/loki-source.js +101 -0
  22. package/src/incident-detector/sources/stream-source.js +39 -0
  23. package/src/incident-detector/sources/webhook-source.js +109 -0
  24. package/src/incident-detector/store.js +119 -0
  25. package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +24 -16
  26. package/src/la-protocol-v2/contract/README.md +103 -3
  27. package/src/la-protocol-v2/contract/config.update.schema.json +85 -1
  28. package/src/la-protocol-v2/contract/hello.schema.json +26 -1
  29. package/src/la-protocol-v2/contract/incident.candidate.schema.json +11 -0
  30. package/src/la-protocol-v2/contract/index.json +9 -2
  31. package/src/la-protocol-v2/contract/ready.schema.json +8 -0
  32. package/src/la-protocol-v2/contract/register.schema.json +26 -1
  33. package/src/la-protocol-v2/contract/webhook.forward.schema.json +72 -3
  34. package/src/la-protocol-v2/contract.js +45 -7
  35. package/src/la-protocol-v2/llm-key/config.js +29 -0
  36. package/src/la-protocol-v2/llm-key/handler.js +33 -0
  37. package/src/la-protocol-v2/llm-key/install.js +21 -0
  38. package/src/la-protocol-v2/llm-key/store.js +173 -0
  39. package/src/la-protocol-v2/runtime.js +44 -1
  40. package/src/la-protocol-v2/session.js +47 -13
  41. package/src/la-protocol-v2/webhooks/assembler.js +85 -0
  42. package/src/la-protocol-v2/webhooks/config.js +51 -0
  43. package/src/la-protocol-v2/webhooks/handler.js +125 -0
  44. package/src/la-protocol-v2/webhooks/install.js +22 -0
  45. package/test/contract/la-protocol-v2/vectors/ack.vectors.json +91 -0
  46. package/test/contract/la-protocol-v2/vectors/config.update.vectors.json +1339 -0
  47. package/test/contract/la-protocol-v2/vectors/hello.vectors.json +261 -0
  48. package/test/contract/la-protocol-v2/vectors/incident.candidate.vectors.json +623 -0
  49. package/test/contract/la-protocol-v2/vectors/ready.vectors.json +599 -0
  50. package/test/contract/la-protocol-v2/vectors/register.vectors.json +275 -0
  51. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-after-last.json +186 -0
  52. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-body-hash-changes.json +186 -0
  53. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-last-flag-wrong.json +186 -0
  54. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-non-last-too-short.json +152 -0
  55. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-out-of-order.json +152 -0
  56. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-sha-mismatch.json +186 -0
  57. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-total-changes.json +186 -0
  58. package/test/contract/la-protocol-v2/vectors/traces/valid-webhook-chunks-in-order.json +218 -0
  59. package/test/contract/la-protocol-v2/vectors/webhook.forward.vectors.json +1263 -0
  60. package/test/incident-detector/parity.test.js +66 -0
  61. package/test/incident-detector/parity.vectors.json +2619 -0
  62. package/test/incident-detector/size-model.test.js +127 -0
  63. package/test/incident-detector/sources.test.js +179 -0
  64. package/test/incident-detector/units.test.js +430 -0
  65. package/test/incident-detector/wiring.test.js +165 -0
  66. package/test/la-protocol-v2/contract-v21.test.js +107 -0
  67. package/test/la-protocol-v2/contract.test.js +6 -4
  68. package/test/la-protocol-v2/helpers/reference-cp.js +8 -4
  69. package/test/la-protocol-v2/llm-key.test.js +509 -0
  70. package/test/la-protocol-v2/traces.test.js +3 -3
  71. package/test/la-protocol-v2/webhook-receiver.test.js +702 -0
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "protocol": 2,
3
+ "revision": "2.1",
3
4
  "baseId": "https://schemas.visionhammer.io/la-protocol-v2/",
4
5
  "limits": {
5
6
  "maxFrameBytes": 49152,
@@ -12,7 +13,13 @@
12
13
  "heartbeatSeconds": 30,
13
14
  "sessionDeadSeconds": 90,
14
15
  "dedupeWindowSeconds": 600,
15
- "clockSkewSeconds": 60
16
+ "clockSkewSeconds": 60,
17
+ "webhookChunkRawBytes": 16384,
18
+ "webhookMaxChunks": 64,
19
+ "webhookMaxBytes": 1048576,
20
+ "webhookSingleFrameRawBytes": 9216,
21
+ "webhookAssemblySeconds": 30,
22
+ "webhookMaxOpenAssemblies": 4
16
23
  },
17
24
  "types": {
18
25
  "register": {
@@ -118,7 +125,7 @@
118
125
  "direction": "cp->la",
119
126
  "control": false,
120
127
  "signed": true,
121
- "contentClass": "none"
128
+ "contentClass": "secret"
122
129
  },
123
130
  "scan.upload": {
124
131
  "schema": "scan.upload.schema.json",
@@ -81,6 +81,14 @@
81
81
  "maxRetries",
82
82
  "heartbeatSeconds"
83
83
  ]
84
+ },
85
+ "capabilities": {
86
+ "type": "array",
87
+ "maxItems": 32,
88
+ "uniqueItems": true,
89
+ "items": {
90
+ "$ref": "defs.schema.json#/$defs/capability"
91
+ }
84
92
  }
85
93
  },
86
94
  "required": [
@@ -75,7 +75,32 @@
75
75
  "capabilities",
76
76
  "signatureMode",
77
77
  "workspaces"
78
- ]
78
+ ],
79
+ "if": {
80
+ "properties": {
81
+ "capabilities": {
82
+ "type": "array",
83
+ "maxItems": 32,
84
+ "contains": {
85
+ "const": "llm.key.held"
86
+ }
87
+ }
88
+ },
89
+ "required": [
90
+ "capabilities"
91
+ ]
92
+ },
93
+ "then": {
94
+ "properties": {
95
+ "capabilities": {
96
+ "type": "array",
97
+ "maxItems": 32,
98
+ "contains": {
99
+ "const": "llm.key"
100
+ }
101
+ }
102
+ }
103
+ }
79
104
  },
80
105
  "seq": {
81
106
  "const": 0
@@ -40,9 +40,17 @@
40
40
  "bodyB64": {
41
41
  "type": "string",
42
42
  "minLength": 0,
43
- "maxLength": 12288,
43
+ "maxLength": 21848,
44
44
  "pattern": "^[A-Za-z0-9+/]*={0,2}$",
45
45
  "x-content-class": "transit"
46
+ },
47
+ "bodyBytes": {
48
+ "type": "integer",
49
+ "minimum": 0,
50
+ "maximum": 1048576
51
+ },
52
+ "bodySha256": {
53
+ "$ref": "defs.schema.json#/$defs/sha256"
46
54
  }
47
55
  },
48
56
  "required": [
@@ -57,7 +65,7 @@
57
65
  "minimum": 1,
58
66
  "maximum": 9007199254740991
59
67
  },
60
- "chunk": false,
68
+ "chunk": true,
61
69
  "sig": true,
62
70
  "paramsRaw": true
63
71
  },
@@ -65,5 +73,66 @@
65
73
  "payload",
66
74
  "sig",
67
75
  "paramsRaw"
68
- ]
76
+ ],
77
+ "if": {
78
+ "type": "object",
79
+ "properties": {
80
+ "chunk": true
81
+ },
82
+ "required": [
83
+ "chunk"
84
+ ]
85
+ },
86
+ "then": {
87
+ "type": "object",
88
+ "properties": {
89
+ "payload": {
90
+ "type": "object",
91
+ "properties": {
92
+ "bodyBytes": true,
93
+ "bodySha256": true
94
+ },
95
+ "required": [
96
+ "bodyBytes",
97
+ "bodySha256"
98
+ ]
99
+ }
100
+ }
101
+ },
102
+ "else": {
103
+ "type": "object",
104
+ "properties": {
105
+ "payload": {
106
+ "type": "object",
107
+ "properties": {
108
+ "bodyB64": {
109
+ "type": "string",
110
+ "maxLength": 12288
111
+ }
112
+ },
113
+ "not": {
114
+ "anyOf": [
115
+ {
116
+ "type": "object",
117
+ "properties": {
118
+ "bodyBytes": true
119
+ },
120
+ "required": [
121
+ "bodyBytes"
122
+ ]
123
+ },
124
+ {
125
+ "type": "object",
126
+ "properties": {
127
+ "bodySha256": true
128
+ },
129
+ "required": [
130
+ "bodySha256"
131
+ ]
132
+ }
133
+ ]
134
+ }
135
+ }
136
+ }
137
+ }
69
138
  }
@@ -10,8 +10,10 @@
10
10
  * unknown_type `type` is not in the allowlist (index.json)
11
11
  * schema_violation the frame does not satisfy the closed schema of its type
12
12
  * payload_too_large ... because a string under /payload is above its maxLength (same rule as the gateway)
13
- * chunk_inconsistent content.response whose byteLength differs from the decoded size of data
13
+ * chunk_inconsistent content.response whose byteLength differs from the decoded size of data; webhook.forward chunk of the wrong size (2.1)
14
+ * (rule llmKey-id-sha256 of 2.1 is a schema_violation: the keyId is not the sha256 of the key)
14
15
  */
16
+ import crypto from "node:crypto";
15
17
  import fs from "node:fs";
16
18
  import path from "node:path";
17
19
  import { fileURLToPath } from "node:url";
@@ -21,16 +23,52 @@ const DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "contract");
21
23
  const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
22
24
  const readJson = (p) => JSON.parse(fs.readFileSync(p, "utf8"));
23
25
 
24
- /** Rules JSON Schema cannot state (README of the contract): byteLength == size of the decoded `data`. */
25
- export function semanticRules(frame) {
26
+ /** The base64 alphabet with padding, canonical form (no whitespace, length multiple of 4). */
27
+ export const isCanonicalBase64 = (s) => typeof s === "string" && BASE64.test(s);
28
+ const decodedLength = (s) => (isCanonicalBase64(s) ? Buffer.from(s, "base64").length : -1);
29
+
30
+ /**
31
+ * Contract 2.1, rule `webhook-chunk-size`: a chunked webhook.forward has total == max(1, ceil(bodyBytes / chunkRaw)), a non-last chunk
32
+ * decodes to exactly chunkRaw bytes and the last one to bodyBytes - chunkRaw * (total - 1). Stateless (one frame).
33
+ * @returns {boolean} true when the frame BREAKS the rule
34
+ */
35
+ export function webhookChunkSizeViolation(payload, chunk, chunkRaw = 16384) {
36
+ if (!payload || !chunk || typeof payload.bodyB64 !== "string" || !Number.isSafeInteger(payload.bodyBytes)) return false;
37
+ const { index, total } = chunk;
38
+ const expectedTotal = Math.max(1, Math.ceil(payload.bodyBytes / chunkRaw));
39
+ const expectedSize = index < total - 1 ? chunkRaw : payload.bodyBytes - chunkRaw * (total - 1);
40
+ return total !== expectedTotal || index >= total || decodedLength(payload.bodyB64) !== expectedSize;
41
+ }
42
+
43
+ /** Contract 2.1, rule `llmKey-id-sha256`: llmKey (op set) keyId == lower-case hex sha256 of the UTF-8 bytes of key. @returns {boolean} true when it BREAKS the rule */
44
+ export function llmKeyIdMismatch(llmKey) {
45
+ if (!llmKey || llmKey.op !== "set" || typeof llmKey.key !== "string") return false;
46
+ return crypto.createHash("sha256").update(Buffer.from(llmKey.key, "utf8")).digest("hex") !== llmKey.keyId;
47
+ }
48
+
49
+ /** Rule -> wire code (the `code` the vectors expect). */
50
+ export const RULE_CODES = Object.freeze({ "byteLength-decoded": "chunk_inconsistent", "webhook-chunk-size": "chunk_inconsistent", "llmKey-id-sha256": "schema_violation" });
51
+
52
+ /**
53
+ * Rules JSON Schema cannot state (README of the contract): byteLength == size of the decoded `data` (content.response), and since 2.1
54
+ * `webhook-chunk-size` and `llmKey-id-sha256`. `skip` names rules the caller applies itself later (the session defers the two 2.1
55
+ * rules to the handlers: they are answered with a non-fatal `rejected` ack, in the order of the reference model: state first).
56
+ */
57
+ export function semanticRules(frame, { skip = [] } = {}) {
26
58
  const rules = [];
27
- if (frame && frame.type === "content.response" && frame.payload && typeof frame.payload.data === "string") {
59
+ const on = (name) => !skip.includes(name);
60
+ if (frame && frame.type === "content.response" && frame.payload && typeof frame.payload.data === "string" && on("byteLength-decoded")) {
28
61
  const d = frame.payload.data;
29
62
  if (!BASE64.test(d) || Buffer.from(d, "base64").length !== frame.payload.byteLength) rules.push("byteLength-decoded");
30
63
  }
64
+ if (frame && frame.type === "webhook.forward" && frame.chunk && on("webhook-chunk-size")
65
+ && webhookChunkSizeViolation(frame.payload, frame.chunk, loadLimits().webhookChunkRawBytes)) rules.push("webhook-chunk-size");
66
+ if (frame && frame.type === "config.update" && frame.payload && on("llmKey-id-sha256") && llmKeyIdMismatch(frame.payload.llmKey)) rules.push("llmKey-id-sha256");
31
67
  return rules;
32
68
  }
33
69
 
70
+ const loadLimits = () => loadContract().limits;
71
+
34
72
  let singleton = null;
35
73
 
36
74
  /** Loads and compiles the contract once per process (about 20 ms). */
@@ -73,7 +111,7 @@ export function loadContract() {
73
111
  if (!v) throw new Error(`contract: no schema ${schemaFile}`);
74
112
  return v(value) === true;
75
113
  },
76
- validateFrame(frame) {
114
+ validateFrame(frame, { skipRules = [] } = {}) {
77
115
  if (frame === null || typeof frame !== "object" || typeof frame.type !== "string") {
78
116
  // no `type` at all (a v1 command frame, say): the envelope says what is missing
79
117
  const envelope = ajv.getSchema(index.baseId + "envelope.schema.json");
@@ -90,8 +128,8 @@ export function loadContract() {
90
128
  const tooLong = errs.some((e) => e.keyword === "maxLength" && String(e.instancePath).startsWith("/payload"));
91
129
  return { ok: false, code: tooLong ? "payload_too_large" : "schema_violation", keywords, rules: [] };
92
130
  }
93
- const rules = semanticRules(frame);
94
- if (rules.length) return { ok: false, code: "chunk_inconsistent", keywords: [], rules };
131
+ const rules = semanticRules(frame, { skip: skipRules });
132
+ if (rules.length) return { ok: false, code: RULE_CODES[rules[0]] || "chunk_inconsistent", keywords: [], rules };
95
133
  return { ok: true };
96
134
  },
97
135
  });
@@ -0,0 +1,29 @@
1
+ /**
2
+ * config.js - LOCAL_AGENT_LLM_KEY flag and limits of the LLM key holder (F3-09 consumer, contract 2.1 (a)).
3
+ *
4
+ * false (default) `config.update` keeps being acked `rejected protocol_unsupported`, the capability `llm.key` is NOT announced, so
5
+ * the control plane never sends a key: nothing of this folder is loaded.
6
+ * true the agent handles `config.update.llmKey` (set / revoke) and keeps the virtual key of the AI gateway IN MEMORY.
7
+ * Needs LOCAL_AGENT_PROTOCOL_V2=true as well (without a v2 session nobody sends a key: the flag does nothing).
8
+ *
9
+ * LOCAL_AGENT_LLM_KEY_FILE optional absolute path of a file (mode 0600, directory 0700) where the key survives a restart. It must be
10
+ * OUTSIDE every workspace (a workspace is what the content handlers may serve). Unset = memory only.
11
+ * Unset/empty/unrecognised = false (an unknown value must never open the door to a key).
12
+ */
13
+ import path from "node:path";
14
+
15
+ export const LLM_KEY_FLAG = "LOCAL_AGENT_LLM_KEY";
16
+
17
+ const TRUE = new Set(["true", "1", "yes", "on"]);
18
+ const FALSE = new Set(["", "false", "0", "no", "off"]);
19
+
20
+ export function resolveLlmKeyConfig(env = process.env, warn = console.warn) {
21
+ const raw = String(env[LLM_KEY_FLAG] ?? "").trim().toLowerCase();
22
+ if (!TRUE.has(raw)) {
23
+ if (!FALSE.has(raw)) warn(`[la-llm-key] unrecognised ${LLM_KEY_FLAG} value; the LLM key handler stays off`);
24
+ return { enabled: false };
25
+ }
26
+ let file = String(env.LOCAL_AGENT_LLM_KEY_FILE ?? "").trim() || null;
27
+ if (file && !path.isAbsolute(file)) { warn("[la-llm-key] LOCAL_AGENT_LLM_KEY_FILE must be an absolute path; the key stays in memory only"); file = null; }
28
+ return { enabled: true, file: file ? path.normalize(file) : null };
29
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * handler.js - `config.update` with `llmKey` (F3-09 consumer, contract 2.1 (a)). Registered by install.js only with
3
+ * LOCAL_AGENT_LLM_KEY=true. The frame was already validated against the closed schema (a frame that carries `llmKey` carries nothing else);
4
+ * the session defers the rule `llmKey-id-sha256` to here, so a wrong id is a non-fatal `rejected schema_violation`, as the README says.
5
+ *
6
+ * set accepted | rejected schema_violation (id is not the sha256 of the key, key already expired)
7
+ * revoke accepted (also for an id this agent does not hold: it never says which key it has)
8
+ * others a `config.update` WITHOUT llmKey (flags, dlp hash, ...) is what it was before: rejected protocol_unsupported
9
+ * The answer is the plain ack: it never carries the key, the id or any text. Nothing here logs a value of the frame.
10
+ */
11
+ import { llmKeyIdMismatch } from "../contract.js";
12
+
13
+ export function createLlmKeyHandler({ holder }) {
14
+ const stats = { frames: 0, set: 0, revoke: 0, rejected: 0, other: 0 };
15
+ return {
16
+ stats,
17
+ handle(ctx) {
18
+ stats.frames++;
19
+ const k = ctx.params && ctx.params.llmKey;
20
+ if (!k) { stats.other++; return { reject: "protocol_unsupported" }; }
21
+ if (k.op === "revoke") { stats.revoke++; holder.revoke(k.keyId); return { accept: true }; }
22
+ if (k.op === "set") {
23
+ if (llmKeyIdMismatch(k)) { stats.rejected++; return { reject: "schema_violation" }; }
24
+ const r = holder.set(k);
25
+ if (!r.ok) { stats.rejected++; return { reject: r.code }; }
26
+ stats.set++;
27
+ return { accept: true };
28
+ }
29
+ stats.rejected++;
30
+ return { reject: "schema_violation" };
31
+ },
32
+ };
33
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * install.js - puts the LLM key holder on a v2 link (F3-09 consumer). Called by runtime.js only when LOCAL_AGENT_LLM_KEY=true and v2 is on.
3
+ * Returns link.llm = { holder, handler, request(path, init) }.
4
+ */
5
+ import { createAudit } from "../content-handlers/audit.js";
6
+ import { createLlmKeyHolder } from "./store.js";
7
+ import { createLlmKeyHandler } from "./handler.js";
8
+
9
+ export const LLM_KEY_CAPABILITIES = Object.freeze(["llm.key"]);
10
+
11
+ export function installLlmKey({ link, config, workspaceRoots = () => [], log = console, now = Date.now }) {
12
+ const audit = createAudit({}); // ring only: a key event never goes to a file the operator did not ask for
13
+ const holder = createLlmKeyHolder({ file: config.file, workspaceRoots, now, audit, log });
14
+ holder.load();
15
+ const handler = createLlmKeyHandler({ holder });
16
+ link.session.setHandler("config.update", (ctx) => handler.handle(ctx));
17
+ const closeLink = link.close.bind(link);
18
+ link.close = () => { holder.close(); closeLink(); };
19
+ link.llm = { holder, handler, audit, request: (p, init, opts) => holder.request(p, init, opts) };
20
+ return link.llm;
21
+ }
@@ -0,0 +1,173 @@
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
+ /** Metadata only. */
154
+ describe() { return active() ? { held: true, models: [...held.models], expiresAt: held.expiresAt } : { held: false }; },
155
+ get held() { return active(); },
156
+ /**
157
+ * The one way out for the key: an authorized request to the AI gateway the control plane named. `pathAndQuery` starts with one "/",
158
+ * the host is never the caller's choice, `authorization` from the caller is replaced, redirects are refused (a 3xx would carry the header elsewhere).
159
+ */
160
+ async request(pathAndQuery, init = {}, { fetchImpl = globalThis.fetch } = {}) {
161
+ if (!active()) throw new LlmKeyError(held ? "expired" : "no_key");
162
+ if (typeof pathAndQuery !== "string" || !PATH_AND_QUERY.test(pathAndQuery) || pathAndQuery.split("?")[0].split("/").includes("..")) throw new LlmKeyError("bad_path");
163
+ const url = held.endpoint + pathAndQuery;
164
+ if (new URL(url).origin !== new URL(held.endpoint).origin) throw new LlmKeyError("bad_path");
165
+ const headers = new Headers(init.headers || {});
166
+ headers.set("authorization", `Bearer ${held.key}`);
167
+ stats.requests++;
168
+ return fetchImpl(url, { ...init, headers, redirect: "error" });
169
+ },
170
+ close() { disarm(); held = null; }, // process shutdown: memory only; a key FILE is kept for the next start
171
+ };
172
+ return holder;
173
+ }
@@ -8,6 +8,9 @@ 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 { loadContract } from "./contract.js";
11
14
 
12
15
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
13
16
  const WORKSPACE_ID = /^[A-Za-z0-9_.-]{0,64}$/;
@@ -56,6 +59,24 @@ export async function buildV2Link({ tenantId, agentId, signing, wsManager, getWo
56
59
  }
57
60
  }
58
61
  const contentCaps = contentCfg.enabled ? ["content.diff", "content.file", "content.file.list"] : []; // content.file.list: F3-07c, how the front finds out what exists (the gateway only sends file.list to agents that announce it)
62
+ // F3-11b: incident detector (logs -> incident.candidate, metadata only), behind LOCAL_AGENT_INCIDENT_DETECTOR (default off: nothing below changes anything)
63
+ const { resolveIncidentDetectorConfig } = await import("../incident-detector/config.js");
64
+ const incidentCfg = resolveIncidentDetectorConfig(env, (m) => log.warn(m));
65
+ // F3-12 consumer: webhook.forward receiver, behind LOCAL_AGENT_WEBHOOK_FORWARD (default off). The incident detector is its local consumer:
66
+ // without it there is nobody to hand a webhook to, so the handler is NOT registered and no webhook capability is announced.
67
+ let webhookCfg = resolveWebhookConfig(env, (m) => log.warn(m), loadContract().limits);
68
+ if (webhookCfg.enabled && !incidentCfg.enabled) {
69
+ (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");
70
+ webhookCfg = { enabled: false };
71
+ }
72
+ // F3-09 consumer: virtual LLM key of the AI gateway (config.update.llmKey), behind LOCAL_AGENT_LLM_KEY (default off)
73
+ const llmCfg = resolveLlmKeyConfig(env, (m) => log.warn(m));
74
+ const hasIncidentSource = incidentCfg.enabled && (incidentCfg.files.length > 0 || incidentCfg.stdin || !!incidentCfg.loki || webhookCfg.enabled);
75
+ const incidentCaps = hasIncidentSource ? ["incident.candidate"] : [];
76
+ const webhookCaps = webhookCfg.enabled ? ["webhook.forward", "webhook.chunked"] : [];
77
+ const llmCaps = llmCfg.enabled ? ["llm.key"] : [];
78
+ // contract 2.1: announced only when a 2.1 feature is on, so with every flag off register/hello are exactly what they were
79
+ const v21Caps = webhookCaps.length || llmCaps.length || incidentCaps.length ? ["contract.v2.1"] : [];
59
80
  const link = createV2Link({
60
81
  log,
61
82
  sessionOptions: {
@@ -65,7 +86,10 @@ export async function buildV2Link({ tenantId, agentId, signing, wsManager, getWo
65
86
  signing,
66
87
  laVersion: agentVersion(),
67
88
  // only what this build implements: the transport, the handshake and the v1 commands on the same connection
68
- capabilities: ["protocol.v2", "v1.commands", "chunk.base64", ...contentCaps],
89
+ capabilities: ["protocol.v2", "v1.commands", "chunk.base64", ...contentCaps, ...incidentCaps, ...webhookCaps, ...llmCaps, ...v21Caps],
90
+ // 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
91
+ // valid, unexpired key (a revoked or expired one is not announced from the next connection on). `link` is assigned below, before any attach.
92
+ dynamicCapabilities: () => (llmCfg.enabled && link.llm && link.llm.holder.held ? ["llm.key.held"] : []),
69
93
  getWorkspaces: () => {
70
94
  const open = (wsManager?.list?.() || []).map((w) => ({
71
95
  workspaceId: String(w.id || ""),
@@ -87,5 +111,24 @@ export async function buildV2Link({ tenantId, agentId, signing, wsManager, getWo
87
111
  const { installContentHandlers } = await import("./content-handlers/install.js");
88
112
  installContentHandlers({ link, tenantId: tenant, wsManager, getWorkspaceRoot, config: contentCfg, env, log });
89
113
  }
114
+ if (llmCfg.enabled) {
115
+ const { installLlmKey } = await import("./llm-key/install.js");
116
+ const roots = () => [getWorkspaceRoot(), ...(wsManager?.list?.() || []).map((w) => w && w.root)].filter(Boolean);
117
+ installLlmKey({ link, config: llmCfg, workspaceRoots: roots, log });
118
+ }
119
+ let webhookSource = null;
120
+ if (webhookCfg.enabled) {
121
+ const { createWebhookSource } = await import("../incident-detector/sources/webhook-source.js");
122
+ webhookSource = createWebhookSource({ maxEntries: webhookCfg.maxEntries, maxBytes: webhookCfg.maxBodyBytes, maxEntryBytes: incidentCfg.maxEntryBytes, maxStackLines: incidentCfg.maxStackLines });
123
+ }
124
+ if (incidentCfg.enabled) {
125
+ const { installIncidentDetector } = await import("../incident-detector/index.js");
126
+ installIncidentDetector({ link, workspaceId: WORKSPACE_ID.test(identityWs) ? identityWs : "", config: incidentCfg, env, log, extraSources: webhookSource ? [webhookSource] : [], model: `stack-detector-${agentVersion()}` });
127
+ }
128
+ if (webhookSource) {
129
+ const { installWebhookReceiver } = await import("./webhooks/install.js");
130
+ const receiver = installWebhookReceiver({ link, config: webhookCfg });
131
+ receiver.setSink((webhook) => webhookSource.deliver(webhook));
132
+ }
90
133
  return link;
91
134
  }