deepdebug-local-agent 1.10.0 → 1.12.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/.github/workflows/build-byoc-image.yml +9 -11
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +5 -0
- package/CLAUDE.md +1 -1
- package/cloudbuild-full.yaml +2 -1
- package/docs/byoc-agent-image.md +8 -7
- package/docs/examples/agent2-runner/docker-compose.yml +53 -0
- package/docs/la-agent2-runner.md +115 -0
- package/docs/la-jobs-and-llm.md +14 -1
- package/docs/la-protocol-v2.md +4 -2
- package/env.example +6 -0
- package/package.json +1 -1
- package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +8 -4
- package/src/la-protocol-v2/contract/README.md +51 -2
- package/src/la-protocol-v2/contract/envelope.schema.json +3 -1
- package/src/la-protocol-v2/contract/index.json +18 -2
- package/src/la-protocol-v2/contract/policy.check.schema.json +97 -0
- package/src/la-protocol-v2/contract/policy.decision.schema.json +119 -0
- package/src/la-protocol-v2/jobs/agent2/api-client.js +150 -0
- package/src/la-protocol-v2/jobs/agent2/approvals.js +114 -0
- package/src/la-protocol-v2/jobs/agent2/bridge.js +299 -0
- package/src/la-protocol-v2/jobs/agent2/config.js +128 -0
- package/src/la-protocol-v2/jobs/agent2/frames.js +61 -0
- package/src/la-protocol-v2/jobs/agent2/policy-check.js +170 -0
- package/src/la-protocol-v2/jobs/agent2/runner.js +258 -0
- package/src/la-protocol-v2/jobs/agent2/supervisor.js +269 -0
- package/src/la-protocol-v2/jobs/git-exec.js +71 -0
- package/src/la-protocol-v2/jobs/install.js +14 -4
- package/src/la-protocol-v2/jobs/manager.js +30 -16
- package/src/la-protocol-v2/jobs/workspace-materializer.js +244 -0
- package/src/la-protocol-v2/llm/client.js +133 -12
- package/src/la-protocol-v2/llm/config.js +2 -0
- package/src/la-protocol-v2/runtime.js +36 -3
- package/src/server.js +4 -0
- package/src/utils/approval-token.js +3 -2
- package/test/contract/la-protocol-v2/vectors/policy.check.vectors.json +908 -0
- package/test/contract/la-protocol-v2/vectors/policy.decision.vectors.json +826 -0
- package/test/la-protocol-v2/agent2-approval-discovery.test.js +114 -0
- package/test/la-protocol-v2/agent2-config.test.js +103 -0
- package/test/la-protocol-v2/agent2-orphan.test.js +118 -0
- package/test/la-protocol-v2/agent2-policy-check.test.js +273 -0
- package/test/la-protocol-v2/agent2-real-e2e.test.js +89 -0
- package/test/la-protocol-v2/agent2-runner.test.js +510 -0
- package/test/la-protocol-v2/agent2-supervisor.test.js +185 -0
- package/test/la-protocol-v2/agent2-workspace-door.test.js +142 -0
- package/test/la-protocol-v2/agent2-workspace-real-server.test.js +137 -0
- package/test/la-protocol-v2/contract-v21.test.js +2 -2
- package/test/la-protocol-v2/contract.test.js +8 -7
- package/test/la-protocol-v2/helpers/agent2-rig.js +96 -0
- package/test/la-protocol-v2/helpers/fake-agent2-cli.js +22 -0
- package/test/la-protocol-v2/helpers/fake-agent2.js +280 -0
- package/test/la-protocol-v2/helpers/fake-la-api.js +29 -0
- package/test/la-protocol-v2/helpers/fake-llm.js +11 -0
- package/test/la-protocol-v2/helpers/reference-cp.js +6 -3
- package/test/la-protocol-v2/helpers/spawn-agent.js +4 -3
- package/test/la-protocol-v2/helpers/supervisor-exit-script.js +15 -0
- package/test/la-protocol-v2/llm-messages.test.js +132 -0
- package/test/la-protocol-v2/workspace-materializer.test.js +341 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* workspace-materializer.js - LA-2 (ADR-0014 Design A, chat on the Local Agent). A `job.assign` names a workspace by id and a `repoRef`
|
|
3
|
+
* {provider, org, repo, branch}. When this agent does not serve that workspace yet, it can CLONE IT ITSELF, into its own cache, with the customer's OWN
|
|
4
|
+
* credentials (credential helper / ssh-agent / GH_TOKEN of this process, or a local token file read by GIT_ASKPASS). The platform never sends a token and
|
|
5
|
+
* never sees one; it only names the repository.
|
|
6
|
+
*
|
|
7
|
+
* LOCAL_AGENT_JOBS_AUTO_WORKSPACE=true (default OFF) turns it on. Needs LOCAL_AGENT_JOBS (the manager that calls it).
|
|
8
|
+
* LOCAL_AGENT_ALLOWED_REPOS `host/org/repo` or `host/org/*`, separated by comma/space/newline; LOCAL_AGENT_ALLOWED_REPOS_FILE adds a file
|
|
9
|
+
* (one entry per line, `#` comments). ONLY the customer decides this list: it is never taken from the control plane.
|
|
10
|
+
* Empty = nothing is materialized (fail closed).
|
|
11
|
+
* LOCAL_AGENT_WORKSPACE_CACHE where clones live (default ~/.deepdebug/workspaces)
|
|
12
|
+
* LOCAL_AGENT_GIT_TOKEN_FILE optional: a file with a token that a GIT_ASKPASS script cats (POSIX). Absent = the machine's own helper/ssh.
|
|
13
|
+
* LOCAL_AGENT_WORKSPACE_CLONE_TIMEOUT_MS (default 300000), LOCAL_AGENT_WORKSPACE_MAX_BYTES (default 2 GiB, a clone above it is removed)
|
|
14
|
+
*
|
|
15
|
+
* Rules: one clone at a time on this agent and one clone per workspace id (a second job for the same id awaits the first); URL built here from the closed
|
|
16
|
+
* provider list and the validated org/repo (never from outside text, never with credentials); `execFile` with an array; the tenant is the SESSION's
|
|
17
|
+
* (wsManager.open(.., {tenantId})); a directory of another tenant, a symlink in the cache or a foreign directory is refused; nothing of git's stderr, of a path
|
|
18
|
+
* or of the allowlist is thrown, logged or audited (codes and short hashes only). An existing clone is fetched and fast-forwarded only when clean: local work
|
|
19
|
+
* of a previous job is never reset.
|
|
20
|
+
*/
|
|
21
|
+
import fs from "node:fs";
|
|
22
|
+
import os from "node:os";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import crypto from "node:crypto";
|
|
25
|
+
import { readWorkspaceSidecar, isSafeToRemove } from "../../utils/open-gate.js";
|
|
26
|
+
import { short } from "../content-handlers/audit.js";
|
|
27
|
+
import { gitEnv, ensureAskpassScript, runGit } from "./git-exec.js";
|
|
28
|
+
|
|
29
|
+
export const MATERIALIZE_FLAG = "LOCAL_AGENT_JOBS_AUTO_WORKSPACE";
|
|
30
|
+
const TRUE = new Set(["true", "1", "yes", "on"]);
|
|
31
|
+
const MIB = 1024 * 1024;
|
|
32
|
+
const WS_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
|
33
|
+
const SEG = /^[A-Za-z0-9_.-]{1,100}$/;
|
|
34
|
+
const BRANCH = /^[A-Za-z0-9_./-]{1,255}$/;
|
|
35
|
+
const HOSTS = Object.freeze({ github: "github.com", gitlab: "gitlab.com", bitbucket: "bitbucket.org" }); // azure/other: no known host, not materializable
|
|
36
|
+
const ENTRY = /^([a-z0-9.-]+)\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+|\*)$/;
|
|
37
|
+
|
|
38
|
+
function int(raw, def, min, max) { const n = Number.parseInt(String(raw ?? ""), 10); return Number.isFinite(n) && n >= min && n <= max ? n : def; }
|
|
39
|
+
|
|
40
|
+
/** An error with a code and the job error code `infra`; the message is generic (the manager never reads it anyway). */
|
|
41
|
+
export class MaterializeError extends Error {
|
|
42
|
+
constructor(code) { super(`workspace not available (${code})`); this.name = "MaterializeError"; this.code = code; this.jobErrorCode = "infra"; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Parses the allowlist text. Returns { exact:Set<"host/org/repo">, orgs:Set<"host/org">, invalid:number } (lower case). */
|
|
46
|
+
export function parseAllowlist(text) {
|
|
47
|
+
const exact = new Set();
|
|
48
|
+
const orgs = new Set();
|
|
49
|
+
let invalid = 0;
|
|
50
|
+
for (const raw of String(text || "").split(/[\s,]+/)) {
|
|
51
|
+
const e = raw.trim().toLowerCase();
|
|
52
|
+
if (!e || e.startsWith("#")) continue;
|
|
53
|
+
const m = ENTRY.exec(e);
|
|
54
|
+
if (!m || m[2] === "." || m[2] === ".." || m[3] === "." || m[3] === "..") { invalid++; continue; }
|
|
55
|
+
if (m[3] === "*") orgs.add(`${m[1]}/${m[2]}`); else exact.add(`${m[1]}/${m[2]}/${m[3]}`);
|
|
56
|
+
}
|
|
57
|
+
return { exact, orgs, invalid };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readAllowlistText(env) {
|
|
61
|
+
let text = String(env.LOCAL_AGENT_ALLOWED_REPOS ?? "");
|
|
62
|
+
const file = String(env.LOCAL_AGENT_ALLOWED_REPOS_FILE ?? "").trim();
|
|
63
|
+
if (file) {
|
|
64
|
+
try { text += "\n" + fs.readFileSync(file, "utf8").split(/\r?\n/).filter((l) => !l.trim().startsWith("#")).join("\n"); } catch { /* unreadable file = no entries from it (fail closed) */ }
|
|
65
|
+
}
|
|
66
|
+
return text;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** { enabled:false } unless LOCAL_AGENT_JOBS_AUTO_WORKSPACE is true. */
|
|
70
|
+
export function resolveMaterializeConfig(env = process.env, warn = console.warn) {
|
|
71
|
+
if (!TRUE.has(String(env[MATERIALIZE_FLAG] ?? "").trim().toLowerCase())) return { enabled: false };
|
|
72
|
+
const allow = parseAllowlist(readAllowlistText(env));
|
|
73
|
+
if (allow.invalid) warn(`[la-workspace] ${allow.invalid} invalid LOCAL_AGENT_ALLOWED_REPOS entr${allow.invalid === 1 ? "y" : "ies"} ignored`);
|
|
74
|
+
if (!allow.exact.size && !allow.orgs.size) warn("[la-workspace] LOCAL_AGENT_ALLOWED_REPOS is empty: no workspace will be materialized");
|
|
75
|
+
const tokenFile = String(env.LOCAL_AGENT_GIT_TOKEN_FILE ?? "").trim() || null;
|
|
76
|
+
return {
|
|
77
|
+
enabled: true,
|
|
78
|
+
allow,
|
|
79
|
+
cacheDir: path.resolve(String(env.LOCAL_AGENT_WORKSPACE_CACHE ?? "").trim() || path.join(os.homedir(), ".deepdebug", "workspaces")),
|
|
80
|
+
tokenFile: tokenFile ? path.resolve(tokenFile) : null,
|
|
81
|
+
cloneTimeoutMs: int(env.LOCAL_AGENT_WORKSPACE_CLONE_TIMEOUT_MS, 300_000, 1_000, 3_600_000),
|
|
82
|
+
gitTimeoutMs: int(env.LOCAL_AGENT_WORKSPACE_GIT_TIMEOUT_MS, 60_000, 1_000, 600_000),
|
|
83
|
+
maxBytes: int(env.LOCAL_AGENT_WORKSPACE_MAX_BYTES, 2048 * MIB, MIB, 64 * 1024 * MIB),
|
|
84
|
+
depth: int(env.LOCAL_AGENT_WORKSPACE_CLONE_DEPTH, 50, 1, 10_000),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Validates a repoRef and builds { host, org, repo, branch, key, url } or null. The url has NO credentials and is only https. */
|
|
89
|
+
export function repoTarget(repoRef) {
|
|
90
|
+
if (!repoRef || typeof repoRef !== "object") return null;
|
|
91
|
+
const host = HOSTS[repoRef.provider];
|
|
92
|
+
const { org, repo, branch } = repoRef;
|
|
93
|
+
if (!host || typeof org !== "string" || typeof repo !== "string" || typeof branch !== "string") return null;
|
|
94
|
+
if (!SEG.test(org) || !SEG.test(repo) || org === "." || org === ".." || repo === "." || repo === "..") return null;
|
|
95
|
+
if (!BRANCH.test(branch) || branch.startsWith("-") || branch.startsWith("/") || branch.endsWith("/") || branch.endsWith(".lock") || branch.includes("..") || branch.includes("//") || branch.includes("/.")) return null;
|
|
96
|
+
return { host, org, repo, branch, key: `${host}/${org.toLowerCase()}/${repo.toLowerCase()}`, orgKey: `${host}/${org.toLowerCase()}`, url: `https://${host}/${org}/${repo}.git` };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Bounded, symlink-safe size of a directory (lstat, no following). Returns bytes counted up to `limit`+1. */
|
|
100
|
+
function dirBytes(dir, limit) {
|
|
101
|
+
let total = 0;
|
|
102
|
+
const stack = [dir];
|
|
103
|
+
while (stack.length) {
|
|
104
|
+
const d = stack.pop();
|
|
105
|
+
let ents;
|
|
106
|
+
try { ents = fs.readdirSync(d, { withFileTypes: true }); } catch { continue; }
|
|
107
|
+
for (const e of ents) {
|
|
108
|
+
const p = path.join(d, e.name);
|
|
109
|
+
if (e.isDirectory()) stack.push(p);
|
|
110
|
+
else { try { total += fs.lstatSync(p).size; } catch { /* vanished */ } }
|
|
111
|
+
if (total > limit) return total;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return total;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @param {object} o
|
|
119
|
+
* @param {object} o.config resolveMaterializeConfig() (enabled)
|
|
120
|
+
* @param {string} o.tenantId the SESSION's tenant
|
|
121
|
+
* @param {object} o.wsManager open(id, dir, {tenantId}), list()
|
|
122
|
+
* @param {object} [o.audit] { record(entry) } - codes and short hashes only
|
|
123
|
+
* @param {object} [o.env]
|
|
124
|
+
* @param {object} [o.seams] TESTS ONLY: { remoteUrl(target) => string, gitConfig: string[] (e.g. protocol.file.allow=always), runGit }
|
|
125
|
+
*/
|
|
126
|
+
export function createWorkspaceMaterializer({ config, tenantId, wsManager, audit: auditIn = null, env = process.env, log = console, seams = {} }) {
|
|
127
|
+
let audit = auditIn;
|
|
128
|
+
const tenant = String(tenantId).toLowerCase();
|
|
129
|
+
const git = seams.runGit || runGit;
|
|
130
|
+
const stats = { admitted: 0, refused: {}, cloned: 0, reused: 0, failed: {} };
|
|
131
|
+
const inflight = new Map(); // workspaceId -> Promise<{root}>
|
|
132
|
+
let chain = Promise.resolve(); // one clone/fetch at a time on this agent
|
|
133
|
+
const note = (event, extra) => { try { audit?.record({ event, ...extra }); } catch { /* the audit never breaks the data path */ } };
|
|
134
|
+
const refuse = (why, extra) => { stats.refused[why] = (stats.refused[why] || 0) + 1; note("workspace_refused", { why, ...extra }); return null; };
|
|
135
|
+
|
|
136
|
+
const cacheReal = () => { try { fs.mkdirSync(config.cacheDir, { recursive: true, mode: 0o700 }); return fs.realpathSync(config.cacheDir); } catch { return null; } };
|
|
137
|
+
const allowed = (t) => config.allow.exact.has(t.key) || config.allow.orgs.has(t.orgKey);
|
|
138
|
+
const tokenUsable = () => { if (!config.tokenFile) return true; try { fs.accessSync(config.tokenFile, fs.constants.R_OK); return fs.statSync(config.tokenFile).isFile(); } catch { return false; } };
|
|
139
|
+
const listed = (id) => (wsManager?.list?.() || []).find((w) => w && w.id === id);
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Synchronous admission (inside the 5 s ack): may this workspace be materialized for this job? true = the manager accepts the job and calls ensure()
|
|
143
|
+
* from inside it. false = the job is refused `unauthorized` with an audit CODE. Never touches the network.
|
|
144
|
+
*/
|
|
145
|
+
function admit(workspaceId, repoRef) {
|
|
146
|
+
const base = { workspaceShort: short(workspaceId) };
|
|
147
|
+
if (typeof workspaceId !== "string" || !WS_ID.test(workspaceId) || workspaceId === "default") return !!refuse("bad_workspace_id", base);
|
|
148
|
+
if (listed(workspaceId)) return !!refuse("already_open_elsewhere", base); // served (the resolver decided) or another tenant's: never replaced
|
|
149
|
+
const t = repoTarget(repoRef);
|
|
150
|
+
if (!t) return !!refuse("bad_repo_ref", base);
|
|
151
|
+
if (!allowed(t)) return !!refuse("repo_not_allowed", { ...base, repoShort: short(t.key) });
|
|
152
|
+
if (!tokenUsable()) return !!refuse("credential_unavailable", base); // a configured token file that cannot be read: a clear refusal now, not a failed clone later
|
|
153
|
+
const root = cacheReal();
|
|
154
|
+
if (!root) return !!refuse("cache_unavailable", base);
|
|
155
|
+
const dir = path.join(root, workspaceId);
|
|
156
|
+
let st = null;
|
|
157
|
+
try { st = fs.lstatSync(dir); } catch { st = null; }
|
|
158
|
+
if (st) {
|
|
159
|
+
if (st.isSymbolicLink() || !st.isDirectory()) return !!refuse("cache_entry_unsafe", base);
|
|
160
|
+
const sc = readWorkspaceSidecar(dir);
|
|
161
|
+
if (!sc || sc.id !== workspaceId) return !!refuse("foreign_directory", base);
|
|
162
|
+
if (!sc.tenantId || sc.tenantId.toLowerCase() !== tenant) return !!refuse("tenant_mismatch", base);
|
|
163
|
+
}
|
|
164
|
+
stats.admitted++;
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function doEnsure(workspaceId, repoRef, signal) {
|
|
169
|
+
const t = repoTarget(repoRef);
|
|
170
|
+
const ws = short(workspaceId);
|
|
171
|
+
const fail = (code) => { stats.failed[code] = (stats.failed[code] || 0) + 1; note("workspace_failed", { code, workspaceShort: ws }); throw new MaterializeError(code); };
|
|
172
|
+
if (!t || !allowed(t) || !WS_ID.test(workspaceId)) fail("repo_not_allowed"); // defence in depth: ensure() re-checks what admit() checked
|
|
173
|
+
const root = cacheReal();
|
|
174
|
+
if (!root) fail("cache_unavailable");
|
|
175
|
+
const dir = path.join(root, workspaceId);
|
|
176
|
+
if (!isSafeToRemove(root, dir)) fail("cache_entry_unsafe");
|
|
177
|
+
let askpass = null;
|
|
178
|
+
if (config.tokenFile) {
|
|
179
|
+
if (!tokenUsable()) fail("credential_unavailable");
|
|
180
|
+
try { askpass = ensureAskpassScript(root); } catch { fail("cache_unavailable"); }
|
|
181
|
+
}
|
|
182
|
+
const env2 = gitEnv(env, { askpass, tokenFile: config.tokenFile });
|
|
183
|
+
const extraConfig = seams.gitConfig || [];
|
|
184
|
+
const remote = seams.remoteUrl ? seams.remoteUrl(t) : t.url;
|
|
185
|
+
let st = null;
|
|
186
|
+
try { st = fs.lstatSync(dir); } catch { st = null; }
|
|
187
|
+
if (st) {
|
|
188
|
+
if (st.isSymbolicLink() || !st.isDirectory()) fail("cache_entry_unsafe");
|
|
189
|
+
const sc = readWorkspaceSidecar(dir);
|
|
190
|
+
if (!sc || sc.id !== workspaceId) fail("foreign_directory");
|
|
191
|
+
if (!sc.tenantId || sc.tenantId.toLowerCase() !== tenant) fail("tenant_mismatch");
|
|
192
|
+
// existing clone: fetch the branch; move only a CLEAN tree (the previous job's local work is never reset)
|
|
193
|
+
const f = await git(["fetch", "--depth", String(config.depth), "--no-tags", "--", "origin", t.branch], { cwd: dir, env: env2, timeoutMs: config.gitTimeoutMs, signal, extraConfig });
|
|
194
|
+
if (!f.ok) fail(f.code === "failed" ? "fetch_failed" : f.code);
|
|
195
|
+
const dirty = await git(["status", "--porcelain"], { cwd: dir, env: env2, timeoutMs: config.gitTimeoutMs, signal, extraConfig });
|
|
196
|
+
if (dirty.ok && dirty.stdout.trim() === "") {
|
|
197
|
+
const co = await git(["checkout", "--quiet", t.branch, "--"], { cwd: dir, env: env2, timeoutMs: config.gitTimeoutMs, signal, extraConfig });
|
|
198
|
+
if (co.ok) await git(["merge", "--ff-only", "--quiet", "FETCH_HEAD"], { cwd: dir, env: env2, timeoutMs: config.gitTimeoutMs, signal, extraConfig });
|
|
199
|
+
} else note("workspace_kept_dirty", { workspaceShort: ws });
|
|
200
|
+
stats.reused++;
|
|
201
|
+
} else {
|
|
202
|
+
const tmp = path.join(root, `.tmp-${workspaceId}-${crypto.randomBytes(4).toString("hex")}`);
|
|
203
|
+
let ok = false;
|
|
204
|
+
try {
|
|
205
|
+
const c = await git(["clone", "--branch", t.branch, "--single-branch", "--depth", String(config.depth), "--no-tags", "--", remote, tmp], { env: env2, cwd: root, timeoutMs: config.cloneTimeoutMs, signal, extraConfig });
|
|
206
|
+
if (!c.ok) fail(c.code === "failed" ? "clone_failed" : c.code);
|
|
207
|
+
if (dirBytes(tmp, config.maxBytes) > config.maxBytes) fail("too_large");
|
|
208
|
+
// the remote of the clone is the credential-free URL we built; nothing else was ever written to its config
|
|
209
|
+
fs.renameSync(tmp, dir);
|
|
210
|
+
ok = true;
|
|
211
|
+
} finally {
|
|
212
|
+
if (!ok) { try { if (isSafeToRemove(root, tmp)) fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
213
|
+
}
|
|
214
|
+
stats.cloned++;
|
|
215
|
+
}
|
|
216
|
+
try { await wsManager.open(workspaceId, dir, { tenantId: tenant }); } catch {
|
|
217
|
+
if (!st) { try { if (isSafeToRemove(root, dir)) fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ } } // a fresh clone that could not be registered must not stay as a "foreign" directory
|
|
218
|
+
fail("open_failed");
|
|
219
|
+
}
|
|
220
|
+
note("workspace_ready", { workspaceShort: ws, repoShort: short(t.key), reused: !!st });
|
|
221
|
+
return { root: dir };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
stats, config,
|
|
226
|
+
/** The job manager hands its audit in (one trail for jobs and workspaces). */
|
|
227
|
+
bindAudit(a) { if (!audit) audit = a; },
|
|
228
|
+
admit,
|
|
229
|
+
/**
|
|
230
|
+
* Clone (or refresh) and register the workspace. Called by the job manager from inside the job; resolves { root } or throws MaterializeError (job `failed`, `infra`).
|
|
231
|
+
* One clone at a time; a second call for the same id shares the first one's promise.
|
|
232
|
+
*/
|
|
233
|
+
ensure(workspaceId, repoRef, signal) {
|
|
234
|
+
const running = inflight.get(workspaceId);
|
|
235
|
+
if (running) return running;
|
|
236
|
+
const p = chain.then(() => { if (signal && signal.aborted) throw new MaterializeError("aborted"); return doEnsure(workspaceId, repoRef, signal); });
|
|
237
|
+
chain = p.catch(() => {});
|
|
238
|
+
inflight.set(workspaceId, p);
|
|
239
|
+
const clear = () => { if (inflight.get(workspaceId) === p) inflight.delete(workspaceId); };
|
|
240
|
+
p.then(clear, clear);
|
|
241
|
+
return p;
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
@@ -38,6 +38,8 @@ export class LlmClientError extends Error {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
const MODEL = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,95}$/;
|
|
41
|
+
const TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/;
|
|
42
|
+
const TOOL_ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
41
43
|
const ROLES = new Set(["system", "user", "assistant"]);
|
|
42
44
|
const COST_HEADER = "x-litellm-response-cost";
|
|
43
45
|
const MAX_SSE_LINE_BYTES = 256 * 1024;
|
|
@@ -111,7 +113,7 @@ export function createLlmClient({ holder, config, tenantId, agentId, env = proce
|
|
|
111
113
|
try {
|
|
112
114
|
let res;
|
|
113
115
|
try {
|
|
114
|
-
res = await holder.request(config.path, { method: "POST", headers: { "content-type": "application/json", accept: o.stream ? "text/event-stream" : "application/json" }, body: bodyText, signal: ac.signal }, fetchImpl ? { fetchImpl } : undefined);
|
|
116
|
+
res = await holder.request(o.path || config.path, { method: "POST", headers: { "content-type": "application/json", accept: o.stream ? "text/event-stream" : "application/json" }, body: bodyText, signal: ac.signal }, fetchImpl ? { fetchImpl } : undefined);
|
|
115
117
|
} catch (e) {
|
|
116
118
|
// LlmKeyError codes from the holder (no_key | expired | bad_path); anything else (fetch failure, abort) is a network error
|
|
117
119
|
if (e && e.name === "LlmKeyError") throw { code: e.code === "expired" ? "key_expired" : e.code === "no_key" ? "no_key" : "internal", retry: false };
|
|
@@ -133,7 +135,7 @@ export function createLlmClient({ holder, config, tenantId, agentId, env = proce
|
|
|
133
135
|
const costUsd = parseCost(res.headers);
|
|
134
136
|
if (!res.body || typeof res.body.getReader !== "function") throw { code: "bad_response", retry: false };
|
|
135
137
|
try {
|
|
136
|
-
const parsed = o.stream ? await readStream(res.body, o, rearm, ctx) : await readJson(res.body, config.maxResponseBytes);
|
|
138
|
+
const parsed = o.stream ? await readStream(res.body, o, rearm, ctx) : o.parseJson ? await o.parseJson(res.body, config.maxResponseBytes) : await readJson(res.body, config.maxResponseBytes);
|
|
137
139
|
return { ...parsed, costUsd };
|
|
138
140
|
} catch (e) {
|
|
139
141
|
if (e && e.code && !e.name) throw e; // our own { code, retry }
|
|
@@ -271,26 +273,31 @@ export function createLlmClient({ holder, config, tenantId, agentId, env = proce
|
|
|
271
273
|
maxTokens = Math.min(maxTokens, room);
|
|
272
274
|
}
|
|
273
275
|
// 5. slot and the call
|
|
276
|
+
return dispatch(o, ctx, {}, (tags) => JSON.stringify({
|
|
277
|
+
model: o.model, messages: masked, max_tokens: maxTokens, stream: !!o.stream,
|
|
278
|
+
...(o.stream ? { stream_options: { include_usage: true } } : {}),
|
|
279
|
+
...(o.temperature !== undefined ? { temperature: o.temperature } : {}),
|
|
280
|
+
...(tags ? { metadata: tags } : {}),
|
|
281
|
+
}));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** The slot, the HTTP call with its retries and the bookkeeping, shared by chat() and messages(). `hooks` = { path, parseJson } of the API in use. */
|
|
285
|
+
async function dispatch(o, ctx, hooks, makeBody) {
|
|
274
286
|
if (active >= config.maxConcurrent) return fail("busy", ctx);
|
|
275
287
|
active++;
|
|
276
288
|
try {
|
|
277
289
|
const tags = config.tags ? { tags: [`vh-tenant:${tenantId}`, `vh-la:${agentId}`, ...(o.job?.jobId ? [`vh-job:${o.job.jobId}`] : []), ...(o.job?.agent && /^[a-z0-9][a-z0-9._-]{0,31}$/.test(o.job.agent) ? [`vh-agent:${o.job.agent}`] : [])] } : null;
|
|
278
|
-
const bodyText =
|
|
279
|
-
model: o.model, messages: masked, max_tokens: maxTokens, stream: !!o.stream,
|
|
280
|
-
...(o.stream ? { stream_options: { include_usage: true } } : {}),
|
|
281
|
-
...(o.temperature !== undefined ? { temperature: o.temperature } : {}),
|
|
282
|
-
...(tags ? { metadata: tags } : {}),
|
|
283
|
-
});
|
|
290
|
+
const bodyText = makeBody(tags);
|
|
284
291
|
stats.bytesOut += byteLength(bodyText);
|
|
285
292
|
let attempts = 0;
|
|
286
293
|
for (;;) {
|
|
287
294
|
attempts++;
|
|
288
295
|
try {
|
|
289
|
-
const r = await attempt(bodyText, o, ctx);
|
|
296
|
+
const r = await attempt(bodyText, { ...o, ...hooks }, ctx);
|
|
290
297
|
const usage = r.usage || { promptTokens: estimateTokens(ctx.promptBytes), completionTokens: estimateTokens(byteLength(r.text)), estimated: true };
|
|
291
298
|
stats.ok++; stats.tokensIn += usage.promptTokens; stats.tokensOut += usage.completionTokens; stats.bytesIn += byteLength(r.text);
|
|
292
|
-
record({ ok: true, model: o.model, jobShort: ctx.jobShort, promptBytes: ctx.promptBytes, outBytes: byteLength(r.text), tokensIn: usage.promptTokens, tokensOut: usage.completionTokens, estimated: usage.estimated, attempts, redactions: ctx.redactions, ms: now() - ctx.t0, stream: !!o.stream });
|
|
293
|
-
return { text: r.text, usage, costUsd: r.costUsd, model: r.model || o.model, finishReason: r.finishReason, attempts };
|
|
299
|
+
record({ ok: true, model: o.model, jobShort: ctx.jobShort, promptBytes: ctx.promptBytes, outBytes: byteLength(r.text), tokensIn: usage.promptTokens, tokensOut: usage.completionTokens, estimated: usage.estimated, attempts, redactions: ctx.redactions, ms: now() - ctx.t0, stream: !!o.stream, ...(hooks.api ? { api: hooks.api } : {}) });
|
|
300
|
+
return { text: r.text, usage, costUsd: r.costUsd, model: r.model || o.model, finishReason: r.finishReason, attempts, ...(r.message ? { message: r.message } : {}) };
|
|
294
301
|
} catch (e) {
|
|
295
302
|
const code = e && typeof e.code === "string" && LLM_ERROR_CODES.includes(e.code) ? e.code : "internal";
|
|
296
303
|
const canRetry = e && e.retry === true && !ctx.delivered && attempts <= config.maxRetries && !o.signal?.aborted;
|
|
@@ -307,5 +314,119 @@ export function createLlmClient({ holder, config, tenantId, agentId, env = proce
|
|
|
307
314
|
}
|
|
308
315
|
}
|
|
309
316
|
|
|
310
|
-
|
|
317
|
+
// ------------------------------------------------------------------------------------------------ Anthropic Messages API (F3-08c)
|
|
318
|
+
|
|
319
|
+
/** Masks one string and adds its size; refuses (throws a code) like maskMessages. */
|
|
320
|
+
function maskText(text, ctx, acc) {
|
|
321
|
+
const res = protectEgress(text, { env, source: "llm.client" });
|
|
322
|
+
addCounts(ctx.redactions, res.redactions);
|
|
323
|
+
if (res.denied || res.text === DENIED || res.text === OVERSIZE) throw "dlp_denied";
|
|
324
|
+
if (res.blocked) throw "dlp_blocked";
|
|
325
|
+
acc.bytes += byteLength(res.text);
|
|
326
|
+
if (acc.bytes > config.maxPromptBytes) throw "prompt_too_large";
|
|
327
|
+
return res.text;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Masks every string leaf of a JSON value (tool input), bounded in depth and size. */
|
|
331
|
+
function maskJson(v, ctx, acc, depth = 0) {
|
|
332
|
+
if (depth > 8 || ++acc.nodes > 20_000) throw "bad_request";
|
|
333
|
+
if (typeof v === "string") return maskText(v, ctx, acc);
|
|
334
|
+
if (v === null || typeof v === "number" || typeof v === "boolean") return v;
|
|
335
|
+
if (Array.isArray(v)) return v.map((x) => maskJson(x, ctx, acc, depth + 1));
|
|
336
|
+
if (typeof v === "object") { const out = {}; for (const [k, x] of Object.entries(v)) { if (k === "__proto__" || k.length > 200) throw "bad_request"; out[k] = maskJson(x, ctx, acc, depth + 1); } return out; }
|
|
337
|
+
throw "bad_request";
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const cacheOf = (b) => (b.cache_control && typeof b.cache_control === "object" && b.cache_control.type === "ephemeral" ? { cache_control: { type: "ephemeral" } } : {});
|
|
341
|
+
|
|
342
|
+
/** A block of the Messages API, rebuilt field by field: a type or a field this agent does not know is refused, never forwarded. */
|
|
343
|
+
function maskBlock(b, ctx, acc, { allowTools }) {
|
|
344
|
+
if (!b || typeof b !== "object" || Array.isArray(b)) throw "bad_request";
|
|
345
|
+
if (b.type === "text" && typeof b.text === "string") return { type: "text", text: maskText(b.text, ctx, acc), ...cacheOf(b) };
|
|
346
|
+
if (allowTools && b.type === "tool_use" && TOOL_ID.test(String(b.id)) && TOOL_NAME.test(String(b.name)) && b.input && typeof b.input === "object" && !Array.isArray(b.input)) return { type: "tool_use", id: b.id, name: b.name, input: maskJson(b.input, ctx, acc) };
|
|
347
|
+
if (allowTools && b.type === "tool_result" && TOOL_ID.test(String(b.tool_use_id))) {
|
|
348
|
+
const content = typeof b.content === "string" ? maskText(b.content, ctx, acc)
|
|
349
|
+
: Array.isArray(b.content) ? b.content.map((x) => maskBlock(x, ctx, acc, { allowTools: false })) : b.content === undefined ? "" : (() => { throw "bad_request"; })();
|
|
350
|
+
return { type: "tool_result", tool_use_id: b.tool_use_id, content, ...(b.is_error === true ? { is_error: true } : {}) };
|
|
351
|
+
}
|
|
352
|
+
throw "bad_request";
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function maskTools(tools, ctx, acc) {
|
|
356
|
+
if (!Array.isArray(tools) || tools.length > 64) throw "bad_request";
|
|
357
|
+
return tools.map((t) => {
|
|
358
|
+
if (!t || typeof t !== "object" || !TOOL_NAME.test(String(t.name)) || !t.input_schema || typeof t.input_schema !== "object" || Array.isArray(t.input_schema)) throw "bad_request";
|
|
359
|
+
if (t.description !== undefined && (typeof t.description !== "string" || byteLength(t.description) > 16 * 1024)) throw "bad_request";
|
|
360
|
+
if (byteLength(JSON.stringify(t.input_schema)) > 64 * 1024) throw "bad_request";
|
|
361
|
+
return { name: t.name, ...(t.description !== undefined ? { description: maskText(t.description, ctx, acc) } : {}), input_schema: maskJson(t.input_schema, ctx, acc), ...cacheOf(t) };
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function parseMessagesJson(body, maxBytes) {
|
|
366
|
+
const text = await readBounded(body, maxBytes);
|
|
367
|
+
let j;
|
|
368
|
+
try { j = JSON.parse(text); } catch { throw { code: "bad_response", retry: false }; }
|
|
369
|
+
if (!j || typeof j !== "object" || !Array.isArray(j.content) || j.content.some((b) => !b || typeof b !== "object" || typeof b.type !== "string")) throw { code: "bad_response", retry: false };
|
|
370
|
+
const u = j.usage && typeof j.usage === "object" ? j.usage : null;
|
|
371
|
+
const n = (x) => (Number.isSafeInteger(x) && x >= 0 ? x : 0);
|
|
372
|
+
const input = u ? n(u.input_tokens) + n(u.cache_creation_input_tokens) + n(u.cache_read_input_tokens) : null;
|
|
373
|
+
const usage = u && Number.isSafeInteger(u.input_tokens) && Number.isSafeInteger(u.output_tokens) ? { promptTokens: input, completionTokens: n(u.output_tokens), estimated: false } : null;
|
|
374
|
+
const stop = typeof j.stop_reason === "string" ? j.stop_reason.slice(0, 32) : null;
|
|
375
|
+
const message = { id: typeof j.id === "string" ? j.id.slice(0, 128) : "msg_la", type: "message", role: "assistant", content: j.content, stop_reason: stop, stop_sequence: null,
|
|
376
|
+
usage: u ? { input_tokens: n(u.input_tokens), output_tokens: n(u.output_tokens), ...(Number.isSafeInteger(u.cache_creation_input_tokens) ? { cache_creation_input_tokens: u.cache_creation_input_tokens } : {}), ...(Number.isSafeInteger(u.cache_read_input_tokens) ? { cache_read_input_tokens: u.cache_read_input_tokens } : {}) } : { input_tokens: 0, output_tokens: 0 } };
|
|
377
|
+
return { text: j.content.filter((b) => b.type === "text" && typeof b.text === "string").map((b) => b.text).join(""), usage, model: modelOf(j.model), finishReason: stop, message };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* F3-08c: one turn of the Anthropic Messages API (`POST <endpoint>/v1/messages`, what the packaged agent loop speaks, with tools), through the SAME gates
|
|
382
|
+
* as chat(): DLP enforce, key and alias, EVERY string the model will read masked (system, text, tool_use input, tool_result, tool descriptions), size and
|
|
383
|
+
* token budget, concurrency slot, timeouts and retries, codes-only errors. Not streaming. Blocks and fields outside the known set are refused.
|
|
384
|
+
* @param {object} o model (alias), system (string | text blocks), messages [{role:user|assistant, content:string|blocks}], tools?, toolChoice?, maxTokens?, temperature?, signal?, job?, tokensLeft?
|
|
385
|
+
* @returns {Promise<{text:string, message:object, usage, costUsd, model, finishReason, attempts}>} `message` = the response in the Messages API shape (local use only)
|
|
386
|
+
*/
|
|
387
|
+
async function messages(o = {}) {
|
|
388
|
+
stats.calls++;
|
|
389
|
+
const ctx = { t0: now(), model: typeof o.model === "string" && MODEL.test(o.model) ? o.model : "invalid", jobShort: o.job?.jobId ? short(o.job.jobId) : undefined, promptBytes: 0, redactions: {}, stream: false, delivered: false, deadline: now() + config.totalMs };
|
|
390
|
+
const msgs = o.messages;
|
|
391
|
+
if (typeof o.model !== "string" || !MODEL.test(o.model) || !Array.isArray(msgs) || msgs.length < 1 || msgs.length > config.maxApiMessages
|
|
392
|
+
|| !msgs.every((m) => m && typeof m === "object" && (m.role === "user" || m.role === "assistant") && (typeof m.content === "string" || (Array.isArray(m.content) && m.content.length >= 1 && m.content.length <= 256)))) return fail("bad_request", ctx);
|
|
393
|
+
if (o.temperature !== undefined && !(Number.isFinite(o.temperature) && o.temperature >= 0 && o.temperature <= 1)) return fail("bad_request", ctx);
|
|
394
|
+
if (o.system !== undefined && typeof o.system !== "string" && !(Array.isArray(o.system) && o.system.length <= 64)) return fail("bad_request", ctx);
|
|
395
|
+
if (o.toolChoice !== undefined && !(o.toolChoice && ((["auto", "any", "none"].includes(o.toolChoice.type)) || (o.toolChoice.type === "tool" && TOOL_NAME.test(String(o.toolChoice.name)))))) return fail("bad_request", ctx);
|
|
396
|
+
if (o.signal?.aborted) return fail("cancelled", ctx);
|
|
397
|
+
if (dlpConfigFromEnv(env).mode !== "enforce") return fail("dlp_not_enforced", ctx);
|
|
398
|
+
if (!holder.held) return fail("no_key", ctx);
|
|
399
|
+
const d = holder.describe();
|
|
400
|
+
if (!d.held) return fail("key_expired", ctx);
|
|
401
|
+
if (!d.models.includes(o.model)) return fail("model_not_allowed", ctx);
|
|
402
|
+
const acc = { bytes: 0, nodes: 0 };
|
|
403
|
+
let system;
|
|
404
|
+
let masked;
|
|
405
|
+
let tools;
|
|
406
|
+
try {
|
|
407
|
+
if (typeof o.system === "string") system = maskText(o.system, ctx, acc);
|
|
408
|
+
else if (Array.isArray(o.system)) system = o.system.map((b) => maskBlock(b, ctx, acc, { allowTools: false }));
|
|
409
|
+
masked = msgs.map((m) => ({ role: m.role, content: typeof m.content === "string" ? maskText(m.content, ctx, acc) : m.content.map((b) => maskBlock(b, ctx, acc, { allowTools: true })) }));
|
|
410
|
+
if (o.tools !== undefined) tools = maskTools(o.tools, ctx, acc);
|
|
411
|
+
} catch (code) {
|
|
412
|
+
return fail(typeof code === "string" ? code : "bad_request", ctx);
|
|
413
|
+
}
|
|
414
|
+
ctx.promptBytes = acc.bytes;
|
|
415
|
+
let maxTokens = Math.min(Number.isSafeInteger(o.maxTokens) && o.maxTokens > 0 ? o.maxTokens : config.maxOutputTokens, config.maxOutputTokens);
|
|
416
|
+
if (o.tokensLeft !== undefined) {
|
|
417
|
+
const room = o.tokensLeft - estimateTokens(ctx.promptBytes);
|
|
418
|
+
if (!(room >= 1)) return fail("budget_exceeded", ctx);
|
|
419
|
+
maxTokens = Math.min(maxTokens, room);
|
|
420
|
+
}
|
|
421
|
+
return dispatch(o, ctx, { path: config.messagesPath, parseJson: parseMessagesJson, api: "messages" }, (tags) => JSON.stringify({
|
|
422
|
+
model: o.model, max_tokens: maxTokens, messages: masked,
|
|
423
|
+
...(system !== undefined ? { system } : {}),
|
|
424
|
+
...(tools ? { tools } : {}),
|
|
425
|
+
...(o.toolChoice ? { tool_choice: o.toolChoice } : {}),
|
|
426
|
+
...(o.temperature !== undefined ? { temperature: o.temperature } : {}),
|
|
427
|
+
...(tags ? { metadata: tags } : {}),
|
|
428
|
+
}));
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return { chat, messages, stats, audit: auditor, get active() { return active; } };
|
|
311
432
|
}
|
|
@@ -44,6 +44,8 @@ export function resolveLlmClientConfig(env = process.env, warn = console.warn) {
|
|
|
44
44
|
return {
|
|
45
45
|
enabled: true,
|
|
46
46
|
path, // appended to the endpoint the control plane gave (the holder never shows it); set it to /chat/completions when that endpoint already ends in /v1
|
|
47
|
+
messagesPath: PATH.test(String(env.LOCAL_AGENT_LLM_MESSAGES_PATH ?? "").trim()) ? String(env.LOCAL_AGENT_LLM_MESSAGES_PATH).trim() : "/v1/messages", // F3-08c: Anthropic Messages API (the packaged agent loop); set /messages when the endpoint of the key already ends in /v1
|
|
48
|
+
maxApiMessages: int(env.LOCAL_AGENT_LLM_MAX_API_MESSAGES, 256, 1, 1024), // messages per turn of the Messages API (an agent loop re-sends its whole history)
|
|
47
49
|
timeoutMs: int(env.LOCAL_AGENT_LLM_TIMEOUT_MS, 60_000, 1_000, 300_000), // one attempt: headers + body (streaming: until the headers)
|
|
48
50
|
streamIdleMs: int(env.LOCAL_AGENT_LLM_STREAM_IDLE_MS, 30_000, 1_000, 120_000), // streaming: silence between two chunks
|
|
49
51
|
totalMs: int(env.LOCAL_AGENT_LLM_TOTAL_MS, 180_000, 1_000, 900_000), // the whole call, retries included
|
|
@@ -82,6 +82,27 @@ export async function buildV2Link({ tenantId, agentId, signing, wsManager, getWo
|
|
|
82
82
|
if (llmClientCfg.enabled && !(await dlpEnforce())) { err("[la-llm] ERROR: LOCAL_AGENT_LLM_CLIENT=true but LOCAL_AGENT_DLP_MODE is not enforce: prompts would leave this machine unmasked, the LLM client is NOT installed"); llmClientCfg = { enabled: false }; }
|
|
83
83
|
let jobsCfg = resolveJobsConfig(env, (m) => log.warn(m));
|
|
84
84
|
let runner = null;
|
|
85
|
+
// F3-08c (Local Agent half): LOCAL_AGENT_RUNNER=agent2 plugs the packaged agent loop (agent2, next to this agent) in as the runner. Default off: nothing of
|
|
86
|
+
// ./jobs/agent2 is loaded. It needs jobs, the LLM client (agent2 reaches the model ONLY through this agent: DLP + key holder) and DLP enforce; when one is
|
|
87
|
+
// missing the job receiver is NOT installed (never silently another runner).
|
|
88
|
+
let agent2Runner = null;
|
|
89
|
+
if (String(env.LOCAL_AGENT_RUNNER ?? "").trim().toLowerCase() === "agent2") {
|
|
90
|
+
const a2 = await import("./jobs/agent2/config.js");
|
|
91
|
+
const a2cfg = a2.resolveAgent2RunnerConfig(env, (m) => log.warn(m));
|
|
92
|
+
if (jobRunner) log.warn("[la-agent2] a runner was injected into buildV2Link: LOCAL_AGENT_RUNNER=agent2 is ignored");
|
|
93
|
+
else if (!jobsCfg.enabled) err("[la-agent2] ERROR: LOCAL_AGENT_RUNNER=agent2 but LOCAL_AGENT_JOBS is not true: the agent2 runner is NOT installed");
|
|
94
|
+
else if (!a2cfg.enabled) { err(`[la-agent2] ERROR: ${a2cfg.error || "the agent2 runner is not configured"}: job.assign is NOT registered`); jobsCfg = { enabled: false }; }
|
|
95
|
+
else if (!llmClientCfg.enabled) { err("[la-agent2] ERROR: LOCAL_AGENT_RUNNER=agent2 needs the LLM client (LOCAL_AGENT_LLM_CLIENT, LOCAL_AGENT_LLM_KEY, DLP enforce): agent2 reaches the model only through this agent. job.assign is NOT registered"); jobsCfg = { enabled: false }; }
|
|
96
|
+
else if (!(await dlpEnforce())) { err("[la-agent2] ERROR: LOCAL_AGENT_RUNNER=agent2 but LOCAL_AGENT_DLP_MODE is not enforce: job.assign is NOT registered"); jobsCfg = { enabled: false }; }
|
|
97
|
+
else {
|
|
98
|
+
const { createAgent2Runner } = await import("./jobs/agent2/runner.js");
|
|
99
|
+
const { makeWorkspaceResolver } = await import("./jobs/install.js");
|
|
100
|
+
const resolveWs = makeWorkspaceResolver({ wsManager, getWorkspaceRoot, tenantId: tenant });
|
|
101
|
+
agent2Runner = createAgent2Runner({ config: a2cfg, tenantId: tenant, resolveWorkspaceRoot: (id) => resolveWs(id)?.root || null, getSession: () => link.session, signing, env, log });
|
|
102
|
+
jobRunner = agent2Runner;
|
|
103
|
+
if (jobsCfg.maxConcurrent !== 1) { log.warn("[la-agent2] agent2 runs one job at a time: LOCAL_AGENT_JOBS_MAX_CONCURRENT is forced to 1"); jobsCfg = { ...jobsCfg, maxConcurrent: 1 }; }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
85
106
|
if (jobsCfg.enabled) {
|
|
86
107
|
const { validateRunner } = await import("./jobs/manager.js");
|
|
87
108
|
const given = jobRunner && validateRunner(jobRunner) ? jobRunner : null;
|
|
@@ -96,12 +117,15 @@ export async function buildV2Link({ tenantId, agentId, signing, wsManager, getWo
|
|
|
96
117
|
const incidentCaps = hasIncidentSource ? ["incident.candidate"] : [];
|
|
97
118
|
const webhookCaps = webhookCfg.enabled ? ["webhook.forward", "webhook.chunked"] : [];
|
|
98
119
|
const llmCaps = llmCfg.enabled ? ["llm.key"] : [];
|
|
99
|
-
const jobCaps = jobsCfg.enabled ? ["job.assign"] : []; // F3-10: this agent runs jobs (the CP sends job.assign only to a session that announced it)
|
|
120
|
+
const jobCaps = jobsCfg.enabled ? ["job.assign", ...(agent2Runner ? ["approval.decision"] : [])] : []; // approval.decision (F3-08c): this agent delivers a person's decision (ADR-0010) to agent2; proposal, not named in the contract // F3-10: this agent runs jobs (the CP sends job.assign only to a session that announced it)
|
|
100
121
|
// F3-10 (contract 2.1): `job.options` = the runner understands job.assign `options` (dryRun, createPullRequest, ...). Only a runner that declared supportsOptions
|
|
101
122
|
// (the smoke runner does); an injected one that did not never gets options, and the CP, without this capability, refuses a dry run instead of sending it.
|
|
102
123
|
const jobOptionCaps = jobsCfg.enabled && (runner ? runner.supportsOptions === true : jobsCfg.runner === "smoke") ? ["job.options"] : [];
|
|
124
|
+
// contract 2.2 (F3-08c): `policy.check` = this agent asks the PolicyGate over the channel (agent2's governed actions). Only the agent2 runner asks, so only then it is announced;
|
|
125
|
+
// the CP lists it back in `ready.capabilities` only with its feature on, and this agent sends a policy.check only after that (without it: DENY, fail closed)
|
|
126
|
+
const policyCaps = jobsCfg.enabled && agent2Runner ? ["policy.check"] : [];
|
|
103
127
|
// contract 2.1: announced only when a 2.1 feature is on, so with every flag off register/hello are exactly what they were
|
|
104
|
-
const v21Caps = webhookCaps.length || llmCaps.length || incidentCaps.length ? ["contract.v2.1"] : [];
|
|
128
|
+
const v21Caps = webhookCaps.length || llmCaps.length || incidentCaps.length || policyCaps.length ? ["contract.v2.1"] : [];
|
|
105
129
|
const link = createV2Link({
|
|
106
130
|
log,
|
|
107
131
|
sessionOptions: {
|
|
@@ -111,7 +135,7 @@ export async function buildV2Link({ tenantId, agentId, signing, wsManager, getWo
|
|
|
111
135
|
signing,
|
|
112
136
|
laVersion: agentVersion(),
|
|
113
137
|
// only what this build implements: the transport, the handshake and the v1 commands on the same connection
|
|
114
|
-
capabilities: ["protocol.v2", "v1.commands", "chunk.base64", ...contentCaps, ...incidentCaps, ...webhookCaps, ...llmCaps, ...jobCaps, ...jobOptionCaps, ...v21Caps],
|
|
138
|
+
capabilities: ["protocol.v2", "v1.commands", "chunk.base64", ...contentCaps, ...incidentCaps, ...webhookCaps, ...llmCaps, ...jobCaps, ...jobOptionCaps, ...policyCaps, ...v21Caps],
|
|
115
139
|
// contract 2.1 (e): `llm.key.held` is a snapshot taken at every connect (register and hello alike): announced only while the holder has a
|
|
116
140
|
// valid, unexpired key (a revoked or expired one is not announced from the next connection on). `link` is assigned below, before any attach.
|
|
117
141
|
dynamicCapabilities: () => (llmCfg.enabled && link.llm && link.llm.holder.held ? ["llm.key.held"] : []),
|
|
@@ -148,6 +172,15 @@ export async function buildV2Link({ tenantId, agentId, signing, wsManager, getWo
|
|
|
148
172
|
if (jobsCfg.enabled) {
|
|
149
173
|
const { installJobs } = await import("./jobs/install.js");
|
|
150
174
|
installJobs({ link, tenantId: tenant, wsManager, getWorkspaceRoot, config: jobsCfg, env, log, runner, onResult: onJobResult });
|
|
175
|
+
if (agent2Runner && runner === agent2Runner) {
|
|
176
|
+
// approval.decision -> POST /local/v1/approvals/{id} of agent2, after the token check; the child process and the bridge stop with the link
|
|
177
|
+
link.session.setHandler("approval.decision", (ctx) => agent2Runner.handleApprovalDecision(ctx), { ackMs: 5_000 });
|
|
178
|
+
// policy.decision (contract 2.2): the CP's signed answer to a policy.check the bridge asked for agent2 (matched by the envelope requestId)
|
|
179
|
+
link.session.setHandler("policy.decision", (ctx) => agent2Runner.handlePolicyDecision(ctx));
|
|
180
|
+
link.agent2 = agent2Runner;
|
|
181
|
+
const closeJobs = link.close.bind(link);
|
|
182
|
+
link.close = () => { closeJobs(); agent2Runner.close().catch(() => {}); };
|
|
183
|
+
}
|
|
151
184
|
}
|
|
152
185
|
let webhookSource = null;
|
|
153
186
|
if (webhookCfg.enabled) {
|
package/src/server.js
CHANGED
|
@@ -7297,6 +7297,8 @@ async function startWebSocketTunnel(port, gatewayUrl, authToken, tenantId, agent
|
|
|
7297
7297
|
process.on('SIGINT', async () => {
|
|
7298
7298
|
isShuttingDown = true;
|
|
7299
7299
|
if (ws) ws.close();
|
|
7300
|
+
// F3-08c: stops what the v2 link started (jobs in flight, the agent2 runner and the agent2 process it supervises). Nothing when v2 is off.
|
|
7301
|
+
try { if (v2Link) v2Link.close(); } catch (_) { /* never block exit */ }
|
|
7300
7302
|
// Phase 2: graceful shutdown of legacy connectors (AC-11; bounded to 5 s).
|
|
7301
7303
|
try { await stopAll(_legacyAdapters, 5000); } catch (_) { /* never block exit */ }
|
|
7302
7304
|
// Preserve existing process model: signal trap does NOT call process.exit();
|
|
@@ -7305,6 +7307,8 @@ async function startWebSocketTunnel(port, gatewayUrl, authToken, tenantId, agent
|
|
|
7305
7307
|
process.on('SIGTERM', async () => {
|
|
7306
7308
|
isShuttingDown = true;
|
|
7307
7309
|
if (ws) ws.close();
|
|
7310
|
+
// F3-08c: stops what the v2 link started (jobs in flight, the agent2 runner and the agent2 process it supervises). Nothing when v2 is off.
|
|
7311
|
+
try { if (v2Link) v2Link.close(); } catch (_) { /* never block exit */ }
|
|
7308
7312
|
// Phase 2: graceful shutdown of legacy connectors (AC-11; bounded to 5 s).
|
|
7309
7313
|
try { await stopAll(_legacyAdapters, 5000); } catch (_) { /* never block exit */ }
|
|
7310
7314
|
// Preserve existing process model: signal trap does NOT call process.exit();
|
|
@@ -23,7 +23,8 @@ export const MAX_APPROVAL_LIFETIME_SECONDS = 15 * 60;
|
|
|
23
23
|
const b64urlDecode = (s) => Buffer.from(String(s).replace(/-/g, "+").replace(/_/g, "/"), "base64");
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
|
-
* Returns { ok: true, approvedBy, decision } or { ok: false, reason }.
|
|
26
|
+
* Returns { ok: true, approvedBy, decision, jti } or { ok: false, reason }. (`jti` is additive, F3-08c: a caller that must not consume the single-use
|
|
27
|
+
* nonce before its own action succeeds passes `nonces: null` and remembers the jti itself.)
|
|
27
28
|
* opts: { subject, keys: Map, identity, requestTenantId, nonces, now }
|
|
28
29
|
*/
|
|
29
30
|
export function verifyApprovalToken(token, { subject, keys, identity = {}, requestTenantId, nonces, now = () => Date.now() } = {}) {
|
|
@@ -52,5 +53,5 @@ export function verifyApprovalToken(token, { subject, keys, identity = {}, reque
|
|
|
52
53
|
if (requestTenantId && p.tid !== requestTenantId) return { ok: false, reason: "approval token tenant differs from the signed request" };
|
|
53
54
|
if (!p.jti || !/^[A-Za-z0-9_-]{8,128}$/.test(p.jti)) return { ok: false, reason: "approval token without jti" };
|
|
54
55
|
if (nonces && !nonces.checkAndRemember(`apr:${p.jti}`)) return { ok: false, reason: "approval token already used" };
|
|
55
|
-
return { ok: true, approvedBy: p.by || null, decision: p.decision || null };
|
|
56
|
+
return { ok: true, approvedBy: p.by || null, decision: p.decision || null, jti: p.jti };
|
|
56
57
|
}
|