deepdebug-local-agent 1.2.0 → 1.4.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/.github/workflows/deploy-gcp.yml +58 -26
- package/CHANGELOG.md +13 -0
- package/docs/la-protocol-v2.md +68 -0
- package/docs/ws-metadata-only.md +44 -0
- package/env.example +7 -0
- package/package.json +2 -1
- package/scripts/sync-la-protocol-v2-contract.mjs +58 -0
- package/src/la-protocol-v2/chunks.js +45 -0
- package/src/la-protocol-v2/config.js +27 -0
- package/src/la-protocol-v2/content.js +57 -0
- package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +81 -0
- package/src/la-protocol-v2/contract/README.md +103 -0
- package/src/la-protocol-v2/contract/ack.schema.json +51 -0
- package/src/la-protocol-v2/contract/alert.schema.json +62 -0
- package/src/la-protocol-v2/contract/approval.decision.schema.json +57 -0
- package/src/la-protocol-v2/contract/audit.event.schema.json +86 -0
- package/src/la-protocol-v2/contract/config.update.schema.json +73 -0
- package/src/la-protocol-v2/contract/content.request.schema.json +110 -0
- package/src/la-protocol-v2/contract/content.response.schema.json +90 -0
- package/src/la-protocol-v2/contract/defs.schema.json +191 -0
- package/src/la-protocol-v2/contract/envelope.schema.json +89 -0
- package/src/la-protocol-v2/contract/error.schema.json +59 -0
- package/src/la-protocol-v2/contract/heartbeat.schema.json +100 -0
- package/src/la-protocol-v2/contract/hello.schema.json +111 -0
- package/src/la-protocol-v2/contract/incident.candidate.schema.json +102 -0
- package/src/la-protocol-v2/contract/index.json +166 -0
- package/src/la-protocol-v2/contract/job.assign.schema.json +212 -0
- package/src/la-protocol-v2/contract/job.cancel.schema.json +52 -0
- package/src/la-protocol-v2/contract/job.event.schema.json +115 -0
- package/src/la-protocol-v2/contract/job.result.schema.json +100 -0
- package/src/la-protocol-v2/contract/log.stats.schema.json +79 -0
- package/src/la-protocol-v2/contract/ready.schema.json +106 -0
- package/src/la-protocol-v2/contract/register.schema.json +92 -0
- package/src/la-protocol-v2/contract/scan.upload.schema.json +59 -0
- package/src/la-protocol-v2/contract/scan.uploaded.schema.json +51 -0
- package/src/la-protocol-v2/contract/webhook.forward.schema.json +69 -0
- package/src/la-protocol-v2/contract.js +90 -0
- package/src/la-protocol-v2/errors.js +34 -0
- package/src/la-protocol-v2/index.js +8 -0
- package/src/la-protocol-v2/link.js +93 -0
- package/src/la-protocol-v2/runtime.js +72 -0
- package/src/la-protocol-v2/session.js +650 -0
- package/src/la-protocol-v2/strict-json.js +47 -0
- package/src/mcp-http-server.js +5 -3
- package/src/server.js +70 -9
- package/src/utils/exec-policy.js +8 -1
- package/src/ws-channel/allowlist.js +143 -0
- package/src/ws-channel/config.js +22 -0
- package/src/ws-channel/connection-state.js +18 -0
- package/src/ws-channel/index.js +6 -0
- package/src/ws-channel/metadata-guard.js +78 -0
- package/src/ws-channel/policy-offline.js +40 -0
- package/src/ws-channel/transit-guard.js +55 -0
- package/test/contract/la-protocol-v2/vectors/ack.vectors.json +149 -0
- package/test/contract/la-protocol-v2/vectors/alert.vectors.json +101 -0
- package/test/contract/la-protocol-v2/vectors/approval.decision.vectors.json +165 -0
- package/test/contract/la-protocol-v2/vectors/audit.event.vectors.json +156 -0
- package/test/contract/la-protocol-v2/vectors/config.update.vectors.json +189 -0
- package/test/contract/la-protocol-v2/vectors/content.request.vectors.json +325 -0
- package/test/contract/la-protocol-v2/vectors/content.response.vectors.json +413 -0
- package/test/contract/la-protocol-v2/vectors/cross-type.vectors.json +260 -0
- package/test/contract/la-protocol-v2/vectors/error.vectors.json +179 -0
- package/test/contract/la-protocol-v2/vectors/heartbeat.vectors.json +109 -0
- package/test/contract/la-protocol-v2/vectors/hello.vectors.json +224 -0
- package/test/contract/la-protocol-v2/vectors/incident.candidate.vectors.json +186 -0
- package/test/contract/la-protocol-v2/vectors/job.assign.vectors.json +631 -0
- package/test/contract/la-protocol-v2/vectors/job.cancel.vectors.json +133 -0
- package/test/contract/la-protocol-v2/vectors/job.event.vectors.json +233 -0
- package/test/contract/la-protocol-v2/vectors/job.result.vectors.json +137 -0
- package/test/contract/la-protocol-v2/vectors/log.stats.vectors.json +137 -0
- package/test/contract/la-protocol-v2/vectors/ready.vectors.json +177 -0
- package/test/contract/la-protocol-v2/vectors/register.vectors.json +399 -0
- package/test/contract/la-protocol-v2/vectors/scan.upload.vectors.json +137 -0
- package/test/contract/la-protocol-v2/vectors/scan.uploaded.vectors.json +101 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-agent-mismatch.json +140 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-after-last.json +200 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-byte-length-mismatch.json +174 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-last-flag.json +200 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-out-of-order.json +200 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-total-changes.json +200 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-clock-skew.json +46 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-event-before-ready.json +72 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-first-frame-not-register.json +48 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-frame-over-48kib.json +141 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-la-sends-job-assign.json +173 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-params-raw-differs-from-payload.json +172 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-payload-above-limit.json +174 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-resume-restarts-seq.json +144 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-seq-gap.json +166 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-seq-repeated.json +166 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-sig-tenant-mismatch.json +172 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-signature-warn-with-enforce-required.json +77 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-tenant-mismatch.json +140 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-type-outside-allowlist.json +140 -0
- package/test/contract/la-protocol-v2/vectors/traces/valid-content-chunks-in-order.json +224 -0
- package/test/contract/la-protocol-v2/vectors/traces/valid-handshake-and-job.json +266 -0
- package/test/contract/la-protocol-v2/vectors/traces/valid-resume-continues-seq.json +168 -0
- package/test/contract/la-protocol-v2/vectors/traces/valid-retransmit-is-idempotent.json +193 -0
- package/test/contract/la-protocol-v2/vectors/webhook.forward.vectors.json +170 -0
- package/test/la-protocol-v2/contract.test.js +131 -0
- package/test/la-protocol-v2/helpers/la-rig.js +83 -0
- package/test/la-protocol-v2/helpers/reference-cp.js +349 -0
- package/test/la-protocol-v2/helpers/spawn-agent.js +35 -0
- package/test/la-protocol-v2/link.test.js +141 -0
- package/test/la-protocol-v2/session-inbound.test.js +279 -0
- package/test/la-protocol-v2/session-outbound.test.js +382 -0
- package/test/la-protocol-v2/traces.test.js +270 -0
- package/test/la-protocol-v2/tunnel-e2e.test.js +124 -0
- package/test/ws-channel/allowlist.test.js +67 -0
- package/test/ws-channel/metadata-guard.test.js +104 -0
- package/test/ws-channel/policy-offline.test.js +99 -0
- package/test/ws-channel/tunnel-e2e.test.js +88 -0
- package/tools/graphify-validation/extras/README.md +19 -0
- package/tools/graphify-validation/extras/bymethod.py +35 -0
- package/tools/graphify-validation/extras/docker/Dockerfile +16 -0
- package/tools/graphify-validation/extras/docker/inner.sh +7 -0
- package/tools/graphify-validation/extras/g10.py +59 -0
- package/tools/graphify-validation/extras/g3aug.py +49 -0
- package/tools/graphify-validation/extras/hybrid.py +34 -0
- package/tools/graphify-validation/extras/truth2.py +44 -0
- package/tools/interop/LaV2NodeInteropTest.java +266 -0
- package/tools/interop/README.md +21 -0
- package/tools/interop/la-interop-client.mjs +97 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// F3-05b: the 24 whole-session traces of the contract (F3-22), reproduced in Node. "Traces are language
|
|
2
|
+
// neutral; the LA must reproduce each expect.error." Three things are shown for them:
|
|
3
|
+
// 1. the reference CP (test helper) reproduces every trace: same error at the same frame (so the model the
|
|
4
|
+
// other tests use is the model of the contract);
|
|
5
|
+
// 2. the two traces whose error is in a CP->LA frame (params_mismatch, tenant_mismatch of the signature)
|
|
6
|
+
// are reproduced by the REAL LA session, same error at the same frame;
|
|
7
|
+
// 3. for the others (an LA frame that the CP refuses) the LA's own send path refuses to produce the same
|
|
8
|
+
// frame, with the same code; the valid traces are replayed with the LA as the LA.
|
|
9
|
+
import { test } from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { ReferenceCp, uuid, waitFor } from "./helpers/reference-cp.js";
|
|
15
|
+
import { inMemory, signingFor } from "./helpers/la-rig.js";
|
|
16
|
+
import { V2Session } from "../../src/la-protocol-v2/session.js";
|
|
17
|
+
|
|
18
|
+
const DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "../contract/la-protocol-v2/vectors/traces");
|
|
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
|
+
|
|
21
|
+
test("the suite has all 24 traces (20 invalid, 4 valid)", () => {
|
|
22
|
+
assert.equal(traces.length, 24);
|
|
23
|
+
assert.equal(traces.filter((t) => t.expect.ok).length, 4);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const textOf = (fr) => {
|
|
27
|
+
let text = JSON.stringify(fr.frame);
|
|
28
|
+
if (fr.inflateBytesTo) text += " ".repeat(Math.max(0, fr.inflateBytesTo - Buffer.byteLength(text)));
|
|
29
|
+
return text;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** The reference CP in the state the trace starts from (a resumed session when the trace says so). */
|
|
33
|
+
function cpFor(trace) {
|
|
34
|
+
const s = trace.session;
|
|
35
|
+
const cp = new ReferenceCp({ tenantId: s.tenantId, agentId: s.agentId, requireEnforce: s.requireEnforce, now: () => Date.parse(s.now) });
|
|
36
|
+
const ready = trace.frames.map((f) => f.frame).find((f) => f.type === "ready");
|
|
37
|
+
const reg = trace.frames.map((f) => f.frame).find((f) => f.type === "register");
|
|
38
|
+
if (ready && ready.payload.resumed) {
|
|
39
|
+
cp.sessionStarted = true;
|
|
40
|
+
cp.sessionId = ready.payload.sessionId;
|
|
41
|
+
cp.laSeq = ready.payload.peerLastSeq;
|
|
42
|
+
cp.cpSeq = reg.payload.resume.lastRecvSeq;
|
|
43
|
+
}
|
|
44
|
+
cp.attach(() => {}, () => {});
|
|
45
|
+
return cp;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ------------------------------------------------------------------------------------------ 1. the reference CP
|
|
49
|
+
|
|
50
|
+
for (const t of traces) {
|
|
51
|
+
const cpSideError = t.expect.error && t.frames[t.expect.atFrame].dir === "cp->la";
|
|
52
|
+
test(`reference CP reproduces ${t.name}${cpSideError ? " (n/a: the error is in a CP frame, see 2)" : ""}`, { skip: cpSideError }, () => {
|
|
53
|
+
const cp = cpFor(t);
|
|
54
|
+
let failed = null;
|
|
55
|
+
for (let i = 0; i < t.frames.length && !failed; i++) {
|
|
56
|
+
const fr = t.frames[i];
|
|
57
|
+
if (fr.dir === "cp->la") { cp.cpSeq = Math.max(cp.cpSeq, fr.frame.seq); continue; }
|
|
58
|
+
const r = cp.feed(textOf(fr));
|
|
59
|
+
if (r) failed = { error: r.code, atFrame: i };
|
|
60
|
+
}
|
|
61
|
+
if (t.expect.ok) {
|
|
62
|
+
assert.equal(failed, null, JSON.stringify(failed));
|
|
63
|
+
if (t.expect.duplicateFrames) assert.equal(cp.duplicates.length, t.expect.duplicateFrames.length);
|
|
64
|
+
} else {
|
|
65
|
+
assert.deepEqual(failed, { error: t.expect.error, atFrame: t.expect.atFrame });
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ------------------------------------------------------------------------------------------ 2. errors in CP frames, by the LA
|
|
71
|
+
|
|
72
|
+
for (const name of ["invalid-params-raw-differs-from-payload", "invalid-sig-tenant-mismatch"]) {
|
|
73
|
+
test(`the LA reproduces ${name}: ${traces.find((t) => t.name === name).expect.error} at frame ${traces.find((t) => t.name === name).expect.atFrame}`, async () => {
|
|
74
|
+
const t = traces.find((x) => x.name === name);
|
|
75
|
+
const cp = new ReferenceCp({ tenantId: t.session.tenantId, agentId: t.session.agentId });
|
|
76
|
+
// the trace's signatures are placeholders: mode off skips the cryptography, the structural checks still run
|
|
77
|
+
const env = inMemory(cp, { mode: "off", tenantId: t.session.tenantId, agentId: t.session.agentId });
|
|
78
|
+
env.session.attach(env.transport);
|
|
79
|
+
await waitFor(() => env.session.state === "ready");
|
|
80
|
+
const bad = t.frames[t.expect.atFrame].frame;
|
|
81
|
+
env.session.setHandler(bad.type, () => ({ accept: true }));
|
|
82
|
+
env.session.handleText(JSON.stringify(bad), env.transport);
|
|
83
|
+
await waitFor(() => cp.peerErrors.length > 0);
|
|
84
|
+
assert.deepEqual(cp.peerErrors, [t.expect.error]);
|
|
85
|
+
assert.equal(env.session.stats.dataOut, 0);
|
|
86
|
+
env.session.close();
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ------------------------------------------------------------------------------------------ 3. the LA never produces them
|
|
91
|
+
|
|
92
|
+
const LA_FRAMES = (t) => t.frames.map((f, i) => ({ ...f, i })).filter((f) => f.dir === "la->cp");
|
|
93
|
+
|
|
94
|
+
async function readyLa() {
|
|
95
|
+
const cp = new ReferenceCp();
|
|
96
|
+
const env = inMemory(cp).start();
|
|
97
|
+
await waitFor(() => env.session.state === "ready");
|
|
98
|
+
return { cp, ...env };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const name of ["invalid-chunk-after-last", "invalid-chunk-byte-length-mismatch", "invalid-chunk-last-flag", "invalid-chunk-out-of-order", "invalid-chunk-total-changes", "invalid-payload-above-limit"]) {
|
|
102
|
+
test(`the LA refuses to send the chunks of ${name}: same code at the same frame`, async () => {
|
|
103
|
+
const t = traces.find((x) => x.name === name);
|
|
104
|
+
const { cp, session } = await readyLa();
|
|
105
|
+
let got = null;
|
|
106
|
+
for (const fr of LA_FRAMES(t)) {
|
|
107
|
+
if (fr.frame.type !== "content.response") continue;
|
|
108
|
+
try {
|
|
109
|
+
await session.enqueue("content.response", fr.frame.payload, { requestId: fr.frame.requestId, chunk: fr.frame.chunk }).then((h) => h.acked);
|
|
110
|
+
} catch (e) { got = { error: e.code, atFrame: fr.i }; break; }
|
|
111
|
+
}
|
|
112
|
+
// the oversized trace chunk is refused by the schema (payload maxLength), which the CP reports as payload_too_large
|
|
113
|
+
assert.deepEqual(got, { error: t.expect.error, atFrame: t.expect.atFrame });
|
|
114
|
+
assert.deepEqual(cp.errorsSent, [], "nothing invalid reached the CP");
|
|
115
|
+
session.close();
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
test("the LA never produces invalid-event-before-ready / invalid-first-frame-not-register: register and hello first, no data before ready", async () => {
|
|
120
|
+
const cp = new ReferenceCp();
|
|
121
|
+
const sent = [];
|
|
122
|
+
const env = inMemory(cp);
|
|
123
|
+
const swallow = { send: (t) => sent.push(JSON.parse(t)), close() {}, bufferedAmount: () => 0 };
|
|
124
|
+
env.session.attach(swallow);
|
|
125
|
+
assert.deepEqual(sent.map((f) => f.type), ["register", "hello"], "the first frame is register, then hello");
|
|
126
|
+
await assert.rejects(env.session.enqueue("job.event", { jobId: "c3d4e5f6-a7b8-4c9d-8e0f-2a3b4c5d6e7f", state: "running" }), (e) => e.code === "not_ready");
|
|
127
|
+
assert.equal(sent.length, 2);
|
|
128
|
+
env.session.close();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("the LA never produces invalid-clock-skew: register.ts is the clock of the LA, hello carries clockEpochMs", async () => {
|
|
132
|
+
const cp = new ReferenceCp();
|
|
133
|
+
const sent = [];
|
|
134
|
+
const env = inMemory(cp);
|
|
135
|
+
env.session.attach({ send: (t) => sent.push(JSON.parse(t)), close() {}, bufferedAmount: () => 0 });
|
|
136
|
+
assert.ok(Math.abs(Date.parse(sent[0].ts) - Date.now()) < 2000);
|
|
137
|
+
assert.ok(Math.abs(sent[1].payload.clockEpochMs - Date.now()) < 2000);
|
|
138
|
+
env.session.close();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("the LA never produces invalid-signature-warn-with-enforce-required: hello.signatureMode is the real mode, and a CP that requires enforce says so", async () => {
|
|
142
|
+
const cp = new ReferenceCp({ requireEnforce: true });
|
|
143
|
+
const env = inMemory(cp, { mode: "warn" }).start();
|
|
144
|
+
await waitFor(() => cp.errorsSent.length > 0);
|
|
145
|
+
assert.deepEqual(cp.errorsSent, ["signature_mode_insufficient"]);
|
|
146
|
+
assert.equal(env.session.state, "await_ready"); // not ready, no data possible
|
|
147
|
+
assert.equal(cp.hellos.length, 0);
|
|
148
|
+
env.session.close();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("the LA never produces invalid-agent-mismatch / invalid-tenant-mismatch: every frame carries the configured ids, never one from a payload", async () => {
|
|
152
|
+
const { cp, session } = await readyLa();
|
|
153
|
+
await session.send("job.event", { jobId: "c3d4e5f6-a7b8-4c9d-8e0f-2a3b4c5d6e7f", state: "running" });
|
|
154
|
+
for (const f of cp.received) {
|
|
155
|
+
assert.equal(f.tenantId, cp.tenantId);
|
|
156
|
+
assert.equal(f.agentId, cp.agentId);
|
|
157
|
+
}
|
|
158
|
+
// there is no way to pass an identity to enqueue(); a payload that tries is refused by the closed schema
|
|
159
|
+
await assert.rejects(session.enqueue("job.event", { jobId: "c3d4e5f6-a7b8-4c9d-8e0f-2a3b4c5d6e7f", state: "running", tenantId: "99999999-5a4d-4e7b-9c21-0a1b2c3d4e5f" }), (e) => e.code === "schema_violation");
|
|
160
|
+
session.close();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("the LA never produces invalid-la-sends-job-assign / invalid-type-outside-allowlist", async () => {
|
|
164
|
+
const { cp, session } = await readyLa();
|
|
165
|
+
await assert.rejects(session.enqueue("job.assign", {}), (e) => e.code === "wrong_direction");
|
|
166
|
+
await assert.rejects(session.enqueue("shell.exec", {}), (e) => e.code === "unknown_type");
|
|
167
|
+
assert.deepEqual(cp.errorsSent, []);
|
|
168
|
+
session.close();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("the LA never produces invalid-seq-gap / invalid-seq-repeated / invalid-resume-restarts-seq: consecutive seq, one msgId per seq, a resume continues", async () => {
|
|
172
|
+
const { cp, session } = await readyLa();
|
|
173
|
+
for (let i = 0; i < 4; i++) await session.send("job.event", { jobId: "c3d4e5f6-a7b8-4c9d-8e0f-2a3b4c5d6e7f", state: "running", iteration: i });
|
|
174
|
+
assert.deepEqual(cp.received.map((f) => f.seq), [1, 2, 3, 4]);
|
|
175
|
+
assert.equal(new Set(cp.received.map((f) => f.msgId)).size, 4);
|
|
176
|
+
assert.deepEqual(cp.errorsSent, []);
|
|
177
|
+
session.close();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("the LA never produces invalid-frame-over-48kib: frames are compact JSON and the size is checked before sending", async () => {
|
|
181
|
+
const { cp, session } = await readyLa();
|
|
182
|
+
await assert.rejects(session.enqueue("job.event", { jobId: "c3d4e5f6-a7b8-4c9d-8e0f-2a3b4c5d6e7f", state: "running", phase: "x".repeat(60_000) }), (e) => ["frame_too_large", "payload_too_large", "schema_violation"].includes(e.code));
|
|
183
|
+
// and the same frame as the trace has it (padded to 50000 bytes) is refused on the way IN as frame_too_large
|
|
184
|
+
const t = traces.find((x) => x.name === "invalid-frame-over-48kib");
|
|
185
|
+
const fr = t.frames[t.expect.atFrame];
|
|
186
|
+
const { transport } = { transport: session.transport };
|
|
187
|
+
session.handleText(textOf(fr), transport);
|
|
188
|
+
await waitFor(() => cp.peerErrors.length > 0);
|
|
189
|
+
assert.deepEqual(cp.peerErrors, ["frame_too_large"]);
|
|
190
|
+
session.close();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// ------------------------------------------------------------------------------------------ valid traces, LA as the LA
|
|
194
|
+
|
|
195
|
+
test("valid-handshake-and-job: CP job.assign -> LA acks it (ackSeq 1) and then reports job.event x2 + job.result with seq 1..3", async () => {
|
|
196
|
+
const t = traces.find((x) => x.name === "valid-handshake-and-job");
|
|
197
|
+
const cp = new ReferenceCp({ tenantId: t.session.tenantId, agentId: t.session.agentId, requireEnforce: true });
|
|
198
|
+
const env = inMemory(cp, { tenantId: t.session.tenantId, agentId: t.session.agentId }).start();
|
|
199
|
+
await waitFor(() => env.session.state === "ready");
|
|
200
|
+
const assign = t.frames.find((f) => f.frame.type === "job.assign").frame;
|
|
201
|
+
env.session.setHandler("job.assign", (ctx) => { assert.deepEqual(ctx.params, assign.payload); return { accept: true }; });
|
|
202
|
+
cp.sendData("job.assign", assign.payload, { workspaceId: assign.workspaceId, requestId: assign.requestId });
|
|
203
|
+
await waitFor(() => cp.acksFromLa.length === 1);
|
|
204
|
+
assert.deepEqual(cp.acksFromLa[0], { ackSeq: 1, status: "accepted", errorCode: undefined });
|
|
205
|
+
for (const fr of LA_FRAMES(t).filter((f) => ["job.event", "job.result"].includes(f.frame.type))) {
|
|
206
|
+
await env.session.send(fr.frame.type, fr.frame.payload, { workspaceId: fr.frame.workspaceId });
|
|
207
|
+
}
|
|
208
|
+
assert.deepEqual(cp.received.map((f) => [f.type, f.seq]), [["job.event", 1], ["job.event", 2], ["job.result", 3]]);
|
|
209
|
+
assert.deepEqual(cp.errorsSent, []);
|
|
210
|
+
env.session.close();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("valid-content-chunks-in-order: the three chunks of the trace go out in order and the CP accepts them", async () => {
|
|
214
|
+
const t = traces.find((x) => x.name === "valid-content-chunks-in-order");
|
|
215
|
+
const cp = new ReferenceCp({ tenantId: t.session.tenantId, agentId: t.session.agentId });
|
|
216
|
+
const env = inMemory(cp, { tenantId: t.session.tenantId, agentId: t.session.agentId }).start();
|
|
217
|
+
await waitFor(() => env.session.state === "ready");
|
|
218
|
+
for (const fr of LA_FRAMES(t).filter((f) => f.frame.type === "content.response")) {
|
|
219
|
+
await env.session.send("content.response", fr.frame.payload, { requestId: fr.frame.requestId, chunk: fr.frame.chunk, workspaceId: fr.frame.workspaceId });
|
|
220
|
+
}
|
|
221
|
+
assert.deepEqual(cp.received.map((f) => [f.seq, f.chunk.index]), [[1, 0], [2, 1], [3, 2]]);
|
|
222
|
+
assert.deepEqual(cp.errorsSent, []);
|
|
223
|
+
env.session.close();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("valid-retransmit-is-idempotent: an ack lost -> the same frame again -> the CP re-acks and counts one event", async () => {
|
|
227
|
+
const cp = new ReferenceCp({ limits: { ackTimeoutMs: 100 } });
|
|
228
|
+
const env = inMemory(cp).start();
|
|
229
|
+
await waitFor(() => env.session.state === "ready");
|
|
230
|
+
cp.dropAcks = 1;
|
|
231
|
+
await env.session.send("job.event", { jobId: "c3d4e5f6-a7b8-4c9d-8e0f-2a3b4c5d6e7f", state: "running" });
|
|
232
|
+
await env.session.send("job.event", { jobId: "c3d4e5f6-a7b8-4c9d-8e0f-2a3b4c5d6e7f", state: "succeeded" });
|
|
233
|
+
assert.deepEqual(cp.duplicates, [1]);
|
|
234
|
+
assert.deepEqual(cp.received.map((f) => f.seq), [1, 2]);
|
|
235
|
+
assert.deepEqual(cp.errorsSent, []);
|
|
236
|
+
env.session.close();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("valid-resume-continues-seq: after a resumed ready the LA continues at peerLastSeq+1", async () => {
|
|
240
|
+
const t = traces.find((x) => x.name === "valid-resume-continues-seq");
|
|
241
|
+
const cp = new ReferenceCp({ tenantId: t.session.tenantId, agentId: t.session.agentId });
|
|
242
|
+
const env = inMemory(cp, { tenantId: t.session.tenantId, agentId: t.session.agentId });
|
|
243
|
+
// first life: 5 events delivered
|
|
244
|
+
env.session.attach(env.transport);
|
|
245
|
+
await waitFor(() => env.session.state === "ready");
|
|
246
|
+
for (let i = 0; i < 5; i++) await env.session.send("job.event", { jobId: "c3d4e5f6-a7b8-4c9d-8e0f-2a3b4c5d6e7f", state: "running", iteration: i });
|
|
247
|
+
// the CP also sent 4 frames
|
|
248
|
+
env.session.setHandler("job.cancel", () => ({ accept: true }));
|
|
249
|
+
for (let i = 0; i < 4; i++) cp.sendData("job.cancel", { jobId: "c3d4e5f6-a7b8-4c9d-8e0f-2a3b4c5d6e7f", reason: "user" });
|
|
250
|
+
await waitFor(() => env.session.lastRecvSeq === 4);
|
|
251
|
+
// second life: same session object (the LA process kept it), new connection
|
|
252
|
+
const t2 = { send: (x) => { env.wire.toCp.push(x); cp.feed(x); }, close() {}, bufferedAmount: () => 0 };
|
|
253
|
+
env.session.detach(env.transport);
|
|
254
|
+
cp.attach((x) => queueMicrotask(() => env.session.handleText(x, t2)), () => {});
|
|
255
|
+
env.session.attach(t2);
|
|
256
|
+
await waitFor(() => env.session.state === "ready");
|
|
257
|
+
assert.deepEqual(cp.registers.at(-1).resume, { sessionId: cp.sessionId, lastRecvSeq: 4 });
|
|
258
|
+
await env.session.send("job.event", { jobId: "c3d4e5f6-a7b8-4c9d-8e0f-2a3b4c5d6e7f", state: "running", iteration: 5 });
|
|
259
|
+
assert.equal(cp.received.at(-1).seq, 6, "continues at peerLastSeq + 1");
|
|
260
|
+
assert.deepEqual(cp.errorsSent, []);
|
|
261
|
+
env.session.close();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("V2Session is exported and constructible without side effects (no timers left running)", () => {
|
|
265
|
+
const cp = new ReferenceCp();
|
|
266
|
+
const s = new V2Session({ tenantId: cp.tenantId, agentId: cp.agentId, signing: signingFor({ keyId: cp.keyId, publicKeyRaw: cp.publicKeyRaw }), laVersion: "1.3.0" });
|
|
267
|
+
assert.equal(s.state, "idle");
|
|
268
|
+
s.close();
|
|
269
|
+
assert.equal(uuid().length, 36);
|
|
270
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// F3-05b end to end: the real agent process (src/server.js) against the reference CP.
|
|
2
|
+
// - flag unset / false: the v1 client, nothing of v2 on the wire (characterization; the frames are the v1 ones)
|
|
3
|
+
// - flag true: v2 handshake with a signed ready, v1 commands still answered on the same connection
|
|
4
|
+
// - flag true against a gateway without v2: falls back to the v1 register on the same connection
|
|
5
|
+
// - flag true without an agent id: stays v1
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { ReferenceCpServer, TENANT, AGENT, waitFor } from "./helpers/reference-cp.js";
|
|
9
|
+
import { runAgent } from "./helpers/spawn-agent.js";
|
|
10
|
+
|
|
11
|
+
const baseEnv = (srv) => ({
|
|
12
|
+
TENANT_ID: TENANT,
|
|
13
|
+
LOCAL_AGENT_SIGNATURE_MODE: "enforce",
|
|
14
|
+
LOCAL_AGENT_SIGNING_PUBLIC_KEYS: `${srv.keyId}=${srv.publicKeyRaw}`,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
for (const flag of [undefined, "false", "off", "0", "bogus"]) {
|
|
18
|
+
test(`flag ${flag === undefined ? "unset" : JSON.stringify(flag)}: the v1 client, exactly: legacy register, no v2 frame, v1 command answered`, async () => {
|
|
19
|
+
const srv = await new ReferenceCpServer({}).start();
|
|
20
|
+
try {
|
|
21
|
+
await runAgent({
|
|
22
|
+
// everything v2 needs IS configured (UUID tenant, agent id, trusted key): only the flag keeps it off
|
|
23
|
+
srv, cfg: { authorizationId: AGENT, tenantId: TENANT }, env: { ...baseEnv(srv), LOCAL_AGENT_PROTOCOL_V2: flag },
|
|
24
|
+
until: async () => {
|
|
25
|
+
if (srv.ws && srv.legacy.some((f) => f.type === "register") && !srv.sent) {
|
|
26
|
+
srv.sent = true;
|
|
27
|
+
srv.ws.send(srv.cp.v1Command("health", {}));
|
|
28
|
+
}
|
|
29
|
+
return srv.legacy.some((f) => f.command === "health");
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
const reg = srv.legacy.find((f) => f.type === "register");
|
|
33
|
+
assert.deepEqual(Object.keys(reg).sort(), ["port", "tenantId", "type", "workspacePath"]);
|
|
34
|
+
assert.equal(reg.tenantId, TENANT);
|
|
35
|
+
const health = srv.legacy.find((f) => f.command === "health");
|
|
36
|
+
assert.equal(health.status, "ok");
|
|
37
|
+
assert.equal(srv.cp.rawIn.length, 0, "no v2 frame was ever sent");
|
|
38
|
+
assert.equal(srv.cp.registers.length, 0);
|
|
39
|
+
assert.equal(srv.textsFromLa.some((t) => t.includes("schemaVersion")), false);
|
|
40
|
+
} finally { await srv.stop(); }
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
test("flag true: v2 handshake (register, hello, signed ready), no v1 register, and a v1 command is still answered on the same connection", async () => {
|
|
45
|
+
const srv = await new ReferenceCpServer({ requireEnforce: true }).start();
|
|
46
|
+
try {
|
|
47
|
+
await runAgent({
|
|
48
|
+
srv, cfg: { authorizationId: AGENT, tenantId: TENANT },
|
|
49
|
+
env: { ...baseEnv(srv), LOCAL_AGENT_PROTOCOL_V2: "true" },
|
|
50
|
+
until: async () => {
|
|
51
|
+
if (srv.cp.state === "ready" && !srv.sent) { srv.sent = true; srv.ws.send(srv.cp.v1Command("health", {})); }
|
|
52
|
+
return srv.legacy.some((f) => f.command === "health");
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
assert.equal(srv.connections, 1);
|
|
56
|
+
const reg = srv.cp.registers[0];
|
|
57
|
+
assert.equal(reg.protocol, 2);
|
|
58
|
+
assert.equal(reg.signatureMode, "enforce");
|
|
59
|
+
assert.match(reg.laVersion, /^\d+\.\d+\.\d+$/);
|
|
60
|
+
assert.deepEqual(reg.capabilities, ["chunk.base64", "protocol.v2", "v1.commands"]);
|
|
61
|
+
assert.ok(reg.workspaces.every((w) => /^[0-9a-f]{64}$/.test(w.pathSha256)), "only hashes of paths");
|
|
62
|
+
assert.equal(JSON.stringify(srv.cp.rawIn).includes("/tmp") || JSON.stringify(srv.cp.rawIn).includes("v2e2e-ws"), false, "no workspace path on the wire");
|
|
63
|
+
assert.equal(srv.cp.hellos[0].signatureMode, "enforce");
|
|
64
|
+
assert.equal(srv.legacy.some((f) => f.type === "register"), false, "no v1 register: v2 owns the registration");
|
|
65
|
+
assert.deepEqual(srv.cp.errorsSent, []);
|
|
66
|
+
assert.equal(srv.legacy.find((f) => f.command === "health").status, "ok", "v1 command on a v2 connection");
|
|
67
|
+
} finally { await srv.stop(); }
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("flag true, the CP sends a signed v2 job.cancel: acked `rejected protocol_unsupported` (no handler yet), connection stays up", async () => {
|
|
71
|
+
const srv = await new ReferenceCpServer({ requireEnforce: true }).start();
|
|
72
|
+
try {
|
|
73
|
+
await runAgent({
|
|
74
|
+
srv, cfg: { authorizationId: AGENT, tenantId: TENANT },
|
|
75
|
+
env: { ...baseEnv(srv), LOCAL_AGENT_PROTOCOL_V2: "true" },
|
|
76
|
+
until: async () => {
|
|
77
|
+
if (srv.cp.state === "ready" && !srv.sent) { srv.sent = true; srv.cp.sendData("job.cancel", { jobId: "c3d4e5f6-a7b8-4c9d-8e0f-2a3b4c5d6e7f", reason: "user" }); }
|
|
78
|
+
return srv.cp.acksFromLa.length > 0;
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
assert.deepEqual(srv.cp.acksFromLa, [{ ackSeq: 1, status: "rejected", errorCode: "protocol_unsupported" }]);
|
|
82
|
+
assert.deepEqual(srv.cp.errorsSent, []);
|
|
83
|
+
} finally { await srv.stop(); }
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("flag true against a gateway that does not speak v2: after the handshake timeout the v1 register goes out on the same connection", async () => {
|
|
87
|
+
const srv = await new ReferenceCpServer({ v2Disabled: true }).start();
|
|
88
|
+
try {
|
|
89
|
+
await runAgent({
|
|
90
|
+
srv, cfg: { authorizationId: AGENT, tenantId: TENANT },
|
|
91
|
+
env: { ...baseEnv(srv), LOCAL_AGENT_PROTOCOL_V2: "true", LOCAL_AGENT_V2_HANDSHAKE_MS: "300" },
|
|
92
|
+
until: async () => srv.legacy.some((f) => f.type === "register"),
|
|
93
|
+
});
|
|
94
|
+
const reg = srv.legacy.find((f) => f.type === "register");
|
|
95
|
+
assert.deepEqual(Object.keys(reg).sort(), ["port", "tenantId", "type", "workspacePath"]);
|
|
96
|
+
assert.equal(srv.connections, 1, "same connection");
|
|
97
|
+
} finally { await srv.stop(); }
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("flag true without an agent id (no authorizationId): v1 only, nothing of v2 on the wire", async () => {
|
|
101
|
+
const srv = await new ReferenceCpServer({}).start();
|
|
102
|
+
try {
|
|
103
|
+
await runAgent({
|
|
104
|
+
srv, env: { ...baseEnv(srv), LOCAL_AGENT_PROTOCOL_V2: "true" },
|
|
105
|
+
until: async () => srv.legacy.some((f) => f.type === "register"),
|
|
106
|
+
});
|
|
107
|
+
assert.equal(srv.cp.rawIn.length, 0);
|
|
108
|
+
assert.ok(srv.legacy.some((f) => f.type === "register"));
|
|
109
|
+
} finally { await srv.stop(); }
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("flag true with a tenant id that is not a UUID: v1 only", async () => {
|
|
113
|
+
const srv = await new ReferenceCpServer({}).start();
|
|
114
|
+
try {
|
|
115
|
+
await runAgent({
|
|
116
|
+
srv, cfg: { authorizationId: AGENT },
|
|
117
|
+
env: { ...baseEnv(srv), TENANT_ID: "tenant-test", LOCAL_AGENT_SIGNATURE_MODE: "off", LOCAL_AGENT_PROTOCOL_V2: "true" },
|
|
118
|
+
until: async () => srv.legacy.some((f) => f.type === "register"),
|
|
119
|
+
});
|
|
120
|
+
assert.equal(srv.cp.rawIn.length, 0);
|
|
121
|
+
} finally { await srv.stop(); }
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("sanity: waitFor is exported", () => assert.equal(typeof waitFor, "function"));
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Contract tests for the metadata-only outbound allowlist (T4-10).
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { validateOutbound, projectResponse, OUTBOUND_SCHEMAS } from '../../src/ws-channel/allowlist.js';
|
|
5
|
+
|
|
6
|
+
const H = 'a'.repeat(64);
|
|
7
|
+
|
|
8
|
+
test('contract: every allowed message type validates with a golden example', () => {
|
|
9
|
+
const golden = [
|
|
10
|
+
{ type: 'register', tenantId: 'tenant-1', port: 5055, workspaceHash: H },
|
|
11
|
+
{ type: 'hello', agentVersion: '1.1.0', protocol: 2, signatureMode: 'enforce', metadataOnly: 'enforce' },
|
|
12
|
+
{ type: 'status', state: 'idle', openWorkspaces: 2, queueDepth: 0, uptimeSeconds: 120 },
|
|
13
|
+
{ type: 'cost', requestId: 'req-1', model: 'claude-sonnet-5-5', tokensIn: 10, tokensOut: 5, costMicros: 1200 },
|
|
14
|
+
{ requestId: 'r1', command: 'health', ok: true, status: 'ok', openWorkspaces: 1 },
|
|
15
|
+
{ requestId: 'r2', command: 'workspace.open', ok: false, errorCode: 'command_error' },
|
|
16
|
+
];
|
|
17
|
+
for (const m of golden) assert.deepEqual(validateOutbound(m).ok, true, JSON.stringify(m));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('contract: schema set is closed (changing it is a deliberate, reviewed edit)', () => {
|
|
21
|
+
assert.deepEqual(Object.keys(OUTBOUND_SCHEMAS).sort(), ['cost', 'hello', 'register', 'response', 'status']);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('rejects unknown types, unknown fields and non-objects', () => {
|
|
25
|
+
assert.equal(validateOutbound({ type: 'file-content', content: 'x' }).reason, 'unknown-type');
|
|
26
|
+
assert.equal(validateOutbound({ type: 'status', state: 'idle', content: 'x' }).reason, 'unknown-field');
|
|
27
|
+
assert.equal(validateOutbound('ping').reason, 'not-an-object');
|
|
28
|
+
assert.equal(validateOutbound([]).reason, 'not-an-object');
|
|
29
|
+
assert.equal(validateOutbound({ type: '__proto__' }).reason, 'unknown-type');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('rejects free text: code, diffs, paths, log lines and chat do not fit any string field', () => {
|
|
33
|
+
const leaks = ['const x = require("fs");', '--- a/x\n+++ b/x', '/Users/me/project/src/A.java', 'ERROR something failed', 'hello how are you', ''];
|
|
34
|
+
for (const v of leaks) {
|
|
35
|
+
assert.equal(validateOutbound({ type: 'register', tenantId: v, port: 1 }).ok, false, JSON.stringify(v));
|
|
36
|
+
assert.equal(validateOutbound({ type: 'cost', requestId: 'r', model: v }).ok, false);
|
|
37
|
+
assert.equal(validateOutbound({ requestId: 'r', command: v }).ok, false);
|
|
38
|
+
}
|
|
39
|
+
assert.equal(validateOutbound({ type: 'register', tenantId: 't', port: 1, workspaceHash: '/etc/passwd' }).field, 'workspaceHash');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('rejects nested values, wrong numeric ranges and missing required fields', () => {
|
|
43
|
+
assert.equal(validateOutbound({ type: 'status', state: { a: 1 } }).ok, false);
|
|
44
|
+
assert.equal(validateOutbound({ type: 'status', openWorkspaces: -1 }).ok, false);
|
|
45
|
+
assert.equal(validateOutbound({ type: 'status', openWorkspaces: 1.5 }).ok, false);
|
|
46
|
+
assert.equal(validateOutbound({ type: 'register', port: 70000, tenantId: 't' }).ok, false);
|
|
47
|
+
assert.equal(validateOutbound({ type: 'register', port: 5055 }).reason, 'missing-field');
|
|
48
|
+
assert.equal(validateOutbound({ type: 'status', state: 'unknown-state' }).ok, false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('validation reports field names and codes, never the offending value', () => {
|
|
52
|
+
const secret = 'AKIA_SECRET_VALUE_123 /home/x';
|
|
53
|
+
const r = validateOutbound({ type: 'register', tenantId: secret, port: 1 });
|
|
54
|
+
assert.equal(JSON.stringify(r).includes('SECRET'), false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('projectResponse strips paths, turns free text into errorCode, rejects content fields', () => {
|
|
58
|
+
assert.deepEqual(projectResponse('r', 'health', { status: 'ok', workspace: '/home/u/p', openWorkspaces: 1 }).message,
|
|
59
|
+
{ requestId: 'r', command: 'health', status: 'ok', openWorkspaces: 1 });
|
|
60
|
+
assert.deepEqual(projectResponse('r', 'workspace.open', { ok: true, root: '/home/u/p' }).message,
|
|
61
|
+
{ requestId: 'r', command: 'workspace.open', ok: true });
|
|
62
|
+
assert.deepEqual(projectResponse('r', 'x', { error: 'ENOENT /home/u/secret.txt' }).message,
|
|
63
|
+
{ requestId: 'r', command: 'x', ok: false, errorCode: 'command_error' });
|
|
64
|
+
const c = projectResponse('r', 'workspace.file-content', { content: 'code', found: true });
|
|
65
|
+
assert.equal(c.ok, false);
|
|
66
|
+
assert.equal(c.field, 'content');
|
|
67
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createWsMetadataGuard } from '../../src/ws-channel/metadata-guard.js';
|
|
4
|
+
import { resolveWsMetadataConfig } from '../../src/ws-channel/config.js';
|
|
5
|
+
import { createTransitGuard } from '../../src/ws-channel/transit-guard.js';
|
|
6
|
+
|
|
7
|
+
const capture = () => { const lines = []; return { lines, warn: (m) => lines.push(String(m)), log: (m) => lines.push(String(m)) }; };
|
|
8
|
+
const enforce = { mode: 'enforce' };
|
|
9
|
+
|
|
10
|
+
test('flag config: default off, enforce, and unrecognised values fail closed', () => {
|
|
11
|
+
const warn = () => {};
|
|
12
|
+
assert.equal(resolveWsMetadataConfig({}, warn).mode, 'off');
|
|
13
|
+
assert.equal(resolveWsMetadataConfig({ LOCAL_AGENT_WS_METADATA_ONLY: '' }, warn).mode, 'off');
|
|
14
|
+
assert.equal(resolveWsMetadataConfig({ LOCAL_AGENT_WS_METADATA_ONLY: 'off' }, warn).mode, 'off');
|
|
15
|
+
assert.equal(resolveWsMetadataConfig({ LOCAL_AGENT_WS_METADATA_ONLY: ' ENFORCE ' }, warn).mode, 'enforce');
|
|
16
|
+
assert.equal(resolveWsMetadataConfig({ LOCAL_AGENT_WS_METADATA_ONLY: 'enfroce' }, warn).mode, 'enforce');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('flag off: no guard is created and no socket is opened', () => {
|
|
20
|
+
let socketsOpened = 0;
|
|
21
|
+
const openSocket = () => { socketsOpened++; };
|
|
22
|
+
const guard = createWsMetadataGuard({ config: resolveWsMetadataConfig({}) });
|
|
23
|
+
assert.equal(guard, null);
|
|
24
|
+
if (guard) openSocket(); // the conditional start point: only reached when the guard exists
|
|
25
|
+
assert.equal(socketsOpened, 0);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('legacy content commands are refused before execution, with a metadata-only error', () => {
|
|
29
|
+
const log = capture();
|
|
30
|
+
const g = createWsMetadataGuard({ config: enforce, logger: log });
|
|
31
|
+
for (const cmd of ['workspace.file-content', 'workspace.diff', 'workspace.safe-patch', 'mcp.read-file', 'mcp.execute-command', 'mcp.apply-patch']) {
|
|
32
|
+
const a = g.admitCommand('req-1', cmd);
|
|
33
|
+
assert.equal(a.ok, false, cmd);
|
|
34
|
+
assert.deepEqual(JSON.parse(a.errorFrame), { requestId: 'req-1', command: cmd, ok: false, errorCode: 'blocked_metadata_only' });
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('health reply carries only metadata (workspace path dropped)', () => {
|
|
39
|
+
const g = createWsMetadataGuard({ config: enforce, logger: capture() });
|
|
40
|
+
assert.equal(g.admitCommand('r1', 'health').ok, true);
|
|
41
|
+
const out = JSON.parse(g.frameResponse('r1', 'health', { status: 'ok', workspace: '/Users/x/client-repo', openWorkspaces: 2 }));
|
|
42
|
+
assert.deepEqual(out, { requestId: 'r1', command: 'health', status: 'ok', openWorkspaces: 2 });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('content in a reply is blocked, replaced by an error, and never logged', () => {
|
|
46
|
+
const log = capture();
|
|
47
|
+
const g = createWsMetadataGuard({ config: enforce, logger: log });
|
|
48
|
+
g.admitCommand('r2', 'health');
|
|
49
|
+
const secret = 'SUPER_SECRET_SOURCE_CODE_LINE';
|
|
50
|
+
const out = JSON.parse(g.frameResponse('r2', 'health', { status: 'ok', diff: secret }));
|
|
51
|
+
assert.deepEqual(out, { requestId: 'r2', command: 'health', ok: false, errorCode: 'blocked_metadata_only' });
|
|
52
|
+
assert.ok(log.lines.some((l) => l.includes('BLOCKED') && l.includes('unknown-field') && l.includes('diff')));
|
|
53
|
+
assert.equal(log.lines.join('\n').includes(secret), false, 'payload must never be logged');
|
|
54
|
+
assert.equal(JSON.stringify(out).includes(secret), false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('free-text error messages become an errorCode and are not sent', () => {
|
|
58
|
+
const g = createWsMetadataGuard({ config: enforce, logger: capture() });
|
|
59
|
+
g.admitCommand('r3', 'workspace.open');
|
|
60
|
+
const out = JSON.parse(g.frameResponse('r3', 'workspace.open', { error: 'ENOENT: /home/u/secret/file.js' }));
|
|
61
|
+
assert.deepEqual(out, { requestId: 'r3', command: 'workspace.open', ok: false, errorCode: 'command_error' });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('spontaneous messages are validated; violations return null (dropped, not queued)', () => {
|
|
65
|
+
const log = capture();
|
|
66
|
+
const g = createWsMetadataGuard({ config: enforce, logger: log });
|
|
67
|
+
assert.ok(g.frameMessage({ type: 'status', state: 'idle', openWorkspaces: 1 }));
|
|
68
|
+
assert.equal(g.frameMessage({ type: 'chat', text: 'hi' }), null);
|
|
69
|
+
assert.equal(g.frameMessage({ type: 'register', tenantId: 't', port: 1, workspacePath: '/x' }), null);
|
|
70
|
+
assert.equal(g.stats.blocked, 2);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('D17 TTL: reply to an unknown, expired or already-answered request is dropped', () => {
|
|
74
|
+
let t = 1_000;
|
|
75
|
+
const transit = createTransitGuard({ now: () => t, limits: { requestTtlMs: 1000 } });
|
|
76
|
+
const g = createWsMetadataGuard({ config: enforce, transit, logger: capture() });
|
|
77
|
+
assert.equal(g.frameResponse('never', 'health', { status: 'ok' }), null);
|
|
78
|
+
g.admitCommand('a', 'health');
|
|
79
|
+
assert.ok(g.frameResponse('a', 'health', { status: 'ok' }));
|
|
80
|
+
assert.equal(g.frameResponse('a', 'health', { status: 'ok' }), null, 'single use');
|
|
81
|
+
g.admitCommand('b', 'health');
|
|
82
|
+
t += 1500;
|
|
83
|
+
assert.equal(g.frameResponse('b', 'health', { status: 'ok' }), null, 'expired');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('D17 limits: message size, rate and in-flight bound', () => {
|
|
87
|
+
const transit = createTransitGuard({ limits: { rateMax: 2, maxInFlight: 2, maxMessageBytes: 60 } });
|
|
88
|
+
const g = createWsMetadataGuard({ config: enforce, transit, logger: capture() });
|
|
89
|
+
assert.equal(g.frameMessage({ type: 'cost', requestId: 'r', model: 'm'.repeat(100) }), null, 'oversize');
|
|
90
|
+
assert.ok(g.frameMessage({ type: 'status', state: 'idle' }));
|
|
91
|
+
assert.ok(g.frameMessage({ type: 'status', state: 'busy' }));
|
|
92
|
+
assert.equal(g.frameMessage({ type: 'status', state: 'idle' }), null, 'rate limited');
|
|
93
|
+
assert.equal(g.admitCommand('x1', 'health').ok, true);
|
|
94
|
+
assert.equal(g.admitCommand('x2', 'health').ok, true);
|
|
95
|
+
assert.equal(g.admitCommand('x3', 'health').ok, false, 'in-flight cap');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('D17: the guard persists nothing (no fs/network usage in the module)', async () => {
|
|
99
|
+
const { readFile } = await import('node:fs/promises');
|
|
100
|
+
for (const f of ['metadata-guard', 'transit-guard', 'allowlist']) {
|
|
101
|
+
const src = await readFile(new URL(`../../src/ws-channel/${f}.js`, import.meta.url), 'utf8');
|
|
102
|
+
assert.equal(/node:fs|from "fs"|appendFile|writeFile|node:net|node:http/.test(src), false, f);
|
|
103
|
+
}
|
|
104
|
+
});
|