deepdebug-local-agent 1.4.0 → 1.5.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 +38 -4
- package/package.json +1 -1
- package/src/la-protocol-v2/content-handlers/audit.js +36 -0
- package/src/la-protocol-v2/content-handlers/config.js +48 -0
- package/src/la-protocol-v2/content-handlers/egress.js +65 -0
- package/src/la-protocol-v2/content-handlers/git-diff.js +92 -0
- package/src/la-protocol-v2/content-handlers/handler.js +170 -0
- package/src/la-protocol-v2/content-handlers/index.js +6 -0
- package/src/la-protocol-v2/content-handlers/install.js +50 -0
- package/src/la-protocol-v2/content-handlers/path-guard.js +142 -0
- package/src/la-protocol-v2/content-handlers/ref-registry.js +87 -0
- package/src/la-protocol-v2/content.js +7 -1
- package/src/la-protocol-v2/runtime.js +13 -4
- package/src/server.js +4 -1
- package/test/la-protocol-v2/content-handlers.test.js +633 -0
- package/test/la-protocol-v2/content-path-guard.test.js +214 -0
- package/test/la-protocol-v2/content-wiring.test.js +225 -0
- package/test/la-protocol-v2/helpers/content-rig.js +94 -0
- package/test/la-protocol-v2/helpers/spawn-agent.js +2 -1
- package/tools/interop/LaV2NodeContentInteropTest.java +248 -0
- package/tools/interop/README.md +17 -0
- package/tools/interop/la-interop-content-client.mjs +91 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* path-guard.js - the Local Agent is the authority on what a content.request may read (F3-07).
|
|
3
|
+
* The gateway lets anything through (it only sees an opaque id); every check that matters is here.
|
|
4
|
+
*
|
|
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/, ...
|
|
7
|
+
* resolveInside realpath of root and target; the real target must stay inside the real root (a symlink that
|
|
8
|
+
* leaves the workspace is `forbidden`), must not be sensitive under its REAL name either
|
|
9
|
+
* (a harmless-looking link to .env), and must be a regular file.
|
|
10
|
+
* readRegular opens with O_NOFOLLOW, checks it is the same inode that was checked, reads at most `maxBytes`.
|
|
11
|
+
*
|
|
12
|
+
* Failures are small codes ("not_found" | "forbidden" | "too_large"); no message ever carries a path.
|
|
13
|
+
* Limits that remain (documented in the note): a directory swapped for a symlink between the check and the open
|
|
14
|
+
* (TOCTOU on parents), hard links to files outside the root (not detectable cheaply).
|
|
15
|
+
*/
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
|
|
19
|
+
const O_NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0;
|
|
20
|
+
|
|
21
|
+
/** Built-in deny list. A pattern with no `/` matches any path segment; with `/` it matches a path (or its prefix). */
|
|
22
|
+
export const DEFAULT_DENY_GLOBS = Object.freeze([
|
|
23
|
+
// secrets by name
|
|
24
|
+
".env", ".env.*", "*.env",
|
|
25
|
+
"*.pem", "*.key", "*.p12", "*.pfx", "*.jks", "*.keystore", "*.p8", "*.ppk", "*.kdbx", "*.gpg", "*.crt.key",
|
|
26
|
+
"id_rsa*", "id_dsa*", "id_ecdsa*", "id_ed25519*",
|
|
27
|
+
".npmrc", ".pypirc", ".netrc", "_netrc", ".pgpass", ".my.cnf", ".git-credentials", ".htpasswd", ".vault-token",
|
|
28
|
+
"credentials", "credentials.json", "service-account*.json", "serviceaccount*.json", "secrets.json", "secrets.yml", "secrets.yaml",
|
|
29
|
+
"*.secret", "*.secrets", "*.tfstate", "*.tfstate.*", "*.tfvars", "*.tfvars.json", "terraform.rc",
|
|
30
|
+
// directories that hold credentials or the repository internals (.git/config may carry a token in a URL)
|
|
31
|
+
".git/", ".ssh/", ".aws/", ".gnupg/", ".kube/", ".docker/", ".azure/", ".terraform/", ".config/gcloud/",
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
const RE_CACHE = new Map();
|
|
35
|
+
|
|
36
|
+
function globToRegExp(glob) {
|
|
37
|
+
const g = glob.toLowerCase();
|
|
38
|
+
const dir = g.endsWith("/");
|
|
39
|
+
const body = dir ? g.slice(0, -1) : g;
|
|
40
|
+
let re = "";
|
|
41
|
+
for (let i = 0; i < body.length; i++) {
|
|
42
|
+
const c = body[i];
|
|
43
|
+
if (c === "*") {
|
|
44
|
+
if (body[i + 1] === "*") { re += ".*"; i++; } else re += "[^/]*";
|
|
45
|
+
} else if (c === "?") re += "[^/]";
|
|
46
|
+
else re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
47
|
+
}
|
|
48
|
+
// anchored at a segment boundary on the left; on the right it must end the path or be followed by `/` (a directory prefix)
|
|
49
|
+
return new RegExp(`(?:^|/)${re}(?:/|$)`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function regexFor(glob) {
|
|
53
|
+
let r = RE_CACHE.get(glob);
|
|
54
|
+
if (!r) { r = globToRegExp(glob); RE_CACHE.set(glob, r); if (RE_CACHE.size > 1000) RE_CACHE.delete(RE_CACHE.keys().next().value); }
|
|
55
|
+
return r;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Lower-case, posix, and without the trailing dots/spaces that Windows ignores (".env." is ".env"). */
|
|
59
|
+
function foldForMatch(rel) {
|
|
60
|
+
return rel.split("/").map((seg) => seg.replace(/[. ]+$/, "") || seg).join("/").toLowerCase();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function isSensitivePath(rel, extraGlobs = []) {
|
|
64
|
+
if (typeof rel !== "string") return true;
|
|
65
|
+
const folded = foldForMatch(rel.replace(/\\/g, "/"));
|
|
66
|
+
for (const g of DEFAULT_DENY_GLOBS) if (regexFor(g).test(folded)) return true;
|
|
67
|
+
for (const g of extraGlobs) if (typeof g === "string" && g && regexFor(g).test(folded)) return true;
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const CONTROL = /[\u0000-\u001f\u007f]/;
|
|
72
|
+
|
|
73
|
+
/** @returns {string | null} posix relative path, or null when it is not an acceptable relative path */
|
|
74
|
+
export function normalizeRel(rel) {
|
|
75
|
+
if (typeof rel !== "string" || rel.length === 0 || rel.length > 1024) return null;
|
|
76
|
+
if (CONTROL.test(rel)) return null;
|
|
77
|
+
const p = rel.replace(/\\/g, "/");
|
|
78
|
+
if (p.startsWith("/") || /^[A-Za-z]:/.test(p)) return null;
|
|
79
|
+
const parts = [];
|
|
80
|
+
for (const seg of p.split("/")) {
|
|
81
|
+
if (seg === "" || seg === ".") continue;
|
|
82
|
+
if (seg === "..") return null;
|
|
83
|
+
parts.push(seg);
|
|
84
|
+
}
|
|
85
|
+
return parts.length ? parts.join("/") : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const toPosix = (p) => p.split(path.sep).join("/");
|
|
89
|
+
const inside = (root, abs) => abs === root || abs.startsWith(root.endsWith(path.sep) ? root : root + path.sep);
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @param {string} root the workspace root as registered
|
|
93
|
+
* @param {string} rel relative path already normalized (normalizeRel)
|
|
94
|
+
* @returns {Promise<{ok:true, real:string, relReal:string, stat:fs.Stats} | {ok:false, status:"not_found"|"forbidden"}>}
|
|
95
|
+
*/
|
|
96
|
+
export async function resolveInside(root, rel, extraGlobs = []) {
|
|
97
|
+
let rootReal;
|
|
98
|
+
try { rootReal = await fs.promises.realpath(root); } catch { return { ok: false, status: "not_found" }; }
|
|
99
|
+
const abs = path.join(rootReal, ...rel.split("/"));
|
|
100
|
+
if (!inside(rootReal, abs)) return { ok: false, status: "forbidden" };
|
|
101
|
+
if (isSensitivePath(rel, extraGlobs)) return { ok: false, status: "forbidden" };
|
|
102
|
+
let real;
|
|
103
|
+
try { real = await fs.promises.realpath(abs); } catch (e) { return { ok: false, status: e && (e.code === "EACCES" || e.code === "EPERM") ? "forbidden" : "not_found" }; }
|
|
104
|
+
if (!inside(rootReal, real)) return { ok: false, status: "forbidden" }; // symlink that leaves the workspace
|
|
105
|
+
const relReal = toPosix(path.relative(rootReal, real));
|
|
106
|
+
if (!relReal || isSensitivePath(relReal, extraGlobs)) return { ok: false, status: "forbidden" }; // link to a sensitive file
|
|
107
|
+
let st;
|
|
108
|
+
try { st = await fs.promises.lstat(real); } catch { return { ok: false, status: "not_found" }; }
|
|
109
|
+
if (!st.isFile()) return { ok: false, status: "not_found" };
|
|
110
|
+
return { ok: true, real, relReal, stat: st };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Reads the whole file (<= maxBytes) through a descriptor opened without following links and verified to be the
|
|
115
|
+
* file that resolveInside() checked.
|
|
116
|
+
* @returns {Promise<{ok:true, buffer:Buffer} | {ok:false, status:"not_found"|"forbidden"|"too_large"}>}
|
|
117
|
+
*/
|
|
118
|
+
export async function readRegular(resolved, maxBytes, signal) {
|
|
119
|
+
if (resolved.stat.size > maxBytes) return { ok: false, status: "too_large" };
|
|
120
|
+
let fh;
|
|
121
|
+
try {
|
|
122
|
+
fh = await fs.promises.open(resolved.real, fs.constants.O_RDONLY | O_NOFOLLOW);
|
|
123
|
+
const st = await fh.stat();
|
|
124
|
+
if (!st.isFile()) return { ok: false, status: "not_found" };
|
|
125
|
+
if (resolved.stat.ino !== 0 && (st.ino !== resolved.stat.ino || st.dev !== resolved.stat.dev)) return { ok: false, status: "forbidden" };
|
|
126
|
+
if (st.size > maxBytes) return { ok: false, status: "too_large" };
|
|
127
|
+
const buf = Buffer.allocUnsafe(Math.min(st.size, maxBytes) + 1);
|
|
128
|
+
let n = 0;
|
|
129
|
+
while (n < buf.length) {
|
|
130
|
+
if (signal && signal.aborted) return { ok: false, status: "not_found" };
|
|
131
|
+
const { bytesRead } = await fh.read(buf, n, buf.length - n, n);
|
|
132
|
+
if (bytesRead === 0) break;
|
|
133
|
+
n += bytesRead;
|
|
134
|
+
}
|
|
135
|
+
if (n > maxBytes) return { ok: false, status: "too_large" }; // grew while reading
|
|
136
|
+
return { ok: true, buffer: Buffer.from(buf.subarray(0, n)) };
|
|
137
|
+
} catch (e) {
|
|
138
|
+
return { ok: false, status: e && (e.code === "ELOOP" || e.code === "EACCES" || e.code === "EPERM") ? "forbidden" : "not_found" };
|
|
139
|
+
} finally {
|
|
140
|
+
if (fh) await fh.close().catch(() => {});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ref-registry.js - opaque content ids (F3-07).
|
|
3
|
+
*
|
|
4
|
+
* The control plane never sees a path: the `ref` of a `content.request` is an id that THIS agent minted when it
|
|
5
|
+
* reported metadata (a file list, a diff summary) and that only this agent can map back to (workspace, relative path).
|
|
6
|
+
*
|
|
7
|
+
* id "cr1_" + 22 base64url chars = 128 random bits from crypto.randomBytes. NOT derived from the path
|
|
8
|
+
* (no hash, no HMAC of it): knowing a path tells nothing about its id and the other way round.
|
|
9
|
+
* validity `ttlMs` from the mint (default 15 min); expired ids are gone (not_found), the CP asks for a new listing.
|
|
10
|
+
* In memory only: a restart of the agent forgets every id (also not_found), by design.
|
|
11
|
+
* binding an id is valid for ONE tenant, ONE workspace (and the root that workspace had when it was minted),
|
|
12
|
+
* ONE kind. Any other combination resolves to null, indistinguishable from "never existed".
|
|
13
|
+
* size at most `maxEntries`; the oldest go first.
|
|
14
|
+
*
|
|
15
|
+
* Nothing here logs, throws a message with a path, or exposes an id's target to anything but resolve().
|
|
16
|
+
*/
|
|
17
|
+
import crypto from "node:crypto";
|
|
18
|
+
import { normalizeRel, isSensitivePath } from "./path-guard.js";
|
|
19
|
+
|
|
20
|
+
export const REF_PATTERN = /^cr1_[A-Za-z0-9_-]{22}$/;
|
|
21
|
+
export const REF_KINDS = Object.freeze(["file", "diff"]);
|
|
22
|
+
|
|
23
|
+
export class ContentRefRegistry {
|
|
24
|
+
constructor({ tenantId, ttlMs = 15 * 60_000, maxEntries = 20_000, now = Date.now, denyGlobs = [] } = {}) {
|
|
25
|
+
this.tenantId = tenantId;
|
|
26
|
+
this.ttlMs = ttlMs;
|
|
27
|
+
this.maxEntries = maxEntries;
|
|
28
|
+
this.now = now;
|
|
29
|
+
this.denyGlobs = denyGlobs;
|
|
30
|
+
this.entries = new Map(); // id -> { tenantId, workspaceId, root, kind, rel, expiresAt } (insertion order = age)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
get size() { return this.entries.size; }
|
|
34
|
+
|
|
35
|
+
#purge() {
|
|
36
|
+
const t = this.now();
|
|
37
|
+
for (const [id, e] of this.entries) {
|
|
38
|
+
if (e.expiresAt > t) break; // insertion order is expiry order (one ttl for all)
|
|
39
|
+
this.entries.delete(id);
|
|
40
|
+
}
|
|
41
|
+
while (this.entries.size > this.maxEntries) this.entries.delete(this.entries.keys().next().value);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {{workspaceId:string, root:string, kind:"file"|"diff", rel?:string|null}} o
|
|
46
|
+
* rel = path relative to the workspace root (file: required; diff: optional, null = the whole work tree)
|
|
47
|
+
* @returns {{id:string, expiresAt:number} | null} null = refused (bad kind/path or a sensitive file): no id exists
|
|
48
|
+
*/
|
|
49
|
+
mint({ workspaceId, root, kind, rel = null }) {
|
|
50
|
+
if (!REF_KINDS.includes(kind) || typeof workspaceId !== "string" || !workspaceId || typeof root !== "string" || !root) return null;
|
|
51
|
+
let norm = null;
|
|
52
|
+
if (rel !== null && rel !== undefined) {
|
|
53
|
+
norm = normalizeRel(rel);
|
|
54
|
+
if (!norm || isSensitivePath(norm, this.denyGlobs)) return null; // never hand out an id for a file we would refuse
|
|
55
|
+
} else if (kind === "file") {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
this.#purge();
|
|
59
|
+
const id = `cr1_${crypto.randomBytes(16).toString("base64url")}`;
|
|
60
|
+
const expiresAt = this.now() + this.ttlMs;
|
|
61
|
+
this.entries.set(id, { tenantId: this.tenantId, workspaceId, root, kind, rel: norm, expiresAt });
|
|
62
|
+
this.#purge(); // capacity includes the new entry
|
|
63
|
+
return { id, expiresAt };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @returns {{rel:string|null}} | null null for: not an id, unknown, expired, other tenant/workspace/root/kind
|
|
68
|
+
*/
|
|
69
|
+
resolve(id, { workspaceId, root, kind }) {
|
|
70
|
+
if (typeof id !== "string" || !REF_PATTERN.test(id)) return null;
|
|
71
|
+
const e = this.entries.get(id);
|
|
72
|
+
if (!e) return null;
|
|
73
|
+
if (e.expiresAt <= this.now()) { this.entries.delete(id); return null; }
|
|
74
|
+
if (e.tenantId !== this.tenantId || e.workspaceId !== workspaceId || e.root !== root || e.kind !== kind) return null;
|
|
75
|
+
return { rel: e.rel };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
revoke(id) { return this.entries.delete(id); }
|
|
79
|
+
|
|
80
|
+
revokeWorkspace(workspaceId) {
|
|
81
|
+
let n = 0;
|
|
82
|
+
for (const [id, e] of this.entries) if (e.workspaceId === workspaceId) { this.entries.delete(id); n++; }
|
|
83
|
+
return n;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
clear() { this.entries.clear(); }
|
|
87
|
+
}
|
|
@@ -28,6 +28,9 @@ export async function sendContent(session, { requestId, source, contentType = "a
|
|
|
28
28
|
const total = chunkCount(size, chunkRawBytes);
|
|
29
29
|
if (total > maxChunks) throw new ProtocolError("payload_too_large", "response above the chunk limit");
|
|
30
30
|
const acks = [];
|
|
31
|
+
// an abort must also free a chunk that is waiting for a window slot (enqueue() does not look at the signal)
|
|
32
|
+
const onAbort = () => session.cancelWaiting(requestId);
|
|
33
|
+
if (signal) { if (signal.aborted) onAbort(); else signal.addEventListener("abort", onAbort, { once: true }); }
|
|
31
34
|
try {
|
|
32
35
|
for (let index = 0; index < total; index++) {
|
|
33
36
|
if (signal && signal.aborted) throw new ProtocolError("agent_offline", "cancelled");
|
|
@@ -44,6 +47,8 @@ export async function sendContent(session, { requestId, source, contentType = "a
|
|
|
44
47
|
session.cancelWaiting(requestId);
|
|
45
48
|
for (const a of acks) a.catch(() => {});
|
|
46
49
|
throw e;
|
|
50
|
+
} finally {
|
|
51
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
47
52
|
}
|
|
48
53
|
await Promise.all(acks);
|
|
49
54
|
return { chunks: total, bytes: size };
|
|
@@ -51,7 +56,8 @@ export async function sendContent(session, { requestId, source, contentType = "a
|
|
|
51
56
|
|
|
52
57
|
/** A failed answer: one empty chunk with the status (error | not_found | forbidden) and a wire error code. */
|
|
53
58
|
export async function sendContentFailure(session, { requestId, status = "error", errorCode = "internal", workspaceId }) {
|
|
59
|
+
// errorCode: null = none on the wire (not_found / forbidden need no code); undefined = "internal" (as before F3-07)
|
|
54
60
|
await session.send("content.response", {
|
|
55
|
-
status, errorCode, encoding: "base64", data: "", byteLength: 0,
|
|
61
|
+
status, ...(errorCode === null ? {} : { errorCode }), encoding: "base64", data: "", byteLength: 0,
|
|
56
62
|
}, { requestId, workspaceId, chunk: { index: 0, total: 1, last: true } });
|
|
57
63
|
}
|
|
@@ -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,10 @@ 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
|
+
const contentCfg = resolveContentHandlersConfig(env, (m) => log.warn(m));
|
|
48
|
+
const contentCaps = contentCfg.enabled ? ["content.diff", "content.file"] : [];
|
|
49
|
+
const link = createV2Link({
|
|
46
50
|
log,
|
|
47
51
|
sessionOptions: {
|
|
48
52
|
tenantId: tenant,
|
|
@@ -51,7 +55,7 @@ export function buildV2Link({ tenantId, agentId, signing, wsManager, getWorkspac
|
|
|
51
55
|
signing,
|
|
52
56
|
laVersion: agentVersion(),
|
|
53
57
|
// 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"],
|
|
58
|
+
capabilities: ["protocol.v2", "v1.commands", "chunk.base64", ...contentCaps],
|
|
55
59
|
getWorkspaces: () => {
|
|
56
60
|
const open = (wsManager?.list?.() || []).map((w) => ({
|
|
57
61
|
workspaceId: String(w.id || ""),
|
|
@@ -69,4 +73,9 @@ export function buildV2Link({ tenantId, agentId, signing, wsManager, getWorkspac
|
|
|
69
73
|
},
|
|
70
74
|
},
|
|
71
75
|
});
|
|
76
|
+
if (contentCfg.enabled) {
|
|
77
|
+
const { installContentHandlers } = await import("./content-handlers/install.js");
|
|
78
|
+
installContentHandlers({ link, tenantId: tenant, wsManager, getWorkspaceRoot, config: contentCfg, env, log });
|
|
79
|
+
}
|
|
80
|
+
return link;
|
|
72
81
|
}
|
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
|
}
|