deepdebug-local-agent 1.5.0 → 1.7.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 +70 -16
- package/env.example +6 -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 +10 -2
- package/src/la-protocol-v2/content-handlers/content-index.js +196 -0
- package/src/la-protocol-v2/content-handlers/egress.js +22 -0
- package/src/la-protocol-v2/content-handlers/file-list.js +174 -0
- package/src/la-protocol-v2/content-handlers/git-diff.js +95 -10
- package/src/la-protocol-v2/content-handlers/handler.js +52 -16
- package/src/la-protocol-v2/content-handlers/install.js +35 -15
- package/src/la-protocol-v2/content-handlers/path-guard.js +120 -10
- package/src/la-protocol-v2/content-handlers/ref-registry.js +41 -13
- package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +13 -8
- package/src/la-protocol-v2/contract/README.md +87 -2
- package/src/la-protocol-v2/contract/content.index.schema.json +93 -0
- package/src/la-protocol-v2/contract/content.request.schema.json +1 -0
- package/src/la-protocol-v2/contract/envelope.schema.json +1 -0
- package/src/la-protocol-v2/contract/file.list.body.schema.json +69 -0
- package/src/la-protocol-v2/contract/index.json +7 -0
- package/src/la-protocol-v2/contract.js +9 -0
- package/src/la-protocol-v2/runtime.js +12 -2
- package/test/contract/la-protocol-v2/vectors/content.index.vectors.json +3809 -0
- package/test/contract/la-protocol-v2/vectors/content.request.vectors.json +215 -0
- package/test/contract/la-protocol-v2/vectors/content.response.vectors.json +30 -0
- package/test/contract/la-protocol-v2/vectors/file.list.body.vectors.json +1645 -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 +669 -0
- package/test/la-protocol-v2/content-deny-list.test.js +138 -0
- package/test/la-protocol-v2/content-diff-symlink.test.js +83 -0
- package/test/la-protocol-v2/content-file-list.test.js +385 -0
- package/test/la-protocol-v2/content-handlers.test.js +55 -61
- package/test/la-protocol-v2/content-path-guard.test.js +10 -5
- package/test/la-protocol-v2/content-wiring.test.js +39 -14
- package/test/la-protocol-v2/contract.test.js +15 -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/LaV2NodeFileListE2ETest.java +369 -0
- package/tools/interop/README.md +16 -0
- package/tools/interop/la-interop-content-client.mjs +19 -8
- package/tools/interop/la-interop-list-client.mjs +93 -0
package/docs/la-protocol-v2.md
CHANGED
|
@@ -50,34 +50,85 @@ 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
|
-
|
|
70
|
-
|
|
81
|
+
must be a regular file opened without following links. Sensitive by default: `.env*` (also `.env~`, `.env#`, `.envrc`,
|
|
82
|
+
`.env_backup`, `.env-prod`, `env.production`), `*.pem`, `*.key`, `*.p12`, `*.kdbx`, `*.keystore`, `*.jks`, `id_rsa*`, `id_ed25519*`...,
|
|
83
|
+
`.npmrc`, `.pypirc`, `.netrc`, `kubeconfig*`, `secrets/`, `*.tfstate`, `.git/`, `.ssh/`, `.aws/`, `.kube/`, `.docker/`... plus the NTFS
|
|
84
|
+
stream form of any of them (`.env::$DATA`) and the Windows 8.3 aliases (`ENV~1`, `CREDEN~1.JSO`, `ID_ED2~1`).
|
|
85
|
+
**Narrowed (F3-07c, Edu 2026-11-03): files that ARE credentials are blocked, code that merely mentions them is served.** A name with
|
|
86
|
+
`credential` (or `kubeconfig`) in it is served only when its LAST extension is source code or documentation (`ts`, `java`, `py`, `go`, `js`,
|
|
87
|
+
`kt`, `rb`, `cs`, `rs`, `php`, `sql`, `md`, `html`, `css`, ... `CODE_EXTENSIONS` in `path-guard.js`): `credentials.service.ts`,
|
|
88
|
+
`CredentialsController.java`, `credentials_test.py` are served; `credentials`, `.credentials`, `credentials.json|yml|xml|toml|ini|cfg|
|
|
89
|
+
conf|csv|txt|properties|db|sqlite|enc|pem|p12|pfx|kdbx|tfvars|env`, `application_default_credentials.json`, `*.credentials`, `aws/credentials`,
|
|
90
|
+
`gcloud/credentials.db` and anything unknown (`credentials.bak`, `credentials-prod`, `credentials.sh`) are not. A FOLDER named
|
|
91
|
+
`credentials` is fine (`src/credentials/`), but a file under it must be code too. The same rule tells `env.ts` / `env.d.ts` (served)
|
|
92
|
+
from `env.production` / `env_prod` / `env` (blocked). `secrets/` (any depth) is blocked whole, code included. The DLP (`enforce`, mandatory)
|
|
93
|
+
stays the second layer: whatever is served is masked. The table of >= 80 names is `test/la-protocol-v2/content-deny-list.test.js`.
|
|
94
|
+
`LOCAL_AGENT_CONTENT_DENY_GLOBS` adds to the list. No id is ever minted for them.
|
|
95
|
+
- **`file.list` (F3-07c): the producer of the ids.** `content.request{kind:"file.list", ref}` lists ONE folder of a workspace (`ref` = `root` or a
|
|
96
|
+
folder id / `nextRef`; `limit` 1..1000, default 200; no range) and answers `application/json` `{entries:[{id, name, type, size, mtime}], nextRef?}`
|
|
97
|
+
(`file.list.body.schema.json` of the contract). The listing is the producer of the ids: each entry gets an id (43 chars, 15 min, bound to
|
|
98
|
+
tenant + workspace + root + kind; `file` for a file, `file.list` for a folder), minted through `mintRef`, so each id is announced in
|
|
99
|
+
`content.index` (`{kind, ref, size}`, no names). Left out, silently (not even a count): sensitive names, a link that leaves the root or
|
|
100
|
+
points to a secret, hard links, sockets/fifos/devices, names the contract cannot express, names the DLP withholds. Names are client
|
|
101
|
+
content: each name is masked by the DLP on its own (never the whole JSON, the ids would be masked by the high-entropy rule). `.gitignore` is
|
|
102
|
+
NOT consulted (a listing of the disk, not of git; the deny list is the control). One level per call, so no depth; a folder with more than
|
|
103
|
+
`LOCAL_AGENT_CONTENT_MAX_DIR_ENTRIES` (20000) entries is `payload_too_large` (never a partial listing); body <= min(`LOCAL_AGENT_CONTENT_MAX_BYTES`,
|
|
104
|
+
1 MiB), a page that does not fit ends with a `nextRef` (a folder id that remembers the position); order = dirs first, then names by UTF-8
|
|
105
|
+
bytes. Announced as capability `content.file.list` (the gateway sends `file.list` only to agents that announce it). Same flag
|
|
106
|
+
(`LOCAL_AGENT_CONTENT_HANDLERS`) and same mandatory DLP `enforce` as `file`/`diff`.
|
|
107
|
+
- **Hard links (F3-07b).** A file with more than one hard link (`st.nlink > 1`) is `forbidden` by default: a hard link
|
|
108
|
+
cannot be told from the file itself and may be a second name of a file OUTSIDE the root, which the realpath check cannot
|
|
109
|
+
see. Checked on the path and again on the open descriptor; the diff leaves such files out. `LOCAL_AGENT_CONTENT_ALLOW_HARDLINKS=true`
|
|
110
|
+
lifts it (pnpm stores and some build trees hard-link every file).
|
|
71
111
|
- **DLP before anything leaves** (`LOCAL_AGENT_DLP_MODE`, the module `src/dlp`): `enforce` masks the whole text before it is
|
|
72
112
|
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
|
-
|
|
113
|
+
closed for binary files, inputs above `LOCAL_AGENT_DLP_MAX_BYTES` and DLP errors. `warn` and `off` serve NOTHING (see
|
|
114
|
+
above).
|
|
75
115
|
- **Limits.** `LOCAL_AGENT_CONTENT_MAX_BYTES` per response (default 1 MiB, ceiling 2 MiB = 64 chunks; a file above it is
|
|
76
116
|
`payload_too_large` without being read), `LOCAL_AGENT_CONTENT_MAX_CONCURRENT` (4) and `..._MAX_INFLIGHT_BYTES`
|
|
77
117
|
(`rate_limited` above), `LOCAL_AGENT_CONTENT_TOTAL_MS` (25 s), the session window (8 chunks in flight, backpressure).
|
|
78
118
|
Cancellation: `handler.cancel(requestId)`, the deadline, closing the link, or loss of the session.
|
|
79
119
|
- **Diff.** `git diff HEAD` of the work tree, no shell, without external diff/textconv/fsmonitor, without any `GIT_*` of
|
|
80
120
|
the agent's env, only for files that pass the same deny list, at most `LOCAL_AGENT_CONTENT_MAX_DIFF_FILES` (500).
|
|
121
|
+
**Symlinks (F3-07c)**: the hunk of a tracked symlink prints the link TARGET (an absolute path of this machine), so entries with mode
|
|
122
|
+
`120000` on either side (`git diff --raw -z`) never reach the diff; names that are links on disk are skipped too, and as a last net
|
|
123
|
+
any block of the text whose header says `120000` is removed.
|
|
124
|
+
**Clean/smudge/process filters are neutralised (F3-07b)**: `--no-ext-diff`/`--no-textconv` do not stop `git diff` from
|
|
125
|
+
running the `filter.<name>.clean` command of the repository (or global) config, a command line chosen by whoever wrote the
|
|
126
|
+
repository. Before the diff the agent lists `filter.*` names (`git config --get-regexp '^filter\.'`, runs nothing) and
|
|
127
|
+
overrides each with an empty clean/smudge/process and `required=false`; if the list cannot be read, or has more than 64
|
|
128
|
+
names, there is no diff (fail closed). A tree that depends on a filter (git-lfs, keyword expansion) is diffed as stored.
|
|
129
|
+
- **When `produce()` fails (F3-07b).** A workspace lookup, registry or filesystem call that throws never leaves the gateway
|
|
130
|
+
waiting: it gets `content.response` `status:"error"` + `errorCode:"internal"` (a code, never the exception message, which
|
|
131
|
+
may hold a path) and the request is audited like any other; an audit failure does not stop the answer.
|
|
81
132
|
- **Audit.** One local record per request (kind, status, code, sizes, DLP mode and per-rule counts, a short hash of the
|
|
82
133
|
ref): in memory and optionally `LOCAL_AGENT_CONTENT_AUDIT_FILE` (JSONL, 0600, rotates at 5 MiB). Never content,
|
|
83
134
|
paths or the ref.
|
|
@@ -85,17 +136,20 @@ Needs `LOCAL_AGENT_PROTOCOL_V2=true` too. Off (unset, empty, `false` or any unkn
|
|
|
85
136
|
## What it does NOT do
|
|
86
137
|
|
|
87
138
|
No handler for the other kinds (`incident.*`, `log.*`, `chat.session`, `wiki.page`, `job.*`, `approval.detail`, `report`),
|
|
88
|
-
no
|
|
139
|
+
no log/incident detector
|
|
89
140
|
(F3-11b), no `job.assign` runtime (F3-08). Those frames are acked `rejected protocol_unsupported`. `sha256` of
|
|
90
141
|
`content.response` is not sent (the contract does not say what it covers).
|
|
91
142
|
|
|
92
143
|
## Tests
|
|
93
144
|
|
|
94
145
|
`npm test` (node:test), all in `test/la-protocol-v2/`:
|
|
95
|
-
`contract.test.js` (all
|
|
146
|
+
`contract.test.js` (all 176 frame vectors of F3-22, valid and invalid, the 54 `file.list` body vectors, plus the drift check), `traces.test.js` (the 26
|
|
96
147
|
traces), `session-inbound.test.js`, `session-outbound.test.js`, `link.test.js`, `tunnel-e2e.test.js` (the real
|
|
97
148
|
`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`)
|
|
149
|
+
`content-handlers.test.js`, `content-wiring.test.js` (flag, runtime wiring, real `src/server.js`), and for F3-07b
|
|
150
|
+
`content-alignment.test.js` (43-char ids and `worktree`, `content.index`, throwing producers, hard links, deny list, git filters), and for F3-07c `content-deny-list.test.js` (the narrowed deny list, >= 80 names), `content-file-list.test.js` (the listing, paging, links, DLP, index) and `content-diff-symlink.test.js`.
|
|
151
|
+
Against the real gateway: `tools/interop` (`LaV2NodeContentInteropTest` straight into `LaV2Session`;
|
|
152
|
+
`LaV2NodeContentRelayE2ETest`: HTTP -> `ContentRelayController` -> `LaV2Session` -> this agent, no patch on either side; `LaV2NodeFileListE2ETest` (F3-07c): the same chain starting from `file.list`, the agent mints no id by itself). The reference CP (`test/la-protocol-v2/helpers/reference-cp.js`)
|
|
99
153
|
is a Node model of the gateway rules, not the gateway.
|
|
100
154
|
|
|
101
155
|
**Interoperability with the real gateway session** (`LaV2Session`, Java, real Reactor Netty socket on loopback, real
|
package/env.example
CHANGED
|
@@ -80,3 +80,9 @@ 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/file.list 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)
|
|
87
|
+
# LOCAL_AGENT_CONTENT_MAX_DIR_ENTRIES=20000 file.list: a folder with more entries is payload_too_large (100..200000)
|
|
88
|
+
# LOCAL_AGENT_CONTENT_DENY_GLOBS=a/,*.x extra deny globs (they add to the built-in list: .env*, keys, credentials files, secrets/ ...)
|
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.",
|
|
@@ -2,10 +2,11 @@
|
|
|
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 `
|
|
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
|
|
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";
|
|
@@ -40,9 +41,16 @@ export function resolveContentHandlersConfig(env = process.env, warn = console.w
|
|
|
40
41
|
// the gateway gives a request 30 s in total (contract: contentTotalMs)
|
|
41
42
|
totalMs: int(env.LOCAL_AGENT_CONTENT_TOTAL_MS, 25_000, 1_000, 30_000),
|
|
42
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),
|
|
43
46
|
maxDiffFiles: int(env.LOCAL_AGENT_CONTENT_MAX_DIFF_FILES, 500, 1, 5_000),
|
|
44
47
|
// additive to the built-in list (path-guard.js); comma separated globs
|
|
45
48
|
extraDenyGlobs: String(env.LOCAL_AGENT_CONTENT_DENY_GLOBS ?? "").split(",").map((s) => s.trim()).filter(Boolean).slice(0, 200),
|
|
46
49
|
auditFile: String(env.LOCAL_AGENT_CONTENT_AUDIT_FILE ?? "").trim() || null,
|
|
50
|
+
// 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)
|
|
51
|
+
allowHardlinks: TRUE.has(String(env.LOCAL_AGENT_CONTENT_ALLOW_HARDLINKS ?? "").trim().toLowerCase()),
|
|
52
|
+
// F3-07b: content.index (inventory of the ids this agent minted). The gateway keeps at most 2048 live ids per session.
|
|
53
|
+
indexMaxIds: int(env.LOCAL_AGENT_CONTENT_INDEX_MAX_IDS, 2048, 1, 2048),
|
|
54
|
+
indexBatchMs: int(env.LOCAL_AGENT_CONTENT_INDEX_BATCH_MS, 100, 0, 5_000),
|
|
47
55
|
};
|
|
48
56
|
}
|
|
@@ -0,0 +1,196 @@
|
|
|
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. 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).
|
|
20
|
+
* never logs an id, a size or a path: counters and codes only.
|
|
21
|
+
*/
|
|
22
|
+
import { ProtocolError } from "../errors.js";
|
|
23
|
+
|
|
24
|
+
const MAX_PER_FRAME = 256;
|
|
25
|
+
const BACKOFF_MIN_MS = 30_000;
|
|
26
|
+
const BACKOFF_MAX_MS = 300_000;
|
|
27
|
+
const RETRY_MS = 5_000;
|
|
28
|
+
const MAX_ATTEMPTS = 5;
|
|
29
|
+
|
|
30
|
+
export function createContentIndexer({ session, registry, config, log = console, now = Date.now, timers = { setTimeout, clearTimeout } }) {
|
|
31
|
+
const warn = (m) => (log.warn || log.log).call(log, m);
|
|
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
|
|
35
|
+
const announced = new Map(); // id -> expiresAt (local clock), ids the gateway accepted in this session
|
|
36
|
+
let batchTimer = null;
|
|
37
|
+
let retryTimer = null;
|
|
38
|
+
let stopped = false; // protocol_unsupported: stay quiet until the next ready
|
|
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)
|
|
41
|
+
let closed = false;
|
|
42
|
+
const stats = { queued: 0, frames: 0, entries: 0, accepted: 0, rejected: 0, dropped: 0, byCode: {} };
|
|
43
|
+
|
|
44
|
+
const ready = () => session.state === "ready";
|
|
45
|
+
const unref = (t) => { if (t && typeof t.unref === "function") t.unref(); return t; };
|
|
46
|
+
|
|
47
|
+
function pruneAnnounced() {
|
|
48
|
+
const t = now();
|
|
49
|
+
for (const [id, exp] of announced) if (exp <= t) announced.delete(id);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function enqueue(e) {
|
|
53
|
+
if (closed || stopped) { stats.dropped++; return false; }
|
|
54
|
+
pruneAnnounced();
|
|
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; }
|
|
57
|
+
const list = pending.get(e.workspaceId) || [];
|
|
58
|
+
list.push({ kind: e.kind, ref: e.ref, size: e.size, expiresAt: e.expiresAt });
|
|
59
|
+
pending.set(e.workspaceId, list);
|
|
60
|
+
queued.add(e.ref);
|
|
61
|
+
stats.queued++;
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function schedule(ms = config.indexBatchMs) {
|
|
66
|
+
if (closed || batchTimer) return;
|
|
67
|
+
batchTimer = unref(timers.setTimeout(() => { batchTimer = null; void pump(); }, ms));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function scheduleRetry(ms) {
|
|
71
|
+
if (closed || retryTimer) return;
|
|
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));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let running = null;
|
|
78
|
+
function pump() {
|
|
79
|
+
if (running) return running;
|
|
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
|
+
}
|
|
85
|
+
running = drain().finally(() => { running = null; });
|
|
86
|
+
return running;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function drain() {
|
|
90
|
+
try {
|
|
91
|
+
for (;;) {
|
|
92
|
+
const next = pending.entries().next();
|
|
93
|
+
if (next.done) break;
|
|
94
|
+
const [workspaceId, list] = next.value;
|
|
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);
|
|
98
|
+
const entries = batch.map(({ kind, ref, size }) => ({ kind, ref, size }));
|
|
99
|
+
stats.frames++;
|
|
100
|
+
stats.entries += entries.length;
|
|
101
|
+
try {
|
|
102
|
+
await session.send("content.index", { entries }, { workspaceId });
|
|
103
|
+
} catch (e) {
|
|
104
|
+
const code = e instanceof ProtocolError ? e.code : "internal";
|
|
105
|
+
stats.rejected += entries.length;
|
|
106
|
+
stats.byCode[code] = (stats.byCode[code] || 0) + 1;
|
|
107
|
+
if (code === "protocol_unsupported") {
|
|
108
|
+
stopped = true;
|
|
109
|
+
pending.clear(); queued.clear(); // this gateway keeps no index: nothing queued will ever be accepted
|
|
110
|
+
warn("[la-content] the gateway keeps no content index (protocol_unsupported); not announcing ids until the next connection");
|
|
111
|
+
} else if (code === "rate_limited") {
|
|
112
|
+
backoffMs = Math.min(BACKOFF_MAX_MS, backoffMs ? backoffMs * 2 : BACKOFF_MIN_MS);
|
|
113
|
+
retryNotBefore = now() + backoffMs;
|
|
114
|
+
warn(`[la-content] content.index rate limited; announcing again in ${Math.round(backoffMs / 1000)} s`);
|
|
115
|
+
scheduleRetry(backoffMs);
|
|
116
|
+
} else if (code === "internal" || code === "timeout") {
|
|
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
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// other codes (not_ready, agent_offline, session reset): the batch stays queued; the next ready announces again
|
|
129
|
+
return;
|
|
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);
|
|
135
|
+
}
|
|
136
|
+
backoffMs = 0;
|
|
137
|
+
retryNotBefore = 0;
|
|
138
|
+
} catch { /* nothing here may throw into the data path */ }
|
|
139
|
+
}
|
|
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
|
+
|
|
149
|
+
/** Queues every live id (of one workspace, or of all) that the gateway does not know yet, and sends. */
|
|
150
|
+
function announce({ workspaceId } = {}) {
|
|
151
|
+
if (closed || stopped) return 0;
|
|
152
|
+
pruneAnnounced();
|
|
153
|
+
let n = 0;
|
|
154
|
+
const live = registry.live().filter((e) => (workspaceId === undefined || e.workspaceId === workspaceId) && !announced.has(e.id));
|
|
155
|
+
// when there are more than the gateway keeps, the newest ones are the ones a client can still use
|
|
156
|
+
const room = Math.max(0, config.indexMaxIds - announced.size);
|
|
157
|
+
for (const e of room === 0 ? [] : live.slice(-room)) {
|
|
158
|
+
if (enqueue({ workspaceId: e.workspaceId, kind: e.kind, ref: e.id, size: e.size, expiresAt: e.expiresAt })) n++;
|
|
159
|
+
}
|
|
160
|
+
if (n > 0) schedule(0);
|
|
161
|
+
return n;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const onReady = (info) => {
|
|
165
|
+
stopped = false;
|
|
166
|
+
backoffMs = 0;
|
|
167
|
+
retryNotBefore = 0;
|
|
168
|
+
if (!info || !info.resumed) { announced.clear(); pending.clear(); queued.clear(); attempts = 0; announce(); }
|
|
169
|
+
else void pump();
|
|
170
|
+
};
|
|
171
|
+
session.on("ready", onReady);
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
stats,
|
|
175
|
+
/** A freshly minted id: queued, sent after the batch delay. */
|
|
176
|
+
add({ workspaceId, kind, ref, size = 0, expiresAt }) {
|
|
177
|
+
const ok = enqueue({ workspaceId, kind, ref, size, expiresAt: expiresAt ?? now() + config.refTtlMs });
|
|
178
|
+
if (ok) schedule();
|
|
179
|
+
return ok;
|
|
180
|
+
},
|
|
181
|
+
announce,
|
|
182
|
+
get pending() { return queued.size; },
|
|
183
|
+
get announcedCount() { pruneAnnounced(); return announced.size; },
|
|
184
|
+
/** Waits until nothing is queued or in flight (tests, shutdown). */
|
|
185
|
+
async flush() { if (batchTimer) { timers.clearTimeout(batchTimer); batchTimer = null; } await pump(); await running; },
|
|
186
|
+
close() {
|
|
187
|
+
closed = true;
|
|
188
|
+
if (batchTimer) timers.clearTimeout(batchTimer);
|
|
189
|
+
if (retryTimer) timers.clearTimeout(retryTimer);
|
|
190
|
+
batchTimer = retryTimer = null;
|
|
191
|
+
pending.clear();
|
|
192
|
+
queued.clear();
|
|
193
|
+
session.off("ready", onReady);
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
@@ -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
|
+
}
|