deepdebug-local-agent 1.3.0 → 1.5.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 +6 -0
- package/docs/la-protocol-v2.md +102 -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-handlers/audit.js +36 -0
- package/src/la-protocol-v2/content-handlers/config.js +48 -0
- package/src/la-protocol-v2/content-handlers/egress.js +65 -0
- package/src/la-protocol-v2/content-handlers/git-diff.js +92 -0
- package/src/la-protocol-v2/content-handlers/handler.js +170 -0
- package/src/la-protocol-v2/content-handlers/index.js +6 -0
- package/src/la-protocol-v2/content-handlers/install.js +50 -0
- package/src/la-protocol-v2/content-handlers/path-guard.js +142 -0
- package/src/la-protocol-v2/content-handlers/ref-registry.js +87 -0
- package/src/la-protocol-v2/content.js +63 -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 +81 -0
- package/src/la-protocol-v2/session.js +650 -0
- package/src/la-protocol-v2/strict-json.js +47 -0
- package/src/server.js +25 -4
- 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/content-handlers.test.js +633 -0
- package/test/la-protocol-v2/content-path-guard.test.js +214 -0
- package/test/la-protocol-v2/content-wiring.test.js +225 -0
- package/test/la-protocol-v2/contract.test.js +131 -0
- package/test/la-protocol-v2/helpers/content-rig.js +94 -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 +36 -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/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/LaV2NodeContentInteropTest.java +248 -0
- package/tools/interop/LaV2NodeInteropTest.java +266 -0
- package/tools/interop/README.md +38 -0
- package/tools/interop/la-interop-client.mjs +97 -0
- package/tools/interop/la-interop-content-client.mjs +91 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// F3-07: the Local Agent is the authority on paths. normalizeRel, the deny list, symlinks, the opaque ids.
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { normalizeRel, isSensitivePath, resolveInside, readRegular, DEFAULT_DENY_GLOBS } from "../../src/la-protocol-v2/content-handlers/path-guard.js";
|
|
7
|
+
import { ContentRefRegistry, REF_PATTERN } from "../../src/la-protocol-v2/content-handlers/ref-registry.js";
|
|
8
|
+
import { tmpDir, write } from "./helpers/content-rig.js";
|
|
9
|
+
|
|
10
|
+
// ------------------------------------------------------------------------------------------ normalizeRel
|
|
11
|
+
|
|
12
|
+
test("normalizeRel: plain relative paths pass, normalized to posix", () => {
|
|
13
|
+
assert.equal(normalizeRel("src/a.js"), "src/a.js");
|
|
14
|
+
assert.equal(normalizeRel("./src//a.js"), "src/a.js");
|
|
15
|
+
assert.equal(normalizeRel("src\\sub\\a.js"), "src/sub/a.js");
|
|
16
|
+
assert.equal(normalizeRel("ficheiro-ção-日本.txt"), "ficheiro-ção-日本.txt");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("normalizeRel: traversal, absolutes, NUL and control characters, empty and huge are refused", () => {
|
|
20
|
+
for (const bad of ["../x", "a/../../x", "a/..", "..", "..\\x", "a\\..\\..\\x", "/etc/passwd", "\\\\server\\share", "C:\\Windows\\win.ini", "c:/x",
|
|
21
|
+
"a\u0000b", "a\nb", "a\u001fb", "a\u007fb", "", ".", "./", "//", "x".repeat(1025), null, undefined, 42, {}, ["a"]]) {
|
|
22
|
+
assert.equal(normalizeRel(bad), null, JSON.stringify(bad)?.slice(0, 40));
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("normalizeRel: a literal %2e%2e is just a file name (nothing is ever URL-decoded)", () => {
|
|
27
|
+
assert.equal(normalizeRel("%2e%2e/etc/passwd"), "%2e%2e/etc/passwd");
|
|
28
|
+
assert.equal(normalizeRel("a/%2e%2e"), "a/%2e%2e");
|
|
29
|
+
assert.equal(normalizeRel("..%2f..%2fx"), "..%2f..%2fx");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// ------------------------------------------------------------------------------------------ deny list
|
|
33
|
+
|
|
34
|
+
test("deny list: secrets by name, keys, credentials, VCS internals (case-insensitive, any depth)", () => {
|
|
35
|
+
for (const p of [".env", ".env.local", ".env.production", "app/.ENV", "config/prod.env", "certs/server.pem", "k/private.KEY", "a/b/id_rsa", "id_ed25519.pub",
|
|
36
|
+
"keystore.jks", ".npmrc", "web/.npmrc", ".netrc", ".git/config", "sub/.git/config", ".git/hooks/pre-commit", ".ssh/id_rsa", "home/.ssh/known_hosts",
|
|
37
|
+
".aws/credentials", ".docker/config.json", "terraform.tfstate", "infra/prod.tfvars", "service-account-prod.json", "secrets.yaml", ".htpasswd",
|
|
38
|
+
".env.", ".env ", "x/.ENV.", ".config/gcloud/credentials.db"]) {
|
|
39
|
+
assert.equal(isSensitivePath(p), true, p);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("deny list: ordinary files are not caught (no false positives on look-alikes)", () => {
|
|
44
|
+
for (const p of ["src/environment.js", "README.md", "src/keyboard.js", "src/monkey.js", "docs/pem-format.md", "environment/readme.txt", "src/secretary.js", "gitignore.txt", "src/.github/workflows/ci.yml", "src/auth/credentials.js"]) {
|
|
45
|
+
assert.equal(isSensitivePath(p), false, p);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("deny list: LOCAL_AGENT_CONTENT_DENY_GLOBS adds to the built-in list, never replaces it", () => {
|
|
50
|
+
assert.equal(isSensitivePath("vault/data.txt", ["vault/"]), true);
|
|
51
|
+
assert.equal(isSensitivePath("docs/internal-*.md".replace("*", "x"), ["internal-*.md"]), true);
|
|
52
|
+
assert.equal(isSensitivePath(".env", ["vault/"]), true);
|
|
53
|
+
assert.equal(isSensitivePath("src/a.js", ["vault/"]), false);
|
|
54
|
+
assert.ok(DEFAULT_DENY_GLOBS.includes(".env.*"));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// ------------------------------------------------------------------------------------------ resolveInside
|
|
58
|
+
|
|
59
|
+
function fixture() {
|
|
60
|
+
const base = tmpDir();
|
|
61
|
+
const root = path.join(base, "ws"); fs.mkdirSync(root);
|
|
62
|
+
const outside = path.join(base, "outside"); fs.mkdirSync(outside);
|
|
63
|
+
write(root, "src/a.txt", "hello");
|
|
64
|
+
write(root, "src/ü ñ 日本.txt", "unicode");
|
|
65
|
+
write(root, ".env", "TOKEN=1");
|
|
66
|
+
write(root, ".git/config", "[remote]\nurl=https://u:p@h/r");
|
|
67
|
+
write(outside, "secret.txt", "TOP SECRET OUTSIDE");
|
|
68
|
+
write(outside, "inner/more.txt", "outside dir file");
|
|
69
|
+
return { base, root, outside, done: () => fs.rmSync(base, { recursive: true, force: true }) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
test("resolveInside: a regular file inside the root resolves; unicode names work", async () => {
|
|
73
|
+
const f = fixture();
|
|
74
|
+
try {
|
|
75
|
+
const r = await resolveInside(f.root, "src/a.txt");
|
|
76
|
+
assert.equal(r.ok, true);
|
|
77
|
+
assert.equal(r.relReal, "src/a.txt");
|
|
78
|
+
assert.equal((await resolveInside(f.root, "src/ü ñ 日本.txt")).ok, true);
|
|
79
|
+
} finally { f.done(); }
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("resolveInside: symlinks that leave the workspace are forbidden (file, directory, chained, relative)", async () => {
|
|
83
|
+
const f = fixture();
|
|
84
|
+
try {
|
|
85
|
+
fs.symlinkSync(path.join(f.outside, "secret.txt"), path.join(f.root, "link-file"));
|
|
86
|
+
fs.symlinkSync(f.outside, path.join(f.root, "link-dir"));
|
|
87
|
+
fs.symlinkSync("../outside/secret.txt", path.join(f.root, "link-rel"));
|
|
88
|
+
fs.symlinkSync("link-file", path.join(f.root, "link-chain"));
|
|
89
|
+
fs.symlinkSync("/etc/passwd", path.join(f.root, "link-etc"));
|
|
90
|
+
for (const rel of ["link-file", "link-dir/secret.txt", "link-dir/inner/more.txt", "link-rel", "link-chain", "link-etc"]) {
|
|
91
|
+
const r = await resolveInside(f.root, rel);
|
|
92
|
+
assert.deepEqual(r, { ok: false, status: "forbidden" }, rel);
|
|
93
|
+
}
|
|
94
|
+
} finally { f.done(); }
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("resolveInside: a link inside the root to a file inside the root is fine, but not when the real target is sensitive", async () => {
|
|
98
|
+
const f = fixture();
|
|
99
|
+
try {
|
|
100
|
+
fs.symlinkSync("a.txt", path.join(f.root, "src", "alias.txt"));
|
|
101
|
+
assert.equal((await resolveInside(f.root, "src/alias.txt")).ok, true);
|
|
102
|
+
fs.symlinkSync("../.env", path.join(f.root, "src", "innocent.txt"));
|
|
103
|
+
assert.deepEqual(await resolveInside(f.root, "src/innocent.txt"), { ok: false, status: "forbidden" });
|
|
104
|
+
fs.symlinkSync("../.git/config", path.join(f.root, "src", "gitcfg.txt"));
|
|
105
|
+
assert.deepEqual(await resolveInside(f.root, "src/gitcfg.txt"), { ok: false, status: "forbidden" });
|
|
106
|
+
} finally { f.done(); }
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("resolveInside: a root that is itself reached through a symlink works (/var -> /private/var style), and a sibling dir with the same prefix is NOT inside", async () => {
|
|
110
|
+
const f = fixture();
|
|
111
|
+
try {
|
|
112
|
+
const viaLink = path.join(f.base, "root-link");
|
|
113
|
+
fs.symlinkSync(f.root, viaLink);
|
|
114
|
+
assert.equal((await resolveInside(viaLink, "src/a.txt")).ok, true);
|
|
115
|
+
// /base/ws vs /base/ws-evil: startsWith without a separator would accept it
|
|
116
|
+
const evil = path.join(f.base, "ws-evil"); fs.mkdirSync(evil);
|
|
117
|
+
write(evil, "x.txt", "evil");
|
|
118
|
+
fs.symlinkSync(path.join(evil, "x.txt"), path.join(f.root, "to-evil"));
|
|
119
|
+
assert.deepEqual(await resolveInside(f.root, "to-evil"), { ok: false, status: "forbidden" });
|
|
120
|
+
} finally { f.done(); }
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("resolveInside: missing, directories and sensitive names; no message ever carries a path", async () => {
|
|
124
|
+
const f = fixture();
|
|
125
|
+
try {
|
|
126
|
+
assert.deepEqual(await resolveInside(f.root, "src/missing.txt"), { ok: false, status: "not_found" });
|
|
127
|
+
assert.deepEqual(await resolveInside(f.root, "src"), { ok: false, status: "not_found" });
|
|
128
|
+
assert.deepEqual(await resolveInside(f.root, ".env"), { ok: false, status: "forbidden" });
|
|
129
|
+
assert.deepEqual(await resolveInside(f.root, ".git/config"), { ok: false, status: "forbidden" });
|
|
130
|
+
assert.deepEqual(await resolveInside(path.join(f.base, "nope"), "a"), { ok: false, status: "not_found" });
|
|
131
|
+
// a sensitive name is forbidden whether or not it exists: no oracle on what a workspace contains
|
|
132
|
+
assert.deepEqual(await resolveInside(f.root, ".env.production"), { ok: false, status: "forbidden" });
|
|
133
|
+
assert.deepEqual(await resolveInside(f.root, "deep/er/id_rsa"), { ok: false, status: "forbidden" });
|
|
134
|
+
assert.deepEqual(Object.keys(await resolveInside(f.root, ".env")).sort(), ["ok", "status"]);
|
|
135
|
+
} finally { f.done(); }
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("readRegular: size cap before reading, exact bytes, the file swapped for a link after the check is refused", async () => {
|
|
139
|
+
const f = fixture();
|
|
140
|
+
try {
|
|
141
|
+
write(f.root, "big.bin", Buffer.alloc(5000, 7));
|
|
142
|
+
const big = await resolveInside(f.root, "big.bin");
|
|
143
|
+
assert.deepEqual(await readRegular(big, 4999), { ok: false, status: "too_large" });
|
|
144
|
+
const ok = await readRegular(big, 5000);
|
|
145
|
+
assert.equal(ok.ok, true);
|
|
146
|
+
assert.equal(ok.buffer.length, 5000);
|
|
147
|
+
// TOCTOU on the last component: checked as a file, swapped for a link to a file outside before the open
|
|
148
|
+
const a = await resolveInside(f.root, "src/a.txt");
|
|
149
|
+
fs.rmSync(path.join(f.root, "src/a.txt"));
|
|
150
|
+
fs.symlinkSync(path.join(f.outside, "secret.txt"), path.join(f.root, "src/a.txt"));
|
|
151
|
+
const swapped = await readRegular(a, 1000);
|
|
152
|
+
assert.equal(swapped.ok, false);
|
|
153
|
+
assert.ok(swapped.status === "forbidden" || swapped.status === "not_found");
|
|
154
|
+
} finally { f.done(); }
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ------------------------------------------------------------------------------------------ ids
|
|
158
|
+
|
|
159
|
+
test("ids: 128 random bits, not derived from the path, a fresh id per mint", () => {
|
|
160
|
+
const reg = new ContentRefRegistry({ tenantId: "t1" });
|
|
161
|
+
const a = reg.mint({ workspaceId: "w", root: "/r", kind: "file", rel: "src/a.txt" });
|
|
162
|
+
const b = reg.mint({ workspaceId: "w", root: "/r", kind: "file", rel: "src/a.txt" });
|
|
163
|
+
assert.match(a.id, REF_PATTERN);
|
|
164
|
+
assert.equal(a.id.length, 4 + 22);
|
|
165
|
+
assert.notEqual(a.id, b.id);
|
|
166
|
+
assert.equal(a.id.includes("a.txt") || a.id.includes("src"), false);
|
|
167
|
+
const ids = new Set(Array.from({ length: 2000 }, () => reg.mint({ workspaceId: "w", root: "/r", kind: "file", rel: "x" }).id));
|
|
168
|
+
assert.equal(ids.size, 2000);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("ids: resolve only for the same tenant, workspace, root and kind; anything else is null (indistinguishable from unknown)", () => {
|
|
172
|
+
const reg = new ContentRefRegistry({ tenantId: "t1" });
|
|
173
|
+
const { id } = reg.mint({ workspaceId: "w1", root: "/r1", kind: "file", rel: "src/a.txt" });
|
|
174
|
+
assert.deepEqual(reg.resolve(id, { workspaceId: "w1", root: "/r1", kind: "file" }), { rel: "src/a.txt" });
|
|
175
|
+
assert.equal(reg.resolve(id, { workspaceId: "w2", root: "/r1", kind: "file" }), null, "other workspace");
|
|
176
|
+
assert.equal(reg.resolve(id, { workspaceId: "w1", root: "/r2", kind: "file" }), null, "same workspace id, re-opened on another root");
|
|
177
|
+
assert.equal(reg.resolve(id, { workspaceId: "w1", root: "/r1", kind: "diff" }), null, "other kind");
|
|
178
|
+
const other = new ContentRefRegistry({ tenantId: "t2" });
|
|
179
|
+
assert.equal(other.resolve(id, { workspaceId: "w1", root: "/r1", kind: "file" }), null, "other tenant's registry");
|
|
180
|
+
for (const junk of ["", "x", "cr1_", "cr1_short", `${id}0`, `${id.slice(0, -1)}!`, "../etc/passwd", "/etc/passwd", "src/a.txt", "%2e%2e", "a\u0000b", 42, null, undefined, {}]) {
|
|
181
|
+
assert.equal(reg.resolve(junk, { workspaceId: "w1", root: "/r1", kind: "file" }), null);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("ids: TTL (expired = gone for good), capacity (oldest first), revoke", () => {
|
|
186
|
+
let t = 1_000_000;
|
|
187
|
+
const reg = new ContentRefRegistry({ tenantId: "t1", ttlMs: 10_000, maxEntries: 3, now: () => t });
|
|
188
|
+
const q = { workspaceId: "w", root: "/r", kind: "file" };
|
|
189
|
+
const { id } = reg.mint({ ...q, rel: "a" });
|
|
190
|
+
t += 9_999;
|
|
191
|
+
assert.ok(reg.resolve(id, q));
|
|
192
|
+
t += 2;
|
|
193
|
+
assert.equal(reg.resolve(id, q), null);
|
|
194
|
+
assert.equal(reg.size, 0, "expired entry removed on access");
|
|
195
|
+
const ids = ["a", "b", "c", "d"].map((rel) => reg.mint({ ...q, rel }).id);
|
|
196
|
+
assert.equal(reg.size, 3);
|
|
197
|
+
assert.equal(reg.resolve(ids[0], q), null, "oldest evicted");
|
|
198
|
+
assert.ok(reg.resolve(ids[3], q));
|
|
199
|
+
assert.equal(reg.revoke(ids[3]), true);
|
|
200
|
+
assert.equal(reg.resolve(ids[3], q), null);
|
|
201
|
+
reg.mint({ ...q, rel: "e" });
|
|
202
|
+
assert.equal(reg.revokeWorkspace("w") >= 1, true);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("ids: nothing is minted for a bad path, a sensitive file or an unknown kind", () => {
|
|
206
|
+
const reg = new ContentRefRegistry({ tenantId: "t1", denyGlobs: ["vault/"] });
|
|
207
|
+
const q = { workspaceId: "w", root: "/r" };
|
|
208
|
+
for (const rel of ["../x", "/etc/passwd", "a\u0000b", "", ".env", "src/.env.local", "id_rsa", ".git/config", "vault/x.txt", null]) {
|
|
209
|
+
assert.equal(reg.mint({ ...q, kind: "file", rel }), null, String(rel));
|
|
210
|
+
}
|
|
211
|
+
assert.equal(reg.mint({ ...q, kind: "chat.session", rel: "a" }), null);
|
|
212
|
+
assert.equal(reg.mint({ ...q, kind: "diff", rel: null }) !== null, true, "the whole work tree diff has no path");
|
|
213
|
+
assert.equal(reg.mint({ workspaceId: "", root: "/r", kind: "file", rel: "a" }), null);
|
|
214
|
+
});
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// F3-07: the flag and the wiring. LOCAL_AGENT_CONTENT_HANDLERS off (default, unknown values) = content.request is still
|
|
2
|
+
// acked `rejected protocol_unsupported`; on = served. In process (buildV2Link) and end to end (the real src/server.js).
|
|
3
|
+
import { test } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { ReferenceCp, ReferenceCpServer, TENANT, AGENT, uuid, waitFor } from "./helpers/reference-cp.js";
|
|
8
|
+
import { signingFor } from "./helpers/la-rig.js";
|
|
9
|
+
import { runAgent } from "./helpers/spawn-agent.js";
|
|
10
|
+
import { write, git, tmpDir, ACTOR } from "./helpers/content-rig.js";
|
|
11
|
+
import { buildV2Link } from "../../src/la-protocol-v2/runtime.js";
|
|
12
|
+
import { resolveContentHandlersConfig } from "../../src/la-protocol-v2/content-handlers/config.js";
|
|
13
|
+
import { createAudit } from "../../src/la-protocol-v2/content-handlers/audit.js";
|
|
14
|
+
|
|
15
|
+
const GH = "ghp_" + "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8";
|
|
16
|
+
|
|
17
|
+
// ------------------------------------------------------------------------------------------ config
|
|
18
|
+
|
|
19
|
+
test("config: only true/1/yes/on enable it; unset, empty, false and unknown values keep it off (unknown warns)", () => {
|
|
20
|
+
for (const v of [undefined, "", "false", "0", "no", "off", " OFF ", "maybe", "2", "enabled", "tru"]) {
|
|
21
|
+
const warns = [];
|
|
22
|
+
assert.deepEqual(resolveContentHandlersConfig({ LOCAL_AGENT_CONTENT_HANDLERS: v }, (m) => warns.push(m)), { enabled: false }, String(v));
|
|
23
|
+
assert.equal(warns.length, ["maybe", "2", "enabled", "tru"].includes(v) ? 1 : 0, String(v));
|
|
24
|
+
}
|
|
25
|
+
for (const v of ["true", "TRUE", "1", "yes", "on", " On "]) assert.equal(resolveContentHandlersConfig({ LOCAL_AGENT_CONTENT_HANDLERS: v }, () => {}).enabled, true, v);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("config: every number is clamped to a sane range; a bad value falls back to the default, never to unlimited", () => {
|
|
29
|
+
const c = resolveContentHandlersConfig({ LOCAL_AGENT_CONTENT_HANDLERS: "true", LOCAL_AGENT_CONTENT_MAX_BYTES: "999999999", LOCAL_AGENT_CONTENT_MAX_CONCURRENT: "0",
|
|
30
|
+
LOCAL_AGENT_CONTENT_REF_TTL_MS: "-5", LOCAL_AGENT_CONTENT_TOTAL_MS: "abc", LOCAL_AGENT_CONTENT_MAX_REFS: "1" }, () => {});
|
|
31
|
+
assert.equal(c.maxBytes, 1024 * 1024);
|
|
32
|
+
assert.equal(c.maxConcurrent, 4);
|
|
33
|
+
assert.equal(c.refTtlMs, 15 * 60_000);
|
|
34
|
+
assert.equal(c.totalMs, 25_000);
|
|
35
|
+
assert.equal(c.maxRefs, 20_000);
|
|
36
|
+
const big = resolveContentHandlersConfig({ LOCAL_AGENT_CONTENT_HANDLERS: "true", LOCAL_AGENT_CONTENT_MAX_BYTES: String(2 * 1024 * 1024) }, () => {});
|
|
37
|
+
assert.equal(big.maxBytes, 2 * 1024 * 1024, "the contract's 2 MiB is the ceiling");
|
|
38
|
+
assert.deepEqual(resolveContentHandlersConfig({ LOCAL_AGENT_CONTENT_HANDLERS: "true", LOCAL_AGENT_CONTENT_DENY_GLOBS: " a/ , ,*.x" }, () => {}).extraDenyGlobs, ["a/", "*.x"]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// ------------------------------------------------------------------------------------------ in process (runtime.js)
|
|
42
|
+
|
|
43
|
+
async function linkFor(cp, { env, root, tenantWs } = {}) {
|
|
44
|
+
const logs = [];
|
|
45
|
+
const log = { info: (m) => logs.push(m), log: (m) => logs.push(m), warn: (m) => logs.push(m) };
|
|
46
|
+
const wsManager = { list: () => [{ id: "ws-1", root, tenantId: tenantWs ?? TENANT }] };
|
|
47
|
+
const link = await buildV2Link({ tenantId: TENANT, agentId: AGENT, signing: signingFor({ keyId: cp.keyId, publicKeyRaw: cp.publicKeyRaw }, "enforce"), wsManager,
|
|
48
|
+
getWorkspaceRoot: () => root, log, env });
|
|
49
|
+
const transport = { send: (t) => cp.feed(t), close() {}, bufferedAmount: () => 0 };
|
|
50
|
+
cp.attach((t) => queueMicrotask(() => link.session.handleText(t, transport)), () => {});
|
|
51
|
+
link.session.attach(transport);
|
|
52
|
+
await waitFor(() => link.session.state === "ready");
|
|
53
|
+
return { link, logs };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
test("flag off (unset or any non-true value): content.request is acked `rejected protocol_unsupported` and the register has no content capability", async () => {
|
|
57
|
+
for (const v of [undefined, "false", "maybe"]) {
|
|
58
|
+
const root = tmpDir(); write(root, "a.txt", "x");
|
|
59
|
+
const cp = new ReferenceCp();
|
|
60
|
+
const { link } = await linkFor(cp, { root, env: { LOCAL_AGENT_CONTENT_HANDLERS: v } });
|
|
61
|
+
assert.equal(link.content, undefined);
|
|
62
|
+
assert.deepEqual(cp.registers[0].capabilities, ["chunk.base64", "protocol.v2", "v1.commands"]);
|
|
63
|
+
cp.sendData("content.request", { kind: "file", ref: "cr1_" + "A".repeat(22), actor: ACTOR }, { workspaceId: "ws-1", requestId: uuid() });
|
|
64
|
+
await waitFor(() => cp.acksFromLa.length > 0);
|
|
65
|
+
assert.deepEqual(cp.acksFromLa[0], { ackSeq: 1, status: "rejected", errorCode: "protocol_unsupported" });
|
|
66
|
+
assert.equal(cp.received.filter((f) => f.type === "content.response").length, 0);
|
|
67
|
+
link.close();
|
|
68
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("flag on: the handler is installed, the register announces content.file and content.diff, mintRef gives the hook for future inventories", async () => {
|
|
73
|
+
const root = tmpDir(); write(root, "src/a.txt", "hello"); write(root, ".env", "X=1");
|
|
74
|
+
const cp = new ReferenceCp();
|
|
75
|
+
const { link, logs } = await linkFor(cp, { root, env: { LOCAL_AGENT_CONTENT_HANDLERS: "true", LOCAL_AGENT_DLP_MODE: "enforce" } });
|
|
76
|
+
assert.deepEqual(cp.registers[0].capabilities, ["chunk.base64", "content.diff", "content.file", "protocol.v2", "v1.commands"]);
|
|
77
|
+
assert.equal(logs.some((l) => l.includes("not enforce")), false, "enforce: no warning");
|
|
78
|
+
const m = link.content.mintRef({ workspaceId: "ws-1", kind: "file", rel: "src/a.txt" });
|
|
79
|
+
assert.match(m.id, /^cr1_[A-Za-z0-9_-]{22}$/);
|
|
80
|
+
assert.equal(link.content.mintRef({ workspaceId: "ws-1", kind: "file", rel: ".env" }), null);
|
|
81
|
+
assert.equal(link.content.mintRef({ workspaceId: "ws-unknown", kind: "file", rel: "src/a.txt" }), null);
|
|
82
|
+
const requestId = uuid();
|
|
83
|
+
cp.sendData("content.request", { kind: "file", ref: m.id, actor: ACTOR }, { workspaceId: "ws-1", requestId });
|
|
84
|
+
await waitFor(() => cp.contentRequests.get(requestId)?.done);
|
|
85
|
+
assert.equal(Buffer.concat(cp.contentRequests.get(requestId).chunks).toString(), "hello");
|
|
86
|
+
link.close();
|
|
87
|
+
assert.equal(link.content.registry.size, 0, "closing the link forgets every id");
|
|
88
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("flag on with the DLP not in enforce: the agent says so at install time (the operator chose, but it is visible)", async () => {
|
|
92
|
+
const root = tmpDir();
|
|
93
|
+
const cp = new ReferenceCp();
|
|
94
|
+
const { link, logs } = await linkFor(cp, { root, env: { LOCAL_AGENT_CONTENT_HANDLERS: "true" } });
|
|
95
|
+
assert.ok(logs.some((l) => l.includes("LOCAL_AGENT_DLP_MODE is not enforce")));
|
|
96
|
+
link.close();
|
|
97
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("flag on: a workspace of another tenant (per the agent's own registry) is not served", async () => {
|
|
101
|
+
const root = tmpDir(); write(root, "a.txt", "x");
|
|
102
|
+
const cp = new ReferenceCp();
|
|
103
|
+
const { link } = await linkFor(cp, { root, tenantWs: "9d1f6a2c-0000-4000-8000-000000000000", env: { LOCAL_AGENT_CONTENT_HANDLERS: "true", LOCAL_AGENT_DLP_MODE: "enforce" } });
|
|
104
|
+
assert.equal(link.content.mintRef({ workspaceId: "ws-1", kind: "file", rel: "a.txt" }), null);
|
|
105
|
+
const requestId = uuid();
|
|
106
|
+
cp.sendData("content.request", { kind: "diff", ref: "worktree", actor: ACTOR }, { workspaceId: "ws-1", requestId });
|
|
107
|
+
await waitFor(() => cp.contentRequests.get(requestId)?.done);
|
|
108
|
+
assert.equal(cp.received.find((f) => f.requestId === requestId).payload.status, "not_found");
|
|
109
|
+
link.close();
|
|
110
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("closing the link while a stream is in flight cancels it (no work left behind)", async () => {
|
|
114
|
+
const root = tmpDir(); write(root, "big.txt", Buffer.alloc(900_000, 0x61));
|
|
115
|
+
const cp = new ReferenceCp();
|
|
116
|
+
const { link } = await linkFor(cp, { root, env: { LOCAL_AGENT_CONTENT_HANDLERS: "true", LOCAL_AGENT_DLP_MODE: "off" } });
|
|
117
|
+
const id = link.content.mintRef({ workspaceId: "ws-1", kind: "file", rel: "big.txt" }).id;
|
|
118
|
+
cp.silent = true;
|
|
119
|
+
cp.sendData("content.request", { kind: "file", ref: id, actor: ACTOR }, { workspaceId: "ws-1", requestId: uuid() });
|
|
120
|
+
await waitFor(() => link.content.handler.inflight === 1 && cp.received.filter((f) => f.type === "content.response").length === 8);
|
|
121
|
+
const handler = link.content.handler;
|
|
122
|
+
link.close();
|
|
123
|
+
await waitFor(() => handler.inflight === 0 && handler.reservedBytes === 0);
|
|
124
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ------------------------------------------------------------------------------------------ audit file
|
|
128
|
+
|
|
129
|
+
test("audit: optional JSONL file, mode 0600, rotates at the size cap; a broken path never breaks the data path", () => {
|
|
130
|
+
const dir = tmpDir(); const file = path.join(dir, "audit.jsonl");
|
|
131
|
+
const a = createAudit({ file, maxFileBytes: 400 });
|
|
132
|
+
for (let i = 0; i < 6; i++) a.record({ kind: "file", status: "ok", bytes: i });
|
|
133
|
+
assert.ok(fs.existsSync(file) && fs.existsSync(`${file}.1`), "rotated");
|
|
134
|
+
assert.equal(fs.statSync(file).mode & 0o777, 0o600);
|
|
135
|
+
for (const l of fs.readFileSync(file, "utf8").trim().split("\n")) assert.equal(JSON.parse(l).kind, "file");
|
|
136
|
+
assert.equal(a.tail(100).length, 6);
|
|
137
|
+
const broken = createAudit({ file: path.join(dir, "no/such/dir/audit.jsonl") });
|
|
138
|
+
assert.doesNotThrow(() => broken.record({ kind: "file" }));
|
|
139
|
+
assert.equal(broken.brokenFile, true);
|
|
140
|
+
assert.equal(broken.tail(1).length, 1, "the ring still has it");
|
|
141
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// ------------------------------------------------------------------------------------------ end to end: the real src/server.js
|
|
145
|
+
|
|
146
|
+
const baseEnv = (srv) => ({
|
|
147
|
+
TENANT_ID: TENANT,
|
|
148
|
+
LOCAL_AGENT_SIGNATURE_MODE: "enforce",
|
|
149
|
+
LOCAL_AGENT_SIGNING_PUBLIC_KEYS: `${srv.keyId}=${srv.publicKeyRaw}`,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
function prepareRepo(ws) {
|
|
153
|
+
write(ws, "src/app.js", "const a = 1;\n");
|
|
154
|
+
write(ws, ".env", "API=old\n");
|
|
155
|
+
git(ws, "init", "-q", "-b", "main"); git(ws, "add", "-f", "-A"); git(ws, "commit", "-q", "-m", "init");
|
|
156
|
+
write(ws, "src/app.js", `const a = 2;\nconst token = "${GH}";\n`);
|
|
157
|
+
write(ws, ".env", "API=NEW-SECRET-VALUE-0123456789\n");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function e2e(env, requests) {
|
|
161
|
+
const srv = await new ReferenceCpServer({ requireEnforce: true }).start();
|
|
162
|
+
const out = { ids: [] };
|
|
163
|
+
try {
|
|
164
|
+
await runAgent({
|
|
165
|
+
srv, cfg: { authorizationId: AGENT, tenantId: TENANT }, prepare: prepareRepo,
|
|
166
|
+
env: { ...baseEnv(srv), LOCAL_AGENT_PROTOCOL_V2: "true", ...env },
|
|
167
|
+
until: async () => {
|
|
168
|
+
if (srv.cp.state === "ready" && !srv.sent) {
|
|
169
|
+
srv.sent = true;
|
|
170
|
+
out.workspaceId = srv.cp.registers[0].workspaces[0].workspaceId;
|
|
171
|
+
for (const r of requests) {
|
|
172
|
+
const requestId = uuid(); out.ids.push(requestId);
|
|
173
|
+
srv.cp.sendData("content.request", { ...r, actor: ACTOR }, { workspaceId: out.workspaceId, requestId });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return srv.sent && out.ids.every((id) => srv.cp.contentRequests.get(id)?.done) || (srv.sent && srv.cp.acksFromLa.filter((a) => a.status === "rejected").length >= requests.length);
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
out.cp = srv.cp;
|
|
180
|
+
out.registers = srv.cp.registers;
|
|
181
|
+
out.errors = srv.cp.errorsSent;
|
|
182
|
+
} finally { await srv.stop(); }
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
test("e2e (real server.js): flags on + DLP enforce: the diff of the work tree comes back masked, without the .env, and file ids that were never minted are not_found", async () => {
|
|
187
|
+
const o = await e2e({ LOCAL_AGENT_CONTENT_HANDLERS: "true", LOCAL_AGENT_DLP_MODE: "enforce" },
|
|
188
|
+
[{ kind: "diff", ref: "worktree" }, { kind: "file", ref: "src/app.js" }, { kind: "file", ref: "../../etc/passwd" }, { kind: "report", ref: "x" }]);
|
|
189
|
+
assert.deepEqual(o.errors, []);
|
|
190
|
+
assert.ok(o.registers[0].capabilities.includes("content.diff"));
|
|
191
|
+
const answer = (i) => {
|
|
192
|
+
const id = o.ids[i]; const fr = o.cp.received.filter((f) => f.requestId === id);
|
|
193
|
+
return { status: fr[0].payload.status, errorCode: fr[0].payload.errorCode, text: Buffer.concat(o.cp.contentRequests.get(id).chunks).toString() };
|
|
194
|
+
};
|
|
195
|
+
const d = answer(0);
|
|
196
|
+
assert.equal(d.status, "ok");
|
|
197
|
+
assert.match(d.text, /src\/app\.js/);
|
|
198
|
+
assert.match(d.text, /REDACTED:github_token/);
|
|
199
|
+
assert.equal(d.text.includes(GH), false);
|
|
200
|
+
assert.equal(d.text.includes("NEW-SECRET-VALUE") || d.text.includes(".env"), false);
|
|
201
|
+
assert.equal(answer(1).status, "not_found", "a path is not an id");
|
|
202
|
+
assert.equal(answer(2).status, "not_found");
|
|
203
|
+
assert.deepEqual([answer(3).status, answer(3).errorCode], ["error", "protocol_unsupported"]);
|
|
204
|
+
const wire = JSON.stringify(o.cp.rawIn);
|
|
205
|
+
assert.equal(wire.includes("v2e2e-ws"), false, "no workspace path on the wire");
|
|
206
|
+
assert.equal(wire.includes(GH), false);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("e2e (real server.js): flag off: content.request is acked rejected protocol_unsupported, exactly as before F3-07", async () => {
|
|
210
|
+
for (const v of [undefined, "false"]) {
|
|
211
|
+
const o = await e2e({ LOCAL_AGENT_CONTENT_HANDLERS: v, LOCAL_AGENT_DLP_MODE: "enforce" }, [{ kind: "diff", ref: "worktree" }]);
|
|
212
|
+
assert.deepEqual(o.registers[0].capabilities, ["chunk.base64", "protocol.v2", "v1.commands"]);
|
|
213
|
+
assert.deepEqual(o.cp.acksFromLa.map((a) => [a.status, a.errorCode]), [["rejected", "protocol_unsupported"]]);
|
|
214
|
+
assert.equal(o.cp.received.filter((f) => f.type === "content.response").length, 0);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("e2e (real server.js): content flag on but protocol v2 off: nothing is served (v1 client), the agent only warns", async () => {
|
|
219
|
+
const srv = await new ReferenceCpServer({}).start();
|
|
220
|
+
try {
|
|
221
|
+
await runAgent({ srv, cfg: { authorizationId: AGENT, tenantId: TENANT }, env: { ...baseEnv(srv), LOCAL_AGENT_CONTENT_HANDLERS: "true" },
|
|
222
|
+
until: async () => srv.legacy.some((f) => f.type === "register") });
|
|
223
|
+
assert.equal(srv.cp.rawIn.length, 0, "no v2 frame");
|
|
224
|
+
} finally { await srv.stop(); }
|
|
225
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// F3-05b: the contract as the Local Agent applies it. Every vector of the gateway's F3-22 suite (valid AND
|
|
2
|
+
// invalid) must be classified the way the vector declares, the copy must be the gateway's, and the
|
|
3
|
+
// tables the LA keeps by hand (error codes, fatal set) must match the schema.
|
|
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 path from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { loadContract } from "../../src/la-protocol-v2/contract.js";
|
|
11
|
+
import { ERROR_CODES, closeCodeOf, isFatal, isRetryable } from "../../src/la-protocol-v2/errors.js";
|
|
12
|
+
import { parseStrict } from "../../src/la-protocol-v2/strict-json.js";
|
|
13
|
+
|
|
14
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
15
|
+
const RUNTIME = path.join(ROOT, "src/la-protocol-v2/contract");
|
|
16
|
+
const VECTORS = path.join(ROOT, "test/contract/la-protocol-v2/vectors");
|
|
17
|
+
const contract = loadContract();
|
|
18
|
+
const sha = (b) => crypto.createHash("sha256").update(b).digest("hex");
|
|
19
|
+
|
|
20
|
+
test("the copy is the gateway's: CONTRACT_SOURCE.json hashes every file and names the commit", () => {
|
|
21
|
+
const src = JSON.parse(fs.readFileSync(path.join(RUNTIME, "CONTRACT_SOURCE.json"), "utf8"));
|
|
22
|
+
assert.match(src.commit, /^[0-9a-f]{40}$/);
|
|
23
|
+
assert.equal(src.repo, "VISIONHAMMER/vh-api-gateway");
|
|
24
|
+
let n = 0;
|
|
25
|
+
for (const [rel, hash] of Object.entries(src.files)) {
|
|
26
|
+
const file = rel.startsWith("vectors/") ? path.join(ROOT, "test/contract/la-protocol-v2", rel) : path.join(RUNTIME, rel);
|
|
27
|
+
assert.equal(sha(fs.readFileSync(file)), hash, `${rel} was edited after the sync`);
|
|
28
|
+
n++;
|
|
29
|
+
}
|
|
30
|
+
assert.ok(n >= 60, `expected the schemas and all vectors, got ${n}`);
|
|
31
|
+
// nothing extra sneaked in next to the copy
|
|
32
|
+
const onDisk = fs.readdirSync(RUNTIME).filter((f) => f !== "CONTRACT_SOURCE.json");
|
|
33
|
+
for (const f of onDisk) assert.ok(src.files[f], `${f} is not part of the synced contract`);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("VH_GATEWAY_CONTRACT_DIR (optional): the copy equals a gateway checkout byte for byte", { skip: !process.env.VH_GATEWAY_CONTRACT_DIR }, () => {
|
|
37
|
+
const dir = process.env.VH_GATEWAY_CONTRACT_DIR;
|
|
38
|
+
for (const f of fs.readdirSync(dir).filter((n) => n.endsWith(".schema.json") || n === "index.json")) {
|
|
39
|
+
assert.equal(sha(fs.readFileSync(path.join(dir, f))), sha(fs.readFileSync(path.join(RUNTIME, f))), f);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// ------------------------------------------------------------------------------------------------ vectors
|
|
44
|
+
|
|
45
|
+
const vectorFiles = fs.readdirSync(VECTORS).filter((f) => f.endsWith(".vectors.json"));
|
|
46
|
+
|
|
47
|
+
test("vectors: the suite is complete (every type has a file, plus cross-type)", () => {
|
|
48
|
+
const types = Object.keys(contract.index.types);
|
|
49
|
+
for (const t of types) assert.ok(vectorFiles.includes(`${t}.vectors.json`), `no vectors for ${t}`);
|
|
50
|
+
assert.ok(vectorFiles.includes("cross-type.vectors.json"));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
let valid = 0;
|
|
54
|
+
let invalid = 0;
|
|
55
|
+
for (const file of vectorFiles) {
|
|
56
|
+
const doc = JSON.parse(fs.readFileSync(path.join(VECTORS, file), "utf8"));
|
|
57
|
+
for (const v of doc.vectors) {
|
|
58
|
+
test(`vector ${file.replace(".vectors.json", "")} :: ${v.name}`, () => {
|
|
59
|
+
const r = contract.validateFrame(v.message);
|
|
60
|
+
if (v.expect.valid) {
|
|
61
|
+
valid++;
|
|
62
|
+
assert.equal(r.ok, true, `expected valid, got ${r.code} ${r.keywords}`);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
invalid++;
|
|
66
|
+
assert.equal(r.ok, false, "expected the frame to be refused");
|
|
67
|
+
// payload_too_large is the session-level refinement of schema_violation for maxLength under /payload (gateway rule)
|
|
68
|
+
const refined = v.expect.code === "schema_violation" && r.code === "payload_too_large" && r.keywords.includes("maxLength");
|
|
69
|
+
assert.ok(r.code === v.expect.code || refined, `code ${r.code} != ${v.expect.code}`);
|
|
70
|
+
if (v.expect.keyword) assert.ok(r.keywords.includes(v.expect.keyword), `keyword ${v.expect.keyword} not in ${r.keywords}`);
|
|
71
|
+
if (v.expect.rule) assert.ok(r.rules.includes(v.expect.rule), `rule ${v.expect.rule} not in ${r.rules}`);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
test("vectors: counts (134 = 24 valid + 110 invalid in this copy)", () => {
|
|
77
|
+
assert.equal(valid + invalid, 134);
|
|
78
|
+
assert.equal(valid, 24);
|
|
79
|
+
assert.equal(invalid, 110);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ------------------------------------------------------------------------------------------------ tables
|
|
83
|
+
|
|
84
|
+
test("error codes: the LA table is the schema enum, in the same order; close code = 4400 + position (timeout = 4420)", () => {
|
|
85
|
+
assert.deepEqual([...ERROR_CODES], contract.errorCodes);
|
|
86
|
+
assert.equal(closeCodeOf("timeout"), 4420);
|
|
87
|
+
assert.equal(closeCodeOf("bad_frame"), 4400);
|
|
88
|
+
for (const c of ERROR_CODES) {
|
|
89
|
+
const cc = closeCodeOf(c);
|
|
90
|
+
assert.ok(cc >= 4400 && cc <= 4499, c);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("error codes: fatal and retryable sets mirror the gateway (LaV2ErrorCode)", () => {
|
|
95
|
+
const nonFatal = ERROR_CODES.filter((c) => !isFatal(c)).sort();
|
|
96
|
+
assert.deepEqual(nonFatal, ["agent_offline", "internal", "protocol_unsupported", "rate_limited", "timeout"]);
|
|
97
|
+
assert.deepEqual(ERROR_CODES.filter(isRetryable).sort(), ["agent_offline", "internal", "rate_limited", "seq_gap", "timeout"]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("allowlist: the LA receives only cp->la/both types and sends only la->cp/both types; v1 commands are not types", () => {
|
|
101
|
+
assert.deepEqual([...contract.inboundTypes].sort(), ["ack", "approval.decision", "config.update", "content.request", "error", "job.assign", "job.cancel", "ready", "scan.upload", "webhook.forward"]);
|
|
102
|
+
assert.deepEqual([...contract.outboundTypes].sort(), ["ack", "alert", "audit.event", "content.response", "error", "heartbeat", "hello", "incident.candidate", "job.event", "job.result", "log.stats", "register", "scan.uploaded"]);
|
|
103
|
+
for (const v1 of ["mcp.execute-command", "workspace.open", "health", "shell.exec", "__proto__", "constructor", "toString"]) {
|
|
104
|
+
assert.equal(contract.canReceive(v1), false, v1);
|
|
105
|
+
assert.equal(contract.canSend(v1), false, v1);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("limits come from index.json and are the documented ones", () => {
|
|
110
|
+
assert.deepEqual({ ...contract.limits }, {
|
|
111
|
+
maxFrameBytes: 49152, chunkRawBytes: 32768, maxChunks: 64, maxContentBytes: 2097152, maxRetries: 3, ackTimeoutMs: 5000,
|
|
112
|
+
handshakeTimeoutMs: 10000, heartbeatSeconds: 30, sessionDeadSeconds: 90, dedupeWindowSeconds: 600, clockSkewSeconds: 60,
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ------------------------------------------------------------------------------------------------ strict JSON
|
|
117
|
+
|
|
118
|
+
test("parseStrict: valid JSON is returned as JSON.parse would", () => {
|
|
119
|
+
assert.deepEqual(parseStrict('{"a":1,"b":{"c":[1,2,{"d":"x,\\"y\\":z"}]},"e":"{\\"a\\":1,\\"a\\":2}"}'), { a: 1, b: { c: [1, 2, { d: 'x,"y":z' }] }, e: '{"a":1,"a":2}' });
|
|
120
|
+
});
|
|
121
|
+
test("parseStrict: a duplicate key at any depth is refused", () => {
|
|
122
|
+
assert.throws(() => parseStrict('{"a":1,"a":2}'));
|
|
123
|
+
assert.throws(() => parseStrict('{"x":{"a":1,"b":2,"a":3}}'));
|
|
124
|
+
assert.throws(() => parseStrict('[{"k":1},{"k":2,"k":3}]'));
|
|
125
|
+
assert.doesNotThrow(() => parseStrict('[{"k":1},{"k":2}]'));
|
|
126
|
+
assert.doesNotThrow(() => parseStrict('{"a":{"k":1},"b":{"k":2}}'));
|
|
127
|
+
});
|
|
128
|
+
test("parseStrict: invalid JSON throws", () => {
|
|
129
|
+
assert.throws(() => parseStrict("{"));
|
|
130
|
+
assert.throws(() => parseStrict("not json"));
|
|
131
|
+
});
|