deepdebug-local-agent 1.6.0 → 1.8.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 (56) hide show
  1. package/docs/la-protocol-v2.md +41 -11
  2. package/env.example +10 -1
  3. package/package.json +1 -1
  4. package/scripts/incident-parity/Gen.java +256 -0
  5. package/scripts/incident-parity/LogEntry.java +17 -0
  6. package/scripts/incident-parity/extract.py +51 -0
  7. package/scripts/incident-parity/gen.sh +32 -0
  8. package/src/incident-detector/README.md +51 -0
  9. package/src/incident-detector/aggregator.js +73 -0
  10. package/src/incident-detector/categorize.js +48 -0
  11. package/src/incident-detector/config.js +95 -0
  12. package/src/incident-detector/detector.js +92 -0
  13. package/src/incident-detector/emitter.js +154 -0
  14. package/src/incident-detector/index.js +44 -0
  15. package/src/incident-detector/java-compat.js +66 -0
  16. package/src/incident-detector/normalize.js +209 -0
  17. package/src/incident-detector/parse.js +114 -0
  18. package/src/incident-detector/sources/file-source.js +113 -0
  19. package/src/incident-detector/sources/loki-source.js +101 -0
  20. package/src/incident-detector/sources/stream-source.js +39 -0
  21. package/src/incident-detector/store.js +119 -0
  22. package/src/la-protocol-v2/content-handlers/config.js +3 -1
  23. package/src/la-protocol-v2/content-handlers/content-index.js +54 -19
  24. package/src/la-protocol-v2/content-handlers/egress.js +22 -0
  25. package/src/la-protocol-v2/content-handlers/file-list.js +174 -0
  26. package/src/la-protocol-v2/content-handlers/git-diff.js +43 -8
  27. package/src/la-protocol-v2/content-handlers/handler.js +28 -4
  28. package/src/la-protocol-v2/content-handlers/install.js +24 -19
  29. package/src/la-protocol-v2/content-handlers/path-guard.js +74 -5
  30. package/src/la-protocol-v2/content-handlers/ref-registry.js +18 -8
  31. package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +10 -8
  32. package/src/la-protocol-v2/contract/README.md +38 -0
  33. package/src/la-protocol-v2/contract/content.index.schema.json +1 -0
  34. package/src/la-protocol-v2/contract/content.request.schema.json +1 -0
  35. package/src/la-protocol-v2/contract/file.list.body.schema.json +69 -0
  36. package/src/la-protocol-v2/contract.js +9 -0
  37. package/src/la-protocol-v2/runtime.js +11 -2
  38. package/test/contract/la-protocol-v2/vectors/content.index.vectors.json +32 -0
  39. package/test/contract/la-protocol-v2/vectors/content.request.vectors.json +215 -0
  40. package/test/contract/la-protocol-v2/vectors/content.response.vectors.json +30 -0
  41. package/test/contract/la-protocol-v2/vectors/file.list.body.vectors.json +1645 -0
  42. package/test/incident-detector/parity.test.js +66 -0
  43. package/test/incident-detector/parity.vectors.json +2619 -0
  44. package/test/incident-detector/sources.test.js +179 -0
  45. package/test/incident-detector/units.test.js +430 -0
  46. package/test/incident-detector/wiring.test.js +165 -0
  47. package/test/la-protocol-v2/content-alignment.test.js +71 -10
  48. package/test/la-protocol-v2/content-deny-list.test.js +138 -0
  49. package/test/la-protocol-v2/content-diff-symlink.test.js +83 -0
  50. package/test/la-protocol-v2/content-file-list.test.js +385 -0
  51. package/test/la-protocol-v2/content-path-guard.test.js +1 -1
  52. package/test/la-protocol-v2/content-wiring.test.js +2 -2
  53. package/test/la-protocol-v2/contract.test.js +14 -4
  54. package/tools/interop/LaV2NodeFileListE2ETest.java +369 -0
  55. package/tools/interop/README.md +16 -0
  56. package/tools/interop/la-interop-list-client.mjs +93 -0
@@ -0,0 +1,119 @@
1
+ /**
2
+ * store.js - the local detail store of detected errors (F3-11b). The TEXT of an error (message, stack trace) lives here and
3
+ * nowhere else: the control plane only ever gets metadata.
4
+ *
5
+ * file <storeDir>/incident-detail.jsonl, one JSON object per line, dir 0700 / file 0600, append only
6
+ * rotation at storeMaxBytes the file moves to incident-detail.jsonl.1 (one generation): disk use <= 2 x storeMaxBytes
7
+ * record {v, ts, errorHash, fingerprint, service, severity, windowStart, message, stack, stackBytes, truncated}
8
+ * message/stack are masked by the DLP (when DLP is on) and cut to storeMessageChars/storeStackChars
9
+ * sampling samplesPerWindow records per errorHash and window (the count is in the candidate, not in this file)
10
+ * queue at most storeMaxQueue writes waiting; add() resolves when the record is queued OR dropped (never blocks the caller
11
+ * for more than the queue allows: when full it waits for room, which slows the reading of the sources = backpressure)
12
+ * getDetail (errorHash, {limit}) reads the newest records back: this is the hook for `content.request` kind `incident.detail`
13
+ * (F3-07 handler, NOT wired here: the contract/gateway kind and its ref scheme are not defined yet, see README.md)
14
+ * never logs a path, a message or a stack: counters only. A broken disk disables the store, not the detector.
15
+ */
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+ import { protectEgress } from "../dlp/index.js";
19
+
20
+ const FILE = "incident-detail.jsonl";
21
+ const cut = (s, n) => (typeof s === "string" && s.length > n ? s.slice(0, n) : s);
22
+
23
+ export function createDetailStore({ dir, maxBytes, maxQueue, messageChars, stackChars, samplesPerWindow, env = process.env, now = Date.now }) {
24
+ const file = path.join(dir, FILE);
25
+ const stats = { written: 0, sampledOut: 0, dropped: 0, errors: 0, rotations: 0, redactions: 0, dlpDenied: 0 };
26
+ const samples = new Map(); // `${errorHash}|${windowStart}` -> records written
27
+ let queue = [];
28
+ let size = null;
29
+ let broken = false;
30
+ let writing = null;
31
+ let waiters = [];
32
+
33
+ async function ensure() {
34
+ if (size !== null) return;
35
+ await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
36
+ size = await fs.promises.stat(file).then((s) => s.size, () => 0);
37
+ }
38
+
39
+ async function drain() {
40
+ try {
41
+ await ensure();
42
+ while (queue.length) {
43
+ const batch = queue;
44
+ queue = [];
45
+ const lines = batch.map((r) => JSON.stringify(r) + "\n");
46
+ const bytes = lines.reduce((n, l) => n + Buffer.byteLength(l), 0);
47
+ if (size > 0 && size + bytes > maxBytes) {
48
+ await fs.promises.rename(file, `${file}.1`).catch(() => {});
49
+ size = 0;
50
+ stats.rotations++;
51
+ }
52
+ await fs.promises.appendFile(file, lines.join(""), { mode: 0o600 });
53
+ size += bytes;
54
+ stats.written += batch.length;
55
+ const w = waiters; waiters = []; for (const f of w) f();
56
+ }
57
+ } catch {
58
+ broken = true; // disk full, permissions...: the detector keeps going without a store
59
+ stats.errors++;
60
+ stats.dropped += queue.length;
61
+ queue = [];
62
+ } finally {
63
+ writing = null;
64
+ const w = waiters; waiters = []; for (const f of w) f();
65
+ }
66
+ }
67
+
68
+ const mask = (text) => {
69
+ if (typeof text !== "string" || text === "") return text;
70
+ const r = protectEgress(text, { env, source: "incident-store" });
71
+ if (r.denied) { stats.dlpDenied++; return "[DLP_DENIED]"; }
72
+ if (r.total) stats.redactions += r.total;
73
+ return r.text;
74
+ };
75
+
76
+ return {
77
+ stats,
78
+ get broken() { return broken; },
79
+ get queued() { return queue.length; },
80
+ /** @returns {Promise<boolean>} true = queued. */
81
+ async add({ errorHash, fingerprint, service, severity, windowStart, message, stack }) {
82
+ if (broken) { stats.dropped++; return false; }
83
+ const key = `${errorHash}|${windowStart}`;
84
+ const n = samples.get(key) || 0;
85
+ if (n >= samplesPerWindow) { stats.sampledOut++; return false; }
86
+ if (samples.size > 50_000) samples.clear(); // bounded: a reset only lets a few extra samples through
87
+ samples.set(key, n + 1);
88
+ while (queue.length >= maxQueue && !broken) await new Promise((res) => { waiters.push(res); if (!writing) writing = drain(); });
89
+ if (broken) { stats.dropped++; return false; }
90
+ const m = typeof message === "string" ? message : "";
91
+ const st = typeof stack === "string" ? stack : "";
92
+ queue.push({
93
+ v: 1, ts: new Date(now()).toISOString(), errorHash, fingerprint, service, severity, windowStart,
94
+ message: mask(cut(m, messageChars)), stack: st ? mask(cut(st, stackChars)) : null,
95
+ stackBytes: Buffer.byteLength(st), truncated: m.length > messageChars || st.length > stackChars,
96
+ });
97
+ if (!writing) writing = drain();
98
+ return true;
99
+ },
100
+ /** Newest first. Reads both generations; a bad line is skipped. */
101
+ async getDetail(errorHash, { limit = 5 } = {}) {
102
+ if (writing) await writing;
103
+ const out = [];
104
+ for (const f of [file, `${file}.1`]) {
105
+ let text;
106
+ try { text = await fs.promises.readFile(f, "utf8"); } catch { continue; }
107
+ const lines = text.split("\n");
108
+ for (let i = lines.length - 1; i >= 0 && out.length < limit; i--) {
109
+ if (!lines[i].includes(errorHash)) continue;
110
+ try { const r = JSON.parse(lines[i]); if (r.errorHash === errorHash) out.push(r); } catch { /* skip */ }
111
+ }
112
+ if (out.length >= limit) break;
113
+ }
114
+ return out;
115
+ },
116
+ /** Waits until everything queued is on disk (tests, shutdown). */
117
+ async flush() { if (!writing && queue.length) writing = drain(); if (writing) await writing; },
118
+ };
119
+ }
@@ -2,7 +2,7 @@
2
2
  * config.js - LOCAL_AGENT_CONTENT_HANDLERS flag and limits of the content.request handlers (F3-07).
3
3
  *
4
4
  * false (default) `content.request` keeps being acked `rejected protocol_unsupported`: nothing of this folder is loaded.
5
- * true handlers for `file` and `diff` are registered. Needs LOCAL_AGENT_PROTOCOL_V2=true as well
5
+ * true handlers for `file`, `diff` and `file.list` (F3-07c) are registered. Needs LOCAL_AGENT_PROTOCOL_V2=true as well
6
6
  * (without it there is no v2 session and the flag does nothing).
7
7
  *
8
8
  * Even when true, the handlers are registered only with LOCAL_AGENT_DLP_MODE=enforce (F3-07b, runtime.js): there is no
@@ -41,6 +41,8 @@ export function resolveContentHandlersConfig(env = process.env, warn = console.w
41
41
  // the gateway gives a request 30 s in total (contract: contentTotalMs)
42
42
  totalMs: int(env.LOCAL_AGENT_CONTENT_TOTAL_MS, 25_000, 1_000, 30_000),
43
43
  gitTimeoutMs: int(env.LOCAL_AGENT_CONTENT_GIT_TIMEOUT_MS, 8_000, 500, 20_000),
44
+ // F3-07c: file.list reads at most this many entries of one folder; a bigger folder is payload_too_large (never a partial listing)
45
+ maxDirEntries: int(env.LOCAL_AGENT_CONTENT_MAX_DIR_ENTRIES, 20_000, 100, 200_000),
44
46
  maxDiffFiles: int(env.LOCAL_AGENT_CONTENT_MAX_DIFF_FILES, 500, 1, 5_000),
45
47
  // additive to the built-in list (path-guard.js); comma separated globs
46
48
  extraDenyGlobs: String(env.LOCAL_AGENT_CONTENT_DENY_GLOBS ?? "").split(",").map((s) => s.trim()).filter(Boolean).slice(0, 200),
@@ -10,10 +10,13 @@
10
10
  * only if v2 session `ready`. install.js exists only with the content handlers on, and those only with DLP `enforce`.
11
11
  * bounds at most `maxIds` (<= 2048, the gateway's per-session limit) live announced ids; the rest is still served, only
12
12
  * not announced. One frame in flight at a time (the ack is the answer).
13
- * the ack accepted = stored. rejected rate_limited = nothing of that frame was stored: back off (30 s, doubling to 5 min)
14
- * and announce again. rejected protocol_unsupported = this gateway keeps no index: stop until the next `ready`.
15
- * rejected internal / timeout = try again after a short pause. Anything else (reset, not_ready): the next `ready`
16
- * announces again.
13
+ * the ack accepted = stored. A frame that fails puts back ONLY ITS OWN entries (F3-07c: it used to wipe the queue, including
14
+ * other workspaces' batches that had nothing to do with the failure); the rest of the queue is untouched.
15
+ * rejected rate_limited = nothing of that frame was stored: back off (30 s, doubling to 5 min), then send the failed
16
+ * batch again. rejected protocol_unsupported = this gateway keeps no index: the queue is useless, drop it and stop
17
+ * until the next `ready`. rejected internal / timeout = send again after a short pause, at most MAX_ATTEMPTS times in
18
+ * a row (then the batch is dropped and counted; the ids stay served, only not announced). Anything else (reset,
19
+ * not_ready): the batch stays queued; the next `ready` announces again (a non-resumed one rebuilds the queue).
17
20
  * never logs an id, a size or a path: counters and codes only.
18
21
  */
19
22
  import { ProtocolError } from "../errors.js";
@@ -22,15 +25,19 @@ const MAX_PER_FRAME = 256;
22
25
  const BACKOFF_MIN_MS = 30_000;
23
26
  const BACKOFF_MAX_MS = 300_000;
24
27
  const RETRY_MS = 5_000;
28
+ const MAX_ATTEMPTS = 5;
25
29
 
26
30
  export function createContentIndexer({ session, registry, config, log = console, now = Date.now, timers = { setTimeout, clearTimeout } }) {
27
31
  const warn = (m) => (log.warn || log.log).call(log, m);
28
32
  const pending = new Map(); // workspaceId -> [{kind, ref, size}]
33
+ const queued = new Set(); // refs in `pending` (an id is queued once, whoever asks: add(), announce(), a retry)
34
+ let attempts = 0; // consecutive transient failures of the batch at the head
29
35
  const announced = new Map(); // id -> expiresAt (local clock), ids the gateway accepted in this session
30
36
  let batchTimer = null;
31
37
  let retryTimer = null;
32
38
  let stopped = false; // protocol_unsupported: stay quiet until the next ready
33
39
  let backoffMs = 0;
40
+ let retryNotBefore = 0; // no send before this time after a rate_limited answer, whoever asks (a listing mints up to 1000 ids)
34
41
  let closed = false;
35
42
  const stats = { queued: 0, frames: 0, entries: 0, accepted: 0, rejected: 0, dropped: 0, byCode: {} };
36
43
 
@@ -45,12 +52,12 @@ export function createContentIndexer({ session, registry, config, log = console,
45
52
  function enqueue(e) {
46
53
  if (closed || stopped) { stats.dropped++; return false; }
47
54
  pruneAnnounced();
48
- let queued = 0;
49
- for (const list of pending.values()) queued += list.length;
50
- if (announced.size + queued >= config.indexMaxIds) { stats.dropped++; return false; }
55
+ if (queued.has(e.ref)) return false; // already waiting to be sent
56
+ if (announced.size + queued.size >= config.indexMaxIds) { stats.dropped++; return false; }
51
57
  const list = pending.get(e.workspaceId) || [];
52
58
  list.push({ kind: e.kind, ref: e.ref, size: e.size, expiresAt: e.expiresAt });
53
59
  pending.set(e.workspaceId, list);
60
+ queued.add(e.ref);
54
61
  stats.queued++;
55
62
  return true;
56
63
  }
@@ -62,13 +69,19 @@ export function createContentIndexer({ session, registry, config, log = console,
62
69
 
63
70
  function scheduleRetry(ms) {
64
71
  if (closed || retryTimer) return;
65
- retryTimer = unref(timers.setTimeout(() => { retryTimer = null; announce(); }, ms));
72
+ // the failed batch is still in `pending`: sending again is a pump (announce() would also pick up what the gateway lost, but a
73
+ // retry after rate_limited/internal has nothing to rebuild)
74
+ retryTimer = unref(timers.setTimeout(() => { retryTimer = null; void pump(); }, ms));
66
75
  }
67
76
 
68
77
  let running = null;
69
78
  function pump() {
70
79
  if (running) return running;
71
80
  if (closed || stopped || !ready()) return Promise.resolve(); // not ready: the next `ready` announces everything still live
81
+ if (retryNotBefore > now()) { // rate limited: new ids wait inside the window, they do not restart it
82
+ scheduleRetry(retryNotBefore - now());
83
+ return Promise.resolve();
84
+ }
72
85
  running = drain().finally(() => { running = null; });
73
86
  return running;
74
87
  }
@@ -79,40 +92,60 @@ export function createContentIndexer({ session, registry, config, log = console,
79
92
  const next = pending.entries().next();
80
93
  if (next.done) break;
81
94
  const [workspaceId, list] = next.value;
82
- const batch = list.splice(0, MAX_PER_FRAME);
83
- if (list.length === 0) pending.delete(workspaceId);
84
- if (batch.length === 0) continue;
95
+ if (list.length === 0) { pending.delete(workspaceId); continue; }
96
+ // the batch stays in `pending` until the gateway accepted it: a failure leaves it (and every other workspace) where it was
97
+ const batch = list.slice(0, MAX_PER_FRAME);
85
98
  const entries = batch.map(({ kind, ref, size }) => ({ kind, ref, size }));
86
99
  stats.frames++;
87
100
  stats.entries += entries.length;
88
101
  try {
89
102
  await session.send("content.index", { entries }, { workspaceId });
90
- stats.accepted += entries.length;
91
- for (const b of batch) announced.set(b.ref, b.expiresAt);
92
103
  } catch (e) {
93
104
  const code = e instanceof ProtocolError ? e.code : "internal";
94
105
  stats.rejected += entries.length;
95
106
  stats.byCode[code] = (stats.byCode[code] || 0) + 1;
96
- pending.clear(); // the rest comes back with the next announce(); nothing is kept half-sent
97
107
  if (code === "protocol_unsupported") {
98
108
  stopped = true;
109
+ pending.clear(); queued.clear(); // this gateway keeps no index: nothing queued will ever be accepted
99
110
  warn("[la-content] the gateway keeps no content index (protocol_unsupported); not announcing ids until the next connection");
100
111
  } else if (code === "rate_limited") {
101
112
  backoffMs = Math.min(BACKOFF_MAX_MS, backoffMs ? backoffMs * 2 : BACKOFF_MIN_MS);
113
+ retryNotBefore = now() + backoffMs;
102
114
  warn(`[la-content] content.index rate limited; announcing again in ${Math.round(backoffMs / 1000)} s`);
103
115
  scheduleRetry(backoffMs);
104
116
  } else if (code === "internal" || code === "timeout") {
105
- warn(`[la-content] content.index not accepted (${code}); trying again shortly`);
106
- scheduleRetry(RETRY_MS);
117
+ if (++attempts >= MAX_ATTEMPTS) {
118
+ attempts = 0;
119
+ discard(workspaceId, list, batch);
120
+ stats.dropped += batch.length;
121
+ warn(`[la-content] content.index not accepted (${code}); gave up on one batch after ${MAX_ATTEMPTS} attempts`);
122
+ if (pending.size > 0) scheduleRetry(RETRY_MS);
123
+ } else {
124
+ warn(`[la-content] content.index not accepted (${code}); trying again shortly`);
125
+ scheduleRetry(RETRY_MS);
126
+ }
107
127
  }
108
- // other codes (not_ready, agent_offline, session reset): the next ready announces again
128
+ // other codes (not_ready, agent_offline, session reset): the batch stays queued; the next ready announces again
109
129
  return;
110
130
  }
131
+ attempts = 0;
132
+ stats.accepted += entries.length;
133
+ for (const b of batch) announced.set(b.ref, b.expiresAt);
134
+ discard(workspaceId, list, batch);
111
135
  }
112
136
  backoffMs = 0;
137
+ retryNotBefore = 0;
113
138
  } catch { /* nothing here may throw into the data path */ }
114
139
  }
115
140
 
141
+ /** Takes a sent (or abandoned) batch out of the queue, unless a new `ready` already replaced the queue meanwhile. */
142
+ function discard(workspaceId, list, batch) {
143
+ if (pending.get(workspaceId) !== list) return;
144
+ for (const b of batch) queued.delete(b.ref);
145
+ list.splice(0, batch.length);
146
+ if (list.length === 0) pending.delete(workspaceId);
147
+ }
148
+
116
149
  /** Queues every live id (of one workspace, or of all) that the gateway does not know yet, and sends. */
117
150
  function announce({ workspaceId } = {}) {
118
151
  if (closed || stopped) return 0;
@@ -131,7 +164,8 @@ export function createContentIndexer({ session, registry, config, log = console,
131
164
  const onReady = (info) => {
132
165
  stopped = false;
133
166
  backoffMs = 0;
134
- if (!info || !info.resumed) { announced.clear(); pending.clear(); announce(); }
167
+ retryNotBefore = 0;
168
+ if (!info || !info.resumed) { announced.clear(); pending.clear(); queued.clear(); attempts = 0; announce(); }
135
169
  else void pump();
136
170
  };
137
171
  session.on("ready", onReady);
@@ -145,7 +179,7 @@ export function createContentIndexer({ session, registry, config, log = console,
145
179
  return ok;
146
180
  },
147
181
  announce,
148
- get pending() { let n = 0; for (const l of pending.values()) n += l.length; return n; },
182
+ get pending() { return queued.size; },
149
183
  get announcedCount() { pruneAnnounced(); return announced.size; },
150
184
  /** Waits until nothing is queued or in flight (tests, shutdown). */
151
185
  async flush() { if (batchTimer) { timers.clearTimeout(batchTimer); batchTimer = null; } await pump(); await running; },
@@ -155,6 +189,7 @@ export function createContentIndexer({ session, registry, config, log = console,
155
189
  if (retryTimer) timers.clearTimeout(retryTimer);
156
190
  batchTimer = retryTimer = null;
157
191
  pending.clear();
192
+ queued.clear();
158
193
  session.off("ready", onReady);
159
194
  },
160
195
  };
@@ -33,6 +33,28 @@ export function contentTypeOf(rel, isText) {
33
33
  return t === "application/zip" ? t : "application/octet-stream";
34
34
  }
35
35
 
36
+ /**
37
+ * DLP of ONE file name (F3-07c, file.list): the same engine and configuration as for content, applied to each name on its own so a
38
+ * mask can never reach across the JSON of a listing. In enforce a hit is replaced by [REDACTED:<rule>]; a hit in
39
+ * LOCAL_AGENT_DLP_BLOCK_CLASSES withholds the entry (`blocked`); a DLP failure is `denied` (the whole listing fails closed).
40
+ * @returns {{name:string, blocked:boolean, denied:boolean, total:number, redactions:object}}
41
+ */
42
+ export function maskName(name, { env = process.env, source = "content.file.list" } = {}) {
43
+ const cfg = dlpConfigFromEnv(env);
44
+ if (cfg.mode !== "enforce") return { name: "", blocked: false, denied: true, total: 0, redactions: {} }; // never list unmasked
45
+ if (name.length > cfg.maxBytes) return { name: "", blocked: true, denied: false, total: 0, redactions: {} };
46
+ const res = protectEgress(name, { env, source });
47
+ if (res.denied || res.text === DENIED || res.text === OVERSIZE) return { name: "", blocked: false, denied: true, total: 0, redactions: {} };
48
+ return { name: res.text, blocked: !!res.blocked, denied: false, total: res.total, redactions: res.redactions };
49
+ }
50
+
51
+ /** A listing body the agent built itself (ids, sizes, times are not client content; the names were masked one by one). */
52
+ export function prepareList({ buffer, maxBytes, dlp }) {
53
+ const cfg = dlp || { mode: "enforce", total: 0, redactions: {} };
54
+ if (buffer.length > maxBytes) return { ok: false, status: "error", errorCode: "payload_too_large", dlp: cfg };
55
+ return { ok: true, buffer, contentType: "application/json", dlp: cfg };
56
+ }
57
+
36
58
  /**
37
59
  * @param {{buffer:Buffer, contentType?:string, range?:{offset:number,length:number}, maxBytes:number, env?:object, source:string}} o
38
60
  * @returns {{ok:true, buffer:Buffer, contentType:string, dlp:{mode:string,total:number,redactions:object}} |
@@ -0,0 +1,174 @@
1
+ /**
2
+ * file-list.js - `file.list` kind: ONE folder of a workspace, one page at a time (F3-07c). This is the producer of the opaque ids:
3
+ * every entry it hands out carries an id minted here (43 chars, bound to tenant + workspace + root + kind, 15 min), which is how the
4
+ * front asks for the file (`file`) or the sub folder (`file.list`) later. Before this nothing in the agent minted a `file` id.
5
+ *
6
+ * What is listed (the same rules as for serving, applied before anything is minted or named):
7
+ * - the folder: realpath inside the root, not sensitive under its logical OR its real name, a directory. `root` = the workspace root.
8
+ * - an entry is left out, silently (its name never leaves, nor a count of them): sensitive name (deny list, `dir` rules for folders),
9
+ * a name the schema cannot express (control chars, `/`, > 255, invalid UTF-8), anything that is not a regular file or a folder
10
+ * (socket, fifo, device), a symlink whose real target is outside the root / sensitive / not a file or folder / unreadable,
11
+ * a hard-linked file (unless LOCAL_AGENT_CONTENT_ALLOW_HARDLINKS), and a name that the DLP withholds (blocked class).
12
+ * - the DLP masks every name on its own (never the whole JSON: the ids are random 43-char strings and the high-entropy rule would
13
+ * mask them). The ids, sizes and times are produced here, they are not client content.
14
+ * - .gitignore is NOT consulted: this is a listing of what is on the disk, not of what git tracks; the deny list is the control, an
15
+ * ignored file is served as any other one (build output, logs, local notes), `.git/` and the secret files stay denied. It also
16
+ * avoids a process per request.
17
+ * Bounds: one level per call (no recursion, so no depth to run away), at most `maxDirEntries` entries read from the folder (more =
18
+ * `payload_too_large`, deterministic, never "the first N the disk happened to return"), `limit` entries per page (<= 1000), the body
19
+ * <= min(maxBytes, 1 MiB) (the gateway refuses a bigger listing), time = the request deadline (`signal`).
20
+ * Paging: order is dirs first, then names by UTF-8 bytes (= code point order). A page that ends before the folder does carries
21
+ * `nextRef`, a folder id that remembers where to continue (the position stays here; the id is the cursor).
22
+ * Nothing here logs, and no path or name is part of any error.
23
+ */
24
+ import fs from "node:fs";
25
+ import path from "node:path";
26
+ import { isSensitivePath } from "./path-guard.js";
27
+
28
+ export const ROOT_REF = "root";
29
+ export const MAX_LIST_BYTES = 1024 * 1024; // the gateway refuses a listing body above this (too_large)
30
+ const DEFAULT_LIMIT = 200;
31
+ const NAME_BAD = /[\u0000-\u001f\u007f/\ufffd]/;
32
+
33
+ const inside = (root, abs) => abs === root || abs.startsWith(root.endsWith(path.sep) ? root : root + path.sep);
34
+ const toPosix = (p) => p.split(path.sep).join("/");
35
+ const byteCmp = (a, b) => Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
36
+ /** dirs first, then by name bytes */
37
+ const order = (a, b) => (a.dir === b.dir ? byteCmp(a.name, b.name) : a.dir ? -1 : 1);
38
+
39
+ export function clampLimit(limit) {
40
+ return Number.isSafeInteger(limit) && limit >= 1 && limit <= 1000 ? limit : DEFAULT_LIMIT;
41
+ }
42
+
43
+ /**
44
+ * @param {object} o
45
+ * @param {string} o.root workspace root as registered
46
+ * @param {string|null} o.rel folder relative to the root (null = the root); already normalized by the caller (normalizeRel)
47
+ * @param {{dir:boolean,name:string}|null} [o.after] continue after this entry
48
+ * @param {number} o.limit
49
+ * @param {number} o.maxBytes the body may not exceed min(this, 1 MiB)
50
+ * @param {number} o.maxDirEntries
51
+ * @param {string[]} [o.denyGlobs]
52
+ * @param {boolean} [o.allowHardlinks]
53
+ * @param {AbortSignal} [o.signal]
54
+ * @param {(o:{kind:"file"|"file.list", rel:string|null, size:number, after?:object}) => ({id:string}|null)} o.mint
55
+ * @param {(name:string) => ({name:string, blocked:boolean, denied:boolean, total:number, redactions:object})} o.maskName
56
+ * @returns {Promise<{ok:true, body:Buffer, entries:number, dlp:{total:number, redactions:object}} |
57
+ * {ok:false, status:"not_found"|"forbidden"|"error", errorCode?:string}>}
58
+ */
59
+ export async function listFolder({ root, rel = null, after = null, limit, maxBytes, maxDirEntries, denyGlobs = [], allowHardlinks = false, signal, mint, maskName }) {
60
+ let rootReal;
61
+ try { rootReal = await fs.promises.realpath(root); } catch { return { ok: false, status: "not_found" }; }
62
+
63
+ // 1. the folder
64
+ let dirReal = rootReal;
65
+ let dirRel = "";
66
+ if (rel) {
67
+ if (isSensitivePath(rel, denyGlobs, { dir: true })) return { ok: false, status: "forbidden" };
68
+ const abs = path.join(rootReal, ...rel.split("/"));
69
+ if (!inside(rootReal, abs)) return { ok: false, status: "forbidden" };
70
+ try { dirReal = await fs.promises.realpath(abs); } catch (e) { return { ok: false, status: e && (e.code === "EACCES" || e.code === "EPERM") ? "forbidden" : "not_found" }; }
71
+ if (!inside(rootReal, dirReal)) return { ok: false, status: "forbidden" }; // a folder link that leaves the workspace
72
+ dirRel = toPosix(path.relative(rootReal, dirReal));
73
+ if (dirRel && isSensitivePath(dirRel, denyGlobs, { dir: true })) return { ok: false, status: "forbidden" }; // a link to a sensitive folder
74
+ }
75
+ try { if (!(await fs.promises.stat(dirReal)).isDirectory()) return { ok: false, status: "not_found" }; } catch { return { ok: false, status: "not_found" }; }
76
+
77
+ // 2. read the folder (bounded) and keep what may be shown, by name alone: no further I/O yet
78
+ const cands = [];
79
+ let seen = 0;
80
+ try {
81
+ const d = await fs.promises.opendir(dirReal);
82
+ try {
83
+ for await (const ent of d) {
84
+ if (++seen > maxDirEntries) return { ok: false, status: "error", errorCode: "payload_too_large" };
85
+ if (signal && signal.aborted) return { ok: false, status: "error", errorCode: "agent_offline" };
86
+ const name = ent.name;
87
+ if (typeof name !== "string" || name.length === 0 || name.length > 255 || NAME_BAD.test(name) || name === "." || name === "..") continue;
88
+ const childRel = dirRel ? `${dirRel}/${name}` : name;
89
+ cands.push({ name, childRel, dirent: ent });
90
+ }
91
+ } finally { await d.close().catch(() => {}); }
92
+ } catch (e) {
93
+ return { ok: false, status: e && (e.code === "EACCES" || e.code === "EPERM") ? "forbidden" : "not_found" };
94
+ }
95
+
96
+ // 3. classify (a symlink is judged by where it really leads), still without reading anything
97
+ const kept = [];
98
+ let n = 0;
99
+ for (const c of cands) {
100
+ if ((++n & 63) === 0 && signal && signal.aborted) return { ok: false, status: "error", errorCode: "agent_offline" };
101
+ const e = c.dirent;
102
+ let dir;
103
+ let link = false;
104
+ if (e.isDirectory()) dir = true;
105
+ else if (e.isFile()) dir = false;
106
+ else if (e.isSymbolicLink()) { link = true; }
107
+ else if (e.isBlockDevice() || e.isCharacterDevice() || e.isFIFO() || e.isSocket()) continue;
108
+ else { // the file system did not say (DT_UNKNOWN)
109
+ try { const st = await fs.promises.lstat(path.join(dirReal, c.name)); dir = st.isDirectory(); if (!dir && !st.isFile()) { if (!st.isSymbolicLink()) continue; link = true; } } catch { continue; }
110
+ }
111
+ if (!link && isSensitivePath(c.childRel, denyGlobs, { dir })) continue;
112
+ if (link) {
113
+ try {
114
+ const real = await fs.promises.realpath(path.join(dirReal, c.name));
115
+ if (!inside(rootReal, real) || real === rootReal) continue;
116
+ const st = await fs.promises.stat(real);
117
+ dir = st.isDirectory();
118
+ if (!dir && !st.isFile()) continue;
119
+ const relReal = toPosix(path.relative(rootReal, real));
120
+ if (isSensitivePath(c.childRel, denyGlobs, { dir }) || isSensitivePath(relReal, denyGlobs, { dir })) continue; // a harmless name that is a link to a secret
121
+ c.real = real;
122
+ c.relReal = relReal;
123
+ } catch { continue; }
124
+ }
125
+ c.dir = dir;
126
+ kept.push(c);
127
+ }
128
+ kept.sort(order);
129
+
130
+ // 4. one page: skip what the previous page already showed, then take entries until the page is full
131
+ const budget = Math.max(1024, Math.min(maxBytes, MAX_LIST_BYTES) - 4096); // room for the envelope of the JSON and for `nextRef`
132
+ let i = 0;
133
+ if (after) while (i < kept.length && order(kept[i], { dir: after.dir, name: after.name }) <= 0) i++;
134
+ const entries = [];
135
+ let bytes = 2 + 11 + 3 + 52; // {"entries":[]} + room
136
+ const dlp = { total: 0, redactions: {} };
137
+ let last = null;
138
+ let more = false;
139
+ for (; i < kept.length; i++) {
140
+ if (entries.length >= limit) { more = true; break; }
141
+ if ((i & 31) === 0 && signal && signal.aborted) return { ok: false, status: "error", errorCode: "agent_offline" };
142
+ const c = kept[i];
143
+ const target = c.real || path.join(dirReal, c.name);
144
+ let st;
145
+ try { st = await fs.promises.lstat(target); } catch { continue; }
146
+ if (c.dir ? !st.isDirectory() : !st.isFile()) continue; // changed under our feet
147
+ if (!c.dir && st.nlink > 1 && !allowHardlinks) continue; // may be a second name of a file outside the root
148
+ const masked = maskName(c.name);
149
+ if (masked.denied) return { ok: false, status: "error", errorCode: "internal" };
150
+ if (masked.blocked || !masked.name || masked.name.length > 255 || NAME_BAD.test(masked.name)) continue;
151
+ const size = c.dir ? 0 : Math.min(st.size, 2 ** 40);
152
+ const mtime = new Date(Number.isFinite(st.mtimeMs) ? Math.min(Math.max(st.mtimeMs, 0), 4_102_444_800_000) : 0).toISOString();
153
+ // measured BEFORE minting: an entry that does not fit must not leave an id behind
154
+ const len = Buffer.byteLength(JSON.stringify({ id: "A".repeat(43), name: masked.name, type: c.dir ? "dir" : "file", size, mtime })) + 1;
155
+ if (entries.length > 0 && bytes + len > budget) { more = true; break; }
156
+ const minted = mint({ kind: c.dir ? "file.list" : "file", rel: c.relReal || c.childRel, size });
157
+ if (!minted) continue;
158
+ const entry = { id: minted.id, name: masked.name, type: c.dir ? "dir" : "file", size, mtime };
159
+ bytes += len;
160
+ entries.push(entry);
161
+ last = { dir: c.dir, name: c.name };
162
+ dlp.total += masked.total;
163
+ for (const [k, v] of Object.entries(masked.redactions || {})) dlp.redactions[k] = (dlp.redactions[k] || 0) + v;
164
+ }
165
+
166
+ // 5. continuation: only when there is more to show after the last entry given
167
+ const body = { entries };
168
+ if (more && last) {
169
+ const next = mint({ kind: "file.list", rel: dirRel || null, size: 0, after: last });
170
+ if (!next) return { ok: false, status: "error", errorCode: "internal" };
171
+ body.nextRef = next.id;
172
+ }
173
+ return { ok: true, body: Buffer.from(JSON.stringify(body), "utf8"), entries: entries.length, dlp };
174
+ }
@@ -16,6 +16,11 @@
16
16
  * clean, smudge and process and `required=false` (git treats an empty command as "no filter"; a still-required one
17
17
  * would abort). Fail closed: if the list cannot be read, or has more than MAX_FILTERS names, there is no diff.
18
18
  * Consequence: a work tree that depends on a filter (git-lfs, keyword expansion) is diffed as stored, unfiltered.
19
+ *
20
+ * Symlinks (F3-07c). The hunk of a tracked symlink ("new file mode 120000", "+/abs/target") prints the TARGET of the link, i.e. an
21
+ * absolute path on this machine. Entries whose mode is 120000 on either side (git diff --raw: a link added, changed, removed, or a file
22
+ * replaced by a link and the other way round) never reach the second call; the names are also lstat-ed (a file turned into a link in
23
+ * between) and, as a last net, `dropSymlinkBlocks()` removes from the text any per-file block whose header says 120000.
19
24
  */
20
25
  import { spawn } from "node:child_process";
21
26
  import fs from "node:fs";
@@ -107,7 +112,7 @@ export async function worktreeDiff({ root, rel = null, maxBytes, maxFiles, timeo
107
112
  const filters = await filterOverrides({ cwd, timeoutMs, signal });
108
113
  if (!filters.ok) return filters;
109
114
  const safe = [...GIT_SAFE, ...filters.args];
110
- const listArgs = [...safe, "diff", "--name-only", "-z", "--no-renames", "--no-ext-diff", "--relative", "HEAD", "--", ...(only ? [only] : ["."])];
115
+ const listArgs = [...safe, "diff", "--raw", "-z", "--no-abbrev", "--no-renames", "--no-ext-diff", "--relative", "HEAD", "--", ...(only ? [only] : ["."])];
111
116
  const listed = await runGit(listArgs, { cwd, maxBytes: 4 * 1024 * 1024, timeoutMs, signal });
112
117
  if (listed.aborted) return { ok: false, status: "error", errorCode: "agent_offline" };
113
118
  if (listed.timedOut) return { ok: false, status: "error", errorCode: "timeout" };
@@ -115,10 +120,11 @@ export async function worktreeDiff({ root, rel = null, maxBytes, maxFiles, timeo
115
120
  if (listed.code !== 0) return { ok: false, status: "not_found" }; // not a repository, no HEAD yet, unsafe directory...
116
121
  const names = [];
117
122
  let argBytes = 0;
118
- for (const n of listed.out.toString("utf8").split("\0")) {
119
- const norm = n ? normalizeRel(n) : null;
123
+ for (const n of rawPaths(listed.out.toString("utf8"))) {
124
+ if (n.symlink) continue; // the hunk would print the target of the link
125
+ const norm = n.path ? normalizeRel(n.path) : null;
120
126
  if (!norm || isSensitivePath(norm, denyGlobs)) continue;
121
- if (!allowHardlinks && await isHardLinked(cwd, norm)) continue; // git would read the data of a file that may live outside the root
127
+ if (await isLinkOnDisk(cwd, norm, allowHardlinks)) continue; // a link (or a hard link) now, whatever git remembers
122
128
  argBytes += Buffer.byteLength(norm) + 1;
123
129
  if (names.length >= maxFiles || argBytes > 128 * 1024) break;
124
130
  names.push(norm);
@@ -130,13 +136,42 @@ export async function worktreeDiff({ root, rel = null, maxBytes, maxFiles, timeo
130
136
  if (diff.timedOut) return { ok: false, status: "error", errorCode: "timeout" };
131
137
  if (diff.overflow) return { ok: false, status: "error", errorCode: "payload_too_large" };
132
138
  if (diff.code !== 0) return { ok: false, status: "error", errorCode: "internal" };
133
- return { ok: true, text: diff.out.toString("utf8") };
139
+ return { ok: true, text: dropSymlinkBlocks(diff.out.toString("utf8")) };
140
+ }
141
+
142
+ /**
143
+ * `git diff --raw -z` -> [{path, symlink}]: ":<oldmode> <newmode> <oldsha> <newsha> <status>\0<path>\0" per entry (no renames: one path).
144
+ * @returns {{path:string, symlink:boolean}[]}
145
+ */
146
+ export function rawPaths(out) {
147
+ const parts = out.split("\0");
148
+ const res = [];
149
+ for (let i = 0; i < parts.length; i++) {
150
+ const meta = parts[i];
151
+ if (!meta.startsWith(":")) continue;
152
+ const f = meta.slice(1).split(" ");
153
+ const p = parts[i + 1];
154
+ i++;
155
+ if (typeof p !== "string") break;
156
+ res.push({ path: p, symlink: f[0] === "120000" || f[1] === "120000" });
157
+ }
158
+ return res;
159
+ }
160
+
161
+ /** Removes the per-file blocks of a diff whose header (up to the first hunk) names mode 120000 (a symlink): they print the link target. */
162
+ export function dropSymlinkBlocks(text) {
163
+ if (!text.includes("120000")) return text;
164
+ const blocks = text.split(/^(?=diff --git )/m);
165
+ return blocks.filter((b) => {
166
+ const head = b.split(/^@@/m, 1)[0];
167
+ return !/^(?:(?:new file|deleted file|old|new) mode 120000|index [0-9a-f]+\.\.[0-9a-f]+ 120000)\s*$/m.test(head);
168
+ }).join("");
134
169
  }
135
170
 
136
- /** True for a regular file with more than one link. A name that no longer exists (a deletion) is not one. */
137
- async function isHardLinked(cwd, rel) {
171
+ /** True for a symlink, or (unless allowed) a regular file with more than one link. A name that no longer exists (a deletion) is neither. */
172
+ async function isLinkOnDisk(cwd, rel, allowHardlinks) {
138
173
  try {
139
174
  const st = await fs.promises.lstat(path.join(cwd, ...rel.split("/")));
140
- return st.isFile() && st.nlink > 1;
175
+ return st.isSymbolicLink() || (!allowHardlinks && st.isFile() && st.nlink > 1);
141
176
  } catch { return false; }
142
177
  }