deepdebug-local-agent 1.4.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/la-protocol-v2.md +69 -5
- package/env.example +4 -0
- package/package.json +1 -1
- package/scripts/sync-la-protocol-v2-contract.mjs +3 -3
- package/src/la-protocol-v2/content-handlers/audit.js +36 -0
- package/src/la-protocol-v2/content-handlers/config.js +54 -0
- package/src/la-protocol-v2/content-handlers/content-index.js +161 -0
- package/src/la-protocol-v2/content-handlers/egress.js +65 -0
- package/src/la-protocol-v2/content-handlers/git-diff.js +142 -0
- package/src/la-protocol-v2/content-handlers/handler.js +182 -0
- package/src/la-protocol-v2/content-handlers/index.js +6 -0
- package/src/la-protocol-v2/content-handlers/install.js +65 -0
- package/src/la-protocol-v2/content-handlers/path-guard.js +183 -0
- package/src/la-protocol-v2/content-handlers/ref-registry.js +105 -0
- package/src/la-protocol-v2/content.js +7 -1
- package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +9 -6
- package/src/la-protocol-v2/contract/README.md +49 -2
- package/src/la-protocol-v2/contract/content.index.schema.json +92 -0
- package/src/la-protocol-v2/contract/envelope.schema.json +1 -0
- package/src/la-protocol-v2/contract/index.json +7 -0
- package/src/la-protocol-v2/runtime.js +23 -4
- package/src/server.js +4 -1
- package/test/contract/la-protocol-v2/vectors/content.index.vectors.json +3777 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-content-index-before-ready.json +68 -0
- package/test/contract/la-protocol-v2/vectors/traces/valid-content-index.json +161 -0
- package/test/la-protocol-v2/content-alignment.test.js +608 -0
- package/test/la-protocol-v2/content-handlers.test.js +627 -0
- package/test/la-protocol-v2/content-path-guard.test.js +219 -0
- package/test/la-protocol-v2/content-wiring.test.js +250 -0
- package/test/la-protocol-v2/contract.test.js +5 -5
- package/test/la-protocol-v2/helpers/content-rig.js +94 -0
- package/test/la-protocol-v2/helpers/reference-cp.js +8 -0
- package/test/la-protocol-v2/helpers/spawn-agent.js +2 -1
- package/test/la-protocol-v2/traces.test.js +29 -4
- package/tools/interop/LaV2NodeContentInteropTest.java +262 -0
- package/tools/interop/LaV2NodeContentRelayE2ETest.java +374 -0
- package/tools/interop/README.md +17 -0
- package/tools/interop/la-interop-content-client.mjs +102 -0
|
@@ -7,7 +7,7 @@ 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
12
|
- `index.json`: allowlist, direction, signed/control flags, content class per type, and the limits below.
|
|
13
13
|
- `vectors/<type>.vectors.json` (+ `cross-type`): valid and invalid messages with `expect` (`valid`, error `code`,
|
|
@@ -79,7 +79,7 @@ schema does not know. That runtime check covers the data frames sent through the
|
|
|
79
79
|
handshake and control frames (`ready`, `ack`, `error`) are assembled by fixed code and are NOT schema-checked at runtime: the
|
|
80
80
|
test transport validates every frame the session writes, and the tests fail on a violation.
|
|
81
81
|
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
|
|
82
|
+
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
83
|
F3-05b to confirm (none changes a schema):
|
|
84
84
|
1. **`byteLength` is the decoded size of `data`**; the gateway refuses a chunk where it differs (`chunk_inconsistent`).
|
|
85
85
|
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 +101,50 @@ F3-05b to confirm (none changes a schema):
|
|
|
101
101
|
7. v1 commands are refused for an LA that speaks v2 (`protocol_mismatch`): until F3-06/07 move the callers, a v2 LA
|
|
102
102
|
cannot serve `sendCommand`, so the LA must stay on v1 for tenants whose flows still use v1 commands.
|
|
103
103
|
|
|
104
|
+
## Opaque refs in `content.request` (F3-06b, F3-07b) - convention, no schema change
|
|
105
|
+
`content.request.payload.ref` stays `string, 1..512, no control characters` (the schema and its vectors are unchanged), but
|
|
106
|
+
the gateway, when `deepdebug.content-relay.enabled` is on, ONLY sends one of three shapes and refuses anything else with a 400
|
|
107
|
+
before the agent is asked (no path ever reaches the Local Agent, the logs of the load balancer or the gateway):
|
|
108
|
+
- **opaque id**: exactly **43 characters** of the base64url alphabet (`A-Z a-z 0-9 - _`, no padding, **no prefix**), i.e. 32
|
|
109
|
+
bytes. The Local Agent mints it (32 random bytes, or HMAC-SHA256 of the workspace path under a local secret: a stable id per
|
|
110
|
+
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
|
|
111
|
+
the `workspaceId` of the envelope: the agent must resolve it inside that workspace only. A shorter or prefixed id (for
|
|
112
|
+
example a 26-character `cr1_...`) is NOT an opaque id: the gateway answers 400 and never asks.
|
|
113
|
+
- **UUID** (canonical lower-case 8-4-4-4-12), only for the kinds whose subject is minted by VisionHammer:
|
|
114
|
+
`incident.detail incident.stack chat.session job.stream job.timeline approval.detail`.
|
|
115
|
+
- **`worktree`**, the one symbolic ref, ONLY for `kind: "diff"`: the diff of the whole work tree of the workspace of the
|
|
116
|
+
envelope (`git diff HEAD`, never a file name). Exact, lower-case; it names nothing of the customer's, so it may appear in a
|
|
117
|
+
URL. The gateway requires a `workspaceId` with it (400 otherwise: "the work tree" of which workspace?) and refuses it for
|
|
118
|
+
every other kind. The agent resolves it inside that workspace only, as for an id.
|
|
119
|
+
The Local Agent is the authority: it MUST NOT trust the shape. It validates the workspace (the one in the signed envelope, one
|
|
120
|
+
it serves, else `forbidden`/`not_found`), resolves the id to a path inside that workspace's root (no `..`, no symlink out of
|
|
121
|
+
the root, no sensitive file) and answers `not_found` for an id it does not know. With several agents per tenant the gateway
|
|
122
|
+
asks the one that declared the workspace in `register.workspaces[]` first and tries the next only after `not_found`,
|
|
123
|
+
`forbidden` or `agent_offline` (see docs/architecture/la-content-relay.md). A later contract revision may tighten the schema
|
|
124
|
+
`pattern` once no client sends a path; until then this README is the convention.
|
|
125
|
+
|
|
126
|
+
## `content.index` (LA -> CP, F3-07b): which ids exist
|
|
127
|
+
An unsolicited, **unsigned** LA -> CP frame (`seq` 1.., never chunked, `agentId` required, like `log.stats`) that announces
|
|
128
|
+
the opaque ids the agent minted for ONE workspace (the `workspaceId` of the envelope, required and non-empty here):
|
|
129
|
+
`payload.entries[]`, 1..256 items `{kind, ref, size, updatedAt?}`, closed. `kind` is the `content.request` kind list; `ref` is
|
|
130
|
+
exactly the 43-character id above; `size` is an integer 0..2^40 bytes of the item (informative: the relay still caps a
|
|
131
|
+
response at 2 MiB); `updatedAt` an optional UTC timestamp. **It cannot carry a name or a path**: no free-form field exists,
|
|
132
|
+
every string is an enum or the 43-character pattern, `additionalProperties:false` everywhere, and `uniqueItems` refuses the
|
|
133
|
+
same entry twice. 256 entries with `updatedAt` are about 36 KiB, one frame; the agent sends more messages for more ids.
|
|
134
|
+
It is metadata about client content, not client content (`contentClass: none`).
|
|
135
|
+
- **What the gateway does** (`deepdebug.content-relay.enabled` on): validates the frame like any other (schema first, then
|
|
136
|
+
tenant/agent/direction/state/seq), keeps the entries IN MEMORY of that gateway instance for `index-ttl-seconds` (default 15 min,
|
|
137
|
+
renewed by announcing again), with limits per session, per tenant and per instance, never persists them and never logs an id
|
|
138
|
+
or a size. The key is a digest of tenant, workspace, kind and id, so a heap dump lists no id.
|
|
139
|
+
- **The answer is the `ack`** (non-fatal, the session goes on): `accepted` = stored; `rejected` + `rate_limited` = a limit was
|
|
140
|
+
reached, NOTHING of that message was stored (all or nothing), announce less and later; `rejected` + `protocol_unsupported` =
|
|
141
|
+
this gateway keeps no index (content relay off), stop sending; `rejected` + `internal` = retry later.
|
|
142
|
+
- **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
|
|
143
|
+
authority and answers `not_found` / `forbidden` for what it does not serve, known id or not. Optional refusal of unknown ids
|
|
144
|
+
before asking the agent: `deepdebug.content-relay.require-known-ref`, default **false**, and it must stay false with more than
|
|
145
|
+
one gateway instance (the index is the memory of the instance that holds the agent's session, a request that lands on another
|
|
146
|
+
instance would be refused wrongly).
|
|
147
|
+
- **What the agent must do**: announce ids when it mints them (or in a burst after `ready`), stay within 256 entries per frame
|
|
148
|
+
and 2048 live ids per session, send `content.index` only when its content handlers are on and DLP is `enforce`, and treat the
|
|
149
|
+
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
|
+
|
|
@@ -0,0 +1,92 @@
|
|
|
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
|
+
"diff",
|
|
36
|
+
"log.window",
|
|
37
|
+
"log.groups",
|
|
38
|
+
"chat.session",
|
|
39
|
+
"wiki.page",
|
|
40
|
+
"job.stream",
|
|
41
|
+
"job.timeline",
|
|
42
|
+
"approval.detail",
|
|
43
|
+
"report"
|
|
44
|
+
]
|
|
45
|
+
},
|
|
46
|
+
"ref": {
|
|
47
|
+
"type": "string",
|
|
48
|
+
"minLength": 43,
|
|
49
|
+
"maxLength": 43,
|
|
50
|
+
"pattern": "^[A-Za-z0-9_-]{43}$"
|
|
51
|
+
},
|
|
52
|
+
"size": {
|
|
53
|
+
"type": "integer",
|
|
54
|
+
"minimum": 0,
|
|
55
|
+
"maximum": 1099511627776
|
|
56
|
+
},
|
|
57
|
+
"updatedAt": {
|
|
58
|
+
"$ref": "defs.schema.json#/$defs/ts"
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
"required": [
|
|
62
|
+
"kind",
|
|
63
|
+
"ref",
|
|
64
|
+
"size"
|
|
65
|
+
]
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
"required": [
|
|
70
|
+
"entries"
|
|
71
|
+
]
|
|
72
|
+
},
|
|
73
|
+
"seq": {
|
|
74
|
+
"type": "integer",
|
|
75
|
+
"minimum": 1,
|
|
76
|
+
"maximum": 9007199254740991
|
|
77
|
+
},
|
|
78
|
+
"workspaceId": {
|
|
79
|
+
"type": "string",
|
|
80
|
+
"minLength": 1,
|
|
81
|
+
"maxLength": 64
|
|
82
|
+
},
|
|
83
|
+
"chunk": false,
|
|
84
|
+
"sig": false,
|
|
85
|
+
"paramsRaw": false,
|
|
86
|
+
"agentId": true
|
|
87
|
+
},
|
|
88
|
+
"required": [
|
|
89
|
+
"payload",
|
|
90
|
+
"agentId"
|
|
91
|
+
]
|
|
92
|
+
}
|
|
@@ -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",
|
|
@@ -7,6 +7,7 @@ import fs from "node:fs";
|
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import { createV2Link } from "./link.js";
|
|
10
|
+
import { resolveContentHandlersConfig } from "./content-handlers/config.js";
|
|
10
11
|
|
|
11
12
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
12
13
|
const WORKSPACE_ID = /^[A-Za-z0-9_.-]{0,64}$/;
|
|
@@ -32,9 +33,9 @@ async function diskFreeBytes(dir) {
|
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
/**
|
|
35
|
-
* @returns the link, or null (with a warning) when v2 cannot run here: then the tunnel is the plain v1 client.
|
|
36
|
+
* @returns {Promise} the link, or null (with a warning) when v2 cannot run here: then the tunnel is the plain v1 client.
|
|
36
37
|
*/
|
|
37
|
-
export function buildV2Link({ tenantId, agentId, signing, wsManager, getWorkspaceRoot, log = console, env = process.env }) {
|
|
38
|
+
export async function buildV2Link({ tenantId, agentId, signing, wsManager, getWorkspaceRoot, log = console, env = process.env }) {
|
|
38
39
|
const tenant = String(tenantId || "").toLowerCase();
|
|
39
40
|
if (!UUID.test(tenant)) { log.warn("[la-v2] off: the tenant id is not a UUID"); return null; }
|
|
40
41
|
if (!agentId) { log.warn("[la-v2] off: no agent id (authorizationId in ~/.deepdebug/config.json or LOCAL_AGENT_ID)"); return null; }
|
|
@@ -42,7 +43,20 @@ export function buildV2Link({ tenantId, agentId, signing, wsManager, getWorkspac
|
|
|
42
43
|
let lastDisk = 0;
|
|
43
44
|
const refreshDisk = () => diskFreeBytes(getWorkspaceRoot() || process.cwd()).then((v) => { lastDisk = v; });
|
|
44
45
|
refreshDisk();
|
|
45
|
-
|
|
46
|
+
// F3-07: content.request handlers, behind LOCAL_AGENT_CONTENT_HANDLERS (default off: nothing below changes anything)
|
|
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"] : [];
|
|
59
|
+
const link = createV2Link({
|
|
46
60
|
log,
|
|
47
61
|
sessionOptions: {
|
|
48
62
|
tenantId: tenant,
|
|
@@ -51,7 +65,7 @@ export function buildV2Link({ tenantId, agentId, signing, wsManager, getWorkspac
|
|
|
51
65
|
signing,
|
|
52
66
|
laVersion: agentVersion(),
|
|
53
67
|
// only what this build implements: the transport, the handshake and the v1 commands on the same connection
|
|
54
|
-
capabilities: ["protocol.v2", "v1.commands", "chunk.base64"],
|
|
68
|
+
capabilities: ["protocol.v2", "v1.commands", "chunk.base64", ...contentCaps],
|
|
55
69
|
getWorkspaces: () => {
|
|
56
70
|
const open = (wsManager?.list?.() || []).map((w) => ({
|
|
57
71
|
workspaceId: String(w.id || ""),
|
|
@@ -69,4 +83,9 @@ export function buildV2Link({ tenantId, agentId, signing, wsManager, getWorkspac
|
|
|
69
83
|
},
|
|
70
84
|
},
|
|
71
85
|
});
|
|
86
|
+
if (contentCfg.enabled) {
|
|
87
|
+
const { installContentHandlers } = await import("./content-handlers/install.js");
|
|
88
|
+
installContentHandlers({ link, tenantId: tenant, wsManager, getWorkspaceRoot, config: contentCfg, env, log });
|
|
89
|
+
}
|
|
90
|
+
return link;
|
|
72
91
|
}
|
package/src/server.js
CHANGED
|
@@ -647,6 +647,9 @@ const WS_METADATA = resolveWsMetadataConfig();
|
|
|
647
647
|
const WS_CONNECTION = createConnectionState();
|
|
648
648
|
// LOCAL_AGENT_PROTOCOL_V2 (F3-05b, default off): with it off the tunnel below is the v1 client, untouched.
|
|
649
649
|
const PROTOCOL_V2 = resolveProtocolV2Config();
|
|
650
|
+
if (!PROTOCOL_V2.enabled && /^(true|1|yes|on)$/i.test(String(process.env.LOCAL_AGENT_CONTENT_HANDLERS ?? '').trim())) {
|
|
651
|
+
console.warn('[la-v2] LOCAL_AGENT_CONTENT_HANDLERS is set but LOCAL_AGENT_PROTOCOL_V2 is off: content handlers stay off');
|
|
652
|
+
}
|
|
650
653
|
if (WS_METADATA.mode === "enforce") {
|
|
651
654
|
EXEC_POLICY.offlineGuard = createOfflinePolicyGuard({
|
|
652
655
|
connection: WS_CONNECTION,
|
|
@@ -6985,7 +6988,7 @@ async function startWebSocketTunnel(port, gatewayUrl, authToken, tenantId, agent
|
|
|
6985
6988
|
if (PROTOCOL_V2.enabled) {
|
|
6986
6989
|
try {
|
|
6987
6990
|
const { buildV2Link } = await import('./la-protocol-v2/runtime.js');
|
|
6988
|
-
v2Link = buildV2Link({ tenantId, agentId, signing: SIGNING, wsManager, getWorkspaceRoot: () => WORKSPACE_ROOT });
|
|
6991
|
+
v2Link = await buildV2Link({ tenantId, agentId, signing: SIGNING, wsManager, getWorkspaceRoot: () => WORKSPACE_ROOT });
|
|
6989
6992
|
} catch (err) {
|
|
6990
6993
|
console.warn(`[la-v2] off: ${err.code || err.name} while loading the protocol module`);
|
|
6991
6994
|
}
|