deepdebug-local-agent 1.6.0 → 1.7.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.
Files changed (33) hide show
  1. package/docs/la-protocol-v2.md +33 -9
  2. package/env.example +3 -1
  3. package/package.json +1 -1
  4. package/src/la-protocol-v2/content-handlers/config.js +3 -1
  5. package/src/la-protocol-v2/content-handlers/content-index.js +54 -19
  6. package/src/la-protocol-v2/content-handlers/egress.js +22 -0
  7. package/src/la-protocol-v2/content-handlers/file-list.js +174 -0
  8. package/src/la-protocol-v2/content-handlers/git-diff.js +43 -8
  9. package/src/la-protocol-v2/content-handlers/handler.js +28 -4
  10. package/src/la-protocol-v2/content-handlers/install.js +24 -19
  11. package/src/la-protocol-v2/content-handlers/path-guard.js +74 -5
  12. package/src/la-protocol-v2/content-handlers/ref-registry.js +18 -8
  13. package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +10 -8
  14. package/src/la-protocol-v2/contract/README.md +38 -0
  15. package/src/la-protocol-v2/contract/content.index.schema.json +1 -0
  16. package/src/la-protocol-v2/contract/content.request.schema.json +1 -0
  17. package/src/la-protocol-v2/contract/file.list.body.schema.json +69 -0
  18. package/src/la-protocol-v2/contract.js +9 -0
  19. package/src/la-protocol-v2/runtime.js +1 -1
  20. package/test/contract/la-protocol-v2/vectors/content.index.vectors.json +32 -0
  21. package/test/contract/la-protocol-v2/vectors/content.request.vectors.json +215 -0
  22. package/test/contract/la-protocol-v2/vectors/content.response.vectors.json +30 -0
  23. package/test/contract/la-protocol-v2/vectors/file.list.body.vectors.json +1645 -0
  24. package/test/la-protocol-v2/content-alignment.test.js +71 -10
  25. package/test/la-protocol-v2/content-deny-list.test.js +138 -0
  26. package/test/la-protocol-v2/content-diff-symlink.test.js +83 -0
  27. package/test/la-protocol-v2/content-file-list.test.js +385 -0
  28. package/test/la-protocol-v2/content-path-guard.test.js +1 -1
  29. package/test/la-protocol-v2/content-wiring.test.js +2 -2
  30. package/test/la-protocol-v2/contract.test.js +14 -4
  31. package/tools/interop/LaV2NodeFileListE2ETest.java +369 -0
  32. package/tools/interop/README.md +16 -0
  33. package/tools/interop/la-interop-list-client.mjs +93 -0
@@ -0,0 +1,385 @@
1
+ // F3-07c: `file.list`, the producer of the opaque ids. Real V2Session, real handler, real folders on disk; the control plane is the
2
+ // in-memory ReferenceCp. Against the REAL gateway: tools/interop/LaV2NodeContentRelayE2ETest.java (F3-07c section).
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 { execFileSync } from "node:child_process";
8
+ import { ReferenceCp, TENANT, AGENT, waitFor } from "./helpers/reference-cp.js";
9
+ import { signingFor } from "./helpers/la-rig.js";
10
+ import { setup, write, tmpDir } from "./helpers/content-rig.js";
11
+ import { buildV2Link } from "../../src/la-protocol-v2/runtime.js";
12
+ import { loadContract } from "../../src/la-protocol-v2/contract.js";
13
+ import { listFolder } from "../../src/la-protocol-v2/content-handlers/file-list.js";
14
+ import { ContentRefRegistry } from "../../src/la-protocol-v2/content-handlers/ref-registry.js";
15
+ import { resolveContentHandlersConfig } from "../../src/la-protocol-v2/content-handlers/config.js";
16
+
17
+ const ID43 = /^[A-Za-z0-9_-]{43}$/;
18
+ const GH = "ghp_" + "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8";
19
+ const contract = loadContract();
20
+
21
+ async function withRig(opts, body) {
22
+ const rig = await setup(opts);
23
+ try { await body(rig); } finally { await rig.stop(); }
24
+ }
25
+ const list = (rig, ref = "root", extra = {}) => rig.request({ kind: "file.list", ref, ...extra });
26
+ const body = (r) => JSON.parse(r.text);
27
+ const names = (r) => body(r).entries.map((e) => e.name);
28
+
29
+ /** A workspace with everything the listing must and must not show. */
30
+ function seed(root) {
31
+ write(root, "README.md", `# readme\ntoken ${GH}\n`);
32
+ write(root, "a.txt", "aaa");
33
+ write(root, "credentials.service.ts", "export const x = 1;\n");
34
+ write(root, "src/main.ts", "export {};\n");
35
+ write(root, "src/credentials/index.ts", "export {};\n");
36
+ // never listed
37
+ write(root, ".env", "A=1");
38
+ write(root, "credentials.json", "{}");
39
+ write(root, ".envrc", "export A=1");
40
+ write(root, "secrets/api.txt", "x");
41
+ write(root, ".git/config", "[core]");
42
+ write(root, "id_rsa", "x");
43
+ write(root, "server.pem", "x");
44
+ }
45
+
46
+ test("file.list: the root lists what may be served, dirs first then names by bytes; credentials.service.ts is there, .env and credentials.json are not (not even their names)", async () => {
47
+ await withRig({}, async (rig) => {
48
+ seed(rig.root());
49
+ const r = await list(rig);
50
+ assert.deepEqual([r.status, r.contentType], ["ok", "application/json"]);
51
+ assert.deepEqual(names(r), ["src", "README.md", "a.txt", "credentials.service.ts"]);
52
+ for (const never of [".env", "credentials.json", ".envrc", "secrets", ".git", "id_rsa", "server.pem"]) assert.equal(r.text.includes(never), false, never);
53
+ const b = body(r);
54
+ assert.equal(contract.validateBody("file.list.body.schema.json", b), true, "the body is the contract's body");
55
+ assert.deepEqual(b.entries.map((e) => e.type), ["dir", "file", "file", "file"]);
56
+ assert.equal(b.nextRef, undefined);
57
+ for (const e of b.entries) assert.match(e.id, ID43);
58
+ assert.equal(new Set(b.entries.map((e) => e.id)).size, 4, "one id per entry");
59
+ const a = b.entries.find((e) => e.name === "a.txt");
60
+ assert.equal(a.size, 3);
61
+ assert.ok(Math.abs(Date.parse(a.mtime) - fs.statSync(path.join(rig.root(), "a.txt")).mtimeMs) < 2000);
62
+ assert.equal(b.entries[0].size, 0, "a folder has size 0");
63
+ });
64
+ });
65
+
66
+ test("file.list: an id of the listing opens the file (masked by the DLP) and the folder id lists the folder; nothing outside the listing can be asked for", async () => {
67
+ await withRig({}, async (rig) => {
68
+ seed(rig.root());
69
+ const b = body(await list(rig));
70
+ const readme = b.entries.find((e) => e.name === "README.md");
71
+ const f = await rig.request({ kind: "file", ref: readme.id });
72
+ assert.equal(f.status, "ok");
73
+ assert.match(f.text, /# readme/);
74
+ assert.equal(f.text.includes(GH), false, "DLP second layer on the content");
75
+ const src = b.entries.find((e) => e.name === "src");
76
+ const sub = await list(rig, src.id);
77
+ assert.deepEqual(names(sub), ["credentials", "main.ts"]);
78
+ const cred = body(sub).entries.find((e) => e.name === "credentials");
79
+ assert.deepEqual(names(await list(rig, cred.id)), ["index.ts"]);
80
+ // kinds do not mix
81
+ assert.equal((await list(rig, readme.id)).status, "not_found", "a file id is not a folder id");
82
+ assert.equal((await rig.request({ kind: "file", ref: src.id })).status, "not_found", "a folder id is not a file id");
83
+ assert.equal((await rig.request({ kind: "diff", ref: src.id })).status, "not_found");
84
+ // other workspace / unknown / path-like
85
+ assert.equal((await list(rig, src.id, { workspaceId: "ws-2" })).status, "not_found");
86
+ assert.equal((await list(rig, "root", { workspaceId: "ws-nope" })).status, "not_found");
87
+ for (const ref of ["src", "../x", "/etc", "A".repeat(43), "cr1_" + "A".repeat(22), "ROOT", "root/", "%2e%2e", ".", "worktree"]) {
88
+ assert.equal((await list(rig, ref)).status, "not_found", ref);
89
+ }
90
+ // the ids of a file the listing did not show cannot be had: there is none for .env
91
+ assert.equal(rig.registry.live().filter((e) => e.kind === "file").length, 5, "exactly the files shown so far: README.md, a.txt, credentials.service.ts, main.ts, index.ts");
92
+ });
93
+ });
94
+
95
+ test("file.list: dlp + blocked entries: a name with a secret is masked (the id still works), a blocked class withholds the entry and mints nothing", async () => {
96
+ await withRig({}, async (rig) => {
97
+ write(rig.root(), `${GH}.txt`, "named after a token");
98
+ write(rig.root(), "plain.txt", "p");
99
+ const r = await list(rig);
100
+ assert.equal(r.text.includes(GH), false);
101
+ const b = body(r);
102
+ const masked = b.entries.find((e) => e.name.includes("REDACTED"));
103
+ assert.ok(masked, "the name was masked: " + names(r));
104
+ assert.equal(contract.validateBody("file.list.body.schema.json", b), true, "a masked name keeps the body valid (the ids are not masked)");
105
+ assert.equal((await rig.request({ kind: "file", ref: masked.id })).text, "named after a token");
106
+ });
107
+ await withRig({ env: { LOCAL_AGENT_DLP_BLOCK_CLASSES: "vcs-token" } }, async (rig) => {
108
+ write(rig.root(), `${GH}.txt`, "x");
109
+ write(rig.root(), "plain.txt", "p");
110
+ const r = await list(rig);
111
+ assert.deepEqual(names(r), ["plain.txt"]);
112
+ assert.equal(rig.registry.size, 1, "no id was minted for the withheld entry");
113
+ });
114
+ });
115
+
116
+ test("file.list: only with DLP enforce (nothing is read, nothing minted otherwise); a range makes no sense; the flag-off path is the old one", async () => {
117
+ for (const dlp of ["off", "warn"]) {
118
+ await withRig({ dlp }, async (rig) => {
119
+ seed(rig.root());
120
+ const r = await list(rig);
121
+ assert.deepEqual([r.status, r.errorCode, r.body.length], ["error", "protocol_unsupported", 0], dlp);
122
+ assert.equal(rig.registry.size, 0);
123
+ });
124
+ }
125
+ await withRig({}, async (rig) => {
126
+ seed(rig.root());
127
+ const r = await list(rig, "root", { range: { offset: 0, length: 10 } });
128
+ assert.deepEqual([r.status, r.errorCode], ["error", "protocol_unsupported"]);
129
+ assert.equal(rig.registry.size, 0);
130
+ });
131
+ });
132
+
133
+ test("file.list: paging by count: limit N gives N entries and a nextRef (a folder id that remembers the position); the pages add up to the folder, in order, once each", async () => {
134
+ await withRig({}, async (rig) => {
135
+ for (let i = 0; i < 25; i++) write(rig.root(), `f${String(i).padStart(2, "0")}.txt`, "x");
136
+ for (let i = 0; i < 3; i++) fs.mkdirSync(path.join(rig.root(), `d${i}`));
137
+ const all = [];
138
+ let ref = "root";
139
+ let pages = 0;
140
+ const refs = new Set();
141
+ for (; ref && pages < 10; pages++) {
142
+ const r = await list(rig, ref, { limit: 10 });
143
+ assert.equal(r.status, "ok");
144
+ const b = body(r);
145
+ assert.ok(b.entries.length <= 10);
146
+ all.push(...b.entries.map((e) => e.name));
147
+ if (b.nextRef) { assert.match(b.nextRef, ID43); assert.equal(refs.has(b.nextRef), false); refs.add(b.nextRef); }
148
+ else assert.equal(pages, 2, "the third page is the last");
149
+ ref = b.nextRef;
150
+ }
151
+ assert.equal(pages, 3);
152
+ assert.deepEqual(all, [...["d0", "d1", "d2"], ...Array.from({ length: 25 }, (_, i) => `f${String(i).padStart(2, "0")}.txt`)]);
153
+ // the nextRef belongs to the workspace and to the listing kind
154
+ const first = body(await list(rig, "root", { limit: 10 }));
155
+ assert.equal((await list(rig, first.nextRef, { workspaceId: "ws-2" })).status, "not_found");
156
+ assert.equal((await rig.request({ kind: "file", ref: first.nextRef })).status, "not_found");
157
+ // a continuation inside a sub folder stays in that folder
158
+ write(rig.root(), "sub/a.txt", "1"); write(rig.root(), "sub/b.txt", "1"); write(rig.root(), "sub/c.txt", "1");
159
+ const sub = body(await list(rig, "root", { limit: 1000 })).entries.find((e) => e.name === "sub");
160
+ const p1 = body(await list(rig, sub.id, { limit: 2 }));
161
+ assert.deepEqual(p1.entries.map((e) => e.name), ["a.txt", "b.txt"]);
162
+ assert.deepEqual(names(await list(rig, p1.nextRef, { limit: 2 })), ["c.txt"]);
163
+ });
164
+ });
165
+
166
+ test("file.list: paging by size: 255-character names do not fit 1000 to a page; every page is within the byte budget and the pages still add up", async () => {
167
+ await withRig({ env: { LOCAL_AGENT_CONTENT_MAX_BYTES: "8192" } }, async (rig) => {
168
+ const N = 40;
169
+ const long = (i) => `${String(i).padStart(3, "0")}-${"n".repeat(240)}.txt`;
170
+ for (let i = 0; i < N; i++) write(rig.root(), long(i), "x");
171
+ const all = [];
172
+ let ref = "root";
173
+ let pages = 0;
174
+ while (ref && pages < 40) {
175
+ const r = await list(rig, ref, { limit: 1000 });
176
+ assert.equal(r.status, "ok", r.errorCode);
177
+ assert.ok(r.body.length <= 8192, `page ${pages}: ${r.body.length} bytes`);
178
+ assert.ok(body(r).entries.length >= 1);
179
+ all.push(...names(r));
180
+ ref = body(r).nextRef;
181
+ pages++;
182
+ }
183
+ assert.ok(pages >= 3, "several pages");
184
+ assert.deepEqual(all, Array.from({ length: N }, (_, i) => long(i)));
185
+ assert.equal(rig.registry.size, N + (pages - 1), "ids exist only for what was shown (+ one continuation per page but the last): an entry that did not fit left no id behind");
186
+ });
187
+ });
188
+
189
+ test("file.list: a folder with more than LOCAL_AGENT_CONTENT_MAX_DIR_ENTRIES entries is payload_too_large (never 'the first N the disk returned'); the limit itself is fine", async () => {
190
+ await withRig({ env: { LOCAL_AGENT_CONTENT_MAX_DIR_ENTRIES: "100" } }, async (rig) => {
191
+ for (let i = 0; i < 100; i++) write(rig.root(), `big/f${i}.txt`, "x");
192
+ const big = body(await list(rig)).entries.find((e) => e.name === "big");
193
+ assert.equal(body(await list(rig, big.id, { limit: 1000 })).entries.length, 100);
194
+ write(rig.root(), "big/one-more.txt", "x");
195
+ const r = await list(rig, big.id);
196
+ assert.deepEqual([r.status, r.errorCode, r.body.length], ["error", "payload_too_large", 0]);
197
+ });
198
+ });
199
+
200
+ test("file.list: links, hard links, devices and odd names: a link that leaves the root, a link to a secret, a hard link, a fifo and a name with a newline are simply not there", async () => {
201
+ await withRig({}, async (rig) => {
202
+ const root = rig.root();
203
+ const out = tmpDir("f307c-out-");
204
+ rig.dirs.push(out);
205
+ write(out, "outside.txt", "outside secret");
206
+ write(out, "dir/inner.txt", "x");
207
+ write(root, "ok.txt", "ok");
208
+ write(root, "real/inner.txt", "i");
209
+ write(root, ".env", "A=1");
210
+ write(root, "secrets/x.txt", "s");
211
+ fs.symlinkSync(path.join(out, "outside.txt"), path.join(root, "link-out.txt"));
212
+ fs.symlinkSync(path.join(out, "dir"), path.join(root, "link-outdir"));
213
+ fs.symlinkSync(path.join(root, ".env"), path.join(root, "innocent.txt"));
214
+ fs.symlinkSync(path.join(root, "secrets"), path.join(root, "innocent-dir"));
215
+ fs.symlinkSync(path.join(root, "ok.txt"), path.join(root, "link-in.txt"));
216
+ fs.symlinkSync(path.join(root, "real"), path.join(root, "link-indir"));
217
+ fs.symlinkSync(path.join(root, "missing"), path.join(root, "dangling"));
218
+ fs.symlinkSync("../outside.txt", path.join(root, "rel-out.txt"));
219
+ fs.symlinkSync(root, path.join(root, "loop")); // a link to the root itself
220
+ try { fs.linkSync(path.join(root, "ok.txt"), path.join(root, "hard.txt")); } catch { /* no hard links here */ }
221
+ try { execFileSync("mkfifo", [path.join(root, "pipe")]); } catch { /* no mkfifo */ }
222
+ try { write(root, "bad\nname.txt", "x"); } catch { /* a file system that refuses it */ }
223
+ try { write(root, "repl\ufffdchar.txt", "x"); } catch { /* idem: a name with U+FFFD is what invalid UTF-8 looks like after decoding, so it is never listed */ }
224
+ const r = await list(rig);
225
+ assert.equal(r.status, "ok");
226
+ const n = names(r);
227
+ // ok.txt itself is hard-linked now (hard.txt is its second name): both are left out by the default hard link rule
228
+ const hard = fs.statSync(path.join(root, "ok.txt")).nlink > 1;
229
+ assert.deepEqual(n.filter((x) => !["ok.txt", "link-in.txt"].includes(x)), ["link-indir", "real"], n.join(","));
230
+ assert.equal(n.includes("ok.txt"), !hard);
231
+ assert.equal(n.includes("link-in.txt"), !hard, "a link to a hard-linked file is a name of it: same rule");
232
+ for (const never of ["link-out", "link-outdir", "innocent", "dangling", "rel-out", "pipe", "bad", "repl", "loop", ".env", "secrets", "hard.txt"]) assert.equal(r.text.includes(never), false, never);
233
+ // a link to a folder inside the root lists as a folder, and the ids it hands out are for the real folder
234
+ const li = body(r).entries.find((e) => e.name === "link-indir");
235
+ assert.equal(li.type, "dir");
236
+ assert.deepEqual(names(await list(rig, li.id)), ["inner.txt"]);
237
+ // a folder swapped for a link out of the root / to a secret after its id was minted is forbidden at list time
238
+ const real = body(r).entries.find((e) => e.name === "real");
239
+ fs.rmSync(path.join(root, "real"), { recursive: true });
240
+ fs.symlinkSync(path.join(out, "dir"), path.join(root, "real"));
241
+ assert.equal((await list(rig, real.id)).status, "forbidden");
242
+ fs.rmSync(path.join(root, "real"));
243
+ fs.symlinkSync(path.join(root, "secrets"), path.join(root, "real"));
244
+ assert.equal((await list(rig, real.id)).status, "forbidden");
245
+ assert.equal(r.text.includes(out), false);
246
+ });
247
+ });
248
+
249
+ test("file.list: the hard link rule can be lifted by LOCAL_AGENT_CONTENT_ALLOW_HARDLINKS (as for file)", async () => {
250
+ await withRig({ env: { LOCAL_AGENT_CONTENT_ALLOW_HARDLINKS: "true" } }, async (rig) => {
251
+ write(rig.root(), "a.txt", "x");
252
+ try { fs.linkSync(path.join(rig.root(), "a.txt"), path.join(rig.root(), "b.txt")); } catch { return; }
253
+ assert.deepEqual(names(await list(rig)), ["a.txt", "b.txt"]);
254
+ });
255
+ });
256
+
257
+ test("file.list: unicode, spaces and a root reached through a link; an empty folder is an empty page; a folder that is not there is not_found", async () => {
258
+ await withRig({}, async (rig) => {
259
+ write(rig.root(), "ação final.txt", "x"); write(rig.root(), "日本語.md", "x"); write(rig.root(), "with space/a b.txt", "x"); fs.mkdirSync(path.join(rig.root(), "empty"));
260
+ const b = body(await list(rig));
261
+ assert.deepEqual(b.entries.map((e) => e.name), ["empty", "with space", "ação final.txt", "日本語.md"]);
262
+ const empty = b.entries.find((e) => e.name === "empty");
263
+ const e = await list(rig, empty.id);
264
+ assert.deepEqual([e.status, body(e)], ["ok", { entries: [] }]);
265
+ fs.rmdirSync(path.join(rig.root(), "empty"));
266
+ assert.equal((await list(rig, empty.id)).status, "not_found");
267
+ });
268
+ const base = tmpDir(); const real = path.join(base, "real"); const link = path.join(base, "link");
269
+ fs.mkdirSync(real); write(real, "x.txt", "x"); fs.symlinkSync(real, link);
270
+ await withRig({ workspaces: { "ws-1": { root: link } } }, async (rig) => {
271
+ assert.deepEqual(names(await list(rig)), ["x.txt"]);
272
+ });
273
+ fs.rmSync(base, { recursive: true, force: true });
274
+ });
275
+
276
+ test("file.list: nothing but codes in errors, audit and logs: no name, no path and no id of a listing is ever in them", async () => {
277
+ await withRig({}, async (rig) => {
278
+ seed(rig.root());
279
+ write(rig.root(), "very-private-customer-name.txt", "x");
280
+ const r = await list(rig);
281
+ const ids = body(r).entries.map((e) => e.id);
282
+ await list(rig, "A".repeat(43)); await list(rig, ids[0]); await list(rig, "root", { workspaceId: "ws-nope" });
283
+ const noise = JSON.stringify([rig.handler.audit.tail(1000), rig.logs]);
284
+ for (const leak of ["very-private-customer-name", "README", "credentials", "main.ts", rig.root(), path.basename(rig.root()), ...ids, GH]) assert.equal(noise.includes(leak), false, `leaked ${leak}`);
285
+ const rec = rig.handler.audit.tail(1000).find((x) => x.kind === "file.list" && x.status === "ok");
286
+ assert.ok(rec && rec.bytes > 0 && rec.chunks >= 1);
287
+ });
288
+ });
289
+
290
+ test("file.list: a folder of 3000 entries gives a page of `limit`, and the slot and the memory reserved for the request come back", async () => {
291
+ await withRig({ env: { LOCAL_AGENT_CONTENT_MAX_DIR_ENTRIES: "20000" } }, async (rig) => {
292
+ for (let i = 0; i < 3000; i++) fs.writeFileSync(path.join(rig.root(), `f${i}.txt`), "x");
293
+ const r = await list(rig, "root", { limit: 1000 });
294
+ assert.equal(r.status, "ok");
295
+ assert.equal(body(r).entries.length, 1000);
296
+ assert.equal(rig.handler.inflight, 0);
297
+ assert.equal(rig.handler.reservedBytes, 0);
298
+ });
299
+ });
300
+
301
+ test("file.list: a cursor belongs to a folder id only (a file id with a cursor, or a bad cursor, is refused by the registry); listFolder stops when the request is aborted", async () => {
302
+ const reg = new ContentRefRegistry({ tenantId: TENANT });
303
+ const q = { workspaceId: "w", root: "/r" };
304
+ assert.equal(reg.mint({ ...q, kind: "file", rel: "a.txt", after: { dir: false, name: "a" } }), null);
305
+ assert.equal(reg.mint({ ...q, kind: "diff", rel: null, after: { dir: false, name: "a" } }), null);
306
+ for (const bad of ["x", 1, {}, { name: "" }, { name: "x".repeat(2000) }]) assert.equal(reg.mint({ ...q, kind: "file.list", rel: null, after: bad }), null, JSON.stringify(bad).slice(0, 20));
307
+ assert.ok(reg.mint({ ...q, kind: "file.list", rel: null, after: { dir: true, name: "d" } }));
308
+ const root = tmpDir();
309
+ write(root, ".env", "A=1"); // nothing listable: only the loop that reads the folder can notice the abort
310
+ const o = { root, limit: 10, maxBytes: 1 << 20, maxDirEntries: 1000, mint: () => null, maskName: (n) => ({ name: n, blocked: false, denied: false, total: 0, redactions: {} }) };
311
+ assert.deepEqual(await listFolder({ ...o }), { ok: true, body: Buffer.from('{"entries":[]}'), entries: 0, dlp: { total: 0, redactions: {} } });
312
+ assert.deepEqual(await listFolder({ ...o, signal: AbortSignal.abort() }), { ok: false, status: "error", errorCode: "agent_offline" });
313
+ fs.rmSync(root, { recursive: true, force: true });
314
+ });
315
+
316
+ test("config (F3-07c): LOCAL_AGENT_CONTENT_MAX_DIR_ENTRIES is clamped to 100..200000, a bad value is the default (20000), never unlimited", () => {
317
+ const c = (v) => resolveContentHandlersConfig({ LOCAL_AGENT_CONTENT_HANDLERS: "true", LOCAL_AGENT_CONTENT_MAX_DIR_ENTRIES: v }, () => {}).maxDirEntries;
318
+ assert.deepEqual([c(undefined), c("abc"), c("99"), c("200001"), c("999999999999"), c("-5"), c("100"), c("200000"), c("5000")], [20_000, 20_000, 20_000, 20_000, 20_000, 20_000, 100, 200_000, 5_000]);
319
+ });
320
+
321
+ // ------------------------------------------------------------------------------------------ the real wiring: content.index
322
+
323
+ async function linkFor(cp, { env, root }) {
324
+ const logs = [];
325
+ const log = { info: (m) => logs.push(m), log: (m) => logs.push(m), warn: (m) => logs.push(m), error: (m) => logs.push(m) };
326
+ const wsManager = { list: () => [{ id: "ws-1", root, tenantId: TENANT }] };
327
+ const link = await buildV2Link({ tenantId: TENANT, agentId: AGENT, signing: signingFor({ keyId: cp.keyId, publicKeyRaw: cp.publicKeyRaw }, "enforce"), wsManager,
328
+ getWorkspaceRoot: () => root, log, env: { LOCAL_AGENT_CONTENT_HANDLERS: "true", LOCAL_AGENT_DLP_MODE: "enforce", ...env } });
329
+ const transport = { send: (t) => cp.feed(t), close() {}, bufferedAmount: () => 0 };
330
+ cp.attach((t) => queueMicrotask(() => link.session.handleText(t, transport)), () => {});
331
+ link.session.attach(transport);
332
+ await waitFor(() => link.session.state === "ready");
333
+ return { link, logs };
334
+ }
335
+
336
+ test("wiring: a listing mints through mintRef, so every id it hands out is announced with content.index ({kind, ref, size}: no names), and the register announces content.file.list", async () => {
337
+ const root = tmpDir(); seed(root); write(root, "very-private-customer-name.txt", "12345");
338
+ const cp = new ReferenceCp();
339
+ const { link, logs } = await linkFor(cp, { root, env: { LOCAL_AGENT_CONTENT_INDEX_BATCH_MS: "0" } });
340
+ assert.ok(cp.registers[0].capabilities.includes("content.file.list"));
341
+ const requestId = (await import("./helpers/reference-cp.js")).uuid();
342
+ cp.sendData("content.request", { kind: "file.list", ref: "root", actor: { userId: "u-1", role: "member" } }, { workspaceId: "ws-1", requestId });
343
+ await waitFor(() => cp.contentRequests.get(requestId)?.done);
344
+ const b = JSON.parse(Buffer.concat(cp.contentRequests.get(requestId).chunks).toString("utf8"));
345
+ assert.equal(b.entries.length, 5);
346
+ await waitFor(() => cp.received.filter((f) => f.type === "content.index").flatMap((f) => f.payload.entries).length === 5);
347
+ const idx = cp.received.filter((f) => f.type === "content.index").flatMap((f) => f.payload.entries);
348
+ const byRef = new Map(idx.map((e) => [e.ref, e]));
349
+ for (const e of b.entries) {
350
+ const i = byRef.get(e.id);
351
+ assert.ok(i, "announced: " + e.name.length);
352
+ assert.deepEqual(Object.keys(i).sort(), ["kind", "ref", "size"]);
353
+ assert.equal(i.kind, e.type === "dir" ? "file.list" : "file");
354
+ assert.equal(i.size, e.size);
355
+ }
356
+ const wire = JSON.stringify(cp.received.filter((f) => f.type === "content.index"));
357
+ for (const leak of ["very-private-customer-name", "README", "main.ts", root, path.basename(root)]) assert.equal(wire.includes(leak), false, `leaked ${leak}`);
358
+ assert.equal(logs.join("\n").includes("very-private"), false);
359
+ assert.deepEqual(cp.errorsSent, []);
360
+ link.close();
361
+ fs.rmSync(root, { recursive: true, force: true });
362
+ });
363
+
364
+ test("wiring: the nextRef of the real link works (the continuation is minted through mintRef with its cursor): page 2 continues where page 1 stopped", async () => {
365
+ const root = tmpDir();
366
+ for (const n of ["a", "b", "c", "d", "e"]) write(root, `${n}.txt`, n);
367
+ const cp = new ReferenceCp();
368
+ const { link } = await linkFor(cp, { root, env: {} });
369
+ const { uuid } = await import("./helpers/reference-cp.js");
370
+ const ask = async (ref, limit) => {
371
+ const requestId = uuid();
372
+ cp.sendData("content.request", { kind: "file.list", ref, limit, actor: { userId: "u-1", role: "member" } }, { workspaceId: "ws-1", requestId });
373
+ await waitFor(() => cp.contentRequests.get(requestId)?.done);
374
+ return JSON.parse(Buffer.concat(cp.contentRequests.get(requestId).chunks).toString("utf8"));
375
+ };
376
+ const p1 = await ask("root", 2);
377
+ assert.deepEqual(p1.entries.map((e) => e.name), ["a.txt", "b.txt"]);
378
+ assert.match(p1.nextRef, ID43);
379
+ const p2 = await ask(p1.nextRef, 2);
380
+ assert.deepEqual(p2.entries.map((e) => e.name), ["c.txt", "d.txt"]);
381
+ const p3 = await ask(p2.nextRef, 2);
382
+ assert.deepEqual([p3.entries.map((e) => e.name), p3.nextRef], [["e.txt"], undefined]);
383
+ link.close();
384
+ fs.rmSync(root, { recursive: true, force: true });
385
+ });
@@ -176,7 +176,7 @@ test("ids (F3-07b): 43 base64url chars, no prefix, 256 random bits, not derived
176
176
  test("ids: resolve only for the same tenant, workspace, root and kind; anything else is null (indistinguishable from unknown)", () => {
177
177
  const reg = new ContentRefRegistry({ tenantId: "t1" });
178
178
  const { id } = reg.mint({ workspaceId: "w1", root: "/r1", kind: "file", rel: "src/a.txt" });
179
- assert.deepEqual(reg.resolve(id, { workspaceId: "w1", root: "/r1", kind: "file" }), { rel: "src/a.txt" });
179
+ assert.deepEqual(reg.resolve(id, { workspaceId: "w1", root: "/r1", kind: "file" }), { rel: "src/a.txt", after: null });
180
180
  assert.equal(reg.resolve(id, { workspaceId: "w2", root: "/r1", kind: "file" }), null, "other workspace");
181
181
  assert.equal(reg.resolve(id, { workspaceId: "w1", root: "/r2", kind: "file" }), null, "same workspace id, re-opened on another root");
182
182
  assert.equal(reg.resolve(id, { workspaceId: "w1", root: "/r1", kind: "diff" }), null, "other kind");
@@ -69,11 +69,11 @@ test("flag off (unset or any non-true value): content.request is acked `rejected
69
69
  }
70
70
  });
71
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 () => {
72
+ test("flag on: the handler is installed, the register announces content.file, content.file.list and content.diff, mintRef is the hook the listing mints through", async () => {
73
73
  const root = tmpDir(); write(root, "src/a.txt", "hello"); write(root, ".env", "X=1");
74
74
  const cp = new ReferenceCp();
75
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"]);
76
+ assert.deepEqual(cp.registers[0].capabilities, ["chunk.base64", "content.diff", "content.file", "content.file.list", "protocol.v2", "v1.commands"]);
77
77
  assert.equal(logs.some((l) => l.includes("not enforce")), false, "enforce: no warning");
78
78
  const m = link.content.mintRef({ workspaceId: "ws-1", kind: "file", rel: "src/a.txt" });
79
79
  assert.match(m.id, /^[A-Za-z0-9_-]{43}$/);
@@ -52,9 +52,18 @@ test("vectors: the suite is complete (every type has a file, plus cross-type)",
52
52
 
53
53
  let valid = 0;
54
54
  let invalid = 0;
55
+ let bodyVectors = 0;
55
56
  for (const file of vectorFiles) {
56
57
  const doc = JSON.parse(fs.readFileSync(path.join(VECTORS, file), "utf8"));
57
58
  for (const v of doc.vectors) {
59
+ // F3-07c: `file.list.body.vectors.json` holds bodies (the decoded data of a content.response), not frames: they name their schema
60
+ if (typeof doc.schema === "string" && doc.schema.endsWith(".body.schema.json")) {
61
+ test(`vector ${file.replace(".vectors.json", "")} :: ${v.name}`, () => {
62
+ assert.equal(contract.validateBody(doc.schema, v.message), v.expect.valid === true);
63
+ bodyVectors++;
64
+ });
65
+ continue;
66
+ }
58
67
  test(`vector ${file.replace(".vectors.json", "")} :: ${v.name}`, () => {
59
68
  const r = contract.validateFrame(v.message);
60
69
  if (v.expect.valid) {
@@ -73,10 +82,11 @@ for (const file of vectorFiles) {
73
82
  }
74
83
  }
75
84
 
76
- test("vectors: counts (168 = 30 valid + 138 invalid in this copy)", () => {
77
- assert.equal(valid + invalid, 168);
78
- assert.equal(valid, 30);
79
- assert.equal(invalid, 138);
85
+ test("vectors: counts (176 frames = 34 valid + 142 invalid in this copy, plus the bodies of file.list)", () => {
86
+ assert.equal(valid + invalid, 176);
87
+ assert.equal(valid, 34);
88
+ assert.equal(invalid, 142);
89
+ assert.equal(bodyVectors, 54, "file.list.body.vectors.json (F3-07c): 54 bodies");
80
90
  });
81
91
 
82
92
  // ------------------------------------------------------------------------------------------------ tables