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.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +5 -0
- package/CLAUDE.md +1 -1
- package/docs/examples/agent2-runner/docker-compose.yml +53 -0
- package/docs/la-agent2-runner.md +115 -0
- package/docs/la-jobs-and-llm.md +62 -0
- package/docs/la-protocol-v2.md +7 -5
- package/env.example +12 -0
- package/package.json +1 -1
- package/src/la-protocol-v2/content-handlers/install.js +27 -1
- package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +10 -6
- package/src/la-protocol-v2/contract/README.md +75 -2
- package/src/la-protocol-v2/contract/envelope.schema.json +3 -1
- package/src/la-protocol-v2/contract/index.json +18 -2
- package/src/la-protocol-v2/contract/job.assign.schema.json +31 -0
- package/src/la-protocol-v2/contract/policy.check.schema.json +97 -0
- package/src/la-protocol-v2/contract/policy.decision.schema.json +119 -0
- package/src/la-protocol-v2/jobs/agent2/api-client.js +150 -0
- package/src/la-protocol-v2/jobs/agent2/approvals.js +114 -0
- package/src/la-protocol-v2/jobs/agent2/bridge.js +299 -0
- package/src/la-protocol-v2/jobs/agent2/config.js +128 -0
- package/src/la-protocol-v2/jobs/agent2/frames.js +61 -0
- package/src/la-protocol-v2/jobs/agent2/policy-check.js +170 -0
- package/src/la-protocol-v2/jobs/agent2/runner.js +258 -0
- package/src/la-protocol-v2/jobs/agent2/supervisor.js +269 -0
- package/src/la-protocol-v2/jobs/config.js +60 -0
- package/src/la-protocol-v2/jobs/events.js +106 -0
- package/src/la-protocol-v2/jobs/install.js +43 -0
- package/src/la-protocol-v2/jobs/manager.js +281 -0
- package/src/la-protocol-v2/jobs/options.js +56 -0
- package/src/la-protocol-v2/jobs/smoke-runner.js +39 -0
- package/src/la-protocol-v2/llm/client.js +432 -0
- package/src/la-protocol-v2/llm/config.js +63 -0
- package/src/la-protocol-v2/llm/install.js +17 -0
- package/src/la-protocol-v2/llm-key/store.js +10 -0
- package/src/la-protocol-v2/runtime.js +69 -3
- package/src/server.js +4 -0
- package/src/utils/approval-token.js +3 -2
- package/test/contract/la-protocol-v2/vectors/job.assign.vectors.json +1538 -0
- package/test/contract/la-protocol-v2/vectors/policy.check.vectors.json +908 -0
- package/test/contract/la-protocol-v2/vectors/policy.decision.vectors.json +826 -0
- package/test/la-protocol-v2/agent2-approval-discovery.test.js +114 -0
- package/test/la-protocol-v2/agent2-config.test.js +103 -0
- package/test/la-protocol-v2/agent2-orphan.test.js +118 -0
- package/test/la-protocol-v2/agent2-policy-check.test.js +273 -0
- package/test/la-protocol-v2/agent2-real-e2e.test.js +89 -0
- package/test/la-protocol-v2/agent2-runner.test.js +510 -0
- package/test/la-protocol-v2/agent2-supervisor.test.js +185 -0
- package/test/la-protocol-v2/agent2-workspace-door.test.js +142 -0
- package/test/la-protocol-v2/agent2-workspace-real-server.test.js +137 -0
- package/test/la-protocol-v2/contract-v21.test.js +2 -2
- package/test/la-protocol-v2/contract.test.js +8 -7
- package/test/la-protocol-v2/helpers/agent2-rig.js +96 -0
- package/test/la-protocol-v2/helpers/fake-agent2-cli.js +22 -0
- package/test/la-protocol-v2/helpers/fake-agent2.js +280 -0
- package/test/la-protocol-v2/helpers/fake-la-api.js +29 -0
- package/test/la-protocol-v2/helpers/fake-llm.js +75 -0
- package/test/la-protocol-v2/helpers/reference-cp.js +6 -3
- package/test/la-protocol-v2/helpers/spawn-agent.js +4 -3
- package/test/la-protocol-v2/helpers/supervisor-exit-script.js +15 -0
- package/test/la-protocol-v2/jobs-e2e.test.js +405 -0
- package/test/la-protocol-v2/jobs-manager.test.js +370 -0
- package/test/la-protocol-v2/jobs-options.test.js +288 -0
- package/test/la-protocol-v2/llm-client.test.js +380 -0
- package/test/la-protocol-v2/llm-messages.test.js +132 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// F3-08c (Local Agent half): the supervised mode (LOCAL_AGENT_AGENT2_CMD). A fake agent2 runs as a real PROCESS (helpers/fake-agent2-cli.js) started by the runner:
|
|
2
|
+
// minimal environment, a new token at every start, health, crash and bounded restarts, stop with the link. Unit tests of the supervisor (health failure, RSS, stop)
|
|
3
|
+
// use an injected spawn so they are fast and exact. NOT the real agent2: agent2-real-e2e.test.js does that, opt-in.
|
|
4
|
+
import { test, after } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { EventEmitter } from "node:events";
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { closeAllFakes, okJson } from "./helpers/fake-llm.js";
|
|
11
|
+
import { agent2Rig, CANARIES, TENANT, waitFor } from "./helpers/agent2-rig.js";
|
|
12
|
+
import { createAgent2Supervisor, agent2ChildEnv } from "../../src/la-protocol-v2/jobs/agent2/supervisor.js";
|
|
13
|
+
import { resolveAgent2RunnerConfig } from "../../src/la-protocol-v2/jobs/agent2/config.js";
|
|
14
|
+
|
|
15
|
+
after(closeAllFakes);
|
|
16
|
+
|
|
17
|
+
const HOME_TMP = fs.mkdtempSync(path.join(os.tmpdir(), "a2sup-home-")); // the pidfile of a unit-tested supervisor goes here, never to the real ~/.deepdebug
|
|
18
|
+
after(() => fs.rmSync(HOME_TMP, { recursive: true, force: true }));
|
|
19
|
+
|
|
20
|
+
const alive = (pid) => { try { process.kill(pid, 0); return true; } catch { return false; } };
|
|
21
|
+
const terminal = (r, jobId) => r.frames(jobId, "job.event").at(-1).payload;
|
|
22
|
+
const childEnvOf = async (port) => (await (await fetch(`http://127.0.0.1:${port}/local/v1/_env`)).json());
|
|
23
|
+
|
|
24
|
+
test("spawn: the process is started at the first job (not at boot), serves it, gets a MINIMAL environment, and stops with the link", async () => {
|
|
25
|
+
const SECRETS = { GITHUB_TOKEN: "ghp-CANARY-ENV-1", AWS_SECRET_ACCESS_KEY: "aws-CANARY-ENV-2", ANTHROPIC_API_KEY: "sk-CANARY-ENV-3", MY_TOKEN: "my-CANARY-ENV-4", LOCAL_AGENT_SIGNING_PUBLIC_KEYS: "kid=CANARY5", PLAIN_VAR: "plain-CANARY-6", JAVA_OPTS: "-Xms64m", LOCAL_AGENT_TOKEN: "la-token-CANARY-7-with-32-characters!!", SSH_AUTH_SOCK: "/tmp/ssh-agent.sock" };
|
|
26
|
+
const r = await agent2Rig({ spawn: {}, scenario: "ok_pr", env: { ...SECRETS, LOCAL_AGENT_AGENT2_ENV_PASSTHROUGH: "JAVA_OPTS,MY_TOKEN,not a name", LOCAL_AGENT_AGENT2_MAX_HEAP_MB: "512", LOCAL_AGENT_AGENT2_LA_URL: "http://127.0.0.1:5055" } });
|
|
27
|
+
assert.equal(r.link.agent2.supervisor.pid, null, "nothing runs at boot");
|
|
28
|
+
const a = r.assign();
|
|
29
|
+
assert.equal((await a.acked()).status, "accepted");
|
|
30
|
+
await r.done(a.p.jobId);
|
|
31
|
+
assert.equal(r.frames(a.p.jobId, "job.result")[0].payload.outcome, "pr_opened");
|
|
32
|
+
const pid = r.link.agent2.supervisor.pid;
|
|
33
|
+
assert.ok(pid && alive(pid));
|
|
34
|
+
// the model was reached only through the bridge of this agent
|
|
35
|
+
assert.equal(r.fakeLlm.requests.length, 1);
|
|
36
|
+
assert.equal(r.fakeLlm.requests[0].headers.authorization, `Bearer ${r.key}`);
|
|
37
|
+
// the environment the child got: an allowlist + what the runner sets
|
|
38
|
+
const e = await childEnvOf(r.fake.port);
|
|
39
|
+
for (const k of ["AGENT2_LOCAL_MODE", "AGENT2_LOCAL_BIND", "AGENT2_LOCAL_PORT", "AGENT2_LOCAL_API_TOKEN", "AGENT2_LOCAL_TENANT_ID", "AGENT2_LLM_BASE_URL", "AGENT2_LA_WORKSPACE_URL", "AGENT2_LA_CALLBACK_URL", "AGENT2_LA_CALLBACK_TOKEN", "PATH", "HOME", "JAVA_OPTS", "SSH_AUTH_SOCK"]) assert.ok(k in e.values, `missing ${k}`);
|
|
40
|
+
assert.equal(e.values.AGENT2_LOCAL_TENANT_ID, TENANT);
|
|
41
|
+
assert.equal(e.values.AGENT2_LOCAL_BIND, "127.0.0.1");
|
|
42
|
+
assert.equal(e.values.AGENT2_LLM_BASE_URL, `${r.link.agent2.bridge.baseUrl}/v1`, "the model is the loopback bridge, not the AI gateway");
|
|
43
|
+
assert.ok(e.values.AGENT2_LOCAL_API_TOKEN.length >= 32);
|
|
44
|
+
// agent2 reaches this agent's /workspace API THROUGH the bridge (which signs for it): the "workspace token" it holds is the bridge's, never LOCAL_AGENT_TOKEN
|
|
45
|
+
assert.equal(e.values.AGENT2_LA_WORKSPACE_URL, r.link.agent2.bridge.baseUrl, "the /workspace door is the bridge, not the LA's API");
|
|
46
|
+
assert.equal(e.values.AGENT2_LA_WORKSPACE_TOKEN, e.values.AGENT2_LA_CALLBACK_TOKEN, "one credential, the bridge's");
|
|
47
|
+
assert.notEqual(e.values.AGENT2_LA_WORKSPACE_TOKEN, SECRETS.LOCAL_AGENT_TOKEN);
|
|
48
|
+
assert.equal(e.values.DEEPDEBUG_POLICYGATE_TIMEOUT_MS, "13000", "agent2 waits longer than the channel's 10 s");
|
|
49
|
+
// the pidfile names the child while it runs (so a restart of THIS agent can end an orphan) and is gone when it stops
|
|
50
|
+
const rec = JSON.parse(fs.readFileSync(r.pidFile, "utf8"));
|
|
51
|
+
assert.equal(rec.pid, pid);
|
|
52
|
+
assert.equal(e.values.JAVA_TOOL_OPTIONS, "-Xmx512m");
|
|
53
|
+
for (const k of ["GITHUB_TOKEN", "AWS_SECRET_ACCESS_KEY", "ANTHROPIC_API_KEY", "MY_TOKEN", "LOCAL_AGENT_SIGNING_PUBLIC_KEYS", "PLAIN_VAR", "LOCAL_AGENT_TOKEN", "LOCAL_AGENT_RUNNER", "LOCAL_AGENT_LLM_KEY", "LOCAL_AGENT_DLP_MODE"]) assert.ok(!(k in e.values), `leaked into the child: ${k}`);
|
|
54
|
+
const dump = JSON.stringify(e.values);
|
|
55
|
+
for (const v of [r.key, "ghp-CANARY-ENV-1", "aws-CANARY-ENV-2", "sk-CANARY-ENV-3", "my-CANARY-ENV-4", "CANARY5", "plain-CANARY-6", "la-token-CANARY-7"]) assert.ok(!dump.includes(v), `value leaked: ${v}`);
|
|
56
|
+
assert.ok(r.logs.some((l) => /not a plain variable name/.test(l)));
|
|
57
|
+
// nothing the child printed (it is drained and dropped) nor its token is in the output of this agent
|
|
58
|
+
const all = r.everything();
|
|
59
|
+
assert.ok(!all.includes(e.values.AGENT2_LOCAL_API_TOKEN) && !all.includes(e.values.AGENT2_LA_CALLBACK_TOKEN));
|
|
60
|
+
// stops with the link
|
|
61
|
+
r.link.close();
|
|
62
|
+
await waitFor(() => !alive(pid), 8000);
|
|
63
|
+
await waitFor(() => !fs.existsSync(r.pidFile), 3000);
|
|
64
|
+
await r.stop();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("spawn: a crash fails the job (infra) and the NEXT job gets a new process with a NEW token; the restart budget is bounded (then `unavailable`, no spawn loop)", async () => {
|
|
68
|
+
const r = await agent2Rig({ spawn: { args: ["--die-on-job"] }, scenario: "slow", env: { LOCAL_AGENT_AGENT2_MAX_RESTARTS: "1", LOCAL_AGENT_AGENT2_RESTART_BACKOFF_MS: "0", LOCAL_AGENT_AGENT2_STREAM_RECONNECTS: "0" } });
|
|
69
|
+
const sup = r.link.agent2.supervisor;
|
|
70
|
+
const tokens = [];
|
|
71
|
+
const pids = [];
|
|
72
|
+
for (let i = 0; i < 2; i++) {
|
|
73
|
+
const a = r.assign();
|
|
74
|
+
await a.acked();
|
|
75
|
+
await r.done(a.p.jobId, 10_000);
|
|
76
|
+
assert.deepEqual([terminal(r, a.p.jobId).state, terminal(r, a.p.jobId).errorCode], ["failed", "infra"], `job ${i}`);
|
|
77
|
+
pids.push(sup.stats.starts);
|
|
78
|
+
}
|
|
79
|
+
assert.equal(sup.stats.starts, 2, "one start + one restart");
|
|
80
|
+
// the third job: the budget (maxRestarts=1 inside the window) is spent
|
|
81
|
+
const c = r.assign();
|
|
82
|
+
await c.acked();
|
|
83
|
+
await r.done(c.p.jobId, 10_000);
|
|
84
|
+
assert.deepEqual([terminal(r, c.p.jobId).state, terminal(r, c.p.jobId).errorCode], ["failed", "infra"]);
|
|
85
|
+
assert.equal(sup.stats.starts, 2, "no third process");
|
|
86
|
+
assert.equal(sup.stats.refusedRestartLimit, 1);
|
|
87
|
+
assert.ok(r.link.agent2.audit.tail(100).some((x) => x.event === "agent2_unavailable" && x.code === "restart_limit"));
|
|
88
|
+
void tokens; void pids;
|
|
89
|
+
await r.stop();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("spawn: the API token and the callback token differ at every start (nothing from a dead process works)", async () => {
|
|
93
|
+
const cfg = { ...resolveAgent2RunnerConfig({ LOCAL_AGENT_RUNNER: "agent2", LOCAL_AGENT_AGENT2_CMD: "x", LOCAL_AGENT_AGENT2_RESTART_BACKOFF_MS: "0" }), healthIntervalMs: 60_000 };
|
|
94
|
+
const seen = [];
|
|
95
|
+
const procs = [];
|
|
96
|
+
const cb = [];
|
|
97
|
+
const sup = createAgent2Supervisor({ config: cfg, tenantId: TENANT, bridge: { listen: async () => ({ baseUrl: "http://127.0.0.1:1" }), setCallbackToken: (t) => cb.push(t) }, env: { HOME: HOME_TMP }, log: { warn() {}, info() {} },
|
|
98
|
+
spawnImpl: (cmd, args, opts) => { seen.push(opts.env.AGENT2_LOCAL_API_TOKEN); const p = fakeProc(); procs.push(p); return p; }, fetchImpl: async () => new Response(JSON.stringify({ status: "up" }), { status: 200 }) });
|
|
99
|
+
await sup.ensureReady();
|
|
100
|
+
procs[0].exited = true; procs[0].emit("exit", 1, null);
|
|
101
|
+
assert.equal(sup.state, "down");
|
|
102
|
+
await sup.ensureReady();
|
|
103
|
+
assert.equal(seen.length, 2);
|
|
104
|
+
assert.notEqual(seen[0], seen[1]);
|
|
105
|
+
assert.ok(seen[0].length >= 43 && seen[1].length >= 43);
|
|
106
|
+
assert.notEqual(cb[0], cb[1]);
|
|
107
|
+
await sup.stop();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("spawn: a process that dies at once or never answers fails the job with infra (no hang), and the start timeout is honoured", async () => {
|
|
111
|
+
for (const args of [["--die-on-start"], ["--hang"]]) {
|
|
112
|
+
const r = await agent2Rig({ spawn: { args }, scenario: "no_change", env: { LOCAL_AGENT_AGENT2_START_TIMEOUT_MS: "1500" } });
|
|
113
|
+
const t0 = Date.now();
|
|
114
|
+
const a = r.assign();
|
|
115
|
+
await a.acked();
|
|
116
|
+
await r.done(a.p.jobId, 10_000);
|
|
117
|
+
assert.deepEqual([terminal(r, a.p.jobId).state, terminal(r, a.p.jobId).errorCode], ["failed", "infra"], args[0]);
|
|
118
|
+
assert.ok(Date.now() - t0 < 6000, args[0]);
|
|
119
|
+
assert.equal(r.fakeLlm.requests.length, 0);
|
|
120
|
+
await r.stop();
|
|
121
|
+
await new Promise((x) => setTimeout(x, 50));
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ------------------------------------------------------------------------------------------------------ the supervisor, with an injected process
|
|
126
|
+
|
|
127
|
+
function fakeProc() {
|
|
128
|
+
const c = new EventEmitter();
|
|
129
|
+
c.stdout = new EventEmitter(); c.stderr = new EventEmitter(); c.pid = 4242; c.killed = []; c.exited = false;
|
|
130
|
+
c.kill = (sig) => { c.killed.push(sig); if (sig === "SIGKILL" || sig === "SIGTERM") queueMicrotask(() => { c.exited = true; c.emit("exit", null, sig); }); return true; };
|
|
131
|
+
return c;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
test("supervisor: health probes that keep failing kill the process and mark it down (the next job restarts it); a healthy one is left alone", async () => {
|
|
135
|
+
let healthy = true;
|
|
136
|
+
const proc = fakeProc();
|
|
137
|
+
const cfg = { ...resolveAgent2RunnerConfig({ LOCAL_AGENT_RUNNER: "agent2", LOCAL_AGENT_AGENT2_CMD: "x", LOCAL_AGENT_AGENT2_HEALTH_INTERVAL_MS: "100", LOCAL_AGENT_AGENT2_HEALTH_FAILURES: "2" }) };
|
|
138
|
+
cfg.healthIntervalMs = 100;
|
|
139
|
+
const downs = [];
|
|
140
|
+
const sup = createAgent2Supervisor({ config: cfg, tenantId: TENANT, bridge: { listen: async () => ({ baseUrl: "http://127.0.0.1:1" }), setCallbackToken() {} }, env: { HOME: HOME_TMP }, log: { warn() {}, info() {} },
|
|
141
|
+
spawnImpl: () => proc, fetchImpl: async () => new Response(JSON.stringify({ status: healthy ? "up" : "down" }), { status: 200 }) });
|
|
142
|
+
sup.on("down", (d) => downs.push(d.why));
|
|
143
|
+
await sup.ensureReady();
|
|
144
|
+
assert.equal(sup.state, "ready");
|
|
145
|
+
await new Promise((x) => setTimeout(x, 350));
|
|
146
|
+
assert.equal(downs.length, 0, "healthy: left alone");
|
|
147
|
+
healthy = false;
|
|
148
|
+
await waitFor(() => downs.length === 1, 3000);
|
|
149
|
+
assert.deepEqual(proc.killed.slice(0, 1), ["SIGKILL"]);
|
|
150
|
+
assert.equal(sup.state, "down");
|
|
151
|
+
await sup.stop();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("supervisor: a process above LOCAL_AGENT_AGENT2_MAX_RSS_BYTES is killed; stop() is SIGTERM first, SIGKILL after the grace", async () => {
|
|
155
|
+
const proc = fakeProc();
|
|
156
|
+
const cfg = resolveAgent2RunnerConfig({ LOCAL_AGENT_RUNNER: "agent2", LOCAL_AGENT_AGENT2_CMD: "x", LOCAL_AGENT_AGENT2_MAX_RSS_BYTES: String(100 * 1024 * 1024) });
|
|
157
|
+
cfg.healthIntervalMs = 100;
|
|
158
|
+
const downs = [];
|
|
159
|
+
const sup = createAgent2Supervisor({ config: cfg, tenantId: TENANT, bridge: { listen: async () => ({ baseUrl: "http://127.0.0.1:1" }), setCallbackToken() {} }, env: { HOME: HOME_TMP }, log: { warn() {}, info() {} },
|
|
160
|
+
spawnImpl: () => proc, rssOf: async () => 500 * 1024 * 1024, fetchImpl: async () => new Response(JSON.stringify({ status: "up" }), { status: 200 }) });
|
|
161
|
+
sup.on("down", (d) => downs.push(d.why));
|
|
162
|
+
await sup.ensureReady();
|
|
163
|
+
await waitFor(() => downs.includes("rss"), 3000);
|
|
164
|
+
assert.equal(sup.stats.killedForRss, 1);
|
|
165
|
+
assert.deepEqual(proc.killed, ["SIGKILL"]);
|
|
166
|
+
|
|
167
|
+
const p2 = fakeProc();
|
|
168
|
+
p2.kill = (sig) => { p2.killed.push(sig); if (sig === "SIGKILL") queueMicrotask(() => p2.emit("exit", null, sig)); return true; }; // ignores SIGTERM
|
|
169
|
+
const cfg2 = { ...resolveAgent2RunnerConfig({ LOCAL_AGENT_RUNNER: "agent2", LOCAL_AGENT_AGENT2_CMD: "x", LOCAL_AGENT_AGENT2_STOP_GRACE_MS: "100" }), healthIntervalMs: 60_000, stopGraceMs: 100 };
|
|
170
|
+
const sup2 = createAgent2Supervisor({ config: cfg2, tenantId: TENANT, bridge: { listen: async () => ({ baseUrl: "http://127.0.0.1:1" }), setCallbackToken() {} }, env: { HOME: HOME_TMP }, log: { warn() {}, info() {} },
|
|
171
|
+
spawnImpl: () => p2, fetchImpl: async () => new Response(JSON.stringify({ status: "up" }), { status: 200 }) });
|
|
172
|
+
await sup2.ensureReady();
|
|
173
|
+
await sup2.stop();
|
|
174
|
+
assert.deepEqual(p2.killed, ["SIGTERM", "SIGKILL"]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("the request cap: a request text longer than the limit is cut on a character boundary before it is handed to agent2; the answer of the model never is", async () => {
|
|
178
|
+
const r = await agent2Rig({ scenario: "no_change", env: { LOCAL_AGENT_AGENT2_MAX_REQUEST_BYTES: "2048" }, behavior: okJson(CANARIES.answer) });
|
|
179
|
+
const big = `${CANARIES.request} ` + "ação ".repeat(900);
|
|
180
|
+
const a = r.assign({ inputs: [{ inline: big.slice(0, 4000), transient: true }] });
|
|
181
|
+
await a.acked(); await r.done(a.p.jobId);
|
|
182
|
+
const text = r.fake.jobs.get(a.p.jobId).request.text;
|
|
183
|
+
assert.ok(Buffer.byteLength(text) <= 2048 && !text.includes("�"));
|
|
184
|
+
await r.stop();
|
|
185
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// F3-08c: agent2's calls to the Local Agent's /workspace API go through the bridge, which vouches for them, so the API's signature check can stay on (LOCAL_AGENT_SIGNATURE_MODE=enforce,
|
|
2
|
+
// the default). Before this, agent2 got 401 "unsigned request" from a real LA in enforce mode. In process: the real bridge + runner against the REAL perimeter middleware of the LA
|
|
3
|
+
// (helpers/fake-la-api.js: auth, body parser, signature check) with a FAKE agent2. The real server process is covered by agent2-workspace-real-server.test.js.
|
|
4
|
+
import { test, after } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import http from "node:http";
|
|
7
|
+
import { closeAllFakes } from "./helpers/fake-llm.js";
|
|
8
|
+
import { agent2Rig, TENANT, waitFor } from "./helpers/agent2-rig.js";
|
|
9
|
+
import { startFakeLaApi } from "./helpers/fake-la-api.js";
|
|
10
|
+
import { newKeyPair } from "./helpers/reference-cp.js";
|
|
11
|
+
import { internalSignatureHeaders } from "../../src/utils/request-signing.js";
|
|
12
|
+
|
|
13
|
+
after(closeAllFakes);
|
|
14
|
+
|
|
15
|
+
const LA_TOKEN = "la-api-token-CANARY-0123456789abcdef0123456789";
|
|
16
|
+
|
|
17
|
+
// laUrl is fixed when the runner is built, so the LA API is started first. It needs the CP's public key to verify nothing but the platform's signature: a key pair is made up front
|
|
18
|
+
// (the ReferenceCp of the rig has its own: that one never signs a /workspace request in these tests)
|
|
19
|
+
async function startAll({ laEnv = {}, scenario = "workspace", env = {} } = {}) {
|
|
20
|
+
const kp = newKeyPair();
|
|
21
|
+
const la = await startFakeLaApi({ env: laEnv, keyId: "vh-cp-test", publicKeyRaw: kp.publicKeyRaw });
|
|
22
|
+
const r = await agent2Rig({ scenario, env: { LOCAL_AGENT_AGENT2_LA_URL: la.url, ...(laEnv.LOCAL_AGENT_TOKEN ? { LOCAL_AGENT_TOKEN: laEnv.LOCAL_AGENT_TOKEN } : {}), ...env } });
|
|
23
|
+
return { r, la, stop: async () => { await r.stop(); await la.close(); } };
|
|
24
|
+
}
|
|
25
|
+
const run = async (r) => { const a = r.assign(); await a.acked(); await r.done(a.p.jobId); return { a, job: r.fake.jobs.get(a.p.jobId) }; };
|
|
26
|
+
|
|
27
|
+
test("enforce (default): agent2's read_file and write_file pass through the bridge; a request straight to the API without the platform signature is still 401", async () => {
|
|
28
|
+
const { r, la, stop } = await startAll();
|
|
29
|
+
const { job } = await run(r);
|
|
30
|
+
assert.equal(job.record.read.status, 200);
|
|
31
|
+
assert.equal(job.record.read.json.content, "// hello from the LA");
|
|
32
|
+
assert.equal(job.record.read.json.root, r.root, "the job's root is what the API sees, whatever agent2 sent");
|
|
33
|
+
assert.equal(job.record.write.status, 200);
|
|
34
|
+
assert.equal(job.record.write.json.written, "src/out.txt");
|
|
35
|
+
const seen = la.requests.filter((q) => q.url.startsWith("/workspace/"));
|
|
36
|
+
assert.equal(seen.length, 2, "exactly the two calls that carried agent2's token");
|
|
37
|
+
for (const q of seen) {
|
|
38
|
+
assert.equal(q.signature.verified, true);
|
|
39
|
+
assert.equal(q.signature.internal, true, "vouched for by this process");
|
|
40
|
+
assert.equal(q.signature.tenantId, TENANT);
|
|
41
|
+
assert.equal(q.signature.workspaceId, "ws-1");
|
|
42
|
+
assert.equal(q.headers["x-workspace-id"], "ws-1");
|
|
43
|
+
assert.equal(q.headers["x-agent-token"], undefined, "no LOCAL_AGENT_TOKEN configured: none sent");
|
|
44
|
+
assert.equal(q.headers["x-vh-internal"].length, internalSignatureHeaders()["X-VH-Internal"].length);
|
|
45
|
+
assert.equal(q.headers.authorization, undefined);
|
|
46
|
+
}
|
|
47
|
+
// the same API, not through the bridge
|
|
48
|
+
for (const [name, headers] of [["no signature", {}], ["agent2's token", { "x-agent-token": r.callbackToken }], ["a guessed internal marker", { "x-vh-internal": internalSignatureHeaders()["X-VH-Internal"].replace(/./g, "a") }],
|
|
49
|
+
["tenant header alone", { "x-vh-tenant-id": TENANT, "x-vh-workspace-id": "ws-1" }]]) {
|
|
50
|
+
const res = await fetch(`${la.url}/workspace/file-content?path=a`, { headers });
|
|
51
|
+
assert.equal(res.status, 401, name);
|
|
52
|
+
assert.equal((await res.json()).reason, "unsigned request", name);
|
|
53
|
+
}
|
|
54
|
+
assert.equal(la.requests.filter((q) => q.url.startsWith("/workspace/")).length, 2, "none of the refused calls reached a route");
|
|
55
|
+
await stop();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("with LOCAL_AGENT_TOKEN the bridge adds the API's X-Agent-Token itself; agent2 never holds it; the API still refuses a caller without it", async () => {
|
|
59
|
+
const { r, la, stop } = await startAll({ laEnv: { LOCAL_AGENT_TOKEN: LA_TOKEN } });
|
|
60
|
+
const { job } = await run(r);
|
|
61
|
+
assert.equal(job.record.read.status, 200);
|
|
62
|
+
assert.equal(job.record.write.status, 200);
|
|
63
|
+
for (const q of la.requests.filter((x) => x.url.startsWith("/workspace/"))) assert.equal(q.headers["x-agent-token"], LA_TOKEN);
|
|
64
|
+
assert.equal((await fetch(`${la.url}/workspace/file-content?path=a`)).status, 401, "no X-Agent-Token");
|
|
65
|
+
assert.equal((await fetch(`${la.url}/workspace/file-content?path=a`, { headers: { "x-agent-token": LA_TOKEN } })).status, 401, "token but no signature");
|
|
66
|
+
const dump = r.everything();
|
|
67
|
+
assert.ok(!dump.includes(LA_TOKEN) && !JSON.stringify(job.record).includes(LA_TOKEN), "the API token is in no log, audit, stat or answer");
|
|
68
|
+
await stop();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("the door: agent2's token opens it; no token / a wrong token / the LLM token do not; only /workspace paths; no traversal; the job's root only; methods and sizes bounded", async () => {
|
|
72
|
+
const { r, la, stop } = await startAll({ scenario: "stuck", env: { LOCAL_AGENT_AGENT2_WORKSPACE_MAX_BODY_BYTES: "4096" } });
|
|
73
|
+
const a = r.assign(); await a.acked(); await waitFor(() => r.fake.jobs.size === 1);
|
|
74
|
+
const job = r.fake.jobs.get(a.p.jobId);
|
|
75
|
+
const h = job.h;
|
|
76
|
+
const call = (m, p, b, o) => h.workspace(m, p, b, o);
|
|
77
|
+
assert.equal((await call("GET", "/workspace/file-content?path=a")).status, 200);
|
|
78
|
+
assert.equal((await call("GET", "/workspace/file-content?path=a", undefined, { tokenOverride: "" })).status, 401);
|
|
79
|
+
assert.equal((await call("GET", "/workspace/file-content?path=a", undefined, { tokenOverride: "z".repeat(43) })).status, 401);
|
|
80
|
+
assert.equal((await call("GET", "/workspace/file-content?path=a", undefined, { tokenOverride: job.llm.key })).status, 401, "the LLM token is not the door's");
|
|
81
|
+
assert.equal((await call("GET", "/workspace/file-content?path=a", undefined, { tokenOverride: undefined, headers: { authorization: `Bearer ${r.callbackToken}` } })).status, 200, "the bearer form works too");
|
|
82
|
+
// only /workspace; nothing else of the API
|
|
83
|
+
assert.equal((await call("POST", "/setup/run-command", { command: "id" })).status, 404);
|
|
84
|
+
assert.equal((await call("GET", "/health")).status, 404);
|
|
85
|
+
// traversal and encodings never reach the API (raw requests: fetch() would normalise the dots away before they leave)
|
|
86
|
+
const raw = (p) => new Promise((resolve, reject) => {
|
|
87
|
+
const q = http.request({ host: "127.0.0.1", port: r.bridgePort, method: "POST", path: p, headers: { "x-agent-token": r.callbackToken, "content-type": "application/json" } }, (res) => { res.resume(); res.on("end", () => resolve(res.statusCode)); });
|
|
88
|
+
q.on("error", reject); q.end("{}");
|
|
89
|
+
});
|
|
90
|
+
for (const p of ["/workspace/../setup/run-command", "/workspace/%2e%2e/setup/run-command", "/workspace/a/%2e%2e/b", "/workspace//x", "/workspace/a%20b", "/workspace/a\\b", "/workspace/./x", "/workspace/a/../../setup/run-command", "/workspace/x#frag"]) {
|
|
91
|
+
assert.equal(await raw(p), 403, p);
|
|
92
|
+
}
|
|
93
|
+
assert.equal(la.requests.filter((q) => q.url.startsWith("/setup/")).length, 0, "/setup/run-command was never reached");
|
|
94
|
+
// the root: the header it sends must be the job's; none is added for it
|
|
95
|
+
assert.equal((await call("GET", "/workspace/file-content?path=a", undefined, { headers: { "x-workspace-root": "/etc" } })).status, 403);
|
|
96
|
+
assert.equal((await call("GET", "/workspace/file-content?path=a", undefined, { headers: { "x-workspace-root": r.root } })).status, 200);
|
|
97
|
+
assert.equal((await call("GET", "/workspace/file-content?path=a", undefined, { headers: { "x-workspace-root": `${r.root}/../elsewhere` } })).status, 403);
|
|
98
|
+
// headers agent2 tries to inject are not forwarded
|
|
99
|
+
await call("GET", "/workspace/file-content?path=a", undefined, { headers: { "x-vh-internal": "x", "x-vh-tenant-id": "9d1f6a2c-0000-4000-8000-000000000000", "x-vh-workspace-id": "other", "x-workspace-id": "other", cookie: "c=1" } });
|
|
100
|
+
const last = la.requests.at(-1);
|
|
101
|
+
assert.equal(last.signature.tenantId, TENANT);
|
|
102
|
+
assert.equal(last.signature.workspaceId, "ws-1");
|
|
103
|
+
assert.equal(last.headers["x-workspace-id"], "ws-1");
|
|
104
|
+
assert.equal(last.headers.cookie, undefined);
|
|
105
|
+
// methods and sizes
|
|
106
|
+
assert.equal((await call("DELETE", "/workspace/thing")).status, 200);
|
|
107
|
+
assert.equal((await h.workspace("PATCH", "/workspace/thing", { a: 1 })).status, 405);
|
|
108
|
+
assert.equal((await call("POST", "/workspace/echo", { blob: "x".repeat(10_000) })).status, 413, "over LOCAL_AGENT_AGENT2_WORKSPACE_MAX_BODY_BYTES");
|
|
109
|
+
assert.equal((await call("POST", "/workspace/echo", { blob: "x".repeat(100) })).status, 200);
|
|
110
|
+
await r.cancel(a.p.jobId);
|
|
111
|
+
await stop();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("the door is closed without a running job and after it; an unreachable API is a 502 (nothing leaks)", async () => {
|
|
115
|
+
const { r, la, stop } = await startAll();
|
|
116
|
+
const { a, job } = await run(r);
|
|
117
|
+
const afterJob = await job.h.workspace("GET", "/workspace/file-content?path=a");
|
|
118
|
+
assert.equal(afterJob.status, 409, "the job is over: no active job");
|
|
119
|
+
assert.deepEqual(afterJob.json.error.code, "no_active_job");
|
|
120
|
+
await stop();
|
|
121
|
+
|
|
122
|
+
const dead = await agent2Rig({ scenario: "workspace", env: { LOCAL_AGENT_AGENT2_LA_URL: "http://127.0.0.1:1" } });
|
|
123
|
+
const b = dead.assign(); await b.acked(); await dead.done(b.p.jobId);
|
|
124
|
+
assert.equal(dead.fake.jobs.get(b.p.jobId).record.read.status, 502);
|
|
125
|
+
assert.equal(dead.fake.jobs.get(b.p.jobId).record.read.json.error.code, "workspace_unreachable");
|
|
126
|
+
await dead.stop();
|
|
127
|
+
void a; void la;
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("a response is passed through as it is (status, content type, body); a hung API is cut at LOCAL_AGENT_AGENT2_WORKSPACE_TIMEOUT_MS", async () => {
|
|
131
|
+
const { r, la, stop } = await startAll({ scenario: "stuck", env: { LOCAL_AGENT_AGENT2_WORKSPACE_TIMEOUT_MS: "1000" } });
|
|
132
|
+
const a = r.assign(); await a.acked(); await waitFor(() => r.fake.jobs.size === 1);
|
|
133
|
+
const h = r.fake.jobs.get(a.p.jobId).h;
|
|
134
|
+
const res = await h.workspace("GET", "/workspace/file-content?path=a");
|
|
135
|
+
assert.deepEqual([res.status, res.json.ok], [200, true]);
|
|
136
|
+
const missing = await h.workspace("GET", "/workspace/nope");
|
|
137
|
+
assert.equal(missing.status, 404, "the API's own 404 comes back");
|
|
138
|
+
assert.equal((await h.workspace("GET", "/workspace/slow")).status, 200);
|
|
139
|
+
void la;
|
|
140
|
+
await r.cancel(a.p.jobId);
|
|
141
|
+
await stop();
|
|
142
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// F3-08c, the real thing for the /workspace door: the REAL Local Agent process (node src/server.js) in LOCAL_AGENT_SIGNATURE_MODE=enforce, a REAL v2 session against the reference
|
|
2
|
+
// CP, LOCAL_AGENT_RUNNER=agent2 in attach mode with a FAKE agent2 that does what agent2 does to the repository: read_file and write_file through AGENT2_LA_WORKSPACE_URL.
|
|
3
|
+
// Before F3-08c this was a 401 "unsigned request". Not proven here: the real agent2 jar, the real gateway, a model (the fake agent2 never calls one).
|
|
4
|
+
import { test, after } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import crypto from "node:crypto";
|
|
9
|
+
import { ReferenceCpServer, TENANT, AGENT, waitFor, uuid } from "./helpers/reference-cp.js";
|
|
10
|
+
import { runAgent, freePort } from "./helpers/spawn-agent.js";
|
|
11
|
+
import { startFakeAgent2 } from "./helpers/fake-agent2.js";
|
|
12
|
+
import { keySet, mkKey, sha, closeAllFakes } from "./helpers/fake-llm.js";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
|
|
15
|
+
const CLI = fileURLToPath(new URL("./helpers/fake-agent2-cli.js", import.meta.url));
|
|
16
|
+
|
|
17
|
+
after(closeAllFakes);
|
|
18
|
+
|
|
19
|
+
async function scenario({ laToken }) {
|
|
20
|
+
const srv = await new ReferenceCpServer({ requireEnforce: true, capabilities: ["policy.check"] }).start();
|
|
21
|
+
const laPort = await freePort();
|
|
22
|
+
const bridgePort = await freePort();
|
|
23
|
+
const apiToken = crypto.randomBytes(32).toString("base64url");
|
|
24
|
+
const callbackToken = crypto.randomBytes(32).toString("base64url");
|
|
25
|
+
const fake = await startFakeAgent2({ token: apiToken, tenantId: TENANT, scenario: "workspace", llmBase: () => `http://127.0.0.1:${bridgePort}/v1`, callbackUrl: () => `http://127.0.0.1:${bridgePort}`, callbackToken: () => callbackToken,
|
|
26
|
+
workspaceUrl: () => `http://127.0.0.1:${bridgePort}`, workspaceToken: () => callbackToken });
|
|
27
|
+
const key = mkKey("RS");
|
|
28
|
+
const out = { direct: {}, jobId: uuid() };
|
|
29
|
+
let ws = null;
|
|
30
|
+
const state = { keySent: false, assigned: false };
|
|
31
|
+
const direct = async (name, headers = {}) => { const r = await fetch(`http://127.0.0.1:${laPort}/workspace/file-content?path=src%2Fhello.js`, { headers }); out.direct[name] = { status: r.status, body: await r.json().catch(() => null) }; };
|
|
32
|
+
try {
|
|
33
|
+
await runAgent({
|
|
34
|
+
srv, cfg: { authorizationId: AGENT, tenantId: TENANT }, ms: 40_000,
|
|
35
|
+
prepare: async (dir) => { ws = dir; fs.mkdirSync(path.join(dir, "src")); fs.writeFileSync(path.join(dir, "src", "hello.js"), "// REAL-SERVER-FILE-CANARY\nconsole.log('hi');\n"); },
|
|
36
|
+
env: {
|
|
37
|
+
PORT: String(laPort), TENANT_ID: TENANT, LOCAL_AGENT_PROTOCOL_V2: "true", LOCAL_AGENT_SIGNATURE_MODE: "enforce", LOCAL_AGENT_SIGNING_PUBLIC_KEYS: `${srv.keyId}=${srv.publicKeyRaw}`,
|
|
38
|
+
LOCAL_AGENT_DLP_MODE: "enforce", LOCAL_AGENT_LLM_KEY: "true", LOCAL_AGENT_LLM_CLIENT: "true", LOCAL_AGENT_JOBS: "true", LOCAL_AGENT_RUNNER: "agent2",
|
|
39
|
+
LOCAL_AGENT_AGENT2_URL: fake.baseUrl, LOCAL_AGENT_AGENT2_TOKEN: apiToken, LOCAL_AGENT_AGENT2_BRIDGE_PORT: String(bridgePort), LOCAL_AGENT_AGENT2_CALLBACK_TOKEN: callbackToken,
|
|
40
|
+
...(laToken ? { LOCAL_AGENT_TOKEN: laToken } : {}),
|
|
41
|
+
},
|
|
42
|
+
until: async () => {
|
|
43
|
+
if (srv.cp.state !== "ready") return false;
|
|
44
|
+
if (!state.keySent) { state.keySent = true; srv.cp.sendData("config.update", { llmKey: keySet(key) }); return false; }
|
|
45
|
+
if (!state.assigned && srv.cp.acksFromLa.length >= 1) {
|
|
46
|
+
state.assigned = true;
|
|
47
|
+
srv.cp.sendData("job.assign", { jobId: out.jobId, kind: "fix", repoRef: { provider: "github", org: "acme", repo: "app", branch: "main" }, policy: { operationMode: "assist", automationLevel: 2, ceiling: "assist" },
|
|
48
|
+
budget: { tokens: 50_000, seconds: 60 }, llm: { alias: "vh-claude-sonnet", keyRef: sha(key) }, incidentRef: { id: "inc-42" }, inputs: [{ inline: "please fix the login bug", transient: true }] }, { workspaceId: "default" });
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
if (!srv.cp.received.some((f) => f.type === "job.result" && f.payload.jobId === out.jobId)) return false;
|
|
52
|
+
// the job is over: the file agent2 wrote is read before the harness removes the workspace
|
|
53
|
+
out.written = ws && fs.existsSync(path.join(ws, "src", "out.txt")) ? fs.readFileSync(path.join(ws, "src", "out.txt"), "utf8") : null;
|
|
54
|
+
// the API of this very process, straight, is still guarded by the signature check
|
|
55
|
+
await direct("none");
|
|
56
|
+
await direct("agent2Token", { "x-agent-token": callbackToken });
|
|
57
|
+
if (laToken) await direct("laTokenOnly", { "x-agent-token": laToken });
|
|
58
|
+
await direct("forgedMarker", { "x-vh-internal": "A".repeat(43), "x-vh-tenant-id": TENANT, "x-vh-workspace-id": "default" });
|
|
59
|
+
return true;
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
out.record = fake.jobs.get(out.jobId)?.record;
|
|
63
|
+
out.frames = srv.cp.received.filter((f) => f.payload && (f.payload.jobId === out.jobId));
|
|
64
|
+
out.cpErrors = [...srv.cp.errorsSent];
|
|
65
|
+
out.key = key;
|
|
66
|
+
out.wire = JSON.stringify(srv.cp.rawIn);
|
|
67
|
+
} finally { await fake.close(); await srv.stop(); }
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
test("REAL server, enforce: agent2's read_file and write_file reach the workspace through the bridge (they were 401); the same API called straight is still refused 401", async () => {
|
|
72
|
+
const o = await scenario({});
|
|
73
|
+
assert.ok(o.record, "the job reached agent2");
|
|
74
|
+
assert.equal(o.record.read.status, 200, JSON.stringify(o.record.read.json));
|
|
75
|
+
assert.match(String(o.record.read.json?.content ?? o.record.read.text), /REAL-SERVER-FILE-CANARY/);
|
|
76
|
+
assert.equal(o.record.write.status, 200, JSON.stringify(o.record.write.json));
|
|
77
|
+
assert.equal(o.written, "written by agent2 through the bridge", "the file really is in the customer's workspace");
|
|
78
|
+
assert.equal(o.record.noToken.status, 401);
|
|
79
|
+
assert.equal(o.record.badToken.status, 401);
|
|
80
|
+
assert.equal(o.record.otherApi.status, 404, "nothing else of the API is behind the bridge");
|
|
81
|
+
for (const name of ["none", "agent2Token", "forgedMarker"]) { assert.equal(o.direct[name].status, 401, name); assert.equal(o.direct[name].body.error, "invalid command signature", name); assert.equal(o.direct[name].body.reason, "unsigned request", name); }
|
|
82
|
+
const result = o.frames.find((f) => f.type === "job.result");
|
|
83
|
+
assert.equal(result.payload.outcome, "no_change");
|
|
84
|
+
assert.deepEqual(o.cpErrors, []);
|
|
85
|
+
assert.ok(!o.wire.includes("REAL-SERVER-FILE-CANARY") && !o.wire.includes(o.key), "the file content and the key never go to the control plane");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("REAL server, enforce + LOCAL_AGENT_TOKEN: the bridge adds the API's X-Agent-Token; the token alone, or without the signature, is refused", async () => {
|
|
89
|
+
const laToken = `la-token-${crypto.randomBytes(24).toString("hex")}`;
|
|
90
|
+
const o = await scenario({ laToken });
|
|
91
|
+
assert.equal(o.record.read.status, 200, JSON.stringify(o.record.read.json));
|
|
92
|
+
assert.equal(o.record.write.status, 200, JSON.stringify(o.record.write.json));
|
|
93
|
+
assert.equal(o.written, "written by agent2 through the bridge");
|
|
94
|
+
assert.equal(o.direct.none.status, 401, "no X-Agent-Token");
|
|
95
|
+
assert.equal(o.direct.agent2Token.status, 401, "agent2's token is not the API's");
|
|
96
|
+
assert.equal(o.direct.laTokenOnly.status, 401, "the API's token without the platform signature");
|
|
97
|
+
assert.equal(o.direct.laTokenOnly.body.reason, "unsigned request");
|
|
98
|
+
assert.ok(!o.wire.includes(laToken) && !JSON.stringify(o.record).includes(laToken));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("REAL server, spawn mode: SIGTERM to the agent stops the agent2 process it supervised (no orphan), and the pidfile goes with it", async () => {
|
|
102
|
+
const srv = await new ReferenceCpServer({ requireEnforce: true, capabilities: ["policy.check"] }).start();
|
|
103
|
+
const dir = fs.mkdtempSync(path.join(fs.realpathSync(process.env.TMPDIR || "/tmp"), "a2term-"));
|
|
104
|
+
const pidFile = path.join(dir, "agent2.pid");
|
|
105
|
+
const key = mkKey("TM");
|
|
106
|
+
const jobId = uuid();
|
|
107
|
+
const state = { keySent: false, assigned: false, agent2Pid: 0, termSent: false, dead: false };
|
|
108
|
+
const alive = (pid) => { try { process.kill(pid, 0); return true; } catch { return false; } };
|
|
109
|
+
try {
|
|
110
|
+
await runAgent({
|
|
111
|
+
srv, cfg: { authorizationId: AGENT, tenantId: TENANT }, ms: 40_000,
|
|
112
|
+
prepare: async (d) => { fs.mkdirSync(path.join(d, "src")); fs.writeFileSync(path.join(d, "src", "hello.js"), "x\n"); },
|
|
113
|
+
env: {
|
|
114
|
+
PORT: String(await freePort()), TENANT_ID: TENANT, LOCAL_AGENT_PROTOCOL_V2: "true", LOCAL_AGENT_SIGNATURE_MODE: "enforce", LOCAL_AGENT_SIGNING_PUBLIC_KEYS: `${srv.keyId}=${srv.publicKeyRaw}`,
|
|
115
|
+
LOCAL_AGENT_DLP_MODE: "enforce", LOCAL_AGENT_LLM_KEY: "true", LOCAL_AGENT_LLM_CLIENT: "true", LOCAL_AGENT_JOBS: "true", LOCAL_AGENT_RUNNER: "agent2",
|
|
116
|
+
LOCAL_AGENT_AGENT2_CMD: JSON.stringify([process.execPath, CLI, "--scenario=no_change"]), LOCAL_AGENT_AGENT2_PORT: String(await freePort()), LOCAL_AGENT_AGENT2_PIDFILE: pidFile, LOCAL_AGENT_AGENT2_START_TIMEOUT_MS: "10000",
|
|
117
|
+
},
|
|
118
|
+
until: async ({ child }) => {
|
|
119
|
+
if (srv.cp.state !== "ready") return false;
|
|
120
|
+
if (!state.keySent) { state.keySent = true; srv.cp.sendData("config.update", { llmKey: keySet(key) }); return false; }
|
|
121
|
+
if (!state.assigned && srv.cp.acksFromLa.length >= 1) {
|
|
122
|
+
state.assigned = true;
|
|
123
|
+
srv.cp.sendData("job.assign", { jobId, kind: "fix", repoRef: { provider: "github", org: "acme", repo: "app", branch: "main" }, policy: { operationMode: "assist", automationLevel: 2, ceiling: "assist" },
|
|
124
|
+
budget: { tokens: 50_000, seconds: 60 }, llm: { alias: "vh-claude-sonnet", keyRef: sha(key) }, incidentRef: { id: "inc-42" }, inputs: [{ inline: "please fix", transient: true }] }, { workspaceId: "default" });
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
if (!srv.cp.received.some((f) => f.type === "job.result" && f.payload.jobId === jobId)) return false;
|
|
128
|
+
if (!state.agent2Pid) { state.agent2Pid = JSON.parse(fs.readFileSync(pidFile, "utf8")).pid; assert.ok(alive(state.agent2Pid), "agent2 runs while the agent runs"); }
|
|
129
|
+
if (!state.termSent) { state.termSent = true; child.kill("SIGTERM"); return false; }
|
|
130
|
+
state.dead = !alive(state.agent2Pid);
|
|
131
|
+
return state.dead;
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
} finally { await srv.stop(); if (state.agent2Pid) { try { process.kill(state.agent2Pid, "SIGKILL"); } catch { /* gone */ } } fs.rmSync(dir, { recursive: true, force: true }); }
|
|
135
|
+
assert.ok(state.agent2Pid, "an agent2 process was started");
|
|
136
|
+
assert.equal(state.dead, true, "the supervised agent2 did not outlive the SIGTERM of the agent");
|
|
137
|
+
});
|
|
@@ -15,8 +15,8 @@ const VECTORS = path.join(path.dirname(fileURLToPath(import.meta.url)), "../cont
|
|
|
15
15
|
const load = (f) => JSON.parse(fs.readFileSync(path.join(VECTORS, f), "utf8")).vectors;
|
|
16
16
|
const sha = (s) => crypto.createHash("sha256").update(Buffer.from(s, "utf8")).digest("hex");
|
|
17
17
|
|
|
18
|
-
test("the copy is revision 2.1 and every 2.1 vector group is present with at least its valid and invalid cases", () => {
|
|
19
|
-
assert.equal(contract.index.revision, "2.
|
|
18
|
+
test("the copy is revision 2.2 (2.1 is a subset) and every 2.1 vector group is present with at least its valid and invalid cases", () => {
|
|
19
|
+
assert.equal(contract.index.revision, "2.2");
|
|
20
20
|
const groups = [
|
|
21
21
|
["config.update", /^llm-key-/, 6, 27], // (a): llm-key-* in config.update (the ack and register ones are checked below)
|
|
22
22
|
["webhook.forward", /^wh-chunk-/, 9, 24], // (b)
|
|
@@ -82,10 +82,10 @@ for (const file of vectorFiles) {
|
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
test("vectors: counts (
|
|
86
|
-
assert.equal(valid + invalid,
|
|
87
|
-
assert.equal(valid,
|
|
88
|
-
assert.equal(invalid,
|
|
85
|
+
test("vectors: counts (363 frames = 82 valid + 281 invalid in this copy (contract 2.2: + policy.check 5+20, policy.decision 5+16), plus the bodies of file.list)", () => {
|
|
86
|
+
assert.equal(valid + invalid, 363);
|
|
87
|
+
assert.equal(valid, 82);
|
|
88
|
+
assert.equal(invalid, 281);
|
|
89
89
|
assert.equal(bodyVectors, 54, "file.list.body.vectors.json (F3-07c): 54 bodies");
|
|
90
90
|
});
|
|
91
91
|
|
|
@@ -108,8 +108,8 @@ test("error codes: fatal and retryable sets mirror the gateway (LaV2ErrorCode)",
|
|
|
108
108
|
});
|
|
109
109
|
|
|
110
110
|
test("allowlist: the LA receives only cp->la/both types and sends only la->cp/both types; v1 commands are not types", () => {
|
|
111
|
-
assert.deepEqual([...contract.inboundTypes].sort(), ["ack", "approval.decision", "config.update", "content.request", "error", "job.assign", "job.cancel", "ready", "scan.upload", "webhook.forward"]);
|
|
112
|
-
assert.deepEqual([...contract.outboundTypes].sort(), ["ack", "alert", "audit.event", "content.index", "content.response", "error", "heartbeat", "hello", "incident.candidate", "job.event", "job.result", "log.stats", "register", "scan.uploaded"]);
|
|
111
|
+
assert.deepEqual([...contract.inboundTypes].sort(), ["ack", "approval.decision", "config.update", "content.request", "error", "job.assign", "job.cancel", "policy.decision", "ready", "scan.upload", "webhook.forward"]);
|
|
112
|
+
assert.deepEqual([...contract.outboundTypes].sort(), ["ack", "alert", "audit.event", "content.index", "content.response", "error", "heartbeat", "hello", "incident.candidate", "job.event", "job.result", "log.stats", "policy.check", "register", "scan.uploaded"]);
|
|
113
113
|
for (const v1 of ["mcp.execute-command", "workspace.open", "health", "shell.exec", "__proto__", "constructor", "toString"]) {
|
|
114
114
|
assert.equal(contract.canReceive(v1), false, v1);
|
|
115
115
|
assert.equal(contract.canSend(v1), false, v1);
|
|
@@ -121,8 +121,9 @@ test("limits come from index.json and are the documented ones", () => {
|
|
|
121
121
|
maxFrameBytes: 49152, chunkRawBytes: 32768, maxChunks: 64, maxContentBytes: 2097152, maxRetries: 3, ackTimeoutMs: 5000,
|
|
122
122
|
handshakeTimeoutMs: 10000, heartbeatSeconds: 30, sessionDeadSeconds: 90, dedupeWindowSeconds: 600, clockSkewSeconds: 60,
|
|
123
123
|
webhookChunkRawBytes: 16384, webhookMaxChunks: 64, webhookMaxBytes: 1048576, webhookSingleFrameRawBytes: 9216, webhookAssemblySeconds: 30, webhookMaxOpenAssemblies: 4,
|
|
124
|
+
policyCheckPerMinute: 120, policyCheckTimeoutMs: 10000,
|
|
124
125
|
});
|
|
125
|
-
assert.equal(contract.index.revision, "2.
|
|
126
|
+
assert.equal(contract.index.revision, "2.2");
|
|
126
127
|
});
|
|
127
128
|
|
|
128
129
|
// ------------------------------------------------------------------------------------------------ strict JSON
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Rig of the agent2 runner tests (F3-08c): the REAL v2 session + buildV2Link (every flag, LOCAL_AGENT_RUNNER=agent2 in attach mode) against the ReferenceCp, the key
|
|
2
|
+
// holder, the LLM client and a fake AI gateway, with a FAKE agent2 (helpers/fake-agent2.js) in the place of the sidecar. What it proves is limited by those fakes.
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import net from "node:net";
|
|
7
|
+
import crypto from "node:crypto";
|
|
8
|
+
import { ReferenceCp, TENANT, AGENT, uuid, waitFor } from "./reference-cp.js";
|
|
9
|
+
import { signingFor } from "./la-rig.js";
|
|
10
|
+
import { write, tmpDir } from "./content-rig.js";
|
|
11
|
+
import { startFakeLlm, okJson, okMessages, byPath, sha, mkKey, keySet } from "./fake-llm.js";
|
|
12
|
+
import { startFakeAgent2 } from "./fake-agent2.js";
|
|
13
|
+
import { buildV2Link } from "../../../src/la-protocol-v2/runtime.js";
|
|
14
|
+
|
|
15
|
+
export { TENANT, AGENT, uuid, waitFor, sha };
|
|
16
|
+
export { okJson, okMessages, byPath };
|
|
17
|
+
export const GH = "ghp_" + "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8";
|
|
18
|
+
export const CANARIES = { request: "REQUEST-CANARY-5521", file: "FILE-CANARY-7714", answer: "ANSWER-CANARY-3306", cmd: "SECRET-COMMAND-CANARY" };
|
|
19
|
+
|
|
20
|
+
export const freePort = () => new Promise((resolve) => { const s = net.createServer(); s.listen(0, "127.0.0.1", () => { const p = s.address().port; s.close(() => resolve(p)); }); });
|
|
21
|
+
export const b64url = (b) => Buffer.from(b).toString("base64url");
|
|
22
|
+
|
|
23
|
+
export const BASE_ENV = { LOCAL_AGENT_DLP_MODE: "enforce", LOCAL_AGENT_CONTENT_HANDLERS: "true", LOCAL_AGENT_LLM_KEY: "true", LOCAL_AGENT_LLM_CLIENT: "true", LOCAL_AGENT_JOBS: "true",
|
|
24
|
+
LOCAL_AGENT_RUNNER: "agent2", LOCAL_AGENT_DLP_BLOCK_CLASSES: "private-key", LOCAL_AGENT_LLM_BACKOFF_MS: "0", LOCAL_AGENT_JOBS_EVENT_MIN_MS: "50", LOCAL_AGENT_JOBS_CANCEL_GRACE_MS: "1500", LOCAL_AGENT_AGENT2_CANCEL_WAIT_MS: "1000",
|
|
25
|
+
LOCAL_AGENT_AGENT2_HEALTH_INTERVAL_MS: "60000", LOCAL_AGENT_AGENT2_AUDIT_RETRY_MS: "20" }; // LOCAL_AGENT_AGENT2_PIDFILE is set per rig (below)
|
|
26
|
+
|
|
27
|
+
/** A vhapr1 token as the gateway's HumanApprovalTokens would mint it (Ed25519 over the platform key). `subject` = what the token is bound to. */
|
|
28
|
+
export function mintApprovalToken({ privateKey, keyId, tenantId = TENANT, workspaceId = "ws-1", subject, by = "u-edu", ttl = 600, iat = Math.floor(Date.now() / 1000), jti = crypto.randomBytes(12).toString("base64url"), typ = "vh-human-approval" }) {
|
|
29
|
+
const payload = b64url(JSON.stringify({ typ, tid: tenantId, wid: workspaceId, sha256: crypto.createHash("sha256").update(subject, "utf8").digest("hex"), iat, exp: iat + ttl, jti, by, decision: "dec-1" }));
|
|
30
|
+
const sig = crypto.sign(null, Buffer.from(`VH-HUMAN-APPROVAL-V1\n${keyId}\n${payload}`, "utf8"), privateKey);
|
|
31
|
+
return `vhapr1.${keyId}.${payload}.${b64url(sig)}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const CLI = fileURLToPath(new URL("./fake-agent2-cli.js", import.meta.url));
|
|
35
|
+
|
|
36
|
+
export async function agent2Rig({ spawn = null, scenario = "ok_pr", env = {}, behavior = byPath({ chat: okJson(CANARIES.answer, { promptTokens: 40, completionTokens: 11, cost: "0.0042" }), messages: okMessages(CANARIES.answer, { promptTokens: 40, completionTokens: 11, cost: "0.0042" }) }), supportList = true, frameApprovalId = true, setKey = true, policy = null, cpPolicyCapability = true, noRunnerEnv = false, assignOver } = {}) {
|
|
37
|
+
const root = tmpDir("f308c-");
|
|
38
|
+
write(root, "src/hello.js", `// ${CANARIES.file}\nconst token = "${GH}";\nconsole.log("hello");\n`);
|
|
39
|
+
const fakeLlm = await startFakeLlm(behavior);
|
|
40
|
+
const cp = new ReferenceCp({ capabilities: cpPolicyCapability ? ["policy.check"] : [] });
|
|
41
|
+
const pidFile = path.join(root, ".agent2-runner.pid"); // never the real ~/.deepdebug
|
|
42
|
+
const logs = [];
|
|
43
|
+
const errors = [];
|
|
44
|
+
const log = { info: (m) => logs.push(m), log: (m) => logs.push(m), warn: (m) => logs.push(m), error: (m) => errors.push(m) };
|
|
45
|
+
const bridgePort = await freePort();
|
|
46
|
+
const bridgeBase = `http://127.0.0.1:${bridgePort}`;
|
|
47
|
+
const apiToken = crypto.randomBytes(32).toString("base64url");
|
|
48
|
+
const callbackToken = crypto.randomBytes(32).toString("base64url");
|
|
49
|
+
// spawn mode: the runner starts the fake agent2 as a PROCESS (fake-agent2-cli.js); `env` is the (fake) environment of the Local Agent, so it carries PATH/HOME and some secrets that must NOT reach the child
|
|
50
|
+
const apiPort = spawn ? await freePort() : 0;
|
|
51
|
+
const fake = spawn ? { port: apiPort, jobs: new Map(), requests: [], approvals: new Map(), async close() {} } : await startFakeAgent2({ token: apiToken, tenantId: TENANT, scenario, llmBase: () => `${bridgeBase}/v1`, callbackUrl: () => bridgeBase, callbackToken: () => callbackToken, workspaceUrl: () => bridgeBase, workspaceToken: () => callbackToken, supportList, frameApprovalId });
|
|
52
|
+
const spawnEnv = spawn ? { PATH: process.env.PATH, HOME: process.env.HOME, LOCAL_AGENT_AGENT2_CMD: JSON.stringify(spawn.cmd || [process.execPath, CLI, `--scenario=${scenario}`, ...(spawn.args || [])]), LOCAL_AGENT_AGENT2_PORT: String(apiPort),
|
|
53
|
+
LOCAL_AGENT_AGENT2_START_TIMEOUT_MS: "5000", ...(spawn.env || {}) } : {};
|
|
54
|
+
const fullEnv = spawn ? { ...BASE_ENV, LOCAL_AGENT_AGENT2_PIDFILE: pidFile, ...spawnEnv, ...env } : noRunnerEnv ? { ...BASE_ENV, ...env } : { ...BASE_ENV, LOCAL_AGENT_AGENT2_PIDFILE: pidFile, LOCAL_AGENT_AGENT2_URL: fake.baseUrl, LOCAL_AGENT_AGENT2_TOKEN: apiToken, LOCAL_AGENT_AGENT2_BRIDGE_PORT: String(bridgePort), LOCAL_AGENT_AGENT2_CALLBACK_TOKEN: callbackToken, ...env };
|
|
55
|
+
const wsManager = { list: () => [{ id: "ws-1", root, tenantId: TENANT }] };
|
|
56
|
+
const results = [];
|
|
57
|
+
const signing = signingFor({ keyId: cp.keyId, publicKeyRaw: cp.publicKeyRaw }, "enforce");
|
|
58
|
+
const link = await buildV2Link({ tenantId: TENANT, agentId: AGENT, signing, wsManager, getWorkspaceRoot: () => root, log, env: fullEnv, llmFetch: fakeLlm.fetchImpl, onJobResult: (r) => results.push(r) });
|
|
59
|
+
const transport = { send: (t) => cp.feed(t), close() {}, bufferedAmount: () => 0 };
|
|
60
|
+
cp.attach((t) => queueMicrotask(() => link.session.handleText(t, transport)), () => {});
|
|
61
|
+
// the CP's answer to a policy.check (contract 2.2): `policy(checkFrame, cp)` returns the policy.decision payload, or null/undefined = no answer (the LA must fail closed)
|
|
62
|
+
const checks = [];
|
|
63
|
+
cp.onReceived = (m) => {
|
|
64
|
+
if (m.type !== "policy.check") return;
|
|
65
|
+
checks.push(m);
|
|
66
|
+
if (!policy) return;
|
|
67
|
+
const p = policy(m, cp);
|
|
68
|
+
if (p) cp.sendData("policy.decision", p, { requestId: m.requestId, workspaceId: m.workspaceId });
|
|
69
|
+
};
|
|
70
|
+
link.session.attach(transport);
|
|
71
|
+
await waitFor(() => link.session.state === "ready");
|
|
72
|
+
const key = mkKey("A2");
|
|
73
|
+
if (setKey && link.llm) { cp.sendData("config.update", { llmKey: keySet(key) }); await waitFor(() => cp.acksFromLa.length >= 1); }
|
|
74
|
+
const refOf = (rel) => link.content?.mintRef({ workspaceId: "ws-1", kind: "file", rel })?.id ?? "A".repeat(43);
|
|
75
|
+
const ack0 = () => cp.acksFromLa.length;
|
|
76
|
+
const r = {
|
|
77
|
+
cp, link, fake, fakeLlm, root, key, checks, pidFile, logs, errors, results, refOf, bridgePort, bridgeBase, apiToken, callbackToken, env: fullEnv, signing,
|
|
78
|
+
assign(over = {}, workspaceId = "ws-1") {
|
|
79
|
+
const p = { jobId: uuid(), kind: "fix", repoRef: { provider: "github", org: "acme", repo: "app", branch: "main" }, policy: { operationMode: "assist", automationLevel: 2, ceiling: "assist" },
|
|
80
|
+
budget: { tokens: 50_000, seconds: 60 }, llm: { alias: "vh-claude-sonnet", keyRef: sha(key) }, incidentRef: { id: "inc-42" },
|
|
81
|
+
inputs: [{ inline: `${CANARIES.request} please fix the login bug`, transient: true }, { ref: refOf("src/hello.js") }], ...(assignOver || {}), ...over };
|
|
82
|
+
const n = ack0();
|
|
83
|
+
cp.sendData("job.assign", p, { workspaceId });
|
|
84
|
+
return { p, acked: () => waitFor(() => cp.acksFromLa.length > n).then(() => cp.acksFromLa[n]) };
|
|
85
|
+
},
|
|
86
|
+
cancel(jobId) { const n = ack0(); cp.sendData("job.cancel", { jobId, reason: "user" }); return waitFor(() => cp.acksFromLa.length > n).then(() => cp.acksFromLa[n]); },
|
|
87
|
+
frames: (jobId, type) => cp.received.filter((f) => (!type || f.type === type) && (f.payload.jobId === jobId || (type === "audit.event"))),
|
|
88
|
+
done: (jobId, ms = 8000) => waitFor(() => r.frames(jobId, "job.result").length === 1 && r.frames(jobId, "job.event").some((e) => ["succeeded", "failed", "cancelled"].includes(e.payload.state)), ms),
|
|
89
|
+
approvalDecision(payload) { const n = ack0(); cp.sendData("approval.decision", payload, { workspaceId: "ws-1" }); return waitFor(() => cp.acksFromLa.length > n).then(() => cp.acksFromLa[n]); },
|
|
90
|
+
mint: (o) => mintApprovalToken({ privateKey: cp.privateKey, keyId: cp.keyId, ...o }),
|
|
91
|
+
everything: () => JSON.stringify([cp.received, logs, errors, link.jobs?.audit.tail(1000), link.llm?.client?.audit.tail(1000), link.llm?.audit.tail(1000), link.llm?.client?.stats, link.jobs?.stats,
|
|
92
|
+
link.agent2?.audit.tail(1000), link.agent2?.stats, link.agent2?.bridge.stats, link.agent2?.approvals.stats, link.agent2?.supervisor.stats]),
|
|
93
|
+
async stop() { link.close(); await fake.close(); await fakeLlm.close(); fs.rmSync(root, { recursive: true, force: true }); },
|
|
94
|
+
};
|
|
95
|
+
return r;
|
|
96
|
+
}
|