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.
- package/.claude/context.md +2 -1
- package/CLAUDE.md +1 -1
- package/docs/la-protocol-v2.md +27 -4
- package/env.example +12 -0
- package/package.json +1 -1
- package/scripts/incident-parity/Gen.java +256 -0
- package/scripts/incident-parity/LogEntry.java +17 -0
- package/scripts/incident-parity/extract.py +51 -0
- package/scripts/incident-parity/gen.sh +32 -0
- package/src/incident-detector/README.md +52 -0
- package/src/incident-detector/aggregator.js +78 -0
- package/src/incident-detector/categorize.js +48 -0
- package/src/incident-detector/config.js +95 -0
- package/src/incident-detector/detector.js +93 -0
- package/src/incident-detector/emitter.js +164 -0
- package/src/incident-detector/index.js +44 -0
- package/src/incident-detector/java-compat.js +66 -0
- package/src/incident-detector/normalize.js +209 -0
- package/src/incident-detector/parse.js +114 -0
- package/src/incident-detector/sources/file-source.js +113 -0
- package/src/incident-detector/sources/loki-source.js +101 -0
- package/src/incident-detector/sources/stream-source.js +39 -0
- package/src/incident-detector/sources/webhook-source.js +109 -0
- package/src/incident-detector/store.js +119 -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 +44 -1
- 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/parity.test.js +66 -0
- package/test/incident-detector/parity.vectors.json +2619 -0
- package/test/incident-detector/size-model.test.js +127 -0
- package/test/incident-detector/sources.test.js +179 -0
- package/test/incident-detector/units.test.js +430 -0
- package/test/incident-detector/wiring.test.js +165 -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
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
// F3-09 consumer (contract 2.1 (a)): config.update.llmKey on the Local Agent. The key is held in memory (or a 0600 file outside every
|
|
2
|
+
// workspace), is never returned in an ack and never reaches a log, a metric, the audit or any frame the agent sends: a test that SWEEPS
|
|
3
|
+
// every output channel for the key and for its id. Strict validation (id = sha256(key), expiry), rotation, revocation, flag off.
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import crypto from "node:crypto";
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import util from "node:util";
|
|
11
|
+
import { ReferenceCp, TENANT, AGENT, waitFor } from "./helpers/reference-cp.js";
|
|
12
|
+
import { signingFor } from "./helpers/la-rig.js";
|
|
13
|
+
import { buildV2Link } from "../../src/la-protocol-v2/runtime.js";
|
|
14
|
+
import { createLlmKeyHolder, LlmKeyError, insideAny } from "../../src/la-protocol-v2/llm-key/store.js";
|
|
15
|
+
import { resolveLlmKeyConfig } from "../../src/la-protocol-v2/llm-key/config.js";
|
|
16
|
+
|
|
17
|
+
const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), "la-llm-"));
|
|
18
|
+
const sha = (s) => crypto.createHash("sha256").update(Buffer.from(s, "utf8")).digest("hex");
|
|
19
|
+
const mkKey = (tag = "A") => `sk-CANARY-LLMKEY-${tag}-${crypto.randomBytes(12).toString("hex")}`;
|
|
20
|
+
const FUTURE = () => new Date(Date.now() + 3_600_000).toISOString();
|
|
21
|
+
const setOf = (key, over = {}) => ({ op: "set", keyId: sha(key), endpoint: "https://ai.visionhammer.io/litellm", key, models: ["vh-claude-sonnet", "vh-embed"], expiresAt: FUTURE(), ...over });
|
|
22
|
+
const noop = () => {};
|
|
23
|
+
|
|
24
|
+
// ------------------------------------------------------------------------------------------ the flag and the config
|
|
25
|
+
|
|
26
|
+
test("config: flag off by default (unset, empty, unknown value -> off); on only with true/1/yes/on; the key file must be an absolute path", () => {
|
|
27
|
+
for (const v of [undefined, "", "false", "0", "off", "nope"]) assert.deepEqual(resolveLlmKeyConfig({ LOCAL_AGENT_LLM_KEY: v }, noop), { enabled: false });
|
|
28
|
+
for (const v of ["true", "1", "YES", "on"]) assert.equal(resolveLlmKeyConfig({ LOCAL_AGENT_LLM_KEY: v }, noop).enabled, true);
|
|
29
|
+
const warns = [];
|
|
30
|
+
assert.equal(resolveLlmKeyConfig({ LOCAL_AGENT_LLM_KEY: "true", LOCAL_AGENT_LLM_KEY_FILE: "relative/key.json" }, (m) => warns.push(m)).file, null);
|
|
31
|
+
assert.ok(warns.length === 1);
|
|
32
|
+
assert.equal(resolveLlmKeyConfig({ LOCAL_AGENT_LLM_KEY: "true", LOCAL_AGENT_LLM_KEY_FILE: "/var/lib/la/llm.json" }, noop).file, "/var/lib/la/llm.json");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// ------------------------------------------------------------------------------------------ the holder
|
|
36
|
+
|
|
37
|
+
function holderWith(extra = {}) {
|
|
38
|
+
let t = 1_700_000_000_000;
|
|
39
|
+
const scheduled = [];
|
|
40
|
+
const timers = { setTimeout: (fn, ms) => { const h = { fn, ms, cleared: false }; scheduled.push(h); return h; }, clearTimeout: (h) => { if (h) h.cleared = true; } };
|
|
41
|
+
const h = createLlmKeyHolder({ now: () => t, timers, log: { warn: noop }, ...extra });
|
|
42
|
+
return { h, scheduled, advance: (ms) => { t += ms; }, at: () => new Date(t).toISOString(), now: () => t };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test("holder: set keeps the key, describe() is metadata only, nothing of the key shows in JSON / inspect / enumerable state", () => {
|
|
46
|
+
const { h, now } = holderWith();
|
|
47
|
+
const key = mkKey();
|
|
48
|
+
assert.deepEqual(h.set(setOf(key, { expiresAt: new Date(now() + 60_000).toISOString() })), { ok: true, replaced: false });
|
|
49
|
+
assert.equal(h.held, true);
|
|
50
|
+
const d = h.describe();
|
|
51
|
+
assert.deepEqual(Object.keys(d).sort(), ["expiresAt", "held", "models"]);
|
|
52
|
+
for (const view of [JSON.stringify(h), util.inspect(h, { depth: 6, showHidden: true }), JSON.stringify(d), JSON.stringify(Object.entries(h)), String(h)]) {
|
|
53
|
+
assert.ok(!view.includes(key) && !view.includes(sha(key)), "no key, no id");
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("holder: strict validation: id must be the sha256 of the key, the key must not be expired, shapes are re-checked (schema_violation, nothing stored)", () => {
|
|
58
|
+
const { h, now } = holderWith();
|
|
59
|
+
const key = mkKey();
|
|
60
|
+
for (const bad of [
|
|
61
|
+
setOf(key, { keyId: sha("another key") }),
|
|
62
|
+
setOf(key, { expiresAt: new Date(now() - 1000).toISOString() }),
|
|
63
|
+
setOf(key, { endpoint: "http://ai.visionhammer.io" }),
|
|
64
|
+
setOf(key, { endpoint: "https://user:pw@ai.visionhammer.io" }),
|
|
65
|
+
setOf(key, { models: [] }),
|
|
66
|
+
setOf("short", {}),
|
|
67
|
+
setOf(key, { key: "with space in the key 1234567890" }),
|
|
68
|
+
]) {
|
|
69
|
+
assert.deepEqual(h.set(bad), { ok: false, code: "schema_violation" });
|
|
70
|
+
}
|
|
71
|
+
assert.equal(h.held, false);
|
|
72
|
+
assert.equal(h.stats.refused, 7);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("holder: rotation replaces atomically (one key per agent); the same id again is idempotent; the old key is gone", () => {
|
|
76
|
+
const { h, now } = holderWith();
|
|
77
|
+
const k1 = mkKey("1"); const k2 = mkKey("2");
|
|
78
|
+
const exp = new Date(now() + 60_000).toISOString();
|
|
79
|
+
assert.equal(h.set(setOf(k1, { expiresAt: exp })).replaced, false);
|
|
80
|
+
assert.equal(h.set(setOf(k1, { expiresAt: exp })).replaced, false, "same keyId again");
|
|
81
|
+
assert.equal(h.set(setOf(k2, { expiresAt: exp })).replaced, true);
|
|
82
|
+
assert.equal(h.stats.replaced, 1);
|
|
83
|
+
assert.deepEqual(h.revoke(sha(k1)), { ok: true, dropped: false }, "the old id is not held any more: a no-op");
|
|
84
|
+
assert.equal(h.held, true);
|
|
85
|
+
assert.deepEqual(h.revoke(sha(k2)), { ok: true, dropped: true });
|
|
86
|
+
assert.equal(h.held, false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("holder: revoke of an id it does not hold is a no-op (it never says which key it has)", () => {
|
|
90
|
+
const { h, now } = holderWith();
|
|
91
|
+
h.set(setOf(mkKey(), { expiresAt: new Date(now() + 60_000).toISOString() }));
|
|
92
|
+
assert.deepEqual(h.revoke("f".repeat(64)), { ok: true, dropped: false });
|
|
93
|
+
assert.equal(h.held, true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("holder: the key expires by itself at expiresAt (timer, and a check at every use)", async () => {
|
|
97
|
+
const { h, scheduled, advance, now } = holderWith();
|
|
98
|
+
const key = mkKey();
|
|
99
|
+
h.set(setOf(key, { expiresAt: new Date(now() + 10_000).toISOString() }));
|
|
100
|
+
assert.equal(scheduled.at(-1).ms, 10_000);
|
|
101
|
+
advance(10_001);
|
|
102
|
+
scheduled.at(-1).fn();
|
|
103
|
+
assert.equal(h.held, false);
|
|
104
|
+
assert.equal(h.stats.expired, 1);
|
|
105
|
+
await assert.rejects(h.request("/v1/chat/completions", {}, { fetchImpl: noop }), (e) => e instanceof LlmKeyError && e.code === "no_key");
|
|
106
|
+
// no timer at all: the use itself notices
|
|
107
|
+
const b = holderWith();
|
|
108
|
+
b.h.set(setOf(key, { expiresAt: new Date(b.now() + 10_000).toISOString() }));
|
|
109
|
+
b.advance(11_000);
|
|
110
|
+
await assert.rejects(b.h.request("/x", {}, { fetchImpl: noop }), (e) => e.code === "no_key" || e.code === "expired");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("request(): the hook for a future LLM client: Authorization is set by the holder, redirects refused, the host is never the caller's, paths are checked", async () => {
|
|
114
|
+
const { h, now } = holderWith();
|
|
115
|
+
const key = mkKey();
|
|
116
|
+
h.set(setOf(key, { endpoint: "https://ai.visionhammer.io:8443/litellm/", expiresAt: new Date(now() + 60_000).toISOString() }));
|
|
117
|
+
const seen = [];
|
|
118
|
+
const fetchImpl = async (url, init) => { seen.push({ url, init }); return { status: 200 }; };
|
|
119
|
+
await h.request("/v1/chat/completions?x=1", { method: "POST", headers: { authorization: "Bearer attacker", "content-type": "application/json" }, body: "{}" }, { fetchImpl });
|
|
120
|
+
assert.equal(seen[0].url, "https://ai.visionhammer.io:8443/litellm/v1/chat/completions?x=1");
|
|
121
|
+
assert.equal(seen[0].init.headers.get("authorization"), `Bearer ${key}`, "the caller's header is replaced");
|
|
122
|
+
assert.equal(seen[0].init.headers.get("content-type"), "application/json");
|
|
123
|
+
assert.equal(seen[0].init.redirect, "error");
|
|
124
|
+
for (const bad of ["//evil.example/x", "http://evil.example/x", "v1/x", "/a/../b", "/x y", "/x\n", "", null, "/" + "a".repeat(2100)]) {
|
|
125
|
+
await assert.rejects(h.request(bad, {}, { fetchImpl }), (e) => e instanceof LlmKeyError && e.code === "bad_path", String(bad).slice(0, 20));
|
|
126
|
+
}
|
|
127
|
+
assert.equal(seen.length, 1);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("holder file: mode 0600 in a 0700 directory, atomic replace, overwritten with zeros and unlinked on revoke, reloaded after a restart", () => {
|
|
131
|
+
const root = tmp();
|
|
132
|
+
const file = path.join(root, "secrets", "llm.json");
|
|
133
|
+
const now = Date.now();
|
|
134
|
+
const key = mkKey();
|
|
135
|
+
const a = createLlmKeyHolder({ file, log: { warn: noop } });
|
|
136
|
+
a.set(setOf(key, { expiresAt: new Date(now + 3_600_000).toISOString() }));
|
|
137
|
+
assert.equal(fs.statSync(file).mode & 0o777, 0o600);
|
|
138
|
+
assert.equal(fs.statSync(path.dirname(file)).mode & 0o777, 0o700);
|
|
139
|
+
assert.deepEqual(fs.readdirSync(path.dirname(file)), ["llm.json"], "no temp file left behind");
|
|
140
|
+
// a restart: a new holder loads it
|
|
141
|
+
a.close();
|
|
142
|
+
const b = createLlmKeyHolder({ file, log: { warn: noop } });
|
|
143
|
+
assert.equal(b.load(), true);
|
|
144
|
+
assert.equal(b.held, true);
|
|
145
|
+
assert.equal(b.stats.set, 0, "a reload is not a new delivery");
|
|
146
|
+
// revoke: the bytes are overwritten before the unlink
|
|
147
|
+
const size = fs.statSync(file).size;
|
|
148
|
+
let overwritten = null;
|
|
149
|
+
const realUnlink = fs.unlinkSync;
|
|
150
|
+
fs.unlinkSync = (p) => { if (p === file) overwritten = fs.readFileSync(file); return realUnlink(p); };
|
|
151
|
+
try { b.revoke(sha(key)); } finally { fs.unlinkSync = realUnlink; }
|
|
152
|
+
assert.equal(fs.existsSync(file), false);
|
|
153
|
+
assert.equal(overwritten.length, size);
|
|
154
|
+
assert.ok(overwritten.every((x) => x === 0), "zeros before the unlink");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("holder file: a file readable by others, an expired one, or a corrupt one is never trusted: wiped, nothing loaded", () => {
|
|
158
|
+
const root = tmp();
|
|
159
|
+
const file = path.join(root, "llm.json");
|
|
160
|
+
const key = mkKey();
|
|
161
|
+
const body = (over = {}) => JSON.stringify({ v: 1, keyId: sha(key), endpoint: "https://ai.visionhammer.io", key, models: ["m"], expiresAt: FUTURE(), ...over });
|
|
162
|
+
fs.writeFileSync(file, body(), { mode: 0o644 });
|
|
163
|
+
fs.chmodSync(file, 0o644);
|
|
164
|
+
assert.equal(createLlmKeyHolder({ file, log: { warn: noop } }).load(), false);
|
|
165
|
+
assert.equal(fs.existsSync(file), false, "world-readable key file: wiped");
|
|
166
|
+
fs.writeFileSync(file, body({ expiresAt: new Date(Date.now() - 1000).toISOString() }), { mode: 0o600 });
|
|
167
|
+
assert.equal(createLlmKeyHolder({ file, log: { warn: noop } }).load(), false);
|
|
168
|
+
assert.equal(fs.existsSync(file), false);
|
|
169
|
+
fs.writeFileSync(file, "{not json", { mode: 0o600 });
|
|
170
|
+
assert.equal(createLlmKeyHolder({ file, log: { warn: noop } }).load(), false);
|
|
171
|
+
fs.writeFileSync(file, body({ keyId: sha("other") }), { mode: 0o600 });
|
|
172
|
+
assert.equal(createLlmKeyHolder({ file, log: { warn: noop } }).load(), false, "id is not the sha256 of the key");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("holder file: a path inside a workspace is refused (the key stays in memory only, nothing is written there)", () => {
|
|
176
|
+
const ws = tmp();
|
|
177
|
+
const warns = [];
|
|
178
|
+
const file = path.join(ws, "sub", "llm.json");
|
|
179
|
+
const h = createLlmKeyHolder({ file, workspaceRoots: () => [ws], log: { warn: (m) => warns.push(m) } });
|
|
180
|
+
h.set(setOf(mkKey(), { expiresAt: FUTURE() }));
|
|
181
|
+
assert.equal(h.held, true, "memory");
|
|
182
|
+
assert.equal(fs.existsSync(path.join(ws, "sub")), false, "nothing written inside the workspace");
|
|
183
|
+
assert.ok(warns.some((m) => /inside a workspace/.test(m)));
|
|
184
|
+
assert.ok(!warns.join("\n").includes(ws), "the warning does not print the path");
|
|
185
|
+
assert.equal(insideAny(path.join(tmp(), "x.json"), [ws]), false);
|
|
186
|
+
assert.equal(insideAny(path.join(ws, "x.json"), [ws]), true);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// ------------------------------------------------------------------------------------------ the handler on the real link
|
|
190
|
+
|
|
191
|
+
async function linkFor(cp, env, root = tmp()) {
|
|
192
|
+
const logs = [];
|
|
193
|
+
const log = { info: (m) => logs.push(String(m)), log: (m) => logs.push(String(m)), warn: (m) => logs.push(String(m)), error: (m) => logs.push(String(m)) };
|
|
194
|
+
const wsManager = { list: () => [{ id: "ws-1", root, tenantId: TENANT }] };
|
|
195
|
+
const link = await buildV2Link({ tenantId: TENANT, agentId: AGENT, signing: signingFor({ keyId: cp.keyId, publicKeyRaw: cp.publicKeyRaw }, "enforce"), wsManager, getWorkspaceRoot: () => root, log, env });
|
|
196
|
+
const transport = { send: (t) => cp.feed(t), close() {}, bufferedAmount: () => 0 };
|
|
197
|
+
cp.attach((t) => queueMicrotask(() => link.session.handleText(t, transport)), () => {});
|
|
198
|
+
link.session.attach(transport);
|
|
199
|
+
await waitFor(() => link.session.state === "ready");
|
|
200
|
+
return { link, logs, transport, root };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Everything the process printed while `fn` ran: console.* and the two std streams. */
|
|
204
|
+
async function capturingOutput(fn) {
|
|
205
|
+
const out = [];
|
|
206
|
+
const saved = {};
|
|
207
|
+
for (const m of ["log", "info", "warn", "error", "debug"]) { saved[m] = console[m]; console[m] = (...a) => out.push(util.format(...a)); }
|
|
208
|
+
const so = process.stdout.write; const se = process.stderr.write;
|
|
209
|
+
process.stdout.write = (c, ...r) => { out.push(String(c)); const cb = r.find((x) => typeof x === "function"); if (cb) cb(); return true; };
|
|
210
|
+
process.stderr.write = (c, ...r) => { out.push(String(c)); const cb = r.find((x) => typeof x === "function"); if (cb) cb(); return true; };
|
|
211
|
+
try { await fn(); } finally { for (const m of Object.keys(saved)) console[m] = saved[m]; process.stdout.write = so; process.stderr.write = se; }
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
test("flag off (unset or any non-true value): `llm.key` is not announced, config.update is acked protocol_unsupported, no holder, nothing stored", async () => {
|
|
216
|
+
for (const v of [undefined, "false", "maybe"]) {
|
|
217
|
+
const dir = tmp();
|
|
218
|
+
const file = path.join(dir, "llm.json");
|
|
219
|
+
const cp = new ReferenceCp();
|
|
220
|
+
const { link } = await linkFor(cp, { LOCAL_AGENT_LLM_KEY: v, LOCAL_AGENT_LLM_KEY_FILE: file });
|
|
221
|
+
assert.deepEqual([...cp.registers[0].capabilities].sort(), ["chunk.base64", "protocol.v2", "v1.commands"], "register is exactly the baseline");
|
|
222
|
+
assert.equal(link.llm, undefined);
|
|
223
|
+
cp.sendData("config.update", { llmKey: setOf(mkKey()) });
|
|
224
|
+
await waitFor(() => cp.acksFromLa.length === 1);
|
|
225
|
+
assert.deepEqual(cp.acksFromLa[0], { ackSeq: 1, status: "rejected", errorCode: "protocol_unsupported" });
|
|
226
|
+
assert.equal(fs.existsSync(file), false);
|
|
227
|
+
link.close();
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("flag on: llm.key + contract.v2.1 announced; set -> accepted; the ack is the plain ack; the holder has the key; rotation and revoke work end to end (real signatures)", async () => {
|
|
232
|
+
const cp = new ReferenceCp();
|
|
233
|
+
const { link } = await linkFor(cp, { LOCAL_AGENT_LLM_KEY: "true" });
|
|
234
|
+
assert.ok(cp.registers[0].capabilities.includes("llm.key"));
|
|
235
|
+
assert.ok(cp.registers[0].capabilities.includes("contract.v2.1"));
|
|
236
|
+
assert.ok(cp.hellos[0].capabilities.includes("llm.key"));
|
|
237
|
+
const k1 = mkKey("1"); const k2 = mkKey("2");
|
|
238
|
+
cp.sendData("config.update", { llmKey: setOf(k1) });
|
|
239
|
+
await waitFor(() => cp.acksFromLa.length === 1);
|
|
240
|
+
assert.deepEqual(cp.acksFromLa[0], { ackSeq: 1, status: "accepted", errorCode: undefined });
|
|
241
|
+
assert.equal(link.llm.holder.held, true);
|
|
242
|
+
cp.sendData("config.update", { llmKey: setOf(k2) });
|
|
243
|
+
await waitFor(() => cp.acksFromLa.length === 2);
|
|
244
|
+
assert.equal(cp.acksFromLa[1].status, "accepted");
|
|
245
|
+
assert.equal(link.llm.holder.stats.replaced, 1);
|
|
246
|
+
// the old key's revoke is a no-op, the new key stays
|
|
247
|
+
cp.sendData("config.update", { llmKey: { op: "revoke", keyId: sha(k1) } });
|
|
248
|
+
await waitFor(() => cp.acksFromLa.length === 3);
|
|
249
|
+
assert.equal(cp.acksFromLa[2].status, "accepted");
|
|
250
|
+
assert.equal(link.llm.holder.held, true);
|
|
251
|
+
cp.sendData("config.update", { llmKey: { op: "revoke", keyId: sha(k2) } });
|
|
252
|
+
await waitFor(() => cp.acksFromLa.length === 4);
|
|
253
|
+
assert.equal(cp.acksFromLa[3].status, "accepted");
|
|
254
|
+
assert.equal(link.llm.holder.held, false);
|
|
255
|
+
link.close();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("flag on: a key that the LA refuses is `rejected schema_violation` (non-fatal): id is not the sha256, already expired; a config.update without llmKey is protocol_unsupported as before", async () => {
|
|
259
|
+
const cp = new ReferenceCp();
|
|
260
|
+
const { link } = await linkFor(cp, { LOCAL_AGENT_LLM_KEY: "true" });
|
|
261
|
+
cp.sendData("config.update", { llmKey: setOf(mkKey(), { keyId: sha("not the key") }) });
|
|
262
|
+
cp.sendData("config.update", { llmKey: setOf(mkKey(), { expiresAt: new Date(Date.now() - 5000).toISOString() }) });
|
|
263
|
+
cp.sendData("config.update", { flags: [{ name: "some.flag", enabled: true }] });
|
|
264
|
+
await waitFor(() => cp.acksFromLa.length === 3);
|
|
265
|
+
assert.deepEqual(cp.acksFromLa.map((a) => [a.status, a.errorCode]), [["rejected", "schema_violation"], ["rejected", "schema_violation"], ["rejected", "protocol_unsupported"]]);
|
|
266
|
+
assert.equal(link.llm.holder.held, false);
|
|
267
|
+
assert.equal(link.session.state, "ready", "not fatal");
|
|
268
|
+
assert.deepEqual(cp.errorsSent, []);
|
|
269
|
+
link.close();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("cross-tenant: a config.update of another tenant is refused before the handler and no key is stored", async () => {
|
|
273
|
+
const cp = new ReferenceCp();
|
|
274
|
+
const { link, transport } = await linkFor(cp, { LOCAL_AGENT_LLM_KEY: "true" });
|
|
275
|
+
try {
|
|
276
|
+
const frame = cp.frameWith("config.update", { llmKey: setOf(mkKey()) });
|
|
277
|
+
frame.tenantId = "99999999-5a4d-4e7b-9c21-0a1b2c3d4e5f";
|
|
278
|
+
link.session.handleText(JSON.stringify(frame), transport);
|
|
279
|
+
await waitFor(() => cp.peerErrors.length > 0);
|
|
280
|
+
assert.ok(["tenant_mismatch", "unauthorized", "params_mismatch"].includes(cp.peerErrors[0]), cp.peerErrors[0]);
|
|
281
|
+
assert.equal(link.llm.holder.held, false);
|
|
282
|
+
assert.equal(link.llm.handler.stats.frames, 0);
|
|
283
|
+
} finally { link.close(); }
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("two links (two tenants) never share a holder: a key set on one is not on the other", async () => {
|
|
287
|
+
const cp = new ReferenceCp();
|
|
288
|
+
const a = await linkFor(cp, { LOCAL_AGENT_LLM_KEY: "true" });
|
|
289
|
+
const cp2 = new ReferenceCp();
|
|
290
|
+
const b = await linkFor(cp2, { LOCAL_AGENT_LLM_KEY: "true" });
|
|
291
|
+
try {
|
|
292
|
+
cp.sendData("config.update", { llmKey: setOf(mkKey()) });
|
|
293
|
+
await waitFor(() => cp.acksFromLa.length >= 1);
|
|
294
|
+
assert.equal(a.link.llm.holder.held, true);
|
|
295
|
+
assert.equal(b.link.llm.holder.held, false);
|
|
296
|
+
} finally { a.link.close(); b.link.close(); }
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("SWEEP: the key and its id appear in no ack, no frame the agent sends, no log, no console/stdout/stderr output, no audit record, no stats, no file but the 0600 one", async () => {
|
|
300
|
+
const dir = tmp();
|
|
301
|
+
const file = path.join(dir, "secrets", "llm.json");
|
|
302
|
+
const audit = path.join(dir, "audit.jsonl");
|
|
303
|
+
const cp = new ReferenceCp();
|
|
304
|
+
const keys = [mkKey("sweep-1"), mkKey("sweep-2"), mkKey("sweep-3")];
|
|
305
|
+
const secrets = keys.flatMap((k) => [k, sha(k)]);
|
|
306
|
+
let linkRef;
|
|
307
|
+
const printed = await capturingOutput(async () => {
|
|
308
|
+
const { link, logs } = await linkFor(cp, { LOCAL_AGENT_LLM_KEY: "true", LOCAL_AGENT_LLM_KEY_FILE: file, LOCAL_AGENT_CONTENT_AUDIT_FILE: audit });
|
|
309
|
+
linkRef = { link, logs };
|
|
310
|
+
cp.sendData("config.update", { llmKey: setOf(keys[0]) }); // set
|
|
311
|
+
cp.sendData("config.update", { llmKey: setOf(keys[1]) }); // rotation
|
|
312
|
+
cp.sendData("config.update", { llmKey: setOf(keys[2], { keyId: sha("wrong") }) }); // refused
|
|
313
|
+
cp.sendData("config.update", { llmKey: setOf(keys[2], { expiresAt: new Date(Date.now() - 1000).toISOString() }) }); // refused (expired)
|
|
314
|
+
cp.sendData("config.update", { llmKey: { op: "revoke", keyId: sha(keys[0]) } }); // revoke of an old id
|
|
315
|
+
await waitFor(() => cp.acksFromLa.length === 5);
|
|
316
|
+
await link.llm.request("/v1/models", {}, { fetchImpl: async () => ({ status: 200 }) }).catch(noop);
|
|
317
|
+
cp.sendData("config.update", { llmKey: { op: "revoke", keyId: sha(keys[1]) } }); // revoke the held one
|
|
318
|
+
await waitFor(() => cp.acksFromLa.length === 6);
|
|
319
|
+
});
|
|
320
|
+
const { link, logs } = linkRef;
|
|
321
|
+
const channels = {
|
|
322
|
+
"frames the agent sent": cp.rawIn.join("\n"),
|
|
323
|
+
"acks": JSON.stringify(cp.acksFromLa),
|
|
324
|
+
"agent logs": logs.join("\n"),
|
|
325
|
+
"console and std streams": printed.join("\n"),
|
|
326
|
+
"audit ring": JSON.stringify(link.llm.audit.tail(1000)),
|
|
327
|
+
"handler/holder/session stats": JSON.stringify([link.llm.holder.stats, link.llm.handler.stats, link.session.snapshot, link.session.stats]),
|
|
328
|
+
"the holder object": JSON.stringify(link.llm.holder) + util.inspect(link.llm, { depth: 8, showHidden: true }),
|
|
329
|
+
};
|
|
330
|
+
if (fs.existsSync(audit)) channels["audit file"] = fs.readFileSync(audit, "utf8");
|
|
331
|
+
for (const [name, text] of Object.entries(channels)) {
|
|
332
|
+
for (const s of secrets) assert.ok(!text.includes(s), `a secret leaked into: ${name}`);
|
|
333
|
+
}
|
|
334
|
+
// the acks have exactly the ack shape, and no LA->CP frame has a key / llmKey / keyId field (contract pin)
|
|
335
|
+
for (const raw of cp.rawIn) {
|
|
336
|
+
const f = JSON.parse(raw);
|
|
337
|
+
const walk = (o) => { if (o && typeof o === "object") for (const [k, v] of Object.entries(o)) { assert.ok(!["key", "llmKey", "keyId"].includes(k), `field ${k} in a ${f.type} frame`); walk(v); } };
|
|
338
|
+
walk(f);
|
|
339
|
+
}
|
|
340
|
+
for (const a of cp.acksFromLa) assert.deepEqual(Object.keys(a).filter((k) => a[k] !== undefined).sort(), a.status === "rejected" ? ["ackSeq", "errorCode", "status"] : ["ackSeq", "status"]);
|
|
341
|
+
// after the revoke nothing is left on disk, and the file was never readable by others
|
|
342
|
+
assert.equal(fs.existsSync(file), false);
|
|
343
|
+
assert.equal(link.llm.holder.held, false);
|
|
344
|
+
// no key material anywhere under the temp dir
|
|
345
|
+
const stack = [dir]; const found = [];
|
|
346
|
+
while (stack.length) { const d = stack.pop(); for (const e of fs.readdirSync(d, { withFileTypes: true })) { const p = path.join(d, e.name); if (e.isDirectory()) stack.push(p); else found.push(p); } }
|
|
347
|
+
for (const p of found) for (const s of secrets) assert.ok(!fs.readFileSync(p, "utf8").includes(s), "a secret is on disk");
|
|
348
|
+
link.close();
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("flag on with a key file: the key survives a restart (loaded when the link is built), file 0600 outside the workspace, and is dropped at revoke", async () => {
|
|
352
|
+
const dir = tmp();
|
|
353
|
+
const root = path.join(dir, "ws");
|
|
354
|
+
fs.mkdirSync(root);
|
|
355
|
+
const file = path.join(dir, "secrets", "llm.json");
|
|
356
|
+
const cp = new ReferenceCp();
|
|
357
|
+
const key = mkKey("restart");
|
|
358
|
+
const a = await linkFor(cp, { LOCAL_AGENT_LLM_KEY: "true", LOCAL_AGENT_LLM_KEY_FILE: file }, root);
|
|
359
|
+
cp.sendData("config.update", { llmKey: setOf(key) });
|
|
360
|
+
await waitFor(() => cp.acksFromLa.length === 1);
|
|
361
|
+
assert.equal(fs.statSync(file).mode & 0o777, 0o600);
|
|
362
|
+
a.link.close();
|
|
363
|
+
const cp2 = new ReferenceCp();
|
|
364
|
+
const b = await linkFor(cp2, { LOCAL_AGENT_LLM_KEY: "true", LOCAL_AGENT_LLM_KEY_FILE: file }, root);
|
|
365
|
+
assert.equal(b.link.llm.holder.held, true, "loaded from the file");
|
|
366
|
+
cp2.sendData("config.update", { llmKey: { op: "revoke", keyId: sha(key) } });
|
|
367
|
+
await waitFor(() => cp2.acksFromLa.length === 1);
|
|
368
|
+
assert.equal(fs.existsSync(file), false);
|
|
369
|
+
b.link.close();
|
|
370
|
+
// a file path inside the workspace is refused
|
|
371
|
+
const c = await linkFor(new ReferenceCp(), { LOCAL_AGENT_LLM_KEY: "true", LOCAL_AGENT_LLM_KEY_FILE: path.join(root, "llm.json") }, root);
|
|
372
|
+
assert.ok(c.logs.some((l) => /inside a workspace/.test(l)));
|
|
373
|
+
c.link.close();
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// ------------------------------------------------------------------------------------------ llm.key.held (contract 2.1 (e))
|
|
377
|
+
|
|
378
|
+
/** A new connection of the same link (what the tunnel does after a dropped socket): detach, the CP sees a new attach, register + hello again. */
|
|
379
|
+
async function reconnect(cp, link, transport) {
|
|
380
|
+
const before = cp.registers.length;
|
|
381
|
+
link.session.detach(transport);
|
|
382
|
+
cp.attach((t) => queueMicrotask(() => link.session.handleText(t, transport)), () => {});
|
|
383
|
+
link.session.attach(transport);
|
|
384
|
+
await waitFor(() => link.session.state === "ready" && cp.registers.length === before + 1 && cp.hellos.length === before + 1);
|
|
385
|
+
}
|
|
386
|
+
const HELD = "llm.key.held";
|
|
387
|
+
|
|
388
|
+
test("llm.key.held: not announced without a key (flag on, nothing delivered yet); announced from the NEXT connection once a valid key is held, in register AND hello", async () => {
|
|
389
|
+
const cp = new ReferenceCp();
|
|
390
|
+
const { link, transport } = await linkFor(cp, { LOCAL_AGENT_LLM_KEY: "true" });
|
|
391
|
+
assert.ok(cp.registers[0].capabilities.includes("llm.key") && !cp.registers[0].capabilities.includes(HELD));
|
|
392
|
+
assert.ok(cp.hellos[0].capabilities.includes("llm.key") && !cp.hellos[0].capabilities.includes(HELD));
|
|
393
|
+
cp.sendData("config.update", { llmKey: setOf(mkKey("held-1")) });
|
|
394
|
+
await waitFor(() => cp.acksFromLa.length === 1);
|
|
395
|
+
assert.equal(cp.registers[0].capabilities.includes(HELD), false, "the announcement is a snapshot taken at connect time");
|
|
396
|
+
await reconnect(cp, link, transport);
|
|
397
|
+
for (const frame of [cp.registers[1], cp.hellos[1]]) {
|
|
398
|
+
assert.ok(frame.capabilities.includes(HELD), "held");
|
|
399
|
+
assert.ok(frame.capabilities.includes("llm.key"), "never without llm.key (the CP's schema refuses it alone)");
|
|
400
|
+
}
|
|
401
|
+
assert.deepEqual(cp.registers[1].capabilities, cp.hellos[1].capabilities, "register and hello of one session carry the same answer");
|
|
402
|
+
assert.deepEqual(cp.errorsSent, [], "the real CP schema accepted both frames");
|
|
403
|
+
link.close();
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test("llm.key.held: a revoked key is no longer announced from the next connection; a refused or expired one never was", async () => {
|
|
407
|
+
const cp = new ReferenceCp();
|
|
408
|
+
const { link, transport } = await linkFor(cp, { LOCAL_AGENT_LLM_KEY: "true" });
|
|
409
|
+
const key = mkKey("held-2");
|
|
410
|
+
cp.sendData("config.update", { llmKey: setOf(key) });
|
|
411
|
+
cp.sendData("config.update", { llmKey: setOf(mkKey("bad"), { keyId: sha("not the key") }) }); // refused: the held key stays
|
|
412
|
+
await waitFor(() => cp.acksFromLa.length === 2);
|
|
413
|
+
await reconnect(cp, link, transport);
|
|
414
|
+
assert.ok(cp.registers[1].capabilities.includes(HELD));
|
|
415
|
+
cp.sendData("config.update", { llmKey: { op: "revoke", keyId: sha(key) } });
|
|
416
|
+
await waitFor(() => cp.acksFromLa.length === 3);
|
|
417
|
+
await reconnect(cp, link, transport);
|
|
418
|
+
assert.ok(!cp.registers[2].capabilities.includes(HELD) && !cp.hellos[2].capabilities.includes(HELD), "revoked");
|
|
419
|
+
assert.ok(cp.registers[2].capabilities.includes("llm.key"), "it can still receive a new key");
|
|
420
|
+
link.close();
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("llm.key.held: a key that EXPIRED while connected is not announced on the next connection (the holder checks the clock, not only its timer)", async () => {
|
|
424
|
+
const cp = new ReferenceCp();
|
|
425
|
+
const { link, transport } = await linkFor(cp, { LOCAL_AGENT_LLM_KEY: "true" });
|
|
426
|
+
cp.sendData("config.update", { llmKey: setOf(mkKey("held-3"), { expiresAt: new Date(Date.now() + 350).toISOString() }) });
|
|
427
|
+
await waitFor(() => cp.acksFromLa.length === 1);
|
|
428
|
+
assert.equal(link.llm.holder.held, true);
|
|
429
|
+
await new Promise((r) => setTimeout(r, 450));
|
|
430
|
+
await reconnect(cp, link, transport);
|
|
431
|
+
assert.ok(!cp.registers[1].capabilities.includes(HELD));
|
|
432
|
+
assert.equal(link.llm.holder.held, false);
|
|
433
|
+
link.close();
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test("llm.key.held: after a restart a file key is announced at once (the first register), a memory-only agent is not; the file is 0600 in a 0700 directory", async () => {
|
|
437
|
+
const dir = tmp();
|
|
438
|
+
const root = path.join(dir, "ws");
|
|
439
|
+
fs.mkdirSync(root);
|
|
440
|
+
const file = path.join(dir, "secrets", "llm.json");
|
|
441
|
+
const key = mkKey("held-4");
|
|
442
|
+
const cp = new ReferenceCp();
|
|
443
|
+
const a = await linkFor(cp, { LOCAL_AGENT_LLM_KEY: "true", LOCAL_AGENT_LLM_KEY_FILE: file }, root);
|
|
444
|
+
cp.sendData("config.update", { llmKey: setOf(key) });
|
|
445
|
+
await waitFor(() => cp.acksFromLa.length === 1);
|
|
446
|
+
assert.equal(fs.statSync(file).mode & 0o777, 0o600, "file 0600");
|
|
447
|
+
assert.equal(fs.statSync(path.dirname(file)).mode & 0o777, 0o700, "directory 0700");
|
|
448
|
+
a.link.close();
|
|
449
|
+
// restart WITH the file: held from the first frame
|
|
450
|
+
const cp2 = new ReferenceCp();
|
|
451
|
+
const b = await linkFor(cp2, { LOCAL_AGENT_LLM_KEY: "true", LOCAL_AGENT_LLM_KEY_FILE: file }, root);
|
|
452
|
+
assert.ok(cp2.registers[0].capabilities.includes(HELD) && cp2.hellos[0].capabilities.includes(HELD));
|
|
453
|
+
b.link.close();
|
|
454
|
+
// restart WITHOUT a file (memory only): the key is gone, so it is not announced and the CP delivers again
|
|
455
|
+
const cp3 = new ReferenceCp();
|
|
456
|
+
const c = await linkFor(cp3, { LOCAL_AGENT_LLM_KEY: "true" }, root);
|
|
457
|
+
assert.ok(!cp3.registers[0].capabilities.includes(HELD));
|
|
458
|
+
assert.ok(cp3.registers[0].capabilities.includes("llm.key"));
|
|
459
|
+
c.link.close();
|
|
460
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
test("llm.key.held: flag off = never announced (not even a leftover key file), and the capability string carries no key material", async () => {
|
|
464
|
+
const dir = tmp();
|
|
465
|
+
const file = path.join(dir, "llm.json");
|
|
466
|
+
const key = mkKey("held-5");
|
|
467
|
+
const cp = new ReferenceCp();
|
|
468
|
+
const a = await linkFor(cp, { LOCAL_AGENT_LLM_KEY: "true", LOCAL_AGENT_LLM_KEY_FILE: file });
|
|
469
|
+
cp.sendData("config.update", { llmKey: setOf(key) });
|
|
470
|
+
await waitFor(() => cp.acksFromLa.length === 1);
|
|
471
|
+
await reconnect(cp, a.link, a.transport);
|
|
472
|
+
const wire = JSON.stringify([cp.registers[1], cp.hellos[1]]);
|
|
473
|
+
assert.ok(wire.includes(HELD) && !wire.includes(key) && !wire.includes(sha(key)), "the announcement is a name, never the key or its id");
|
|
474
|
+
a.link.close();
|
|
475
|
+
const cp2 = new ReferenceCp();
|
|
476
|
+
const b = await linkFor(cp2, { LOCAL_AGENT_LLM_KEY: "false", LOCAL_AGENT_LLM_KEY_FILE: file });
|
|
477
|
+
assert.deepEqual([...cp2.registers[0].capabilities].sort(), ["chunk.base64", "protocol.v2", "v1.commands"]);
|
|
478
|
+
b.link.close();
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test("session: dynamic capabilities are evaluated at EVERY attach; a throwing provider announces nothing extra; llm.key.held never goes out without llm.key", async () => {
|
|
482
|
+
const { ReferenceCp: Cp } = await import("./helpers/reference-cp.js");
|
|
483
|
+
const { inMemory } = await import("./helpers/la-rig.js");
|
|
484
|
+
let extra = [];
|
|
485
|
+
const cp = new Cp();
|
|
486
|
+
const env = inMemory(cp, { sessionOptions: { capabilities: ["protocol.v2", "llm.key"], dynamicCapabilities: () => extra } }).start();
|
|
487
|
+
await waitFor(() => env.session.state === "ready");
|
|
488
|
+
assert.deepEqual(cp.registers[0].capabilities, ["llm.key", "protocol.v2"]);
|
|
489
|
+
extra = [HELD, "BAD NAME", 7, HELD];
|
|
490
|
+
env.session.detach(env.transport);
|
|
491
|
+
cp.attach((t) => queueMicrotask(() => env.session.handleText(t, env.transport)), () => {});
|
|
492
|
+
env.session.attach(env.transport);
|
|
493
|
+
await waitFor(() => cp.registers.length === 2);
|
|
494
|
+
assert.deepEqual(cp.registers[1].capabilities, ["llm.key", HELD, "protocol.v2"], "deduplicated, valid names only, sorted");
|
|
495
|
+
assert.deepEqual(cp.registers[1].capabilities, JSON.parse(env.wire.toCp.filter((t) => JSON.parse(t).type === "hello").at(-1)).payload.capabilities);
|
|
496
|
+
env.session.close();
|
|
497
|
+
// no llm.key: the dependent capability is dropped instead of producing a frame the CP would refuse
|
|
498
|
+
const cp2 = new Cp();
|
|
499
|
+
const env2 = inMemory(cp2, { sessionOptions: { capabilities: ["protocol.v2"], dynamicCapabilities: () => { throw new Error("boom"); } } }).start();
|
|
500
|
+
await waitFor(() => env2.session.state === "ready");
|
|
501
|
+
assert.deepEqual(cp2.registers[0].capabilities, ["protocol.v2"]);
|
|
502
|
+
env2.session.close();
|
|
503
|
+
const cp3 = new Cp();
|
|
504
|
+
const env3 = inMemory(cp3, { sessionOptions: { capabilities: ["protocol.v2"], dynamicCapabilities: () => [HELD] } }).start();
|
|
505
|
+
await waitFor(() => env3.session.state === "ready");
|
|
506
|
+
assert.deepEqual(cp3.registers[0].capabilities, ["protocol.v2"]);
|
|
507
|
+
assert.deepEqual(cp3.errorsSent, []);
|
|
508
|
+
env3.session.close();
|
|
509
|
+
});
|
|
@@ -18,9 +18,9 @@ import { V2Session } from "../../src/la-protocol-v2/session.js";
|
|
|
18
18
|
const DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "../contract/la-protocol-v2/vectors/traces");
|
|
19
19
|
const traces = fs.readdirSync(DIR).filter((f) => f.endsWith(".json")).sort().map((f) => ({ file: f, name: f.replace(".json", ""), ...JSON.parse(fs.readFileSync(path.join(DIR, f), "utf8")) }));
|
|
20
20
|
|
|
21
|
-
test("the suite has all
|
|
22
|
-
assert.equal(traces.length,
|
|
23
|
-
assert.equal(traces.filter((t) => t.expect.ok).length,
|
|
21
|
+
test("the suite has all 34 traces (28 invalid, 6 valid: 26 of 2.0 + 8 of the chunked webhooks of 2.1)", () => {
|
|
22
|
+
assert.equal(traces.length, 34);
|
|
23
|
+
assert.equal(traces.filter((t) => t.expect.ok).length, 6);
|
|
24
24
|
});
|
|
25
25
|
|
|
26
26
|
const textOf = (fr) => {
|