deepdebug-local-agent 1.9.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 (65) 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 +62 -0
  7. package/docs/la-protocol-v2.md +7 -5
  8. package/env.example +12 -0
  9. package/package.json +1 -1
  10. package/src/la-protocol-v2/content-handlers/install.js +27 -1
  11. package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +10 -6
  12. package/src/la-protocol-v2/contract/README.md +75 -2
  13. package/src/la-protocol-v2/contract/envelope.schema.json +3 -1
  14. package/src/la-protocol-v2/contract/index.json +18 -2
  15. package/src/la-protocol-v2/contract/job.assign.schema.json +31 -0
  16. package/src/la-protocol-v2/contract/policy.check.schema.json +97 -0
  17. package/src/la-protocol-v2/contract/policy.decision.schema.json +119 -0
  18. package/src/la-protocol-v2/jobs/agent2/api-client.js +150 -0
  19. package/src/la-protocol-v2/jobs/agent2/approvals.js +114 -0
  20. package/src/la-protocol-v2/jobs/agent2/bridge.js +299 -0
  21. package/src/la-protocol-v2/jobs/agent2/config.js +128 -0
  22. package/src/la-protocol-v2/jobs/agent2/frames.js +61 -0
  23. package/src/la-protocol-v2/jobs/agent2/policy-check.js +170 -0
  24. package/src/la-protocol-v2/jobs/agent2/runner.js +258 -0
  25. package/src/la-protocol-v2/jobs/agent2/supervisor.js +269 -0
  26. package/src/la-protocol-v2/jobs/config.js +60 -0
  27. package/src/la-protocol-v2/jobs/events.js +106 -0
  28. package/src/la-protocol-v2/jobs/install.js +43 -0
  29. package/src/la-protocol-v2/jobs/manager.js +281 -0
  30. package/src/la-protocol-v2/jobs/options.js +56 -0
  31. package/src/la-protocol-v2/jobs/smoke-runner.js +39 -0
  32. package/src/la-protocol-v2/llm/client.js +432 -0
  33. package/src/la-protocol-v2/llm/config.js +63 -0
  34. package/src/la-protocol-v2/llm/install.js +17 -0
  35. package/src/la-protocol-v2/llm-key/store.js +10 -0
  36. package/src/la-protocol-v2/runtime.js +69 -3
  37. package/src/server.js +4 -0
  38. package/src/utils/approval-token.js +3 -2
  39. package/test/contract/la-protocol-v2/vectors/job.assign.vectors.json +1538 -0
  40. package/test/contract/la-protocol-v2/vectors/policy.check.vectors.json +908 -0
  41. package/test/contract/la-protocol-v2/vectors/policy.decision.vectors.json +826 -0
  42. package/test/la-protocol-v2/agent2-approval-discovery.test.js +114 -0
  43. package/test/la-protocol-v2/agent2-config.test.js +103 -0
  44. package/test/la-protocol-v2/agent2-orphan.test.js +118 -0
  45. package/test/la-protocol-v2/agent2-policy-check.test.js +273 -0
  46. package/test/la-protocol-v2/agent2-real-e2e.test.js +89 -0
  47. package/test/la-protocol-v2/agent2-runner.test.js +510 -0
  48. package/test/la-protocol-v2/agent2-supervisor.test.js +185 -0
  49. package/test/la-protocol-v2/agent2-workspace-door.test.js +142 -0
  50. package/test/la-protocol-v2/agent2-workspace-real-server.test.js +137 -0
  51. package/test/la-protocol-v2/contract-v21.test.js +2 -2
  52. package/test/la-protocol-v2/contract.test.js +8 -7
  53. package/test/la-protocol-v2/helpers/agent2-rig.js +96 -0
  54. package/test/la-protocol-v2/helpers/fake-agent2-cli.js +22 -0
  55. package/test/la-protocol-v2/helpers/fake-agent2.js +280 -0
  56. package/test/la-protocol-v2/helpers/fake-la-api.js +29 -0
  57. package/test/la-protocol-v2/helpers/fake-llm.js +75 -0
  58. package/test/la-protocol-v2/helpers/reference-cp.js +6 -3
  59. package/test/la-protocol-v2/helpers/spawn-agent.js +4 -3
  60. package/test/la-protocol-v2/helpers/supervisor-exit-script.js +15 -0
  61. package/test/la-protocol-v2/jobs-e2e.test.js +405 -0
  62. package/test/la-protocol-v2/jobs-manager.test.js +370 -0
  63. package/test/la-protocol-v2/jobs-options.test.js +288 -0
  64. package/test/la-protocol-v2/llm-client.test.js +380 -0
  65. package/test/la-protocol-v2/llm-messages.test.js +132 -0
@@ -0,0 +1,22 @@
1
+ // The fake agent2 as a PROCESS (spawn-mode tests): reads the same environment the real agent2 reads (AGENT2_LOCAL_PORT, AGENT2_LOCAL_API_TOKEN,
2
+ // AGENT2_LOCAL_TENANT_ID, AGENT2_LLM_BASE_URL, AGENT2_LA_CALLBACK_URL/TOKEN). argv: --scenario=<name>; --die-on-start exits at once; --hang never serves; --die-on-job exits when a job arrives.
3
+ // GET /local/v1/_env (debug) reports the environment it was started with so the tests can pin the allowlist.
4
+ import { startFakeAgent2 } from "./fake-agent2.js";
5
+
6
+ const arg = (n) => process.argv.find((a) => a.startsWith(`--${n}=`))?.split("=")[1];
7
+ const has = (n) => process.argv.includes(`--${n}`);
8
+
9
+ // `node --test` also loads every file under test/: without the environment of a started agent2 this file is a no-op
10
+ if (!process.env.AGENT2_LOCAL_API_TOKEN && !has("die-on-start")) process.exit(0);
11
+ if (has("die-on-start")) process.exit(3);
12
+ if (has("hang")) { setInterval(() => {}, 1 << 30); } else {
13
+ const f = await startFakeAgent2({
14
+ token: process.env.AGENT2_LOCAL_API_TOKEN, tenantId: process.env.AGENT2_LOCAL_TENANT_ID, port: Number(process.env.AGENT2_LOCAL_PORT), scenario: arg("scenario") || "ok_pr",
15
+ llmBase: () => process.env.AGENT2_LLM_BASE_URL, callbackUrl: () => process.env.AGENT2_LA_CALLBACK_URL, callbackToken: () => process.env.AGENT2_LA_CALLBACK_TOKEN,
16
+ workspaceUrl: () => process.env.AGENT2_LA_WORKSPACE_URL, workspaceToken: () => process.env.AGENT2_LA_WORKSPACE_TOKEN,
17
+ onCrash: () => process.exit(1),
18
+ supportList: !has("no-list"),
19
+ });
20
+ void f;
21
+ if (has("die-on-job")) setInterval(() => { if (f.jobs.size) process.exit(1); }, 20);
22
+ }
@@ -0,0 +1,280 @@
1
+ // A FAKE agent2 (F3-08c): implements the local API of PR #308 (docs/runbooks/local-agent-api.md of visionhammer-agents) closely enough to test the Local Agent's runner:
2
+ // bearer auth, closed JSON bodies, 409 busy, idempotent re-assign, SSE of closed frames that replays from the start and ends after job.result, cancel, approvals
3
+ // (detail, decide, and the LIST endpoint that #308 does not have yet - switch it off with `supportList:false`). It is NOT the real agent2: what it proves is limited
4
+ // by that (the real jar is exercised by test/la-protocol-v2/agent2-real-e2e.test.js when AGENT2_JAR points at one).
5
+ // A scenario is `async (job, h) => void`; h talks back through the bridge exactly as agent2 does (LLM with the per-job key, PolicyGate relay with the callback token).
6
+ import http from "node:http";
7
+ import crypto from "node:crypto";
8
+
9
+ const JOB_FIELDS = new Set(["jobId", "tenantId", "workspaceId", "kind", "repoRef", "policy", "budget", "llm", "options", "incidentId", "request", "workspacePath"]);
10
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
11
+
12
+ export const SCENARIOS = {
13
+ /** one LLM call, an edit, a PR */
14
+ async ok_pr(job, h) {
15
+ h.event({ state: "running", phase: "context", iteration: 0, filesRead: 2 });
16
+ const r = await h.llm([{ role: "system", content: "You are agent2." }, { role: "user", content: job.request.text }]);
17
+ job.record.llm = r;
18
+ h.event({ state: "running", phase: "edit", iteration: 3, filesRead: 5, filesChanged: 2, tokensIn: 999999, tokensOut: 999999, costUsd: 99 }); // agent2's own counters must be ignored
19
+ await sleep(160); // longer than the progress coalescing window of the tests, so this frame is not folded into the terminal one
20
+ h.event({ state: "succeeded", phase: "done", errorCode: "none", iteration: 3, filesChanged: 2 });
21
+ h.result({ outcome: "pr_opened", prUrl: "https://github.com/acme/app/pull/7", branch: "fix/vh-1", commitSha: "a".repeat(40), filesChangedCount: 2, diffSha256: "b".repeat(64), tests: { pass: 12, fail: 0, skip: 1 } });
22
+ },
23
+ async no_change(job, h) {
24
+ h.event({ state: "running", phase: "plan", iteration: 1 });
25
+ h.event({ state: "succeeded", phase: "done", errorCode: "none" });
26
+ h.result({ outcome: "no_change" });
27
+ },
28
+ async fail_tests(job, h) {
29
+ h.event({ state: "running", phase: "validate", iteration: 2 });
30
+ h.event({ state: "failed", phase: "validate", errorCode: "tests_failed" });
31
+ h.result({ outcome: "failed" });
32
+ },
33
+ /** asks a person; goes on only when approved */
34
+ async approval(job, h) {
35
+ h.event({ state: "running", phase: "edit", iteration: 1 });
36
+ const a = h.requestApproval({ actionType: "RUN_SHELL_FREEFORM", toolName: "run_command", requestHash: job.record.requestHash || crypto.createHash("sha256").update("rm -rf build").digest("hex"), summary: "SECRET-COMMAND-CANARY rm -rf /home/cliente/build" });
37
+ job.record.approvalId = a.approvalId;
38
+ const d = await a.decision;
39
+ job.record.decision = d;
40
+ h.event({ state: "running", phase: "edit", iteration: 2 });
41
+ if (d && d.approved) { h.event({ state: "succeeded", phase: "done", errorCode: "none" }); h.result({ outcome: "no_change" }); }
42
+ else { h.event({ state: "failed", errorCode: "policy_denied" }); h.result({ outcome: "failed" }); }
43
+ },
44
+ /** runs until cancelled, then reports `cancelled` like agent2 does */
45
+ async slow(job, h) {
46
+ h.event({ state: "running", phase: "plan", iteration: 1 });
47
+ await h.cancelled;
48
+ h.event({ state: "cancelled", errorCode: "cancelled" });
49
+ h.result({ outcome: "cancelled" });
50
+ },
51
+ /** runs until cancelled and never answers (a wedged agent2) */
52
+ async stuck(job, h) { h.event({ state: "running", phase: "plan" }); await new Promise(() => {}); },
53
+ /** a governed action, asked EXACTLY like PR #308 does (LocalGatewayInternalClient + LocalPolicyCheck): `{type:"policy.check",payload}`, the tool name from the guard's `model` */
54
+ async policy(job, h) {
55
+ h.event({ state: "running", phase: "edit" });
56
+ job.record.policy = await h.policyCheck({ actionType: "APPLY_PATCH", tool: "apply_patch" });
57
+ job.record.policyOtherShape = await h.policyRaw({ method: "POST", path: "/api/v1/internal/policy/evaluate", body: { actionType: "APPLY_PATCH" } }); // the hosted shape: not accepted
58
+ h.event({ state: "succeeded", phase: "done", errorCode: "none" });
59
+ h.result({ outcome: "no_change" });
60
+ },
61
+ /** the same, with what a compromised or buggy agent2 could add on top: claims about who it is, paths, URLs. The bridge must drop them */
62
+ async policy_check(job, h) {
63
+ h.event({ state: "running", phase: "edit" });
64
+ job.record.policy = await h.policyCheck({ actionType: "APPLY_PATCH", tool: "apply_patch", incident: "inc-42",
65
+ extra: { tenantId: "11111111-1111-4111-8111-111111111111", workspaceId: job.workspaceId, agent: "software-engineer", executionOrigin: "hosted", diffFiles: ["src/SECRET-PATH-CANARY.js"], allowedFiles: ["src/SECRET-PATH-CANARY.js"],
66
+ repoUrl: "https://github.com/acme/app.git", repoUrls: ["https://github.com/acme/app.git"] } });
67
+ h.event({ state: "succeeded", phase: "done", errorCode: "none" });
68
+ h.result({ outcome: "no_change" });
69
+ },
70
+ /** what agent2 must NOT get away with: another job, another repository, bad shapes, in one run (results in job.record.bad) */
71
+ async policy_hostile(job, h) {
72
+ h.event({ state: "running", phase: "edit" });
73
+ const bad = {};
74
+ bad.otherJob = await h.policyCheck({ actionType: "APPLY_PATCH", payload: { jobId: crypto.randomUUID() } });
75
+ bad.otherRepo = await h.policyCheck({ actionType: "APPLY_PATCH", payload: { repoRefs: [{ provider: "github", org: "acme", repo: "other" }] } });
76
+ bad.twoRepos = await h.policyCheck({ actionType: "APPLY_PATCH", payload: { repoRefs: [{ provider: "github", org: "acme", repo: "app" }, { provider: "github", org: "acme", repo: "lib" }] } });
77
+ bad.repoExtraKey = await h.policyCheck({ actionType: "APPLY_PATCH", payload: { repoRefs: [{ provider: "github", org: "acme", repo: "app", url: "https://x" }] } });
78
+ bad.noHash = await h.policyCheck({ actionType: "APPLY_PATCH", payload: { requestHash: undefined } });
79
+ bad.badHash = await h.policyCheck({ actionType: "APPLY_PATCH", payload: { requestHash: "NOT-A-HASH" } });
80
+ bad.badAction = await h.policyCheck({ actionType: "APPLY PATCH" });
81
+ bad.badTool = await h.policyCheck({ actionType: "APPLY_PATCH", payload: { toolName: "write file /etc/passwd" } });
82
+ bad.payloadArray = await h.policyRaw({ type: "policy.check", payload: [1] });
83
+ bad.noPayload = await h.policyRaw({ type: "policy.check" });
84
+ bad.extraTop = await h.policyRaw({ type: "policy.check", payload: {}, tenantId: "x" });
85
+ bad.otherType = await h.policyRaw({ type: "audit.event", payload: {} });
86
+ job.record.bad = bad;
87
+ h.event({ state: "succeeded", phase: "done", errorCode: "none" });
88
+ h.result({ outcome: "no_change" });
89
+ },
90
+ /** reads and writes files of the workspace through the LA /workspace API, as agent2 does (read_file, write_file) */
91
+ async workspace(job, h) {
92
+ h.event({ state: "running", phase: "edit" });
93
+ job.record.read = await h.workspace("GET", "/workspace/file-content?path=src%2Fhello.js");
94
+ job.record.write = await h.workspace("POST", "/workspace/write-file", { path: "src/out.txt", content: "written by agent2 through the bridge" });
95
+ job.record.noToken = await h.workspace("GET", "/workspace/file-content?path=src%2Fhello.js", undefined, { tokenOverride: "" });
96
+ job.record.badToken = await h.workspace("GET", "/workspace/file-content?path=src%2Fhello.js", undefined, { tokenOverride: "z".repeat(43) });
97
+ job.record.otherApi = await h.workspace("POST", "/setup/run-command", { command: "id" }).catch(() => null);
98
+ h.event({ state: "succeeded", phase: "done", errorCode: "none" });
99
+ h.result({ outcome: "no_change" });
100
+ },
101
+ /** the model refuses (budget / DLP): agent2 maps it to its own failure */
102
+ async llm_refused(job, h) {
103
+ const r = await h.llm([{ role: "user", content: job.request.text }]);
104
+ job.record.llm = r;
105
+ h.event({ state: "failed", errorCode: "llm_error" });
106
+ h.result({ outcome: "failed" });
107
+ },
108
+ /** frames of another tenant / job / a forbidden audit action, mixed with good ones */
109
+ async hostile(job, h) {
110
+ h.raw({ type: "job.event", tenantId: "9d1f6a2c-0000-4000-8000-000000000000", payload: { jobId: job.jobId, state: "failed", errorCode: "unknown" } });
111
+ h.raw({ type: "job.event", payload: { jobId: crypto.randomUUID(), state: "failed" } });
112
+ h.raw({ type: "audit.event", payload: { action: "config_applied", decision: "allow" } });
113
+ h.raw({ type: "audit.event", payload: { action: "policy_decision", decision: "deny", actionType: "APPLY_PATCH", toolName: "apply_patch", requestHash: "c".repeat(64), prompt: "LEAK-CANARY-PROMPT" } });
114
+ h.event({ state: "running", phase: "edit", model: "claude-secret-model", message: "LEAK-CANARY-MESSAGE", path: "/home/cliente/x" });
115
+ h.event({ state: "succeeded", phase: "done", errorCode: "none" });
116
+ h.result({ outcome: "no_change", prUrl: "javascript:alert(1)" });
117
+ },
118
+ /** exits mid-job (spawn mode: the process dies; in-process: the server vanishes) */
119
+ async crash(job, h) { h.event({ state: "running", phase: "plan" }); await sleep(50); h.crash(); },
120
+ };
121
+
122
+ export async function startFakeAgent2({ token, tenantId, scenario = "ok_pr", port = 0, llmBase = null, callbackUrl = null, callbackToken = null, workspaceUrl = null, workspaceToken = null, supportList = true, frameApprovalId = true, healthDelayMs = 0, onCrash = null } = {}) {
123
+ const jobs = new Map();
124
+ const approvals = new Map();
125
+ const requests = [];
126
+ const sockets = new Set();
127
+ let current = null;
128
+ const sc = typeof scenario === "function" ? scenario : SCENARIOS[scenario];
129
+ let scenarioName = scenario;
130
+
131
+ const json = (res, status, body) => { const t = JSON.stringify(body); res.writeHead(status, { "content-type": "application/json", "content-length": Buffer.byteLength(t) }); res.end(t); };
132
+ const auth = (req) => req.headers.authorization === `Bearer ${token}`;
133
+ const body = (req) => new Promise((resolve) => { const c = []; req.on("data", (x) => c.push(x)); req.on("end", () => { try { resolve(JSON.parse(Buffer.concat(c).toString("utf8") || "null")); } catch { resolve(undefined); } }); });
134
+
135
+ function makeJob(b) {
136
+ const job = { ...b, frames: [], listeners: new Set(), done: false, seq: 0, record: {}, cancelRequested: false, cancelReason: null };
137
+ job.cancelled = new Promise((r) => { job.onCancel = r; });
138
+ const push = (type, payload, over = {}) => {
139
+ const f = { seq: ++job.seq, type, tenantId: b.tenantId, workspaceId: b.workspaceId, payload, ...over };
140
+ if (over.drop) for (const k of over.drop) delete f[k];
141
+ job.frames.push(f);
142
+ for (const l of job.listeners) l(f);
143
+ if (type === "job.result") { job.done = true; for (const l of job.listeners) l(null); }
144
+ };
145
+ const h = {
146
+ event: (p) => push("job.event", { jobId: b.jobId, ...p }),
147
+ result: (p) => push("job.result", { jobId: b.jobId, ...p }),
148
+ audit: (p) => push("audit.event", p),
149
+ raw: ({ type, tenantId: t, payload }) => { const f = { seq: ++job.seq, type, ...(t ? { tenantId: t } : { tenantId: b.tenantId }), workspaceId: b.workspaceId, payload }; job.frames.push(f); for (const l of job.listeners) l(f); },
150
+ cancelled: job.cancelled,
151
+ requestApproval({ actionType, toolName, requestHash, summary }) {
152
+ const approvalId = crypto.randomUUID();
153
+ let resolve;
154
+ const decision = new Promise((r) => { resolve = r; });
155
+ const p = { approvalId, jobId: b.jobId, actionType, toolName, summary, requestHash, expiresAt: new Date(Date.now() + 600_000).toISOString(), resolve, decided: false };
156
+ approvals.set(approvalId, p);
157
+ push("audit.event", { action: "approval_requested", decision: "require_human", actionType, toolName, requestHash, ...(frameApprovalId ? { approvalId } : {}) }); // PR #308 puts its own id in both frames (loopback only)
158
+ push("job.event", { jobId: b.jobId, state: "waiting_approval", ...(frameApprovalId ? { approvalId } : {}) });
159
+ return { approvalId, decision };
160
+ },
161
+ /** One model turn like agent2's ClaudeApiClient does it (Anthropic Messages API, tools, content blocks, its own metadata); `over.api:"chat"` = the OpenAI-shaped route. */
162
+ async llm(messages, over = {}) {
163
+ const { api = "messages", ...rest } = over;
164
+ const sys = messages.filter((m) => m.role === "system").map((m) => ({ type: "text", text: m.content, cache_control: { type: "ephemeral" } }));
165
+ const turns = messages.filter((m) => m.role !== "system").map((m) => ({ role: m.role, content: [{ type: "text", text: m.content }] }));
166
+ const body = api === "chat" ? { model: b.llm.alias, messages, max_tokens: 512, temperature: 0, metadata: { vh_tier: "x" }, ...rest }
167
+ : { model: b.llm.alias, max_tokens: 512, ...(sys.length ? { system: sys } : {}), messages: turns, tools: [{ name: "read_file", description: "Reads a file", input_schema: { type: "object", properties: { path: { type: "string" } } } }], metadata: { vh_tier: "reasoning" }, ...rest };
168
+ const res = await fetch(`${llmBase()}/${api === "chat" ? "chat/completions" : "messages"}`, { method: "POST", headers: { authorization: `Bearer ${b.llm.key}`, "content-type": "application/json", "x-litellm-tags": "tenant:x" }, body: JSON.stringify(body) });
169
+ const text = await res.text();
170
+ let j = null; try { j = JSON.parse(text); } catch { /* keep null */ }
171
+ return { status: res.status, json: j, text };
172
+ },
173
+ /** POST /agent2/cp-request with exactly this JSON body (any shape: for the negative cases) */
174
+ async policyRaw(bodyObj, { tokenOverride } = {}) {
175
+ const res = await fetch(`${callbackUrl()}/agent2/cp-request`, { method: "POST", headers: { authorization: `Bearer ${tokenOverride ?? callbackToken()}`, "content-type": "application/json" }, body: JSON.stringify(bodyObj) });
176
+ const text = await res.text();
177
+ let j = null; try { j = JSON.parse(text); } catch { /* keep null */ }
178
+ return { status: res.status, json: j };
179
+ },
180
+ /** The request of PR #308's LocalGatewayInternalClient, byte for byte in shape: `{type:"policy.check", payload:{actionType, requestHash, toolName?, modelAlias, incidentId?, jobId, repoRefs}}`
181
+ * (LocalPolicyCheck.checkPayload: the repo comes from the job, the hash from the guard's context). `payload` overrides fields (undefined removes one), `extra` adds fields. */
182
+ async policyCheck({ actionType, tool, incident, payload = {}, extra = {}, tokenOverride }) {
183
+ const requestHash = crypto.createHash("sha256").update(`${b.jobId}|${actionType}|${tool ?? ""}`).digest("hex");
184
+ const base = { actionType, requestHash, ...(tool ? { toolName: tool } : {}), modelAlias: b.llm.alias, ...((incident ?? b.incidentId) ? { incidentId: incident ?? b.incidentId } : {}), jobId: b.jobId,
185
+ repoRefs: [{ provider: b.repoRef.provider, org: b.repoRef.org, repo: b.repoRef.repo }] };
186
+ const merged = { ...base, ...payload, ...extra };
187
+ for (const k of Object.keys(merged)) if (merged[k] === undefined) delete merged[k];
188
+ const r = await h.policyRaw({ type: "policy.check", payload: merged }, { tokenOverride });
189
+ r.requestHash = merged.requestHash;
190
+ return r;
191
+ },
192
+ /** a call to the LA /workspace API the way agent2 makes it: at AGENT2_LA_WORKSPACE_URL (the bridge) with X-Agent-Token = AGENT2_LA_WORKSPACE_TOKEN, no signature of its own */
193
+ async workspace(method, pathAndQuery, bodyObj, { tokenOverride, headers = {} } = {}) {
194
+ const t = tokenOverride === undefined ? (workspaceToken ? workspaceToken() : callbackToken()) : tokenOverride;
195
+ const res = await fetch(`${(workspaceUrl ? workspaceUrl() : callbackUrl())}${pathAndQuery}`, { method, headers: { ...(t ? { "x-agent-token": t } : {}), ...(bodyObj !== undefined ? { "content-type": "application/json" } : {}), ...headers }, ...(bodyObj !== undefined ? { body: JSON.stringify(bodyObj) } : {}) });
196
+ const text = await res.text();
197
+ let j = null; try { j = JSON.parse(text); } catch { /* keep null */ }
198
+ return { status: res.status, json: j, text };
199
+ },
200
+ crash() { if (onCrash) onCrash(); else { for (const s of sockets) s.destroy(); server.close(); } },
201
+ };
202
+ job.h = h;
203
+ return job;
204
+ }
205
+
206
+ const server = http.createServer(async (req, res) => {
207
+ const url = new URL(req.url, "http://x");
208
+ const p = url.pathname;
209
+ requests.push({ method: req.method, path: p });
210
+ if (req.method === "GET" && p === "/local/v1/health") { if (healthDelayMs) await sleep(healthDelayMs); return json(res, 200, { status: "up", mode: "local", busy: !!(current && !current.done) }); }
211
+ if (req.method === "GET" && p === "/local/v1/_env") return json(res, 200, { keys: Object.keys(process.env), values: process.env }); // debug route of the CLI wrapper only
212
+ if (!auth(req)) return json(res, 401, { error: "unauthorized" });
213
+ let m;
214
+ if (req.method === "POST" && p === "/local/v1/jobs") {
215
+ const b = await body(req);
216
+ if (!b || typeof b !== "object" || Array.isArray(b)) return json(res, 400, { error: "invalid_body" });
217
+ for (const k of Object.keys(b)) if (!JOB_FIELDS.has(k)) return json(res, 400, { error: "invalid_body" });
218
+ if (b.tenantId !== tenantId) return json(res, 403, { error: "tenant_mismatch" });
219
+ if (b.kind === "quality") return json(res, 422, { error: "unsupported_kind" });
220
+ if (!/^vh-/.test(b.llm?.alias || "")) return json(res, 400, { error: "invalid", field: "llm.alias" });
221
+ if (jobs.has(b.jobId)) return json(res, 202, { jobId: b.jobId, state: "running", created: false });
222
+ if (current && !current.done) return json(res, 409, { error: "busy" });
223
+ const job = makeJob(b);
224
+ jobs.set(b.jobId, job);
225
+ current = job;
226
+ Promise.resolve().then(() => (sc || SCENARIOS.ok_pr)(job, job.h)).catch(() => {});
227
+ return json(res, 202, { jobId: b.jobId, state: "running", created: true });
228
+ }
229
+ if ((m = p.match(/^\/local\/v1\/jobs\/([^/]+)\/events$/)) && req.method === "GET") {
230
+ const job = jobs.get(m[1]);
231
+ if (!job) return json(res, 404, { error: "unknown_job" });
232
+ res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store" });
233
+ const write = (f) => res.write(`event: frame\ndata: ${JSON.stringify(f)}\n\n`);
234
+ for (const f of job.frames) write(f);
235
+ if (job.done) return res.end();
236
+ const l = (f) => { if (f === null) { res.end(); job.listeners.delete(l); } else write(f); };
237
+ job.listeners.add(l);
238
+ req.on("close", () => job.listeners.delete(l));
239
+ return;
240
+ }
241
+ if ((m = p.match(/^\/local\/v1\/jobs\/([^/]+)\/cancel$/)) && req.method === "POST") {
242
+ const job = jobs.get(m[1]);
243
+ const b = await body(req);
244
+ if (job) { job.cancelRequested = true; job.cancelReason = b?.reason ?? null; job.record.cancelCalls = (job.record.cancelCalls || 0) + 1; job.onCancel(); }
245
+ return json(res, 202, { jobId: m[1], state: job?.done ? "finished" : "cancelling" });
246
+ }
247
+ if ((m = p.match(/^\/local\/v1\/jobs\/([^/]+)$/)) && req.method === "GET") { const job = jobs.get(m[1]); return job ? json(res, 200, { jobId: m[1], state: job.done ? "finished" : "running" }) : json(res, 404, { error: "unknown_job" }); }
248
+ if (req.method === "GET" && p === "/local/v1/approvals") {
249
+ if (!supportList) return json(res, 404, { error: "not_found" });
250
+ requests.push({ method: "LIST", path: p, query: url.search });
251
+ const only = url.searchParams.get("jobId");
252
+ return json(res, 200, { approvals: [...approvals.values()].filter((a) => !a.decided && (!only || a.jobId === only)).map(({ approvalId, jobId, actionType, toolName, requestHash, expiresAt }) => ({ approvalId, jobId, actionType, toolName, requestHash, expiresAt })) });
253
+ }
254
+ if ((m = p.match(/^\/local\/v1\/approvals\/([^/]+)$/))) {
255
+ const a = approvals.get(m[1]);
256
+ if (req.method === "GET") return a && !a.decided ? json(res, 200, { approvalId: a.approvalId, jobId: a.jobId, actionType: a.actionType, toolName: a.toolName, summary: a.summary, requestHash: a.requestHash, expiresAt: a.expiresAt }) : json(res, 404, { error: "unknown_approval" });
257
+ if (req.method === "POST") {
258
+ const b = await body(req);
259
+ if (!a) return json(res, 404, { error: "unknown_approval" });
260
+ if (a.decided) return json(res, 409, { error: "already_decided" });
261
+ if (!b || !["approved", "denied"].includes(b.decision)) return json(res, 400, { error: "invalid_body" });
262
+ a.decided = true;
263
+ a.decidedBy = b.decidedBy;
264
+ requests.push({ method: "DECIDE", path: p, decision: b.decision, decidedBy: b.decidedBy });
265
+ a.resolve({ approved: b.decision === "approved", decidedBy: b.decidedBy });
266
+ return json(res, 200, { approvalId: a.approvalId, decision: b.decision });
267
+ }
268
+ }
269
+ return json(res, 404, { error: "not_found" });
270
+ });
271
+ server.on("connection", (s) => { sockets.add(s); s.on("close", () => sockets.delete(s)); });
272
+ await new Promise((r) => server.listen(port, "127.0.0.1", r));
273
+ const actualPort = server.address().port;
274
+ return {
275
+ port: actualPort, baseUrl: `http://127.0.0.1:${actualPort}`, jobs, approvals, requests,
276
+ get current() { return current; },
277
+ set scenario(s) { scenarioName = s; },
278
+ async close() { for (const s of sockets) s.destroy(); await new Promise((r) => server.close(() => r())); },
279
+ };
280
+ }
@@ -0,0 +1,29 @@
1
+ // A stand-in for the Local Agent's own HTTP API with the REAL perimeter of src/server.js in front of two trivial routes: the same auth middleware (utils/agent-auth.js), the same body
2
+ // parser hook and the same signature middleware (utils/request-signing.js, LOCAL_AGENT_SIGNATURE_MODE=enforce by default). What it proves is that a request is accepted or refused by
3
+ // the real checks; the routes are not the real /workspace handlers (the real server is exercised by agent2-workspace-real-server.test.js).
4
+ import http from "node:http";
5
+ import express from "express";
6
+ import bodyParser from "body-parser";
7
+ import { createAuthMiddleware, resolveAuthConfig, allowedOrigins } from "../../../src/utils/agent-auth.js";
8
+ import { createSignatureMiddleware, resolveSigningConfig, captureRawBody } from "../../../src/utils/request-signing.js";
9
+
10
+ export async function startFakeLaApi({ env = {}, keyId, publicKeyRaw, port = 0 } = {}) {
11
+ const requests = [];
12
+ const app = express();
13
+ app.use(createAuthMiddleware(resolveAuthConfig(env), allowedOrigins(env)));
14
+ app.use(bodyParser.json({ limit: "50mb", verify: captureRawBody }));
15
+ app.use(createSignatureMiddleware(resolveSigningConfig({ LOCAL_AGENT_SIGNATURE_MODE: "enforce", ...env, LOCAL_AGENT_SIGNING_PUBLIC_KEYS: `${keyId}=${publicKeyRaw}`, LOCAL_AGENT_SIGNING_KEYS_FILE: "/nonexistent" }, { defaultFile: null })));
16
+ const note = (req, extra = {}) => requests.push({ method: req.method, url: req.originalUrl, headers: { ...req.headers }, signature: req.vhSignature, body: req.body, ...extra });
17
+ app.get("/workspace/file-content", (req, res) => { note(req); res.json({ ok: true, content: "// hello from the LA", root: req.headers["x-workspace-root"] || null }); });
18
+ app.post("/workspace/write-file", (req, res) => { note(req); res.json({ ok: true, written: req.body && req.body.path }); });
19
+ app.post("/workspace/echo", (req, res) => { note(req); res.json({ ok: true, bytes: JSON.stringify(req.body || {}).length }); });
20
+ app.delete("/workspace/thing", (req, res) => { note(req); res.json({ ok: true }); });
21
+ app.get("/workspace/slow", (req, res) => { note(req); setTimeout(() => res.json({ ok: true }), 300); });
22
+ app.post("/setup/run-command", (req, res) => { note(req); res.json({ ok: true, ran: true }); }); // must never be reachable through the bridge
23
+ const server = http.createServer(app);
24
+ const sockets = new Set();
25
+ server.on("connection", (s) => { sockets.add(s); s.on("close", () => sockets.delete(s)); });
26
+ await new Promise((r) => server.listen(port, "127.0.0.1", r));
27
+ const p = server.address().port;
28
+ return { port: p, url: `http://127.0.0.1:${p}`, requests, async close() { for (const s of sockets) s.destroy(); await new Promise((r) => server.close(() => r())); } };
29
+ }
@@ -0,0 +1,75 @@
1
+ // A fake OpenAI-compatible server (chat/completions, JSON and SSE) on 127.0.0.1, plus the fetch that points the https endpoint of the key at it.
2
+ // It records what it was sent (method, url, headers, parsed body) so the tests can prove what left the agent and what did not.
3
+ import http from "node:http";
4
+ import crypto from "node:crypto";
5
+
6
+ export const sha = (s) => crypto.createHash("sha256").update(Buffer.from(s, "utf8")).digest("hex");
7
+ export const mkKey = (tag = "A") => `sk-CANARY-LLMKEY-${tag}-${crypto.randomBytes(12).toString("hex")}`;
8
+ export const FUTURE = (ms = 3_600_000) => new Date(Date.now() + ms).toISOString();
9
+ export const keySet = (key, over = {}) => ({ op: "set", keyId: sha(key), endpoint: "https://ai.visionhammer.io/litellm", key, models: ["vh-claude-sonnet", "vh-embed"], expiresAt: FUTURE(), ...over });
10
+
11
+ export const okJson = (content = "ok", { promptTokens = 12, completionTokens = 5, cost, model = "vh-claude-sonnet" } = {}) => (req, res) => {
12
+ const body = JSON.stringify({ id: "cmpl-1", object: "chat.completion", model, choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }], usage: { prompt_tokens: promptTokens, completion_tokens: completionTokens, total_tokens: promptTokens + completionTokens } });
13
+ res.writeHead(200, { "content-type": "application/json", ...(cost !== undefined ? { "x-litellm-response-cost": String(cost) } : {}) });
14
+ res.end(body);
15
+ };
16
+
17
+ /** The Anthropic Messages API answer (what LiteLLM returns on /v1/messages): text, or tool_use blocks. */
18
+ export const okMessages = (content = "ok", { promptTokens = 12, completionTokens = 5, cost, model = "claude-upstream-model", blocks } = {}) => (req, res) => {
19
+ const body = JSON.stringify({ id: "msg_1", type: "message", role: "assistant", model, content: blocks || [{ type: "text", text: content }], stop_reason: blocks ? "tool_use" : "end_turn", stop_sequence: null,
20
+ usage: { input_tokens: promptTokens, output_tokens: completionTokens } });
21
+ res.writeHead(200, { "content-type": "application/json", ...(cost !== undefined ? { "x-litellm-response-cost": String(cost) } : {}) });
22
+ res.end(body);
23
+ };
24
+
25
+ /** One behavior per API: /v1/messages -> `messages`, anything else (chat/completions) -> `chat`. */
26
+ export const byPath = ({ chat = okJson(), messages = okMessages() } = {}) => (req, res, entry) => (/\/messages$/.test(req.url) ? messages(req, res, entry) : chat(req, res, entry));
27
+
28
+ export const okSse = (deltas, { promptTokens = 20, completionTokens = 7, gapMs = 0 } = {}) => async (req, res) => {
29
+ res.writeHead(200, { "content-type": "text/event-stream" });
30
+ for (const d of deltas) {
31
+ res.write(`data: ${JSON.stringify({ choices: [{ index: 0, delta: { content: d } }] })}\n\n`);
32
+ if (gapMs) await new Promise((r) => setTimeout(r, gapMs));
33
+ }
34
+ res.write(`data: ${JSON.stringify({ choices: [{ index: 0, delta: {}, finish_reason: "stop" }] })}\n\n`);
35
+ res.write(`data: ${JSON.stringify({ choices: [], usage: { prompt_tokens: promptTokens, completion_tokens: completionTokens } })}\n\n`);
36
+ res.end("data: [DONE]\n\n");
37
+ };
38
+
39
+ export const status = (code, body = "") => (req, res) => { res.writeHead(code, { "content-type": "application/json" }); res.end(body); };
40
+
41
+ /** @param {Function|Function[]} behavior one handler for every request, or a list used in order (the last one repeats) */
42
+ const OPEN = new Set();
43
+ /** Closes every fake server a failed test left open (so a red test never hangs the run). */
44
+ export async function closeAllFakes() { await Promise.all([...OPEN].map((f) => f.close())); }
45
+
46
+ export async function startFakeLlm(behavior = okJson()) {
47
+ const requests = [];
48
+ let list = Array.isArray(behavior) ? behavior : [behavior];
49
+ const sockets = new Set();
50
+ const server = http.createServer((req, res) => {
51
+ const chunks = [];
52
+ req.on("data", (c) => chunks.push(c));
53
+ req.on("end", () => {
54
+ const raw = Buffer.concat(chunks).toString("utf8");
55
+ let body = null;
56
+ try { body = JSON.parse(raw); } catch { /* keep null */ }
57
+ const entry = { method: req.method, url: req.url, headers: req.headers, raw, body };
58
+ requests.push(entry);
59
+ const h = list[Math.min(requests.length - 1, list.length - 1)];
60
+ Promise.resolve(h(req, res, entry)).catch(() => { try { res.destroy(); } catch { /* gone */ } });
61
+ });
62
+ });
63
+ server.on("connection", (s) => { sockets.add(s); s.on("close", () => sockets.delete(s)); });
64
+ await new Promise((r) => server.listen(0, "127.0.0.1", r));
65
+ const port = server.address().port;
66
+ const fake = {
67
+ port, requests,
68
+ set(b) { list = Array.isArray(b) ? b : [b]; },
69
+ /** the fetch the holder uses: the https origin of the key's endpoint is pointed at this server, everything else is untouched */
70
+ fetchImpl: (url, init) => fetch(String(url).replace(/^https:\/\/[^/]+/, `http://127.0.0.1:${port}`), init),
71
+ async close() { OPEN.delete(fake); for (const s of sockets) s.destroy(); await new Promise((r) => server.close(r)); },
72
+ };
73
+ OPEN.add(fake);
74
+ return fake;
75
+ }
@@ -54,6 +54,7 @@ export class ReferenceCp {
54
54
  this.dropAcks = 0; // do not ack the next n accepted LA data frames (simulates lost acks)
55
55
  this.silent = false; // do not ack anything
56
56
  this.rejectData = []; // F3-07b: error codes, consumed one per LA data frame: the frame is acked `rejected` with that code (not stored)
57
+ this.rejectTypes = {}; // F3-08c: { "<frame type>": "<error code>" }: every LA data frame of that type is acked `rejected` with that code (not stored)
57
58
  this.rejectedFrames = []; // [type, code] of the frames acked `rejected`
58
59
  this.ackDelayMs = 0; // answer acks late (slow consumer)
59
60
  this.onReceived = () => {};
@@ -170,8 +171,8 @@ export class ReferenceCp {
170
171
  this.ring.set(seq, msgId);
171
172
  while (this.ring.size > 512) this.ring.delete(this.ring.keys().next().value);
172
173
  if (m.ack !== undefined) this.#ackUpTo(m.ack);
173
- if (this.rejectData.length > 0) { // the gateway's non-fatal answer: the seq is consumed, the frame is not stored
174
- const code = this.rejectData.shift();
174
+ if (this.rejectData.length > 0 || this.rejectTypes[m.type]) { // the gateway's non-fatal answer: the seq is consumed, the frame is not stored
175
+ const code = this.rejectTypes[m.type] || this.rejectData.shift();
175
176
  this.rejectedFrames.push([m.type, code]);
176
177
  this.#sendAck(seq, "rejected", code);
177
178
  return null;
@@ -225,7 +226,9 @@ export class ReferenceCp {
225
226
  #sendReady(resumed) {
226
227
  const f = this.#envelope("ready", 0, null, this.workspaceId);
227
228
  const announced = (this.registers.at(-1) || {}).capabilities || [];
228
- const caps = this.capabilities.length > 0 && announced.includes("contract.v2.1") ? { capabilities: [...this.capabilities] } : {};
229
+ // like the gateway: `policy.check` (2.2) is listed only for an LA that announced it, and everything 2.1+ only for an LA that announced contract.v2.1
230
+ const listed = this.capabilities.filter((c) => c !== "policy.check" || announced.includes("policy.check"));
231
+ const caps = listed.length > 0 && announced.includes("contract.v2.1") ? { capabilities: listed } : {};
229
232
  this.#sign(f, "ready", { protocol: 2, sessionId: this.sessionId, resumed, peerLastSeq: this.laSeq, limits: { ...this.limits }, ...caps }, this.workspaceId);
230
233
  this.sendFn(JSON.stringify(f));
231
234
  }
@@ -18,14 +18,15 @@ export async function runAgent({ srv, root = DEFAULT_ROOT, env = {}, cfg, until,
18
18
  fs.mkdirSync(path.join(home, ".deepdebug"));
19
19
  fs.writeFileSync(path.join(home, ".deepdebug", "config.json"), JSON.stringify(cfg));
20
20
  }
21
- const full = { ...process.env, PORT: String(await freePort()), GATEWAY_URL: srv.url, AUTH_TOKEN: srv.token, WORKSPACE_ROOT: ws, HOME: home, ...env };
22
- delete full.LOCAL_AGENT_TOKEN;
21
+ const base = { ...process.env };
22
+ delete base.LOCAL_AGENT_TOKEN; // the developer's own token never leaks into a test; a test that wants one passes it in `env`
23
+ const full = { ...base, PORT: String(await freePort()), GATEWAY_URL: srv.url, AUTH_TOKEN: srv.token, WORKSPACE_ROOT: ws, HOME: home, ...env };
23
24
  for (const k of Object.keys(full)) if (full[k] === undefined) delete full[k];
24
25
  const child = spawn(process.execPath, ["src/server.js"], { cwd: root, env: full, stdio: "ignore" });
25
26
  try {
26
27
  const deadline = Date.now() + ms;
27
28
  while (Date.now() < deadline) {
28
- if (until && (await until())) break;
29
+ if (until && (await until({ child }))) break;
29
30
  await new Promise((r) => setTimeout(r, 50));
30
31
  }
31
32
  } finally {
@@ -0,0 +1,15 @@
1
+ // A tiny stand-in for "the Local Agent exits": starts a supervisor (spawn mode) with the fake agent2 CLI as the child, prints the child's pid and exits NORMALLY with process.exit().
2
+ // Used by agent2-orphan.test.js to prove the child does not outlive an orderly exit of its supervisor. argv: <cli> <pidfile>
3
+ import { createAgent2Supervisor } from "../../../src/la-protocol-v2/jobs/agent2/supervisor.js";
4
+ import { resolveAgent2RunnerConfig } from "../../../src/la-protocol-v2/jobs/agent2/config.js";
5
+ import net from "node:net";
6
+
7
+ const [, , cli, pidFile] = process.argv;
8
+ // `node --test` also loads every file under test/: without its arguments this file is a no-op
9
+ if (!cli || !pidFile) process.exit(0);
10
+ const port = await new Promise((res) => { const s = net.createServer().listen(0, "127.0.0.1", () => { const p = s.address().port; s.close(() => res(p)); }); });
11
+ const config = resolveAgent2RunnerConfig({ LOCAL_AGENT_RUNNER: "agent2", LOCAL_AGENT_AGENT2_CMD: JSON.stringify([process.execPath, cli, "--scenario=slow"]), LOCAL_AGENT_AGENT2_PORT: String(port), LOCAL_AGENT_AGENT2_PIDFILE: pidFile, LOCAL_AGENT_AGENT2_START_TIMEOUT_MS: "8000" });
12
+ const sup = createAgent2Supervisor({ config, tenantId: "3f2b8c1e-5a4d-4e7b-9c21-0a1b2c3d4e5f", bridge: { listen: async () => ({ baseUrl: "http://127.0.0.1:1" }), setCallbackToken() {} }, env: { PATH: process.env.PATH, HOME: process.env.HOME }, log: { warn() {}, info() {} } });
13
+ await sup.ensureReady();
14
+ console.log(`CHILD_PID=${sup.pid}`);
15
+ process.exit(0);