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.
Files changed (58) hide show
  1. package/.github/workflows/build-byoc-image.yml +9 -11
  2. package/AGENTS.md +1 -1
  3. package/CHANGELOG.md +5 -0
  4. package/CLAUDE.md +1 -1
  5. package/cloudbuild-full.yaml +2 -1
  6. package/docs/byoc-agent-image.md +8 -7
  7. package/docs/examples/agent2-runner/docker-compose.yml +53 -0
  8. package/docs/la-agent2-runner.md +115 -0
  9. package/docs/la-jobs-and-llm.md +14 -1
  10. package/docs/la-protocol-v2.md +4 -2
  11. package/env.example +6 -0
  12. package/package.json +1 -1
  13. package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +8 -4
  14. package/src/la-protocol-v2/contract/README.md +51 -2
  15. package/src/la-protocol-v2/contract/envelope.schema.json +3 -1
  16. package/src/la-protocol-v2/contract/index.json +18 -2
  17. package/src/la-protocol-v2/contract/policy.check.schema.json +97 -0
  18. package/src/la-protocol-v2/contract/policy.decision.schema.json +119 -0
  19. package/src/la-protocol-v2/jobs/agent2/api-client.js +150 -0
  20. package/src/la-protocol-v2/jobs/agent2/approvals.js +114 -0
  21. package/src/la-protocol-v2/jobs/agent2/bridge.js +299 -0
  22. package/src/la-protocol-v2/jobs/agent2/config.js +128 -0
  23. package/src/la-protocol-v2/jobs/agent2/frames.js +61 -0
  24. package/src/la-protocol-v2/jobs/agent2/policy-check.js +170 -0
  25. package/src/la-protocol-v2/jobs/agent2/runner.js +258 -0
  26. package/src/la-protocol-v2/jobs/agent2/supervisor.js +269 -0
  27. package/src/la-protocol-v2/jobs/git-exec.js +71 -0
  28. package/src/la-protocol-v2/jobs/install.js +14 -4
  29. package/src/la-protocol-v2/jobs/manager.js +30 -16
  30. package/src/la-protocol-v2/jobs/workspace-materializer.js +244 -0
  31. package/src/la-protocol-v2/llm/client.js +133 -12
  32. package/src/la-protocol-v2/llm/config.js +2 -0
  33. package/src/la-protocol-v2/runtime.js +36 -3
  34. package/src/server.js +4 -0
  35. package/src/utils/approval-token.js +3 -2
  36. package/test/contract/la-protocol-v2/vectors/policy.check.vectors.json +908 -0
  37. package/test/contract/la-protocol-v2/vectors/policy.decision.vectors.json +826 -0
  38. package/test/la-protocol-v2/agent2-approval-discovery.test.js +114 -0
  39. package/test/la-protocol-v2/agent2-config.test.js +103 -0
  40. package/test/la-protocol-v2/agent2-orphan.test.js +118 -0
  41. package/test/la-protocol-v2/agent2-policy-check.test.js +273 -0
  42. package/test/la-protocol-v2/agent2-real-e2e.test.js +89 -0
  43. package/test/la-protocol-v2/agent2-runner.test.js +510 -0
  44. package/test/la-protocol-v2/agent2-supervisor.test.js +185 -0
  45. package/test/la-protocol-v2/agent2-workspace-door.test.js +142 -0
  46. package/test/la-protocol-v2/agent2-workspace-real-server.test.js +137 -0
  47. package/test/la-protocol-v2/contract-v21.test.js +2 -2
  48. package/test/la-protocol-v2/contract.test.js +8 -7
  49. package/test/la-protocol-v2/helpers/agent2-rig.js +96 -0
  50. package/test/la-protocol-v2/helpers/fake-agent2-cli.js +22 -0
  51. package/test/la-protocol-v2/helpers/fake-agent2.js +280 -0
  52. package/test/la-protocol-v2/helpers/fake-la-api.js +29 -0
  53. package/test/la-protocol-v2/helpers/fake-llm.js +11 -0
  54. package/test/la-protocol-v2/helpers/reference-cp.js +6 -3
  55. package/test/la-protocol-v2/helpers/spawn-agent.js +4 -3
  56. package/test/la-protocol-v2/helpers/supervisor-exit-script.js +15 -0
  57. package/test/la-protocol-v2/llm-messages.test.js +132 -0
  58. 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 = JSON.stringify({
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
- return { chat, stats, audit: auditor, get active() { return active; } };
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
  }