deepdebug-local-agent 1.12.1 → 1.13.1
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/CHANGELOG.md +2 -0
- package/docs/la-chat.md +35 -0
- package/docs/la-jobs-and-llm.md +1 -1
- package/package.json +1 -1
- package/src/dlp/rules.js +2 -2
- package/src/la-protocol-v2/chat/config.js +60 -0
- package/src/la-protocol-v2/chat/cursors.js +57 -0
- package/src/la-protocol-v2/chat/dlp.js +92 -0
- package/src/la-protocol-v2/chat/index.js +28 -0
- package/src/la-protocol-v2/chat/store.js +337 -0
- package/src/la-protocol-v2/chat/stream-source.js +23 -0
- package/src/la-protocol-v2/content-handlers/chat-session.js +41 -0
- package/src/la-protocol-v2/content-handlers/handler.js +10 -2
- package/src/la-protocol-v2/content-handlers/install.js +2 -2
- package/src/la-protocol-v2/content-handlers/job-stream.js +62 -0
- package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +13 -8
- package/src/la-protocol-v2/contract/README.md +42 -0
- package/src/la-protocol-v2/contract/chat.session.body.schema.json +47 -0
- package/src/la-protocol-v2/contract/index.json +1 -1
- package/src/la-protocol-v2/contract/job.assign.schema.json +3 -2
- package/src/la-protocol-v2/contract/job.stream.body.schema.json +49 -0
- package/src/la-protocol-v2/jobs/agent2/api-client.js +78 -51
- package/src/la-protocol-v2/jobs/agent2/config.js +1 -0
- package/src/la-protocol-v2/jobs/agent2/runner.js +92 -11
- package/src/la-protocol-v2/jobs/agent2/supervisor.js +2 -0
- package/src/la-protocol-v2/jobs/manager.js +5 -3
- package/src/la-protocol-v2/llm/client.js +21 -1
- package/src/la-protocol-v2/runtime.js +32 -3
- package/test/contract/la-protocol-v2/vectors/chat.session.body.vectors.json +10379 -0
- package/test/contract/la-protocol-v2/vectors/content.request.vectors.json +245 -0
- package/test/contract/la-protocol-v2/vectors/hello.vectors.json +112 -0
- package/test/contract/la-protocol-v2/vectors/job.assign.vectors.json +568 -3
- package/test/contract/la-protocol-v2/vectors/job.stream.body.vectors.json +4572 -0
- package/test/contract/la-protocol-v2/vectors/register.vectors.json +106 -0
- package/test/contract/la-protocol-v2/vectors/traces/valid-chat-job-and-stream.json +311 -0
- package/test/la-protocol-v2/agent2-runner.test.js +3 -1
- package/test/la-protocol-v2/chat-dlp-json.test.js +169 -0
- package/test/la-protocol-v2/chat-la.test.js +490 -0
- package/test/la-protocol-v2/chat-real-e2e.test.js +69 -0
- package/test/la-protocol-v2/chat-store.test.js +301 -0
- package/test/la-protocol-v2/contract-v21.test.js +2 -2
- package/test/la-protocol-v2/contract.test.js +6 -6
- package/test/la-protocol-v2/helpers/agent2-rig.js +2 -2
- package/test/la-protocol-v2/helpers/fake-agent2-cli.js +1 -0
- package/test/la-protocol-v2/helpers/fake-agent2.js +80 -5
- package/test/la-protocol-v2/jobs-e2e.test.js +2 -1
- package/test/la-protocol-v2/llm-client.test.js +1 -1
- package/test/la-protocol-v2/llm-messages.test.js +2 -0
- package/test/la-protocol-v2/llm-scope-ids.test.js +129 -0
- package/test/la-protocol-v2/traces.test.js +3 -3
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// QA 2026-11-05: vh-litellm's scope-tag guard (enforce) rejected every Local Agent chat call with tenant_tag_mismatch: the LA sent the tag vh-tenant:<id>
|
|
2
|
+
// but NOT vh_tenant_id / vh_workspace_id in the body. `guard()` below is a JS port of vh_plugins/scope_tags.py (collect + check); the fake gateway applies it to
|
|
3
|
+
// what really arrives, on both routes. Not proven here: the real LiteLLM (how it merges key tags / request tags / litellm_metadata).
|
|
4
|
+
import { test, after } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { createLlmKeyHolder } from "../../src/la-protocol-v2/llm-key/store.js";
|
|
7
|
+
import { createLlmClient } from "../../src/la-protocol-v2/llm/client.js";
|
|
8
|
+
import { resolveLlmClientConfig } from "../../src/la-protocol-v2/llm/config.js";
|
|
9
|
+
import { startFakeLlm, closeAllFakes, okJson, okMessages, byPath, mkKey, keySet } from "./helpers/fake-llm.js";
|
|
10
|
+
import { agent2Rig, TENANT, waitFor } from "./helpers/agent2-rig.js";
|
|
11
|
+
|
|
12
|
+
after(closeAllFakes);
|
|
13
|
+
const noop = () => {};
|
|
14
|
+
const ENFORCE = { LOCAL_AGENT_DLP_MODE: "enforce" };
|
|
15
|
+
const AGENT = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d";
|
|
16
|
+
|
|
17
|
+
// ---- port of vh_plugins/scope_tags.py
|
|
18
|
+
const blank = (v) => v === null || v === undefined || (typeof v === "string" && !v.trim());
|
|
19
|
+
const vals = (tags, prefix) => [...new Set(tags.filter((t) => typeof t === "string" && t.startsWith(prefix)).map((t) => t.slice(prefix.length).trim()))].sort();
|
|
20
|
+
export function check(tags, tenantId, workspaceId) {
|
|
21
|
+
const tt = vals(tags, "vh-tenant:"), wt = vals(tags, "vh-ws:");
|
|
22
|
+
if (tt.length > 1) return "multiple_tenant_tags";
|
|
23
|
+
if (wt.length > 1) return "multiple_workspace_tags";
|
|
24
|
+
if (tt.length && (blank(tenantId) || tt[0] !== String(tenantId).trim())) return "tenant_tag_mismatch";
|
|
25
|
+
if (wt.length && (blank(workspaceId) || wt[0] !== String(workspaceId).trim())) return "workspace_tag_mismatch";
|
|
26
|
+
if (!blank(tenantId) && !tt.length) return "missing_tenant_tag";
|
|
27
|
+
if (!blank(workspaceId) && !wt.length) return "missing_workspace_tag";
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
export function collect(data) {
|
|
31
|
+
const tags = []; const ids = {};
|
|
32
|
+
for (const f of ["metadata", "litellm_metadata"]) {
|
|
33
|
+
const md = data[f];
|
|
34
|
+
if (!md || typeof md !== "object" || Array.isArray(md)) continue;
|
|
35
|
+
if (Array.isArray(md.tags)) tags.push(...md.tags);
|
|
36
|
+
for (const k of ["vh_tenant_id", "vh_workspace_id"]) if (blank(ids[k]) && !blank(md[k])) ids[k] = md[k];
|
|
37
|
+
}
|
|
38
|
+
if (Array.isArray(data.tags)) tags.push(...data.tags);
|
|
39
|
+
return [tags, ids.vh_tenant_id, ids.vh_workspace_id];
|
|
40
|
+
}
|
|
41
|
+
/** What the proxy does for /v1/messages: ids stay in `metadata`, the tags are also present in `litellm_metadata` (merged by the proxy), and the key's own tenant tag is added too (the duplicate seen in QA). */
|
|
42
|
+
const asProxySees = (body, route, keyTags = []) => (route === "messages"
|
|
43
|
+
? { ...body, litellm_metadata: { tags: [...(body.metadata?.tags || []), ...keyTags] } }
|
|
44
|
+
: { ...body, metadata: { ...body.metadata, tags: [...(body.metadata?.tags || []), ...keyTags] } });
|
|
45
|
+
export const guard = (body, route, keyTags) => check(...collect(asProxySees(body, route, keyTags)));
|
|
46
|
+
const KEY_TAGS = [`vh-tenant:${TENANT}`, `vh-la:${AGENT}`, "vh-dpm:x"];
|
|
47
|
+
|
|
48
|
+
async function rig(tenantId = TENANT) {
|
|
49
|
+
const fake = await startFakeLlm(byPath({ chat: okJson("hi"), messages: okMessages("hi") }));
|
|
50
|
+
const key = mkKey();
|
|
51
|
+
const holder = createLlmKeyHolder({ log: { warn: noop } });
|
|
52
|
+
holder.set(keySet(key));
|
|
53
|
+
const config = { ...resolveLlmClientConfig({ LOCAL_AGENT_LLM_CLIENT: "true" }, noop), backoffMs: 0 };
|
|
54
|
+
const client = createLlmClient({ holder: { request: (p, i, o) => holder.request(p, i, o), describe: () => holder.describe(), get held() { return holder.held; } }, config, tenantId, agentId: AGENT, env: ENFORCE, fetchImpl: fake.fetchImpl });
|
|
55
|
+
return { fake, client };
|
|
56
|
+
}
|
|
57
|
+
const msgs = [{ role: "user", content: "go" }];
|
|
58
|
+
|
|
59
|
+
test("the guard port rejects what QA rejected (tag without ids) and accepts the agreeing request", () => {
|
|
60
|
+
assert.equal(check([`vh-tenant:${TENANT}`, `vh-tenant:${TENANT}`], null, null), "tenant_tag_mismatch");
|
|
61
|
+
assert.equal(check([`vh-tenant:${TENANT}`, `vh-tenant:${TENANT}`], TENANT, null), null, "the same tag twice is one value");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
for (const route of ["chat", "messages"]) {
|
|
65
|
+
test(`${route}: the request carries vh_tenant_id and vh_workspace_id and ONE tag each; the scope guard passes (with the key's tenant tag duplicated on top)`, async () => {
|
|
66
|
+
const r = await rig();
|
|
67
|
+
await r.client[route]({ model: "vh-claude-sonnet", messages: msgs, job: { jobId: "job-1", agent: "agent2", workspaceId: "ws-1" } });
|
|
68
|
+
const body = r.fake.requests[0].body;
|
|
69
|
+
assert.equal(body.metadata.vh_tenant_id, TENANT);
|
|
70
|
+
assert.equal(body.metadata.vh_workspace_id, "ws-1");
|
|
71
|
+
assert.equal(body.metadata.tags.filter((t) => t.startsWith("vh-tenant:")).length, 1);
|
|
72
|
+
assert.equal(body.metadata.tags.filter((t) => t.startsWith("vh-ws:")).length, 1);
|
|
73
|
+
assert.equal(new Set(body.metadata.tags).size, body.metadata.tags.length, "no duplicates");
|
|
74
|
+
assert.equal(guard(body, route, KEY_TAGS), null);
|
|
75
|
+
assert.equal(guard(body, route, []), null);
|
|
76
|
+
// before the fix: tags only, no ids
|
|
77
|
+
const old = { ...body, metadata: { tags: body.metadata.tags } };
|
|
78
|
+
assert.equal(guard(old, route, KEY_TAGS), "tenant_tag_mismatch");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test(`${route}: no workspace (or an unsafe one): the tenant is claimed, the workspace is not, no ws tag`, async () => {
|
|
82
|
+
const r = await rig();
|
|
83
|
+
await r.client[route]({ model: "vh-claude-sonnet", messages: msgs, job: { jobId: "job-1" } });
|
|
84
|
+
await r.client[route]({ model: "vh-claude-sonnet", messages: msgs, job: { jobId: "job-1", workspaceId: "ws-1,vh-tenant:evil" } });
|
|
85
|
+
for (const q of r.fake.requests) {
|
|
86
|
+
assert.equal(q.body.metadata.vh_tenant_id, TENANT);
|
|
87
|
+
assert.equal(q.body.metadata.vh_workspace_id, undefined);
|
|
88
|
+
assert.ok(!q.body.metadata.tags.some((t) => t.startsWith("vh-ws:") || t.includes("evil")));
|
|
89
|
+
assert.equal(guard(q.body, route, KEY_TAGS), null);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
test("agent2 hostile through the bridge: its metadata, tags and headers (another tenant/workspace) are overwritten by the LA's own, on /v1/messages and /v1/chat/completions", async () => {
|
|
95
|
+
const r = await agent2Rig({ scenario: "stuck" });
|
|
96
|
+
const a = r.assign();
|
|
97
|
+
await a.acked();
|
|
98
|
+
await waitFor(() => r.fake.jobs.size === 1);
|
|
99
|
+
const job = r.fake.jobs.get(a.p.jobId);
|
|
100
|
+
const evil = { vh_tenant_id: "11111111-2222-3333-4444-555555555555", vh_workspace_id: "other-ws", tags: ["vh-tenant:11111111-2222-3333-4444-555555555555", "vh-ws:other-ws", "vh-tenant:" + TENANT] };
|
|
101
|
+
const headers = { authorization: `Bearer ${job.llm.key}`, "content-type": "application/json", "x-litellm-tags": "vh-tenant:11111111-2222-3333-4444-555555555555", "x-vh-tenant-id": "evil" };
|
|
102
|
+
const before = r.fakeLlm.requests.length;
|
|
103
|
+
const m = await fetch(`${r.bridgeBase}/v1/messages`, { method: "POST", headers, body: JSON.stringify({ model: "claude-x", max_tokens: 50, messages: msgs, metadata: evil, litellm_metadata: evil, tags: evil.tags }) });
|
|
104
|
+
const c = await fetch(`${r.bridgeBase}/v1/chat/completions`, { method: "POST", headers, body: JSON.stringify({ model: "gpt-x", messages: msgs, metadata: evil, tags: evil.tags }) });
|
|
105
|
+
assert.equal(m.status, 200); assert.equal(c.status, 200);
|
|
106
|
+
const sent = r.fakeLlm.requests.slice(before);
|
|
107
|
+
assert.equal(sent.length, 2);
|
|
108
|
+
for (const q of sent) {
|
|
109
|
+
const route = /\/messages$/.test(q.url) ? "messages" : "chat";
|
|
110
|
+
assert.equal(q.body.metadata.vh_tenant_id, TENANT);
|
|
111
|
+
assert.equal(q.body.metadata.vh_workspace_id, "ws-1");
|
|
112
|
+
assert.ok(!q.raw.includes("11111111-2222") && !q.raw.includes("other-ws") && !q.raw.includes("evil"), "nothing of agent2's claim leaves");
|
|
113
|
+
assert.equal(q.headers["x-litellm-tags"], undefined);
|
|
114
|
+
assert.equal(q.body.litellm_metadata, undefined);
|
|
115
|
+
assert.equal(q.body.tags, undefined);
|
|
116
|
+
assert.equal(new Set(q.body.metadata.tags).size, q.body.metadata.tags.length);
|
|
117
|
+
assert.equal(guard(q.body, route, KEY_TAGS), null);
|
|
118
|
+
}
|
|
119
|
+
await r.stop();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("an unsafe tenant id claims nothing and tags nothing (never a smuggled tag)", async () => {
|
|
123
|
+
const r = await rig("t1,vh-tenant:evil");
|
|
124
|
+
await r.client.chat({ model: "vh-claude-sonnet", messages: msgs, job: { jobId: "j", workspaceId: "ws-1" } });
|
|
125
|
+
const md = r.fake.requests[0].body.metadata;
|
|
126
|
+
assert.equal(md.vh_tenant_id, undefined);
|
|
127
|
+
assert.equal(md.vh_workspace_id, undefined);
|
|
128
|
+
assert.ok(!md.tags.some((t) => t.startsWith("vh-tenant:") || t.startsWith("vh-ws:") || t.includes("evil")));
|
|
129
|
+
});
|
|
@@ -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 35 traces (28 invalid, 7 valid: 26 of 2.0 + 8 of the chunked webhooks of 2.1 + the chat of 2.3)", () => {
|
|
22
|
+
assert.equal(traces.length, 35);
|
|
23
|
+
assert.equal(traces.filter((t) => t.expect.ok).length, 7);
|
|
24
24
|
});
|
|
25
25
|
|
|
26
26
|
const textOf = (fr) => {
|