deepdebug-local-agent 1.4.0 → 1.6.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 +69 -5
- package/env.example +4 -0
- package/package.json +1 -1
- package/scripts/sync-la-protocol-v2-contract.mjs +3 -3
- package/src/la-protocol-v2/content-handlers/audit.js +36 -0
- package/src/la-protocol-v2/content-handlers/config.js +54 -0
- package/src/la-protocol-v2/content-handlers/content-index.js +161 -0
- package/src/la-protocol-v2/content-handlers/egress.js +65 -0
- package/src/la-protocol-v2/content-handlers/git-diff.js +142 -0
- package/src/la-protocol-v2/content-handlers/handler.js +182 -0
- package/src/la-protocol-v2/content-handlers/index.js +6 -0
- package/src/la-protocol-v2/content-handlers/install.js +65 -0
- package/src/la-protocol-v2/content-handlers/path-guard.js +183 -0
- package/src/la-protocol-v2/content-handlers/ref-registry.js +105 -0
- package/src/la-protocol-v2/content.js +7 -1
- package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +9 -6
- package/src/la-protocol-v2/contract/README.md +49 -2
- package/src/la-protocol-v2/contract/content.index.schema.json +92 -0
- package/src/la-protocol-v2/contract/envelope.schema.json +1 -0
- package/src/la-protocol-v2/contract/index.json +7 -0
- package/src/la-protocol-v2/runtime.js +23 -4
- package/src/server.js +4 -1
- package/test/contract/la-protocol-v2/vectors/content.index.vectors.json +3777 -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 +608 -0
- package/test/la-protocol-v2/content-handlers.test.js +627 -0
- package/test/la-protocol-v2/content-path-guard.test.js +219 -0
- package/test/la-protocol-v2/content-wiring.test.js +250 -0
- package/test/la-protocol-v2/contract.test.js +5 -5
- package/test/la-protocol-v2/helpers/content-rig.js +94 -0
- package/test/la-protocol-v2/helpers/reference-cp.js +8 -0
- package/test/la-protocol-v2/helpers/spawn-agent.js +2 -1
- package/test/la-protocol-v2/traces.test.js +29 -4
- package/tools/interop/LaV2NodeContentInteropTest.java +262 -0
- package/tools/interop/LaV2NodeContentRelayE2ETest.java +374 -0
- package/tools/interop/README.md +17 -0
- package/tools/interop/la-interop-content-client.mjs +102 -0
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
// F3-07b: aligning the Local Agent content handlers with the gateway (F3-06b / F3-07b): 43-char ids + `worktree`, content.index
|
|
2
|
+
// (inventory of ids, no names), produce() that throws, hard links, the wider deny list, git clean/smudge filters.
|
|
3
|
+
// Real V2Session, real handlers, real files/repos on disk; the control plane is the in-memory ReferenceCp.
|
|
4
|
+
// Against the REAL gateway (HTTP -> controller -> LaV2Session -> this agent): tools/interop/LaV2NodeContentRelayE2ETest.java.
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { EventEmitter } from "node:events";
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { ReferenceCp, TENANT, AGENT, uuid, waitFor } from "./helpers/reference-cp.js";
|
|
11
|
+
import { signingFor } from "./helpers/la-rig.js";
|
|
12
|
+
import { setup, write, git, initRepo, tmpDir, ACTOR } from "./helpers/content-rig.js";
|
|
13
|
+
import { buildV2Link } from "../../src/la-protocol-v2/runtime.js";
|
|
14
|
+
import { createContentIndexer } from "../../src/la-protocol-v2/content-handlers/content-index.js";
|
|
15
|
+
import { ContentRefRegistry } from "../../src/la-protocol-v2/content-handlers/ref-registry.js";
|
|
16
|
+
import { resolveContentHandlersConfig } from "../../src/la-protocol-v2/content-handlers/config.js";
|
|
17
|
+
import { isSensitivePath, resolveInside, readRegular, normalizeRel } from "../../src/la-protocol-v2/content-handlers/path-guard.js";
|
|
18
|
+
import { worktreeDiff, filterOverrides } from "../../src/la-protocol-v2/content-handlers/git-diff.js";
|
|
19
|
+
import { ProtocolError } from "../../src/la-protocol-v2/errors.js";
|
|
20
|
+
|
|
21
|
+
const ID43 = /^[A-Za-z0-9_-]{43}$/;
|
|
22
|
+
const GH = "ghp_" + "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8";
|
|
23
|
+
|
|
24
|
+
async function withRig(opts, body) {
|
|
25
|
+
const rig = await setup(opts);
|
|
26
|
+
try { await body(rig); } finally { await rig.stop(); }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The real runtime wiring (buildV2Link) over an in-memory CP, as content-wiring.test.js does. */
|
|
30
|
+
async function linkFor(cp, { env, root, errors = [] }) {
|
|
31
|
+
const logs = [];
|
|
32
|
+
const log = { info: (m) => logs.push(m), log: (m) => logs.push(m), warn: (m) => logs.push(m), error: (m) => errors.push(m) };
|
|
33
|
+
const wsManager = { list: () => [{ id: "ws-1", root, tenantId: TENANT }] };
|
|
34
|
+
const link = await buildV2Link({ tenantId: TENANT, agentId: AGENT, signing: signingFor({ keyId: cp.keyId, publicKeyRaw: cp.publicKeyRaw }, "enforce"), wsManager,
|
|
35
|
+
getWorkspaceRoot: () => root, log, env: { LOCAL_AGENT_CONTENT_HANDLERS: "true", LOCAL_AGENT_DLP_MODE: "enforce", ...env } });
|
|
36
|
+
const transport = { send: (t) => cp.feed(t), close() {}, bufferedAmount: () => 0 };
|
|
37
|
+
cp.attach((t) => queueMicrotask(() => link.session.handleText(t, transport)), () => {});
|
|
38
|
+
link.session.attach(transport);
|
|
39
|
+
await waitFor(() => link.session.state === "ready");
|
|
40
|
+
return { link, logs, transport };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const indexFrames = (cp) => cp.received.filter((f) => f.type === "content.index");
|
|
44
|
+
|
|
45
|
+
// ------------------------------------------------------------------------------------------ 2. ids of 43 chars, `worktree`
|
|
46
|
+
|
|
47
|
+
test("ids: the agent mints 43-char base64url ids and serves them; worktree is the symbolic ref of kind diff only; the F3-07 form is not_found", async () => {
|
|
48
|
+
await withRig({}, async (rig) => {
|
|
49
|
+
write(rig.root(), "src/app.js", "const a = 1;\n");
|
|
50
|
+
initRepo(rig.root());
|
|
51
|
+
write(rig.root(), "src/app.js", `const a = 2; // ${GH}\n`);
|
|
52
|
+
const id = rig.mint("file", "src/app.js");
|
|
53
|
+
assert.match(id, ID43);
|
|
54
|
+
const f = await rig.request({ ref: id });
|
|
55
|
+
assert.deepEqual([f.status, f.text.includes("const a = 2"), f.text.includes(GH)], ["ok", true, false]);
|
|
56
|
+
const d = await rig.request({ kind: "diff", ref: "worktree" });
|
|
57
|
+
assert.equal(d.status, "ok");
|
|
58
|
+
assert.match(d.text, /src\/app\.js/);
|
|
59
|
+
assert.equal(d.text.includes(GH), false, "masked");
|
|
60
|
+
assert.equal((await rig.request({ kind: "file", ref: "worktree" })).status, "not_found", "worktree is not a file");
|
|
61
|
+
assert.equal((await rig.request({ ref: "cr1_" + "A".repeat(22) })).status, "not_found");
|
|
62
|
+
assert.equal((await rig.request({ ref: "A".repeat(43) })).status, "not_found", "well-formed but never minted");
|
|
63
|
+
assert.equal((await rig.request({ kind: "diff", ref: "worktree", workspaceId: "ws-nope" })).status, "not_found", "worktree of an unregistered workspace");
|
|
64
|
+
assert.equal((await rig.request({ kind: "diff", ref: id })).status, "not_found", "an id minted for a file is not a diff id");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("ids: the F3-07 form (cr1_ + 22), 42/44 chars, padding, '.', '/', '+' are not ids for the registry or the pattern", async () => {
|
|
69
|
+
const { REF_PATTERN } = await import("../../src/la-protocol-v2/content-handlers/ref-registry.js");
|
|
70
|
+
const ok = "Zm9vYmFyYmF6cXV4MTIzNDU2Nzg5MGFiY2RlZmdoaWo";
|
|
71
|
+
assert.equal(ok.length, 43);
|
|
72
|
+
assert.equal(REF_PATTERN.test(ok), true);
|
|
73
|
+
for (const bad of ["cr1_" + "A".repeat(22), ok.slice(1), ok + "A", ok.slice(0, 42) + "=", ok.slice(0, 42) + ".", ok.slice(0, 42) + "/", ok.slice(0, 42) + "+", "worktree", ""]) {
|
|
74
|
+
assert.equal(REF_PATTERN.test(bad), false, bad);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ------------------------------------------------------------------------------------------ 3. content.index
|
|
79
|
+
|
|
80
|
+
test("content.index: a minted id is announced as {kind, ref, size} and nothing else; no name, no path, no directory anywhere in the frame", async () => {
|
|
81
|
+
const root = tmpDir(); write(root, "src/secret-name-xyz/app.txt", "hello world"); write(root, ".env", "X=1");
|
|
82
|
+
const cp = new ReferenceCp();
|
|
83
|
+
const { link } = await linkFor(cp, { root });
|
|
84
|
+
const m = link.content.mintRef({ workspaceId: "ws-1", kind: "file", rel: "src/secret-name-xyz/app.txt" });
|
|
85
|
+
assert.equal(link.content.mintRef({ workspaceId: "ws-1", kind: "file", rel: ".env" }), null, "sensitive: no id, no entry");
|
|
86
|
+
assert.equal(link.content.mintRef({ workspaceId: "ws-unknown", kind: "file", rel: "src/secret-name-xyz/app.txt" }), null);
|
|
87
|
+
await waitFor(() => indexFrames(cp).length === 1);
|
|
88
|
+
const [fr] = indexFrames(cp);
|
|
89
|
+
assert.deepEqual(fr.payload, { entries: [{ kind: "file", ref: m.id, size: 11 }] });
|
|
90
|
+
assert.equal(fr.workspaceId, "ws-1");
|
|
91
|
+
assert.equal(fr.chunk, undefined);
|
|
92
|
+
assert.equal(fr.sig, undefined, "unsigned, like log.stats");
|
|
93
|
+
const wire = JSON.stringify(cp.rawIn);
|
|
94
|
+
for (const leak of ["secret-name-xyz", "app.txt", root, path.basename(root), ".env"]) assert.equal(wire.includes(leak), false, `leaked ${leak}`);
|
|
95
|
+
assert.deepEqual(cp.errorsSent, []);
|
|
96
|
+
assert.equal(link.content.indexer.announcedCount, 1);
|
|
97
|
+
link.close();
|
|
98
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("content.index: size is informative and honest (a file outside the root by link, a missing file, a directory: 0); diff ids announce size 0", async () => {
|
|
102
|
+
const base = tmpDir(); const root = path.join(base, "ws"); fs.mkdirSync(root);
|
|
103
|
+
write(base, "outside.txt", "x".repeat(5000));
|
|
104
|
+
write(root, "in.txt", "x".repeat(123));
|
|
105
|
+
fs.symlinkSync(path.join(base, "outside.txt"), path.join(root, "link.txt"));
|
|
106
|
+
fs.mkdirSync(path.join(root, "adir"));
|
|
107
|
+
const cp = new ReferenceCp();
|
|
108
|
+
const { link } = await linkFor(cp, { root });
|
|
109
|
+
const sizes = {};
|
|
110
|
+
for (const rel of ["in.txt", "link.txt", "missing.txt", "adir"]) sizes[rel] = link.content.mintRef({ workspaceId: "ws-1", kind: "file", rel });
|
|
111
|
+
const diff = link.content.mintRef({ workspaceId: "ws-1", kind: "diff", rel: "in.txt" });
|
|
112
|
+
await link.content.indexer.flush();
|
|
113
|
+
const bySize = new Map(indexFrames(cp).flatMap((f) => f.payload.entries).map((e) => [e.ref, e]));
|
|
114
|
+
assert.equal(bySize.get(sizes["in.txt"].id).size, 123);
|
|
115
|
+
assert.equal(bySize.get(sizes["link.txt"].id).size, 0, "a link that leaves the root reveals nothing about its target");
|
|
116
|
+
assert.equal(bySize.get(sizes["missing.txt"].id).size, 0);
|
|
117
|
+
assert.equal(bySize.get(sizes["adir"].id).size, 0);
|
|
118
|
+
assert.deepEqual([bySize.get(diff.id).kind, bySize.get(diff.id).size], ["diff", 0]);
|
|
119
|
+
link.close();
|
|
120
|
+
fs.rmSync(base, { recursive: true, force: true });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("content.index: ids are batched (one frame per workspace up to 256 entries, the contract limit), every id exactly once", async () => {
|
|
124
|
+
const root = tmpDir(); write(root, "a.txt", "a");
|
|
125
|
+
const cp = new ReferenceCp();
|
|
126
|
+
const { link } = await linkFor(cp, { root, env: { LOCAL_AGENT_CONTENT_INDEX_BATCH_MS: "200" } });
|
|
127
|
+
const ids = [];
|
|
128
|
+
for (let i = 0; i < 300; i++) ids.push(link.content.mintRef({ workspaceId: "ws-1", kind: "file", rel: "a.txt" }).id);
|
|
129
|
+
await waitFor(() => indexFrames(cp).length === 2, 4000);
|
|
130
|
+
const frames = indexFrames(cp);
|
|
131
|
+
assert.deepEqual(frames.map((f) => f.payload.entries.length), [256, 44]);
|
|
132
|
+
const announced = frames.flatMap((f) => f.payload.entries.map((e) => e.ref));
|
|
133
|
+
assert.equal(new Set(announced).size, 300);
|
|
134
|
+
assert.deepEqual([...announced].sort(), [...ids].sort());
|
|
135
|
+
assert.deepEqual(cp.errorsSent, []);
|
|
136
|
+
link.close();
|
|
137
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("content.index: after a connection that is NOT a resume every live id is announced again; after a resume nothing is repeated", async () => {
|
|
141
|
+
const root = tmpDir(); write(root, "a.txt", "abc"); write(root, "b.txt", "defg");
|
|
142
|
+
const cp = new ReferenceCp();
|
|
143
|
+
const { link, transport } = await linkFor(cp, { root });
|
|
144
|
+
const a = link.content.mintRef({ workspaceId: "ws-1", kind: "file", rel: "a.txt" }).id;
|
|
145
|
+
const b = link.content.mintRef({ workspaceId: "ws-1", kind: "file", rel: "b.txt" }).id;
|
|
146
|
+
await waitFor(() => indexFrames(cp).flatMap((f) => f.payload.entries).length === 2);
|
|
147
|
+
// resume: same CP, same session id -> nothing again
|
|
148
|
+
link.session.detach(transport);
|
|
149
|
+
const t2 = { send: (t) => cp.feed(t), close() {}, bufferedAmount: () => 0 };
|
|
150
|
+
cp.attach((t) => queueMicrotask(() => link.session.handleText(t, t2)), () => {});
|
|
151
|
+
link.session.attach(t2);
|
|
152
|
+
await waitFor(() => link.session.state === "ready");
|
|
153
|
+
await link.content.indexer.flush();
|
|
154
|
+
assert.equal(cp.hellos.length >= 2, true);
|
|
155
|
+
assert.equal(indexFrames(cp).flatMap((f) => f.payload.entries).length, 2, "a resumed session keeps its index at the gateway");
|
|
156
|
+
// a brand new gateway session (a CP that does not know the old one): ready with resumed=false -> announce again
|
|
157
|
+
link.session.detach(t2);
|
|
158
|
+
const cp2 = new ReferenceCp({ privateKey: cp.privateKey, publicKeyRaw: cp.publicKeyRaw, keyId: cp.keyId }); // another gateway session, the same signing key
|
|
159
|
+
const t3 = { send: (t) => cp2.feed(t), close() {}, bufferedAmount: () => 0 };
|
|
160
|
+
cp2.attach((t) => queueMicrotask(() => link.session.handleText(t, t3)), () => {});
|
|
161
|
+
link.session.attach(t3);
|
|
162
|
+
await waitFor(() => link.session.state === "ready" && indexFrames(cp2).length > 0, 4000);
|
|
163
|
+
const again = indexFrames(cp2).flatMap((f) => f.payload.entries);
|
|
164
|
+
assert.deepEqual(again.map((e) => e.ref).sort(), [a, b].sort());
|
|
165
|
+
assert.deepEqual(again.map((e) => e.size).sort(), [3, 4]);
|
|
166
|
+
link.close();
|
|
167
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("content.index: the gateway's ack `rejected protocol_unsupported` stops the announcements, the session stays up and content is still served", async () => {
|
|
171
|
+
const root = tmpDir(); write(root, "a.txt", "hello");
|
|
172
|
+
const cp = new ReferenceCp();
|
|
173
|
+
cp.rejectData = ["protocol_unsupported"];
|
|
174
|
+
const { link, logs } = await linkFor(cp, { root });
|
|
175
|
+
const id = link.content.mintRef({ workspaceId: "ws-1", kind: "file", rel: "a.txt" }).id;
|
|
176
|
+
await waitFor(() => cp.rejectedFrames.length === 1);
|
|
177
|
+
await link.content.indexer.flush();
|
|
178
|
+
assert.deepEqual(cp.rejectedFrames, [["content.index", "protocol_unsupported"]]);
|
|
179
|
+
assert.equal(link.session.state, "ready", "non-fatal");
|
|
180
|
+
const more = link.content.mintRef({ workspaceId: "ws-1", kind: "file", rel: "a.txt" });
|
|
181
|
+
assert.ok(more);
|
|
182
|
+
await link.content.indexer.flush();
|
|
183
|
+
assert.equal(cp.rejectedFrames.length, 1, "nothing more is sent");
|
|
184
|
+
assert.equal(indexFrames(cp).length, 0);
|
|
185
|
+
assert.ok(logs.some((l) => l.includes("keeps no content index")));
|
|
186
|
+
const requestId = uuid();
|
|
187
|
+
cp.sendData("content.request", { kind: "file", ref: id, actor: ACTOR }, { workspaceId: "ws-1", requestId });
|
|
188
|
+
await waitFor(() => cp.contentRequests.get(requestId)?.done);
|
|
189
|
+
assert.equal(Buffer.concat(cp.contentRequests.get(requestId).chunks).toString(), "hello");
|
|
190
|
+
assert.equal(logs.concat([]).some((l) => l.includes(root) || l.includes(id)), false);
|
|
191
|
+
link.close();
|
|
192
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// content.index with a fake session and fake timers: the ack policy, bounds, no leaks
|
|
196
|
+
function fakeSession() {
|
|
197
|
+
const s = new EventEmitter();
|
|
198
|
+
s.state = "ready";
|
|
199
|
+
s.sent = [];
|
|
200
|
+
s.script = []; // codes to fail the next sends with (null = accept)
|
|
201
|
+
s.send = async (type, payload, opts) => {
|
|
202
|
+
const code = s.script.length ? s.script.shift() : null;
|
|
203
|
+
s.sent.push({ type, payload, workspaceId: opts.workspaceId, code });
|
|
204
|
+
if (code) throw new ProtocolError(code, "rejected by the control plane");
|
|
205
|
+
};
|
|
206
|
+
return s;
|
|
207
|
+
}
|
|
208
|
+
function fakeTimers() {
|
|
209
|
+
const pending = [];
|
|
210
|
+
return { pending, setTimeout: (fn, ms) => { const t = { fn, ms, off: false }; pending.push(t); return t; }, clearTimeout: (t) => { if (t) t.off = true; },
|
|
211
|
+
async fire() { const due = pending.splice(0).filter((t) => !t.off); for (const t of due) await t.fn(); } };
|
|
212
|
+
}
|
|
213
|
+
function indexerRig({ maxIds = 2048, ttl = 900_000 } = {}) {
|
|
214
|
+
const session = fakeSession();
|
|
215
|
+
const timers = fakeTimers();
|
|
216
|
+
let t = 1_000_000;
|
|
217
|
+
const registry = new ContentRefRegistry({ tenantId: TENANT, ttlMs: ttl, now: () => t });
|
|
218
|
+
const logs = [];
|
|
219
|
+
const config = { indexMaxIds: maxIds, indexBatchMs: 100, refTtlMs: ttl };
|
|
220
|
+
const indexer = createContentIndexer({ session, registry, config, log: { warn: (m) => logs.push(m), log: (m) => logs.push(m) }, now: () => t, timers });
|
|
221
|
+
const mint = (rel = "f.txt", ws = "ws-1", size = 1) => {
|
|
222
|
+
const m = registry.mint({ workspaceId: ws, root: "/r", kind: "file", rel, size });
|
|
223
|
+
indexer.add({ workspaceId: ws, kind: "file", ref: m.id, size, expiresAt: m.expiresAt });
|
|
224
|
+
return m.id;
|
|
225
|
+
};
|
|
226
|
+
return { session, timers, registry, indexer, logs, mint, advance: (ms) => { t += ms; } };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
test("indexer: rate_limited -> nothing stored, the ids come back after 30 s, then 60 s, doubling to 5 min; success resets it", async () => {
|
|
230
|
+
const r = indexerRig();
|
|
231
|
+
r.session.script = ["rate_limited", "rate_limited"];
|
|
232
|
+
const id = r.mint();
|
|
233
|
+
await r.indexer.flush();
|
|
234
|
+
assert.deepEqual(r.session.sent.map((s) => s.code), ["rate_limited"]);
|
|
235
|
+
assert.equal(r.indexer.announcedCount, 0);
|
|
236
|
+
assert.equal(r.timers.pending.at(-1).ms, 30_000);
|
|
237
|
+
await r.timers.fire(); // announce() -> schedules the batch (0 ms)
|
|
238
|
+
await r.timers.fire(); // the batch: rejected again
|
|
239
|
+
await r.indexer.flush();
|
|
240
|
+
assert.equal(r.timers.pending.at(-1).ms, 60_000);
|
|
241
|
+
await r.timers.fire(); await r.timers.fire(); await r.indexer.flush();
|
|
242
|
+
assert.equal(r.session.sent.at(-1).code, null, "accepted at last");
|
|
243
|
+
assert.deepEqual(r.session.sent.at(-1).payload.entries.map((e) => e.ref), [id]);
|
|
244
|
+
assert.equal(r.indexer.announcedCount, 1);
|
|
245
|
+
assert.ok(r.logs.every((l) => !l.includes(id)), "no id in a log line");
|
|
246
|
+
r.indexer.close();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("indexer: protocol_unsupported stops for the connection (no timers, adds are dropped); the next non-resumed ready starts again", async () => {
|
|
250
|
+
const r = indexerRig();
|
|
251
|
+
r.session.script = ["protocol_unsupported"];
|
|
252
|
+
r.mint();
|
|
253
|
+
await r.indexer.flush();
|
|
254
|
+
assert.equal(r.timers.pending.filter((t) => !t.off).length, 0, "no retry timer");
|
|
255
|
+
const before = r.session.sent.length;
|
|
256
|
+
r.mint("g.txt");
|
|
257
|
+
await r.indexer.flush();
|
|
258
|
+
assert.equal(r.session.sent.length, before);
|
|
259
|
+
assert.equal(r.indexer.stats.dropped >= 1, true);
|
|
260
|
+
r.session.emit("ready", { resumed: false });
|
|
261
|
+
await r.indexer.flush(); await r.timers.fire(); await r.indexer.flush();
|
|
262
|
+
assert.equal(r.session.sent.at(-1).code, null);
|
|
263
|
+
assert.equal(r.session.sent.at(-1).payload.entries.length, 2, "both live ids come back: the one that was refused and the one dropped while stopped (it is in the registry)");
|
|
264
|
+
r.indexer.close();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("indexer: internal and timeout are retried after 5 s; other failures wait for the next ready; nothing is thrown into the caller", async () => {
|
|
268
|
+
const r = indexerRig();
|
|
269
|
+
r.session.script = ["internal", "timeout", "agent_offline"];
|
|
270
|
+
r.mint();
|
|
271
|
+
await r.indexer.flush();
|
|
272
|
+
assert.equal(r.timers.pending.at(-1).ms, 5_000);
|
|
273
|
+
await r.timers.fire(); await r.timers.fire(); await r.indexer.flush();
|
|
274
|
+
assert.equal(r.timers.pending.at(-1).ms, 5_000);
|
|
275
|
+
await r.timers.fire(); await r.timers.fire(); await r.indexer.flush();
|
|
276
|
+
assert.equal(r.session.sent.at(-1).code, "agent_offline");
|
|
277
|
+
assert.equal(r.timers.pending.filter((t) => !t.off).length, 0, "agent_offline: no timer, the next ready announces again");
|
|
278
|
+
r.session.emit("ready", { resumed: false });
|
|
279
|
+
await r.indexer.flush(); await r.timers.fire(); await r.indexer.flush();
|
|
280
|
+
assert.equal(r.session.sent.at(-1).code, null);
|
|
281
|
+
r.indexer.close();
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("indexer: not ready -> nothing is sent; ready -> every live id goes out; expired ids are not announced", async () => {
|
|
285
|
+
const r = indexerRig({ ttl: 60_000 });
|
|
286
|
+
r.session.state = "await_ready";
|
|
287
|
+
const a = r.mint("a.txt"); const b = r.mint("b.txt");
|
|
288
|
+
await r.timers.fire(); await r.indexer.flush();
|
|
289
|
+
assert.equal(r.session.sent.length, 0);
|
|
290
|
+
r.advance(61_000);
|
|
291
|
+
const c = r.mint("c.txt"); // a and b expired in the meantime
|
|
292
|
+
r.session.state = "ready";
|
|
293
|
+
r.session.emit("ready", { resumed: false });
|
|
294
|
+
await r.indexer.flush(); await r.timers.fire(); await r.indexer.flush();
|
|
295
|
+
const refs = r.session.sent.flatMap((s) => s.payload.entries.map((e) => e.ref));
|
|
296
|
+
assert.deepEqual(refs, [c]);
|
|
297
|
+
assert.equal(refs.includes(a) || refs.includes(b), false);
|
|
298
|
+
r.indexer.close();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("indexer: at most LOCAL_AGENT_CONTENT_INDEX_MAX_IDS live ids are announced (the gateway keeps 2048 per session); the rest is only counted", async () => {
|
|
302
|
+
const r = indexerRig({ maxIds: 3 });
|
|
303
|
+
for (let i = 0; i < 5; i++) r.mint(`f${i}.txt`);
|
|
304
|
+
await r.indexer.flush();
|
|
305
|
+
assert.equal(r.session.sent.flatMap((s) => s.payload.entries).length, 3);
|
|
306
|
+
assert.equal(r.indexer.stats.dropped, 2);
|
|
307
|
+
r.indexer.close();
|
|
308
|
+
assert.equal(resolveContentHandlersConfig({ LOCAL_AGENT_CONTENT_HANDLERS: "true", LOCAL_AGENT_CONTENT_INDEX_MAX_IDS: "999999" }, () => {}).indexMaxIds, 2048, "never above the gateway's limit");
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("indexer: workspaces never share a frame; entries carry only kind, ref, size; close() leaves no timer", async () => {
|
|
312
|
+
const r = indexerRig();
|
|
313
|
+
r.mint("a", "ws-1"); r.mint("b", "ws-2"); r.mint("c", "ws-1");
|
|
314
|
+
await r.indexer.flush();
|
|
315
|
+
assert.deepEqual(r.session.sent.map((s) => [s.workspaceId, s.payload.entries.length]), [["ws-1", 2], ["ws-2", 1]]);
|
|
316
|
+
for (const s of r.session.sent) for (const e of s.payload.entries) assert.deepEqual(Object.keys(e).sort(), ["kind", "ref", "size"]);
|
|
317
|
+
r.mint("d", "ws-1");
|
|
318
|
+
r.indexer.close();
|
|
319
|
+
assert.equal(r.timers.pending.filter((t) => !t.off).length, 0);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// ------------------------------------------------------------------------------------------ 5. produce() that throws
|
|
323
|
+
|
|
324
|
+
test("a throwing workspace lookup, registry or audit never leaves the gateway without an answer: internal, audited, no path or message anywhere", async () => {
|
|
325
|
+
const unhandled = [];
|
|
326
|
+
const onUnhandled = (e) => unhandled.push(e);
|
|
327
|
+
process.on("unhandledRejection", onUnhandled);
|
|
328
|
+
try {
|
|
329
|
+
await withRig({}, async (rig) => {
|
|
330
|
+
const root = rig.root();
|
|
331
|
+
write(root, "a.txt", "hello");
|
|
332
|
+
const id = rig.mint("file", "a.txt");
|
|
333
|
+
const LEAK = "/Users/victim/very-secret-dir/passwords.txt";
|
|
334
|
+
const realGet = rig.wsMap.get.bind(rig.wsMap);
|
|
335
|
+
// 1. resolveWorkspace / the workspace list throws
|
|
336
|
+
rig.wsMap.get = () => { throw new TypeError(`cannot read ${LEAK}`); };
|
|
337
|
+
let r = await rig.request({ ref: id, waitMs: 2500 });
|
|
338
|
+
assert.deepEqual([r.status, r.errorCode, r.frames.length, r.body.length], ["error", "internal", 1, 0]);
|
|
339
|
+
let rec = rig.handler.audit.tail(1)[0];
|
|
340
|
+
assert.deepEqual([rec.kind, rec.status, rec.errorCode, rec.bytes], ["file", "error", "internal", 0]);
|
|
341
|
+
rig.wsMap.get = realGet;
|
|
342
|
+
// 2. the registry throws
|
|
343
|
+
const realResolve = rig.registry.resolve.bind(rig.registry);
|
|
344
|
+
rig.registry.resolve = () => { throw new Error(`boom ${LEAK}`); };
|
|
345
|
+
r = await rig.request({ ref: id, waitMs: 2500 });
|
|
346
|
+
assert.deepEqual([r.status, r.errorCode], ["error", "internal"]);
|
|
347
|
+
rig.registry.resolve = realResolve;
|
|
348
|
+
// 3. a diff whose producer throws deep inside (the repository is replaced by something that throws on realpath is not portable: use the registry again)
|
|
349
|
+
rig.registry.resolve = () => { throw new RangeError(LEAK); };
|
|
350
|
+
r = await rig.request({ kind: "diff", ref: "worktree", waitMs: 2500 });
|
|
351
|
+
assert.deepEqual([r.status, r.errorCode], ["error", "internal"]);
|
|
352
|
+
rig.registry.resolve = realResolve;
|
|
353
|
+
// 4. the audit throws: the content is still delivered
|
|
354
|
+
const realRecord = rig.handler.audit.record;
|
|
355
|
+
rig.handler.audit.record = () => { throw new Error(`audit ${LEAK}`); };
|
|
356
|
+
r = await rig.request({ ref: id, waitMs: 2500 });
|
|
357
|
+
assert.deepEqual([r.status, r.text], ["ok", "hello"]);
|
|
358
|
+
await waitFor(() => rig.handler.inflight === 0);
|
|
359
|
+
assert.equal(rig.logs.some((l) => l.includes("failed internally")), false, "an audit failure is absorbed where it happens, not escalated to the last-resort handler");
|
|
360
|
+
rig.handler.audit.record = realRecord;
|
|
361
|
+
// and the next request is normal: nothing is stuck (slot, memory)
|
|
362
|
+
assert.equal((await rig.request({ ref: id })).status, "ok");
|
|
363
|
+
await waitFor(() => rig.handler.inflight === 0 && rig.handler.reservedBytes === 0);
|
|
364
|
+
const everything = rig.everything();
|
|
365
|
+
assert.equal(everything.includes("victim"), false, "the message of an exception never reaches the wire, the audit or a log");
|
|
366
|
+
assert.equal(everything.includes("passwords.txt"), false);
|
|
367
|
+
assert.equal(everything.includes(root), false);
|
|
368
|
+
assert.deepEqual(rig.cp.errorsSent, []);
|
|
369
|
+
});
|
|
370
|
+
} finally { process.off("unhandledRejection", onUnhandled); }
|
|
371
|
+
assert.deepEqual(unhandled, [], "no TypeError swallowed into an unhandled rejection");
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("mintRef with a workspace manager that throws returns null (no exception with a path in it, no id)", async () => {
|
|
375
|
+
const root = tmpDir(); write(root, "a.txt", "x");
|
|
376
|
+
const cp = new ReferenceCp();
|
|
377
|
+
const { link } = await linkFor(cp, { root });
|
|
378
|
+
const { installContentHandlers } = await import("../../src/la-protocol-v2/content-handlers/install.js");
|
|
379
|
+
const bad = { session: link.session, close() {} };
|
|
380
|
+
const c = installContentHandlers({ link: bad, tenantId: TENANT, wsManager: { list: () => { throw new Error("/leaky/path"); } }, getWorkspaceRoot: () => root,
|
|
381
|
+
config: resolveContentHandlersConfig({ LOCAL_AGENT_CONTENT_HANDLERS: "true" }, () => {}), env: { LOCAL_AGENT_DLP_MODE: "enforce" } });
|
|
382
|
+
assert.equal(c.mintRef({ workspaceId: "ws-1", kind: "file", rel: "a.txt" }), null);
|
|
383
|
+
bad.close();
|
|
384
|
+
link.close();
|
|
385
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// ------------------------------------------------------------------------------------------ 6. hard links, deny list
|
|
389
|
+
|
|
390
|
+
function hardLinkFixture() {
|
|
391
|
+
const base = tmpDir(); const root = path.join(base, "ws"); fs.mkdirSync(root);
|
|
392
|
+
write(base, "outside-secret.txt", "OUTSIDE-SECRET-CONTENT");
|
|
393
|
+
write(root, "plain.txt", "plain");
|
|
394
|
+
let linked = true;
|
|
395
|
+
try { fs.linkSync(path.join(base, "outside-secret.txt"), path.join(root, "hard.txt")); } catch { linked = false; }
|
|
396
|
+
return { base, root, linked };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
test("hard link: a second name of a file outside the root is forbidden by default (nlink > 1); LOCAL_AGENT_CONTENT_ALLOW_HARDLINKS lifts it; ordinary files are untouched", async (t) => {
|
|
400
|
+
const f = hardLinkFixture();
|
|
401
|
+
if (!f.linked) { t.skip("hard links not supported here"); return; }
|
|
402
|
+
try {
|
|
403
|
+
assert.equal((await resolveInside(f.root, "hard.txt")).status, "forbidden");
|
|
404
|
+
assert.equal((await resolveInside(f.root, "plain.txt")).ok, true);
|
|
405
|
+
const allowed = await resolveInside(f.root, "hard.txt", [], { allowHardlinks: true });
|
|
406
|
+
assert.equal(allowed.ok, true);
|
|
407
|
+
// through the handler, with an id minted before the link existed
|
|
408
|
+
await withRig({ workspaces: { "ws-1": { root: f.root } } }, async (rig) => {
|
|
409
|
+
const id = rig.mint("file", "hard.txt");
|
|
410
|
+
const r = await rig.request({ ref: id });
|
|
411
|
+
assert.deepEqual([r.status, r.body.length], ["forbidden", 0]);
|
|
412
|
+
assert.equal(rig.everything().includes("OUTSIDE-SECRET"), false);
|
|
413
|
+
assert.equal((await rig.request({ ref: rig.mint("file", "plain.txt") })).text, "plain");
|
|
414
|
+
});
|
|
415
|
+
await withRig({ workspaces: { "ws-1": { root: f.root } }, env: { LOCAL_AGENT_CONTENT_ALLOW_HARDLINKS: "true" } }, async (rig) => {
|
|
416
|
+
assert.equal((await rig.request({ ref: rig.mint("file", "hard.txt") })).text, "OUTSIDE-SECRET-CONTENT");
|
|
417
|
+
});
|
|
418
|
+
assert.equal(resolveContentHandlersConfig({ LOCAL_AGENT_CONTENT_HANDLERS: "true" }, () => {}).allowHardlinks, false, "default off");
|
|
419
|
+
assert.equal(resolveContentHandlersConfig({ LOCAL_AGENT_CONTENT_HANDLERS: "true", LOCAL_AGENT_CONTENT_ALLOW_HARDLINKS: "maybe" }, () => {}).allowHardlinks, false, "unknown stays off");
|
|
420
|
+
} finally { fs.rmSync(f.base, { recursive: true, force: true }); }
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("hard link created AFTER the path check is still caught on the open descriptor (fstat), not only on the path", async (t) => {
|
|
424
|
+
const base = tmpDir(); const root = path.join(base, "ws"); fs.mkdirSync(root);
|
|
425
|
+
write(root, "late.txt", "late");
|
|
426
|
+
try {
|
|
427
|
+
const resolved = await resolveInside(root, "late.txt");
|
|
428
|
+
assert.equal(resolved.ok, true);
|
|
429
|
+
try { fs.linkSync(path.join(root, "late.txt"), path.join(base, "alias.txt")); } catch { t.skip("hard links not supported here"); return; }
|
|
430
|
+
assert.deepEqual(await readRegular(resolved, 1024, undefined), { ok: false, status: "forbidden" });
|
|
431
|
+
const ok = await readRegular(resolved, 1024, undefined, { allowHardlinks: true });
|
|
432
|
+
assert.equal(ok.ok, true);
|
|
433
|
+
} finally { fs.rmSync(base, { recursive: true, force: true }); }
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
const SENSITIVE = [
|
|
437
|
+
".env~", "app/.env~", ".ENV~", ".env.local~", ".env.production.bak", ".env#", ".#.env",
|
|
438
|
+
".env::$DATA", "app/.env::$DATA", ".env:Zone.Identifier", ".env.::$DATA", "id_rsa::$DATA", "keys/server.pem::$DATA",
|
|
439
|
+
"kubeconfig", "deploy/kubeconfig", "kubeconfig.yaml", "prod.kubeconfig", ".kube/config", "KUBECONFIG",
|
|
440
|
+
"id_rsa", "id_rsa.pub", "id_ed25519", "id_ecdsa", "x/id_dsa", "vault.kdbx", "db/secrets.KDBX",
|
|
441
|
+
".npmrc", "pkg/.npmrc", ".pypirc", "credentials", "credentials.json", "credentials.yml", "credentials.xml", "aws/credentials",
|
|
442
|
+
// Windows 8.3 aliases of sensitive names
|
|
443
|
+
"ENV~1", "env~1", "ENV~10", "NPMRC~1", "PYPIRC~1", "NETRC~1", "PGPASS~1", "HTPASS~1", "GIT-CR~1", "VAULT-~1", "KUBECO~1", "CREDEN~1.JSO", "CREDEN~1",
|
|
444
|
+
"ID_ED2~1", "ID_RSA~1", "SERVIC~1.JSO", "SECRET~1.JSO", "MY-LON~1.PEM", "SERVER~1.KEY", "STATE~1.TFS", "PROD~1.TFV", "VAULT~1.KDB", "API-KE~1.SEC", "dir/ENV~1",
|
|
445
|
+
];
|
|
446
|
+
const NOT_SENSITIVE = ["src/environment.js", "env.js", "src/env/index.js", "notes~1.txt", "enviro~1.txt", "a~1.txt", "credential-store.js", "kube.js", "src/kubeconfigs.md.txt".replace("kubeconfigs.md.txt", "kubectl.md"),
|
|
447
|
+
"README~1.md", "id_rsa_notes_dir/readme.md".replace("id_rsa_notes_dir/", "docs/id-rsa-")];
|
|
448
|
+
|
|
449
|
+
test("deny list (F3-07b): .env~, NTFS streams (::$DATA), kubeconfig, id_*, *.kdbx, .npmrc, .pypirc, credentials*, 8.3 aliases; look-alikes stay readable", () => {
|
|
450
|
+
for (const p of SENSITIVE) assert.equal(isSensitivePath(p), true, p);
|
|
451
|
+
for (const p of NOT_SENSITIVE) assert.equal(isSensitivePath(p), false, p);
|
|
452
|
+
for (const p of [".env.", ".env ", ".ENV. .", "dir/.env..."]) assert.equal(isSensitivePath(p), true, "trailing dots/spaces: " + JSON.stringify(p));
|
|
453
|
+
assert.equal(isSensitivePath("a:b/c.txt"), false, "a colon alone is an ordinary character");
|
|
454
|
+
assert.equal(isSensitivePath("cert:v1.pem"), true, "an extension that is sensitive stays sensitive with a colon in the name");
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test("deny list: nothing in it can be minted as an id and a swapped-in sensitive name is forbidden at read time", async () => {
|
|
458
|
+
await withRig({}, async (rig) => {
|
|
459
|
+
for (const rel of [".env~", ".env::$DATA", "kubeconfig", "CREDEN~1.JSO", "ENV~1", "credentials.json", ".npmrc"]) {
|
|
460
|
+
assert.equal(rig.mint("file", rel), null, rel);
|
|
461
|
+
}
|
|
462
|
+
write(rig.root(), "ok.txt", "fine");
|
|
463
|
+
const id = rig.mint("file", "ok.txt");
|
|
464
|
+
fs.rmSync(path.join(rig.root(), "ok.txt"));
|
|
465
|
+
write(rig.root(), "kubeconfig", "apiVersion: v1\ntoken: abc\n");
|
|
466
|
+
fs.symlinkSync(path.join(rig.root(), "kubeconfig"), path.join(rig.root(), "ok.txt"));
|
|
467
|
+
assert.equal((await rig.request({ ref: id })).status, "forbidden", "a harmless name that is a link to kubeconfig");
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test("normalizeRel still refuses what it refused (regression of the wider list)", () => {
|
|
472
|
+
for (const bad of ["../a", "/a", "C:\\a", "a\u0000b", ""]) assert.equal(normalizeRel(bad), null);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
// ------------------------------------------------------------------------------------------ 7. git filters
|
|
476
|
+
|
|
477
|
+
/** A repo with a clean/smudge/process filter that would touch a marker file; returns where the marker would be. */
|
|
478
|
+
function repoWithFilters(root, { required = true, names = ["evil"], withProcess = true } = {}) {
|
|
479
|
+
const marks = path.join(tmpDir("f307b-marks-"), "m");
|
|
480
|
+
fs.mkdirSync(marks);
|
|
481
|
+
write(root, ".gitattributes", "*.js filter=evil\n*.txt filter=we.ird.name\n");
|
|
482
|
+
write(root, "a.js", "one\n");
|
|
483
|
+
write(root, "b.txt", "one\n");
|
|
484
|
+
initRepo(root);
|
|
485
|
+
const sh = (n) => `touch '${path.join(marks, n)}'; cat`;
|
|
486
|
+
git(root, "config", "filter.evil.clean", sh("evil-clean"));
|
|
487
|
+
git(root, "config", "filter.evil.smudge", sh("evil-smudge"));
|
|
488
|
+
if (withProcess) git(root, "config", "filter.evil.process", `touch '${path.join(marks, "evil-process")}'`);
|
|
489
|
+
git(root, "config", "filter.we.ird.name.clean", sh("dotted-clean"));
|
|
490
|
+
if (required) { git(root, "config", "filter.evil.required", "true"); git(root, "config", "filter.we.ird.name.required", "true"); }
|
|
491
|
+
for (const n of names.slice(1)) git(root, "config", `filter.${n}.clean`, sh(`${n}-clean`));
|
|
492
|
+
write(root, "a.js", "two\n");
|
|
493
|
+
write(root, "b.txt", "two\n");
|
|
494
|
+
return marks;
|
|
495
|
+
}
|
|
496
|
+
const marksOf = (dir) => fs.readdirSync(dir);
|
|
497
|
+
|
|
498
|
+
test("git filters (F3-07b): the clean filter of the repository config is NOT executed by the diff (control: plain `git diff` executes it); the diff is still right", async () => {
|
|
499
|
+
const root = tmpDir(); const marks = repoWithFilters(root, { required: false, withProcess: false });
|
|
500
|
+
// control: without the neutralisation git runs the repository's command (this is what the finding was about)
|
|
501
|
+
git(root, "-c", "core.fsmonitor=false", "diff", "--no-ext-diff", "--no-textconv", "--name-only", "HEAD");
|
|
502
|
+
assert.ok(marksOf(marks).length > 0, "control: a plain git diff DID run a filter here; if this fails the test below proves nothing");
|
|
503
|
+
for (const f of marksOf(marks)) fs.rmSync(path.join(marks, f));
|
|
504
|
+
const d = await worktreeDiff({ root, maxBytes: 1 << 20, maxFiles: 50, timeoutMs: 8000 });
|
|
505
|
+
assert.equal(d.ok, true);
|
|
506
|
+
assert.match(d.text, /\+two/);
|
|
507
|
+
assert.match(d.text, /a\.js/);
|
|
508
|
+
assert.match(d.text, /b\.txt/);
|
|
509
|
+
assert.deepEqual(marksOf(marks), [], "no clean, smudge or process command ran");
|
|
510
|
+
fs.rmSync(root, { recursive: true, force: true }); fs.rmSync(marks, { recursive: true, force: true });
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test("git filters: required=true filters, process filters and filter names with dots are neutralised too (a required filter with an empty command would abort git)", async () => {
|
|
514
|
+
const root = tmpDir(); const marks = repoWithFilters(root, { required: true });
|
|
515
|
+
const d = await worktreeDiff({ root, maxBytes: 1 << 20, maxFiles: 50, timeoutMs: 8000 });
|
|
516
|
+
assert.equal(d.ok, true, JSON.stringify(d));
|
|
517
|
+
assert.match(d.text, /\+two/);
|
|
518
|
+
assert.deepEqual(marksOf(marks), []);
|
|
519
|
+
fs.rmSync(root, { recursive: true, force: true }); fs.rmSync(marks, { recursive: true, force: true });
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
test("git filters: a filter in the user's GLOBAL git config is neutralised as well", async () => {
|
|
523
|
+
const root = tmpDir(); write(root, ".gitattributes", "*.js filter=gl\n"); write(root, "a.js", "one\n"); initRepo(root);
|
|
524
|
+
write(root, "a.js", "two\n");
|
|
525
|
+
const home = tmpDir("f307b-home-"); const marks = tmpDir("f307b-gm-");
|
|
526
|
+
fs.writeFileSync(path.join(home, ".gitconfig"), `[filter "gl"]\n\tclean = touch '${path.join(marks, "global")}'; cat\n\trequired = true\n`);
|
|
527
|
+
const oldHome = process.env.HOME; const oldUp = process.env.USERPROFILE; const oldXdg = process.env.XDG_CONFIG_HOME;
|
|
528
|
+
process.env.HOME = home; process.env.USERPROFILE = home; process.env.XDG_CONFIG_HOME = path.join(home, ".config");
|
|
529
|
+
try {
|
|
530
|
+
const o = await filterOverrides({ cwd: root, timeoutMs: 5000 });
|
|
531
|
+
assert.equal(o.ok, true);
|
|
532
|
+
assert.ok(o.args.includes("filter.gl.clean="), "the global filter is listed");
|
|
533
|
+
const d = await worktreeDiff({ root, maxBytes: 1 << 20, maxFiles: 50, timeoutMs: 8000 });
|
|
534
|
+
assert.equal(d.ok, true);
|
|
535
|
+
assert.match(d.text, /\+two/);
|
|
536
|
+
assert.deepEqual(marksOf(marks), []);
|
|
537
|
+
} finally {
|
|
538
|
+
for (const [k, v] of [["HOME", oldHome], ["USERPROFILE", oldUp], ["XDG_CONFIG_HOME", oldXdg]]) { if (v === undefined) delete process.env[k]; else process.env[k] = v; }
|
|
539
|
+
for (const d of [root, home, marks]) fs.rmSync(d, { recursive: true, force: true });
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
test("git filters: fail closed when the repository defines an absurd number of filters; none defined is a normal diff", async () => {
|
|
544
|
+
const root = tmpDir(); write(root, "a.txt", "one\n"); initRepo(root); write(root, "a.txt", "two\n");
|
|
545
|
+
const none = await filterOverrides({ cwd: root, timeoutMs: 5000 });
|
|
546
|
+
assert.equal(none.ok, true); // (a global/system git-lfs config, if any, is listed: it is neutralised too)
|
|
547
|
+
assert.equal(none.args.some((a) => a.startsWith("filter.f")), false);
|
|
548
|
+
for (let i = 0; i < 65; i++) git(root, "config", `filter.f${i}.clean`, "cat");
|
|
549
|
+
const d = await worktreeDiff({ root, maxBytes: 1 << 20, maxFiles: 50, timeoutMs: 8000 });
|
|
550
|
+
assert.deepEqual([d.ok, d.status, d.errorCode], [false, "error", "internal"]);
|
|
551
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
test("git filters: a repository config git cannot read (the filter list fails) means no diff, not a diff without the neutralisation", async () => {
|
|
555
|
+
const root = tmpDir(); write(root, "a.txt", "one\n"); initRepo(root); write(root, "a.txt", "two\n");
|
|
556
|
+
fs.appendFileSync(path.join(root, ".git", "config"), "\n[filter \"x\"\n clean = ((( broken\n");
|
|
557
|
+
const d = await worktreeDiff({ root, maxBytes: 1 << 20, maxFiles: 50, timeoutMs: 8000 });
|
|
558
|
+
assert.deepEqual([d.ok, d.status, d.errorCode], [false, "error", "internal"]);
|
|
559
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
test("git filters through the handler: kind diff worktree and an id, in enforce, run no filter; the answer is masked", async () => {
|
|
563
|
+
await withRig({}, async (rig) => {
|
|
564
|
+
const marks = repoWithFilters(rig.root(), { required: true });
|
|
565
|
+
rig.dirs.push(marks);
|
|
566
|
+
write(rig.root(), "a.js", `two\nk=${GH}\n`);
|
|
567
|
+
const r = await rig.request({ kind: "diff", ref: "worktree" });
|
|
568
|
+
assert.equal(r.status, "ok");
|
|
569
|
+
assert.equal(r.text.includes(GH), false);
|
|
570
|
+
assert.match(r.text, /\[REDACTED:github_token\]/);
|
|
571
|
+
const one = await rig.request({ kind: "diff", ref: rig.mint("diff", "a.js") });
|
|
572
|
+
assert.equal(one.status, "ok");
|
|
573
|
+
assert.deepEqual(marksOf(marks), [], "no filter command ran, for either request");
|
|
574
|
+
});
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test("git diff and hard links: a tracked file that is a hard link is left out of the diff by default (git would read the data of a file that may live outside the root); the flag lifts it", async (t) => {
|
|
578
|
+
const base = tmpDir(); const root = path.join(base, "ws"); fs.mkdirSync(root);
|
|
579
|
+
write(root, "plain.txt", "one\n"); write(root, "hard.txt", "one\n");
|
|
580
|
+
initRepo(root);
|
|
581
|
+
write(root, "plain.txt", "two\n"); write(root, "hard.txt", "two\n");
|
|
582
|
+
try { fs.linkSync(path.join(root, "hard.txt"), path.join(base, "alias.txt")); } catch { t.skip("hard links not supported here"); return; }
|
|
583
|
+
try {
|
|
584
|
+
const d = await worktreeDiff({ root, maxBytes: 1 << 20, maxFiles: 50, timeoutMs: 8000 });
|
|
585
|
+
assert.equal(d.ok, true);
|
|
586
|
+
assert.match(d.text, /plain\.txt/);
|
|
587
|
+
assert.equal(d.text.includes("hard.txt"), false);
|
|
588
|
+
const allowed = await worktreeDiff({ root, maxBytes: 1 << 20, maxFiles: 50, timeoutMs: 8000, allowHardlinks: true });
|
|
589
|
+
assert.match(allowed.text, /hard\.txt/);
|
|
590
|
+
} finally { fs.rmSync(base, { recursive: true, force: true }); }
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
test("git diff and hard links through the handler: LOCAL_AGENT_CONTENT_ALLOW_HARDLINKS reaches the diff (default: left out)", async (t) => {
|
|
594
|
+
for (const [allow, expected] of [[undefined, false], ["true", true]]) {
|
|
595
|
+
const base = tmpDir(); const root = path.join(base, "ws"); fs.mkdirSync(root);
|
|
596
|
+
write(root, "plain.txt", "one\n"); write(root, "hard.txt", "one\n");
|
|
597
|
+
initRepo(root);
|
|
598
|
+
write(root, "plain.txt", "two\n"); write(root, "hard.txt", "two\n");
|
|
599
|
+
try { fs.linkSync(path.join(root, "hard.txt"), path.join(base, "alias.txt")); } catch { t.skip("hard links not supported here"); fs.rmSync(base, { recursive: true, force: true }); return; }
|
|
600
|
+
await withRig({ workspaces: { "ws-1": { root } }, env: allow ? { LOCAL_AGENT_CONTENT_ALLOW_HARDLINKS: allow } : {} }, async (rig) => {
|
|
601
|
+
const r = await rig.request({ kind: "diff", ref: "worktree" });
|
|
602
|
+
assert.equal(r.status, "ok");
|
|
603
|
+
assert.match(r.text, /plain\.txt/);
|
|
604
|
+
assert.equal(r.text.includes("hard.txt"), expected, String(allow));
|
|
605
|
+
});
|
|
606
|
+
fs.rmSync(base, { recursive: true, force: true });
|
|
607
|
+
}
|
|
608
|
+
});
|