deepdebug-local-agent 1.4.0 → 1.5.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.
@@ -50,18 +50,52 @@ It also needs `TENANT_ID` to be a UUID and the agent id (`authorizationId` of `~
50
50
  answers in the v1 shape. **The gateway (F3-05a) still refuses to send v1 commands to a v2 agent
51
51
  (`protocol_mismatch`)**: this is ready on the LA side, but it will only be exercised once the gateway sends them.
52
52
 
53
+ ## Content handlers (F3-07, `LOCAL_AGENT_CONTENT_HANDLERS`, default false)
54
+
55
+ Needs `LOCAL_AGENT_PROTOCOL_V2=true` too. Off (unset, empty, `false` or any unknown value): `content.request` is acked
56
+ `rejected protocol_unsupported` exactly as before and the register announces no `content.*` capability. On: kinds
57
+ `file` and `diff` are served (capabilities `content.file`, `content.diff`); any other kind of the allowlist answers
58
+ `content.response` `status:"error"` + `errorCode:"protocol_unsupported"`.
59
+
60
+ - **Opaque ids.** The `ref` is an id (`cr1_` + 128 random bits, base64url) minted by the agent, never derived from a path.
61
+ It maps in memory to (tenant, workspace, root, kind, relative path), lives `LOCAL_AGENT_CONTENT_REF_TTL_MS` (15 min) and
62
+ is forgotten when the agent restarts. Anything that is not a live id for this tenant+workspace+root+kind (unknown,
63
+ expired, another workspace, a path, `../x`) is `not_found` and is never echoed. The only other ref is the symbolic
64
+ `worktree` for kind `diff` (the work tree of the envelope's workspace). Whoever reports metadata to the control plane
65
+ gets an id with `link.content.mintRef({workspaceId, kind, rel})` (no caller yet: see the F3-07 note).
66
+ - **The agent is the authority on paths.** Workspace must be registered here and belong to this tenant; the real path
67
+ must stay inside the real root (a link that leaves it is `forbidden`), must not be sensitive under its real name,
68
+ must be a regular file opened without following links. Sensitive by default: `.env*`, `*.pem`, `*.key`, `*.p12`,
69
+ `id_rsa*`..., `.npmrc`, `.netrc`, `*.tfstate`, `.git/`, `.ssh/`, `.aws/`, `.docker/`... (`LOCAL_AGENT_CONTENT_DENY_GLOBS`
70
+ adds to the list). No id is ever minted for them.
71
+ - **DLP before anything leaves** (`LOCAL_AGENT_DLP_MODE`, the module `src/dlp`): `enforce` masks the whole text before it is
72
+ chunked (a secret cannot straddle chunks), withholds a file with a hit in `LOCAL_AGENT_DLP_BLOCK_CLASSES`, and fails
73
+ closed for binary files, inputs above `LOCAL_AGENT_DLP_MAX_BYTES` and DLP errors. `warn` and `off` send the content as
74
+ read (warn only counts). The agent warns at start-up when handlers are on and the mode is not `enforce`.
75
+ - **Limits.** `LOCAL_AGENT_CONTENT_MAX_BYTES` per response (default 1 MiB, ceiling 2 MiB = 64 chunks; a file above it is
76
+ `payload_too_large` without being read), `LOCAL_AGENT_CONTENT_MAX_CONCURRENT` (4) and `..._MAX_INFLIGHT_BYTES`
77
+ (`rate_limited` above), `LOCAL_AGENT_CONTENT_TOTAL_MS` (25 s), the session window (8 chunks in flight, backpressure).
78
+ Cancellation: `handler.cancel(requestId)`, the deadline, closing the link, or loss of the session.
79
+ - **Diff.** `git diff HEAD` of the work tree, no shell, without external diff/textconv/fsmonitor, without any `GIT_*` of
80
+ the agent's env, only for files that pass the same deny list, at most `LOCAL_AGENT_CONTENT_MAX_DIFF_FILES` (500).
81
+ - **Audit.** One local record per request (kind, status, code, sizes, DLP mode and per-rule counts, a short hash of the
82
+ ref): in memory and optionally `LOCAL_AGENT_CONTENT_AUDIT_FILE` (JSONL, 0600, rotates at 5 MiB). Never content,
83
+ paths or the ref.
84
+
53
85
  ## What it does NOT do
54
86
 
55
- No handler of `content.request` (F3-07), no DLP on what is sent (F3-09, must run before `sendContent` is
56
- called), no log/incident detector (F3-11b), no `job.assign` runtime (F3-08). Those frames are acked
57
- `rejected protocol_unsupported`. `sha256` of `content.response` is not sent (the contract does not say what it covers).
87
+ No handler for the other kinds (`incident.*`, `log.*`, `chat.session`, `wiki.page`, `job.*`, `approval.detail`, `report`),
88
+ no inventory frame that tells the control plane which ids exist (the contract has none), no log/incident detector
89
+ (F3-11b), no `job.assign` runtime (F3-08). Those frames are acked `rejected protocol_unsupported`. `sha256` of
90
+ `content.response` is not sent (the contract does not say what it covers).
58
91
 
59
92
  ## Tests
60
93
 
61
94
  `npm test` (node:test), all in `test/la-protocol-v2/`:
62
95
  `contract.test.js` (all 134 vectors of F3-22, valid and invalid, plus the drift check), `traces.test.js` (the 24
63
96
  traces), `session-inbound.test.js`, `session-outbound.test.js`, `link.test.js`, `tunnel-e2e.test.js` (the real
64
- `src/server.js` against a reference CP; flag off = v1 frames). The reference CP (`test/la-protocol-v2/helpers/reference-cp.js`)
97
+ `src/server.js` against a reference CP; flag off = v1 frames), and for F3-07 `content-path-guard.test.js`,
98
+ `content-handlers.test.js`, `content-wiring.test.js` (flag, runtime wiring, real `src/server.js`). The reference CP (`test/la-protocol-v2/helpers/reference-cp.js`)
65
99
  is a Node model of the gateway rules, not the gateway.
66
100
 
67
101
  **Interoperability with the real gateway session** (`LaV2Session`, Java, real Reactor Netty socket on loopback, real
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deepdebug-local-agent",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "DeepDebug Local Agent - AI-powered code debugging assistant",
5
5
  "private": false,
6
6
  "type": "module",
@@ -0,0 +1,36 @@
1
+ /**
2
+ * audit.js - local audit of served content (F3-07). One record per content.request, NEVER the content, a path or
3
+ * the ref: kind, status, sizes, DLP mode and per-rule counts, timing, a short hash of the ref and of the user id.
4
+ *
5
+ * ring the last `ringSize` records in memory (tests, support, future heartbeat summary)
6
+ * file optional JSONL (LOCAL_AGENT_CONTENT_AUDIT_FILE), mode 0600; at `maxFileBytes` it rotates to <file>.1
7
+ */
8
+ import crypto from "node:crypto";
9
+ import fs from "node:fs";
10
+
11
+ export const short = (v) => crypto.createHash("sha256").update(String(v ?? "")).digest("hex").slice(0, 16);
12
+
13
+ export function createAudit({ file = null, ringSize = 500, maxFileBytes = 5 * 1024 * 1024, now = Date.now } = {}) {
14
+ const ring = [];
15
+ let written = 0;
16
+ let broken = false;
17
+ if (file) { try { written = fs.statSync(file).size; } catch { written = 0; } }
18
+ return {
19
+ record(entry) {
20
+ const rec = { ts: new Date(now()).toISOString(), ...entry };
21
+ ring.push(rec);
22
+ if (ring.length > ringSize) ring.shift();
23
+ if (file && !broken) {
24
+ try {
25
+ const line = JSON.stringify(rec) + "\n";
26
+ if (written + line.length > maxFileBytes) { fs.renameSync(file, `${file}.1`); written = 0; }
27
+ fs.appendFileSync(file, line, { mode: 0o600 });
28
+ written += line.length;
29
+ } catch { broken = true; /* auditing to disk must never break the data path; the ring still has it */ }
30
+ }
31
+ return rec;
32
+ },
33
+ tail(n = 50) { return ring.slice(-n); },
34
+ get brokenFile() { return broken; },
35
+ };
36
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * config.js - LOCAL_AGENT_CONTENT_HANDLERS flag and limits of the content.request handlers (F3-07).
3
+ *
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
6
+ * (without it there is no v2 session and the flag does nothing).
7
+ *
8
+ * Unset/empty/unrecognised = false (an unknown value must never open the door to client content).
9
+ * Every number is clamped: a bad value falls back to the default, never to "unlimited".
10
+ */
11
+ export const CONTENT_HANDLERS_FLAG = "LOCAL_AGENT_CONTENT_HANDLERS";
12
+
13
+ const TRUE = new Set(["true", "1", "yes", "on"]);
14
+ const FALSE = new Set(["", "false", "0", "no", "off"]);
15
+
16
+ const MIB = 1024 * 1024;
17
+
18
+ function int(raw, def, min, max) {
19
+ const n = Number.parseInt(String(raw ?? ""), 10);
20
+ return Number.isFinite(n) && n >= min && n <= max ? n : def;
21
+ }
22
+
23
+ export function resolveContentHandlersConfig(env = process.env, warn = console.warn) {
24
+ const raw = String(env[CONTENT_HANDLERS_FLAG] ?? "").trim().toLowerCase();
25
+ if (!TRUE.has(raw)) {
26
+ if (!FALSE.has(raw)) warn(`[la-content] unrecognised ${CONTENT_HANDLERS_FLAG} value; content handlers stay off`);
27
+ return { enabled: false };
28
+ }
29
+ const maxConcurrent = int(env.LOCAL_AGENT_CONTENT_MAX_CONCURRENT, 4, 1, 16);
30
+ const maxBytes = int(env.LOCAL_AGENT_CONTENT_MAX_BYTES, 1 * MIB, 1, 2 * MIB); // per response (the contract caps it at 2 MiB)
31
+ return {
32
+ enabled: true,
33
+ maxBytes,
34
+ maxConcurrent,
35
+ // total bytes held in memory by requests in flight (a response is built whole, masked, then streamed)
36
+ // (each request reserves 2 x maxBytes: the bytes read and the masked copy)
37
+ maxInFlightBytes: int(env.LOCAL_AGENT_CONTENT_MAX_INFLIGHT_BYTES, 2 * maxBytes * maxConcurrent, 2 * maxBytes, 256 * MIB),
38
+ refTtlMs: int(env.LOCAL_AGENT_CONTENT_REF_TTL_MS, 15 * 60_000, 10_000, 24 * 3_600_000),
39
+ maxRefs: int(env.LOCAL_AGENT_CONTENT_MAX_REFS, 20_000, 100, 200_000),
40
+ // the gateway gives a request 30 s in total (contract: contentTotalMs)
41
+ totalMs: int(env.LOCAL_AGENT_CONTENT_TOTAL_MS, 25_000, 1_000, 30_000),
42
+ gitTimeoutMs: int(env.LOCAL_AGENT_CONTENT_GIT_TIMEOUT_MS, 8_000, 500, 20_000),
43
+ maxDiffFiles: int(env.LOCAL_AGENT_CONTENT_MAX_DIFF_FILES, 500, 1, 5_000),
44
+ // additive to the built-in list (path-guard.js); comma separated globs
45
+ extraDenyGlobs: String(env.LOCAL_AGENT_CONTENT_DENY_GLOBS ?? "").split(",").map((s) => s.trim()).filter(Boolean).slice(0, 200),
46
+ auditFile: String(env.LOCAL_AGENT_CONTENT_AUDIT_FILE ?? "").trim() || null,
47
+ };
48
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * egress.js - everything between "bytes read on this machine" and "bytes handed to sendContent" (F3-07).
3
+ *
4
+ * The DLP is the one of the agent (src/dlp, LOCAL_AGENT_DLP_MODE=off|warn|enforce, same rules, same counters):
5
+ * off content goes as read.
6
+ * warn content goes as read; the DLP only counts what enforce WOULD have masked (the existing "warn" meaning).
7
+ * enforce every hit is replaced by [REDACTED:<rule>]; a hit in LOCAL_AGENT_DLP_BLOCK_CLASSES withholds the whole
8
+ * content (`forbidden`); an input above LOCAL_AGENT_DLP_MAX_BYTES, a DLP failure, or a binary file (it cannot
9
+ * be inspected) never goes out (payload_too_large | internal | forbidden). Fail closed, as in the DLP module.
10
+ * The WHOLE text is masked before it is split into chunks, so a secret that would straddle two chunks is still found.
11
+ * A `range` is applied to the bytes AS SERVED (after masking): the offsets are stable between requests.
12
+ */
13
+ import { dlpConfigFromEnv, protectEgress, DENIED } from "../../dlp/index.js";
14
+ import { OVERSIZE } from "../../dlp/engine.js";
15
+
16
+ const utf8 = new TextDecoder("utf-8", { fatal: true });
17
+
18
+ export function decodeText(buffer) {
19
+ const probe = buffer.subarray(0, 8000);
20
+ if (probe.includes(0)) return null;
21
+ try { return utf8.decode(buffer); } catch { return null; }
22
+ }
23
+
24
+ const EXT_TYPES = new Map([
25
+ [".md", "text/markdown"], [".markdown", "text/markdown"], [".json", "application/json"],
26
+ [".diff", "text/x-diff"], [".patch", "text/x-diff"], [".zip", "application/zip"],
27
+ ]);
28
+
29
+ export function contentTypeOf(rel, isText) {
30
+ const m = /(\.[A-Za-z0-9]+)$/.exec(rel || "");
31
+ const t = m ? EXT_TYPES.get(m[1].toLowerCase()) : undefined;
32
+ if (isText) return t && t !== "application/zip" ? t : "text/plain";
33
+ return t === "application/zip" ? t : "application/octet-stream";
34
+ }
35
+
36
+ /**
37
+ * @param {{buffer:Buffer, contentType?:string, range?:{offset:number,length:number}, maxBytes:number, env?:object, source:string}} o
38
+ * @returns {{ok:true, buffer:Buffer, contentType:string, dlp:{mode:string,total:number,redactions:object}} |
39
+ * {ok:false, status:"forbidden"|"error", errorCode?:string, dlp:{mode:string,total:number,redactions:object}}}
40
+ */
41
+ export function prepareOutgoing({ buffer, contentType, range, maxBytes, env = process.env, source }) {
42
+ const cfg = dlpConfigFromEnv(env);
43
+ const dlp = { mode: cfg.mode, total: 0, redactions: {} };
44
+ let out = buffer;
45
+ let type = contentType;
46
+ const text = decodeText(buffer);
47
+ if (text === null) {
48
+ if (cfg.mode === "enforce") return { ok: false, status: "forbidden", dlp }; // cannot be inspected: fail closed
49
+ type = type && !type.startsWith("text/") ? type : "application/octet-stream";
50
+ } else if (cfg.mode !== "off") {
51
+ if (cfg.mode === "enforce" && text.length > cfg.maxBytes) return { ok: false, status: "error", errorCode: "payload_too_large", dlp };
52
+ const res = protectEgress(text, { env, source });
53
+ dlp.total = res.total;
54
+ dlp.redactions = res.redactions;
55
+ if (res.denied || res.text === DENIED) return { ok: false, status: "error", errorCode: "internal", dlp };
56
+ if (cfg.mode === "enforce") {
57
+ if (res.blocked) return { ok: false, status: "forbidden", dlp };
58
+ if (res.text === OVERSIZE) return { ok: false, status: "error", errorCode: "payload_too_large", dlp };
59
+ out = Buffer.from(res.text, "utf8");
60
+ }
61
+ }
62
+ if (range) out = out.subarray(Math.min(range.offset, out.length), Math.min(range.offset + range.length, out.length));
63
+ if (out.length > maxBytes) return { ok: false, status: "error", errorCode: "payload_too_large", dlp };
64
+ return { ok: true, buffer: out, contentType: type || "text/plain", dlp };
65
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * git-diff.js - `diff` kind: the work tree against HEAD, for the files that may be shown (F3-07).
3
+ *
4
+ * No shell. git runs with the agent's env minus secret-looking variables and minus every GIT_* variable (a stray
5
+ * GIT_DIR must not point it at another repository), without external diff drivers, textconv filters or fsmonitor
6
+ * hooks (a malicious repo config could name a command for each), with literal pathspecs. The file list is taken
7
+ * first and filtered (sensitive and out-of-root names never reach the second call), then diffed with that explicit list
8
+ * (at most `maxFiles` names and 128 KiB of arguments). Output is capped while it streams (the process is killed above the cap).
9
+ */
10
+ import { spawn } from "node:child_process";
11
+ import fs from "node:fs";
12
+ import { sanitizedChildEnv } from "../../utils/child-env.js";
13
+ import { isSensitivePath, normalizeRel } from "./path-guard.js";
14
+
15
+ const GIT_SAFE = ["--literal-pathspecs", "-c", "core.quotepath=off", "-c", "core.fsmonitor=false", "-c", "core.pager=cat", "-c", "diff.external="];
16
+
17
+ function gitEnv() {
18
+ const env = sanitizedChildEnv(process.env);
19
+ for (const k of Object.keys(env)) if (k.startsWith("GIT_")) delete env[k];
20
+ return { ...env, GIT_TERMINAL_PROMPT: "0", GIT_OPTIONAL_LOCKS: "0", GIT_PAGER: "cat" };
21
+ }
22
+
23
+ /** @returns {Promise<{code:number|null, out:Buffer, overflow:boolean, timedOut:boolean, aborted:boolean}>} */
24
+ function runGit(args, { cwd, input, maxBytes, timeoutMs, signal }) {
25
+ return new Promise((resolve) => {
26
+ let child;
27
+ try {
28
+ child = spawn("git", args, { cwd, env: gitEnv(), shell: false, stdio: ["pipe", "pipe", "ignore"], windowsHide: true });
29
+ } catch {
30
+ resolve({ code: null, out: Buffer.alloc(0), overflow: false, timedOut: false, aborted: false });
31
+ return;
32
+ }
33
+ const chunks = [];
34
+ let size = 0;
35
+ const st = { overflow: false, timedOut: false, aborted: false };
36
+ const kill = () => { try { child.kill("SIGKILL"); } catch { /* gone */ } };
37
+ const timer = setTimeout(() => { st.timedOut = true; kill(); }, timeoutMs);
38
+ const onAbort = () => { st.aborted = true; kill(); };
39
+ if (signal) { if (signal.aborted) onAbort(); else signal.addEventListener("abort", onAbort, { once: true }); }
40
+ child.stdout.on("data", (d) => {
41
+ size += d.length;
42
+ if (size > maxBytes) { st.overflow = true; kill(); return; }
43
+ chunks.push(d);
44
+ });
45
+ child.stdin.on("error", () => {});
46
+ child.stdin.end(input || "");
47
+ child.on("error", () => { clearTimeout(timer); resolve({ code: null, out: Buffer.alloc(0), ...st }); });
48
+ child.on("close", (code) => {
49
+ clearTimeout(timer);
50
+ if (signal) signal.removeEventListener("abort", onAbort);
51
+ resolve({ code, out: Buffer.concat(chunks), ...st });
52
+ });
53
+ });
54
+ }
55
+
56
+ /**
57
+ * @param {{root:string, rel?:string|null, maxBytes:number, maxFiles:number, timeoutMs:number, denyGlobs?:string[], signal?:AbortSignal}} o
58
+ * @returns {Promise<{ok:true, text:string} | {ok:false, status:"not_found"|"forbidden"|"error", errorCode?:string}>}
59
+ */
60
+ export async function worktreeDiff({ root, rel = null, maxBytes, maxFiles, timeoutMs, denyGlobs = [], signal }) {
61
+ let cwd;
62
+ try { cwd = await fs.promises.realpath(root); } catch { return { ok: false, status: "not_found" }; }
63
+ let only = null;
64
+ if (rel !== null) {
65
+ only = normalizeRel(rel);
66
+ if (!only) return { ok: false, status: "not_found" };
67
+ if (isSensitivePath(only, denyGlobs)) return { ok: false, status: "forbidden" };
68
+ }
69
+ const listArgs = [...GIT_SAFE, "diff", "--name-only", "-z", "--no-renames", "--no-ext-diff", "--relative", "HEAD", "--", ...(only ? [only] : ["."])];
70
+ const listed = await runGit(listArgs, { cwd, maxBytes: 4 * 1024 * 1024, timeoutMs, signal });
71
+ if (listed.aborted) return { ok: false, status: "error", errorCode: "agent_offline" };
72
+ if (listed.timedOut) return { ok: false, status: "error", errorCode: "timeout" };
73
+ if (listed.overflow) return { ok: false, status: "error", errorCode: "payload_too_large" };
74
+ if (listed.code !== 0) return { ok: false, status: "not_found" }; // not a repository, no HEAD yet, unsafe directory...
75
+ const names = [];
76
+ let argBytes = 0;
77
+ for (const n of listed.out.toString("utf8").split("\0")) {
78
+ const norm = n ? normalizeRel(n) : null;
79
+ if (!norm || isSensitivePath(norm, denyGlobs)) continue;
80
+ argBytes += Buffer.byteLength(norm) + 1;
81
+ if (names.length >= maxFiles || argBytes > 128 * 1024) break;
82
+ names.push(norm);
83
+ }
84
+ if (names.length === 0) return { ok: true, text: "" };
85
+ const diffArgs = [...GIT_SAFE, "diff", "--no-color", "--no-ext-diff", "--no-textconv", "--no-renames", "--relative", "-U3", "HEAD", "--", ...names];
86
+ const diff = await runGit(diffArgs, { cwd, maxBytes: maxBytes + 1, timeoutMs, signal });
87
+ if (diff.aborted) return { ok: false, status: "error", errorCode: "agent_offline" };
88
+ if (diff.timedOut) return { ok: false, status: "error", errorCode: "timeout" };
89
+ if (diff.overflow) return { ok: false, status: "error", errorCode: "payload_too_large" };
90
+ if (diff.code !== 0) return { ok: false, status: "error", errorCode: "internal" };
91
+ return { ok: true, text: diff.out.toString("utf8") };
92
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * handler.js - `content.request` handlers of the Local Agent (F3-07). Registered by runtime.js only when
3
+ * LOCAL_AGENT_PROTOCOL_V2=true AND LOCAL_AGENT_CONTENT_HANDLERS=true.
4
+ *
5
+ * Order of every request (nothing is read before the checks, nothing leaves before the DLP):
6
+ * 1. accept: the handler answers the session at once ({accept:true} = ack `accepted`); the work goes on in the
7
+ * background (the first chunk may take seconds, the ack timeout is 5 s). A request without requestId is refused.
8
+ * 2. admission: at most `maxConcurrent` requests and `maxInFlightBytes` in memory; above it -> `rate_limited`.
9
+ * 3. kind: `file` and `diff` are served; every other kind of the allowlist -> status `error` + `protocol_unsupported`
10
+ * (the contract has no "not supported" status; this is the code already used for "no handler").
11
+ * 4. workspace: the envelope's workspace must be registered here (and belong to this tenant) -> else `not_found`.
12
+ * 5. ref: an opaque id (ref-registry.js) valid for this tenant+workspace+root+kind -> else `not_found`, whatever the
13
+ * reason (unknown, expired, other workspace, or "../etc/passwd": no oracle, nothing echoed). The ref is never
14
+ * interpreted as a path.
15
+ * 6. path (file): realpath inside the root, not sensitive, regular file, <= maxBytes (path-guard.js) -> `forbidden` /
16
+ * `not_found` / `payload_too_large`.
17
+ * 7. DLP + range + size (egress.js), then 8. sendContent: <= 32 KiB per chunk, <= 64 chunks, window + backpressure of the
18
+ * session, aborted by cancel()/deadline/connection loss.
19
+ * Answers carry a status and a wire code only. Logs, errors and the audit never contain a path, a ref or content.
20
+ */
21
+ import { ProtocolError } from "../errors.js";
22
+ import { sendContent, sendContentFailure } from "../content.js";
23
+ import { REF_KINDS } from "./ref-registry.js";
24
+ import { normalizeRel, resolveInside, readRegular } from "./path-guard.js";
25
+ import { worktreeDiff } from "./git-diff.js";
26
+ import { prepareOutgoing, contentTypeOf, decodeText } from "./egress.js";
27
+ import { createAudit, short } from "./audit.js";
28
+
29
+ export const SERVED_KINDS = Object.freeze(["file", "diff"]);
30
+ /** Symbolic ref of kind `diff`: the whole work tree of the envelope's workspace (carries no client data). */
31
+ export const WORKTREE_REF = "worktree";
32
+
33
+ export function createContentHandler({ session, tenantId, resolveWorkspace, registry, config, env = process.env, log = console, audit = null, now = Date.now }) {
34
+ const inflight = new Map(); // requestId -> { ac, timer }
35
+ let reservedBytes = 0;
36
+ const auditor = audit || createAudit({ file: config.auditFile, now });
37
+ const reserveEach = 2 * config.maxBytes;
38
+ const warn = (m) => (log.warn || log.log).call(log, m);
39
+
40
+ const stats = { received: 0, ok: 0, notFound: 0, forbidden: 0, error: 0, cancelled: 0, byCode: {} };
41
+ const bump = (k) => { stats.byCode[k] = (stats.byCode[k] || 0) + 1; };
42
+
43
+ /** @returns {Promise<{status:string, errorCode?:string, buffer?:Buffer, contentType?:string, dlp?:object}>} */
44
+ async function produce(ctx, p, signal) {
45
+ if (!SERVED_KINDS.includes(p.kind)) return { status: "error", errorCode: "protocol_unsupported" };
46
+ const ws = await resolveWorkspace(ctx.workspaceId);
47
+ if (!ws || !ws.root) return { status: "not_found" };
48
+ const target = registry.resolve(p.ref, { workspaceId: ctx.workspaceId, root: ws.root, kind: p.kind });
49
+ if (p.kind === "diff" && !target && p.ref === WORKTREE_REF) return produceDiff(p, ws, null, signal);
50
+ if (!target) return { status: "not_found" };
51
+ if (p.kind === "diff") return produceDiff(p, ws, target.rel, signal);
52
+ return produceFile(p, ws, target.rel, signal);
53
+ }
54
+
55
+ async function produceFile(p, ws, rel, signal) {
56
+ const norm = rel && normalizeRel(rel);
57
+ if (!norm) return { status: "not_found" };
58
+ const resolved = await resolveInside(ws.root, norm, config.extraDenyGlobs);
59
+ if (!resolved.ok) return { status: resolved.status };
60
+ if (signal.aborted) return { status: "error", errorCode: "agent_offline" };
61
+ const read = await readRegular(resolved, config.maxBytes, signal);
62
+ if (!read.ok) return read.status === "too_large" ? { status: "error", errorCode: "payload_too_large" } : { status: read.status };
63
+ const isText = decodeText(read.buffer) !== null;
64
+ return finish(p, read.buffer, contentTypeOf(norm, isText), "content.file");
65
+ }
66
+
67
+ async function produceDiff(p, ws, rel, signal) {
68
+ const d = await worktreeDiff({ root: ws.root, rel, maxBytes: config.maxBytes, maxFiles: config.maxDiffFiles,
69
+ timeoutMs: config.gitTimeoutMs, denyGlobs: config.extraDenyGlobs, signal });
70
+ if (!d.ok) return d;
71
+ return finish(p, Buffer.from(d.text, "utf8"), "text/x-diff", "content.diff");
72
+ }
73
+
74
+ function finish(p, buffer, contentType, source) {
75
+ const out = prepareOutgoing({ buffer, contentType, range: p.range, maxBytes: config.maxBytes, env, source });
76
+ return out.ok ? { status: "ok", buffer: out.buffer, contentType: out.contentType, dlp: out.dlp }
77
+ : { status: out.status, errorCode: out.errorCode, dlp: out.dlp };
78
+ }
79
+
80
+ async function run(ctx, p, entry) {
81
+ const t0 = now();
82
+ let result;
83
+ let sent = null;
84
+ let errorCode;
85
+ try {
86
+ if (inflight.size > config.maxConcurrent || reservedBytes + reserveEach > config.maxInFlightBytes) {
87
+ result = { status: "error", errorCode: "rate_limited" };
88
+ } else {
89
+ reservedBytes += reserveEach;
90
+ entry.reserved = true;
91
+ result = await produce(ctx, p, entry.ac.signal);
92
+ }
93
+ if (entry.ac.signal.aborted) {
94
+ errorCode = "cancelled";
95
+ // out of time before the first chunk: the gateway still waits for an answer, give it one (a code only)
96
+ if (entry.reason === "deadline") {
97
+ await sendContentFailure(session, { requestId: ctx.requestId, status: "error", errorCode: "timeout", workspaceId: ctx.workspaceId }).catch(() => {});
98
+ errorCode = "timeout";
99
+ }
100
+ } else if (result.status === "ok") {
101
+ try {
102
+ sent = await sendContent(session, { requestId: ctx.requestId, source: result.buffer, contentType: result.contentType,
103
+ workspaceId: ctx.workspaceId, signal: entry.ac.signal });
104
+ } catch (e) {
105
+ errorCode = e instanceof ProtocolError ? (entry.ac.signal.aborted ? "cancelled" : e.code) : "internal";
106
+ }
107
+ } else {
108
+ await sendContentFailure(session, { requestId: ctx.requestId, status: result.status,
109
+ errorCode: result.errorCode || null, workspaceId: ctx.workspaceId });
110
+ }
111
+ } catch (e) {
112
+ errorCode = e instanceof ProtocolError ? e.code : "internal";
113
+ } finally {
114
+ clearTimeout(entry.timer);
115
+ if (entry.reserved) reservedBytes -= reserveEach;
116
+ inflight.delete(ctx.requestId);
117
+ }
118
+ const status = errorCode === "cancelled" || errorCode === "timeout" ? errorCode : errorCode ? "send_failed" : result.status;
119
+ if (status === "ok") stats.ok++;
120
+ else if (status === "not_found") stats.notFound++;
121
+ else if (status === "forbidden") stats.forbidden++;
122
+ else if (status === "cancelled" || status === "timeout") stats.cancelled++;
123
+ else stats.error++;
124
+ bump(errorCode || result.errorCode || status);
125
+ auditor.record({
126
+ requestId: ctx.requestId, workspaceId: ctx.workspaceId, kind: p.kind, status, errorCode: errorCode || result.errorCode || null,
127
+ bytes: sent ? sent.bytes : 0, chunks: sent ? sent.chunks : 0, ms: now() - t0,
128
+ dlpMode: result.dlp ? result.dlp.mode : null, dlpHits: result.dlp ? result.dlp.total : 0, dlpRules: result.dlp ? result.dlp.redactions : {},
129
+ refSha: short(p.ref), actorRole: p.actor && p.actor.role, actorSha: short(p.actor && p.actor.userId),
130
+ });
131
+ if (status === "send_failed") warn(`[la-content] ${p.kind} not delivered: ${errorCode}`);
132
+ }
133
+
134
+ return {
135
+ stats,
136
+ audit: auditor,
137
+ get inflight() { return inflight.size; },
138
+ get reservedBytes() { return reservedBytes; },
139
+
140
+ /** The session handler for `content.request` (session.setHandler). */
141
+ handle(ctx) {
142
+ stats.received++;
143
+ const p = ctx.params;
144
+ if (!ctx.requestId) return { reject: "schema_violation" };
145
+ if (inflight.has(ctx.requestId)) return { accept: true }; // already working on it (a retransmission with another seq)
146
+ const entry = { ac: new AbortController(), timer: null, reserved: false, reason: null };
147
+ entry.timer = setTimeout(() => { entry.reason = "deadline"; entry.ac.abort(); }, config.totalMs);
148
+ inflight.set(ctx.requestId, entry);
149
+ // background: ack first, answer later
150
+ queueMicrotask(() => { run(ctx, p, entry).catch(() => {}); });
151
+ return { accept: true };
152
+ },
153
+
154
+ /** Stops one request (no further chunk is read or written). There is no content.cancel frame in the contract. */
155
+ cancel(requestId) {
156
+ const e = inflight.get(requestId);
157
+ if (!e) return false;
158
+ e.reason = e.reason || "cancel";
159
+ e.ac.abort();
160
+ session.cancelWaiting(requestId);
161
+ return true;
162
+ },
163
+
164
+ cancelAll() {
165
+ for (const id of [...inflight.keys()]) this.cancel(id);
166
+ },
167
+ };
168
+ }
169
+
170
+ export { REF_KINDS };
@@ -0,0 +1,6 @@
1
+ export { CONTENT_HANDLERS_FLAG, resolveContentHandlersConfig } from "./config.js";
2
+ export { ContentRefRegistry, REF_PATTERN } from "./ref-registry.js";
3
+ export { createContentHandler, SERVED_KINDS, WORKTREE_REF } from "./handler.js";
4
+ export { installContentHandlers, CONTENT_CAPABILITIES } from "./install.js";
5
+ export { isSensitivePath, normalizeRel, resolveInside, DEFAULT_DENY_GLOBS } from "./path-guard.js";
6
+ export { createAudit } from "./audit.js";
@@ -0,0 +1,50 @@
1
+ /**
2
+ * install.js - puts the content handlers on a v2 link (F3-07). Called by runtime.js only when
3
+ * LOCAL_AGENT_CONTENT_HANDLERS=true (and v2 is on). Returns what the rest of the agent needs:
4
+ *
5
+ * mintRef({workspaceId, kind, rel}) the HOOK for whoever reports metadata to the control plane (file lists,
6
+ * diff summaries): returns an opaque id for that item, or null (sensitive
7
+ * path, unknown workspace). Nothing calls it yet: the contract has no
8
+ * LA->CP frame that carries an inventory (see the F3-07 note, open questions).
9
+ */
10
+ import { dlpConfigFromEnv } from "../../dlp/index.js";
11
+ import { ContentRefRegistry } from "./ref-registry.js";
12
+ import { createContentHandler, SERVED_KINDS } from "./handler.js";
13
+
14
+ export const CONTENT_CAPABILITIES = Object.freeze(SERVED_KINDS.map((k) => `content.${k}`));
15
+
16
+ export function installContentHandlers({ link, tenantId, wsManager, getWorkspaceRoot, config, env = process.env, log = console }) {
17
+ const tenant = String(tenantId).toLowerCase();
18
+ const registry = new ContentRefRegistry({ tenantId: tenant, ttlMs: config.refTtlMs, maxEntries: config.maxRefs, denyGlobs: config.extraDenyGlobs });
19
+
20
+ /** A workspace "registered here": open in the manager (of this tenant, or of none yet) or the default root. */
21
+ const resolveWorkspace = (workspaceId) => {
22
+ if (typeof workspaceId !== "string" || !workspaceId) return null;
23
+ const listed = (wsManager?.list?.() || []).find((w) => w && w.id === workspaceId);
24
+ if (listed) {
25
+ if (listed.tenantId && String(listed.tenantId).toLowerCase() !== tenant) return null;
26
+ return listed.root ? { root: listed.root } : null;
27
+ }
28
+ if (workspaceId === "default") { const root = getWorkspaceRoot(); return root ? { root } : null; }
29
+ return null;
30
+ };
31
+
32
+ const handler = createContentHandler({ session: link.session, tenantId: tenant, resolveWorkspace, registry, config, env, log });
33
+ link.session.setHandler("content.request", (ctx) => handler.handle(ctx));
34
+
35
+ const closeLink = link.close.bind(link);
36
+ link.close = () => { handler.cancelAll(); registry.clear(); closeLink(); };
37
+
38
+ if (dlpConfigFromEnv(env).mode !== "enforce") {
39
+ (log.warn || log.log).call(log, "[la-content] content handlers are on but LOCAL_AGENT_DLP_MODE is not enforce: file content leaves this machine unmasked");
40
+ }
41
+ link.content = {
42
+ registry,
43
+ handler,
44
+ mintRef: ({ workspaceId, kind, rel }) => {
45
+ const ws = resolveWorkspace(workspaceId);
46
+ return ws ? registry.mint({ workspaceId, root: ws.root, kind, rel }) : null;
47
+ },
48
+ };
49
+ return link.content;
50
+ }