deepdebug-local-agent 1.10.0 → 1.11.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 (52) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +5 -0
  3. package/CLAUDE.md +1 -1
  4. package/docs/examples/agent2-runner/docker-compose.yml +53 -0
  5. package/docs/la-agent2-runner.md +115 -0
  6. package/docs/la-jobs-and-llm.md +7 -1
  7. package/docs/la-protocol-v2.md +4 -2
  8. package/env.example +6 -0
  9. package/package.json +1 -1
  10. package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +8 -4
  11. package/src/la-protocol-v2/contract/README.md +51 -2
  12. package/src/la-protocol-v2/contract/envelope.schema.json +3 -1
  13. package/src/la-protocol-v2/contract/index.json +18 -2
  14. package/src/la-protocol-v2/contract/policy.check.schema.json +97 -0
  15. package/src/la-protocol-v2/contract/policy.decision.schema.json +119 -0
  16. package/src/la-protocol-v2/jobs/agent2/api-client.js +150 -0
  17. package/src/la-protocol-v2/jobs/agent2/approvals.js +114 -0
  18. package/src/la-protocol-v2/jobs/agent2/bridge.js +299 -0
  19. package/src/la-protocol-v2/jobs/agent2/config.js +128 -0
  20. package/src/la-protocol-v2/jobs/agent2/frames.js +61 -0
  21. package/src/la-protocol-v2/jobs/agent2/policy-check.js +170 -0
  22. package/src/la-protocol-v2/jobs/agent2/runner.js +258 -0
  23. package/src/la-protocol-v2/jobs/agent2/supervisor.js +269 -0
  24. package/src/la-protocol-v2/jobs/install.js +9 -3
  25. package/src/la-protocol-v2/jobs/manager.js +17 -12
  26. package/src/la-protocol-v2/llm/client.js +133 -12
  27. package/src/la-protocol-v2/llm/config.js +2 -0
  28. package/src/la-protocol-v2/runtime.js +36 -3
  29. package/src/server.js +4 -0
  30. package/src/utils/approval-token.js +3 -2
  31. package/test/contract/la-protocol-v2/vectors/policy.check.vectors.json +908 -0
  32. package/test/contract/la-protocol-v2/vectors/policy.decision.vectors.json +826 -0
  33. package/test/la-protocol-v2/agent2-approval-discovery.test.js +114 -0
  34. package/test/la-protocol-v2/agent2-config.test.js +103 -0
  35. package/test/la-protocol-v2/agent2-orphan.test.js +118 -0
  36. package/test/la-protocol-v2/agent2-policy-check.test.js +273 -0
  37. package/test/la-protocol-v2/agent2-real-e2e.test.js +89 -0
  38. package/test/la-protocol-v2/agent2-runner.test.js +510 -0
  39. package/test/la-protocol-v2/agent2-supervisor.test.js +185 -0
  40. package/test/la-protocol-v2/agent2-workspace-door.test.js +142 -0
  41. package/test/la-protocol-v2/agent2-workspace-real-server.test.js +137 -0
  42. package/test/la-protocol-v2/contract-v21.test.js +2 -2
  43. package/test/la-protocol-v2/contract.test.js +8 -7
  44. package/test/la-protocol-v2/helpers/agent2-rig.js +96 -0
  45. package/test/la-protocol-v2/helpers/fake-agent2-cli.js +22 -0
  46. package/test/la-protocol-v2/helpers/fake-agent2.js +280 -0
  47. package/test/la-protocol-v2/helpers/fake-la-api.js +29 -0
  48. package/test/la-protocol-v2/helpers/fake-llm.js +11 -0
  49. package/test/la-protocol-v2/helpers/reference-cp.js +6 -3
  50. package/test/la-protocol-v2/helpers/spawn-agent.js +4 -3
  51. package/test/la-protocol-v2/helpers/supervisor-exit-script.js +15 -0
  52. package/test/la-protocol-v2/llm-messages.test.js +132 -0
@@ -0,0 +1,299 @@
1
+ /**
2
+ * bridge.js - the loopback door agent2 uses to reach the outside world through THIS agent (F3-08c, Local Agent half).
3
+ *
4
+ * POST /v1/messages (also /messages) the LLM, Anthropic Messages API with tools: what the packaged agent loop speaks (ClaudeApiClient), so agent2's
5
+ * `AGENT2_LLM_BASE_URL` points at `<bridge>/v1`. The bearer is a PER-JOB token minted by the runner (memory only, revoked when the job ends); the virtual
6
+ * key of the AI gateway is never given to agent2: the request is handed to the job's `ctx.llm.messages`, i.e. the LLM client of this agent, which masks
7
+ * EVERY string the model will read with the DLP (enforce, no opt-out: system, text, tool_use input, tool_result, tool descriptions), counts tokens and
8
+ * cost into the job, applies the job's budget/alias/tags and calls the AI gateway through the key holder. `model` and `metadata` in the request are
9
+ * ignored: the job's alias and this agent's tags are used. Not streaming.
10
+ * POST /v1/chat/completions (also /chat/completions) the same for an OpenAI-shaped, text-only caller (`ctx.llm.chat`); `tools` are refused there.
11
+ * POST /agent2/cp-request the PolicyGate door: `{"type":"policy.check","payload":{actionType, requestHash, toolName?, modelAlias?, incidentId?, jobId, repoRefs?}}` (what agent2's
12
+ * LocalGatewayInternalClient sends; bearer = the callback token of the running agent2), EXACTLY that shape and nothing else (any other body, e.g. the hosted
13
+ * `{method,path,body}`, is 403 path_not_allowed). The question goes to the control plane as a `policy.check` frame over the v2 channel (policy-check.js) and the answer is
14
+ * the signed `policy.decision` payload (200), in the contract's field names. This agent does NOT say who is asking (tenant, agent and `executionOrigin=local-agent` are
15
+ * derived by the control plane from the authenticated session): the payload is rebuilt from a whitelist, the job id and the repository are the RUNNING job's, and a
16
+ * tenant/origin/agent claim in it is dropped. Without a relay, or when the channel cannot answer, the answer is a 4xx/5xx DENY and agent2 blocks every governed action
17
+ * (fail closed).
18
+ * ANY /workspace/... agent2's door to this agent's own `/workspace` API (read_file, write_file, exec, ...). The LA verifies a platform signature on its API
19
+ * (LOCAL_AGENT_SIGNATURE_MODE=enforce, default) that agent2 cannot make, so THIS bridge, which agent2 can only reach with the callback token, forwards the request
20
+ * to the API on agent2's behalf and vouches for it with the process-local internal marker (the same one the WS tunnel uses for its own hops), the tenant
21
+ * and the running job's workspace; the API's X-Agent-Token is added here, so agent2 never holds it. The signature check stays ON: a request that does
22
+ * not come through this bridge is still refused 401. Only `/workspace/` paths, no `..`, no encoded characters, one body cap.
23
+ *
24
+ * Perimeter: bound to 127.0.0.1, requests from a loopback socket only, a `Host` that is loopback, no `Origin` (a browser), constant-time token comparison,
25
+ * body cap, JSON only. What it never does: log a body, an answer, a token or a path; echo a request value in an error (codes only).
26
+ */
27
+ import http from "node:http";
28
+ import crypto from "node:crypto";
29
+ import path from "node:path";
30
+
31
+ const LLM_STATUS = Object.freeze({
32
+ bad_request: 400, dlp_blocked: 403, dlp_denied: 403, dlp_not_enforced: 403, model_not_allowed: 403, prompt_too_large: 413,
33
+ no_key: 401, key_expired: 401, key_rejected: 401, budget_exceeded: 402, rate_limited: 429, busy: 429, timeout: 504,
34
+ network_error: 502, upstream_error: 502, response_too_large: 502, bad_response: 502, cancelled: 409, internal: 500,
35
+ });
36
+ const ROLES = new Set(["system", "user", "assistant"]);
37
+ export const POLICY_CHECK_TYPE = "policy.check";
38
+
39
+ function safeEqual(a, b) {
40
+ const x = crypto.createHash("sha256").update(String(a)).digest();
41
+ const y = crypto.createHash("sha256").update(String(b)).digest();
42
+ return crypto.timingSafeEqual(x, y) && String(a).length === String(b).length;
43
+ }
44
+
45
+ const isLoopbackAddr = (a) => { const s = String(a || "").replace(/^::ffff:/, ""); return s === "::1" || /^127\./.test(s); };
46
+ const isLoopbackHost = (h) => /^(127\.\d{1,3}\.\d{1,3}\.\d{1,3}|localhost|\[::1\])(:\d{1,5})?$/.test(String(h || ""));
47
+
48
+ /**
49
+ * @param {object} o
50
+ * @param {number} [o.port] 0 = any free port
51
+ * @param {number} o.maxBodyBytes
52
+ * @param {(args:{check:object, job:{jobId:string, workspaceId:string, alias:string, incidentId?:string, repoRef?:object}}) => Promise<{status:number, json:object}>} [o.policyRelay] null = no relay (503)
53
+ * @param {string} o.tenantId the ONE tenant this agent serves (vouched for in the /workspace hop; never put in a policy request)
54
+ * @param {{url:string, headers:(job:object)=>object, maxBodyBytes?:number, timeoutMs?:number}|null} [o.workspace] the LA's own /workspace API: base URL (loopback) and the headers
55
+ * that make the hop authentic (internal marker + X-Agent-Token); null = the /workspace door is closed (404)
56
+ */
57
+ export function createAgent2Bridge({ port = 0, maxBodyBytes, policyRelay = null, tenantId, log = console, now = Date.now, workspace = null }) {
58
+ const jobs = new Map(); // token -> { jobId, alias, llm, workspaceId, incidentId?, root?, inflight }
59
+ const byJob = new Map(); // jobId -> token
60
+ let callbackToken = null;
61
+ let server = null;
62
+ let listening = null;
63
+ let baseUrl = null;
64
+ const stats = { llmCalls: 0, llmErrors: 0, llmUnauthorized: 0, relayCalls: 0, relayRefused: 0, relayErrors: 0, wsForwarded: 0, wsRefused: 0, wsErrors: 0, rejectedPerimeter: 0, byCode: {} };
65
+ const bump = (k) => { stats.byCode[k] = (stats.byCode[k] || 0) + 1; };
66
+ const warn = (m) => (log.warn || log.log).call(log, m);
67
+
68
+ const send = (res, status, json, headers = {}) => {
69
+ const text = JSON.stringify(json);
70
+ res.writeHead(status, { "content-type": "application/json", "content-length": Buffer.byteLength(text), "cache-control": "no-store", ...headers });
71
+ res.end(text);
72
+ };
73
+ const fail = (res, status, code) => { bump(code); send(res, status, { error: { message: code, type: "vh_la_error", code } }, status === 413 ? { connection: "close" } : {}); };
74
+
75
+ function readBody(req) {
76
+ return new Promise((resolve, reject) => {
77
+ const chunks = [];
78
+ let total = 0;
79
+ let over = false;
80
+ req.on("data", (c) => {
81
+ total += c.length;
82
+ if (total > maxBodyBytes) {
83
+ if (!over) { over = true; chunks.length = 0; reject(Object.assign(new Error("too_large"), { code: "payload_too_large" })); } // nothing is kept; the rest is read and thrown away so the 413 can be delivered
84
+ return;
85
+ }
86
+ if (!over) chunks.push(c);
87
+ });
88
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
89
+ req.on("error", () => reject(Object.assign(new Error("read"), { code: "bad_request" })));
90
+ });
91
+ }
92
+
93
+ const bearer = (req) => { const h = req.headers.authorization; return typeof h === "string" && /^Bearer\s+\S{16,512}$/i.test(h) ? h.replace(/^Bearer\s+/i, "") : null; };
94
+
95
+ function findJob(token) {
96
+ if (!token) return null;
97
+ for (const [t, rec] of jobs) if (safeEqual(t, token)) return rec; // compares against every token: no early exit on a prefix
98
+ return null;
99
+ }
100
+
101
+ async function llm(req, res) {
102
+ const rec = findJob(bearer(req));
103
+ if (!rec) { stats.llmUnauthorized++; return fail(res, 401, "unauthorized"); }
104
+ let body;
105
+ try {
106
+ if (!/^application\/json\b/i.test(String(req.headers["content-type"] || ""))) return fail(res, 415, "unsupported_media_type");
107
+ body = JSON.parse(await readBody(req));
108
+ } catch (e) { return fail(res, e && e.code === "payload_too_large" ? 413 : 400, e && e.code === "payload_too_large" ? "payload_too_large" : "bad_request"); }
109
+ if (!body || typeof body !== "object" || Array.isArray(body)) return fail(res, 400, "bad_request");
110
+ if (body.tools !== undefined || body.functions !== undefined || body.tool_choice !== undefined || body.function_call !== undefined) return fail(res, 400, "tools_unsupported");
111
+ const msgs = body.messages;
112
+ if (!Array.isArray(msgs) || msgs.length < 1 || !msgs.every((m) => m && typeof m === "object" && ROLES.has(m.role) && typeof m.content === "string")) return fail(res, 400, "bad_request");
113
+ if (body.n !== undefined && body.n !== 1) return fail(res, 400, "bad_request");
114
+ const stream = body.stream === true;
115
+ const maxTokens = Number.isSafeInteger(body.max_tokens) && body.max_tokens > 0 ? body.max_tokens : Number.isSafeInteger(body.max_completion_tokens) && body.max_completion_tokens > 0 ? body.max_completion_tokens : undefined;
116
+ const temperature = typeof body.temperature === "number" && Number.isFinite(body.temperature) && body.temperature >= 0 && body.temperature <= 2 ? body.temperature : undefined;
117
+ if (rec.inflight >= 2) return fail(res, 429, "busy");
118
+ rec.inflight++;
119
+ stats.llmCalls++;
120
+ try {
121
+ const r = await rec.llm.chat({ messages: msgs.map((m) => ({ role: m.role, content: m.content })), ...(maxTokens ? { maxTokens } : {}), ...(temperature !== undefined ? { temperature } : {}) });
122
+ const usage = { prompt_tokens: r.usage.promptTokens, completion_tokens: r.usage.completionTokens, total_tokens: r.usage.promptTokens + r.usage.completionTokens };
123
+ const created = Math.floor(now() / 1000);
124
+ const id = `chatcmpl-${crypto.randomBytes(8).toString("hex")}`;
125
+ const model = rec.alias;
126
+ const finish = r.finishReason || "stop";
127
+ if (!stream) return send(res, 200, { id, object: "chat.completion", created, model, choices: [{ index: 0, message: { role: "assistant", content: r.text }, finish_reason: finish }], usage });
128
+ res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store" });
129
+ res.write(`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { role: "assistant", content: r.text }, finish_reason: null }] })}\n\n`);
130
+ res.write(`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: {}, finish_reason: finish }], usage })}\n\n`);
131
+ res.end("data: [DONE]\n\n");
132
+ } catch (e) {
133
+ stats.llmErrors++;
134
+ const code = e && typeof e.code === "string" && Object.hasOwn(LLM_STATUS, e.code) ? e.code : "internal";
135
+ fail(res, LLM_STATUS[code], code);
136
+ } finally {
137
+ rec.inflight--;
138
+ }
139
+ }
140
+
141
+ /** Anthropic Messages API. Errors keep the Messages shape ({type:"error", error:{type, message}}) with a code as the message. */
142
+ async function messagesRoute(req, res) {
143
+ const anthropicFail = (status, code) => { bump(code); send(res, status, { type: "error", error: { type: "vh_la_error", message: code } }, status === 413 ? { connection: "close" } : {}); };
144
+ const rec = findJob(bearer(req));
145
+ if (!rec) { stats.llmUnauthorized++; return anthropicFail(401, "unauthorized"); }
146
+ let body;
147
+ try {
148
+ if (!/^application\/json\b/i.test(String(req.headers["content-type"] || ""))) return anthropicFail(415, "unsupported_media_type");
149
+ body = JSON.parse(await readBody(req));
150
+ } catch (e) { return e && e.code === "payload_too_large" ? anthropicFail(413, "payload_too_large") : anthropicFail(400, "bad_request"); }
151
+ if (!body || typeof body !== "object" || Array.isArray(body)) return anthropicFail(400, "bad_request");
152
+ if (body.stream === true) return anthropicFail(400, "stream_unsupported");
153
+ if (!Array.isArray(body.messages)) return anthropicFail(400, "bad_request");
154
+ const maxTokens = Number.isSafeInteger(body.max_tokens) && body.max_tokens > 0 ? body.max_tokens : undefined;
155
+ const temperature = typeof body.temperature === "number" && Number.isFinite(body.temperature) ? body.temperature : undefined;
156
+ if (rec.inflight >= 2) return anthropicFail(429, "busy");
157
+ rec.inflight++;
158
+ stats.llmCalls++;
159
+ try {
160
+ const r = await rec.llm.messages({ messages: body.messages, ...(body.system !== undefined ? { system: body.system } : {}), ...(body.tools !== undefined ? { tools: body.tools } : {}),
161
+ ...(body.tool_choice !== undefined ? { toolChoice: body.tool_choice } : {}), ...(maxTokens ? { maxTokens } : {}), ...(temperature !== undefined ? { temperature } : {}) });
162
+ send(res, 200, { ...r.message, model: rec.alias });
163
+ } catch (e) {
164
+ stats.llmErrors++;
165
+ const code = e && typeof e.code === "string" && Object.hasOwn(LLM_STATUS, e.code) ? e.code : "internal";
166
+ anthropicFail(LLM_STATUS[code], code);
167
+ } finally {
168
+ rec.inflight--;
169
+ }
170
+ }
171
+
172
+ async function cpRequest(req, res) {
173
+ if (!callbackToken || !bearer(req) || !safeEqual(bearer(req), callbackToken)) { stats.relayRefused++; return fail(res, 401, "unauthorized"); }
174
+ let body;
175
+ try { body = JSON.parse(await readBody(req)); } catch (e) { return fail(res, e && e.code === "payload_too_large" ? 413 : 400, e && e.code === "payload_too_large" ? "payload_too_large" : "bad_request"); }
176
+ if (!body || typeof body !== "object" || Array.isArray(body)) return fail(res, 400, "bad_request");
177
+ if (Object.keys(body).length !== 2 || body.type !== POLICY_CHECK_TYPE || !body.payload || typeof body.payload !== "object" || Array.isArray(body.payload)) { stats.relayRefused++; return fail(res, 403, "path_not_allowed"); }
178
+ const active = [...jobs.values()][0];
179
+ if (!active) { stats.relayRefused++; return fail(res, 409, "no_active_job"); }
180
+ if (!policyRelay) { stats.relayRefused++; return fail(res, 503, "policy_relay_unavailable"); }
181
+ stats.relayCalls++;
182
+ try {
183
+ // the relay rebuilds the question from a whitelist and pins the job, its repository and its alias: agent2's own claims about who it is are never carried
184
+ const out = await policyRelay({ check: body.payload, job: { jobId: active.jobId, workspaceId: active.workspaceId, alias: active.alias, ...(active.incidentId ? { incidentId: active.incidentId } : {}),
185
+ ...(active.repoRef ? { repoRef: active.repoRef } : {}) } });
186
+ const status = Number.isInteger(out?.status) && out.status >= 200 && out.status < 600 ? out.status : 502;
187
+ send(res, status, out?.json && typeof out.json === "object" ? out.json : { error: "bad_response" });
188
+ } catch { stats.relayErrors++; fail(res, 502, "policy_relay_failed"); }
189
+ }
190
+
191
+ const WS_PATH = /^\/workspace(?:\/[A-Za-z0-9._~-]+)+\/?$/;
192
+ const WS_METHODS = new Set(["GET", "POST", "PUT", "DELETE"]);
193
+ const FORWARD_REQ_HEADERS = ["content-type", "accept"];
194
+ const sameRoot = (a, b) => typeof a === "string" && typeof b === "string" && a.trim() !== "" && path.resolve(a.trim()) === path.resolve(b);
195
+ const FORWARD_RES_HEADERS = ["content-type", "cache-control"];
196
+
197
+ /** agent2 -> this agent's /workspace API, vouched for by this agent (see the header). */
198
+ function workspaceRoute(req, res) {
199
+ const presented = typeof req.headers["x-agent-token"] === "string" ? req.headers["x-agent-token"] : bearer(req);
200
+ if (!workspace) { stats.wsRefused++; return fail(res, 404, "not_found"); }
201
+ if (!callbackToken || !presented || !safeEqual(presented, callbackToken)) { stats.wsRefused++; return fail(res, 401, "unauthorized"); }
202
+ if (!WS_METHODS.has(req.method)) { stats.wsRefused++; return fail(res, 405, "method_not_allowed"); }
203
+ const raw = String(req.url || "");
204
+ const q = raw.indexOf("?");
205
+ const pathOnly = q >= 0 ? raw.slice(0, q) : raw;
206
+ const query = q >= 0 ? raw.slice(q) : "";
207
+ if (!WS_PATH.test(pathOnly) || pathOnly.split("/").some((seg) => seg === "." || seg === "..") || /[\u0000-\u001f\u007f#]/.test(query)) { stats.wsRefused++; return fail(res, 403, "path_not_allowed"); }
208
+ const active = [...jobs.values()][0];
209
+ if (!active) { stats.wsRefused++; return fail(res, 409, "no_active_job"); }
210
+ // agent2 acts on the workspace of ITS job: the root header it sends must be that root (it is added when it sends none); the id header is the job's, not agent2's
211
+ const claimed = req.headers["x-workspace-root"];
212
+ if (claimed !== undefined && !sameRoot(Array.isArray(claimed) ? claimed[0] : claimed, active.root)) { stats.wsRefused++; return fail(res, 403, "workspace_mismatch"); }
213
+ const cap = workspace.maxBodyBytes || 16 * 1024 * 1024;
214
+ const declared = Number(req.headers["content-length"]);
215
+ if (Number.isFinite(declared) && declared > cap) { stats.wsRefused++; return fail(res, 413, "payload_too_large"); }
216
+ const chunks = [];
217
+ let total = 0;
218
+ let over = false;
219
+ req.on("data", (c) => { total += c.length; if (total > cap) { over = true; chunks.length = 0; } else if (!over) chunks.push(c); });
220
+ req.on("error", () => { try { fail(res, 400, "bad_request"); } catch { /* gone */ } });
221
+ req.on("end", () => {
222
+ if (over) { stats.wsRefused++; return fail(res, 413, "payload_too_large"); }
223
+ const body = Buffer.concat(chunks);
224
+ const headers = {};
225
+ for (const h of FORWARD_REQ_HEADERS) if (typeof req.headers[h] === "string") headers[h] = req.headers[h];
226
+ if (active.root) headers["x-workspace-root"] = active.root;
227
+ headers["x-workspace-id"] = active.workspaceId;
228
+ Object.assign(headers, workspace.headers({ jobId: active.jobId, workspaceId: active.workspaceId, tenantId })); // internal marker, tenant, workspace, X-Agent-Token: ours, not agent2's
229
+ if (body.length > 0 || req.method === "POST" || req.method === "PUT") headers["content-length"] = String(body.length);
230
+ stats.wsForwarded++;
231
+ let up;
232
+ try {
233
+ const u = new URL(workspace.url);
234
+ up = http.request({ host: u.hostname.replace(/^\[|\]$/g, ""), port: u.port || 80, method: req.method, path: raw, headers, agent: false, timeout: workspace.timeoutMs || 15 * 60_000 }, (r) => {
235
+ const out = {};
236
+ for (const h of FORWARD_RES_HEADERS) if (typeof r.headers[h] === "string") out[h] = r.headers[h];
237
+ if (typeof r.headers["content-length"] === "string") out["content-length"] = r.headers["content-length"];
238
+ res.writeHead(r.statusCode || 502, out);
239
+ r.pipe(res);
240
+ r.on("error", () => { try { res.destroy(); } catch { /* gone */ } });
241
+ });
242
+ } catch { stats.wsErrors++; return fail(res, 502, "workspace_unreachable"); }
243
+ up.on("timeout", () => { up.destroy(); });
244
+ up.on("error", () => { stats.wsErrors++; if (!res.headersSent) fail(res, 502, "workspace_unreachable"); else { try { res.destroy(); } catch { /* gone */ } } });
245
+ res.on("close", () => { if (!up.destroyed) up.destroy(); });
246
+ up.end(body);
247
+ });
248
+ }
249
+
250
+ function handler(req, res) {
251
+ // perimeter first: nothing below runs for a request that does not come from this host's own processes
252
+ if (!isLoopbackAddr(req.socket.remoteAddress) || !isLoopbackHost(req.headers.host) || req.headers.origin !== undefined) { stats.rejectedPerimeter++; return fail(res, 403, "forbidden"); }
253
+ const url = String(req.url || "").split("?")[0];
254
+ if (req.method === "POST" && (url === "/v1/chat/completions" || url === "/chat/completions")) return void llm(req, res).catch(() => { try { fail(res, 500, "internal"); } catch { /* gone */ } });
255
+ if (req.method === "POST" && (url === "/v1/messages" || url === "/messages")) return void messagesRoute(req, res).catch(() => { try { fail(res, 500, "internal"); } catch { /* gone */ } });
256
+ if (req.method === "POST" && url === "/agent2/cp-request") return void cpRequest(req, res).catch(() => { try { fail(res, 500, "internal"); } catch { /* gone */ } });
257
+ if (url === "/workspace" || url.startsWith("/workspace/")) return workspaceRoute(req, res);
258
+ return fail(res, 404, "not_found");
259
+ }
260
+
261
+ return {
262
+ stats,
263
+ get baseUrl() { return baseUrl; },
264
+ get activeJobs() { return jobs.size; },
265
+ /** Starts listening once (idempotent). */
266
+ listen() {
267
+ if (listening) return listening;
268
+ listening = new Promise((resolve, reject) => {
269
+ server = http.createServer(handler);
270
+ server.requestTimeout = 180_000;
271
+ server.headersTimeout = 10_000;
272
+ server.on("error", (e) => { listening = null; reject(e); });
273
+ server.listen(port, "127.0.0.1", () => { baseUrl = `http://127.0.0.1:${server.address().port}`; resolve({ baseUrl, port: server.address().port }); });
274
+ });
275
+ return listening;
276
+ },
277
+ setCallbackToken(t) { callbackToken = typeof t === "string" && t.length >= 32 ? t : null; },
278
+ /** The token agent2 presents as its "LLM key" for this job (random, memory only). `llm` = the job's ctx.llm ({chat, messages}). */
279
+ registerJob({ jobId, alias, llm, workspaceId, incidentId, root, repoRef }) {
280
+ const token = crypto.randomBytes(32).toString("base64url");
281
+ const old = byJob.get(jobId);
282
+ if (old) jobs.delete(old);
283
+ jobs.set(token, { jobId, alias, llm, workspaceId, ...(incidentId ? { incidentId } : {}), ...(root ? { root } : {}), ...(repoRef ? { repoRef } : {}), inflight: 0 });
284
+ byJob.set(jobId, token);
285
+ return token;
286
+ },
287
+ revokeJob(jobId) {
288
+ const t = byJob.get(jobId);
289
+ if (t) jobs.delete(t);
290
+ byJob.delete(jobId);
291
+ },
292
+ async close() {
293
+ jobs.clear(); byJob.clear(); callbackToken = null;
294
+ const s = server;
295
+ server = null; listening = null; baseUrl = null;
296
+ if (s) { if (s.closeAllConnections) s.closeAllConnections(); await new Promise((r) => s.close(() => r())); }
297
+ },
298
+ };
299
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * config.js - LOCAL_AGENT_RUNNER=agent2 and the limits of the agent2 runner (F3-08c, Local Agent half).
3
+ *
4
+ * unset / anything else (default) nothing of this folder is loaded: no process, no listener, no capability.
5
+ * agent2 the runner of the job.assign receiver is the packaged agent2 (`visionhammer-agents`, profile local-agent) that runs
6
+ * NEXT TO this agent. It needs, all of them: LOCAL_AGENT_PROTOCOL_V2=true, LOCAL_AGENT_JOBS=true, LOCAL_AGENT_LLM_KEY=true,
7
+ * LOCAL_AGENT_LLM_CLIENT=true and LOCAL_AGENT_DLP_MODE=enforce (runtime.js refuses, and says so, when one is missing), plus
8
+ * ONE way to reach agent2:
9
+ * LOCAL_AGENT_AGENT2_CMD this agent starts and supervises it (JSON array `["java","-jar","/opt/agent2.jar"]`, or a plain
10
+ * command split on spaces; never run through a shell)
11
+ * LOCAL_AGENT_AGENT2_URL agent2 already runs (sidecar / compose); loopback http only. Needs LOCAL_AGENT_AGENT2_TOKEN
12
+ * (the AGENT2_LOCAL_API_TOKEN it was started with, >= 32 chars) and LOCAL_AGENT_AGENT2_BRIDGE_PORT
13
+ * (the port of the loopback bridge; the sidecar's AGENT2_LLM_BASE_URL points at it)
14
+ *
15
+ * Every number is clamped: a bad value falls back to the default, never to "unlimited".
16
+ */
17
+ export const RUNNER_FLAG = "LOCAL_AGENT_RUNNER";
18
+
19
+ const KIB = 1024;
20
+ const MIB = 1024 * KIB;
21
+
22
+ function int(raw, def, min, max) {
23
+ const n = Number.parseInt(String(raw ?? ""), 10);
24
+ return Number.isFinite(n) && n >= min && n <= max ? n : def;
25
+ }
26
+
27
+ /** True when LOCAL_AGENT_RUNNER selects the agent2 runner. */
28
+ export function agent2RunnerRequested(env = process.env) {
29
+ return String(env[RUNNER_FLAG] ?? "").trim().toLowerCase() === "agent2";
30
+ }
31
+
32
+ /** `["a","b"]` (JSON) or `a b` (split on whitespace). Never a shell string: no quoting, no expansion. */
33
+ export function parseCommand(raw) {
34
+ const s = String(raw ?? "").trim();
35
+ if (!s) return null;
36
+ if (s.startsWith("[")) {
37
+ try {
38
+ const a = JSON.parse(s);
39
+ return Array.isArray(a) && a.length > 0 && a.length <= 64 && a.every((x) => typeof x === "string" && x.length > 0 && x.length <= 4096 && !x.includes("\0")) ? a : null;
40
+ } catch { return null; }
41
+ }
42
+ const parts = s.split(/\s+/).filter(Boolean);
43
+ return parts.length > 0 && parts.length <= 64 && parts.every((x) => !x.includes("\0")) ? parts : null;
44
+ }
45
+
46
+ const LOOPBACK_HOST = /^(127\.\d{1,3}\.\d{1,3}\.\d{1,3}|localhost|\[::1\])$/;
47
+
48
+ /** Loopback `http://host:port` (no path, no credentials) or null. */
49
+ export function loopbackBase(raw) {
50
+ try {
51
+ const u = new URL(String(raw ?? "").trim());
52
+ if (u.protocol !== "http:" || u.username || u.password || !LOOPBACK_HOST.test(u.hostname) || (u.pathname !== "/" && u.pathname !== "") || u.search || u.hash) return null;
53
+ return `http://${u.host}`;
54
+ } catch { return null; }
55
+ }
56
+
57
+ /** `https://host[:port][/base]` or loopback `http`, no credentials, no query. */
58
+ export function relayBase(raw) {
59
+ try {
60
+ const u = new URL(String(raw ?? "").trim());
61
+ if (u.username || u.password || u.search || u.hash) return null;
62
+ if (u.protocol === "https:" || (u.protocol === "http:" && LOOPBACK_HOST.test(u.hostname))) return `${u.protocol}//${u.host}${u.pathname.replace(/\/+$/, "")}`;
63
+ return null;
64
+ } catch { return null; }
65
+ }
66
+
67
+ const PASSTHROUGH_NAME = /^[A-Z][A-Z0-9_]{1,63}$/;
68
+
69
+ /**
70
+ * @returns {{enabled:false, error?:string} | {enabled:true, mode:"spawn"|"attach", ...}}
71
+ */
72
+ export function resolveAgent2RunnerConfig(env = process.env, warn = console.warn) {
73
+ const raw = String(env[RUNNER_FLAG] ?? "").trim().toLowerCase();
74
+ if (raw !== "agent2") {
75
+ if (raw && !["smoke", "false", "0", "no", "off"].includes(raw)) warn(`[la-agent2] unrecognised ${RUNNER_FLAG} value; the agent2 runner stays off`);
76
+ return { enabled: false };
77
+ }
78
+ const cmd = parseCommand(env.LOCAL_AGENT_AGENT2_CMD);
79
+ const urlRaw = String(env.LOCAL_AGENT_AGENT2_URL ?? "").trim();
80
+ if (String(env.LOCAL_AGENT_AGENT2_CMD ?? "").trim() && !cmd) return { enabled: false, error: "LOCAL_AGENT_AGENT2_CMD is not a JSON array of strings or a plain command" };
81
+ if (cmd && urlRaw) return { enabled: false, error: "set LOCAL_AGENT_AGENT2_CMD (this agent starts agent2) OR LOCAL_AGENT_AGENT2_URL (agent2 already runs), not both" };
82
+ if (!cmd && !urlRaw) return { enabled: false, error: "LOCAL_AGENT_RUNNER=agent2 needs LOCAL_AGENT_AGENT2_CMD or LOCAL_AGENT_AGENT2_URL" };
83
+
84
+ const pass = [];
85
+ for (const n of String(env.LOCAL_AGENT_AGENT2_ENV_PASSTHROUGH ?? "").split(/[,\s]+/).filter(Boolean)) {
86
+ if (PASSTHROUGH_NAME.test(n)) pass.push(n); else warn("[la-agent2] LOCAL_AGENT_AGENT2_ENV_PASSTHROUGH: an entry is not a plain variable name; ignored");
87
+ }
88
+ // F3-08c (Edu 2026-11-04): the PolicyGate is asked over the v2 channel (`policy.check`), not over HTTP with a platform bearer. The old pair is ignored, loudly.
89
+ if (String(env.LOCAL_AGENT_AGENT2_POLICY_URL ?? "").trim() || String(env.LOCAL_AGENT_AGENT2_POLICY_TOKEN ?? "").trim()) {
90
+ warn("[la-agent2] LOCAL_AGENT_AGENT2_POLICY_URL / _POLICY_TOKEN are ignored: the PolicyGate is asked over the v2 channel (policy.check). No platform credential belongs on this machine");
91
+ }
92
+ const common = {
93
+ enabled: true,
94
+ passthrough: pass,
95
+ laUrl: loopbackBase(env.LOCAL_AGENT_AGENT2_LA_URL) || `http://127.0.0.1:${int(env.LOCAL_AGENT_PORT ?? env.PORT, 5055, 1, 65535)}`, // this agent's own API: the bridge forwards agent2's /workspace calls to it, signed for agent2
96
+ maxWorkspaceBodyBytes: int(env.LOCAL_AGENT_AGENT2_WORKSPACE_MAX_BODY_BYTES, 16 * MIB, 4 * KIB, 256 * MIB), // one /workspace request body (write_file, safe-patch, ...)
97
+ workspaceTimeoutMs: int(env.LOCAL_AGENT_AGENT2_WORKSPACE_TIMEOUT_MS, 15 * 60_000, 1_000, 3_600_000), // one /workspace call (exec and tests can be long)
98
+ policyCheckTimeoutMs: int(env.LOCAL_AGENT_AGENT2_POLICY_TIMEOUT_MS, 10_000, 1_000, 60_000), // policy.check -> policy.decision; none in time = DENY (the contract says 10 s)
99
+ pidFile: String(env.LOCAL_AGENT_AGENT2_PIDFILE ?? "").trim() || null, // spawn mode: where the child's pid is kept so a restart of THIS agent can end an orphan (default: ~/.deepdebug/agent2-runner.pid)
100
+ bridgePort: int(env.LOCAL_AGENT_AGENT2_BRIDGE_PORT, 0, 0, 65535), // 0 = any free port (spawn mode); attach mode needs a fixed one
101
+ startTimeoutMs: int(env.LOCAL_AGENT_AGENT2_START_TIMEOUT_MS, 90_000, 1_000, 600_000),
102
+ healthIntervalMs: int(env.LOCAL_AGENT_AGENT2_HEALTH_INTERVAL_MS, 15_000, 100, 300_000),
103
+ healthFailures: int(env.LOCAL_AGENT_AGENT2_HEALTH_FAILURES, 3, 1, 20), // consecutive failed probes before the process is killed (spawn) / marked down (attach)
104
+ maxRestarts: int(env.LOCAL_AGENT_AGENT2_MAX_RESTARTS, 3, 0, 20), // restarts inside restartWindowMs; beyond it agent2 is `unavailable` until the window passes
105
+ restartWindowMs: int(env.LOCAL_AGENT_AGENT2_RESTART_WINDOW_MS, 600_000, 1_000, 86_400_000),
106
+ restartBackoffMs: int(env.LOCAL_AGENT_AGENT2_RESTART_BACKOFF_MS, 1_000, 0, 60_000),
107
+ maxHeapMb: int(env.LOCAL_AGENT_AGENT2_MAX_HEAP_MB, 0, 0, 262_144), // spawn: JAVA_TOOL_OPTIONS=-Xmx<n>m (0 = leave it to the command)
108
+ maxRssBytes: int(env.LOCAL_AGENT_AGENT2_MAX_RSS_BYTES, 0, 0, Number.MAX_SAFE_INTEGER), // spawn: killed (and restarted at the next job) above it; 0 = off
109
+ stopGraceMs: int(env.LOCAL_AGENT_AGENT2_STOP_GRACE_MS, 5_000, 100, 60_000),
110
+ requestTimeoutMs: int(env.LOCAL_AGENT_AGENT2_REQUEST_TIMEOUT_MS, 10_000, 100, 120_000), // one call to the local API (not the event stream)
111
+ streamReconnects: int(env.LOCAL_AGENT_AGENT2_STREAM_RECONNECTS, 3, 0, 20),
112
+ maxBodyBytes: int(env.LOCAL_AGENT_AGENT2_MAX_BODY_BYTES, MIB, 4 * KIB, 8 * MIB), // bridge: one LLM request body
113
+ maxRequestTextBytes: int(env.LOCAL_AGENT_AGENT2_MAX_REQUEST_BYTES, 64 * KIB, KIB, MIB), // the text handed to agent2 as the job's request
114
+ maxRefFileBytes: int(env.LOCAL_AGENT_AGENT2_MAX_REF_FILE_BYTES, 32 * KIB, KIB, MIB),
115
+ approvalWaitSeconds: int(env.LOCAL_AGENT_AGENT2_APPROVAL_WAIT_SECONDS, 0, 0, 3_600), // 0 = agent2's own default (600)
116
+ cancelWaitMs: int(env.LOCAL_AGENT_AGENT2_CANCEL_WAIT_MS, 8_000, 1_000, 120_000), // after a cancel/timeout: how long the runner waits for agent2 to report `cancelled` before it lets go of the job (keep it below LOCAL_AGENT_JOBS_CANCEL_GRACE_MS, 10 s: the next job needs agent2 free)
117
+ auditForwardRetries: int(env.LOCAL_AGENT_AGENT2_AUDIT_RETRIES, 10, 0, 100),
118
+ auditForwardRetryMs: int(env.LOCAL_AGENT_AGENT2_AUDIT_RETRY_MS, 500, 10, 10_000),
119
+ };
120
+ if (cmd) return { ...common, mode: "spawn", cmd, apiPort: int(env.LOCAL_AGENT_AGENT2_PORT, 8765, 1, 65535) };
121
+ const base = loopbackBase(urlRaw);
122
+ if (!base) return { enabled: false, error: "LOCAL_AGENT_AGENT2_URL must be a loopback http URL (agent2 runs next to this agent: same host, same pod or same network namespace)" };
123
+ const token = String(env.LOCAL_AGENT_AGENT2_TOKEN ?? "").trim();
124
+ if (token.length < 32) return { enabled: false, error: "LOCAL_AGENT_AGENT2_URL needs LOCAL_AGENT_AGENT2_TOKEN (>= 32 chars, the AGENT2_LOCAL_API_TOKEN agent2 was started with)" };
125
+ if (!common.bridgePort) return { enabled: false, error: "LOCAL_AGENT_AGENT2_URL needs LOCAL_AGENT_AGENT2_BRIDGE_PORT (the fixed loopback port agent2's AGENT2_LLM_BASE_URL and AGENT2_LA_CALLBACK_URL point at)" };
126
+ const callbackToken = String(env.LOCAL_AGENT_AGENT2_CALLBACK_TOKEN ?? "").trim(); // the AGENT2_LA_CALLBACK_TOKEN the sidecar was started with (the PolicyGate relay); without it the relay answers 401
127
+ return { ...common, mode: "attach", url: base, token, callbackToken: callbackToken.length >= 32 ? callbackToken : null };
128
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * frames.js - what the Local Agent accepts from agent2's event stream (`GET /local/v1/jobs/{id}/events`, F3-08b frames) before anything goes to the control
3
+ * plane. agent2 is a separate process: its frames are DATA, checked against the same closed shapes the contract has, from the same whitelists. A frame of
4
+ * another tenant or job is dropped (and counted); a field that is not in the whitelist is dropped; a value out of range is dropped. The result is a plain
5
+ * object that cannot carry text, a path, a model name or a prompt.
6
+ *
7
+ * One thing is kept for THIS agent only and never goes to the control plane (its schema is closed): the `approvalId` agent2 puts in `approval_requested` (PR #308), returned beside
8
+ * the payload as `approvalHint` so the approval decider can find the approval a later `approval.decision` is about.
9
+ *
10
+ * What is NOT taken from agent2: token counts and cost (this agent counts them itself, on the LLM bridge - agent2 is not the authority on its own spending).
11
+ */
12
+ import { JOB_ERROR_CODES, resultPayload } from "../events.js";
13
+
14
+ const STATES = new Set(["queued", "running", "waiting_approval", "succeeded", "failed", "cancelled"]);
15
+ const TERMINAL = new Set(["succeeded", "failed", "cancelled"]);
16
+ const PHASES = new Set(["init", "context", "plan", "edit", "validate", "pr", "done"]);
17
+ const AUDIT_ACTIONS = new Set(["policy_decision", "approval_requested"]); // the others (redaction, content_served, config_applied) are this agent's to say
18
+ const AUDIT_DECISIONS = new Set(["allow", "deny", "require_human", "n/a"]);
19
+ const SHORT_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/;
20
+ const SHA256 = /^[0-9a-f]{64}$/;
21
+ const UUID_LOWER = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
22
+ const isCount = (v) => Number.isSafeInteger(v) && v >= 0;
23
+
24
+ /**
25
+ * @returns {null | {violation:"tenant"|"job"} | {type:"job.event"|"job.result"|"audit.event", seq:number, payload:object, approvalHint?:{approvalId:string, requestHash:string}}}
26
+ */
27
+ export function normalizeFrame(frame, { tenantId, jobId }) {
28
+ if (!frame || typeof frame !== "object" || Array.isArray(frame)) return null;
29
+ if (!Number.isSafeInteger(frame.seq) || frame.seq < 1) return null;
30
+ if (typeof frame.tenantId === "string" && frame.tenantId.toLowerCase() !== String(tenantId).toLowerCase()) return { violation: "tenant" };
31
+ const p = frame.payload;
32
+ if (!p || typeof p !== "object" || Array.isArray(p)) return null;
33
+ if (frame.type === "job.event") {
34
+ if (p.jobId !== jobId) return { violation: "job" };
35
+ if (!STATES.has(p.state)) return null;
36
+ const out = { jobId, state: p.state };
37
+ if (PHASES.has(p.phase)) out.phase = p.phase;
38
+ if (Number.isInteger(p.iteration) && p.iteration >= 0 && p.iteration <= 1000) out.iteration = p.iteration;
39
+ for (const k of ["filesRead", "filesChanged"]) if (isCount(p[k])) out[k] = p[k];
40
+ if (JOB_ERROR_CODES.includes(p.errorCode)) out.errorCode = p.errorCode;
41
+ return { type: "job.event", seq: frame.seq, payload: out, terminal: TERMINAL.has(p.state) };
42
+ }
43
+ if (frame.type === "job.result") {
44
+ if (p.jobId !== jobId) return { violation: "job" };
45
+ const outcome = ["pr_opened", "no_change", "failed", "cancelled"].includes(p.outcome) ? p.outcome : null;
46
+ if (!outcome) return null;
47
+ const r = resultPayload(jobId, outcome, p);
48
+ return { type: "job.result", seq: frame.seq, payload: r };
49
+ }
50
+ if (frame.type === "audit.event") {
51
+ if (!AUDIT_ACTIONS.has(p.action)) return null;
52
+ const out = { action: p.action };
53
+ if (AUDIT_DECISIONS.has(p.decision)) out.decision = p.decision;
54
+ if (typeof p.actionType === "string" && SHORT_ID.test(p.actionType)) out.actionType = p.actionType;
55
+ if (typeof p.toolName === "string" && SHORT_ID.test(p.toolName)) out.toolName = p.toolName;
56
+ if (typeof p.requestHash === "string" && SHA256.test(p.requestHash)) out.requestHash = p.requestHash;
57
+ const hint = out.action === "approval_requested" && typeof p.approvalId === "string" && UUID_LOWER.test(p.approvalId) && out.requestHash ? { approvalHint: { approvalId: p.approvalId, requestHash: out.requestHash } } : {};
58
+ return { type: "audit.event", seq: frame.seq, payload: out, ...hint };
59
+ }
60
+ return null;
61
+ }