deepdebug-local-agent 1.5.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.
- package/docs/la-protocol-v2.md +70 -16
- package/env.example +6 -0
- package/package.json +1 -1
- package/scripts/sync-la-protocol-v2-contract.mjs +3 -3
- package/src/la-protocol-v2/content-handlers/config.js +10 -2
- package/src/la-protocol-v2/content-handlers/content-index.js +196 -0
- package/src/la-protocol-v2/content-handlers/egress.js +22 -0
- package/src/la-protocol-v2/content-handlers/file-list.js +174 -0
- package/src/la-protocol-v2/content-handlers/git-diff.js +95 -10
- package/src/la-protocol-v2/content-handlers/handler.js +52 -16
- package/src/la-protocol-v2/content-handlers/install.js +35 -15
- package/src/la-protocol-v2/content-handlers/path-guard.js +120 -10
- package/src/la-protocol-v2/content-handlers/ref-registry.js +41 -13
- package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +13 -8
- package/src/la-protocol-v2/contract/README.md +87 -2
- package/src/la-protocol-v2/contract/content.index.schema.json +93 -0
- package/src/la-protocol-v2/contract/content.request.schema.json +1 -0
- package/src/la-protocol-v2/contract/envelope.schema.json +1 -0
- package/src/la-protocol-v2/contract/file.list.body.schema.json +69 -0
- package/src/la-protocol-v2/contract/index.json +7 -0
- package/src/la-protocol-v2/contract.js +9 -0
- package/src/la-protocol-v2/runtime.js +12 -2
- package/test/contract/la-protocol-v2/vectors/content.index.vectors.json +3809 -0
- package/test/contract/la-protocol-v2/vectors/content.request.vectors.json +215 -0
- package/test/contract/la-protocol-v2/vectors/content.response.vectors.json +30 -0
- package/test/contract/la-protocol-v2/vectors/file.list.body.vectors.json +1645 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-content-index-before-ready.json +68 -0
- package/test/contract/la-protocol-v2/vectors/traces/valid-content-index.json +161 -0
- package/test/la-protocol-v2/content-alignment.test.js +669 -0
- package/test/la-protocol-v2/content-deny-list.test.js +138 -0
- package/test/la-protocol-v2/content-diff-symlink.test.js +83 -0
- package/test/la-protocol-v2/content-file-list.test.js +385 -0
- package/test/la-protocol-v2/content-handlers.test.js +55 -61
- package/test/la-protocol-v2/content-path-guard.test.js +10 -5
- package/test/la-protocol-v2/content-wiring.test.js +39 -14
- package/test/la-protocol-v2/contract.test.js +15 -5
- package/test/la-protocol-v2/helpers/reference-cp.js +8 -0
- package/test/la-protocol-v2/traces.test.js +29 -4
- package/tools/interop/LaV2NodeContentInteropTest.java +18 -4
- package/tools/interop/LaV2NodeContentRelayE2ETest.java +374 -0
- package/tools/interop/LaV2NodeFileListE2ETest.java +369 -0
- package/tools/interop/README.md +16 -0
- package/tools/interop/la-interop-content-client.mjs +19 -8
- package/tools/interop/la-interop-list-client.mjs +93 -0
|
@@ -27,7 +27,7 @@ async function withRig(opts, body) {
|
|
|
27
27
|
// ------------------------------------------------------------------------------------------ file: the happy path
|
|
28
28
|
|
|
29
29
|
test("file: an id minted by the agent is served; the request is acked `accepted` before the answer; content type from the extension", async () => {
|
|
30
|
-
await withRig({ dlp: "
|
|
30
|
+
await withRig({ dlp: "enforce" }, async (rig) => {
|
|
31
31
|
write(rig.root(), "docs/readme.md", "# hello\n");
|
|
32
32
|
write(rig.root(), "src/data.json", '{"a":1}');
|
|
33
33
|
write(rig.root(), "src/a.txt", "plain");
|
|
@@ -43,7 +43,7 @@ test("file: an id minted by the agent is served; the request is acked `accepted`
|
|
|
43
43
|
});
|
|
44
44
|
|
|
45
45
|
test("file: 100 KiB goes out in 4 chunks and the CP rebuilds it byte for byte; exactly the size limit is accepted", async () => {
|
|
46
|
-
await withRig({ dlp: "
|
|
46
|
+
await withRig({ dlp: "enforce" }, async (rig) => {
|
|
47
47
|
const data = Buffer.from("line of text number x\n".repeat(5000)).subarray(0, 100 * 1024);
|
|
48
48
|
write(rig.root(), "big.txt", data);
|
|
49
49
|
const r = await rig.request({ ref: rig.mint("file", "big.txt") });
|
|
@@ -59,14 +59,14 @@ test("file: 100 KiB goes out in 4 chunks and the CP rebuilds it byte for byte; e
|
|
|
59
59
|
});
|
|
60
60
|
|
|
61
61
|
test("file: unicode names, spaces and a root reached through a symlink work", async () => {
|
|
62
|
-
await withRig({ dlp: "
|
|
62
|
+
await withRig({ dlp: "enforce" }, async (rig) => {
|
|
63
63
|
write(rig.root(), "Dir Com Espaço/ficheiro ü ñ 日本.txt", "olá");
|
|
64
64
|
assert.equal((await rig.request({ ref: rig.mint("file", "Dir Com Espaço/ficheiro ü ñ 日本.txt") })).text, "olá");
|
|
65
65
|
});
|
|
66
66
|
});
|
|
67
67
|
|
|
68
68
|
test("file: range is applied to the bytes as served, and past the end is an empty ok", async () => {
|
|
69
|
-
await withRig({ dlp: "
|
|
69
|
+
await withRig({ dlp: "enforce" }, async (rig) => {
|
|
70
70
|
write(rig.root(), "r.txt", "0123456789");
|
|
71
71
|
const ref = rig.mint("file", "r.txt");
|
|
72
72
|
assert.equal((await rig.request({ ref, range: { offset: 2, length: 3 } })).text, "234");
|
|
@@ -79,7 +79,7 @@ test("file: range is applied to the bytes as served, and past the end is an empt
|
|
|
79
79
|
// ------------------------------------------------------------------------------------------ refs: opaque ids, nothing is a path
|
|
80
80
|
|
|
81
81
|
test("ref: path-like refs are never interpreted as paths: traversal, absolute, NUL, %2e%2e, unicode tricks -> not_found, nothing echoed", async () => {
|
|
82
|
-
await withRig({ dlp: "
|
|
82
|
+
await withRig({ dlp: "enforce" }, async (rig) => {
|
|
83
83
|
const outside = tmpDir(); rig.dirs.push(outside);
|
|
84
84
|
write(outside, "passwd", "OUTSIDE-CONTENT");
|
|
85
85
|
write(rig.root(), "src/a.txt", "inside");
|
|
@@ -103,7 +103,7 @@ test("ref: path-like refs are never interpreted as paths: traversal, absolute, N
|
|
|
103
103
|
|
|
104
104
|
test("ref: unknown and expired ids are not_found; an expired id does not come back", async () => {
|
|
105
105
|
let t = 5_000_000;
|
|
106
|
-
await withRig({ dlp: "
|
|
106
|
+
await withRig({ dlp: "enforce", now: () => t, env: { LOCAL_AGENT_CONTENT_REF_TTL_MS: "60000" } }, async (rig) => {
|
|
107
107
|
write(rig.root(), "a.txt", "x");
|
|
108
108
|
const id = rig.mint("file", "a.txt");
|
|
109
109
|
assert.equal((await rig.request({ ref: id })).status, "ok");
|
|
@@ -118,7 +118,7 @@ test("ref: unknown and expired ids are not_found; an expired id does not come ba
|
|
|
118
118
|
});
|
|
119
119
|
|
|
120
120
|
test("ref: an id of workspace A does not open anything through workspace B (both registered), nor through an unregistered one", async () => {
|
|
121
|
-
await withRig({ dlp: "
|
|
121
|
+
await withRig({ dlp: "enforce" }, async (rig) => {
|
|
122
122
|
write(rig.root("ws-1"), "a.txt", "A-CONTENT");
|
|
123
123
|
write(rig.root("ws-2"), "a.txt", "B-CONTENT");
|
|
124
124
|
const idA = rig.mint("file", "a.txt", "ws-1");
|
|
@@ -134,7 +134,7 @@ test("ref: an id of workspace A does not open anything through workspace B (both
|
|
|
134
134
|
|
|
135
135
|
test("ref: a workspace that belongs to another tenant is not served, even with a valid id", async () => {
|
|
136
136
|
const rootA = tmpDir(); const rootB = tmpDir();
|
|
137
|
-
await withRig({ dlp: "
|
|
137
|
+
await withRig({ dlp: "enforce", workspaces: { "ws-1": { root: rootA }, "ws-foreign": { root: rootB, tenantId: "9d1f6a2c-0000-4000-8000-000000000000" } } }, async (rig) => {
|
|
138
138
|
write(rootA, "a.txt", "mine"); write(rootB, "a.txt", "FOREIGN");
|
|
139
139
|
const id = rig.registry.mint({ workspaceId: "ws-foreign", root: rootB, kind: "file", rel: "a.txt" }).id;
|
|
140
140
|
assert.equal((await rig.request({ ref: id, workspaceId: "ws-foreign" })).status, "not_found");
|
|
@@ -144,7 +144,7 @@ test("ref: a workspace that belongs to another tenant is not served, even with a
|
|
|
144
144
|
});
|
|
145
145
|
|
|
146
146
|
test("ref: a frame for another tenant never reaches the handler (the session refuses it as tenant_mismatch)", async () => {
|
|
147
|
-
await withRig({ dlp: "
|
|
147
|
+
await withRig({ dlp: "enforce" }, async (rig) => {
|
|
148
148
|
write(rig.root(), "a.txt", "x");
|
|
149
149
|
const f = rig.cp.frameWith("content.request", { kind: "file", ref: rig.mint("file", "a.txt"), actor: { userId: "u-1", role: "member" } }, { requestId: uuid() });
|
|
150
150
|
f.tenantId = "9d1f6a2c-0000-4000-8000-000000000000";
|
|
@@ -156,7 +156,7 @@ test("ref: a frame for another tenant never reaches the handler (the session ref
|
|
|
156
156
|
});
|
|
157
157
|
|
|
158
158
|
test("ref: a workspace re-opened on another directory invalidates the ids minted for the old root", async () => {
|
|
159
|
-
await withRig({ dlp: "
|
|
159
|
+
await withRig({ dlp: "enforce" }, async (rig) => {
|
|
160
160
|
write(rig.root(), "a.txt", "old root");
|
|
161
161
|
const id = rig.mint("file", "a.txt");
|
|
162
162
|
const other = tmpDir(); rig.dirs.push(other);
|
|
@@ -171,7 +171,7 @@ test("ref: a workspace re-opened on another directory invalidates the ids minted
|
|
|
171
171
|
// ------------------------------------------------------------------------------------------ path checks at read time
|
|
172
172
|
|
|
173
173
|
test("file: sensitive files are forbidden by name (an id for them cannot be minted) and by link (a harmless name pointing to .env)", async () => {
|
|
174
|
-
await withRig({ dlp: "
|
|
174
|
+
await withRig({ dlp: "enforce" }, async (rig) => {
|
|
175
175
|
write(rig.root(), ".env", "DB_PASSWORD=hunter2hunter2");
|
|
176
176
|
write(rig.root(), "certs/server.pem", "PEMDATA");
|
|
177
177
|
write(rig.root(), ".git/config", "[remote]\n\turl = https://user:tok@host/r.git");
|
|
@@ -189,7 +189,7 @@ test("file: sensitive files are forbidden by name (an id for them cannot be mint
|
|
|
189
189
|
});
|
|
190
190
|
|
|
191
191
|
test("file: the deny list is configurable (LOCAL_AGENT_CONTENT_DENY_GLOBS) and applied at read time too", async () => {
|
|
192
|
-
await withRig({ dlp: "
|
|
192
|
+
await withRig({ dlp: "enforce", env: { LOCAL_AGENT_CONTENT_DENY_GLOBS: "private-notes/, *.hush" } }, async (rig) => {
|
|
193
193
|
write(rig.root(), "private-notes/n.txt", "n"); write(rig.root(), "a.hush", "h");
|
|
194
194
|
assert.equal(rig.mint("file", "private-notes/n.txt"), null);
|
|
195
195
|
assert.equal(rig.mint("file", "a.hush"), null);
|
|
@@ -202,7 +202,7 @@ test("file: the deny list is configurable (LOCAL_AGENT_CONTENT_DENY_GLOBS) and a
|
|
|
202
202
|
});
|
|
203
203
|
|
|
204
204
|
test("file: a symlink that leaves the workspace (swapped in after the id was minted) is forbidden and its target is never read", async () => {
|
|
205
|
-
await withRig({ dlp: "
|
|
205
|
+
await withRig({ dlp: "enforce" }, async (rig) => {
|
|
206
206
|
const outside = tmpDir(); rig.dirs.push(outside);
|
|
207
207
|
write(outside, "secret.txt", "OUTSIDE-SECRET-CONTENT");
|
|
208
208
|
write(outside, "d/x.txt", "OUTSIDE-DIR-CONTENT");
|
|
@@ -221,7 +221,7 @@ test("file: a symlink that leaves the workspace (swapped in after the id was min
|
|
|
221
221
|
});
|
|
222
222
|
|
|
223
223
|
test("file: missing file, a directory and an id minted for a path that never existed are not_found", async () => {
|
|
224
|
-
await withRig({ dlp: "
|
|
224
|
+
await withRig({ dlp: "enforce" }, async (rig) => {
|
|
225
225
|
write(rig.root(), "d/x.txt", "x");
|
|
226
226
|
assert.equal((await rig.request({ ref: rig.mint("file", "nope.txt") })).status, "not_found");
|
|
227
227
|
assert.equal((await rig.request({ ref: rig.mint("file", "d") })).status, "not_found");
|
|
@@ -229,7 +229,7 @@ test("file: missing file, a directory and an id minted for a path that never exi
|
|
|
229
229
|
});
|
|
230
230
|
|
|
231
231
|
test("file: a file above the limit is payload_too_large WITHOUT being read (3 GiB sparse file answered at once)", async () => {
|
|
232
|
-
await withRig({ dlp: "
|
|
232
|
+
await withRig({ dlp: "enforce" }, async (rig) => {
|
|
233
233
|
write(rig.root(), "huge.bin", "");
|
|
234
234
|
fs.truncateSync(path.join(rig.root(), "huge.bin"), 3 * 1024 ** 3);
|
|
235
235
|
const t0 = Date.now();
|
|
@@ -238,7 +238,7 @@ test("file: a file above the limit is payload_too_large WITHOUT being read (3 Gi
|
|
|
238
238
|
assert.ok(Date.now() - t0 < 1500);
|
|
239
239
|
write(rig.root(), "over.txt", Buffer.alloc(rig.config.maxBytes + 1, 0x61));
|
|
240
240
|
assert.equal((await rig.request({ ref: rig.mint("file", "over.txt") })).errorCode, "payload_too_large");
|
|
241
|
-
const small = await setup({ dlp: "
|
|
241
|
+
const small = await setup({ dlp: "enforce", env: { LOCAL_AGENT_CONTENT_MAX_BYTES: "4096" } });
|
|
242
242
|
try {
|
|
243
243
|
write(small.root(), "f.txt", Buffer.alloc(4097, 0x61));
|
|
244
244
|
assert.equal((await small.request({ ref: small.mint("file", "f.txt") })).errorCode, "payload_too_large");
|
|
@@ -248,19 +248,16 @@ test("file: a file above the limit is payload_too_large WITHOUT being read (3 Gi
|
|
|
248
248
|
|
|
249
249
|
// ------------------------------------------------------------------------------------------ binary
|
|
250
250
|
|
|
251
|
-
test("binary:
|
|
251
|
+
test("binary: in enforce it cannot be inspected and is withheld (forbidden); invalid UTF-8 counts as binary", async () => {
|
|
252
252
|
const bin = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 0, 0, 1]), crypto.randomBytes(5000)]);
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
assert.equal(l.status, want, "invalid UTF-8 is binary for the DLP");
|
|
262
|
-
});
|
|
263
|
-
}
|
|
253
|
+
await withRig({ dlp: "enforce" }, async (rig) => {
|
|
254
|
+
write(rig.root(), "logo.png", bin);
|
|
255
|
+
write(rig.root(), "latin1.txt", Buffer.from([0x63, 0x61, 0x66, 0xe9, 0xff, 0xfe]));
|
|
256
|
+
const r = await rig.request({ ref: rig.mint("file", "logo.png") });
|
|
257
|
+
assert.equal(r.status, "forbidden");
|
|
258
|
+
assert.equal(r.body.length, 0);
|
|
259
|
+
assert.equal((await rig.request({ ref: rig.mint("file", "latin1.txt") })).status, "forbidden");
|
|
260
|
+
});
|
|
264
261
|
});
|
|
265
262
|
|
|
266
263
|
// ------------------------------------------------------------------------------------------ DLP
|
|
@@ -303,23 +300,17 @@ test("DLP enforce: a secret that would straddle two chunks is found (the whole t
|
|
|
303
300
|
});
|
|
304
301
|
});
|
|
305
302
|
|
|
306
|
-
test("DLP warn
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
test("DLP off: the content goes as read; the agent warns once at install time (see the install test), the audit says dlpMode off", async () => {
|
|
318
|
-
await withRig({ dlp: "off" }, async (rig) => {
|
|
319
|
-
write(rig.root(), "c.txt", `k=${GH}\n`);
|
|
320
|
-
assert.equal((await rig.request({ ref: rig.mint("file", "c.txt") })).text, `k=${GH}\n`);
|
|
321
|
-
assert.equal(rig.handler.audit.tail(1)[0].dlpMode, "off");
|
|
322
|
-
});
|
|
303
|
+
test("DLP warn and off (F3-07b): no content is served at all, the answer is protocol_unsupported and nothing was read", async () => {
|
|
304
|
+
for (const dlp of ["warn", "off"]) {
|
|
305
|
+
await withRig({ dlp }, async (rig) => {
|
|
306
|
+
write(rig.root(), "c.txt", `k=${GH}\n`);
|
|
307
|
+
const r = await rig.request({ ref: rig.mint("file", "c.txt") });
|
|
308
|
+
assert.deepEqual([r.status, r.errorCode, r.body.length], ["error", "protocol_unsupported", 0], dlp);
|
|
309
|
+
assert.equal(r.text.includes(GH), false);
|
|
310
|
+
const rec = rig.handler.audit.tail(1)[0];
|
|
311
|
+
assert.deepEqual([rec.status, rec.errorCode, rec.bytes], ["error", "protocol_unsupported", 0]);
|
|
312
|
+
});
|
|
313
|
+
}
|
|
323
314
|
});
|
|
324
315
|
|
|
325
316
|
test("DLP enforce: a hit in LOCAL_AGENT_DLP_BLOCK_CLASSES withholds the whole file (forbidden, no body)", async () => {
|
|
@@ -351,13 +342,16 @@ test("DLP enforce: masking that makes the text grow past the limit is payload_to
|
|
|
351
342
|
});
|
|
352
343
|
});
|
|
353
344
|
|
|
354
|
-
test("DLP: the mode is read from LOCAL_AGENT_DLP_MODE at each request
|
|
355
|
-
await withRig({ dlp: "
|
|
345
|
+
test("DLP: the mode is read from LOCAL_AGENT_DLP_MODE at each request; leaving enforce stops serving at once (no opt-out), going back resumes", async () => {
|
|
346
|
+
await withRig({ dlp: "enforce" }, async (rig) => {
|
|
356
347
|
write(rig.root(), "c.txt", `k=${GH}\n`);
|
|
357
348
|
const id = rig.mint("file", "c.txt");
|
|
358
|
-
assert.equal((await rig.request({ ref: id })).text.includes(GH), true);
|
|
359
|
-
rig.env.LOCAL_AGENT_DLP_MODE = "enforce";
|
|
360
349
|
assert.equal((await rig.request({ ref: id })).text.includes(GH), false);
|
|
350
|
+
rig.env.LOCAL_AGENT_DLP_MODE = "off";
|
|
351
|
+
const off = await rig.request({ ref: id });
|
|
352
|
+
assert.deepEqual([off.status, off.errorCode, off.text], ["error", "protocol_unsupported", ""]);
|
|
353
|
+
rig.env.LOCAL_AGENT_DLP_MODE = "enforce";
|
|
354
|
+
assert.equal((await rig.request({ ref: id })).status, "ok");
|
|
361
355
|
});
|
|
362
356
|
});
|
|
363
357
|
|
|
@@ -388,7 +382,7 @@ test("every other kind of the allowlist answers `error` + protocol_unsupported (
|
|
|
388
382
|
});
|
|
389
383
|
|
|
390
384
|
test("an id minted for a file is not valid as a diff, and the other way round", async () => {
|
|
391
|
-
await withRig({ dlp: "
|
|
385
|
+
await withRig({ dlp: "enforce" }, async (rig) => {
|
|
392
386
|
write(rig.root(), "a.txt", "x");
|
|
393
387
|
assert.equal((await rig.request({ kind: "diff", ref: rig.mint("file", "a.txt") })).status, "not_found");
|
|
394
388
|
assert.equal((await rig.request({ kind: "file", ref: rig.mint("diff", null) })).status, "not_found");
|
|
@@ -433,7 +427,7 @@ test("diff: `worktree` is the work tree against HEAD; sensitive files are left o
|
|
|
433
427
|
});
|
|
434
428
|
|
|
435
429
|
test("diff: a minted id can ask for one file; a sensitive path cannot be minted; an unchanged file is an empty ok", async () => {
|
|
436
|
-
await withRig({ dlp: "
|
|
430
|
+
await withRig({ dlp: "enforce" }, async (rig) => {
|
|
437
431
|
repoWithChanges(rig.root());
|
|
438
432
|
const one = await rig.request({ kind: "diff", ref: rig.mint("diff", "src/app.js") });
|
|
439
433
|
assert.equal(one.status, "ok");
|
|
@@ -448,7 +442,7 @@ test("diff: a minted id can ask for one file; a sensitive path cannot be minted;
|
|
|
448
442
|
});
|
|
449
443
|
|
|
450
444
|
test("diff: not a repository, or a repository without commits -> not_found; another workspace's id does not diff this one", async () => {
|
|
451
|
-
await withRig({ dlp: "
|
|
445
|
+
await withRig({ dlp: "enforce" }, async (rig) => {
|
|
452
446
|
write(rig.root("ws-1"), "a.txt", "x");
|
|
453
447
|
assert.equal((await rig.request({ kind: "diff", ref: "worktree" })).status, "not_found");
|
|
454
448
|
git(rig.root("ws-1"), "init", "-q", "-b", "main");
|
|
@@ -461,7 +455,7 @@ test("diff: not a repository, or a repository without commits -> not_found; anot
|
|
|
461
455
|
});
|
|
462
456
|
|
|
463
457
|
test("diff: a workspace inside a bigger repository only shows its own subtree", async () => {
|
|
464
|
-
await withRig({ dlp: "
|
|
458
|
+
await withRig({ dlp: "enforce" }, async (rig) => {
|
|
465
459
|
const top = rig.root();
|
|
466
460
|
write(top, "pkg/in.txt", "1\n"); write(top, "outside.txt", "1\n");
|
|
467
461
|
git(top, "init", "-q", "-b", "main"); git(top, "add", "-A"); git(top, "commit", "-q", "-m", "i");
|
|
@@ -475,7 +469,7 @@ test("diff: a workspace inside a bigger repository only shows its own subtree",
|
|
|
475
469
|
});
|
|
476
470
|
|
|
477
471
|
test("diff: repository config cannot run anything (fsmonitor, external diff, textconv) and GIT_DIR in the agent's env is ignored", async () => {
|
|
478
|
-
await withRig({ dlp: "
|
|
472
|
+
await withRig({ dlp: "enforce" }, async (rig) => {
|
|
479
473
|
const root = rig.root();
|
|
480
474
|
repoWithChanges(root);
|
|
481
475
|
const marker = path.join(tmpDir(), "PWNED"); rig.dirs.push(path.dirname(marker));
|
|
@@ -498,7 +492,7 @@ test("diff: repository config cannot run anything (fsmonitor, external diff, tex
|
|
|
498
492
|
});
|
|
499
493
|
|
|
500
494
|
test("diff: above the size limit is payload_too_large (git is stopped while it streams)", async () => {
|
|
501
|
-
await withRig({ dlp: "
|
|
495
|
+
await withRig({ dlp: "enforce", env: { LOCAL_AGENT_CONTENT_MAX_BYTES: "20000" } }, async (rig) => {
|
|
502
496
|
write(rig.root(), "big.txt", "a\n");
|
|
503
497
|
git(rig.root(), "init", "-q", "-b", "main"); git(rig.root(), "add", "-A"); git(rig.root(), "commit", "-q", "-m", "i");
|
|
504
498
|
write(rig.root(), "big.txt", "change number x\n".repeat(5000));
|
|
@@ -510,7 +504,7 @@ test("diff: above the size limit is payload_too_large (git is stopped while it s
|
|
|
510
504
|
// ------------------------------------------------------------------------------------------ cancellation, limits, backpressure
|
|
511
505
|
|
|
512
506
|
test("cancel in the middle of a stream: no further chunk is read or sent, the slot and the memory come back, the audit says cancelled", async () => {
|
|
513
|
-
await withRig({ dlp: "
|
|
507
|
+
await withRig({ dlp: "enforce" }, async (rig) => {
|
|
514
508
|
write(rig.root(), "big.txt", Buffer.alloc(rig.config.maxBytes, 0x61));
|
|
515
509
|
const requestId = uuid();
|
|
516
510
|
rig.cp.silent = true; // the CP never acks: 8 chunks fit the window, the rest waits (backpressure)
|
|
@@ -534,7 +528,7 @@ test("cancel in the middle of a stream: no further chunk is read or sent, the sl
|
|
|
534
528
|
});
|
|
535
529
|
|
|
536
530
|
test("the request deadline (LOCAL_AGENT_CONTENT_TOTAL_MS) stops a stream the CP does not consume", async () => {
|
|
537
|
-
await withRig({ dlp: "
|
|
531
|
+
await withRig({ dlp: "enforce", env: { LOCAL_AGENT_CONTENT_TOTAL_MS: "1000" } }, async (rig) => {
|
|
538
532
|
write(rig.root(), "big.txt", Buffer.alloc(rig.config.maxBytes, 0x61));
|
|
539
533
|
rig.cp.silent = true;
|
|
540
534
|
const requestId = uuid();
|
|
@@ -548,7 +542,7 @@ test("the request deadline (LOCAL_AGENT_CONTENT_TOTAL_MS) stops a stream the CP
|
|
|
548
542
|
});
|
|
549
543
|
|
|
550
544
|
test("backpressure: with a slow consumer the window of the session holds (at most 8 chunks unacked) and the file still arrives intact", async () => {
|
|
551
|
-
await withRig({ dlp: "
|
|
545
|
+
await withRig({ dlp: "enforce", cp: {} }, async (rig) => {
|
|
552
546
|
const data = crypto.randomBytes(Math.floor(rig.config.maxBytes / 2)).toString("base64").slice(0, Math.floor(rig.config.maxBytes / 2));
|
|
553
547
|
write(rig.root(), "big.txt", data);
|
|
554
548
|
rig.cp.ackDelayMs = 20;
|
|
@@ -563,7 +557,7 @@ test("backpressure: with a slow consumer the window of the session holds (at mos
|
|
|
563
557
|
});
|
|
564
558
|
|
|
565
559
|
test("limits: above LOCAL_AGENT_CONTENT_MAX_CONCURRENT the extra request gets rate_limited (a code, no content); the first one is not disturbed", async () => {
|
|
566
|
-
await withRig({ dlp: "
|
|
560
|
+
await withRig({ dlp: "enforce", env: { LOCAL_AGENT_CONTENT_MAX_CONCURRENT: "1", LOCAL_AGENT_CONTENT_MAX_INFLIGHT_BYTES: String(64 * 1024 * 1024) } }, async (rig) => {
|
|
567
561
|
write(rig.root(), "big.txt", Buffer.alloc(rig.config.maxBytes, 0x61));
|
|
568
562
|
write(rig.root(), "small.txt", "s");
|
|
569
563
|
rig.cp.ackDelayMs = 40;
|
|
@@ -581,7 +575,7 @@ test("limits: above LOCAL_AGENT_CONTENT_MAX_CONCURRENT the extra request gets ra
|
|
|
581
575
|
});
|
|
582
576
|
|
|
583
577
|
test("limits: the memory reservation (LOCAL_AGENT_CONTENT_MAX_INFLIGHT_BYTES) is enforced too", async () => {
|
|
584
|
-
await withRig({ dlp: "
|
|
578
|
+
await withRig({ dlp: "enforce", env: { LOCAL_AGENT_CONTENT_MAX_BYTES: "4096", LOCAL_AGENT_CONTENT_MAX_INFLIGHT_BYTES: "16384", LOCAL_AGENT_CONTENT_MAX_CONCURRENT: "8" } }, async (rig) => {
|
|
585
579
|
assert.equal(rig.config.maxInFlightBytes, 16384); // each request reserves 2 x 4096: two fit
|
|
586
580
|
write(rig.root(), "a.txt", "a".repeat(4096));
|
|
587
581
|
rig.cp.ackDelayMs = 100;
|
|
@@ -625,7 +619,7 @@ test("nothing but codes in errors, audit and logs: no path, no ref, no content,
|
|
|
625
619
|
});
|
|
626
620
|
|
|
627
621
|
test("a request without requestId is refused with a rejected ack, not left hanging", async () => {
|
|
628
|
-
await withRig({ dlp: "
|
|
622
|
+
await withRig({ dlp: "enforce" }, async (rig) => {
|
|
629
623
|
const before = rig.handler.stats.received;
|
|
630
624
|
assert.deepEqual(rig.handler.handle({ params: { kind: "file", ref: "x" }, workspaceId: "ws-1" }), { reject: "schema_violation" });
|
|
631
625
|
assert.equal(rig.handler.stats.received, before + 1);
|
|
@@ -41,7 +41,7 @@ test("deny list: secrets by name, keys, credentials, VCS internals (case-insensi
|
|
|
41
41
|
});
|
|
42
42
|
|
|
43
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/
|
|
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/credential-store.js", "src/kubernetes.js", "docs/kube-notes.md"]) {
|
|
45
45
|
assert.equal(isSensitivePath(p), false, p);
|
|
46
46
|
}
|
|
47
47
|
});
|
|
@@ -156,12 +156,17 @@ test("readRegular: size cap before reading, exact bytes, the file swapped for a
|
|
|
156
156
|
|
|
157
157
|
// ------------------------------------------------------------------------------------------ ids
|
|
158
158
|
|
|
159
|
-
test("ids:
|
|
159
|
+
test("ids (F3-07b): 43 base64url chars, no prefix, 256 random bits, not derived from the path, a fresh id per mint; the F3-07 form cr1_+22 is gone", () => {
|
|
160
160
|
const reg = new ContentRefRegistry({ tenantId: "t1" });
|
|
161
161
|
const a = reg.mint({ workspaceId: "w", root: "/r", kind: "file", rel: "src/a.txt" });
|
|
162
162
|
const b = reg.mint({ workspaceId: "w", root: "/r", kind: "file", rel: "src/a.txt" });
|
|
163
163
|
assert.match(a.id, REF_PATTERN);
|
|
164
|
-
assert.
|
|
164
|
+
assert.match(a.id, /^[A-Za-z0-9_-]{43}$/);
|
|
165
|
+
assert.equal(a.id.length, 43);
|
|
166
|
+
assert.equal(a.id.startsWith("cr1_"), false);
|
|
167
|
+
assert.equal(Buffer.from(a.id, "base64url").length, 32, "32 random bytes");
|
|
168
|
+
assert.equal(reg.resolve("cr1_" + "A".repeat(22), { workspaceId: "w", root: "/r", kind: "file" }), null);
|
|
169
|
+
assert.equal(REF_PATTERN.test("worktree"), false, "the symbolic ref is not an id");
|
|
165
170
|
assert.notEqual(a.id, b.id);
|
|
166
171
|
assert.equal(a.id.includes("a.txt") || a.id.includes("src"), false);
|
|
167
172
|
const ids = new Set(Array.from({ length: 2000 }, () => reg.mint({ workspaceId: "w", root: "/r", kind: "file", rel: "x" }).id));
|
|
@@ -171,13 +176,13 @@ test("ids: 128 random bits, not derived from the path, a fresh id per mint", ()
|
|
|
171
176
|
test("ids: resolve only for the same tenant, workspace, root and kind; anything else is null (indistinguishable from unknown)", () => {
|
|
172
177
|
const reg = new ContentRefRegistry({ tenantId: "t1" });
|
|
173
178
|
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" });
|
|
179
|
+
assert.deepEqual(reg.resolve(id, { workspaceId: "w1", root: "/r1", kind: "file" }), { rel: "src/a.txt", after: null });
|
|
175
180
|
assert.equal(reg.resolve(id, { workspaceId: "w2", root: "/r1", kind: "file" }), null, "other workspace");
|
|
176
181
|
assert.equal(reg.resolve(id, { workspaceId: "w1", root: "/r2", kind: "file" }), null, "same workspace id, re-opened on another root");
|
|
177
182
|
assert.equal(reg.resolve(id, { workspaceId: "w1", root: "/r1", kind: "diff" }), null, "other kind");
|
|
178
183
|
const other = new ContentRefRegistry({ tenantId: "t2" });
|
|
179
184
|
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, {}]) {
|
|
185
|
+
for (const junk of ["", "x", "cr1_", "cr1_short", "worktree", "cr1_" + "A".repeat(22), `${id}0`, `${id.slice(0, -1)}!`, "../etc/passwd", "/etc/passwd", "src/a.txt", "%2e%2e", "a\u0000b", 42, null, undefined, {}]) {
|
|
181
186
|
assert.equal(reg.resolve(junk, { workspaceId: "w1", root: "/r1", kind: "file" }), null);
|
|
182
187
|
}
|
|
183
188
|
});
|
|
@@ -40,9 +40,9 @@ test("config: every number is clamped to a sane range; a bad value falls back to
|
|
|
40
40
|
|
|
41
41
|
// ------------------------------------------------------------------------------------------ in process (runtime.js)
|
|
42
42
|
|
|
43
|
-
async function linkFor(cp, { env, root, tenantWs } = {}) {
|
|
43
|
+
async function linkFor(cp, { env, root, tenantWs, errors = [] } = {}) {
|
|
44
44
|
const logs = [];
|
|
45
|
-
const log = { info: (m) => logs.push(m), log: (m) => logs.push(m), warn: (m) => logs.push(m) };
|
|
45
|
+
const log = { info: (m) => logs.push(m), log: (m) => logs.push(m), warn: (m) => logs.push(m), error: (m) => errors.push(m) };
|
|
46
46
|
const wsManager = { list: () => [{ id: "ws-1", root, tenantId: tenantWs ?? TENANT }] };
|
|
47
47
|
const link = await buildV2Link({ tenantId: TENANT, agentId: AGENT, signing: signingFor({ keyId: cp.keyId, publicKeyRaw: cp.publicKeyRaw }, "enforce"), wsManager,
|
|
48
48
|
getWorkspaceRoot: () => root, log, env });
|
|
@@ -60,7 +60,7 @@ test("flag off (unset or any non-true value): content.request is acked `rejected
|
|
|
60
60
|
const { link } = await linkFor(cp, { root, env: { LOCAL_AGENT_CONTENT_HANDLERS: v } });
|
|
61
61
|
assert.equal(link.content, undefined);
|
|
62
62
|
assert.deepEqual(cp.registers[0].capabilities, ["chunk.base64", "protocol.v2", "v1.commands"]);
|
|
63
|
-
cp.sendData("content.request", { kind: "file", ref: "
|
|
63
|
+
cp.sendData("content.request", { kind: "file", ref: "A".repeat(43), actor: ACTOR }, { workspaceId: "ws-1", requestId: uuid() });
|
|
64
64
|
await waitFor(() => cp.acksFromLa.length > 0);
|
|
65
65
|
assert.deepEqual(cp.acksFromLa[0], { ackSeq: 1, status: "rejected", errorCode: "protocol_unsupported" });
|
|
66
66
|
assert.equal(cp.received.filter((f) => f.type === "content.response").length, 0);
|
|
@@ -69,14 +69,14 @@ 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
|
|
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
|
-
assert.match(m.id, /^
|
|
79
|
+
assert.match(m.id, /^[A-Za-z0-9_-]{43}$/);
|
|
80
80
|
assert.equal(link.content.mintRef({ workspaceId: "ws-1", kind: "file", rel: ".env" }), null);
|
|
81
81
|
assert.equal(link.content.mintRef({ workspaceId: "ws-unknown", kind: "file", rel: "src/a.txt" }), null);
|
|
82
82
|
const requestId = uuid();
|
|
@@ -88,13 +88,38 @@ test("flag on: the handler is installed, the register announces content.file and
|
|
|
88
88
|
fs.rmSync(root, { recursive: true, force: true });
|
|
89
89
|
});
|
|
90
90
|
|
|
91
|
-
test("flag on
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
91
|
+
test("flag on x DLP mode (F3-07b): only enforce registers the handlers; off, warn, unset and unknown values do not (ERROR at startup, no content capability, protocol_unsupported)", async () => {
|
|
92
|
+
for (const mode of [undefined, "off", "warn", "enforce-ish", "ENFORCE"]) {
|
|
93
|
+
const root = tmpDir(); write(root, "a.txt", "x");
|
|
94
|
+
const cp = new ReferenceCp();
|
|
95
|
+
const errors = [];
|
|
96
|
+
const env = { LOCAL_AGENT_CONTENT_HANDLERS: "true", ...(mode === undefined ? {} : { LOCAL_AGENT_DLP_MODE: mode }) };
|
|
97
|
+
const { link, logs } = await linkFor(cp, { root, env, errors });
|
|
98
|
+
const enforce = mode === "ENFORCE"; // normalizeMode of the DLP module is case-insensitive: the agent and the DLP agree on what enforce means
|
|
99
|
+
assert.equal(!!link.content, enforce, String(mode));
|
|
100
|
+
assert.equal(cp.registers.at(-1).capabilities.includes("content.file"), enforce, String(mode));
|
|
101
|
+
assert.equal(cp.registers.at(-1).capabilities.includes("content.diff"), enforce, String(mode));
|
|
102
|
+
if (!enforce) {
|
|
103
|
+
assert.ok(errors.some((l) => l.includes("ERROR") && l.includes("LOCAL_AGENT_DLP_MODE") && l.includes("NOT registered")), String(mode));
|
|
104
|
+
const requestId = uuid();
|
|
105
|
+
cp.sendData("content.request", { kind: "diff", ref: "worktree", actor: ACTOR }, { workspaceId: "ws-1", requestId });
|
|
106
|
+
await waitFor(() => cp.acksFromLa.some((a) => a.status === "rejected"));
|
|
107
|
+
assert.deepEqual(cp.acksFromLa.filter((a) => a.status === "rejected").map((a) => a.errorCode), ["protocol_unsupported"], String(mode));
|
|
108
|
+
assert.equal(cp.received.some((f) => f.type === "content.response"), false, "no content.response, no file read");
|
|
109
|
+
assert.equal(logs.concat(errors).some((l) => l.includes(root)), false, "no path in the logs");
|
|
110
|
+
}
|
|
111
|
+
link.close();
|
|
112
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("installContentHandlers itself refuses to install without DLP enforce (defence in depth, no opt-out)", async () => {
|
|
117
|
+
const { installContentHandlers } = await import("../../src/la-protocol-v2/content-handlers/install.js");
|
|
118
|
+
const cfg = resolveContentHandlersConfig({ LOCAL_AGENT_CONTENT_HANDLERS: "true" }, () => {});
|
|
119
|
+
for (const mode of [undefined, "off", "warn"]) {
|
|
120
|
+
assert.throws(() => installContentHandlers({ link: { session: {}, close() {} }, tenantId: TENANT, wsManager: null, getWorkspaceRoot: () => null, config: cfg,
|
|
121
|
+
env: mode ? { LOCAL_AGENT_DLP_MODE: mode } : {} }), /DLP_MODE=enforce/);
|
|
122
|
+
}
|
|
98
123
|
});
|
|
99
124
|
|
|
100
125
|
test("flag on: a workspace of another tenant (per the agent's own registry) is not served", async () => {
|
|
@@ -113,7 +138,7 @@ test("flag on: a workspace of another tenant (per the agent's own registry) is n
|
|
|
113
138
|
test("closing the link while a stream is in flight cancels it (no work left behind)", async () => {
|
|
114
139
|
const root = tmpDir(); write(root, "big.txt", Buffer.alloc(900_000, 0x61));
|
|
115
140
|
const cp = new ReferenceCp();
|
|
116
|
-
const { link } = await linkFor(cp, { root, env: { LOCAL_AGENT_CONTENT_HANDLERS: "true", LOCAL_AGENT_DLP_MODE: "
|
|
141
|
+
const { link } = await linkFor(cp, { root, env: { LOCAL_AGENT_CONTENT_HANDLERS: "true", LOCAL_AGENT_DLP_MODE: "enforce" } });
|
|
117
142
|
const id = link.content.mintRef({ workspaceId: "ws-1", kind: "file", rel: "big.txt" }).id;
|
|
118
143
|
cp.silent = true;
|
|
119
144
|
cp.sendData("content.request", { kind: "file", ref: id, actor: ACTOR }, { workspaceId: "ws-1", requestId: uuid() });
|
|
@@ -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 (
|
|
77
|
-
assert.equal(valid + invalid,
|
|
78
|
-
assert.equal(valid,
|
|
79
|
-
assert.equal(invalid,
|
|
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
|
|
@@ -99,7 +109,7 @@ test("error codes: fatal and retryable sets mirror the gateway (LaV2ErrorCode)",
|
|
|
99
109
|
|
|
100
110
|
test("allowlist: the LA receives only cp->la/both types and sends only la->cp/both types; v1 commands are not types", () => {
|
|
101
111
|
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"]);
|
|
112
|
+
assert.deepEqual([...contract.outboundTypes].sort(), ["ack", "alert", "audit.event", "content.index", "content.response", "error", "heartbeat", "hello", "incident.candidate", "job.event", "job.result", "log.stats", "register", "scan.uploaded"]);
|
|
103
113
|
for (const v1 of ["mcp.execute-command", "workspace.open", "health", "shell.exec", "__proto__", "constructor", "toString"]) {
|
|
104
114
|
assert.equal(contract.canReceive(v1), false, v1);
|
|
105
115
|
assert.equal(contract.canSend(v1), false, v1);
|
|
@@ -52,6 +52,8 @@ export class ReferenceCp {
|
|
|
52
52
|
this.rawIn = [];
|
|
53
53
|
this.dropAcks = 0; // do not ack the next n accepted LA data frames (simulates lost acks)
|
|
54
54
|
this.silent = false; // do not ack anything
|
|
55
|
+
this.rejectData = []; // F3-07b: error codes, consumed one per LA data frame: the frame is acked `rejected` with that code (not stored)
|
|
56
|
+
this.rejectedFrames = []; // [type, code] of the frames acked `rejected`
|
|
55
57
|
this.ackDelayMs = 0; // answer acks late (slow consumer)
|
|
56
58
|
this.onReceived = () => {};
|
|
57
59
|
this.contentRequests = new Map(); // requestId -> {chunks:[], done}
|
|
@@ -167,6 +169,12 @@ export class ReferenceCp {
|
|
|
167
169
|
this.ring.set(seq, msgId);
|
|
168
170
|
while (this.ring.size > 512) this.ring.delete(this.ring.keys().next().value);
|
|
169
171
|
if (m.ack !== undefined) this.#ackUpTo(m.ack);
|
|
172
|
+
if (this.rejectData.length > 0) { // the gateway's non-fatal answer: the seq is consumed, the frame is not stored
|
|
173
|
+
const code = this.rejectData.shift();
|
|
174
|
+
this.rejectedFrames.push([m.type, code]);
|
|
175
|
+
this.#sendAck(seq, "rejected", code);
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
170
178
|
this.received.push(m);
|
|
171
179
|
if (m.type === "content.response") {
|
|
172
180
|
const c = this.contentRequests.get(m.requestId) || { chunks: [], done: false };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// F3-05b: the
|
|
1
|
+
// F3-05b (+2 of F3-07b, content.index): the 26 whole-session traces of the contract (F3-22), reproduced in Node. "Traces are language
|
|
2
2
|
// neutral; the LA must reproduce each expect.error." Three things are shown for them:
|
|
3
3
|
// 1. the reference CP (test helper) reproduces every trace: same error at the same frame (so the model the
|
|
4
4
|
// other tests use is the model of the contract);
|
|
@@ -18,9 +18,9 @@ import { V2Session } from "../../src/la-protocol-v2/session.js";
|
|
|
18
18
|
const DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "../contract/la-protocol-v2/vectors/traces");
|
|
19
19
|
const traces = fs.readdirSync(DIR).filter((f) => f.endsWith(".json")).sort().map((f) => ({ file: f, name: f.replace(".json", ""), ...JSON.parse(fs.readFileSync(path.join(DIR, f), "utf8")) }));
|
|
20
20
|
|
|
21
|
-
test("the suite has all
|
|
22
|
-
assert.equal(traces.length,
|
|
23
|
-
assert.equal(traces.filter((t) => t.expect.ok).length,
|
|
21
|
+
test("the suite has all 26 traces (21 invalid, 5 valid)", () => {
|
|
22
|
+
assert.equal(traces.length, 26);
|
|
23
|
+
assert.equal(traces.filter((t) => t.expect.ok).length, 5);
|
|
24
24
|
});
|
|
25
25
|
|
|
26
26
|
const textOf = (fr) => {
|
|
@@ -223,6 +223,31 @@ test("valid-content-chunks-in-order: the three chunks of the trace go out in ord
|
|
|
223
223
|
env.session.close();
|
|
224
224
|
});
|
|
225
225
|
|
|
226
|
+
test("valid-content-index: the two inventories of the trace go out in order after ready and the CP accepts them", async () => {
|
|
227
|
+
const t = traces.find((x) => x.name === "valid-content-index");
|
|
228
|
+
const cp = new ReferenceCp({ tenantId: t.session.tenantId, agentId: t.session.agentId });
|
|
229
|
+
const env = inMemory(cp, { tenantId: t.session.tenantId, agentId: t.session.agentId }).start();
|
|
230
|
+
await waitFor(() => env.session.state === "ready");
|
|
231
|
+
for (const fr of LA_FRAMES(t).filter((f) => f.frame.type === "content.index")) {
|
|
232
|
+
await env.session.send("content.index", fr.frame.payload, { workspaceId: fr.frame.workspaceId });
|
|
233
|
+
}
|
|
234
|
+
assert.deepEqual(cp.received.map((f) => [f.type, f.seq]), [["content.index", 1], ["content.index", 2]]);
|
|
235
|
+
assert.deepEqual(cp.errorsSent, []);
|
|
236
|
+
env.session.close();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("invalid-content-index-before-ready: the LA never produces it (no data before ready: not_ready, nothing written)", async () => {
|
|
240
|
+
const cp = new ReferenceCp();
|
|
241
|
+
const sent = [];
|
|
242
|
+
const env = inMemory(cp);
|
|
243
|
+
env.session.attach({ send: (x) => sent.push(JSON.parse(x)), close() {}, bufferedAmount: () => 0 });
|
|
244
|
+
const t = traces.find((x) => x.name === "invalid-content-index-before-ready");
|
|
245
|
+
const bad = t.frames[t.expect.atFrame].frame;
|
|
246
|
+
await assert.rejects(env.session.enqueue("content.index", bad.payload, { workspaceId: bad.workspaceId }), (e) => e.code === "not_ready");
|
|
247
|
+
assert.deepEqual(sent.map((f) => f.type), ["register", "hello"]);
|
|
248
|
+
env.session.close();
|
|
249
|
+
});
|
|
250
|
+
|
|
226
251
|
test("valid-retransmit-is-idempotent: an ack lost -> the same frame again -> the CP re-acks and counts one event", async () => {
|
|
227
252
|
const cp = new ReferenceCp({ limits: { ackTimeoutMs: 100 } });
|
|
228
253
|
const env = inMemory(cp).start();
|