deepdebug-local-agent 1.12.0 → 1.13.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 (47) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/docs/la-chat.md +35 -0
  3. package/package.json +1 -1
  4. package/src/dlp/rules.js +2 -2
  5. package/src/la-protocol-v2/chat/config.js +60 -0
  6. package/src/la-protocol-v2/chat/cursors.js +57 -0
  7. package/src/la-protocol-v2/chat/dlp.js +92 -0
  8. package/src/la-protocol-v2/chat/index.js +28 -0
  9. package/src/la-protocol-v2/chat/store.js +337 -0
  10. package/src/la-protocol-v2/chat/stream-source.js +23 -0
  11. package/src/la-protocol-v2/content-handlers/chat-session.js +41 -0
  12. package/src/la-protocol-v2/content-handlers/handler.js +10 -2
  13. package/src/la-protocol-v2/content-handlers/install.js +2 -2
  14. package/src/la-protocol-v2/content-handlers/job-stream.js +62 -0
  15. package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +13 -8
  16. package/src/la-protocol-v2/contract/README.md +42 -0
  17. package/src/la-protocol-v2/contract/chat.session.body.schema.json +47 -0
  18. package/src/la-protocol-v2/contract/index.json +1 -1
  19. package/src/la-protocol-v2/contract/job.assign.schema.json +3 -2
  20. package/src/la-protocol-v2/contract/job.stream.body.schema.json +49 -0
  21. package/src/la-protocol-v2/jobs/agent2/api-client.js +78 -51
  22. package/src/la-protocol-v2/jobs/agent2/config.js +1 -0
  23. package/src/la-protocol-v2/jobs/agent2/runner.js +92 -11
  24. package/src/la-protocol-v2/jobs/agent2/supervisor.js +2 -0
  25. package/src/la-protocol-v2/jobs/git-exec.js +5 -4
  26. package/src/la-protocol-v2/jobs/manager.js +4 -2
  27. package/src/la-protocol-v2/jobs/workspace-materializer.js +35 -12
  28. package/src/la-protocol-v2/runtime.js +32 -3
  29. package/src/workspace-manager.js +3 -2
  30. package/test/contract/la-protocol-v2/vectors/chat.session.body.vectors.json +10379 -0
  31. package/test/contract/la-protocol-v2/vectors/content.request.vectors.json +245 -0
  32. package/test/contract/la-protocol-v2/vectors/hello.vectors.json +112 -0
  33. package/test/contract/la-protocol-v2/vectors/job.assign.vectors.json +568 -3
  34. package/test/contract/la-protocol-v2/vectors/job.stream.body.vectors.json +4572 -0
  35. package/test/contract/la-protocol-v2/vectors/register.vectors.json +106 -0
  36. package/test/contract/la-protocol-v2/vectors/traces/valid-chat-job-and-stream.json +311 -0
  37. package/test/la-protocol-v2/chat-dlp-json.test.js +169 -0
  38. package/test/la-protocol-v2/chat-la.test.js +490 -0
  39. package/test/la-protocol-v2/chat-real-e2e.test.js +69 -0
  40. package/test/la-protocol-v2/chat-store.test.js +301 -0
  41. package/test/la-protocol-v2/contract-v21.test.js +2 -2
  42. package/test/la-protocol-v2/contract.test.js +6 -6
  43. package/test/la-protocol-v2/helpers/agent2-rig.js +2 -2
  44. package/test/la-protocol-v2/helpers/fake-agent2-cli.js +1 -0
  45. package/test/la-protocol-v2/helpers/fake-agent2.js +80 -5
  46. package/test/la-protocol-v2/traces.test.js +3 -3
  47. package/test/la-protocol-v2/workspace-materializer-hostile.test.js +105 -0
@@ -0,0 +1,301 @@
1
+ // LA-3: the ChatStore (disk, retention, no secrets, cursor of a long-poll), the cursor registry and the config. Pure units: no session, no agent2.
2
+ // The same pieces with the real link, the content handlers and a fake agent2 are in chat-la.test.js.
3
+ import { test } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import fs from "node:fs";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import crypto from "node:crypto";
9
+ import { resolveChatConfig, chatRequested } from "../../src/la-protocol-v2/chat/config.js";
10
+ import { createChatStore } from "../../src/la-protocol-v2/chat/store.js";
11
+ import { createCursorRegistry } from "../../src/la-protocol-v2/chat/cursors.js";
12
+ import { maskChatText, WITHHELD_LINE, WITHHELD_TEXT } from "../../src/la-protocol-v2/chat/dlp.js";
13
+
14
+ const TENANT = "3f2b8c1e-5a4d-4e7b-9c21-0a1b2c3d4e5f";
15
+ const GH = "ghp_" + "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8";
16
+ const PEM = "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n-----END RSA PRIVATE KEY-----";
17
+ const DLP_ENV = { LOCAL_AGENT_DLP_MODE: "enforce", LOCAL_AGENT_DLP_BLOCK_CLASSES: "private-key" };
18
+ const uuid = () => crypto.randomUUID();
19
+
20
+ function tmp() { return fs.mkdtempSync(path.join(os.tmpdir(), "la-chat-")); }
21
+ function mk({ env = {}, clock, isActive } = {}) {
22
+ const dir = tmp();
23
+ const full = { ...DLP_ENV, LOCAL_AGENT_CHAT: "true", LOCAL_AGENT_CHAT_DIR: dir, ...env };
24
+ const config = resolveChatConfig(full, () => {});
25
+ const logs = [];
26
+ const store = createChatStore({ dir, tenantId: TENANT, config, env: full, log: { info: (m) => logs.push(m), warn: (m) => logs.push(m), log: (m) => logs.push(m) }, ...(clock ? { now: clock } : {}), ...(isActive ? { isActive } : {}) });
27
+ return { dir, store, config, env: full, logs, stop: () => { store.close(); fs.rmSync(dir, { recursive: true, force: true }); } };
28
+ }
29
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
30
+ const line = (o) => JSON.stringify(o);
31
+
32
+ test("config: off by default; unrecognised stays off; numbers are clamped; 30 days of retention", () => {
33
+ assert.deepEqual(resolveChatConfig({}, () => {}), { enabled: false });
34
+ const warns = [];
35
+ assert.deepEqual(resolveChatConfig({ LOCAL_AGENT_CHAT: "maybe" }, (m) => warns.push(m)), { enabled: false });
36
+ assert.equal(warns.length, 1);
37
+ assert.equal(chatRequested({ LOCAL_AGENT_CHAT: "TRUE" }), true);
38
+ assert.equal(chatRequested({ LOCAL_AGENT_CHAT: "no" }), false);
39
+ const c = resolveChatConfig({ LOCAL_AGENT_CHAT: "true" }, () => {});
40
+ assert.equal(c.retentionMs, 30 * 86_400_000);
41
+ assert.equal(c.pollMs, 8_000);
42
+ assert.match(c.dir, /\.deepdebug[\\/]chat$/);
43
+ const x = resolveChatConfig({ LOCAL_AGENT_CHAT: "true", LOCAL_AGENT_CHAT_POLL_MS: "999999", LOCAL_AGENT_CHAT_RETENTION_DAYS: "0", LOCAL_AGENT_CHAT_PAGE_EVENTS: "5000", LOCAL_AGENT_CHAT_MAX_BYTES: "-1" }, () => {});
44
+ assert.equal(x.pollMs, 8_000, "the long-poll never exceeds the 8 s of the contract");
45
+ assert.equal(x.retentionMs, 30 * 86_400_000, "an invalid retention falls back to the default, not to forever");
46
+ assert.equal(x.pageEvents, 256, "above the contract's 512 per page falls back");
47
+ assert.equal(x.maxBytes, 256 * 1024 * 1024);
48
+ });
49
+
50
+ test("dlp: enforce only; a secret is masked, a blocked class is withheld, anything else is fail closed", () => {
51
+ const ok = maskChatText(`key ${GH}`, { env: DLP_ENV });
52
+ assert.equal(ok.ok, true);
53
+ assert.ok(!ok.text.includes(GH));
54
+ assert.match(ok.text, /\[REDACTED:/);
55
+ assert.equal(maskChatText(PEM, { env: DLP_ENV }).blocked, true);
56
+ assert.equal(maskChatText("hello", { env: { LOCAL_AGENT_DLP_MODE: "warn" } }).ok, false, "warn is not enough to let chat text out");
57
+ assert.equal(maskChatText("hello", { env: {} }).ok, false);
58
+ assert.equal(maskChatText("x".repeat(100), { env: { ...DLP_ENV, LOCAL_AGENT_DLP_MAX_BYTES: "10" } }).ok, false, "above the DLP limit: withheld, never let out");
59
+ assert.equal(maskChatText(42, { env: DLP_ENV }).ok, false);
60
+ assert.doesNotThrow(() => JSON.parse(WITHHELD_LINE));
61
+ assert.equal(JSON.parse(WITHHELD_LINE).type, "status");
62
+ });
63
+
64
+ test("store: files are 0600 in a 0700 folder, JSONL, and NO secret ever reaches the disk (prompt, events, answer)", async () => {
65
+ const t = mk();
66
+ try {
67
+ const id = uuid();
68
+ const rec = t.store.begin({ jobId: id, workspaceId: "ws-1", userText: `fix the login, token ${GH} CANARY-PROMPT` });
69
+ rec.append(line({ type: "token", content: `the key is ${GH} CANARY-TOKEN` }));
70
+ rec.append(line({ type: "tool_result", content: PEM }));
71
+ rec.append(line({ type: "summary", content: { answer: `answer ${GH} CANARY-ANSWER` } }));
72
+ rec.finish("done");
73
+ await t.store.flush();
74
+ const file = path.join(t.dir, TENANT, `${id}.jsonl`);
75
+ const text = fs.readFileSync(file, "utf8");
76
+ for (const secret of [GH, "BEGIN RSA PRIVATE KEY", "MIIEowIBAAKC"]) assert.ok(!text.includes(secret), `a secret reached the disk: ${secret.slice(0, 12)}`);
77
+ assert.ok(text.includes("CANARY-PROMPT") && text.includes("CANARY-ANSWER"), "the content itself is kept (it is the customer's conversation)");
78
+ assert.equal(fs.statSync(file).mode & 0o777, 0o600);
79
+ assert.equal(fs.statSync(path.join(t.dir, TENANT)).mode & 0o777, 0o700);
80
+ assert.equal(fs.statSync(t.dir).mode & 0o777, 0o700);
81
+ const recs = text.trim().split("\n").map((l) => JSON.parse(l));
82
+ assert.deepEqual(recs.map((r) => r.k), ["meta", "turn", "ev", "ev", "ev", "turn", "end"]);
83
+ assert.equal(recs[3].e, WITHHELD_LINE, "a blocked class (private key) is withheld as a whole");
84
+ const s = t.store.readSession({ conversationId: id, workspaceId: "ws-1" });
85
+ assert.deepEqual(s.turns.map((x) => x.role), ["user", "assistant"]);
86
+ assert.match(s.turns[1].text, /CANARY-ANSWER/);
87
+ assert.ok(!JSON.stringify(s).includes(GH));
88
+ assert.ok(!t.logs.join("\n").includes("CANARY"), "nothing of the content in the logs");
89
+ } finally { t.stop(); }
90
+ });
91
+
92
+ test("store: a file name can only be a UUID; a path-like id is not a conversation", () => {
93
+ const t = mk();
94
+ try {
95
+ assert.throws(() => t.store.begin({ jobId: "../../etc/passwd", workspaceId: "ws-1", userText: "x" }));
96
+ assert.throws(() => t.store.begin({ jobId: "not-a-uuid", workspaceId: "ws-1", userText: "x" }));
97
+ assert.throws(() => t.store.begin({ jobId: uuid(), workspaceId: "", userText: "x" }));
98
+ assert.deepEqual(fs.readdirSync(path.join(t.dir, TENANT)), []);
99
+ } finally { t.stop(); }
100
+ });
101
+
102
+ test("store: read after a restart (new store, same folder): the turns are there, the unfinished job is `interrupted` and its stream is done; another workspace sees nothing", async () => {
103
+ const t = mk();
104
+ try {
105
+ const a = uuid();
106
+ const b = uuid();
107
+ const ra = t.store.begin({ jobId: a, workspaceId: "ws-1", userText: "first question" });
108
+ ra.append(line({ type: "token", content: "answer one" }));
109
+ ra.finish("done");
110
+ const rb = t.store.begin({ jobId: b, workspaceId: "ws-1", userText: "second question" });
111
+ rb.append(line({ type: "token", content: "half an answer" })); // the agent dies here: no finish
112
+ await t.store.flush();
113
+ t.store.close();
114
+ const again = createChatStore({ dir: t.dir, tenantId: TENANT, config: t.config, env: t.env, log: console });
115
+ try {
116
+ const sa = again.readSession({ conversationId: a, workspaceId: "ws-1" });
117
+ assert.deepEqual(sa.turns.map((x) => [x.role, x.text]), [["user", "first question"], ["assistant", "answer one"]]);
118
+ const pa = await again.readStream({ conversationId: a, workspaceId: "ws-1", from: 0, waitMs: 0 });
119
+ assert.equal(pa.events.length, 1);
120
+ assert.equal(pa.done, true);
121
+ const pb = await again.readStream({ conversationId: b, workspaceId: "ws-1", from: 0, waitMs: 0 });
122
+ assert.equal(pb.status, "ok");
123
+ assert.equal(pb.done, true, "nobody is writing it any more: the stream closes instead of holding the caller forever");
124
+ assert.equal((await again.readStream({ conversationId: b, workspaceId: "ws-2", from: 0, waitMs: 0 })).status, "not_found", "a conversation belongs to its workspace");
125
+ assert.equal(again.readSession({ conversationId: b, workspaceId: "ws-2" }).status, "not_found");
126
+ assert.equal(again.readSession({ conversationId: uuid(), workspaceId: "ws-1" }).status, "not_found");
127
+ } finally { again.close(); }
128
+ } finally { t.stop(); }
129
+ });
130
+
131
+ test("store: a torn last line (crash while writing) is ignored, the rest is read", async () => {
132
+ const t = mk();
133
+ try {
134
+ const id = uuid();
135
+ const r = t.store.begin({ jobId: id, workspaceId: "ws-1", userText: "q" });
136
+ r.append(line({ type: "token", content: "ok" }));
137
+ r.finish("done");
138
+ await t.store.flush();
139
+ fs.appendFileSync(path.join(t.dir, TENANT, `${id}.jsonl`), '{"k":"ev","e":"{\\"type\\":\\"tok');
140
+ t.store.close();
141
+ const again = createChatStore({ dir: t.dir, tenantId: TENANT, config: t.config, env: t.env, log: console });
142
+ try { assert.equal((await again.readStream({ conversationId: id, workspaceId: "ws-1", from: 0, waitMs: 0 })).events.length, 1); } finally { again.close(); }
143
+ } finally { t.stop(); }
144
+ });
145
+
146
+ test("stream: events come in order with their position; a page is bounded; `from` past the end is not a position", async () => {
147
+ const t = mk({ env: { LOCAL_AGENT_CHAT_PAGE_EVENTS: "3" } });
148
+ try {
149
+ const id = uuid();
150
+ const r = t.store.begin({ jobId: id, workspaceId: "ws-1", userText: "q" });
151
+ for (let i = 0; i < 7; i++) r.append(line({ type: "token", content: `e${i}` }));
152
+ r.finish("done");
153
+ const p1 = await t.store.readStream({ conversationId: id, workspaceId: "ws-1", from: 0, waitMs: 0 });
154
+ assert.deepEqual(p1.events.map((e) => e.i), [0, 1, 2]);
155
+ assert.equal(p1.next, 3);
156
+ assert.equal(p1.done, false);
157
+ const p3 = await t.store.readStream({ conversationId: id, workspaceId: "ws-1", from: 6, waitMs: 0 });
158
+ assert.deepEqual(p3.events.map((e) => e.i), [6]);
159
+ assert.equal(p3.done, true);
160
+ const end = await t.store.readStream({ conversationId: id, workspaceId: "ws-1", from: 7, waitMs: 0 });
161
+ assert.deepEqual([end.events.length, end.done], [0, true]);
162
+ assert.equal((await t.store.readStream({ conversationId: id, workspaceId: "ws-1", from: 8, waitMs: 0 })).status, "not_found");
163
+ assert.equal((await t.store.readStream({ conversationId: id, workspaceId: "ws-1", from: -1, waitMs: 0 })).status, "not_found");
164
+ } finally { t.stop(); }
165
+ });
166
+
167
+ test("stream: the long-poll returns as soon as an event arrives, not at the end of the hold; an idle one returns empty with done=false after the hold", async () => {
168
+ const t = mk();
169
+ try {
170
+ const id = uuid();
171
+ const r = t.store.begin({ jobId: id, workspaceId: "ws-1", userText: "q" });
172
+ const t0 = Date.now();
173
+ setTimeout(() => r.append(line({ type: "token", content: "late" })), 60);
174
+ const p = await t.store.readStream({ conversationId: id, workspaceId: "ws-1", from: 0, waitMs: 3000 });
175
+ assert.equal(p.events.length, 1);
176
+ assert.ok(Date.now() - t0 < 1500, `woken by the event (${Date.now() - t0} ms)`);
177
+ const t1 = Date.now();
178
+ const idle = await t.store.readStream({ conversationId: id, workspaceId: "ws-1", from: 1, waitMs: 150 });
179
+ assert.deepEqual([idle.events.length, idle.done, idle.next], [0, false, 1]);
180
+ assert.ok(Date.now() - t1 >= 120, "the hold was honoured");
181
+ // finishing wakes a waiting poll with done=true
182
+ setTimeout(() => r.finish("done"), 50);
183
+ const last = await t.store.readStream({ conversationId: id, workspaceId: "ws-1", from: 1, waitMs: 3000 });
184
+ assert.deepEqual([last.events.length, last.done], [0, true]);
185
+ } finally { t.stop(); }
186
+ });
187
+
188
+ test("stream: an aborted request returns at once (the request was cancelled); a job that is alive but not begun yet is waited for, one that is not alive is not_found", async () => {
189
+ let alive = true;
190
+ const t = mk({ isActive: () => alive });
191
+ try {
192
+ const id = uuid();
193
+ const ac = new AbortController();
194
+ setTimeout(() => ac.abort(), 40);
195
+ const t0 = Date.now();
196
+ const a = await t.store.readStream({ conversationId: id, workspaceId: "ws-1", from: 0, waitMs: 5000, signal: ac.signal });
197
+ assert.equal(a.status, "aborted");
198
+ assert.ok(Date.now() - t0 < 1000);
199
+ alive = false;
200
+ assert.equal((await t.store.readStream({ conversationId: id, workspaceId: "ws-1", from: 0, waitMs: 200 })).status, "not_found", "nobody has this job");
201
+ alive = true;
202
+ setTimeout(() => t.store.begin({ jobId: id, workspaceId: "ws-1", userText: "q" }), 80);
203
+ const b = await t.store.readStream({ conversationId: id, workspaceId: "ws-1", from: 0, waitMs: 2000 });
204
+ assert.equal(b.status, "ok", "the job reached its runner while the request was held");
205
+ assert.equal(b.events.length, 0);
206
+ } finally { t.stop(); }
207
+ });
208
+
209
+ test("store: one conversation is capped (events beyond it are dropped, the chat is truncated); the page is bounded in bytes", async () => {
210
+ const t = mk({ env: { LOCAL_AGENT_CHAT_MAX_CONVERSATION_BYTES: String(16 * 1024), LOCAL_AGENT_CHAT_PAGE_BYTES: String(16 * 1024) } });
211
+ try {
212
+ const id = uuid();
213
+ const r = t.store.begin({ jobId: id, workspaceId: "ws-1", userText: "q" });
214
+ let kept = 0;
215
+ for (let i = 0; i < 20; i++) if (r.append(line({ type: "token", content: "x".repeat(2000) }))) kept++;
216
+ r.finish("done");
217
+ assert.ok(kept >= 5 && kept < 20, `kept ${kept}`);
218
+ assert.equal(t.store.stats.truncated, 1);
219
+ assert.equal(t.store.readSession({ conversationId: id, workspaceId: "ws-1" }).truncated, true);
220
+ const p = await t.store.readStream({ conversationId: id, workspaceId: "ws-1", from: 0, waitMs: 0 });
221
+ assert.equal(p.truncated, true);
222
+ assert.ok(p.events.length <= kept);
223
+ } finally { t.stop(); }
224
+ });
225
+
226
+ test("retention: older than the window goes, newer stays; a conversation being written is never swept; the ceiling removes the oldest first", async () => {
227
+ let clock = Date.parse("2026-11-05T10:00:00Z");
228
+ const t = mk({ clock: () => clock, env: { LOCAL_AGENT_CHAT_RETENTION_DAYS: "30", LOCAL_AGENT_CHAT_MAX_BYTES: String(1024 * 1024) } });
229
+ try {
230
+ const old = uuid(); const fresh = uuid(); const live = uuid();
231
+ for (const [id, text] of [[old, "old"], [fresh, "fresh"]]) { const r = t.store.begin({ jobId: id, workspaceId: "ws-1", userText: text }); r.finish("done"); }
232
+ t.store.begin({ jobId: live, workspaceId: "ws-1", userText: "being written" });
233
+ await t.store.flush();
234
+ const f = (id) => path.join(t.dir, TENANT, `${id}.jsonl`);
235
+ const days = (n) => new Date(clock - n * 86_400_000);
236
+ fs.utimesSync(f(old), days(31), days(31));
237
+ fs.utimesSync(f(fresh), days(29), days(29));
238
+ fs.utimesSync(f(live), days(90), days(90)); // old on disk, but alive in this process
239
+ const r1 = await t.store.sweep();
240
+ assert.equal(r1.removed, 1);
241
+ assert.ok(!fs.existsSync(f(old)) && fs.existsSync(f(fresh)) && fs.existsSync(f(live)));
242
+ assert.equal(t.store.readSession({ conversationId: old, workspaceId: "ws-1" }).status, "not_found", "gone from memory too");
243
+ // the ceiling: three finished conversations of ~400 KiB each against 1 MiB
244
+ const ids = [uuid(), uuid(), uuid()];
245
+ for (const [n, id] of ids.entries()) {
246
+ const r = t.store.begin({ jobId: id, workspaceId: "ws-1", userText: "big" });
247
+ for (let i = 0; i < 26; i++) r.append(line({ type: "token", content: "lorem ipsum dolor ".repeat(800) }));
248
+ r.finish("done");
249
+ await t.store.flush();
250
+ fs.utimesSync(f(id), days(10 - n), days(10 - n));
251
+ }
252
+ const before = ids.filter((id) => fs.existsSync(f(id))).length;
253
+ assert.ok(ids.reduce((n, id) => n + fs.statSync(f(id)).size, 0) > 1024 * 1024, "the three together are above the ceiling");
254
+ const r2 = await t.store.sweep();
255
+ const after = ids.filter((id) => fs.existsSync(f(id)));
256
+ assert.equal(before, 3);
257
+ assert.ok(r2.removed >= 1 && after.length < 3, "the store went under its ceiling");
258
+ assert.ok(!after.includes(ids[0]), "the oldest of the three is the first to go");
259
+ assert.ok(fs.existsSync(f(live)), "the one being written is untouched");
260
+ } finally { t.stop(); }
261
+ });
262
+
263
+ test("deleteWorkspace: removes the conversations of one workspace and only those", async () => {
264
+ const t = mk();
265
+ try {
266
+ const a = uuid(); const b = uuid();
267
+ t.store.begin({ jobId: a, workspaceId: "ws-1", userText: "q" }).finish("done");
268
+ t.store.begin({ jobId: b, workspaceId: "ws-2", userText: "q" }).finish("done");
269
+ await t.store.flush();
270
+ assert.equal(await t.store.deleteWorkspace("ws-1"), 1);
271
+ assert.equal(t.store.readSession({ conversationId: a, workspaceId: "ws-1" }).status, "not_found");
272
+ assert.equal(t.store.readSession({ conversationId: b, workspaceId: "ws-2" }).status, "ok");
273
+ } finally { t.stop(); }
274
+ });
275
+
276
+ test("cursors: 43 chars, opaque, idempotent per position, bound to the workspace, expire, bounded", () => {
277
+ let now = 1_000;
278
+ const c = createCursorRegistry({ tenantId: TENANT, ttlMs: 10_000, maxEntries: 4, now: () => now });
279
+ const job = uuid();
280
+ const id = c.mint({ workspaceId: "ws-1", jobId: job, next: 5 });
281
+ assert.match(id, /^[A-Za-z0-9_-]{43}$/);
282
+ assert.equal(c.mint({ workspaceId: "ws-1", jobId: job, next: 5 }), id, "the same position, the same id (a long-poll that timed out empty does not mint a new one)");
283
+ assert.notEqual(c.mint({ workspaceId: "ws-1", jobId: job, next: 6 }), id);
284
+ assert.deepEqual(c.resolve(id, { workspaceId: "ws-1" }), { jobId: job, next: 5 });
285
+ assert.deepEqual(c.resolve(id, { workspaceId: "ws-1" }), { jobId: job, next: 5 }, "not consumed by use: a lost answer is asked for again");
286
+ assert.equal(c.resolve(id, { workspaceId: "ws-2" }), null, "another workspace");
287
+ assert.equal(c.resolve("A".repeat(43), { workspaceId: "ws-1" }), null);
288
+ assert.equal(c.resolve("short", { workspaceId: "ws-1" }), null);
289
+ assert.equal(c.resolve(job, { workspaceId: "ws-1" }), null, "a job id is not a cursor");
290
+ assert.equal(c.resolve(`${id}=`, { workspaceId: "ws-1" }), null);
291
+ now += 10_001;
292
+ assert.equal(c.resolve(id, { workspaceId: "ws-1" }), null, "expired");
293
+ for (let i = 0; i < 10; i++) c.mint({ workspaceId: "ws-1", jobId: job, next: 100 + i });
294
+ assert.ok(c.size <= 4, "bounded");
295
+ });
296
+
297
+ test("sanity: the WITHHELD constants are valid pieces for the contract (event line min 1 char, JSON)", () => {
298
+ assert.ok(WITHHELD_LINE.length > 0 && WITHHELD_LINE.length < 16384);
299
+ assert.ok(WITHHELD_TEXT.length > 0);
300
+ void sleep;
301
+ });
@@ -15,8 +15,8 @@ const VECTORS = path.join(path.dirname(fileURLToPath(import.meta.url)), "../cont
15
15
  const load = (f) => JSON.parse(fs.readFileSync(path.join(VECTORS, f), "utf8")).vectors;
16
16
  const sha = (s) => crypto.createHash("sha256").update(Buffer.from(s, "utf8")).digest("hex");
17
17
 
18
- test("the copy is revision 2.2 (2.1 is a subset) and every 2.1 vector group is present with at least its valid and invalid cases", () => {
19
- assert.equal(contract.index.revision, "2.2");
18
+ test("the copy is revision 2.3 (2.1 is a subset) and every 2.1 vector group is present with at least its valid and invalid cases", () => {
19
+ assert.equal(contract.index.revision, "2.3");
20
20
  const groups = [
21
21
  ["config.update", /^llm-key-/, 6, 27], // (a): llm-key-* in config.update (the ack and register ones are checked below)
22
22
  ["webhook.forward", /^wh-chunk-/, 9, 24], // (b)
@@ -82,11 +82,11 @@ for (const file of vectorFiles) {
82
82
  }
83
83
  }
84
84
 
85
- test("vectors: counts (363 frames = 82 valid + 281 invalid in this copy (contract 2.2: + policy.check 5+20, policy.decision 5+16), plus the bodies of file.list)", () => {
86
- assert.equal(valid + invalid, 363);
87
- assert.equal(valid, 82);
88
- assert.equal(invalid, 281);
89
- assert.equal(bodyVectors, 54, "file.list.body.vectors.json (F3-07c): 54 bodies");
85
+ test("vectors: counts (386 frames = 92 valid + 294 invalid in this copy (contract 2.3: + the chat vectors of job.assign, register, hello, content.request), plus the bodies of file.list, job.stream and chat.session)", () => {
86
+ assert.equal(valid + invalid, 386);
87
+ assert.equal(valid, 92);
88
+ assert.equal(invalid, 294);
89
+ assert.equal(bodyVectors, 109, "file.list (54, F3-07c) + job.stream (31) + chat.session (24) bodies");
90
90
  });
91
91
 
92
92
  // ------------------------------------------------------------------------------------------------ tables
@@ -123,7 +123,7 @@ test("limits come from index.json and are the documented ones", () => {
123
123
  webhookChunkRawBytes: 16384, webhookMaxChunks: 64, webhookMaxBytes: 1048576, webhookSingleFrameRawBytes: 9216, webhookAssemblySeconds: 30, webhookMaxOpenAssemblies: 4,
124
124
  policyCheckPerMinute: 120, policyCheckTimeoutMs: 10000,
125
125
  });
126
- assert.equal(contract.index.revision, "2.2");
126
+ assert.equal(contract.index.revision, "2.3");
127
127
  });
128
128
 
129
129
  // ------------------------------------------------------------------------------------------------ strict JSON
@@ -33,7 +33,7 @@ export function mintApprovalToken({ privateKey, keyId, tenantId = TENANT, worksp
33
33
 
34
34
  const CLI = fileURLToPath(new URL("./fake-agent2-cli.js", import.meta.url));
35
35
 
36
- export async function agent2Rig({ spawn = null, scenario = "ok_pr", env = {}, behavior = byPath({ chat: okJson(CANARIES.answer, { promptTokens: 40, completionTokens: 11, cost: "0.0042" }), messages: okMessages(CANARIES.answer, { promptTokens: 40, completionTokens: 11, cost: "0.0042" }) }), supportList = true, frameApprovalId = true, setKey = true, policy = null, cpPolicyCapability = true, noRunnerEnv = false, assignOver } = {}) {
36
+ export async function agent2Rig({ spawn = null, scenario = "ok_pr", env = {}, behavior = byPath({ chat: okJson(CANARIES.answer, { promptTokens: 40, completionTokens: 11, cost: "0.0042" }), messages: okMessages(CANARIES.answer, { promptTokens: 40, completionTokens: 11, cost: "0.0042" }) }), supportList = true, frameApprovalId = true, setKey = true, policy = null, cpPolicyCapability = true, noRunnerEnv = false, assignOver, fakeKinds = ["fix", "implement"] } = {}) {
37
37
  const root = tmpDir("f308c-");
38
38
  write(root, "src/hello.js", `// ${CANARIES.file}\nconst token = "${GH}";\nconsole.log("hello");\n`);
39
39
  const fakeLlm = await startFakeLlm(behavior);
@@ -48,7 +48,7 @@ export async function agent2Rig({ spawn = null, scenario = "ok_pr", env = {}, be
48
48
  const callbackToken = crypto.randomBytes(32).toString("base64url");
49
49
  // spawn mode: the runner starts the fake agent2 as a PROCESS (fake-agent2-cli.js); `env` is the (fake) environment of the Local Agent, so it carries PATH/HOME and some secrets that must NOT reach the child
50
50
  const apiPort = spawn ? await freePort() : 0;
51
- const fake = spawn ? { port: apiPort, jobs: new Map(), requests: [], approvals: new Map(), async close() {} } : await startFakeAgent2({ token: apiToken, tenantId: TENANT, scenario, llmBase: () => `${bridgeBase}/v1`, callbackUrl: () => bridgeBase, callbackToken: () => callbackToken, workspaceUrl: () => bridgeBase, workspaceToken: () => callbackToken, supportList, frameApprovalId });
51
+ const fake = spawn ? { port: apiPort, jobs: new Map(), requests: [], approvals: new Map(), async close() {} } : await startFakeAgent2({ token: apiToken, tenantId: TENANT, scenario, kinds: fakeKinds, llmBase: () => `${bridgeBase}/v1`, callbackUrl: () => bridgeBase, callbackToken: () => callbackToken, workspaceUrl: () => bridgeBase, workspaceToken: () => callbackToken, supportList, frameApprovalId });
52
52
  const spawnEnv = spawn ? { PATH: process.env.PATH, HOME: process.env.HOME, LOCAL_AGENT_AGENT2_CMD: JSON.stringify(spawn.cmd || [process.execPath, CLI, `--scenario=${scenario}`, ...(spawn.args || [])]), LOCAL_AGENT_AGENT2_PORT: String(apiPort),
53
53
  LOCAL_AGENT_AGENT2_START_TIMEOUT_MS: "5000", ...(spawn.env || {}) } : {};
54
54
  const fullEnv = spawn ? { ...BASE_ENV, LOCAL_AGENT_AGENT2_PIDFILE: pidFile, ...spawnEnv, ...env } : noRunnerEnv ? { ...BASE_ENV, ...env } : { ...BASE_ENV, LOCAL_AGENT_AGENT2_PIDFILE: pidFile, LOCAL_AGENT_AGENT2_URL: fake.baseUrl, LOCAL_AGENT_AGENT2_TOKEN: apiToken, LOCAL_AGENT_AGENT2_BRIDGE_PORT: String(bridgePort), LOCAL_AGENT_AGENT2_CALLBACK_TOKEN: callbackToken, ...env };
@@ -16,6 +16,7 @@ if (has("hang")) { setInterval(() => {}, 1 << 30); } else {
16
16
  workspaceUrl: () => process.env.AGENT2_LA_WORKSPACE_URL, workspaceToken: () => process.env.AGENT2_LA_WORKSPACE_TOKEN,
17
17
  onCrash: () => process.exit(1),
18
18
  supportList: !has("no-list"),
19
+ kinds: process.env.AGENT2_LOCAL_CHAT_ENABLED === "true" ? ["fix", "implement", "chat"] : ["fix", "implement"], // like the real agent2: `chat` only with AGENT2_LOCAL_CHAT_ENABLED
19
20
  });
20
21
  void f;
21
22
  if (has("die-on-job")) setInterval(() => { if (f.jobs.size) process.exit(1); }, 20);
@@ -115,11 +115,70 @@ export const SCENARIOS = {
115
115
  h.event({ state: "succeeded", phase: "done", errorCode: "none" });
116
116
  h.result({ outcome: "no_change", prUrl: "javascript:alert(1)" });
117
117
  },
118
+ // ---- LA-3: kind chat. The content stream (`GET /jobs/{id}/stream`) is what agent2's unified loop emits: JSON lines with a `type`, ending in a summary.
119
+ /** a question answered without a change: session_start, status, two token lines, a summary with the answer */
120
+ async chat_answer(job, h) {
121
+ h.event({ state: "running", phase: "context", iteration: 0 });
122
+ h.line(JSON.stringify({ type: "session_start", content: { conversationId: job.jobId, mode: "unified" } }));
123
+ h.line(JSON.stringify({ type: "status", content: "reading the code" }));
124
+ h.line(JSON.stringify({ type: "token", content: "CHAT-TOKEN-ONE the login is in AuthService" }));
125
+ h.line(JSON.stringify({ type: "token", content: "CHAT-TOKEN-TWO and it validates the session" }));
126
+ await sleep(30);
127
+ h.line(JSON.stringify({ type: "summary", content: { status: "COMPLETED", outcome: "ANSWERED", concludedNoChange: "true", answer: "CHAT-ANSWER-FINAL the login is in AuthService", reason: "informational", conversationId: job.jobId, prUrl: "", dryRun: true } }));
128
+ h.event({ state: "succeeded", phase: "done", errorCode: "none" });
129
+ h.result({ outcome: "no_change" });
130
+ },
131
+ /** the loop changed files on disk, the model concluded no_change: agent2 reports no filesChangedCount in the result */
132
+ async chat_files(job, h) {
133
+ h.event({ state: "running", phase: "edit", iteration: 1, filesRead: 3, filesChanged: 1 });
134
+ h.line(JSON.stringify({ type: "token", content: "CHAT-FILES I patched two files" }));
135
+ h.event({ state: "running", phase: "edit", iteration: 2, filesRead: 4, filesChanged: 3 });
136
+ await sleep(160);
137
+ h.line(JSON.stringify({ type: "summary", content: { status: "COMPLETED", answer: "CHAT-FILES-ANSWER done", conversationId: job.jobId, prUrl: "", dryRun: true } }));
138
+ h.event({ state: "succeeded", phase: "done", errorCode: "none", filesChanged: 3 });
139
+ h.result({ outcome: "no_change" });
140
+ },
141
+ /** secrets in the content: a token, a private key (a blocked class in the rig), and a canary */
142
+ async chat_secret(job, h) {
143
+ h.event({ state: "running", phase: "context" });
144
+ h.line(JSON.stringify({ type: "token", content: `CHAT-SECRET-CANARY the key is ${"ghp_" + "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8"}` }));
145
+ h.line(JSON.stringify({ type: "tool_result", content: "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n-----END RSA PRIVATE KEY-----" }));
146
+ h.line(JSON.stringify({ type: "summary", content: { status: "COMPLETED", answer: `CHAT-SECRET-ANSWER ${"ghp_" + "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8"}`, conversationId: job.jobId } }));
147
+ h.event({ state: "succeeded", phase: "done", errorCode: "none" });
148
+ h.result({ outcome: "no_change" });
149
+ },
150
+ /** streams for a while, until cancelled; then reports cancelled like agent2 does */
151
+ async chat_slow(job, h) {
152
+ h.event({ state: "running", phase: "plan" });
153
+ h.line(JSON.stringify({ type: "token", content: "CHAT-SLOW first" }));
154
+ let n = 0;
155
+ const tick = setInterval(() => h.line(JSON.stringify({ type: "token", content: `CHAT-SLOW tick ${++n}` })), 60);
156
+ await h.cancelled;
157
+ clearInterval(tick);
158
+ h.line(JSON.stringify({ type: "summary", content: { status: "CANCELLED", conversationId: job.jobId } }));
159
+ h.event({ state: "cancelled", errorCode: "cancelled" });
160
+ h.result({ outcome: "cancelled" });
161
+ },
162
+ /** many lines, some at the 16 KiB limit */
163
+ async chat_many(job, h) {
164
+ h.event({ state: "running", phase: "plan" });
165
+ for (let i = 0; i < 60; i++) h.line(JSON.stringify({ type: "token", content: `line-${i} ${"x".repeat(i % 7 === 0 ? 15_000 : 40)}` }));
166
+ h.line(JSON.stringify({ type: "summary", content: { status: "COMPLETED", answer: "many done", conversationId: job.jobId } }));
167
+ h.event({ state: "succeeded", phase: "done", errorCode: "none" });
168
+ h.result({ outcome: "no_change" });
169
+ },
170
+ /** the loop fails: lines, then a failed result and no summary */
171
+ async chat_fail(job, h) {
172
+ h.event({ state: "running", phase: "plan" });
173
+ h.line(JSON.stringify({ type: "token", content: "CHAT-FAIL started" }));
174
+ h.event({ state: "failed", errorCode: "llm_error" });
175
+ h.result({ outcome: "failed" });
176
+ },
118
177
  /** exits mid-job (spawn mode: the process dies; in-process: the server vanishes) */
119
178
  async crash(job, h) { h.event({ state: "running", phase: "plan" }); await sleep(50); h.crash(); },
120
179
  };
121
180
 
122
- export async function startFakeAgent2({ token, tenantId, scenario = "ok_pr", port = 0, llmBase = null, callbackUrl = null, callbackToken = null, workspaceUrl = null, workspaceToken = null, supportList = true, frameApprovalId = true, healthDelayMs = 0, onCrash = null } = {}) {
181
+ export async function startFakeAgent2({ token, tenantId, scenario = "ok_pr", kinds = ["fix", "implement"], port = 0, llmBase = null, callbackUrl = null, callbackToken = null, workspaceUrl = null, workspaceToken = null, supportList = true, frameApprovalId = true, healthDelayMs = 0, onCrash = null } = {}) {
123
182
  const jobs = new Map();
124
183
  const approvals = new Map();
125
184
  const requests = [];
@@ -133,19 +192,21 @@ export async function startFakeAgent2({ token, tenantId, scenario = "ok_pr", por
133
192
  const body = (req) => new Promise((resolve) => { const c = []; req.on("data", (x) => c.push(x)); req.on("end", () => { try { resolve(JSON.parse(Buffer.concat(c).toString("utf8") || "null")); } catch { resolve(undefined); } }); });
134
193
 
135
194
  function makeJob(b) {
136
- const job = { ...b, frames: [], listeners: new Set(), done: false, seq: 0, record: {}, cancelRequested: false, cancelReason: null };
195
+ const job = { ...b, frames: [], listeners: new Set(), lines: [], lineListeners: new Set(), done: false, seq: 0, record: {}, cancelRequested: false, cancelReason: null };
137
196
  job.cancelled = new Promise((r) => { job.onCancel = r; });
138
197
  const push = (type, payload, over = {}) => {
139
198
  const f = { seq: ++job.seq, type, tenantId: b.tenantId, workspaceId: b.workspaceId, payload, ...over };
140
199
  if (over.drop) for (const k of over.drop) delete f[k];
141
200
  job.frames.push(f);
142
201
  for (const l of job.listeners) l(f);
143
- if (type === "job.result") { job.done = true; for (const l of job.listeners) l(null); }
202
+ if (type === "job.result") { job.done = true; for (const l of job.listeners) l(null); for (const l of job.lineListeners) l(null); }
144
203
  };
145
204
  const h = {
146
205
  event: (p) => push("job.event", { jobId: b.jobId, ...p }),
147
206
  result: (p) => push("job.result", { jobId: b.jobId, ...p }),
148
207
  audit: (p) => push("audit.event", p),
208
+ /** one line of the content stream (`GET /jobs/{id}/stream`): customer content, never in a frame */
209
+ line: (text) => { job.lines.push(text); for (const l of job.lineListeners) l(text); },
149
210
  raw: ({ type, tenantId: t, payload }) => { const f = { seq: ++job.seq, type, ...(t ? { tenantId: t } : { tenantId: b.tenantId }), workspaceId: b.workspaceId, payload }; job.frames.push(f); for (const l of job.listeners) l(f); },
150
211
  cancelled: job.cancelled,
151
212
  requestApproval({ actionType, toolName, requestHash, summary }) {
@@ -207,7 +268,7 @@ export async function startFakeAgent2({ token, tenantId, scenario = "ok_pr", por
207
268
  const url = new URL(req.url, "http://x");
208
269
  const p = url.pathname;
209
270
  requests.push({ method: req.method, path: p });
210
- if (req.method === "GET" && p === "/local/v1/health") { if (healthDelayMs) await sleep(healthDelayMs); return json(res, 200, { status: "up", mode: "local", busy: !!(current && !current.done) }); }
271
+ if (req.method === "GET" && p === "/local/v1/health") { if (healthDelayMs) await sleep(healthDelayMs); return json(res, 200, { status: "up", mode: "local", busy: !!(current && !current.done), ...(kinds ? { kinds } : {}) }); }
211
272
  if (req.method === "GET" && p === "/local/v1/_env") return json(res, 200, { keys: Object.keys(process.env), values: process.env }); // debug route of the CLI wrapper only
212
273
  if (!auth(req)) return json(res, 401, { error: "unauthorized" });
213
274
  let m;
@@ -216,7 +277,8 @@ export async function startFakeAgent2({ token, tenantId, scenario = "ok_pr", por
216
277
  if (!b || typeof b !== "object" || Array.isArray(b)) return json(res, 400, { error: "invalid_body" });
217
278
  for (const k of Object.keys(b)) if (!JOB_FIELDS.has(k)) return json(res, 400, { error: "invalid_body" });
218
279
  if (b.tenantId !== tenantId) return json(res, 403, { error: "tenant_mismatch" });
219
- if (b.kind === "quality") return json(res, 422, { error: "unsupported_kind" });
280
+ if (b.kind === "quality" || (b.kind === "chat" && !kinds.includes("chat"))) return json(res, 422, { error: "unsupported_kind" });
281
+ if (b.kind === "chat" && Buffer.byteLength(b.request?.text ?? "", "utf8") > 16 * 1024) return json(res, 400, { error: "invalid", field: "request" });
220
282
  if (!/^vh-/.test(b.llm?.alias || "")) return json(res, 400, { error: "invalid", field: "llm.alias" });
221
283
  if (jobs.has(b.jobId)) return json(res, 202, { jobId: b.jobId, state: "running", created: false });
222
284
  if (current && !current.done) return json(res, 409, { error: "busy" });
@@ -238,6 +300,19 @@ export async function startFakeAgent2({ token, tenantId, scenario = "ok_pr", por
238
300
  req.on("close", () => job.listeners.delete(l));
239
301
  return;
240
302
  }
303
+ if ((m = p.match(/^\/local\/v1\/jobs\/([^/]+)\/stream$/)) && req.method === "GET") {
304
+ const job = jobs.get(m[1]);
305
+ if (!job) return json(res, 404, { error: "unknown_job" });
306
+ requests.push({ method: "STREAM", path: p });
307
+ res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store" });
308
+ const write = (t) => res.write(`data: ${t}\n\n`); // like Spring's ServerSentEvent.data(line): no event name
309
+ for (const t of job.lines) write(t);
310
+ if (job.done) return res.end();
311
+ const l = (t) => { if (t === null) { res.end(); job.lineListeners.delete(l); } else write(t); };
312
+ job.lineListeners.add(l);
313
+ req.on("close", () => job.lineListeners.delete(l));
314
+ return;
315
+ }
241
316
  if ((m = p.match(/^\/local\/v1\/jobs\/([^/]+)\/cancel$/)) && req.method === "POST") {
242
317
  const job = jobs.get(m[1]);
243
318
  const b = await body(req);
@@ -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 34 traces (28 invalid, 6 valid: 26 of 2.0 + 8 of the chunked webhooks of 2.1)", () => {
22
- assert.equal(traces.length, 34);
23
- assert.equal(traces.filter((t) => t.expect.ok).length, 6);
21
+ test("the suite has all 35 traces (28 invalid, 7 valid: 26 of 2.0 + 8 of the chunked webhooks of 2.1 + the chat of 2.3)", () => {
22
+ assert.equal(traces.length, 35);
23
+ assert.equal(traces.filter((t) => t.expect.ok).length, 7);
24
24
  });
25
25
 
26
26
  const textOf = (fr) => {
@@ -0,0 +1,105 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import http from "node:http";
4
+ import { execFileSync, execSync } from "node:child_process";
5
+ import { createWorkspaceMaterializer, resolveMaterializeConfig, repoTarget } from "../../src/la-protocol-v2/jobs/workspace-materializer.js";
6
+ import { WorkspaceManager } from "../../src/workspace-manager.js";
7
+ const TENANT = "3f2b8c1e-5a4d-4e7b-9c21-0a1b2c3d4e5f";
8
+ const WS = "11111111-2222-4333-8444-555555555555";
9
+ const REF = { provider: "github", org: "acme", repo: "app", branch: "main" };
10
+ const tmp = (p) => fs.mkdtempSync(path.join(os.tmpdir(), `hos-${p}-`));
11
+ const git = (cwd, ...a) => execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", ...a], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
12
+ function mk(files = (w) => fs.writeFileSync(path.join(w, "a.txt"), "one\n")) {
13
+ const bare = tmp("remote"), work = tmp("seed");
14
+ git(bare, "init", "--bare", "-b", "main", "."); git(work, "init", "-b", "main", ".");
15
+ files(work); git(work, "add", "-A"); git(work, "commit", "-m", "one"); git(work, "remote", "add", "origin", bare); git(work, "push", "origin", "main");
16
+ return { bare, work };
17
+ }
18
+ function setup({ remote, cacheDir, env = {}, runGitSeam, allow = "github.com/acme/app" } = {}) {
19
+ const cache = cacheDir || tmp("cache");
20
+ const cfg = resolveMaterializeConfig({ LOCAL_AGENT_JOBS_AUTO_WORKSPACE: "true", LOCAL_AGENT_ALLOWED_REPOS: allow, LOCAL_AGENT_WORKSPACE_CACHE: cache, ...env }, () => {});
21
+ const wsManager = new WorkspaceManager();
22
+ const seams = { remoteUrl: () => remote, gitConfig: ["-c", "protocol.file.allow=always"] };
23
+ if (runGitSeam) seams.runGit = runGitSeam;
24
+ const mat = createWorkspaceMaterializer({ config: cfg, tenantId: TENANT, wsManager, audit: { record() {} }, env: { PATH: process.env.PATH, HOME: process.env.HOME }, seams });
25
+ return { mat, cache, wsManager, cfg };
26
+ }
27
+
28
+
29
+
30
+ test("hostile repo: a symlink planted at the sidecar temp name never makes open() overwrite a file outside the clone", async () => {
31
+ const victim = path.join(tmp("victim"), "important.txt"); fs.writeFileSync(victim, "KEEP ME\n");
32
+ const r = mk((w) => { fs.writeFileSync(path.join(w, "a.txt"), "x\n"); fs.symlinkSync(victim, path.join(w, `.vh-workspace.json.${process.pid}.tmp`)); });
33
+ const { mat } = setup({ remote: r.bare });
34
+ assert.equal(mat.admit(WS, REF), true);
35
+ await mat.ensure(WS, REF);
36
+ const after = fs.readFileSync(victim, "utf8");
37
+ console.log("VICTIM AFTER:", JSON.stringify(after.slice(0, 80)));
38
+ assert.equal(after, "KEEP ME\n", "victim file was clobbered through repo-planted symlink");
39
+ });
40
+
41
+ test("existing dir with a sidecar but no .git under an ancestor git repository: refused, git never touches the ancestor", async () => {
42
+ const home = tmp("fakehome"); // acts as $HOME dotfiles repo
43
+ const up = mk(); // upstream of the "dotfiles" repo with branch main
44
+ git(home, "init", "-b", "main", "."); fs.writeFileSync(path.join(home, ".zshrc"), "a\n"); git(home, "add", "-A"); git(home, "commit", "-m", "d");
45
+ git(home, "remote", "add", "origin", up.bare); git(home, "fetch", "origin");
46
+ git(up.work, "checkout", "-b", "evil"); fs.writeFileSync(path.join(up.work, "PLANTED"), "p\n"); git(up.work, "add", "-A"); git(up.work, "commit", "-m", "evil"); git(up.work, "push", "origin", "evil");
47
+ const cache = path.join(home, ".deepdebug", "workspaces"); fs.mkdirSync(cache, { recursive: true });
48
+ fs.writeFileSync(path.join(home, ".gitignore"), ".deepdebug/\n"); git(home, "add", ".gitignore"); git(home, "commit", "-m", "ig");
49
+ const { mat, wsManager } = setup({ remote: "unused", cacheDir: cache });
50
+ const dir = path.join(fs.realpathSync(cache), WS); fs.mkdirSync(dir);
51
+ await wsManager.open(WS, dir, { tenantId: TENANT }); wsManager.close(WS); // leaves no sidecar; write one manually
52
+ wsManager.workspaces.clear(); fs.writeFileSync(path.join(dir, ".vh-workspace.json"), JSON.stringify({ id: WS, tenantId: TENANT, root: dir }));
53
+ const before = git(home, "rev-parse", "HEAD").trim();
54
+ await assert.rejects(mat.ensure(WS, { ...REF, branch: "evil" }), (e) => e.code === "not_a_clone");
55
+ const afterHead = git(home, "rev-parse", "HEAD").trim();
56
+ console.log("ancestor HEAD moved:", before !== afterHead, "FETCH_HEAD exists:", fs.existsSync(path.join(home, ".git", "FETCH_HEAD")), "PLANTED in ancestor:", fs.existsSync(path.join(home, "PLANTED")));
57
+ assert.equal(afterHead, before); assert.ok(!fs.existsSync(path.join(home, "PLANTED")), "git operated on the ancestor repository");
58
+ });
59
+
60
+ test("cancel of one job does not abort another job waiting for the same clone", async () => {
61
+ let calls = 0;
62
+ const slow = (args, o) => new Promise((res) => { calls++; if (o.signal) o.signal.addEventListener("abort", () => res({ ok: false, code: "aborted" })); setTimeout(() => res({ ok: false, code: "failed" }), 3000); });
63
+ const { mat } = setup({ remote: "x", runGitSeam: slow });
64
+ const ac1 = new AbortController(), ac2 = new AbortController();
65
+ const p1 = mat.ensure(WS, REF, ac1.signal).catch((e) => e.code);
66
+ const p2 = mat.ensure(WS, REF, ac2.signal).catch((e) => e.code);
67
+ await new Promise((r) => setTimeout(r, 100)); ac1.abort();
68
+ console.log("job1:", await p1, "job2 (never cancelled):", await p2);
69
+ assert.notEqual(await p2, "aborted");
70
+ });
71
+
72
+
73
+ test("a redirect never makes git send the token to another host", async () => {
74
+ const seen = [];
75
+ const evil = http.createServer((q, s) => { seen.push({ host: "evil", auth: q.headers.authorization || null, url: q.url }); if (!q.headers.authorization) { s.writeHead(401, { "WWW-Authenticate": 'Basic realm="x"' }); s.end(); } else { s.writeHead(404); s.end(); } }).listen(0);
76
+ await new Promise((r) => evil.once("listening", r));
77
+ const good = http.createServer((q, s) => { seen.push({ host: "good", auth: q.headers.authorization || null, url: q.url }); s.writeHead(302, { Location: `http://127.0.0.1:${evil.address().port}${q.url}` }); s.end(); }).listen(0);
78
+ await new Promise((r) => good.once("listening", r));
79
+ const tokf = path.join(tmp("tok"), "t"); fs.writeFileSync(tokf, "SECRETTOKEN123\n", { mode: 0o600 });
80
+ const { mat } = setup({ remote: `http://127.0.0.1:${good.address().port}/acme/app.git`, env: { LOCAL_AGENT_GIT_TOKEN_FILE: tokf } });
81
+ try { await mat.ensure(WS, REF); } catch (e) { console.log("ensure:", e.code); }
82
+ console.log("SEEN:", JSON.stringify(seen.map((s) => ({ ...s, auth: s.auth ? "PRESENT(" + Buffer.from(s.auth.split(" ")[1], "base64").toString().replace(/:.*/, ":***") + ")" : null }))));
83
+ good.close(); evil.close();
84
+ assert.ok(!seen.some((s) => s.host === "evil" && s.auth), "token sent to redirect target");
85
+ });
86
+
87
+
88
+ test("repoTarget: provider is an own key of the closed list (no prototype keys)", () => {
89
+ for (const p of ["constructor", "__proto__", "toString", "hasOwnProperty"]) assert.equal(repoTarget({ ...REF, provider: p }), null, p);
90
+ });
91
+
92
+ test("size limit is enforced WHILE cloning: the clone is aborted once the partial clone passes the limit", async () => {
93
+ let aborted = false;
94
+ const fake = (args, o) => new Promise((res) => {
95
+ const tmpDir = args[args.length - 1]; fs.mkdirSync(tmpDir, { recursive: true });
96
+ const t = setInterval(() => fs.appendFileSync(path.join(tmpDir, "blob"), Buffer.alloc(512 * 1024)), 100);
97
+ const stop = (c) => { clearInterval(t); res({ ok: false, code: c }); };
98
+ o.signal.addEventListener("abort", () => { aborted = true; stop("aborted"); });
99
+ setTimeout(() => stop("failed"), 15000);
100
+ });
101
+ const { mat, cache } = setup({ remote: "x", runGitSeam: fake, env: { LOCAL_AGENT_WORKSPACE_MAX_BYTES: String(1024 * 1024) } });
102
+ await assert.rejects(mat.ensure(WS, REF), (e) => e.code === "too_large");
103
+ assert.ok(aborted);
104
+ assert.deepEqual(fs.readdirSync(cache), []);
105
+ });