deepdebug-local-agent 1.5.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 +46 -16
- 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/config.js +7 -1
- package/src/la-protocol-v2/content-handlers/content-index.js +161 -0
- package/src/la-protocol-v2/content-handlers/git-diff.js +57 -7
- package/src/la-protocol-v2/content-handlers/handler.js +24 -12
- package/src/la-protocol-v2/content-handlers/install.js +28 -13
- package/src/la-protocol-v2/content-handlers/path-guard.js +48 -7
- package/src/la-protocol-v2/content-handlers/ref-registry.js +27 -9
- 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 +11 -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 +55 -61
- package/test/la-protocol-v2/content-path-guard.test.js +9 -4
- package/test/la-protocol-v2/content-wiring.test.js +37 -12
- package/test/la-protocol-v2/contract.test.js +5 -5
- package/test/la-protocol-v2/helpers/reference-cp.js +8 -0
- package/test/la-protocol-v2/traces.test.js +29 -4
- package/tools/interop/LaV2NodeContentInteropTest.java +18 -4
- package/tools/interop/LaV2NodeContentRelayE2ETest.java +374 -0
- package/tools/interop/la-interop-content-client.mjs +19 -8
package/docs/la-protocol-v2.md
CHANGED
|
@@ -50,34 +50,61 @@ 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)
|
|
53
|
+
## Content handlers (F3-07 / F3-07b, `LOCAL_AGENT_CONTENT_HANDLERS`, default false)
|
|
54
54
|
|
|
55
|
-
Needs `LOCAL_AGENT_PROTOCOL_V2=true` too
|
|
56
|
-
`rejected protocol_unsupported` exactly as before and the register
|
|
57
|
-
`file` and `diff` are served (capabilities `content.file`,
|
|
58
|
-
`content.response` `status:"error"` + `errorCode:"protocol_unsupported"`.
|
|
55
|
+
Needs `LOCAL_AGENT_PROTOCOL_V2=true` too, **and `LOCAL_AGENT_DLP_MODE=enforce` (F3-07b, no opt-out)**. Off (unset, empty,
|
|
56
|
+
`false` or any unknown value): `content.request` is acked `rejected protocol_unsupported` exactly as before and the register
|
|
57
|
+
announces no `content.*` capability. On with DLP `enforce`: kinds `file` and `diff` are served (capabilities `content.file`,
|
|
58
|
+
`content.diff`); any other kind of the allowlist answers `content.response` `status:"error"` + `errorCode:"protocol_unsupported"`.
|
|
59
|
+
On with DLP anything else (`off`, `warn`, unset, an unknown value): the handlers are **not registered**, no `content.*`
|
|
60
|
+
capability is announced, `content.request` is acked `rejected protocol_unsupported`, and the agent logs an explicit ERROR at
|
|
61
|
+
start-up. The mode is also read at each request: leaving `enforce` at run time stops serving at once.
|
|
59
62
|
|
|
60
|
-
- **Opaque ids.** The `ref` is an id
|
|
63
|
+
- **Opaque ids.** The `ref` is an id of **43 base64url characters, no prefix** (32 random bytes = 256 bits, the shape the
|
|
64
|
+
gateway and the contract require; the F3-07 form `cr1_` + 22 is gone) minted by the agent, never derived from a path.
|
|
61
65
|
It maps in memory to (tenant, workspace, root, kind, relative path), lives `LOCAL_AGENT_CONTENT_REF_TTL_MS` (15 min) and
|
|
62
66
|
is forgotten when the agent restarts. Anything that is not a live id for this tenant+workspace+root+kind (unknown,
|
|
63
67
|
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
|
|
65
|
-
gets an id with `link.content.mintRef({workspaceId, kind, rel})`
|
|
68
|
+
`worktree` for kind `diff` (the whole work tree of the envelope's workspace; the gateway requires a workspaceId with it).
|
|
69
|
+
Whoever reports metadata to the control plane gets an id with `link.content.mintRef({workspaceId, kind, rel, size?})`
|
|
70
|
+
(no caller yet: see the F3-07 note); the id is announced in `content.index` (below).
|
|
71
|
+
- **`content.index` (LA -> CP, F3-07b).** An unsigned, unsolicited frame with the inventory of ids: `payload.entries[]`
|
|
72
|
+
(1..256) of `{kind, ref, size}`, for ONE workspace (the envelope's). It cannot carry a name or a path (the schema has no
|
|
73
|
+
such field). Sent only when the handlers are on (so DLP is `enforce`) and the session is `ready`: a batch after each
|
|
74
|
+
`mintRef` (`LOCAL_AGENT_CONTENT_INDEX_BATCH_MS`, 100 ms), and every live id again after a `ready` that is not a resume
|
|
75
|
+
(`announce()` does it on request). At most `LOCAL_AGENT_CONTENT_INDEX_MAX_IDS` (2048, the gateway's per-session limit) live
|
|
76
|
+
announced ids; one frame in flight. The ack is the answer: `rejected rate_limited` = nothing stored, back off 30 s doubling
|
|
77
|
+
to 5 min; `rejected protocol_unsupported` = the gateway keeps no index, stop until the next connection; `rejected internal`
|
|
78
|
+
or a timeout = retry in 5 s. The index is NOT an access list: the agent stays the authority.
|
|
66
79
|
- **The agent is the authority on paths.** Workspace must be registered here and belong to this tenant; the real path
|
|
67
80
|
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
|
|
69
|
-
`id_rsa*`..., `.npmrc`, `.
|
|
70
|
-
|
|
81
|
+
must be a regular file opened without following links. Sensitive by default: `.env*` (also `.env~`, `.env#`), `*.pem`,
|
|
82
|
+
`*.key`, `*.p12`, `*.kdbx`, `id_rsa*`..., `.npmrc`, `.pypirc`, `.netrc`, `credentials*` (so `credentials.service.ts` is
|
|
83
|
+
refused too: add nothing, rename or read it another way), `kubeconfig`, `*.tfstate`, `.git/`, `.ssh/`, `.aws/`, `.kube/`,
|
|
84
|
+
`.docker/`... plus the NTFS stream form of any of them (`.env::$DATA`) and the Windows 8.3 aliases (`ENV~1`, `CREDEN~1.JSO`,
|
|
85
|
+
`ID_ED2~1`). `LOCAL_AGENT_CONTENT_DENY_GLOBS` adds to the list. No id is ever minted for them.
|
|
86
|
+
- **Hard links (F3-07b).** A file with more than one hard link (`st.nlink > 1`) is `forbidden` by default: a hard link
|
|
87
|
+
cannot be told from the file itself and may be a second name of a file OUTSIDE the root, which the realpath check cannot
|
|
88
|
+
see. Checked on the path and again on the open descriptor; the diff leaves such files out. `LOCAL_AGENT_CONTENT_ALLOW_HARDLINKS=true`
|
|
89
|
+
lifts it (pnpm stores and some build trees hard-link every file).
|
|
71
90
|
- **DLP before anything leaves** (`LOCAL_AGENT_DLP_MODE`, the module `src/dlp`): `enforce` masks the whole text before it is
|
|
72
91
|
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`
|
|
74
|
-
|
|
92
|
+
closed for binary files, inputs above `LOCAL_AGENT_DLP_MAX_BYTES` and DLP errors. `warn` and `off` serve NOTHING (see
|
|
93
|
+
above).
|
|
75
94
|
- **Limits.** `LOCAL_AGENT_CONTENT_MAX_BYTES` per response (default 1 MiB, ceiling 2 MiB = 64 chunks; a file above it is
|
|
76
95
|
`payload_too_large` without being read), `LOCAL_AGENT_CONTENT_MAX_CONCURRENT` (4) and `..._MAX_INFLIGHT_BYTES`
|
|
77
96
|
(`rate_limited` above), `LOCAL_AGENT_CONTENT_TOTAL_MS` (25 s), the session window (8 chunks in flight, backpressure).
|
|
78
97
|
Cancellation: `handler.cancel(requestId)`, the deadline, closing the link, or loss of the session.
|
|
79
98
|
- **Diff.** `git diff HEAD` of the work tree, no shell, without external diff/textconv/fsmonitor, without any `GIT_*` of
|
|
80
99
|
the agent's env, only for files that pass the same deny list, at most `LOCAL_AGENT_CONTENT_MAX_DIFF_FILES` (500).
|
|
100
|
+
**Clean/smudge/process filters are neutralised (F3-07b)**: `--no-ext-diff`/`--no-textconv` do not stop `git diff` from
|
|
101
|
+
running the `filter.<name>.clean` command of the repository (or global) config, a command line chosen by whoever wrote the
|
|
102
|
+
repository. Before the diff the agent lists `filter.*` names (`git config --get-regexp '^filter\.'`, runs nothing) and
|
|
103
|
+
overrides each with an empty clean/smudge/process and `required=false`; if the list cannot be read, or has more than 64
|
|
104
|
+
names, there is no diff (fail closed). A tree that depends on a filter (git-lfs, keyword expansion) is diffed as stored.
|
|
105
|
+
- **When `produce()` fails (F3-07b).** A workspace lookup, registry or filesystem call that throws never leaves the gateway
|
|
106
|
+
waiting: it gets `content.response` `status:"error"` + `errorCode:"internal"` (a code, never the exception message, which
|
|
107
|
+
may hold a path) and the request is audited like any other; an audit failure does not stop the answer.
|
|
81
108
|
- **Audit.** One local record per request (kind, status, code, sizes, DLP mode and per-rule counts, a short hash of the
|
|
82
109
|
ref): in memory and optionally `LOCAL_AGENT_CONTENT_AUDIT_FILE` (JSONL, 0600, rotates at 5 MiB). Never content,
|
|
83
110
|
paths or the ref.
|
|
@@ -85,17 +112,20 @@ Needs `LOCAL_AGENT_PROTOCOL_V2=true` too. Off (unset, empty, `false` or any unkn
|
|
|
85
112
|
## What it does NOT do
|
|
86
113
|
|
|
87
114
|
No handler for the other kinds (`incident.*`, `log.*`, `chat.session`, `wiki.page`, `job.*`, `approval.detail`, `report`),
|
|
88
|
-
|
|
115
|
+
nothing in the agent calls `mintRef` yet (the producer of the file/diff inventory is a later increment), no log/incident detector
|
|
89
116
|
(F3-11b), no `job.assign` runtime (F3-08). Those frames are acked `rejected protocol_unsupported`. `sha256` of
|
|
90
117
|
`content.response` is not sent (the contract does not say what it covers).
|
|
91
118
|
|
|
92
119
|
## Tests
|
|
93
120
|
|
|
94
121
|
`npm test` (node:test), all in `test/la-protocol-v2/`:
|
|
95
|
-
`contract.test.js` (all
|
|
122
|
+
`contract.test.js` (all 168 vectors of F3-22, valid and invalid, plus the drift check), `traces.test.js` (the 26
|
|
96
123
|
traces), `session-inbound.test.js`, `session-outbound.test.js`, `link.test.js`, `tunnel-e2e.test.js` (the real
|
|
97
124
|
`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`)
|
|
125
|
+
`content-handlers.test.js`, `content-wiring.test.js` (flag, runtime wiring, real `src/server.js`), and for F3-07b
|
|
126
|
+
`content-alignment.test.js` (43-char ids and `worktree`, `content.index`, throwing producers, hard links, deny list, git filters).
|
|
127
|
+
Against the real gateway: `tools/interop` (`LaV2NodeContentInteropTest` straight into `LaV2Session`;
|
|
128
|
+
`LaV2NodeContentRelayE2ETest`: HTTP -> `ContentRelayController` -> `LaV2Session` -> this agent, no patch on either side). The reference CP (`test/la-protocol-v2/helpers/reference-cp.js`)
|
|
99
129
|
is a Node model of the gateway rules, not the gateway.
|
|
100
130
|
|
|
101
131
|
**Interoperability with the real gateway session** (`LaV2Session`, Java, real Reactor Netty socket on loopback, real
|
package/env.example
CHANGED
|
@@ -80,3 +80,7 @@ LOG_LEVEL=info
|
|
|
80
80
|
# DLP (T4-09): mask secrets/PII before content leaves the agent. Default off.
|
|
81
81
|
# LOCAL_AGENT_DLP_MODE=off|warn|enforce (warn = count only, enforce = redact)
|
|
82
82
|
# LOCAL_AGENT_DLP_BLOCK_CLASSES=private-key,cloud-credential (enforce: withhold whole text)
|
|
83
|
+
# LOCAL_AGENT_CONTENT_HANDLERS=true serves content.request kinds file/diff over protocol v2 (needs LOCAL_AGENT_PROTOCOL_V2=true AND
|
|
84
|
+
# LOCAL_AGENT_DLP_MODE=enforce: without enforce the handlers are NOT registered, no opt-out)
|
|
85
|
+
# LOCAL_AGENT_CONTENT_ALLOW_HARDLINKS=true serve files with more than one hard link (refused by default)
|
|
86
|
+
# LOCAL_AGENT_CONTENT_INDEX_MAX_IDS=2048 live ids announced to the gateway in content.index (max 2048)
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// scripts/sync-la-protocol-v2-contract.mjs - copies the CP <-> LA protocol v2 contract (F3-22) from a
|
|
3
3
|
// vh-api-gateway checkout into this repo and records where it came from.
|
|
4
4
|
//
|
|
5
|
-
// node scripts/sync-la-protocol-v2-contract.mjs <gateway-contract-dir> --commit <sha> [--tip <sha>]
|
|
5
|
+
// node scripts/sync-la-protocol-v2-contract.mjs <gateway-contract-dir> --commit <sha> [--tip <sha>] [--branch <name>]
|
|
6
6
|
//
|
|
7
7
|
// <gateway-contract-dir> is docs/contracts/la-protocol-v2 of vh-api-gateway (a worktree of release/qa, or
|
|
8
8
|
// the output of `git archive`). The gateway folder stays the only source of truth: never edit the copies.
|
|
@@ -20,7 +20,7 @@ const src = args.find((a) => !a.startsWith("--"));
|
|
|
20
20
|
const opt = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : undefined; };
|
|
21
21
|
const commit = opt("commit");
|
|
22
22
|
if (!src || !commit || !/^[0-9a-f]{40}$/.test(commit)) {
|
|
23
|
-
console.error("usage: sync-la-protocol-v2-contract.mjs <gateway docs/contracts/la-protocol-v2> --commit <40-hex sha> [--tip <sha>]");
|
|
23
|
+
console.error("usage: sync-la-protocol-v2-contract.mjs <gateway docs/contracts/la-protocol-v2> --commit <40-hex sha> [--tip <sha>] [--branch <name>]");
|
|
24
24
|
process.exit(2);
|
|
25
25
|
}
|
|
26
26
|
const sha256 = (buf) => crypto.createHash("sha256").update(buf).digest("hex");
|
|
@@ -49,7 +49,7 @@ copyTree(path.join(src, "vectors"), path.join(vectorsDir, "vectors"), "vectors")
|
|
|
49
49
|
fs.writeFileSync(path.join(runtimeDir, "CONTRACT_SOURCE.json"), JSON.stringify({
|
|
50
50
|
repo: "VISIONHAMMER/vh-api-gateway",
|
|
51
51
|
path: "docs/contracts/la-protocol-v2",
|
|
52
|
-
branch: "release/qa",
|
|
52
|
+
branch: opt("branch") || "release/qa", // default release/qa; a stacked gateway PR passes its own branch until it is merged
|
|
53
53
|
commit,
|
|
54
54
|
...(opt("tip") ? { branchTip: opt("tip") } : {}),
|
|
55
55
|
note: "Copied by scripts/sync-la-protocol-v2-contract.mjs. Do not edit: change the gateway folder and sync again.",
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* true handlers for `file` and `diff` 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
|
|
9
|
+
* opt-out. Unset/empty/unrecognised = false (an unknown value must never open the door to client content).
|
|
9
10
|
* Every number is clamped: a bad value falls back to the default, never to "unlimited".
|
|
10
11
|
*/
|
|
11
12
|
export const CONTENT_HANDLERS_FLAG = "LOCAL_AGENT_CONTENT_HANDLERS";
|
|
@@ -44,5 +45,10 @@ export function resolveContentHandlersConfig(env = process.env, warn = console.w
|
|
|
44
45
|
// additive to the built-in list (path-guard.js); comma separated globs
|
|
45
46
|
extraDenyGlobs: String(env.LOCAL_AGENT_CONTENT_DENY_GLOBS ?? "").split(",").map((s) => s.trim()).filter(Boolean).slice(0, 200),
|
|
46
47
|
auditFile: String(env.LOCAL_AGENT_CONTENT_AUDIT_FILE ?? "").trim() || null,
|
|
48
|
+
// F3-07b: a file with more than one hard link is refused by default (it may be a second name of a file outside the root)
|
|
49
|
+
allowHardlinks: TRUE.has(String(env.LOCAL_AGENT_CONTENT_ALLOW_HARDLINKS ?? "").trim().toLowerCase()),
|
|
50
|
+
// F3-07b: content.index (inventory of the ids this agent minted). The gateway keeps at most 2048 live ids per session.
|
|
51
|
+
indexMaxIds: int(env.LOCAL_AGENT_CONTENT_INDEX_MAX_IDS, 2048, 1, 2048),
|
|
52
|
+
indexBatchMs: int(env.LOCAL_AGENT_CONTENT_INDEX_BATCH_MS, 100, 0, 5_000),
|
|
47
53
|
};
|
|
48
54
|
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* content-index.js - announces the opaque ids this agent minted to the control plane: `content.index` (F3-07b, contract
|
|
3
|
+
* F3-22). The frame carries `{kind, ref, size}` per id and nothing else: no name, no path, no free text (the schema has no
|
|
4
|
+
* field for one). The control plane keeps it in memory with a TTL so a client can know which ids exist; it is NOT an access
|
|
5
|
+
* list: this agent stays the authority and answers `not_found` / `forbidden` for whatever it does not serve.
|
|
6
|
+
*
|
|
7
|
+
* when every id minted by link.content.mintRef is queued and sent after `batchMs` (grouped by workspace, at most 256 per
|
|
8
|
+
* frame, the contract limit); after every `ready` that is not a resume, every live id is announced again (a new
|
|
9
|
+
* gateway session knows nothing); `announce()` does the same on request.
|
|
10
|
+
* only if v2 session `ready`. install.js exists only with the content handlers on, and those only with DLP `enforce`.
|
|
11
|
+
* bounds at most `maxIds` (<= 2048, the gateway's per-session limit) live announced ids; the rest is still served, only
|
|
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.
|
|
17
|
+
* never logs an id, a size or a path: counters and codes only.
|
|
18
|
+
*/
|
|
19
|
+
import { ProtocolError } from "../errors.js";
|
|
20
|
+
|
|
21
|
+
const MAX_PER_FRAME = 256;
|
|
22
|
+
const BACKOFF_MIN_MS = 30_000;
|
|
23
|
+
const BACKOFF_MAX_MS = 300_000;
|
|
24
|
+
const RETRY_MS = 5_000;
|
|
25
|
+
|
|
26
|
+
export function createContentIndexer({ session, registry, config, log = console, now = Date.now, timers = { setTimeout, clearTimeout } }) {
|
|
27
|
+
const warn = (m) => (log.warn || log.log).call(log, m);
|
|
28
|
+
const pending = new Map(); // workspaceId -> [{kind, ref, size}]
|
|
29
|
+
const announced = new Map(); // id -> expiresAt (local clock), ids the gateway accepted in this session
|
|
30
|
+
let batchTimer = null;
|
|
31
|
+
let retryTimer = null;
|
|
32
|
+
let stopped = false; // protocol_unsupported: stay quiet until the next ready
|
|
33
|
+
let backoffMs = 0;
|
|
34
|
+
let closed = false;
|
|
35
|
+
const stats = { queued: 0, frames: 0, entries: 0, accepted: 0, rejected: 0, dropped: 0, byCode: {} };
|
|
36
|
+
|
|
37
|
+
const ready = () => session.state === "ready";
|
|
38
|
+
const unref = (t) => { if (t && typeof t.unref === "function") t.unref(); return t; };
|
|
39
|
+
|
|
40
|
+
function pruneAnnounced() {
|
|
41
|
+
const t = now();
|
|
42
|
+
for (const [id, exp] of announced) if (exp <= t) announced.delete(id);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function enqueue(e) {
|
|
46
|
+
if (closed || stopped) { stats.dropped++; return false; }
|
|
47
|
+
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; }
|
|
51
|
+
const list = pending.get(e.workspaceId) || [];
|
|
52
|
+
list.push({ kind: e.kind, ref: e.ref, size: e.size, expiresAt: e.expiresAt });
|
|
53
|
+
pending.set(e.workspaceId, list);
|
|
54
|
+
stats.queued++;
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function schedule(ms = config.indexBatchMs) {
|
|
59
|
+
if (closed || batchTimer) return;
|
|
60
|
+
batchTimer = unref(timers.setTimeout(() => { batchTimer = null; void pump(); }, ms));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function scheduleRetry(ms) {
|
|
64
|
+
if (closed || retryTimer) return;
|
|
65
|
+
retryTimer = unref(timers.setTimeout(() => { retryTimer = null; announce(); }, ms));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let running = null;
|
|
69
|
+
function pump() {
|
|
70
|
+
if (running) return running;
|
|
71
|
+
if (closed || stopped || !ready()) return Promise.resolve(); // not ready: the next `ready` announces everything still live
|
|
72
|
+
running = drain().finally(() => { running = null; });
|
|
73
|
+
return running;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function drain() {
|
|
77
|
+
try {
|
|
78
|
+
for (;;) {
|
|
79
|
+
const next = pending.entries().next();
|
|
80
|
+
if (next.done) break;
|
|
81
|
+
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;
|
|
85
|
+
const entries = batch.map(({ kind, ref, size }) => ({ kind, ref, size }));
|
|
86
|
+
stats.frames++;
|
|
87
|
+
stats.entries += entries.length;
|
|
88
|
+
try {
|
|
89
|
+
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
|
+
} catch (e) {
|
|
93
|
+
const code = e instanceof ProtocolError ? e.code : "internal";
|
|
94
|
+
stats.rejected += entries.length;
|
|
95
|
+
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
|
+
if (code === "protocol_unsupported") {
|
|
98
|
+
stopped = true;
|
|
99
|
+
warn("[la-content] the gateway keeps no content index (protocol_unsupported); not announcing ids until the next connection");
|
|
100
|
+
} else if (code === "rate_limited") {
|
|
101
|
+
backoffMs = Math.min(BACKOFF_MAX_MS, backoffMs ? backoffMs * 2 : BACKOFF_MIN_MS);
|
|
102
|
+
warn(`[la-content] content.index rate limited; announcing again in ${Math.round(backoffMs / 1000)} s`);
|
|
103
|
+
scheduleRetry(backoffMs);
|
|
104
|
+
} else if (code === "internal" || code === "timeout") {
|
|
105
|
+
warn(`[la-content] content.index not accepted (${code}); trying again shortly`);
|
|
106
|
+
scheduleRetry(RETRY_MS);
|
|
107
|
+
}
|
|
108
|
+
// other codes (not_ready, agent_offline, session reset): the next ready announces again
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
backoffMs = 0;
|
|
113
|
+
} catch { /* nothing here may throw into the data path */ }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Queues every live id (of one workspace, or of all) that the gateway does not know yet, and sends. */
|
|
117
|
+
function announce({ workspaceId } = {}) {
|
|
118
|
+
if (closed || stopped) return 0;
|
|
119
|
+
pruneAnnounced();
|
|
120
|
+
let n = 0;
|
|
121
|
+
const live = registry.live().filter((e) => (workspaceId === undefined || e.workspaceId === workspaceId) && !announced.has(e.id));
|
|
122
|
+
// when there are more than the gateway keeps, the newest ones are the ones a client can still use
|
|
123
|
+
const room = Math.max(0, config.indexMaxIds - announced.size);
|
|
124
|
+
for (const e of room === 0 ? [] : live.slice(-room)) {
|
|
125
|
+
if (enqueue({ workspaceId: e.workspaceId, kind: e.kind, ref: e.id, size: e.size, expiresAt: e.expiresAt })) n++;
|
|
126
|
+
}
|
|
127
|
+
if (n > 0) schedule(0);
|
|
128
|
+
return n;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const onReady = (info) => {
|
|
132
|
+
stopped = false;
|
|
133
|
+
backoffMs = 0;
|
|
134
|
+
if (!info || !info.resumed) { announced.clear(); pending.clear(); announce(); }
|
|
135
|
+
else void pump();
|
|
136
|
+
};
|
|
137
|
+
session.on("ready", onReady);
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
stats,
|
|
141
|
+
/** A freshly minted id: queued, sent after the batch delay. */
|
|
142
|
+
add({ workspaceId, kind, ref, size = 0, expiresAt }) {
|
|
143
|
+
const ok = enqueue({ workspaceId, kind, ref, size, expiresAt: expiresAt ?? now() + config.refTtlMs });
|
|
144
|
+
if (ok) schedule();
|
|
145
|
+
return ok;
|
|
146
|
+
},
|
|
147
|
+
announce,
|
|
148
|
+
get pending() { let n = 0; for (const l of pending.values()) n += l.length; return n; },
|
|
149
|
+
get announcedCount() { pruneAnnounced(); return announced.size; },
|
|
150
|
+
/** Waits until nothing is queued or in flight (tests, shutdown). */
|
|
151
|
+
async flush() { if (batchTimer) { timers.clearTimeout(batchTimer); batchTimer = null; } await pump(); await running; },
|
|
152
|
+
close() {
|
|
153
|
+
closed = true;
|
|
154
|
+
if (batchTimer) timers.clearTimeout(batchTimer);
|
|
155
|
+
if (retryTimer) timers.clearTimeout(retryTimer);
|
|
156
|
+
batchTimer = retryTimer = null;
|
|
157
|
+
pending.clear();
|
|
158
|
+
session.off("ready", onReady);
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
@@ -2,16 +2,29 @@
|
|
|
2
2
|
* git-diff.js - `diff` kind: the work tree against HEAD, for the files that may be shown (F3-07).
|
|
3
3
|
*
|
|
4
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
|
|
6
|
-
*
|
|
7
|
-
* first and filtered (sensitive and
|
|
8
|
-
* (at most `maxFiles` names and 128 KiB of arguments). Output is capped while it streams (the
|
|
5
|
+
* GIT_DIR must not point it at another repository), without external diff drivers, textconv or fsmonitor hooks
|
|
6
|
+
* (a malicious repo config could name a command for each), with literal pathspecs. The file list is taken
|
|
7
|
+
* first and filtered (sensitive names, and hard-linked files unless allowed, never reach the second call), then diffed
|
|
8
|
+
* with that explicit list (at most `maxFiles` names and 128 KiB of arguments). Output is capped while it streams (the
|
|
9
|
+
* process is killed above the cap).
|
|
10
|
+
*
|
|
11
|
+
* Clean/smudge filters (F3-07b). `--no-ext-diff` and `--no-textconv` do NOT stop `git diff` from running the
|
|
12
|
+
* `filter.<name>.clean` command (and `process`, the long-running filter) of the repository config: to compare a work tree file with
|
|
13
|
+
* the index git first converts it with the clean filter its .gitattributes names, and that is a command line chosen
|
|
14
|
+
* by whoever wrote the repository. Before the two diff calls the filters the config defines are listed (`git config
|
|
15
|
+
* --get-regexp '^filter\.'`, which runs nothing) and every one of them is overridden on the command line with an empty
|
|
16
|
+
* clean, smudge and process and `required=false` (git treats an empty command as "no filter"; a still-required one
|
|
17
|
+
* would abort). Fail closed: if the list cannot be read, or has more than MAX_FILTERS names, there is no diff.
|
|
18
|
+
* Consequence: a work tree that depends on a filter (git-lfs, keyword expansion) is diffed as stored, unfiltered.
|
|
9
19
|
*/
|
|
10
20
|
import { spawn } from "node:child_process";
|
|
11
21
|
import fs from "node:fs";
|
|
12
22
|
import { sanitizedChildEnv } from "../../utils/child-env.js";
|
|
23
|
+
import path from "node:path";
|
|
13
24
|
import { isSensitivePath, normalizeRel } from "./path-guard.js";
|
|
14
25
|
|
|
26
|
+
const MAX_FILTERS = 64;
|
|
27
|
+
|
|
15
28
|
const GIT_SAFE = ["--literal-pathspecs", "-c", "core.quotepath=off", "-c", "core.fsmonitor=false", "-c", "core.pager=cat", "-c", "diff.external="];
|
|
16
29
|
|
|
17
30
|
function gitEnv() {
|
|
@@ -53,11 +66,36 @@ function runGit(args, { cwd, input, maxBytes, timeoutMs, signal }) {
|
|
|
53
66
|
});
|
|
54
67
|
}
|
|
55
68
|
|
|
69
|
+
/**
|
|
70
|
+
* `-c` overrides that switch off every filter the repository (or the user's global config) defines.
|
|
71
|
+
* @returns {Promise<{ok:true, args:string[]} | {ok:false, status:"error", errorCode:string}>}
|
|
72
|
+
*/
|
|
73
|
+
export async function filterOverrides({ cwd, timeoutMs, signal }) {
|
|
74
|
+
const r = await runGit([...GIT_SAFE, "config", "--null", "--get-regexp", "^filter\\."], { cwd, maxBytes: 1024 * 1024, timeoutMs, signal });
|
|
75
|
+
if (r.aborted) return { ok: false, status: "error", errorCode: "agent_offline" };
|
|
76
|
+
if (r.timedOut) return { ok: false, status: "error", errorCode: "timeout" };
|
|
77
|
+
if (r.code === 1 && !r.overflow) return { ok: true, args: [] }; // exit 1 = no such key: no filter is defined
|
|
78
|
+
if (r.code !== 0 || r.overflow) return { ok: false, status: "error", errorCode: "internal" };
|
|
79
|
+
const names = new Set();
|
|
80
|
+
for (const entry of r.out.toString("utf8").split("\0")) {
|
|
81
|
+
const key = entry.split("\n", 1)[0]; // "filter.<name>.<variable>" (name may contain dots)
|
|
82
|
+
if (!key.toLowerCase().startsWith("filter.")) continue;
|
|
83
|
+
const last = key.lastIndexOf(".");
|
|
84
|
+
if (last <= "filter.".length) continue; // "filter.x": a variable without a name, nothing to run
|
|
85
|
+
names.add(key.slice("filter.".length, last));
|
|
86
|
+
}
|
|
87
|
+
if (names.size > MAX_FILTERS) return { ok: false, status: "error", errorCode: "internal" };
|
|
88
|
+
const args = [];
|
|
89
|
+
for (const n of names) for (const v of ["clean", "smudge", "process"]) args.push("-c", `filter.${n}.${v}=`);
|
|
90
|
+
for (const n of names) args.push("-c", `filter.${n}.required=false`);
|
|
91
|
+
return { ok: true, args };
|
|
92
|
+
}
|
|
93
|
+
|
|
56
94
|
/**
|
|
57
95
|
* @param {{root:string, rel?:string|null, maxBytes:number, maxFiles:number, timeoutMs:number, denyGlobs?:string[], signal?:AbortSignal}} o
|
|
58
96
|
* @returns {Promise<{ok:true, text:string} | {ok:false, status:"not_found"|"forbidden"|"error", errorCode?:string}>}
|
|
59
97
|
*/
|
|
60
|
-
export async function worktreeDiff({ root, rel = null, maxBytes, maxFiles, timeoutMs, denyGlobs = [], signal }) {
|
|
98
|
+
export async function worktreeDiff({ root, rel = null, maxBytes, maxFiles, timeoutMs, denyGlobs = [], allowHardlinks = false, signal }) {
|
|
61
99
|
let cwd;
|
|
62
100
|
try { cwd = await fs.promises.realpath(root); } catch { return { ok: false, status: "not_found" }; }
|
|
63
101
|
let only = null;
|
|
@@ -66,7 +104,10 @@ export async function worktreeDiff({ root, rel = null, maxBytes, maxFiles, timeo
|
|
|
66
104
|
if (!only) return { ok: false, status: "not_found" };
|
|
67
105
|
if (isSensitivePath(only, denyGlobs)) return { ok: false, status: "forbidden" };
|
|
68
106
|
}
|
|
69
|
-
const
|
|
107
|
+
const filters = await filterOverrides({ cwd, timeoutMs, signal });
|
|
108
|
+
if (!filters.ok) return filters;
|
|
109
|
+
const safe = [...GIT_SAFE, ...filters.args];
|
|
110
|
+
const listArgs = [...safe, "diff", "--name-only", "-z", "--no-renames", "--no-ext-diff", "--relative", "HEAD", "--", ...(only ? [only] : ["."])];
|
|
70
111
|
const listed = await runGit(listArgs, { cwd, maxBytes: 4 * 1024 * 1024, timeoutMs, signal });
|
|
71
112
|
if (listed.aborted) return { ok: false, status: "error", errorCode: "agent_offline" };
|
|
72
113
|
if (listed.timedOut) return { ok: false, status: "error", errorCode: "timeout" };
|
|
@@ -77,12 +118,13 @@ export async function worktreeDiff({ root, rel = null, maxBytes, maxFiles, timeo
|
|
|
77
118
|
for (const n of listed.out.toString("utf8").split("\0")) {
|
|
78
119
|
const norm = n ? normalizeRel(n) : null;
|
|
79
120
|
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
|
|
80
122
|
argBytes += Buffer.byteLength(norm) + 1;
|
|
81
123
|
if (names.length >= maxFiles || argBytes > 128 * 1024) break;
|
|
82
124
|
names.push(norm);
|
|
83
125
|
}
|
|
84
126
|
if (names.length === 0) return { ok: true, text: "" };
|
|
85
|
-
const diffArgs = [...
|
|
127
|
+
const diffArgs = [...safe, "diff", "--no-color", "--no-ext-diff", "--no-textconv", "--no-renames", "--relative", "-U3", "HEAD", "--", ...names];
|
|
86
128
|
const diff = await runGit(diffArgs, { cwd, maxBytes: maxBytes + 1, timeoutMs, signal });
|
|
87
129
|
if (diff.aborted) return { ok: false, status: "error", errorCode: "agent_offline" };
|
|
88
130
|
if (diff.timedOut) return { ok: false, status: "error", errorCode: "timeout" };
|
|
@@ -90,3 +132,11 @@ export async function worktreeDiff({ root, rel = null, maxBytes, maxFiles, timeo
|
|
|
90
132
|
if (diff.code !== 0) return { ok: false, status: "error", errorCode: "internal" };
|
|
91
133
|
return { ok: true, text: diff.out.toString("utf8") };
|
|
92
134
|
}
|
|
135
|
+
|
|
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) {
|
|
138
|
+
try {
|
|
139
|
+
const st = await fs.promises.lstat(path.join(cwd, ...rel.split("/")));
|
|
140
|
+
return st.isFile() && st.nlink > 1;
|
|
141
|
+
} catch { return false; }
|
|
142
|
+
}
|
|
@@ -25,6 +25,7 @@ import { normalizeRel, resolveInside, readRegular } from "./path-guard.js";
|
|
|
25
25
|
import { worktreeDiff } from "./git-diff.js";
|
|
26
26
|
import { prepareOutgoing, contentTypeOf, decodeText } from "./egress.js";
|
|
27
27
|
import { createAudit, short } from "./audit.js";
|
|
28
|
+
import { dlpConfigFromEnv } from "../../dlp/index.js";
|
|
28
29
|
|
|
29
30
|
export const SERVED_KINDS = Object.freeze(["file", "diff"]);
|
|
30
31
|
/** Symbolic ref of kind `diff`: the whole work tree of the envelope's workspace (carries no client data). */
|
|
@@ -43,6 +44,9 @@ export function createContentHandler({ session, tenantId, resolveWorkspace, regi
|
|
|
43
44
|
/** @returns {Promise<{status:string, errorCode?:string, buffer?:Buffer, contentType?:string, dlp?:object}>} */
|
|
44
45
|
async function produce(ctx, p, signal) {
|
|
45
46
|
if (!SERVED_KINDS.includes(p.kind)) return { status: "error", errorCode: "protocol_unsupported" };
|
|
47
|
+
// F3-07b: content never leaves unmasked. runtime.js does not even register this handler without DLP enforce; this is the
|
|
48
|
+
// same rule at the moment of the request (the env is read per request), so a changed mode can not open the door.
|
|
49
|
+
if (dlpConfigFromEnv(env).mode !== "enforce") return { status: "error", errorCode: "protocol_unsupported" };
|
|
46
50
|
const ws = await resolveWorkspace(ctx.workspaceId);
|
|
47
51
|
if (!ws || !ws.root) return { status: "not_found" };
|
|
48
52
|
const target = registry.resolve(p.ref, { workspaceId: ctx.workspaceId, root: ws.root, kind: p.kind });
|
|
@@ -55,10 +59,10 @@ export function createContentHandler({ session, tenantId, resolveWorkspace, regi
|
|
|
55
59
|
async function produceFile(p, ws, rel, signal) {
|
|
56
60
|
const norm = rel && normalizeRel(rel);
|
|
57
61
|
if (!norm) return { status: "not_found" };
|
|
58
|
-
const resolved = await resolveInside(ws.root, norm, config.extraDenyGlobs);
|
|
62
|
+
const resolved = await resolveInside(ws.root, norm, config.extraDenyGlobs, { allowHardlinks: config.allowHardlinks });
|
|
59
63
|
if (!resolved.ok) return { status: resolved.status };
|
|
60
64
|
if (signal.aborted) return { status: "error", errorCode: "agent_offline" };
|
|
61
|
-
const read = await readRegular(resolved, config.maxBytes, signal);
|
|
65
|
+
const read = await readRegular(resolved, config.maxBytes, signal, { allowHardlinks: config.allowHardlinks });
|
|
62
66
|
if (!read.ok) return read.status === "too_large" ? { status: "error", errorCode: "payload_too_large" } : { status: read.status };
|
|
63
67
|
const isText = decodeText(read.buffer) !== null;
|
|
64
68
|
return finish(p, read.buffer, contentTypeOf(norm, isText), "content.file");
|
|
@@ -66,7 +70,7 @@ export function createContentHandler({ session, tenantId, resolveWorkspace, regi
|
|
|
66
70
|
|
|
67
71
|
async function produceDiff(p, ws, rel, signal) {
|
|
68
72
|
const d = await worktreeDiff({ root: ws.root, rel, maxBytes: config.maxBytes, maxFiles: config.maxDiffFiles,
|
|
69
|
-
timeoutMs: config.gitTimeoutMs, denyGlobs: config.extraDenyGlobs, signal });
|
|
73
|
+
timeoutMs: config.gitTimeoutMs, denyGlobs: config.extraDenyGlobs, allowHardlinks: config.allowHardlinks, signal });
|
|
70
74
|
if (!d.ok) return d;
|
|
71
75
|
return finish(p, Buffer.from(d.text, "utf8"), "text/x-diff", "content.diff");
|
|
72
76
|
}
|
|
@@ -79,7 +83,9 @@ export function createContentHandler({ session, tenantId, resolveWorkspace, regi
|
|
|
79
83
|
|
|
80
84
|
async function run(ctx, p, entry) {
|
|
81
85
|
const t0 = now();
|
|
82
|
-
|
|
86
|
+
// F3-07b: always defined. If produce() throws, the gateway still gets an answer (a code, never the message of the
|
|
87
|
+
// exception: it may carry a path) and the request is audited like any other.
|
|
88
|
+
let result = { status: "error", errorCode: "internal" };
|
|
83
89
|
let sent = null;
|
|
84
90
|
let errorCode;
|
|
85
91
|
try {
|
|
@@ -88,7 +94,11 @@ export function createContentHandler({ session, tenantId, resolveWorkspace, regi
|
|
|
88
94
|
} else {
|
|
89
95
|
reservedBytes += reserveEach;
|
|
90
96
|
entry.reserved = true;
|
|
91
|
-
|
|
97
|
+
try {
|
|
98
|
+
result = await produce(ctx, p, entry.ac.signal);
|
|
99
|
+
} catch {
|
|
100
|
+
result = { status: "error", errorCode: "internal" };
|
|
101
|
+
}
|
|
92
102
|
}
|
|
93
103
|
if (entry.ac.signal.aborted) {
|
|
94
104
|
errorCode = "cancelled";
|
|
@@ -122,12 +132,14 @@ export function createContentHandler({ session, tenantId, resolveWorkspace, regi
|
|
|
122
132
|
else if (status === "cancelled" || status === "timeout") stats.cancelled++;
|
|
123
133
|
else stats.error++;
|
|
124
134
|
bump(errorCode || result.errorCode || status);
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
135
|
+
try {
|
|
136
|
+
auditor.record({
|
|
137
|
+
requestId: ctx.requestId, workspaceId: ctx.workspaceId, kind: p.kind, status, errorCode: errorCode || result.errorCode || null,
|
|
138
|
+
bytes: sent ? sent.bytes : 0, chunks: sent ? sent.chunks : 0, ms: now() - t0,
|
|
139
|
+
dlpMode: result.dlp ? result.dlp.mode : null, dlpHits: result.dlp ? result.dlp.total : 0, dlpRules: result.dlp ? result.dlp.redactions : {},
|
|
140
|
+
refSha: short(p.ref), actorRole: p.actor && p.actor.role, actorSha: short(p.actor && p.actor.userId),
|
|
141
|
+
});
|
|
142
|
+
} catch { /* the audit must never take the data path down (nor leave a request without its answer) */ }
|
|
131
143
|
if (status === "send_failed") warn(`[la-content] ${p.kind} not delivered: ${errorCode}`);
|
|
132
144
|
}
|
|
133
145
|
|
|
@@ -147,7 +159,7 @@ export function createContentHandler({ session, tenantId, resolveWorkspace, regi
|
|
|
147
159
|
entry.timer = setTimeout(() => { entry.reason = "deadline"; entry.ac.abort(); }, config.totalMs);
|
|
148
160
|
inflight.set(ctx.requestId, entry);
|
|
149
161
|
// background: ack first, answer later
|
|
150
|
-
queueMicrotask(() => { run(ctx, p, entry).catch(() =>
|
|
162
|
+
queueMicrotask(() => { run(ctx, p, entry).catch(() => warn("[la-content] request failed internally")); });
|
|
151
163
|
return { accept: true };
|
|
152
164
|
},
|
|
153
165
|
|
|
@@ -1,19 +1,24 @@
|
|
|
1
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
|
|
2
|
+
* install.js - puts the content handlers on a v2 link (F3-07, F3-07b). Called by runtime.js only when
|
|
3
|
+
* LOCAL_AGENT_CONTENT_HANDLERS=true, v2 is on AND LOCAL_AGENT_DLP_MODE=enforce. Returns what the rest of the agent needs:
|
|
4
4
|
*
|
|
5
|
-
* mintRef({workspaceId, kind, rel}) the HOOK for whoever reports metadata to the control plane (file lists,
|
|
6
|
-
*
|
|
7
|
-
* path, unknown workspace).
|
|
8
|
-
*
|
|
5
|
+
* mintRef({workspaceId, kind, rel, size?}) the HOOK for whoever reports metadata to the control plane (file lists, diff
|
|
6
|
+
* summaries): returns {id, expiresAt} for that item (id = 43 base64url chars), or null
|
|
7
|
+
* (sensitive path, unknown workspace). The id is also announced to the control plane in a
|
|
8
|
+
* `content.index` frame ({kind, ref, size}: no name, no path). Nothing in the agent calls
|
|
9
|
+
* it yet (the inventory producer is a later increment); `announce()` re-sends what is live.
|
|
9
10
|
*/
|
|
10
11
|
import { dlpConfigFromEnv } from "../../dlp/index.js";
|
|
11
12
|
import { ContentRefRegistry } from "./ref-registry.js";
|
|
12
13
|
import { createContentHandler, SERVED_KINDS } from "./handler.js";
|
|
14
|
+
import { createContentIndexer } from "./content-index.js";
|
|
15
|
+
import { sizeInsideSync } from "./path-guard.js";
|
|
13
16
|
|
|
14
17
|
export const CONTENT_CAPABILITIES = Object.freeze(SERVED_KINDS.map((k) => `content.${k}`));
|
|
15
18
|
|
|
16
19
|
export function installContentHandlers({ link, tenantId, wsManager, getWorkspaceRoot, config, env = process.env, log = console }) {
|
|
20
|
+
// F3-07b, defence in depth: runtime.js already refuses; nobody can install the handlers without DLP enforce
|
|
21
|
+
if (dlpConfigFromEnv(env).mode !== "enforce") throw new Error("content handlers need LOCAL_AGENT_DLP_MODE=enforce");
|
|
17
22
|
const tenant = String(tenantId).toLowerCase();
|
|
18
23
|
const registry = new ContentRefRegistry({ tenantId: tenant, ttlMs: config.refTtlMs, maxEntries: config.maxRefs, denyGlobs: config.extraDenyGlobs });
|
|
19
24
|
|
|
@@ -31,20 +36,30 @@ export function installContentHandlers({ link, tenantId, wsManager, getWorkspace
|
|
|
31
36
|
|
|
32
37
|
const handler = createContentHandler({ session: link.session, tenantId: tenant, resolveWorkspace, registry, config, env, log });
|
|
33
38
|
link.session.setHandler("content.request", (ctx) => handler.handle(ctx));
|
|
39
|
+
const indexer = createContentIndexer({ session: link.session, registry, config, log });
|
|
34
40
|
|
|
35
41
|
const closeLink = link.close.bind(link);
|
|
36
|
-
link.close = () => { handler.cancelAll(); registry.clear(); closeLink(); };
|
|
42
|
+
link.close = () => { handler.cancelAll(); indexer.close(); registry.clear(); closeLink(); };
|
|
37
43
|
|
|
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
44
|
link.content = {
|
|
42
45
|
registry,
|
|
43
46
|
handler,
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
+
indexer,
|
|
48
|
+
mintRef: ({ workspaceId, kind, rel, size }) => {
|
|
49
|
+
try {
|
|
50
|
+
const ws = resolveWorkspace(workspaceId);
|
|
51
|
+
if (!ws) return null;
|
|
52
|
+
const bytes = Number.isSafeInteger(size) ? size
|
|
53
|
+
: kind === "file" && typeof rel === "string" ? sizeInsideSync(ws.root, rel.replace(/\\/g, "/"), { allowHardlinks: config.allowHardlinks }) : 0;
|
|
54
|
+
const minted = registry.mint({ workspaceId, root: ws.root, kind, rel, size: bytes });
|
|
55
|
+
if (minted) indexer.add({ workspaceId, kind, ref: minted.id, size: bytes, expiresAt: minted.expiresAt });
|
|
56
|
+
return minted;
|
|
57
|
+
} catch {
|
|
58
|
+
return null; // a failing workspace manager means "no id", never an exception with a path in it
|
|
59
|
+
}
|
|
47
60
|
},
|
|
61
|
+
/** Announces again the live ids (of one workspace or all) that the gateway does not know yet. */
|
|
62
|
+
announce: (opts) => indexer.announce(opts),
|
|
48
63
|
};
|
|
49
64
|
return link.content;
|
|
50
65
|
}
|