deepdebug-local-agent 1.8.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/context.md +2 -1
- package/CLAUDE.md +1 -1
- package/docs/la-protocol-v2.md +19 -2
- package/env.example +5 -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/contract/CONTRACT_SOURCE.json +24 -16
- package/src/la-protocol-v2/contract/README.md +103 -3
- package/src/la-protocol-v2/contract/config.update.schema.json +85 -1
- package/src/la-protocol-v2/contract/hello.schema.json +26 -1
- package/src/la-protocol-v2/contract/incident.candidate.schema.json +11 -0
- package/src/la-protocol-v2/contract/index.json +9 -2
- package/src/la-protocol-v2/contract/ready.schema.json +8 -0
- package/src/la-protocol-v2/contract/register.schema.json +26 -1
- package/src/la-protocol-v2/contract/webhook.forward.schema.json +72 -3
- package/src/la-protocol-v2/contract.js +45 -7
- package/src/la-protocol-v2/llm-key/config.js +29 -0
- package/src/la-protocol-v2/llm-key/handler.js +33 -0
- package/src/la-protocol-v2/llm-key/install.js +21 -0
- package/src/la-protocol-v2/llm-key/store.js +173 -0
- package/src/la-protocol-v2/runtime.js +37 -3
- package/src/la-protocol-v2/session.js +47 -13
- package/src/la-protocol-v2/webhooks/assembler.js +85 -0
- package/src/la-protocol-v2/webhooks/config.js +51 -0
- package/src/la-protocol-v2/webhooks/handler.js +125 -0
- package/src/la-protocol-v2/webhooks/install.js +22 -0
- package/test/contract/la-protocol-v2/vectors/ack.vectors.json +91 -0
- package/test/contract/la-protocol-v2/vectors/config.update.vectors.json +1339 -0
- package/test/contract/la-protocol-v2/vectors/hello.vectors.json +261 -0
- package/test/contract/la-protocol-v2/vectors/incident.candidate.vectors.json +623 -0
- package/test/contract/la-protocol-v2/vectors/ready.vectors.json +599 -0
- package/test/contract/la-protocol-v2/vectors/register.vectors.json +275 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-after-last.json +186 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-body-hash-changes.json +186 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-last-flag-wrong.json +186 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-non-last-too-short.json +152 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-out-of-order.json +152 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-sha-mismatch.json +186 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-total-changes.json +186 -0
- package/test/contract/la-protocol-v2/vectors/traces/valid-webhook-chunks-in-order.json +218 -0
- package/test/contract/la-protocol-v2/vectors/webhook.forward.vectors.json +1263 -0
- package/test/incident-detector/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/reference-cp.js +8 -4
- package/test/la-protocol-v2/llm-key.test.js +509 -0
- package/test/la-protocol-v2/traces.test.js +3 -3
- package/test/la-protocol-v2/webhook-receiver.test.js +702 -0
|
@@ -52,9 +52,93 @@
|
|
|
52
52
|
},
|
|
53
53
|
"dataPlaneMode": {
|
|
54
54
|
"$ref": "defs.schema.json#/$defs/dataPlaneMode"
|
|
55
|
+
},
|
|
56
|
+
"llmKey": {
|
|
57
|
+
"oneOf": [
|
|
58
|
+
{
|
|
59
|
+
"type": "object",
|
|
60
|
+
"additionalProperties": false,
|
|
61
|
+
"properties": {
|
|
62
|
+
"op": {
|
|
63
|
+
"const": "set"
|
|
64
|
+
},
|
|
65
|
+
"keyId": {
|
|
66
|
+
"$ref": "defs.schema.json#/$defs/sha256"
|
|
67
|
+
},
|
|
68
|
+
"endpoint": {
|
|
69
|
+
"type": "string",
|
|
70
|
+
"minLength": 12,
|
|
71
|
+
"maxLength": 256,
|
|
72
|
+
"pattern": "^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})?$"
|
|
73
|
+
},
|
|
74
|
+
"key": {
|
|
75
|
+
"type": "string",
|
|
76
|
+
"minLength": 16,
|
|
77
|
+
"maxLength": 256,
|
|
78
|
+
"pattern": "^[A-Za-z0-9._~+/=-]{16,256}$",
|
|
79
|
+
"x-content-class": "secret"
|
|
80
|
+
},
|
|
81
|
+
"models": {
|
|
82
|
+
"type": "array",
|
|
83
|
+
"minItems": 1,
|
|
84
|
+
"maxItems": 32,
|
|
85
|
+
"uniqueItems": true,
|
|
86
|
+
"items": {
|
|
87
|
+
"type": "string",
|
|
88
|
+
"minLength": 1,
|
|
89
|
+
"maxLength": 96,
|
|
90
|
+
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,95}$"
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
"expiresAt": {
|
|
94
|
+
"$ref": "defs.schema.json#/$defs/ts"
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
"required": [
|
|
98
|
+
"op",
|
|
99
|
+
"keyId",
|
|
100
|
+
"endpoint",
|
|
101
|
+
"key",
|
|
102
|
+
"models",
|
|
103
|
+
"expiresAt"
|
|
104
|
+
]
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
"type": "object",
|
|
108
|
+
"additionalProperties": false,
|
|
109
|
+
"properties": {
|
|
110
|
+
"op": {
|
|
111
|
+
"const": "revoke"
|
|
112
|
+
},
|
|
113
|
+
"keyId": {
|
|
114
|
+
"$ref": "defs.schema.json#/$defs/sha256"
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
"required": [
|
|
118
|
+
"op",
|
|
119
|
+
"keyId"
|
|
120
|
+
]
|
|
121
|
+
}
|
|
122
|
+
]
|
|
55
123
|
}
|
|
56
124
|
},
|
|
57
|
-
"minProperties": 1
|
|
125
|
+
"minProperties": 1,
|
|
126
|
+
"if": {
|
|
127
|
+
"type": "object",
|
|
128
|
+
"properties": {
|
|
129
|
+
"llmKey": true
|
|
130
|
+
},
|
|
131
|
+
"required": [
|
|
132
|
+
"llmKey"
|
|
133
|
+
]
|
|
134
|
+
},
|
|
135
|
+
"then": {
|
|
136
|
+
"type": "object",
|
|
137
|
+
"properties": {
|
|
138
|
+
"llmKey": true
|
|
139
|
+
},
|
|
140
|
+
"maxProperties": 1
|
|
141
|
+
}
|
|
58
142
|
},
|
|
59
143
|
"seq": {
|
|
60
144
|
"type": "integer",
|
|
@@ -94,7 +94,32 @@
|
|
|
94
94
|
"clockEpochMs",
|
|
95
95
|
"diskFreeBytes",
|
|
96
96
|
"queueDepth"
|
|
97
|
-
]
|
|
97
|
+
],
|
|
98
|
+
"if": {
|
|
99
|
+
"properties": {
|
|
100
|
+
"capabilities": {
|
|
101
|
+
"type": "array",
|
|
102
|
+
"maxItems": 32,
|
|
103
|
+
"contains": {
|
|
104
|
+
"const": "llm.key.held"
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
"required": [
|
|
109
|
+
"capabilities"
|
|
110
|
+
]
|
|
111
|
+
},
|
|
112
|
+
"then": {
|
|
113
|
+
"properties": {
|
|
114
|
+
"capabilities": {
|
|
115
|
+
"type": "array",
|
|
116
|
+
"maxItems": 32,
|
|
117
|
+
"contains": {
|
|
118
|
+
"const": "llm.key"
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
98
123
|
},
|
|
99
124
|
"seq": {
|
|
100
125
|
"const": 0
|
|
@@ -72,6 +72,17 @@
|
|
|
72
72
|
"security",
|
|
73
73
|
"other"
|
|
74
74
|
]
|
|
75
|
+
},
|
|
76
|
+
"stackBytes": {
|
|
77
|
+
"type": "integer",
|
|
78
|
+
"minimum": 0,
|
|
79
|
+
"maximum": 16777216
|
|
80
|
+
},
|
|
81
|
+
"model": {
|
|
82
|
+
"type": "string",
|
|
83
|
+
"minLength": 1,
|
|
84
|
+
"maxLength": 48,
|
|
85
|
+
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:+-]{0,47}$"
|
|
75
86
|
}
|
|
76
87
|
},
|
|
77
88
|
"required": [
|
|
@@ -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": "
|
|
128
|
+
"contentClass": "secret"
|
|
122
129
|
},
|
|
123
130
|
"scan.upload": {
|
|
124
131
|
"schema": "scan.upload.schema.json",
|
|
@@ -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":
|
|
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":
|
|
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
|
-
/**
|
|
25
|
-
export
|
|
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
|
-
|
|
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
|
+
}
|