deepdebug-local-agent 1.6.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 +33 -9
- package/env.example +3 -1
- package/package.json +1 -1
- package/src/la-protocol-v2/content-handlers/config.js +3 -1
- package/src/la-protocol-v2/content-handlers/content-index.js +54 -19
- 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 +43 -8
- package/src/la-protocol-v2/content-handlers/handler.js +28 -4
- package/src/la-protocol-v2/content-handlers/install.js +24 -19
- package/src/la-protocol-v2/content-handlers/path-guard.js +74 -5
- package/src/la-protocol-v2/content-handlers/ref-registry.js +18 -8
- package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +10 -8
- package/src/la-protocol-v2/contract/README.md +38 -0
- package/src/la-protocol-v2/contract/content.index.schema.json +1 -0
- package/src/la-protocol-v2/contract/content.request.schema.json +1 -0
- package/src/la-protocol-v2/contract/file.list.body.schema.json +69 -0
- package/src/la-protocol-v2/contract.js +9 -0
- package/src/la-protocol-v2/runtime.js +1 -1
- package/test/contract/la-protocol-v2/vectors/content.index.vectors.json +32 -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/la-protocol-v2/content-alignment.test.js +71 -10
- 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-path-guard.test.js +1 -1
- package/test/la-protocol-v2/content-wiring.test.js +2 -2
- package/test/la-protocol-v2/contract.test.js +14 -4
- package/tools/interop/LaV2NodeFileListE2ETest.java +369 -0
- package/tools/interop/README.md +16 -0
- package/tools/interop/la-interop-list-client.mjs +93 -0
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* 1. accept: the handler answers the session at once ({accept:true} = ack `accepted`); the work goes on in the
|
|
7
7
|
* background (the first chunk may take seconds, the ack timeout is 5 s). A request without requestId is refused.
|
|
8
8
|
* 2. admission: at most `maxConcurrent` requests and `maxInFlightBytes` in memory; above it -> `rate_limited`.
|
|
9
|
-
* 3. kind: `file` and `
|
|
9
|
+
* 3. kind: `file`, `diff` and `file.list` (F3-07c: a folder listing that mints the ids of what it shows) are served; every other kind of the allowlist -> status `error` + `protocol_unsupported`
|
|
10
10
|
* (the contract has no "not supported" status; this is the code already used for "no handler").
|
|
11
11
|
* 4. workspace: the envelope's workspace must be registered here (and belong to this tenant) -> else `not_found`.
|
|
12
12
|
* 5. ref: an opaque id (ref-registry.js) valid for this tenant+workspace+root+kind -> else `not_found`, whatever the
|
|
@@ -22,16 +22,19 @@ import { ProtocolError } from "../errors.js";
|
|
|
22
22
|
import { sendContent, sendContentFailure } from "../content.js";
|
|
23
23
|
import { REF_KINDS } from "./ref-registry.js";
|
|
24
24
|
import { normalizeRel, resolveInside, readRegular } from "./path-guard.js";
|
|
25
|
+
import { listFolder, ROOT_REF, clampLimit, MAX_LIST_BYTES } from "./file-list.js";
|
|
25
26
|
import { worktreeDiff } from "./git-diff.js";
|
|
26
|
-
import { prepareOutgoing, contentTypeOf, decodeText } from "./egress.js";
|
|
27
|
+
import { prepareOutgoing, prepareList, maskName, contentTypeOf, decodeText } from "./egress.js";
|
|
27
28
|
import { createAudit, short } from "./audit.js";
|
|
28
29
|
import { dlpConfigFromEnv } from "../../dlp/index.js";
|
|
29
30
|
|
|
30
|
-
export const SERVED_KINDS = Object.freeze(["file", "diff"]);
|
|
31
|
+
export const SERVED_KINDS = Object.freeze(["file", "diff", "file.list"]);
|
|
31
32
|
/** Symbolic ref of kind `diff`: the whole work tree of the envelope's workspace (carries no client data). */
|
|
32
33
|
export const WORKTREE_REF = "worktree";
|
|
33
34
|
|
|
34
|
-
export function createContentHandler({ session, tenantId, resolveWorkspace, registry, config, env = process.env, log = console, audit = null, now = Date.now }) {
|
|
35
|
+
export function createContentHandler({ session, tenantId, resolveWorkspace, registry, config, env = process.env, log = console, audit = null, now = Date.now, mint = null }) {
|
|
36
|
+
// F3-07c: where the ids of a listing come from. install.js passes mintRef (registry + content.index); without it (tests) the registry alone.
|
|
37
|
+
const mintFor = (workspaceId, root) => (o) => (mint ? mint({ workspaceId, ...o }) : registry.mint({ workspaceId, root, ...o }));
|
|
35
38
|
const inflight = new Map(); // requestId -> { ac, timer }
|
|
36
39
|
let reservedBytes = 0;
|
|
37
40
|
const auditor = audit || createAudit({ file: config.auditFile, now });
|
|
@@ -49,6 +52,7 @@ export function createContentHandler({ session, tenantId, resolveWorkspace, regi
|
|
|
49
52
|
if (dlpConfigFromEnv(env).mode !== "enforce") return { status: "error", errorCode: "protocol_unsupported" };
|
|
50
53
|
const ws = await resolveWorkspace(ctx.workspaceId);
|
|
51
54
|
if (!ws || !ws.root) return { status: "not_found" };
|
|
55
|
+
if (p.kind === "file.list") return produceList(ctx, p, ws, signal);
|
|
52
56
|
const target = registry.resolve(p.ref, { workspaceId: ctx.workspaceId, root: ws.root, kind: p.kind });
|
|
53
57
|
if (p.kind === "diff" && !target && p.ref === WORKTREE_REF) return produceDiff(p, ws, null, signal);
|
|
54
58
|
if (!target) return { status: "not_found" };
|
|
@@ -68,6 +72,26 @@ export function createContentHandler({ session, tenantId, resolveWorkspace, regi
|
|
|
68
72
|
return finish(p, read.buffer, contentTypeOf(norm, isText), "content.file");
|
|
69
73
|
}
|
|
70
74
|
|
|
75
|
+
/** F3-07c: `root` or a folder id (or the nextRef of a previous page) -> one page of names with the ids to ask for them. */
|
|
76
|
+
async function produceList(ctx, p, ws, signal) {
|
|
77
|
+
if (p.range) return { status: "error", errorCode: "protocol_unsupported" }; // a listing has no byte range
|
|
78
|
+
let rel = null;
|
|
79
|
+
let after = null;
|
|
80
|
+
if (p.ref !== ROOT_REF) {
|
|
81
|
+
const target = registry.resolve(p.ref, { workspaceId: ctx.workspaceId, root: ws.root, kind: "file.list" });
|
|
82
|
+
if (!target) return { status: "not_found" };
|
|
83
|
+
rel = target.rel && normalizeRel(target.rel);
|
|
84
|
+
if (target.rel && !rel) return { status: "not_found" };
|
|
85
|
+
after = target.after;
|
|
86
|
+
}
|
|
87
|
+
const r = await listFolder({ root: ws.root, rel, after, limit: clampLimit(p.limit), maxBytes: config.maxBytes, maxDirEntries: config.maxDirEntries,
|
|
88
|
+
denyGlobs: config.extraDenyGlobs, allowHardlinks: config.allowHardlinks, signal, mint: mintFor(ctx.workspaceId, ws.root),
|
|
89
|
+
maskName: (n) => maskName(n, { env }) });
|
|
90
|
+
if (!r.ok) return r;
|
|
91
|
+
const out = prepareList({ buffer: r.body, maxBytes: Math.min(config.maxBytes, MAX_LIST_BYTES), dlp: { mode: "enforce", total: r.dlp.total, redactions: r.dlp.redactions } });
|
|
92
|
+
return out.ok ? { status: "ok", buffer: out.buffer, contentType: out.contentType, dlp: out.dlp } : { status: out.status, errorCode: out.errorCode, dlp: out.dlp };
|
|
93
|
+
}
|
|
94
|
+
|
|
71
95
|
async function produceDiff(p, ws, rel, signal) {
|
|
72
96
|
const d = await worktreeDiff({ root: ws.root, rel, maxBytes: config.maxBytes, maxFiles: config.maxDiffFiles,
|
|
73
97
|
timeoutMs: config.gitTimeoutMs, denyGlobs: config.extraDenyGlobs, allowHardlinks: config.allowHardlinks, signal });
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
* install.js - puts the content handlers on a v2 link (F3-07, F3-07b). Called by runtime.js only when
|
|
3
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, size?})
|
|
6
|
-
* summaries): returns {id, expiresAt} for that item (id = 43 base64url chars), or null
|
|
5
|
+
* mintRef({workspaceId, kind, rel, size?, after?}) returns {id, expiresAt} for that item (id = 43 base64url chars), or null
|
|
7
6
|
* (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).
|
|
9
|
-
*
|
|
7
|
+
* `content.index` frame ({kind, ref, size}: no name, no path). F3-07c: the real caller is the
|
|
8
|
+
* `file.list` handler (handler.js / file-list.js), which mints one id per entry it lists;
|
|
9
|
+
* `announce()` re-sends what is live.
|
|
10
10
|
*/
|
|
11
11
|
import { dlpConfigFromEnv } from "../../dlp/index.js";
|
|
12
12
|
import { ContentRefRegistry } from "./ref-registry.js";
|
|
@@ -34,9 +34,26 @@ export function installContentHandlers({ link, tenantId, wsManager, getWorkspace
|
|
|
34
34
|
return null;
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
// `indexer` is created below (it needs the session only); mintRef and the handler use it through this binding
|
|
38
|
+
let indexer = null;
|
|
39
|
+
const mintRef = ({ workspaceId, kind, rel, size, after }) => {
|
|
40
|
+
try {
|
|
41
|
+
const ws = resolveWorkspace(workspaceId);
|
|
42
|
+
if (!ws) return null;
|
|
43
|
+
const bytes = Number.isSafeInteger(size) ? size
|
|
44
|
+
: kind === "file" && typeof rel === "string" ? sizeInsideSync(ws.root, rel.replace(/\\/g, "/"), { allowHardlinks: config.allowHardlinks }) : 0;
|
|
45
|
+
const minted = registry.mint({ workspaceId, root: ws.root, kind, rel, size: bytes, after });
|
|
46
|
+
if (minted && indexer) indexer.add({ workspaceId, kind, ref: minted.id, size: bytes, expiresAt: minted.expiresAt });
|
|
47
|
+
return minted;
|
|
48
|
+
} catch {
|
|
49
|
+
return null; // a failing workspace manager means "no id", never an exception with a path in it
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// F3-07c: the listing handler mints through mintRef, so every id it hands out is also announced with content.index
|
|
54
|
+
const handler = createContentHandler({ session: link.session, tenantId: tenant, resolveWorkspace, registry, config, env, log, mint: mintRef });
|
|
38
55
|
link.session.setHandler("content.request", (ctx) => handler.handle(ctx));
|
|
39
|
-
|
|
56
|
+
indexer = createContentIndexer({ session: link.session, registry, config, log });
|
|
40
57
|
|
|
41
58
|
const closeLink = link.close.bind(link);
|
|
42
59
|
link.close = () => { handler.cancelAll(); indexer.close(); registry.clear(); closeLink(); };
|
|
@@ -45,19 +62,7 @@ export function installContentHandlers({ link, tenantId, wsManager, getWorkspace
|
|
|
45
62
|
registry,
|
|
46
63
|
handler,
|
|
47
64
|
indexer,
|
|
48
|
-
mintRef
|
|
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
|
-
}
|
|
60
|
-
},
|
|
65
|
+
mintRef,
|
|
61
66
|
/** Announces again the live ids (of one workspace or all) that the gateway does not know yet. */
|
|
62
67
|
announce: (opts) => indexer.announce(opts),
|
|
63
68
|
};
|
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
* The gateway lets anything through (it only sees an opaque id); every check that matters is here.
|
|
4
4
|
*
|
|
5
5
|
* normalizeRel a relative path as the agent itself minted it: no NUL/control chars, not absolute, no `..`.
|
|
6
|
-
* isSensitivePath deny list (built-in + LOCAL_AGENT_CONTENT_DENY_GLOBS): .env*, private keys, .git/, .ssh/, ...
|
|
6
|
+
* isSensitivePath deny list (built-in globs + name rules + LOCAL_AGENT_CONTENT_DENY_GLOBS): .env*, private keys, .git/, .ssh/, ...
|
|
7
|
+
* F3-07c: names that merely MENTION a credential (credentials.service.ts, CredentialsController.java) are
|
|
8
|
+
* code and are served; files that ARE credentials (credentials, credentials.json, .credentials,
|
|
9
|
+
* application_default_credentials.json, ...) are not. See sensitiveByName(). The DLP stays the 2nd layer.
|
|
7
10
|
* resolveInside realpath of root and target; the real target must stay inside the real root (a symlink that
|
|
8
11
|
* leaves the workspace is `forbidden`), must not be sensitive under its REAL name either
|
|
9
12
|
* (a harmless-looking link to .env), and must be a regular file.
|
|
@@ -25,14 +28,17 @@ const O_NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0;
|
|
|
25
28
|
/** Built-in deny list. A pattern with no `/` matches any path segment; with `/` it matches a path (or its prefix). */
|
|
26
29
|
export const DEFAULT_DENY_GLOBS = Object.freeze([
|
|
27
30
|
// secrets by name
|
|
28
|
-
".env", ".env.*", "*.env",
|
|
31
|
+
".env", ".env.*", "*.env", ".envs/",
|
|
29
32
|
"*.pem", "*.key", "*.p12", "*.pfx", "*.jks", "*.keystore", "*.p8", "*.ppk", "*.kdbx", "*.gpg", "*.crt.key",
|
|
30
33
|
"id_rsa*", "id_dsa*", "id_ecdsa*", "id_ed25519*",
|
|
31
34
|
".npmrc", ".pypirc", ".netrc", "_netrc", ".pgpass", ".my.cnf", ".git-credentials", ".htpasswd", ".vault-token",
|
|
32
|
-
"
|
|
35
|
+
"service-account*.json", "serviceaccount*.json", "secrets.json", "secrets.yml", "secrets.yaml",
|
|
36
|
+
// F3-07c: directories named for secrets (everything under them), by Edu's decision of 2026-11-03
|
|
37
|
+
"secrets/", ".secrets/", ".credentials/",
|
|
33
38
|
"*.secret", "*.secrets", "*.tfstate", "*.tfstate.*", "*.tfvars", "*.tfvars.json", "terraform.rc",
|
|
34
39
|
// F3-07b: editor backups / autosaves of the env file, kubeconfig files
|
|
35
|
-
".env~", ".env#", ".#.env", "
|
|
40
|
+
".env~", ".env#", ".#.env", "*.kubeconfig",
|
|
41
|
+
// (F3-07c: .envrc, .env_backup, .env-prod, env.production, kubeconfig-prod, credentials* ... are name RULES, sensitiveByName() below)
|
|
36
42
|
// F3-07b: Windows 8.3 aliases ("short names") of the files above: ".env" is also reachable as ENV~1, ".npmrc" as NPMRC~1,
|
|
37
43
|
// "credentials.json" as CREDEN~1.JSO, "id_ed25519" as ID_ED2~1 ... The real name is checked as well (resolveInside), this
|
|
38
44
|
// covers the alias form on its own. `~?*` = "~" + at least one more character.
|
|
@@ -41,6 +47,16 @@ export const DEFAULT_DENY_GLOBS = Object.freeze([
|
|
|
41
47
|
// (an alias keeps the first 3 characters of the extension: `*.pem`, `*.key`, `*.p12`... already catch their aliases; these are the ones whose
|
|
42
48
|
// extension is longer than 3: .tfstate -> .TFS, .tfvars -> .TFV, .kdbx -> .KDB, .secret(s) -> .SEC)
|
|
43
49
|
"*~?*.tfs", "*~?*.tfv", "*~?*.kdb", "*~?*.sec",
|
|
50
|
+
// F3-07c review: credential files that no rule above names (the DLP is only the second layer: it masks known token shapes, not plain values)
|
|
51
|
+
".dockercfg", ".yarnrc", ".yarnrc.yml", ".pnpmrc", ".s3cfg", ".boto", ".terraformrc", ".renviron", "auth.json",
|
|
52
|
+
"client_secret*.json", "firebase-adminsdk*.json", "gha-creds-*.json", "local.settings.json", "wp-config.php",
|
|
53
|
+
"application-prod.properties", "application-prod.yml", "application-prod.yaml",
|
|
54
|
+
"*_rsa", "*_dsa", "*_ecdsa", "*_ed25519", "ssh_host_*_key",
|
|
55
|
+
".m2/settings.xml", ".m2/settings-security.xml",
|
|
56
|
+
// family names that are also directories: everything under them is blocked (a bare `.env_backup/a.txt` was readable)
|
|
57
|
+
".envrc/", ".env_backup/", ".env-prod/", "env.production/", "kubeconfig-prod/",
|
|
58
|
+
// 8.3 aliases of .envrc / .envs
|
|
59
|
+
"envrc~?*", "envs~?*",
|
|
44
60
|
// directories that hold credentials or the repository internals (.git/config may carry a token in a URL)
|
|
45
61
|
".git/", ".ssh/", ".aws/", ".gnupg/", ".kube/", ".docker/", ".azure/", ".terraform/", ".config/gcloud/",
|
|
46
62
|
]);
|
|
@@ -82,12 +98,65 @@ function withoutStreams(folded) {
|
|
|
82
98
|
return folded.split("/").map((seg) => { const i = seg.indexOf(":"); return i > 0 ? seg.slice(0, i) : seg; }).join("/");
|
|
83
99
|
}
|
|
84
100
|
|
|
85
|
-
|
|
101
|
+
/**
|
|
102
|
+
* Extensions of SOURCE / documentation files (F3-07c). A name that mentions a credential, a kubeconfig or an env file is
|
|
103
|
+
* served only when its last extension is one of these: credentials.service.ts, CredentialsController.java, credentials_test.py
|
|
104
|
+
* are code about credentials; credentials, credentials.json, .credentials, credentials.bak are credentials. Everything that is not
|
|
105
|
+
* known code fails closed (an unknown extension is NOT a reason to serve). Not in the list on purpose: data and config formats
|
|
106
|
+
* (json, yml, yaml, xml, toml, ini, cfg, conf, csv, txt, properties, db, sqlite, enc, env ...), shell/batch scripts and gradle
|
|
107
|
+
* (they export or hold values), key stores, backups (bak, old, orig, tmp, swp) and anything with no extension.
|
|
108
|
+
*/
|
|
109
|
+
export const CODE_EXTENSIONS = Object.freeze(new Set([
|
|
110
|
+
"ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs", "java", "kt", "kts", "py", "go", "rb", "cs", "rs", "php", "scala", "swift",
|
|
111
|
+
"vue", "svelte", "astro", "c", "cc", "cpp", "cxx", "h", "hh", "hpp", "m", "mm", "sql", "md", "markdown", "rst", "adoc", "html", "htm",
|
|
112
|
+
"css", "scss", "sass", "less", "dart", "ex", "exs", "erl", "hrl", "clj", "cljs", "lua", "pl", "pm", "r", "jl", "hs", "ml", "fs", "fsx",
|
|
113
|
+
"vb", "groovy", "proto", "graphql", "gql", "tf", "cshtml", "razor", "jsp", "ejs", "hbs", "pug", "zig", "nim", "sol",
|
|
114
|
+
]));
|
|
115
|
+
|
|
116
|
+
/** Last extension of a (folded) file name; "" for no extension and for a name that only starts with a dot. */
|
|
117
|
+
function extOf(name) {
|
|
118
|
+
const i = name.lastIndexOf(".");
|
|
119
|
+
return i > 0 ? name.slice(i + 1) : "";
|
|
120
|
+
}
|
|
121
|
+
const isCodeName = (name) => CODE_EXTENSIONS.has(extOf(name));
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Name rules (on top of the globs): the families whose names have to be told apart from source code. `segs` are the folded path
|
|
125
|
+
* segments; the last one is a directory when `dir` is true (a listing entry), a file otherwise.
|
|
126
|
+
* - .env family: `.env`, `.envrc`, then end or a separator (. _ - # ~): .env_backup, .env-prod, .env.local~, .envrc.local. Not .environment.
|
|
127
|
+
* - env.<x> / env_<x> / env-<x> and a bare `env` file: env.production, env_prod: unless code (env.ts, env.d.ts, env.config.mjs).
|
|
128
|
+
* - names with "credential" or "kubeconfig": unless code (credentials.service.ts) ... a DIRECTORY with such a name is fine
|
|
129
|
+
* (src/credentials/), but a file under one has to be code (credentials/prod.json is not), and `.credentials` is never fine.
|
|
130
|
+
*/
|
|
131
|
+
function sensitiveByName(segs, dir) {
|
|
132
|
+
const last = segs.length - 1;
|
|
133
|
+
for (let i = 0; i < segs.length; i++) {
|
|
134
|
+
const seg = segs[i];
|
|
135
|
+
const asDir = i < last || dir;
|
|
136
|
+
if (asDir) {
|
|
137
|
+
if (seg === ".credentials") return true;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (/^\.env(?:rc)?(?![a-z])/.test(seg)) return true;
|
|
141
|
+
if (seg === "env" || (/^env[._-]/.test(seg) && !isCodeName(seg))) return true;
|
|
142
|
+
if ((seg.includes("credential") || seg.includes("kubeconfig")) && !isCodeName(seg)) return true;
|
|
143
|
+
if (i > 0 && segs.slice(0, i).some((d) => d.includes("credential")) && !isCodeName(seg)) return true;
|
|
144
|
+
}
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* @param {string} rel
|
|
150
|
+
* @param {string[]} [extraGlobs]
|
|
151
|
+
* @param {{dir?: boolean}} [o] dir: the last segment is a directory (a listing entry, a folder id): file-only name rules do not apply to it
|
|
152
|
+
*/
|
|
153
|
+
export function isSensitivePath(rel, extraGlobs = [], { dir = false } = {}) {
|
|
86
154
|
if (typeof rel !== "string") return true;
|
|
87
155
|
const folded = foldForMatch(rel.replace(/\\/g, "/"));
|
|
88
156
|
const stripped = withoutStreams(folded);
|
|
89
157
|
const forms = stripped === folded ? [folded] : [folded, stripped];
|
|
90
158
|
for (const g of DEFAULT_DENY_GLOBS) { const re = regexFor(g); if (forms.some((f) => re.test(f))) return true; }
|
|
159
|
+
for (const f of forms) if (sensitiveByName(f.split("/"), dir)) return true;
|
|
91
160
|
for (const g of extraGlobs) if (typeof g === "string" && g) { const re = regexFor(g); if (forms.some((f) => re.test(f))) return true; }
|
|
92
161
|
return false;
|
|
93
162
|
}
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* In memory only: a restart of the agent forgets every id (also not_found), by design.
|
|
13
13
|
* binding an id is valid for ONE tenant, ONE workspace (and the root that workspace had when it was minted),
|
|
14
14
|
* ONE kind. Any other combination resolves to null, indistinguishable from "never existed".
|
|
15
|
+
* file.list (F3-07c) the id of a FOLDER (rel = the folder, null = the workspace root) or the continuation of a listing page
|
|
16
|
+
* (`after` = the position where the previous page stopped, kept here: the control plane only ever sees the id).
|
|
15
17
|
* size at most `maxEntries`; the oldest go first.
|
|
16
18
|
*
|
|
17
19
|
* Nothing here logs, throws a message with a path, or exposes an id's target to anything but resolve().
|
|
@@ -20,7 +22,7 @@ import crypto from "node:crypto";
|
|
|
20
22
|
import { normalizeRel, isSensitivePath } from "./path-guard.js";
|
|
21
23
|
|
|
22
24
|
export const REF_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
|
23
|
-
export const REF_KINDS = Object.freeze(["file", "diff"]);
|
|
25
|
+
export const REF_KINDS = Object.freeze(["file", "file.list", "diff"]);
|
|
24
26
|
|
|
25
27
|
export class ContentRefRegistry {
|
|
26
28
|
constructor({ tenantId, ttlMs = 15 * 60_000, maxEntries = 20_000, now = Date.now, denyGlobs = [] } = {}) {
|
|
@@ -44,32 +46,40 @@ export class ContentRefRegistry {
|
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
/**
|
|
47
|
-
* @param {{workspaceId:string, root:string, kind:"file"|"diff", rel?:string|null, size?:number}} o
|
|
48
|
-
* rel = path relative to the workspace root (file: required; diff: optional, null = the whole work tree
|
|
49
|
+
* @param {{workspaceId:string, root:string, kind:"file"|"file.list"|"diff", rel?:string|null, size?:number, after?:{dir:boolean,name:string}|null}} o
|
|
50
|
+
* rel = path relative to the workspace root (file: required; diff: optional, null = the whole work tree;
|
|
51
|
+
* file.list: the folder, null = the root)
|
|
52
|
+
* after = file.list only: the last entry of the previous page (dirs first, then names by code point)
|
|
49
53
|
* size = informative size in bytes that content.index announces (0 when unknown); never a name or a path
|
|
50
54
|
* @returns {{id:string, expiresAt:number} | null} null = refused (bad kind/path or a sensitive file): no id exists
|
|
51
55
|
*/
|
|
52
|
-
mint({ workspaceId, root, kind, rel = null, size = 0 }) {
|
|
56
|
+
mint({ workspaceId, root, kind, rel = null, size = 0, after = null }) {
|
|
53
57
|
if (!REF_KINDS.includes(kind) || typeof workspaceId !== "string" || !workspaceId || typeof root !== "string" || !root) return null;
|
|
54
58
|
let norm = null;
|
|
55
59
|
if (rel !== null && rel !== undefined) {
|
|
56
60
|
norm = normalizeRel(rel);
|
|
57
|
-
|
|
61
|
+
// never hand out an id for a file we would refuse (for a folder id: the folder rules)
|
|
62
|
+
if (!norm || isSensitivePath(norm, this.denyGlobs, { dir: kind === "file.list" })) return null;
|
|
58
63
|
} else if (kind === "file") {
|
|
59
64
|
return null;
|
|
60
65
|
}
|
|
66
|
+
let cursor = null;
|
|
67
|
+
if (after !== null && after !== undefined) {
|
|
68
|
+
if (kind !== "file.list" || typeof after !== "object" || typeof after.name !== "string" || !after.name || after.name.length > 1024) return null;
|
|
69
|
+
cursor = { dir: after.dir === true, name: after.name };
|
|
70
|
+
}
|
|
61
71
|
this.#purge();
|
|
62
72
|
let id;
|
|
63
73
|
do { id = crypto.randomBytes(32).toString("base64url"); } while (this.entries.has(id)); // 43 chars; a repeat is astronomically unlikely, never reused
|
|
64
74
|
const expiresAt = this.now() + this.ttlMs;
|
|
65
75
|
const sz = Number.isSafeInteger(size) && size >= 0 && size <= 2 ** 40 ? size : 0;
|
|
66
|
-
this.entries.set(id, { tenantId: this.tenantId, workspaceId, root, kind, rel: norm, size: sz, expiresAt });
|
|
76
|
+
this.entries.set(id, { tenantId: this.tenantId, workspaceId, root, kind, rel: norm, size: sz, after: cursor, expiresAt });
|
|
67
77
|
this.#purge(); // capacity includes the new entry
|
|
68
78
|
return { id, expiresAt };
|
|
69
79
|
}
|
|
70
80
|
|
|
71
81
|
/**
|
|
72
|
-
* @returns {{rel:string|null}} | null null for: not an id, unknown, expired, other tenant/workspace/root/kind
|
|
82
|
+
* @returns {{rel:string|null, after:{dir:boolean,name:string}|null}} | null null for: not an id, unknown, expired, other tenant/workspace/root/kind
|
|
73
83
|
*/
|
|
74
84
|
resolve(id, { workspaceId, root, kind }) {
|
|
75
85
|
if (typeof id !== "string" || !REF_PATTERN.test(id)) return null;
|
|
@@ -77,7 +87,7 @@ export class ContentRefRegistry {
|
|
|
77
87
|
if (!e) return null;
|
|
78
88
|
if (e.expiresAt <= this.now()) { this.entries.delete(id); return null; }
|
|
79
89
|
if (e.tenantId !== this.tenantId || e.workspaceId !== workspaceId || e.root !== root || e.kind !== kind) return null;
|
|
80
|
-
return { rel: e.rel };
|
|
90
|
+
return { rel: e.rel, after: e.after };
|
|
81
91
|
}
|
|
82
92
|
|
|
83
93
|
/**
|
|
@@ -1,22 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"repo": "VISIONHAMMER/vh-api-gateway",
|
|
3
3
|
"path": "docs/contracts/la-protocol-v2",
|
|
4
|
-
"branch": "
|
|
5
|
-
"commit": "
|
|
4
|
+
"branch": "release/qa",
|
|
5
|
+
"commit": "b9176054337eeabe416117bafd90ce484a71968e",
|
|
6
6
|
"note": "Copied by scripts/sync-la-protocol-v2-contract.mjs. Do not edit: change the gateway folder and sync again.",
|
|
7
7
|
"files": {
|
|
8
|
-
"README.md": "
|
|
8
|
+
"README.md": "93fe7d94f212c36d5402a20cd1397e5236012c6bf1f6bb81546a65033703a88f",
|
|
9
9
|
"ack.schema.json": "d6dfdaa8b1ec2dd47db9e858cd047ca860196ab6b0ad350f8ccb5d2596441ccf",
|
|
10
10
|
"alert.schema.json": "01a8a77840be8ab7901ed7d07c3d68a2295bfaeb638694564e19e60be1747054",
|
|
11
11
|
"approval.decision.schema.json": "a9c7bc74d146465afde1d5830839165a02f232b830b5f6cfd8c8e91fb6da1563",
|
|
12
12
|
"audit.event.schema.json": "e9ceb58ea00077151373955957b8319324de488c80da4063f6d0dcb40a8b3ed0",
|
|
13
13
|
"config.update.schema.json": "f5318f24230f542c76a3cb996d08f33210c0b2cc2fc55ad73c4f95648582033f",
|
|
14
|
-
"content.index.schema.json": "
|
|
15
|
-
"content.request.schema.json": "
|
|
14
|
+
"content.index.schema.json": "1abc24344435f0f392d5b626c3abb6879d8f9dd2c1d0463840a67634a5caa71e",
|
|
15
|
+
"content.request.schema.json": "440e3675246657e9aae23e51176168bc21c91283b5fcaf6782111a18110bf76e",
|
|
16
16
|
"content.response.schema.json": "c9a66ac9cbbcaec7fc182b3e3fa23b34877bd342592521c7e393779230332231",
|
|
17
17
|
"defs.schema.json": "f19fd27fd103d8da02de4506c086263c6236250c5c127aa3aab41833a90b743a",
|
|
18
18
|
"envelope.schema.json": "92b3e5b31e6235689a1c22fae1d1eab3f93d377a19032809e47ee014c0ce67e1",
|
|
19
19
|
"error.schema.json": "b1fd663d4a1ea73d88cb575422f15f8c7279001f9385ebb2d5e1889b5d5ef878",
|
|
20
|
+
"file.list.body.schema.json": "dd58ac2af91bbde53e0fd585dc55af0adb01380492d1604f21ed8e972d7ba8d1",
|
|
20
21
|
"heartbeat.schema.json": "8aff84a1ef3ca56bdff8819abb3b0ab01a2547814c2ea2f9ee893cb08f7a08f1",
|
|
21
22
|
"hello.schema.json": "bd500320f01ff10da49ded6f99c7e8081252019984afb3c178e27003df20b963",
|
|
22
23
|
"incident.candidate.schema.json": "b292a85917b5958fc6de10a2895732e30a35420385623930c78e47341862dc62",
|
|
@@ -36,11 +37,12 @@
|
|
|
36
37
|
"vectors/approval.decision.vectors.json": "338115973bf2c4390d2bb33ce8f4079f3132cd739cd5505e1e873fdc252d2271",
|
|
37
38
|
"vectors/audit.event.vectors.json": "d05c1820103eef041a84eb8b13d9a67a8a5b54b9b54ee3b6e43fd4ef15ed7000",
|
|
38
39
|
"vectors/config.update.vectors.json": "8a5088d547e3eb4e460b25a5e413caaa02d4402a601340e279166d8c17758e1c",
|
|
39
|
-
"vectors/content.index.vectors.json": "
|
|
40
|
-
"vectors/content.request.vectors.json": "
|
|
41
|
-
"vectors/content.response.vectors.json": "
|
|
40
|
+
"vectors/content.index.vectors.json": "f167db30910989c46f317313d651ca59f9cd9d881d2ea81b84912155d18227b7",
|
|
41
|
+
"vectors/content.request.vectors.json": "fdf0f4d9e19f6480e20e40f4652bf8205da2b113620f8461dfaeb0d61add36a0",
|
|
42
|
+
"vectors/content.response.vectors.json": "cbb47cbf8a9512aa5360eea425f0cdd9064334311d37834dc75bddff07dadc5d",
|
|
42
43
|
"vectors/cross-type.vectors.json": "f7ed403d3e3f53150b715a5423fde1bed708e8c6245709ed4ad74389dc8c3307",
|
|
43
44
|
"vectors/error.vectors.json": "f5fd7954f2c1736ee0943eb330c2f85b7fdd7a8959e4f043f9cc9e9bcda39aa5",
|
|
45
|
+
"vectors/file.list.body.vectors.json": "da1dcc5d4ec976853161bef4b3947572f36b5526cbe16b838ac4ef72ddded403",
|
|
44
46
|
"vectors/heartbeat.vectors.json": "cfd2e048139c67aa925f1fa94c05326e75248839645c1a1e4d6d3bb60cafa624",
|
|
45
47
|
"vectors/hello.vectors.json": "51067dc645a2e9d8116997b36e6f3fe36401d27f3ee3c18de32ec30f8a052c5f",
|
|
46
48
|
"vectors/incident.candidate.vectors.json": "0dd75ef396bf7b7911d20b3ab220469701f07866bb032ee31af58523c3633ab6",
|
|
@@ -9,6 +9,8 @@ exist yet. v1 is untouched. The gateway packages `*.schema.json` + `index.json`
|
|
|
9
9
|
## Files
|
|
10
10
|
- `envelope.schema.json`: common frame, closed (`additionalProperties:false`). `<type>.schema.json` (22):
|
|
11
11
|
one per allowlisted type, each `$ref`s the envelope and closes `payload`. `defs.schema.json`: shared types.
|
|
12
|
+
- `file.list.body.schema.json` (F3-07c): not a frame type; the schema of the DECODED body of a `content.response` for kind `file.list`
|
|
13
|
+
(below). `vectors/file.list.body.vectors.json` holds its vectors (same format, `schema` names that file).
|
|
12
14
|
- `index.json`: allowlist, direction, signed/control flags, content class per type, and the limits below.
|
|
13
15
|
- `vectors/<type>.vectors.json` (+ `cross-type`): valid and invalid messages with `expect` (`valid`, error `code`,
|
|
14
16
|
optional JSON Schema `keyword`, optional session `rule`). `vectors/traces/*.json`: whole sessions for the stateful rules.
|
|
@@ -148,3 +150,39 @@ It is metadata about client content, not client content (`contentClass: none`).
|
|
|
148
150
|
and 2048 live ids per session, send `content.index` only when its content handlers are on and DLP is `enforce`, and treat the
|
|
149
151
|
ids as expiring (it answers `not_found` for an id it forgot). It must never put a path or a file name in a frame.
|
|
150
152
|
|
|
153
|
+
|
|
154
|
+
## `file.list` (CP -> LA content kind, F3-07c): how the front finds out what exists on the customer's side
|
|
155
|
+
**Decision (Edu, 2026-11-03; no new frame type, no new limit in `index.json`).** The control plane knows no file, no folder and no name,
|
|
156
|
+
and must not learn them: `content.index` carries ids only. So the answer to "what is there?" is a content kind like the others, served on
|
|
157
|
+
demand and relayed without being kept: `file.list` lists ONE folder of ONE workspace, and its body (names, sizes, times) travels only
|
|
158
|
+
inside `content.response.data` (client content in transit: DLP at the agent, never parsed, stored or logged by the CP, bounded). The
|
|
159
|
+
front then asks for an item by the id it was given (`file` for a file, `file.list` for a folder). Nothing else was needed: the
|
|
160
|
+
existing kinds, `ref` convention, ids, envelope and chunking are reused; the changes are the kind in two enums and the body schema.
|
|
161
|
+
- **Request** `content.request{kind:"file.list", ref, limit?, actor}` (the envelope carries the `workspaceId`, always required for this
|
|
162
|
+
kind). `ref` is `root` (symbolic, exact, lower-case: the root folder of that workspace) or a 43-character opaque id: a folder id taken
|
|
163
|
+
from a previous listing, or the `nextRef` of one. `range` is not used (the gateway refuses it); `limit` is the page size, 1..1000
|
|
164
|
+
(default chosen by the agent, 200 suggested). The gateway never accepts a path or a UUID here.
|
|
165
|
+
- **Response** `content.response`, `contentType:"application/json"` (anything else is refused by the gateway as `agent_error`), body =
|
|
166
|
+
`file.list.body.schema.json`: `{entries:[{id, name, type:"file"|"dir", size, mtime}], nextRef?}`. `name` is the BASE name, 1..255
|
|
167
|
+
characters, no `/`, no control character, never `.` or `..`; a path cannot be expressed. `id` (43 chars) is minted by the agent
|
|
168
|
+
for the item, scoped to the workspace like every id; `size` is bytes (0 for a dir); `mtime` UTC. `nextRef`, when present, means
|
|
169
|
+
more entries: ask `file.list` again with it as `ref` (it is a continuation id the agent minted, 43 chars, not a position the client
|
|
170
|
+
can build). Order is the agent's and stable within one listing: dirs first, then names in code point order. An empty folder is
|
|
171
|
+
`entries: []`.
|
|
172
|
+
- **Limits.** At most **1000 entries** per page. The whole body is at most **1048576 bytes (1 MiB)**, half of the generic 2 MiB: the
|
|
173
|
+
gateway answers `too_large` above it, so the agent must cut a page by size as well as by count (1000 entries of 255-character
|
|
174
|
+
names do not fit) and give a `nextRef`. A page never holds the whole tree: there is no recursive listing.
|
|
175
|
+
- **What the agent lists.** Only what it could serve with kind `file` / `file.list`: it applies the same deny list, the same
|
|
176
|
+
symlink/hard-link and root checks to the listing, so an entry the agent would refuse (a credential, a link out of the root, an
|
|
177
|
+
unreadable file) is simply absent, and the ids it hands out resolve inside the workspace only. Names are client content: the DLP
|
|
178
|
+
runs on the body like on any other, the front must treat them as text (escape, never HTML).
|
|
179
|
+
- **Ids and the index.** Ids minted for a listing may be announced with `content.index` (kind `file.list` for a folder id or a
|
|
180
|
+
continuation, `file` for a file), within its limits (256 per frame, 2048 per session). That matters only with
|
|
181
|
+
`deepdebug.content-relay.require-known-ref`, default off and single-instance: with it on, an id that was not announced is `not_found`
|
|
182
|
+
at the gateway; the agent stays the authority either way.
|
|
183
|
+
- **Capability.** An agent that serves this announces `content.file.list` in `register.capabilities` (and `hello`). The gateway sends
|
|
184
|
+
kind `file.list` ONLY to a session that announced it (a Local Agent built before F3-07c does not have the kind in its copy of this
|
|
185
|
+
contract and would answer the whole request with a schema violation); others are skipped, and if no agent of the tenant has it the
|
|
186
|
+
answer is HTTP 501 `not_supported`. That announcement is also how the front/CP discovers that listing exists on a given agent.
|
|
187
|
+
- **Compatibility.** Additive: the kind is added to the `content.request` and `content.index` enums, nothing else changes there. The
|
|
188
|
+
`content.request` payload gained no field. With `deepdebug.content-relay.enabled` off nothing is mapped, as before.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://schemas.visionhammer.io/la-protocol-v2/file.list.body.schema.json",
|
|
4
|
+
"title": "LA protocol v2: decoded body of a content.response for kind file.list (not a frame type)",
|
|
5
|
+
"description": "One page of the listing of ONE folder of a workspace: what the Local Agent puts, as JSON, in the base64 `data` of a content.response (contentType application/json). Client content in transit: the control plane relays it, never parses, stores or logs it. Entries carry the opaque id to ask for the item later (kind `file` for a file, kind `file.list` for a dir), the base name (never a path), size and mtime. Total size of the body: at most 1048576 bytes, which JSON Schema cannot state (README).",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"properties": {
|
|
9
|
+
"entries": {
|
|
10
|
+
"type": "array",
|
|
11
|
+
"maxItems": 1000,
|
|
12
|
+
"items": {
|
|
13
|
+
"type": "object",
|
|
14
|
+
"additionalProperties": false,
|
|
15
|
+
"properties": {
|
|
16
|
+
"id": {
|
|
17
|
+
"type": "string",
|
|
18
|
+
"minLength": 43,
|
|
19
|
+
"maxLength": 43,
|
|
20
|
+
"pattern": "^[A-Za-z0-9_-]{43}$"
|
|
21
|
+
},
|
|
22
|
+
"name": {
|
|
23
|
+
"type": "string",
|
|
24
|
+
"minLength": 1,
|
|
25
|
+
"maxLength": 255,
|
|
26
|
+
"pattern": "^[^\\u0000-\\u001f\\u007f/]{1,255}$",
|
|
27
|
+
"not": {
|
|
28
|
+
"enum": [
|
|
29
|
+
".",
|
|
30
|
+
".."
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"type": {
|
|
35
|
+
"type": "string",
|
|
36
|
+
"enum": [
|
|
37
|
+
"file",
|
|
38
|
+
"dir"
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
"size": {
|
|
42
|
+
"type": "integer",
|
|
43
|
+
"minimum": 0,
|
|
44
|
+
"maximum": 1099511627776
|
|
45
|
+
},
|
|
46
|
+
"mtime": {
|
|
47
|
+
"$ref": "defs.schema.json#/$defs/ts"
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"required": [
|
|
51
|
+
"id",
|
|
52
|
+
"name",
|
|
53
|
+
"type",
|
|
54
|
+
"size",
|
|
55
|
+
"mtime"
|
|
56
|
+
]
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"nextRef": {
|
|
60
|
+
"type": "string",
|
|
61
|
+
"minLength": 43,
|
|
62
|
+
"maxLength": 43,
|
|
63
|
+
"pattern": "^[A-Za-z0-9_-]{43}$"
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
"required": [
|
|
67
|
+
"entries"
|
|
68
|
+
]
|
|
69
|
+
}
|
|
@@ -64,6 +64,15 @@ export function loadContract() {
|
|
|
64
64
|
outboundTypes: Object.entries(index.types).filter(([, i]) => accepts(i, "la->cp")).map(([t]) => t),
|
|
65
65
|
canReceive: (type) => { const i = typeInfo(type); return !!i && accepts(i, "cp->la"); },
|
|
66
66
|
canSend: (type) => { const i = typeInfo(type); return !!i && accepts(i, "la->cp"); },
|
|
67
|
+
/**
|
|
68
|
+
* F3-07c: validates a DECODED body that is not a frame (the file.list listing carried in content.response.data) against its
|
|
69
|
+
* schema file (`*.body.schema.json`). @returns {boolean}
|
|
70
|
+
*/
|
|
71
|
+
validateBody(schemaFile, value) {
|
|
72
|
+
const v = ajv.getSchema(index.baseId + schemaFile);
|
|
73
|
+
if (!v) throw new Error(`contract: no schema ${schemaFile}`);
|
|
74
|
+
return v(value) === true;
|
|
75
|
+
},
|
|
67
76
|
validateFrame(frame) {
|
|
68
77
|
if (frame === null || typeof frame !== "object" || typeof frame.type !== "string") {
|
|
69
78
|
// no `type` at all (a v1 command frame, say): the envelope says what is missing
|
|
@@ -55,7 +55,7 @@ export async function buildV2Link({ tenantId, agentId, signing, wsManager, getWo
|
|
|
55
55
|
contentCfg = { enabled: false };
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
|
-
const contentCaps = contentCfg.enabled ? ["content.diff", "content.file"] : [];
|
|
58
|
+
const contentCaps = contentCfg.enabled ? ["content.diff", "content.file", "content.file.list"] : []; // content.file.list: F3-07c, how the front finds out what exists (the gateway only sends file.list to agents that announce it)
|
|
59
59
|
const link = createV2Link({
|
|
60
60
|
log,
|
|
61
61
|
sessionOptions: {
|
|
@@ -3772,6 +3772,38 @@
|
|
|
3772
3772
|
"last": true
|
|
3773
3773
|
}
|
|
3774
3774
|
}
|
|
3775
|
+
},
|
|
3776
|
+
{
|
|
3777
|
+
"name": "file-list-kind",
|
|
3778
|
+
"why": "ids of folders (kind file.list) are announced like any other id",
|
|
3779
|
+
"expect": {
|
|
3780
|
+
"valid": true
|
|
3781
|
+
},
|
|
3782
|
+
"message": {
|
|
3783
|
+
"schemaVersion": 2,
|
|
3784
|
+
"type": "content.index",
|
|
3785
|
+
"msgId": "00000000-0000-4000-8000-000000000001",
|
|
3786
|
+
"seq": 1,
|
|
3787
|
+
"tenantId": "3f2b8c1e-5a4d-4e7b-9c21-0a1b2c3d4e5f",
|
|
3788
|
+
"workspaceId": "ws-1",
|
|
3789
|
+
"ts": "2026-11-03T10:00:00Z",
|
|
3790
|
+
"agentId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
|
|
3791
|
+
"payload": {
|
|
3792
|
+
"entries": [
|
|
3793
|
+
{
|
|
3794
|
+
"kind": "file.list",
|
|
3795
|
+
"ref": "CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk",
|
|
3796
|
+
"size": 0
|
|
3797
|
+
},
|
|
3798
|
+
{
|
|
3799
|
+
"kind": "file",
|
|
3800
|
+
"ref": "CgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgo",
|
|
3801
|
+
"size": 2048,
|
|
3802
|
+
"updatedAt": "2026-11-03T09:30:00Z"
|
|
3803
|
+
}
|
|
3804
|
+
]
|
|
3805
|
+
}
|
|
3806
|
+
}
|
|
3775
3807
|
}
|
|
3776
3808
|
]
|
|
3777
3809
|
}
|