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.
Files changed (38) hide show
  1. package/docs/la-protocol-v2.md +69 -5
  2. package/env.example +4 -0
  3. package/package.json +1 -1
  4. package/scripts/sync-la-protocol-v2-contract.mjs +3 -3
  5. package/src/la-protocol-v2/content-handlers/audit.js +36 -0
  6. package/src/la-protocol-v2/content-handlers/config.js +54 -0
  7. package/src/la-protocol-v2/content-handlers/content-index.js +161 -0
  8. package/src/la-protocol-v2/content-handlers/egress.js +65 -0
  9. package/src/la-protocol-v2/content-handlers/git-diff.js +142 -0
  10. package/src/la-protocol-v2/content-handlers/handler.js +182 -0
  11. package/src/la-protocol-v2/content-handlers/index.js +6 -0
  12. package/src/la-protocol-v2/content-handlers/install.js +65 -0
  13. package/src/la-protocol-v2/content-handlers/path-guard.js +183 -0
  14. package/src/la-protocol-v2/content-handlers/ref-registry.js +105 -0
  15. package/src/la-protocol-v2/content.js +7 -1
  16. package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +9 -6
  17. package/src/la-protocol-v2/contract/README.md +49 -2
  18. package/src/la-protocol-v2/contract/content.index.schema.json +92 -0
  19. package/src/la-protocol-v2/contract/envelope.schema.json +1 -0
  20. package/src/la-protocol-v2/contract/index.json +7 -0
  21. package/src/la-protocol-v2/runtime.js +23 -4
  22. package/src/server.js +4 -1
  23. package/test/contract/la-protocol-v2/vectors/content.index.vectors.json +3777 -0
  24. package/test/contract/la-protocol-v2/vectors/traces/invalid-content-index-before-ready.json +68 -0
  25. package/test/contract/la-protocol-v2/vectors/traces/valid-content-index.json +161 -0
  26. package/test/la-protocol-v2/content-alignment.test.js +608 -0
  27. package/test/la-protocol-v2/content-handlers.test.js +627 -0
  28. package/test/la-protocol-v2/content-path-guard.test.js +219 -0
  29. package/test/la-protocol-v2/content-wiring.test.js +250 -0
  30. package/test/la-protocol-v2/contract.test.js +5 -5
  31. package/test/la-protocol-v2/helpers/content-rig.js +94 -0
  32. package/test/la-protocol-v2/helpers/reference-cp.js +8 -0
  33. package/test/la-protocol-v2/helpers/spawn-agent.js +2 -1
  34. package/test/la-protocol-v2/traces.test.js +29 -4
  35. package/tools/interop/LaV2NodeContentInteropTest.java +262 -0
  36. package/tools/interop/LaV2NodeContentRelayE2ETest.java +374 -0
  37. package/tools/interop/README.md +17 -0
  38. 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` (21):
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, the HTTP relay (F3-06), LA handlers (F3-05b/07). Gaps, for
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
+ }
@@ -34,6 +34,7 @@
34
34
  "job.result",
35
35
  "content.request",
36
36
  "content.response",
37
+ "content.index",
37
38
  "approval.decision",
38
39
  "config.update",
39
40
  "scan.upload",
@@ -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
- return createV2Link({
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
  }