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
|
@@ -4,12 +4,16 @@
|
|
|
4
4
|
* The control plane never sees a path: the `ref` of a `content.request` is an id that THIS agent minted when it
|
|
5
5
|
* reported metadata (a file list, a diff summary) and that only this agent can map back to (workspace, relative path).
|
|
6
6
|
*
|
|
7
|
-
* id
|
|
8
|
-
*
|
|
7
|
+
* id 43 base64url chars, NO prefix = 256 random bits from crypto.randomBytes(32) (F3-07b: this is the shape
|
|
8
|
+
* the gateway and the contract require; the F3-07 form `cr1_` + 22 chars is no longer minted nor accepted).
|
|
9
|
+
* NOT derived from the path (no hash, no HMAC of it): knowing a path tells nothing about its id and the
|
|
10
|
+
* other way round. The only symbolic ref is `worktree` (kind diff), handled by handler.js, never minted.
|
|
9
11
|
* validity `ttlMs` from the mint (default 15 min); expired ids are gone (not_found), the CP asks for a new listing.
|
|
10
12
|
* In memory only: a restart of the agent forgets every id (also not_found), by design.
|
|
11
13
|
* binding an id is valid for ONE tenant, ONE workspace (and the root that workspace had when it was minted),
|
|
12
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).
|
|
13
17
|
* size at most `maxEntries`; the oldest go first.
|
|
14
18
|
*
|
|
15
19
|
* Nothing here logs, throws a message with a path, or exposes an id's target to anything but resolve().
|
|
@@ -17,8 +21,8 @@
|
|
|
17
21
|
import crypto from "node:crypto";
|
|
18
22
|
import { normalizeRel, isSensitivePath } from "./path-guard.js";
|
|
19
23
|
|
|
20
|
-
export const REF_PATTERN = /^
|
|
21
|
-
export const REF_KINDS = Object.freeze(["file", "diff"]);
|
|
24
|
+
export const REF_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
|
25
|
+
export const REF_KINDS = Object.freeze(["file", "file.list", "diff"]);
|
|
22
26
|
|
|
23
27
|
export class ContentRefRegistry {
|
|
24
28
|
constructor({ tenantId, ttlMs = 15 * 60_000, maxEntries = 20_000, now = Date.now, denyGlobs = [] } = {}) {
|
|
@@ -27,7 +31,7 @@ export class ContentRefRegistry {
|
|
|
27
31
|
this.maxEntries = maxEntries;
|
|
28
32
|
this.now = now;
|
|
29
33
|
this.denyGlobs = denyGlobs;
|
|
30
|
-
this.entries = new Map(); // id -> { tenantId, workspaceId, root, kind, rel, expiresAt } (insertion order = age)
|
|
34
|
+
this.entries = new Map(); // id -> { tenantId, workspaceId, root, kind, rel, size, expiresAt } (insertion order = age)
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
get size() { return this.entries.size; }
|
|
@@ -42,29 +46,40 @@ export class ContentRefRegistry {
|
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
/**
|
|
45
|
-
* @param {{workspaceId:string, root:string, kind:"file"|"diff", rel?:string|null}} o
|
|
46
|
-
* rel
|
|
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)
|
|
53
|
+
* size = informative size in bytes that content.index announces (0 when unknown); never a name or a path
|
|
47
54
|
* @returns {{id:string, expiresAt:number} | null} null = refused (bad kind/path or a sensitive file): no id exists
|
|
48
55
|
*/
|
|
49
|
-
mint({ workspaceId, root, kind, rel = null }) {
|
|
56
|
+
mint({ workspaceId, root, kind, rel = null, size = 0, after = null }) {
|
|
50
57
|
if (!REF_KINDS.includes(kind) || typeof workspaceId !== "string" || !workspaceId || typeof root !== "string" || !root) return null;
|
|
51
58
|
let norm = null;
|
|
52
59
|
if (rel !== null && rel !== undefined) {
|
|
53
60
|
norm = normalizeRel(rel);
|
|
54
|
-
|
|
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;
|
|
55
63
|
} else if (kind === "file") {
|
|
56
64
|
return null;
|
|
57
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
|
+
}
|
|
58
71
|
this.#purge();
|
|
59
|
-
|
|
72
|
+
let id;
|
|
73
|
+
do { id = crypto.randomBytes(32).toString("base64url"); } while (this.entries.has(id)); // 43 chars; a repeat is astronomically unlikely, never reused
|
|
60
74
|
const expiresAt = this.now() + this.ttlMs;
|
|
61
|
-
|
|
75
|
+
const sz = Number.isSafeInteger(size) && size >= 0 && size <= 2 ** 40 ? size : 0;
|
|
76
|
+
this.entries.set(id, { tenantId: this.tenantId, workspaceId, root, kind, rel: norm, size: sz, after: cursor, expiresAt });
|
|
62
77
|
this.#purge(); // capacity includes the new entry
|
|
63
78
|
return { id, expiresAt };
|
|
64
79
|
}
|
|
65
80
|
|
|
66
81
|
/**
|
|
67
|
-
* @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
|
|
68
83
|
*/
|
|
69
84
|
resolve(id, { workspaceId, root, kind }) {
|
|
70
85
|
if (typeof id !== "string" || !REF_PATTERN.test(id)) return null;
|
|
@@ -72,7 +87,20 @@ export class ContentRefRegistry {
|
|
|
72
87
|
if (!e) return null;
|
|
73
88
|
if (e.expiresAt <= this.now()) { this.entries.delete(id); return null; }
|
|
74
89
|
if (e.tenantId !== this.tenantId || e.workspaceId !== workspaceId || e.root !== root || e.kind !== kind) return null;
|
|
75
|
-
return { rel: e.rel };
|
|
90
|
+
return { rel: e.rel, after: e.after };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The live ids of this tenant, oldest first, for content.index (re-announcing after a reconnect). Only what that
|
|
95
|
+
* frame may carry: workspace, kind, id, size. The path stays here.
|
|
96
|
+
* @returns {{workspaceId:string, kind:string, id:string, size:number, expiresAt:number}[]}
|
|
97
|
+
*/
|
|
98
|
+
live() {
|
|
99
|
+
this.#purge();
|
|
100
|
+
const t = this.now();
|
|
101
|
+
const out = [];
|
|
102
|
+
for (const [id, e] of this.entries) if (e.expiresAt > t) out.push({ workspaceId: e.workspaceId, kind: e.kind, id, size: e.size, expiresAt: e.expiresAt });
|
|
103
|
+
return out;
|
|
76
104
|
}
|
|
77
105
|
|
|
78
106
|
revoke(id) { return this.entries.delete(id); }
|
|
@@ -2,25 +2,26 @@
|
|
|
2
2
|
"repo": "VISIONHAMMER/vh-api-gateway",
|
|
3
3
|
"path": "docs/contracts/la-protocol-v2",
|
|
4
4
|
"branch": "release/qa",
|
|
5
|
-
"commit": "
|
|
6
|
-
"branchTip": "7043f75e3ed1b201e2c958fd52f721b5a1177efe",
|
|
5
|
+
"commit": "b9176054337eeabe416117bafd90ce484a71968e",
|
|
7
6
|
"note": "Copied by scripts/sync-la-protocol-v2-contract.mjs. Do not edit: change the gateway folder and sync again.",
|
|
8
7
|
"files": {
|
|
9
|
-
"README.md": "
|
|
8
|
+
"README.md": "93fe7d94f212c36d5402a20cd1397e5236012c6bf1f6bb81546a65033703a88f",
|
|
10
9
|
"ack.schema.json": "d6dfdaa8b1ec2dd47db9e858cd047ca860196ab6b0ad350f8ccb5d2596441ccf",
|
|
11
10
|
"alert.schema.json": "01a8a77840be8ab7901ed7d07c3d68a2295bfaeb638694564e19e60be1747054",
|
|
12
11
|
"approval.decision.schema.json": "a9c7bc74d146465afde1d5830839165a02f232b830b5f6cfd8c8e91fb6da1563",
|
|
13
12
|
"audit.event.schema.json": "e9ceb58ea00077151373955957b8319324de488c80da4063f6d0dcb40a8b3ed0",
|
|
14
13
|
"config.update.schema.json": "f5318f24230f542c76a3cb996d08f33210c0b2cc2fc55ad73c4f95648582033f",
|
|
15
|
-
"content.
|
|
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
|
-
"envelope.schema.json": "
|
|
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",
|
|
23
|
-
"index.json": "
|
|
24
|
+
"index.json": "cf8f05e70b6265a169b0117d4577f78b699818eb5091df6cd4c7c7416385582a",
|
|
24
25
|
"job.assign.schema.json": "fdebb4ae98bf38a2c723281c1cf9477f71d877171df9a55b0baeb0b65244f60c",
|
|
25
26
|
"job.cancel.schema.json": "40ef64373283e17306d857c9a40081689786015294b0b88ab8f68f063bd2cfb8",
|
|
26
27
|
"job.event.schema.json": "493d08f9db4f7ffd4a97403ee33ca4a73a65dc8f774d8f18ef0a137fdb5fc470",
|
|
@@ -36,10 +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.
|
|
40
|
-
"vectors/content.
|
|
40
|
+
"vectors/content.index.vectors.json": "f167db30910989c46f317313d651ca59f9cd9d881d2ea81b84912155d18227b7",
|
|
41
|
+
"vectors/content.request.vectors.json": "fdf0f4d9e19f6480e20e40f4652bf8205da2b113620f8461dfaeb0d61add36a0",
|
|
42
|
+
"vectors/content.response.vectors.json": "cbb47cbf8a9512aa5360eea425f0cdd9064334311d37834dc75bddff07dadc5d",
|
|
41
43
|
"vectors/cross-type.vectors.json": "f7ed403d3e3f53150b715a5423fde1bed708e8c6245709ed4ad74389dc8c3307",
|
|
42
44
|
"vectors/error.vectors.json": "f5fd7954f2c1736ee0943eb330c2f85b7fdd7a8959e4f043f9cc9e9bcda39aa5",
|
|
45
|
+
"vectors/file.list.body.vectors.json": "da1dcc5d4ec976853161bef4b3947572f36b5526cbe16b838ac4ef72ddded403",
|
|
43
46
|
"vectors/heartbeat.vectors.json": "cfd2e048139c67aa925f1fa94c05326e75248839645c1a1e4d6d3bb60cafa624",
|
|
44
47
|
"vectors/hello.vectors.json": "51067dc645a2e9d8116997b36e6f3fe36401d27f3ee3c18de32ec30f8a052c5f",
|
|
45
48
|
"vectors/incident.candidate.vectors.json": "0dd75ef396bf7b7911d20b3ab220469701f07866bb032ee31af58523c3633ab6",
|
|
@@ -59,6 +62,7 @@
|
|
|
59
62
|
"vectors/traces/invalid-chunk-out-of-order.json": "c6560290755d86b524316823d32fcbd1420c170b13e198813c3b7e7b7f32ecb6",
|
|
60
63
|
"vectors/traces/invalid-chunk-total-changes.json": "9b7ade197252275725bc0754e40eadc12a289c063fed13a2905c55583a27a767",
|
|
61
64
|
"vectors/traces/invalid-clock-skew.json": "de16e0bd72e08bb03d44d5f2f04a238dcd38447ff1690f8acfe712ca3fe2028b",
|
|
65
|
+
"vectors/traces/invalid-content-index-before-ready.json": "360ea95992bdc7f0530c4983db0035d73eb0a4c15db952b8c3d9886f4bd00ad7",
|
|
62
66
|
"vectors/traces/invalid-event-before-ready.json": "17081f197fab5f4490b15a088f2bce620921007b5fdd7ed63bd988899d674883",
|
|
63
67
|
"vectors/traces/invalid-first-frame-not-register.json": "cf41c2d0e67f6df382790dab13bb9e49db99a34a88c1f9d108214be5df1e4ea3",
|
|
64
68
|
"vectors/traces/invalid-frame-over-48kib.json": "912e37c9a326ad92284ba22af752269cb5dd52ed06cdc22b17fd3b74a8423734",
|
|
@@ -73,6 +77,7 @@
|
|
|
73
77
|
"vectors/traces/invalid-tenant-mismatch.json": "dcc04ea423bddef20524ead7bfd146a5d9c42fe474c9216dc38c90544d291151",
|
|
74
78
|
"vectors/traces/invalid-type-outside-allowlist.json": "e492d326cfa044d3fc9f7e48dee63a52b715a62ba4867fc5ce21f1d86e3c435f",
|
|
75
79
|
"vectors/traces/valid-content-chunks-in-order.json": "c5fc301527b062b30c625da2798c7cfab3e3debac29a36f6ec7c699705267ab3",
|
|
80
|
+
"vectors/traces/valid-content-index.json": "9305dc955c4cca1f09158a9c8cf21d9e89d6a6b03646088c547f64a61cd59c30",
|
|
76
81
|
"vectors/traces/valid-handshake-and-job.json": "9aa3c423c496403ca77d63965f1805f0a57ea9f6f8a01351310515b265aeb9cc",
|
|
77
82
|
"vectors/traces/valid-resume-continues-seq.json": "8aa90f24598f8f2d87a5355ec58c3531d16fd11ad41c782f53eca063372b704a",
|
|
78
83
|
"vectors/traces/valid-retransmit-is-idempotent.json": "924e9d296a67f0006c289e7cc978b9529ae1aa03bec2cd331a62f31354a90257",
|
|
@@ -7,8 +7,10 @@ exist yet. v1 is untouched. The gateway packages `*.schema.json` + `index.json`
|
|
|
7
7
|
`la-protocol-v2/` (pom resource; both Dockerfiles copy this folder), so this folder stays the only source.
|
|
8
8
|
|
|
9
9
|
## Files
|
|
10
|
-
- `envelope.schema.json`: common frame, closed (`additionalProperties:false`). `<type>.schema.json` (
|
|
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.
|
|
@@ -79,7 +81,7 @@ schema does not know. That runtime check covers the data frames sent through the
|
|
|
79
81
|
handshake and control frames (`ready`, `ack`, `error`) are assembled by fixed code and are NOT schema-checked at runtime: the
|
|
80
82
|
test transport validates every frame the session writes, and the tests fail on a violation.
|
|
81
83
|
A fatal error closes the socket with 4400 + the position of its code in the `errorCode` enum (a test pins the order; `timeout` = 4420). Resume works only on the same gateway instance (state is in memory, 90 s); elsewhere `resumed:false`. Not done in
|
|
82
|
-
F3-05a: relaying a v2 stream between gateway instances
|
|
84
|
+
F3-05a: relaying a v2 stream between gateway instances and the HTTP relay (both done in F3-06, see docs/architecture/la-content-relay.md), LA handlers (F3-05b/07). Gaps, for
|
|
83
85
|
F3-05b to confirm (none changes a schema):
|
|
84
86
|
1. **`byteLength` is the decoded size of `data`**; the gateway refuses a chunk where it differs (`chunk_inconsistent`).
|
|
85
87
|
The F3-22 vectors had 27 for a 26-byte payload and a `max-size-chunk` that decoded to 32769 bytes (fixed in F3-05a); the
|
|
@@ -101,3 +103,86 @@ F3-05b to confirm (none changes a schema):
|
|
|
101
103
|
7. v1 commands are refused for an LA that speaks v2 (`protocol_mismatch`): until F3-06/07 move the callers, a v2 LA
|
|
102
104
|
cannot serve `sendCommand`, so the LA must stay on v1 for tenants whose flows still use v1 commands.
|
|
103
105
|
|
|
106
|
+
## Opaque refs in `content.request` (F3-06b, F3-07b) - convention, no schema change
|
|
107
|
+
`content.request.payload.ref` stays `string, 1..512, no control characters` (the schema and its vectors are unchanged), but
|
|
108
|
+
the gateway, when `deepdebug.content-relay.enabled` is on, ONLY sends one of three shapes and refuses anything else with a 400
|
|
109
|
+
before the agent is asked (no path ever reaches the Local Agent, the logs of the load balancer or the gateway):
|
|
110
|
+
- **opaque id**: exactly **43 characters** of the base64url alphabet (`A-Z a-z 0-9 - _`, no padding, **no prefix**), i.e. 32
|
|
111
|
+
bytes. The Local Agent mints it (32 random bytes, or HMAC-SHA256 of the workspace path under a local secret: a stable id per
|
|
112
|
+
file is fine, a guessable one is not) and keeps the id -> path map LOCALLY; the gateway cannot reverse it. An id is scoped to
|
|
113
|
+
the `workspaceId` of the envelope: the agent must resolve it inside that workspace only. A shorter or prefixed id (for
|
|
114
|
+
example a 26-character `cr1_...`) is NOT an opaque id: the gateway answers 400 and never asks.
|
|
115
|
+
- **UUID** (canonical lower-case 8-4-4-4-12), only for the kinds whose subject is minted by VisionHammer:
|
|
116
|
+
`incident.detail incident.stack chat.session job.stream job.timeline approval.detail`.
|
|
117
|
+
- **`worktree`**, the one symbolic ref, ONLY for `kind: "diff"`: the diff of the whole work tree of the workspace of the
|
|
118
|
+
envelope (`git diff HEAD`, never a file name). Exact, lower-case; it names nothing of the customer's, so it may appear in a
|
|
119
|
+
URL. The gateway requires a `workspaceId` with it (400 otherwise: "the work tree" of which workspace?) and refuses it for
|
|
120
|
+
every other kind. The agent resolves it inside that workspace only, as for an id.
|
|
121
|
+
The Local Agent is the authority: it MUST NOT trust the shape. It validates the workspace (the one in the signed envelope, one
|
|
122
|
+
it serves, else `forbidden`/`not_found`), resolves the id to a path inside that workspace's root (no `..`, no symlink out of
|
|
123
|
+
the root, no sensitive file) and answers `not_found` for an id it does not know. With several agents per tenant the gateway
|
|
124
|
+
asks the one that declared the workspace in `register.workspaces[]` first and tries the next only after `not_found`,
|
|
125
|
+
`forbidden` or `agent_offline` (see docs/architecture/la-content-relay.md). A later contract revision may tighten the schema
|
|
126
|
+
`pattern` once no client sends a path; until then this README is the convention.
|
|
127
|
+
|
|
128
|
+
## `content.index` (LA -> CP, F3-07b): which ids exist
|
|
129
|
+
An unsolicited, **unsigned** LA -> CP frame (`seq` 1.., never chunked, `agentId` required, like `log.stats`) that announces
|
|
130
|
+
the opaque ids the agent minted for ONE workspace (the `workspaceId` of the envelope, required and non-empty here):
|
|
131
|
+
`payload.entries[]`, 1..256 items `{kind, ref, size, updatedAt?}`, closed. `kind` is the `content.request` kind list; `ref` is
|
|
132
|
+
exactly the 43-character id above; `size` is an integer 0..2^40 bytes of the item (informative: the relay still caps a
|
|
133
|
+
response at 2 MiB); `updatedAt` an optional UTC timestamp. **It cannot carry a name or a path**: no free-form field exists,
|
|
134
|
+
every string is an enum or the 43-character pattern, `additionalProperties:false` everywhere, and `uniqueItems` refuses the
|
|
135
|
+
same entry twice. 256 entries with `updatedAt` are about 36 KiB, one frame; the agent sends more messages for more ids.
|
|
136
|
+
It is metadata about client content, not client content (`contentClass: none`).
|
|
137
|
+
- **What the gateway does** (`deepdebug.content-relay.enabled` on): validates the frame like any other (schema first, then
|
|
138
|
+
tenant/agent/direction/state/seq), keeps the entries IN MEMORY of that gateway instance for `index-ttl-seconds` (default 15 min,
|
|
139
|
+
renewed by announcing again), with limits per session, per tenant and per instance, never persists them and never logs an id
|
|
140
|
+
or a size. The key is a digest of tenant, workspace, kind and id, so a heap dump lists no id.
|
|
141
|
+
- **The answer is the `ack`** (non-fatal, the session goes on): `accepted` = stored; `rejected` + `rate_limited` = a limit was
|
|
142
|
+
reached, NOTHING of that message was stored (all or nothing), announce less and later; `rejected` + `protocol_unsupported` =
|
|
143
|
+
this gateway keeps no index (content relay off), stop sending; `rejected` + `internal` = retry later.
|
|
144
|
+
- **What it is for**: the control plane can know which ids exist for a workspace. It is NOT an access list: the agent stays the
|
|
145
|
+
authority and answers `not_found` / `forbidden` for what it does not serve, known id or not. Optional refusal of unknown ids
|
|
146
|
+
before asking the agent: `deepdebug.content-relay.require-known-ref`, default **false**, and it must stay false with more than
|
|
147
|
+
one gateway instance (the index is the memory of the instance that holds the agent's session, a request that lands on another
|
|
148
|
+
instance would be refused wrongly).
|
|
149
|
+
- **What the agent must do**: announce ids when it mints them (or in a burst after `ready`), stay within 256 entries per frame
|
|
150
|
+
and 2048 live ids per session, send `content.index` only when its content handlers are on and DLP is `enforce`, and treat the
|
|
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.
|
|
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,93 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://schemas.visionhammer.io/la-protocol-v2/content.index.schema.json",
|
|
4
|
+
"title": "LA protocol v2: content.index (la->cp)",
|
|
5
|
+
"description": "Inventory of the opaque content ids the Local Agent minted for ONE workspace (the workspaceId of the envelope): kind, id and size, never a name or a path. The control plane keeps it in memory with a TTL, never persists or logs it.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"allOf": [
|
|
8
|
+
{
|
|
9
|
+
"$ref": "envelope.schema.json"
|
|
10
|
+
}
|
|
11
|
+
],
|
|
12
|
+
"properties": {
|
|
13
|
+
"type": {
|
|
14
|
+
"const": "content.index"
|
|
15
|
+
},
|
|
16
|
+
"payload": {
|
|
17
|
+
"type": "object",
|
|
18
|
+
"additionalProperties": false,
|
|
19
|
+
"properties": {
|
|
20
|
+
"entries": {
|
|
21
|
+
"type": "array",
|
|
22
|
+
"minItems": 1,
|
|
23
|
+
"maxItems": 256,
|
|
24
|
+
"uniqueItems": true,
|
|
25
|
+
"items": {
|
|
26
|
+
"type": "object",
|
|
27
|
+
"additionalProperties": false,
|
|
28
|
+
"properties": {
|
|
29
|
+
"kind": {
|
|
30
|
+
"type": "string",
|
|
31
|
+
"enum": [
|
|
32
|
+
"incident.detail",
|
|
33
|
+
"incident.stack",
|
|
34
|
+
"file",
|
|
35
|
+
"file.list",
|
|
36
|
+
"diff",
|
|
37
|
+
"log.window",
|
|
38
|
+
"log.groups",
|
|
39
|
+
"chat.session",
|
|
40
|
+
"wiki.page",
|
|
41
|
+
"job.stream",
|
|
42
|
+
"job.timeline",
|
|
43
|
+
"approval.detail",
|
|
44
|
+
"report"
|
|
45
|
+
]
|
|
46
|
+
},
|
|
47
|
+
"ref": {
|
|
48
|
+
"type": "string",
|
|
49
|
+
"minLength": 43,
|
|
50
|
+
"maxLength": 43,
|
|
51
|
+
"pattern": "^[A-Za-z0-9_-]{43}$"
|
|
52
|
+
},
|
|
53
|
+
"size": {
|
|
54
|
+
"type": "integer",
|
|
55
|
+
"minimum": 0,
|
|
56
|
+
"maximum": 1099511627776
|
|
57
|
+
},
|
|
58
|
+
"updatedAt": {
|
|
59
|
+
"$ref": "defs.schema.json#/$defs/ts"
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"required": [
|
|
63
|
+
"kind",
|
|
64
|
+
"ref",
|
|
65
|
+
"size"
|
|
66
|
+
]
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
"required": [
|
|
71
|
+
"entries"
|
|
72
|
+
]
|
|
73
|
+
},
|
|
74
|
+
"seq": {
|
|
75
|
+
"type": "integer",
|
|
76
|
+
"minimum": 1,
|
|
77
|
+
"maximum": 9007199254740991
|
|
78
|
+
},
|
|
79
|
+
"workspaceId": {
|
|
80
|
+
"type": "string",
|
|
81
|
+
"minLength": 1,
|
|
82
|
+
"maxLength": 64
|
|
83
|
+
},
|
|
84
|
+
"chunk": false,
|
|
85
|
+
"sig": false,
|
|
86
|
+
"paramsRaw": false,
|
|
87
|
+
"agentId": true
|
|
88
|
+
},
|
|
89
|
+
"required": [
|
|
90
|
+
"payload",
|
|
91
|
+
"agentId"
|
|
92
|
+
]
|
|
93
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -99,6 +99,13 @@
|
|
|
99
99
|
"signed": false,
|
|
100
100
|
"contentClass": "relay"
|
|
101
101
|
},
|
|
102
|
+
"content.index": {
|
|
103
|
+
"schema": "content.index.schema.json",
|
|
104
|
+
"direction": "la->cp",
|
|
105
|
+
"control": false,
|
|
106
|
+
"signed": false,
|
|
107
|
+
"contentClass": "none"
|
|
108
|
+
},
|
|
102
109
|
"approval.decision": {
|
|
103
110
|
"schema": "approval.decision.schema.json",
|
|
104
111
|
"direction": "cp->la",
|
|
@@ -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
|
|
@@ -44,8 +44,18 @@ export async function buildV2Link({ tenantId, agentId, signing, wsManager, getWo
|
|
|
44
44
|
const refreshDisk = () => diskFreeBytes(getWorkspaceRoot() || process.cwd()).then((v) => { lastDisk = v; });
|
|
45
45
|
refreshDisk();
|
|
46
46
|
// F3-07: content.request handlers, behind LOCAL_AGENT_CONTENT_HANDLERS (default off: nothing below changes anything)
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
let contentCfg = resolveContentHandlersConfig(env, (m) => log.warn(m));
|
|
48
|
+
if (contentCfg.enabled) {
|
|
49
|
+
// F3-07b: client content leaves this machine only masked. Without LOCAL_AGENT_DLP_MODE=enforce the handlers are NOT
|
|
50
|
+
// registered and no content.* capability is announced: content.request is acked `rejected protocol_unsupported`, as
|
|
51
|
+
// with the flag off. There is no opt-out.
|
|
52
|
+
const { dlpConfigFromEnv } = await import("../dlp/index.js");
|
|
53
|
+
if (dlpConfigFromEnv(env).mode !== "enforce") {
|
|
54
|
+
(log.error || log.warn).call(log, "[la-content] ERROR: LOCAL_AGENT_CONTENT_HANDLERS=true but LOCAL_AGENT_DLP_MODE is not enforce: the content handlers are NOT registered and no content capability is announced (content.request answers protocol_unsupported). Set LOCAL_AGENT_DLP_MODE=enforce to serve content.");
|
|
55
|
+
contentCfg = { enabled: false };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
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)
|
|
49
59
|
const link = createV2Link({
|
|
50
60
|
log,
|
|
51
61
|
sessionOptions: {
|