deepdebug-local-agent 1.10.0 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/build-byoc-image.yml +9 -11
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +5 -0
- package/CLAUDE.md +1 -1
- package/cloudbuild-full.yaml +2 -1
- package/docs/byoc-agent-image.md +8 -7
- package/docs/examples/agent2-runner/docker-compose.yml +53 -0
- package/docs/la-agent2-runner.md +115 -0
- package/docs/la-jobs-and-llm.md +14 -1
- package/docs/la-protocol-v2.md +4 -2
- package/env.example +6 -0
- package/package.json +1 -1
- package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +8 -4
- package/src/la-protocol-v2/contract/README.md +51 -2
- package/src/la-protocol-v2/contract/envelope.schema.json +3 -1
- package/src/la-protocol-v2/contract/index.json +18 -2
- package/src/la-protocol-v2/contract/policy.check.schema.json +97 -0
- package/src/la-protocol-v2/contract/policy.decision.schema.json +119 -0
- package/src/la-protocol-v2/jobs/agent2/api-client.js +150 -0
- package/src/la-protocol-v2/jobs/agent2/approvals.js +114 -0
- package/src/la-protocol-v2/jobs/agent2/bridge.js +299 -0
- package/src/la-protocol-v2/jobs/agent2/config.js +128 -0
- package/src/la-protocol-v2/jobs/agent2/frames.js +61 -0
- package/src/la-protocol-v2/jobs/agent2/policy-check.js +170 -0
- package/src/la-protocol-v2/jobs/agent2/runner.js +258 -0
- package/src/la-protocol-v2/jobs/agent2/supervisor.js +269 -0
- package/src/la-protocol-v2/jobs/git-exec.js +71 -0
- package/src/la-protocol-v2/jobs/install.js +14 -4
- package/src/la-protocol-v2/jobs/manager.js +30 -16
- package/src/la-protocol-v2/jobs/workspace-materializer.js +244 -0
- package/src/la-protocol-v2/llm/client.js +133 -12
- package/src/la-protocol-v2/llm/config.js +2 -0
- package/src/la-protocol-v2/runtime.js +36 -3
- package/src/server.js +4 -0
- package/src/utils/approval-token.js +3 -2
- package/test/contract/la-protocol-v2/vectors/policy.check.vectors.json +908 -0
- package/test/contract/la-protocol-v2/vectors/policy.decision.vectors.json +826 -0
- package/test/la-protocol-v2/agent2-approval-discovery.test.js +114 -0
- package/test/la-protocol-v2/agent2-config.test.js +103 -0
- package/test/la-protocol-v2/agent2-orphan.test.js +118 -0
- package/test/la-protocol-v2/agent2-policy-check.test.js +273 -0
- package/test/la-protocol-v2/agent2-real-e2e.test.js +89 -0
- package/test/la-protocol-v2/agent2-runner.test.js +510 -0
- package/test/la-protocol-v2/agent2-supervisor.test.js +185 -0
- package/test/la-protocol-v2/agent2-workspace-door.test.js +142 -0
- package/test/la-protocol-v2/agent2-workspace-real-server.test.js +137 -0
- package/test/la-protocol-v2/contract-v21.test.js +2 -2
- package/test/la-protocol-v2/contract.test.js +8 -7
- package/test/la-protocol-v2/helpers/agent2-rig.js +96 -0
- package/test/la-protocol-v2/helpers/fake-agent2-cli.js +22 -0
- package/test/la-protocol-v2/helpers/fake-agent2.js +280 -0
- package/test/la-protocol-v2/helpers/fake-la-api.js +29 -0
- package/test/la-protocol-v2/helpers/fake-llm.js +11 -0
- package/test/la-protocol-v2/helpers/reference-cp.js +6 -3
- package/test/la-protocol-v2/helpers/spawn-agent.js +4 -3
- package/test/la-protocol-v2/helpers/supervisor-exit-script.js +15 -0
- package/test/la-protocol-v2/llm-messages.test.js +132 -0
- package/test/la-protocol-v2/workspace-materializer.test.js +341 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// F3-08c: how an `approval.decision` finds the approval agent2 is waiting on. The id in the frame is the control plane's, `audit.event approval_requested` carries none, so this agent
|
|
2
|
+
// asks agent2 for the pending approvals OF THE JOB (`GET /local/v1/approvals?jobId=`) and the vhapr1 token (bound to the sha256 of the request hash) picks the one. A FAKE agent2
|
|
3
|
+
// that exposes that endpoint (helpers/fake-agent2.js); the real agent2 gets the endpoint in PR #308's follow-up (task A of the F3-08c closing).
|
|
4
|
+
import { test, after } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import crypto from "node:crypto";
|
|
7
|
+
import { closeAllFakes } from "./helpers/fake-llm.js";
|
|
8
|
+
import { agent2Rig, uuid, waitFor } from "./helpers/agent2-rig.js";
|
|
9
|
+
|
|
10
|
+
after(closeAllFakes);
|
|
11
|
+
|
|
12
|
+
const h = (s) => crypto.createHash("sha256").update(s).digest("hex");
|
|
13
|
+
async function pending(r) {
|
|
14
|
+
const a = r.assign(); await a.acked();
|
|
15
|
+
await waitFor(() => [...r.fake.approvals.values()].some((x) => x.jobId === a.p.jobId));
|
|
16
|
+
return { a, ap: [...r.fake.approvals.values()].find((x) => x.jobId === a.p.jobId) };
|
|
17
|
+
}
|
|
18
|
+
const foreign = (r, jobId, requestHash) => { const approvalId = uuid(); r.fake.approvals.set(approvalId, { approvalId, jobId, actionType: "RUN_SHELL_FREEFORM", toolName: "run_command", requestHash, expiresAt: new Date(Date.now() + 600_000).toISOString(), resolve() {}, decided: false }); return approvalId; };
|
|
19
|
+
|
|
20
|
+
test("the pending list is asked FOR THE JOB (?jobId=); an approval of another job with the very same request hash is never a candidate, never decided", async () => {
|
|
21
|
+
const r = await agent2Rig({ scenario: "approval" });
|
|
22
|
+
const { a, ap } = await pending(r);
|
|
23
|
+
const other = foreign(r, uuid(), ap.requestHash);
|
|
24
|
+
const ack = await r.approvalDecision({ approvalId: uuid(), decision: "approved", token: r.mint({ subject: ap.requestHash }) });
|
|
25
|
+
assert.equal(ack.status, "accepted");
|
|
26
|
+
await r.done(a.p.jobId);
|
|
27
|
+
const lists = r.fake.requests.filter((q) => q.method === "LIST");
|
|
28
|
+
assert.ok(lists.length >= 1);
|
|
29
|
+
for (const q of lists) assert.equal(q.query, `?jobId=${a.p.jobId}`, "always scoped to the running job");
|
|
30
|
+
assert.deepEqual(r.fake.requests.filter((q) => q.method === "DECIDE").map((q) => q.path), [`/local/v1/approvals/${ap.approvalId}`], "only the job's own approval was decided");
|
|
31
|
+
assert.equal(r.fake.approvals.get(other).decided, false);
|
|
32
|
+
await r.stop();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("approvals of other jobs exist next to the job's own: the decision touches only the job's", async () => {
|
|
36
|
+
const r = await agent2Rig({ scenario: "approval" });
|
|
37
|
+
const { a, ap } = await pending(r);
|
|
38
|
+
const other = foreign(r, uuid(), h("another request"));
|
|
39
|
+
const list = await (await fetch(`${r.fake.baseUrl}/local/v1/approvals`, { headers: { authorization: `Bearer ${r.apiToken}` } })).json();
|
|
40
|
+
assert.equal(list.approvals.length, 2, "unfiltered, agent2 would list both");
|
|
41
|
+
const ack = await r.approvalDecision({ approvalId: uuid(), decision: "approved", token: r.mint({ subject: ap.requestHash }) });
|
|
42
|
+
assert.equal(ack.status, "accepted");
|
|
43
|
+
await r.done(a.p.jobId);
|
|
44
|
+
assert.equal(r.fake.approvals.get(other).decided, false);
|
|
45
|
+
await r.stop();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("two approvals pending in the job: the token decides which one is answered (the other keeps waiting)", async () => {
|
|
49
|
+
const r = await agent2Rig({ scenario: "approval" });
|
|
50
|
+
const { a, ap } = await pending(r);
|
|
51
|
+
const second = foreign(r, a.p.jobId, h("second request"));
|
|
52
|
+
const ack = await r.approvalDecision({ approvalId: uuid(), decision: "denied", token: r.mint({ subject: h("second request") }) });
|
|
53
|
+
assert.equal(ack.status, "accepted");
|
|
54
|
+
assert.deepEqual(r.fake.requests.filter((q) => q.method === "DECIDE").map((q) => [q.path, q.decision]), [[`/local/v1/approvals/${second}`, "denied"]]);
|
|
55
|
+
assert.equal(r.fake.approvals.get(ap.approvalId).decided, false, "the first is still waiting");
|
|
56
|
+
const ack2 = await r.approvalDecision({ approvalId: uuid(), decision: "approved", token: r.mint({ subject: ap.requestHash }) });
|
|
57
|
+
assert.equal(ack2.status, "accepted");
|
|
58
|
+
await r.done(a.p.jobId);
|
|
59
|
+
await r.stop();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("a decision for an approval that is no longer pending (decided, expired) is acked and delivers nothing; the control plane's id is not trusted as agent2's", async () => {
|
|
63
|
+
const r = await agent2Rig({ scenario: "approval" });
|
|
64
|
+
const { a, ap } = await pending(r);
|
|
65
|
+
assert.equal((await r.approvalDecision({ approvalId: uuid(), decision: "approved", token: r.mint({ subject: ap.requestHash }) })).status, "accepted");
|
|
66
|
+
await r.done(a.p.jobId);
|
|
67
|
+
const decides = r.fake.requests.filter((q) => q.method === "DECIDE").length;
|
|
68
|
+
// a new, valid token for the same (now gone) request: nothing waits any more
|
|
69
|
+
const late = await r.approvalDecision({ approvalId: uuid(), decision: "approved", token: r.mint({ subject: ap.requestHash }) });
|
|
70
|
+
assert.equal(late.status, "accepted");
|
|
71
|
+
assert.equal(r.fake.requests.filter((q) => q.method === "DECIDE").length, decides);
|
|
72
|
+
await r.stop();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("an agent2 without the list endpoint falls back to the control plane's id as agent2's own (detail route); an id it does not know cannot be delivered and says so", async () => {
|
|
76
|
+
const r = await agent2Rig({ scenario: "approval", supportList: false, frameApprovalId: false });
|
|
77
|
+
const { ap } = await pending(r);
|
|
78
|
+
const lost = await r.approvalDecision({ approvalId: uuid(), decision: "approved", token: r.mint({ subject: ap.requestHash }) });
|
|
79
|
+
assert.deepEqual([lost.status, lost.errorCode], ["rejected", "internal"]);
|
|
80
|
+
const found = await r.approvalDecision({ approvalId: ap.approvalId, decision: "approved", token: r.mint({ subject: ap.requestHash }) });
|
|
81
|
+
assert.equal(found.status, "accepted");
|
|
82
|
+
assert.equal(r.fake.requests.filter((q) => q.method === "DECIDE").length, 1);
|
|
83
|
+
await r.stop();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("the approval id agent2 puts in its own frames (PR #308) is learned by this agent only: it never reaches the control plane; without the list endpoint it finds the approval for a decision with the control plane's (unrelated) id", async () => {
|
|
87
|
+
const r = await agent2Rig({ scenario: "approval", supportList: false, frameApprovalId: true });
|
|
88
|
+
const { a, ap } = await pending(r);
|
|
89
|
+
const audit = r.cp.received.filter((f) => f.type === "audit.event" && f.payload.action === "approval_requested");
|
|
90
|
+
assert.equal(audit.length, 1);
|
|
91
|
+
assert.deepEqual(Object.keys(audit[0].payload).sort(), ["action", "actionType", "decision", "requestHash", "toolName"], "the contract's closed payload: no approvalId, no summary");
|
|
92
|
+
assert.ok(!JSON.stringify(r.cp.received).includes(ap.approvalId), "agent2's approval id is not on the wire to the control plane");
|
|
93
|
+
const ack = await r.approvalDecision({ approvalId: uuid(), decision: "approved", token: r.mint({ subject: ap.requestHash }) });
|
|
94
|
+
assert.equal(ack.status, "accepted", "found through the frame, not through a list agent2 does not have");
|
|
95
|
+
assert.deepEqual(r.fake.requests.filter((q) => q.method === "DECIDE").map((q) => q.path), [`/local/v1/approvals/${ap.approvalId}`]);
|
|
96
|
+
await r.done(a.p.jobId);
|
|
97
|
+
assert.ok(!r.everything().includes(ap.approvalId), "nor in a log, an audit or a stat of this agent");
|
|
98
|
+
await r.stop();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("frame-learned approvals do not widen what a token can approve: a token for another request hash still decides nothing, and a replayed token after the job is over finds nothing", async () => {
|
|
102
|
+
const r = await agent2Rig({ scenario: "approval", supportList: false, frameApprovalId: true });
|
|
103
|
+
const { a, ap } = await pending(r);
|
|
104
|
+
const wrong = await r.approvalDecision({ approvalId: uuid(), decision: "approved", token: r.mint({ subject: h("another request") }) });
|
|
105
|
+
assert.deepEqual([wrong.status, wrong.errorCode], ["rejected", "unauthorized"]);
|
|
106
|
+
assert.equal(r.fake.requests.filter((q) => q.method === "DECIDE").length, 0);
|
|
107
|
+
assert.equal((await r.approvalDecision({ approvalId: uuid(), decision: "approved", token: r.mint({ subject: ap.requestHash }) })).status, "accepted");
|
|
108
|
+
await r.done(a.p.jobId);
|
|
109
|
+
await waitFor(() => r.link.agent2.active === null);
|
|
110
|
+
const late = await r.approvalDecision({ approvalId: uuid(), decision: "approved", token: r.mint({ subject: ap.requestHash }) });
|
|
111
|
+
assert.equal(late.status, "accepted", "nothing runs: acked without an oracle");
|
|
112
|
+
assert.equal(r.fake.requests.filter((q) => q.method === "DECIDE").length, 1, "and nothing was delivered");
|
|
113
|
+
await r.stop();
|
|
114
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// F3-08c (Local Agent half): configuration, frame whitelist and the small pure helpers of the agent2 runner.
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { resolveAgent2RunnerConfig, parseCommand, loopbackBase, relayBase, agent2RunnerRequested } from "../../src/la-protocol-v2/jobs/agent2/config.js";
|
|
5
|
+
import { normalizeFrame } from "../../src/la-protocol-v2/jobs/agent2/frames.js";
|
|
6
|
+
import { workspaceUuid } from "../../src/la-protocol-v2/jobs/agent2/runner.js";
|
|
7
|
+
import { approvalSubject } from "../../src/la-protocol-v2/jobs/agent2/approvals.js";
|
|
8
|
+
|
|
9
|
+
const T = "3f2b8c1e-5a4d-4e7b-9c21-0a1b2c3d4e5f";
|
|
10
|
+
const J = "0b6f1c22-7a0e-4d8e-9a53-1b7a8c9d0e1f";
|
|
11
|
+
const warns = () => { const w = []; return { w, warn: (m) => w.push(m) }; };
|
|
12
|
+
const TOKEN = "k".repeat(40);
|
|
13
|
+
|
|
14
|
+
test("flag: only LOCAL_AGENT_RUNNER=agent2 turns it on; anything else is off (an unknown value is said, `smoke` is the other runner's)", () => {
|
|
15
|
+
for (const v of [undefined, "", "false", "smoke", "AGENT3", "1"]) {
|
|
16
|
+
const { w, warn } = warns();
|
|
17
|
+
assert.deepEqual(resolveAgent2RunnerConfig({ LOCAL_AGENT_RUNNER: v, LOCAL_AGENT_AGENT2_CMD: "x" }, warn), { enabled: false }, String(v));
|
|
18
|
+
assert.equal(agent2RunnerRequested({ LOCAL_AGENT_RUNNER: v }), false);
|
|
19
|
+
assert.equal(w.length, v && !["false", "smoke"].includes(v) ? 1 : 0, String(v));
|
|
20
|
+
}
|
|
21
|
+
assert.equal(agent2RunnerRequested({ LOCAL_AGENT_RUNNER: " Agent2 " }), true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("spawn and attach need exactly one way to reach agent2; attach needs a loopback URL, a 32+ char token and a fixed bridge port", () => {
|
|
25
|
+
const base = { LOCAL_AGENT_RUNNER: "agent2" };
|
|
26
|
+
assert.match(resolveAgent2RunnerConfig(base).error, /needs LOCAL_AGENT_AGENT2_CMD or LOCAL_AGENT_AGENT2_URL/);
|
|
27
|
+
assert.match(resolveAgent2RunnerConfig({ ...base, LOCAL_AGENT_AGENT2_CMD: "a", LOCAL_AGENT_AGENT2_URL: "http://127.0.0.1:1" }).error, /not both/);
|
|
28
|
+
assert.match(resolveAgent2RunnerConfig({ ...base, LOCAL_AGENT_AGENT2_CMD: "[1,2]" }).error, /JSON array/);
|
|
29
|
+
const spawn = resolveAgent2RunnerConfig({ ...base, LOCAL_AGENT_AGENT2_CMD: '["java","-jar","/opt/agent2.jar"]' });
|
|
30
|
+
assert.deepEqual([spawn.enabled, spawn.mode, spawn.cmd, spawn.apiPort, spawn.bridgePort], [true, "spawn", ["java", "-jar", "/opt/agent2.jar"], 8765, 0]);
|
|
31
|
+
assert.deepEqual(resolveAgent2RunnerConfig({ ...base, LOCAL_AGENT_AGENT2_CMD: "java -jar x.jar" }).cmd, ["java", "-jar", "x.jar"]);
|
|
32
|
+
for (const url of ["http://10.0.0.5:8765", "https://127.0.0.1:8765", "http://user:pw@127.0.0.1:8765", "http://127.0.0.1:8765/x", "http://example.com", "ftp://127.0.0.1"]) {
|
|
33
|
+
assert.match(resolveAgent2RunnerConfig({ ...base, LOCAL_AGENT_AGENT2_URL: url, LOCAL_AGENT_AGENT2_TOKEN: TOKEN, LOCAL_AGENT_AGENT2_BRIDGE_PORT: "5058" }).error, /loopback/, url);
|
|
34
|
+
}
|
|
35
|
+
const u = "http://127.0.0.1:8765";
|
|
36
|
+
assert.match(resolveAgent2RunnerConfig({ ...base, LOCAL_AGENT_AGENT2_URL: u }).error, /LOCAL_AGENT_AGENT2_TOKEN/);
|
|
37
|
+
assert.match(resolveAgent2RunnerConfig({ ...base, LOCAL_AGENT_AGENT2_URL: u, LOCAL_AGENT_AGENT2_TOKEN: "short" }).error, /LOCAL_AGENT_AGENT2_TOKEN/);
|
|
38
|
+
assert.match(resolveAgent2RunnerConfig({ ...base, LOCAL_AGENT_AGENT2_URL: u, LOCAL_AGENT_AGENT2_TOKEN: TOKEN }).error, /BRIDGE_PORT/);
|
|
39
|
+
const att = resolveAgent2RunnerConfig({ ...base, LOCAL_AGENT_AGENT2_URL: "http://localhost:8765/", LOCAL_AGENT_AGENT2_TOKEN: TOKEN, LOCAL_AGENT_AGENT2_BRIDGE_PORT: "5058", LOCAL_AGENT_AGENT2_CALLBACK_TOKEN: "c".repeat(32) });
|
|
40
|
+
assert.deepEqual([att.mode, att.url, att.bridgePort, att.callbackToken], ["attach", "http://localhost:8765", 5058, "c".repeat(32)]);
|
|
41
|
+
assert.equal(resolveAgent2RunnerConfig({ ...base, LOCAL_AGENT_AGENT2_URL: u, LOCAL_AGENT_AGENT2_TOKEN: TOKEN, LOCAL_AGENT_AGENT2_BRIDGE_PORT: "5058", LOCAL_AGENT_AGENT2_CALLBACK_TOKEN: "short" }).callbackToken, null);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("numbers are clamped, never unlimited; the old HTTP PolicyGate pair is ignored (the gate is asked over the v2 channel)", () => {
|
|
45
|
+
const c = resolveAgent2RunnerConfig({ LOCAL_AGENT_RUNNER: "agent2", LOCAL_AGENT_AGENT2_CMD: "x", LOCAL_AGENT_AGENT2_MAX_RESTARTS: "9999", LOCAL_AGENT_AGENT2_START_TIMEOUT_MS: "-1", LOCAL_AGENT_AGENT2_MAX_BODY_BYTES: "abc", LOCAL_AGENT_AGENT2_CANCEL_WAIT_MS: "5" });
|
|
46
|
+
assert.deepEqual([c.maxRestarts, c.startTimeoutMs, c.maxBodyBytes, c.cancelWaitMs], [3, 90_000, 1024 * 1024, 8_000]);
|
|
47
|
+
assert.equal(c.policy, undefined, "no HTTP relay in the config any more");
|
|
48
|
+
assert.deepEqual([c.policyCheckTimeoutMs, c.maxWorkspaceBodyBytes, c.workspaceTimeoutMs], [10_000, 16 * 1024 * 1024, 900_000]);
|
|
49
|
+
const clamped = resolveAgent2RunnerConfig({ LOCAL_AGENT_RUNNER: "agent2", LOCAL_AGENT_AGENT2_CMD: "x", LOCAL_AGENT_AGENT2_POLICY_TIMEOUT_MS: "5", LOCAL_AGENT_AGENT2_WORKSPACE_MAX_BODY_BYTES: "0", LOCAL_AGENT_AGENT2_WORKSPACE_TIMEOUT_MS: "999999999" });
|
|
50
|
+
assert.deepEqual([clamped.policyCheckTimeoutMs, clamped.maxWorkspaceBodyBytes, clamped.workspaceTimeoutMs], [10_000, 16 * 1024 * 1024, 900_000]);
|
|
51
|
+
const { w, warn } = warns();
|
|
52
|
+
const old = resolveAgent2RunnerConfig({ LOCAL_AGENT_RUNNER: "agent2", LOCAL_AGENT_AGENT2_CMD: "x", LOCAL_AGENT_AGENT2_POLICY_URL: "https://gw.example.com/", LOCAL_AGENT_AGENT2_POLICY_TOKEN: "SECRET-BEARER-CANARY" }, warn);
|
|
53
|
+
assert.equal(old.enabled, true);
|
|
54
|
+
assert.equal(old.policy, undefined);
|
|
55
|
+
assert.equal(w.length, 1);
|
|
56
|
+
assert.match(w[0], /ignored/);
|
|
57
|
+
assert.ok(!w[0].includes("SECRET-BEARER-CANARY"), "the bearer is never echoed");
|
|
58
|
+
assert.equal(relayBase("https://u:p@gw.example.com"), null);
|
|
59
|
+
assert.equal(relayBase("http://127.0.0.1:9"), "http://127.0.0.1:9");
|
|
60
|
+
assert.equal(loopbackBase("http://[::1]:9"), "http://[::1]:9");
|
|
61
|
+
assert.equal(parseCommand("a\0b"), null);
|
|
62
|
+
assert.equal(parseCommand("[]"), null);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("workspaceUuid: a UUID stays, a free-form id becomes a stable name-based UUID that depends on the tenant", () => {
|
|
66
|
+
assert.equal(workspaceUuid(T, J), J);
|
|
67
|
+
const a = workspaceUuid(T, "default");
|
|
68
|
+
assert.match(a, /^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
|
69
|
+
assert.equal(workspaceUuid(T, "default"), a);
|
|
70
|
+
assert.notEqual(workspaceUuid(T, "ws-1"), a);
|
|
71
|
+
assert.notEqual(workspaceUuid("9d1f6a2c-0000-4000-8000-000000000000", "default"), a);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const ctx = { tenantId: T, jobId: J };
|
|
75
|
+
const frame = (type, payload, over = {}) => ({ seq: 1, type, tenantId: T, workspaceId: J, payload, ...over });
|
|
76
|
+
|
|
77
|
+
test("frames: job.event / job.result / audit.event are reduced to the closed whitelist; unknown or out-of-range values are dropped, never repaired", () => {
|
|
78
|
+
const ev = normalizeFrame(frame("job.event", { jobId: J, state: "running", phase: "edit", iteration: 3, filesRead: 2, filesChanged: 1, tokensIn: 9, tokensOut: 9, costUsd: 5, model: "m", message: "x" }), ctx);
|
|
79
|
+
assert.deepEqual(ev.payload, { jobId: J, state: "running", phase: "edit", iteration: 3, filesRead: 2, filesChanged: 1 });
|
|
80
|
+
assert.deepEqual(normalizeFrame(frame("job.event", { jobId: J, state: "running", phase: "hack", iteration: -1, filesRead: 1.5, errorCode: "boom" }), ctx).payload, { jobId: J, state: "running" });
|
|
81
|
+
assert.equal(normalizeFrame(frame("job.event", { jobId: J, state: "exploded" }), ctx), null);
|
|
82
|
+
assert.equal(normalizeFrame(frame("job.event", { jobId: J, state: "failed", errorCode: "tests_failed" }), ctx).terminal, true);
|
|
83
|
+
const res = normalizeFrame(frame("job.result", { jobId: J, outcome: "pr_opened", prUrl: "https://x/y", filesChangedCount: 2, tests: { pass: 1, fail: 0, skip: 0 }, extra: "no" }), ctx);
|
|
84
|
+
assert.deepEqual(res.payload, { jobId: J, outcome: "pr_opened", prUrl: "https://x/y", filesChangedCount: 2, tests: { pass: 1, fail: 0, skip: 0 } });
|
|
85
|
+
assert.equal(normalizeFrame(frame("job.result", { jobId: J, outcome: "great" }), ctx), null);
|
|
86
|
+
const au = normalizeFrame(frame("audit.event", { action: "approval_requested", decision: "require_human", actionType: "RUN_SHELL_FREEFORM", toolName: "run_command", requestHash: "a".repeat(64), summary: "rm -rf" }), ctx);
|
|
87
|
+
assert.deepEqual(au.payload, { action: "approval_requested", decision: "require_human", actionType: "RUN_SHELL_FREEFORM", toolName: "run_command", requestHash: "a".repeat(64) });
|
|
88
|
+
assert.deepEqual(normalizeFrame(frame("audit.event", { action: "policy_decision", decision: "maybe", actionType: "has space", toolName: "../etc", requestHash: "XYZ" }), ctx).payload, { action: "policy_decision" });
|
|
89
|
+
for (const action of ["redaction", "content_served", "config_applied", "x"]) assert.equal(normalizeFrame(frame("audit.event", { action }), ctx), null, action);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("frames: another tenant or another job is a violation (not data); bad shapes are ignored", () => {
|
|
93
|
+
assert.deepEqual(normalizeFrame(frame("job.event", { jobId: J, state: "failed" }, { tenantId: "9d1f6a2c-0000-4000-8000-000000000000" }), ctx), { violation: "tenant" });
|
|
94
|
+
assert.deepEqual(normalizeFrame(frame("job.event", { jobId: "6c9a1c22-7a0e-4d8e-9a53-1b7a8c9d0e1f", state: "failed" }), ctx), { violation: "job" });
|
|
95
|
+
assert.deepEqual(normalizeFrame(frame("job.result", { jobId: "6c9a1c22-7a0e-4d8e-9a53-1b7a8c9d0e1f", outcome: "failed" }), ctx), { violation: "job" });
|
|
96
|
+
for (const f of [null, [], "x", {}, { seq: 0, type: "job.event", payload: {} }, frame("job.event", null), frame("job.event", []), frame("nope", {}), { ...frame("job.event", { jobId: J, state: "running" }), seq: "1" }]) assert.equal(normalizeFrame(f, ctx), null);
|
|
97
|
+
assert.notEqual(normalizeFrame({ seq: 1, type: "job.event", payload: { jobId: J, state: "running" } }, ctx), null, "a frame without a tenant field is taken at the word of the authenticated loopback channel");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("approvalSubject: the token is bound to the request hash string", () => {
|
|
101
|
+
assert.equal(approvalSubject("ab"), "ab");
|
|
102
|
+
assert.equal(approvalSubject(undefined), "");
|
|
103
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// F3-08c: an agent2 left behind by a previous run of this agent (it was SIGKILLed, it crashed) keeps the API port and an old token; the next start must end it, and must never
|
|
2
|
+
// touch anything else. The supervisor keeps a pidfile for that; an orderly exit of the agent takes the child with it. Real processes (the fake agent2 CLI), no mocks of the OS.
|
|
3
|
+
import { test, after } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import crypto from "node:crypto";
|
|
8
|
+
import { spawn, execFileSync } from "node:child_process";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { closeAllFakes } from "./helpers/fake-llm.js";
|
|
11
|
+
import { agent2Rig, TENANT, waitFor } from "./helpers/agent2-rig.js";
|
|
12
|
+
|
|
13
|
+
after(closeAllFakes);
|
|
14
|
+
|
|
15
|
+
const CLI = fileURLToPath(new URL("./helpers/fake-agent2-cli.js", import.meta.url));
|
|
16
|
+
const EXIT_SCRIPT = fileURLToPath(new URL("./helpers/supervisor-exit-script.js", import.meta.url));
|
|
17
|
+
const alive = (pid) => { try { process.kill(pid, 0); return true; } catch { return false; } };
|
|
18
|
+
const toKill = new Set();
|
|
19
|
+
after(() => { for (const p of toKill) { try { process.kill(p, "SIGKILL"); } catch { /* gone */ } } });
|
|
20
|
+
const sha = (cmd) => crypto.createHash("sha256").update(JSON.stringify(cmd)).digest("hex");
|
|
21
|
+
|
|
22
|
+
/** the process a crashed LA would have left: the same command, listening on the API port with an OLD token */
|
|
23
|
+
async function orphanOf(r, { cmd = [process.execPath, CLI, "--scenario=ok_pr"] } = {}) {
|
|
24
|
+
const child = spawn(cmd[0], cmd.slice(1), { env: { PATH: process.env.PATH, AGENT2_LOCAL_API_TOKEN: "old-token-".padEnd(40, "o"), AGENT2_LOCAL_TENANT_ID: TENANT, AGENT2_LOCAL_PORT: r.env.LOCAL_AGENT_AGENT2_PORT,
|
|
25
|
+
AGENT2_LLM_BASE_URL: "http://127.0.0.1:1/v1", AGENT2_LA_CALLBACK_URL: "http://127.0.0.1:1", AGENT2_LA_CALLBACK_TOKEN: "c".repeat(40) }, stdio: "ignore", detached: true });
|
|
26
|
+
child.unref();
|
|
27
|
+
toKill.add(child.pid);
|
|
28
|
+
await waitFor(async () => { try { return (await fetch(`http://127.0.0.1:${r.env.LOCAL_AGENT_AGENT2_PORT}/local/v1/health`)).ok; } catch { return false; } }, 8000, 50);
|
|
29
|
+
return child;
|
|
30
|
+
}
|
|
31
|
+
const writePid = (r, pid, over = {}) => fs.writeFileSync(r.pidFile, JSON.stringify({ pid, cmdSha: sha(JSON.parse(r.env.LOCAL_AGENT_AGENT2_CMD)), startedAt: Date.now(), ...over }));
|
|
32
|
+
const run = async (r) => { const a = r.assign(); await a.acked(); await r.done(a.p.jobId, 15_000); return a; };
|
|
33
|
+
|
|
34
|
+
test("an orphan agent2 from a previous run (pidfile + same command, alive) is ended at the next start, and the job runs on a NEW process on the same port", async () => {
|
|
35
|
+
const r = await agent2Rig({ spawn: {}, scenario: "ok_pr", env: { LOCAL_AGENT_AGENT2_STOP_GRACE_MS: "2000" } });
|
|
36
|
+
const orphan = await orphanOf(r);
|
|
37
|
+
writePid(r, orphan.pid);
|
|
38
|
+
assert.ok(alive(orphan.pid));
|
|
39
|
+
const a = await run(r);
|
|
40
|
+
assert.equal(r.frames(a.p.jobId, "job.result")[0].payload.outcome, "pr_opened", "the job ran on the new process: the old token would have been refused");
|
|
41
|
+
await waitFor(() => !alive(orphan.pid), 4000);
|
|
42
|
+
assert.equal(r.link.agent2.supervisor.stats.orphansEnded, 1);
|
|
43
|
+
const fresh = JSON.parse(fs.readFileSync(r.pidFile, "utf8"));
|
|
44
|
+
assert.equal(fresh.pid, r.link.agent2.supervisor.pid);
|
|
45
|
+
assert.notEqual(fresh.pid, orphan.pid);
|
|
46
|
+
assert.ok(r.logs.some((l) => /previous run is still alive/.test(l)));
|
|
47
|
+
r.link.close();
|
|
48
|
+
await waitFor(() => !fs.existsSync(r.pidFile), 5000);
|
|
49
|
+
await r.stop();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("a pid that is alive but is NOT our command (the pid was reused) is never touched; a dead pid and a command that changed are cleared; nothing blocks the start", async () => {
|
|
53
|
+
const r = await agent2Rig({ spawn: {}, scenario: "ok_pr" });
|
|
54
|
+
const stranger = spawn("sleep", ["60"], { stdio: "ignore", detached: true });
|
|
55
|
+
stranger.unref(); toKill.add(stranger.pid);
|
|
56
|
+
writePid(r, stranger.pid);
|
|
57
|
+
const a = await run(r);
|
|
58
|
+
assert.ok(alive(stranger.pid), "an unrelated process is left alone");
|
|
59
|
+
assert.equal(r.link.agent2.supervisor.stats.orphansEnded, 0);
|
|
60
|
+
assert.equal(r.frames(a.p.jobId, "job.result")[0].payload.outcome, "pr_opened");
|
|
61
|
+
r.link.close(); await waitFor(() => !fs.existsSync(r.pidFile), 5000); await r.stop();
|
|
62
|
+
|
|
63
|
+
// a dead pid
|
|
64
|
+
const r2 = await agent2Rig({ spawn: {}, scenario: "ok_pr" });
|
|
65
|
+
const gone = spawn(process.execPath, ["-e", "0"], { stdio: "ignore" });
|
|
66
|
+
await new Promise((x) => gone.on("exit", x));
|
|
67
|
+
writePid(r2, gone.pid);
|
|
68
|
+
await run(r2);
|
|
69
|
+
assert.equal(r2.link.agent2.supervisor.stats.orphansEnded, 0);
|
|
70
|
+
r2.link.close(); await r2.stop();
|
|
71
|
+
|
|
72
|
+
// the pidfile of ANOTHER command (the configuration changed): a look-alike is not ended
|
|
73
|
+
const r3 = await agent2Rig({ spawn: {}, scenario: "ok_pr" });
|
|
74
|
+
const lookalike = await orphanOf(r3);
|
|
75
|
+
writePid(r3, lookalike.pid, { cmdSha: sha(["something", "else"]) });
|
|
76
|
+
await new Promise((x) => setTimeout(x, 100));
|
|
77
|
+
assert.ok(alive(lookalike.pid));
|
|
78
|
+
r3.link.close(); await r3.stop();
|
|
79
|
+
process.kill(lookalike.pid, "SIGKILL");
|
|
80
|
+
|
|
81
|
+
// junk in the pidfile, and our own pid
|
|
82
|
+
const r4 = await agent2Rig({ spawn: {}, scenario: "ok_pr" });
|
|
83
|
+
fs.writeFileSync(r4.pidFile, "not json");
|
|
84
|
+
await run(r4);
|
|
85
|
+
fs.writeFileSync(r4.pidFile, JSON.stringify({ pid: process.pid, cmdSha: sha(JSON.parse(r4.env.LOCAL_AGENT_AGENT2_CMD)) }));
|
|
86
|
+
r4.link.close(); await r4.stop();
|
|
87
|
+
assert.ok(alive(process.pid));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("where the command line of the pid cannot be read, nothing is killed (the start goes on, loudly)", async () => {
|
|
91
|
+
const { createAgent2Supervisor } = await import("../../src/la-protocol-v2/jobs/agent2/supervisor.js");
|
|
92
|
+
const { resolveAgent2RunnerConfig } = await import("../../src/la-protocol-v2/jobs/agent2/config.js");
|
|
93
|
+
const dir = fs.mkdtempSync(path.join(fs.realpathSync(process.env.TMPDIR || "/tmp"), "a2orph-"));
|
|
94
|
+
const pidFile = path.join(dir, "p.pid");
|
|
95
|
+
const cfg = resolveAgent2RunnerConfig({ LOCAL_AGENT_RUNNER: "agent2", LOCAL_AGENT_AGENT2_CMD: JSON.stringify([process.execPath, CLI, "--scenario=ok_pr"]), LOCAL_AGENT_AGENT2_PIDFILE: pidFile });
|
|
96
|
+
const victim = spawn("sleep", ["60"], { stdio: "ignore", detached: true });
|
|
97
|
+
victim.unref(); toKill.add(victim.pid);
|
|
98
|
+
fs.writeFileSync(pidFile, JSON.stringify({ pid: victim.pid, cmdSha: sha(cfg.cmd) }));
|
|
99
|
+
const warns = [];
|
|
100
|
+
const proc = Object.assign(new (await import("node:events")).EventEmitter(), { stdout: null, stderr: null, pid: 999999, kill: () => true });
|
|
101
|
+
const sup = createAgent2Supervisor({ config: cfg, tenantId: TENANT, bridge: { listen: async () => ({ baseUrl: "http://127.0.0.1:1" }), setCallbackToken() {} }, env: { HOME: dir }, log: { warn: (m) => warns.push(m), info() {} },
|
|
102
|
+
cmdlineOf: async () => null, spawnImpl: () => proc, fetchImpl: async () => new Response(JSON.stringify({ status: "up" }), { status: 200 }) });
|
|
103
|
+
await sup.ensureReady();
|
|
104
|
+
assert.ok(alive(victim.pid), "not verified, not killed");
|
|
105
|
+
assert.equal(sup.stats.orphanChecksSkipped, 1);
|
|
106
|
+
assert.ok(warns.some((w) => /cannot be read/.test(w)));
|
|
107
|
+
await sup.stop();
|
|
108
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("an orderly exit of the agent (process.exit) takes the child with it", async () => {
|
|
112
|
+
const dir = fs.mkdtempSync(path.join(fs.realpathSync(process.env.TMPDIR || "/tmp"), "a2exit-"));
|
|
113
|
+
const out = execFileSync(process.execPath, [EXIT_SCRIPT, CLI, path.join(dir, "p.pid")], { encoding: "utf8", timeout: 30_000 });
|
|
114
|
+
const pid = Number(/CHILD_PID=(\d+)/.exec(out)[1]);
|
|
115
|
+
toKill.add(pid);
|
|
116
|
+
await waitFor(() => !alive(pid), 5000, 50);
|
|
117
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
118
|
+
});
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// F3-08c, contract 2.2: the PolicyGate of agent2 is asked over the v2 channel (`policy.check` LA -> CP, `policy.decision` CP -> LA), not with a platform bearer over HTTP.
|
|
2
|
+
// In process: the REAL v2 session + buildV2Link (LOCAL_AGENT_RUNNER=agent2, attach mode) against the ReferenceCp (which validates every frame against the synced contract
|
|
3
|
+
// and answers like the gateway: it lists `policy.check` in `ready` only for an LA that announced it), with a FAKE agent2 that asks through the bridge like the hosted
|
|
4
|
+
// PolicyGateGuard does. What it proves: the frame is the closed one (no tenant, agent, origin, path, command, URL), the answer maps back to what agent2 reads, and every
|
|
5
|
+
// failure (no capability, no answer, a refused frame, a wrong hash, a late answer) is a DENY. NOT proven: the real gateway handler (its own tests, PR #633).
|
|
6
|
+
import { test, after } from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { closeAllFakes } from "./helpers/fake-llm.js";
|
|
9
|
+
import { agent2Rig, TENANT, waitFor, uuid } from "./helpers/agent2-rig.js";
|
|
10
|
+
import { createPolicyCheckRelay, normalizePolicyCheck, decisionBody } from "../../src/la-protocol-v2/jobs/agent2/policy-check.js";
|
|
11
|
+
|
|
12
|
+
after(closeAllFakes);
|
|
13
|
+
|
|
14
|
+
const HASH = "c".repeat(64);
|
|
15
|
+
const decisionFor = (m, over = {}) => ({ decision: "ALLOW", reasonCode: "autonomous", shadow: false, approvalRequired: false, actionType: m.payload.actionType, requestHash: m.payload.requestHash, policySnapshot: { hash: "d".repeat(64) }, ...over });
|
|
16
|
+
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) }; };
|
|
17
|
+
|
|
18
|
+
// ------------------------------------------------------------------------------------------------------ the frame
|
|
19
|
+
|
|
20
|
+
test("policy.check: the frame is closed and says nothing about WHO asks: no tenant/agent/origin claim, no path, no command, no URL; the answer is the decision payload agent2 (LocalPolicyCheck.verdict) requires", async () => {
|
|
21
|
+
const r = await agent2Rig({ scenario: "policy_check", policy: (m) => decisionFor(m, { effectiveMode: "assist" }) });
|
|
22
|
+
const { a, job } = await run(r);
|
|
23
|
+
assert.equal(r.checks.length, 1);
|
|
24
|
+
const m = r.checks[0];
|
|
25
|
+
assert.equal(m.type, "policy.check");
|
|
26
|
+
assert.match(m.requestId, /^[0-9a-f-]{36}$/);
|
|
27
|
+
assert.equal(m.tenantId, TENANT, "the envelope names the session's tenant, as every frame does; it is the CP that derives it, not this agent that asserts it");
|
|
28
|
+
assert.equal(m.workspaceId, "ws-1");
|
|
29
|
+
assert.equal(m.sig, undefined, "unsigned: the authenticated session is the identity");
|
|
30
|
+
assert.deepEqual(m.payload, {
|
|
31
|
+
actionType: "APPLY_PATCH", toolName: "apply_patch", requestHash: m.payload.requestHash,
|
|
32
|
+
repoRefs: [{ provider: "github", org: "acme", repo: "app" }],
|
|
33
|
+
incidentId: "inc-42", jobId: a.p.jobId, modelAlias: "vh-claude-sonnet",
|
|
34
|
+
});
|
|
35
|
+
assert.match(m.payload.requestHash, /^[0-9a-f]{64}$/);
|
|
36
|
+
for (const forbidden of ["tenantId", "agentId", "agent", "executionOrigin", "workspaceId", "diffFiles", "allowedFiles", "repoUrl", "repoUrls"]) assert.ok(!(forbidden in m.payload), forbidden);
|
|
37
|
+
assert.equal(r.link.agent2.policyCheck.stats.dropped, 8, "agent2's extra claims (tenant, workspace, agent, origin, files, URLs) were dropped and counted");
|
|
38
|
+
const wire = JSON.stringify(r.cp.rawIn);
|
|
39
|
+
for (const leak of ["SECRET-PATH-CANARY", "11111111-1111-4111-8111-111111111111", "hosted", "github.com", "gitlab.com", ".git"]) assert.ok(!wire.includes(leak), `on the wire: ${leak}`);
|
|
40
|
+
assert.deepEqual(r.cp.errorsSent, [], "the CP's contract validator accepted the frame");
|
|
41
|
+
// what agent2 (PolicyGateGuard) reads
|
|
42
|
+
assert.equal(job.record.policy.status, 200);
|
|
43
|
+
assert.deepEqual(job.record.policy.json, { decision: "ALLOW", reasonCode: "autonomous", shadow: false, approvalRequired: false, actionType: "APPLY_PATCH", requestHash: job.record.policy.requestHash, effectiveMode: "assist", policySnapshot: { hash: "d".repeat(64) } });
|
|
44
|
+
assert.equal(job.record.policy.requestHash, m.payload.requestHash, "the decision is bound to the hash agent2 sent (the one of its approval_requested)");
|
|
45
|
+
assert.equal(r.link.agent2.policyCheck.pending, 0);
|
|
46
|
+
await r.stop();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("policy.check: this agent announces the capability only with the agent2 runner; the contract copy is 2.2", async () => {
|
|
50
|
+
const r = await agent2Rig({ scenario: "no_change" });
|
|
51
|
+
assert.ok(r.cp.registers[0].capabilities.includes("policy.check") && r.cp.registers[0].capabilities.includes("contract.v2.1"));
|
|
52
|
+
assert.ok(r.cp.hellos[0].capabilities.includes("policy.check"));
|
|
53
|
+
assert.ok(r.link.session.peerHas("policy.check"), "the CP listed it in ready");
|
|
54
|
+
await r.stop();
|
|
55
|
+
const off = await agent2Rig({ env: { LOCAL_AGENT_RUNNER: "", LOCAL_AGENT_JOBS: "false" }, noRunnerEnv: true, setKey: false });
|
|
56
|
+
assert.ok(!off.cp.registers[0].capabilities.includes("policy.check"), "no runner, no question to ask");
|
|
57
|
+
await off.stop();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// ------------------------------------------------------------------------------------------------------ the decisions
|
|
61
|
+
|
|
62
|
+
test("decisions: REQUIRE_HUMAN (enforced) says approvalRequired; a shadow DENY says shadow; a DENY keeps its reason code; the CP's own codes pass through", async () => {
|
|
63
|
+
const cases = [
|
|
64
|
+
[{ decision: "REQUIRE_HUMAN", approvalRequired: true, reasonCode: "assist_mode" }, { decision: "REQUIRE_HUMAN", shadow: false, approvalRequired: true, reasonCode: "assist_mode" }],
|
|
65
|
+
[{ decision: "REQUIRE_HUMAN", shadow: true, reasonCode: "assist_mode" }, { decision: "REQUIRE_HUMAN", shadow: true, approvalRequired: false, reasonCode: "assist_mode" }],
|
|
66
|
+
[{ decision: "DENY", shadow: true, reasonCode: "assist_mode" }, { decision: "DENY", shadow: true, approvalRequired: false, reasonCode: "assist_mode" }],
|
|
67
|
+
[{ decision: "DENY", reasonCode: "rate_limited" }, { decision: "DENY", shadow: false, approvalRequired: false, reasonCode: "rate_limited" }],
|
|
68
|
+
[{ decision: "DENY", reasonCode: "not_client_mode" }, { decision: "DENY", shadow: false, approvalRequired: false, reasonCode: "not_client_mode" }],
|
|
69
|
+
[{ decision: "DENY", reasonCode: "workspace_unknown" }, { decision: "DENY", shadow: false, approvalRequired: false, reasonCode: "workspace_unknown" }],
|
|
70
|
+
];
|
|
71
|
+
for (const [over, expected] of cases) {
|
|
72
|
+
const r = await agent2Rig({ scenario: "policy_check", policy: (m) => decisionFor(m, over) });
|
|
73
|
+
const { job } = await run(r);
|
|
74
|
+
assert.equal(job.record.policy.status, 200);
|
|
75
|
+
for (const [k, v] of Object.entries(expected)) assert.equal(job.record.policy.json[k], v, `${over.reasonCode} :: ${k}`);
|
|
76
|
+
await r.stop();
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("the decision agent2 gets is complete for LocalPolicyCheck.verdict (every field it requires) and carries nothing else", async () => {
|
|
81
|
+
const r = await agent2Rig({ scenario: "policy_check", policy: (m) => ({ ...decisionFor(m, { effectiveMode: "observe" }) }) });
|
|
82
|
+
const { job } = await run(r);
|
|
83
|
+
assert.deepEqual(Object.keys(job.record.policy.json).sort(), ["actionType", "approvalRequired", "decision", "effectiveMode", "policySnapshot", "reasonCode", "requestHash", "shadow"]);
|
|
84
|
+
await r.stop();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("fail closed: a decision the contract would call inconsistent or incomplete is never read as an answer: 502 DENY (the session's schema check is not the only look)", async () => {
|
|
88
|
+
// the ReferenceCp does not validate what the CP sends (the LA session does), so a malformed-but-schema-valid decision cannot be built here: test the second look directly
|
|
89
|
+
const asked = { requestHash: HASH };
|
|
90
|
+
const ok = { decision: "REQUIRE_HUMAN", reasonCode: "assist_mode", shadow: false, approvalRequired: true, actionType: "APPLY_PATCH", requestHash: HASH, policySnapshot: { hash: "d".repeat(64) } };
|
|
91
|
+
assert.deepEqual(decisionBody(ok, asked), ok);
|
|
92
|
+
for (const bad of [{ approvalRequired: false }, { shadow: true }, { decision: "MAYBE" }, { reasonCode: "Bad Code" }, { requestHash: "e".repeat(64) }, { actionType: "has space" }, { policySnapshot: {} }, { policySnapshot: { hash: "z" } }, { shadow: "false" }]) {
|
|
93
|
+
assert.equal(decisionBody({ ...ok, ...bad }, asked), null, JSON.stringify(bad));
|
|
94
|
+
}
|
|
95
|
+
for (const drop of ["decision", "reasonCode", "shadow", "approvalRequired", "actionType", "requestHash", "policySnapshot"]) { const x = { ...ok }; delete x[drop]; assert.equal(decisionBody(x, asked), null, drop); }
|
|
96
|
+
assert.equal(decisionBody(null, asked), null);
|
|
97
|
+
assert.equal(decisionBody({ ...ok, junk: "x", effectiveMode: "weird" }, asked).junk, undefined);
|
|
98
|
+
assert.equal(decisionBody({ ...ok, effectiveMode: "weird" }, asked).effectiveMode, undefined);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// ------------------------------------------------------------------------------------------------------ fail closed
|
|
102
|
+
|
|
103
|
+
test("fail closed: handleDecision with an inconsistent decision for a pending question (a session that let it through) answers 502 DENY decision_invalid, never the decision", async () => {
|
|
104
|
+
const job = { jobId: uuid(), workspaceId: "ws-1", alias: "vh-x", repoRef: { provider: "github", org: "acme", repo: "app" } };
|
|
105
|
+
let sent = null;
|
|
106
|
+
const session = { state: "ready", peerHas: () => true, enqueue: (type, payload, o) => { sent = { type, payload, o }; return Promise.resolve({ acked: new Promise(() => {}) }); } };
|
|
107
|
+
const rl = createPolicyCheckRelay({ getSession: () => session, timeoutMs: 30_000 });
|
|
108
|
+
const pending = rl.relay({ check: { actionType: "APPLY_PATCH", requestHash: HASH }, job });
|
|
109
|
+
await waitFor(() => sent !== null);
|
|
110
|
+
const inconsistent = { decision: "ALLOW", reasonCode: "autonomous", shadow: false, approvalRequired: true, actionType: "APPLY_PATCH", requestHash: HASH, policySnapshot: { hash: "d".repeat(64) } };
|
|
111
|
+
assert.deepEqual(rl.handleDecision({ requestId: sent.o.requestId, params: inconsistent }), { accept: true });
|
|
112
|
+
const out = await pending;
|
|
113
|
+
assert.deepEqual([out.status, out.json.decision, out.json.error], [502, "DENY", "decision_invalid"]);
|
|
114
|
+
assert.equal(rl.stats.invalid, 1);
|
|
115
|
+
assert.equal(rl.stats.allow, 0, "not counted as an ALLOW");
|
|
116
|
+
rl.close();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("fail closed: the CP does not list policy.check -> nothing is sent and agent2 gets a 503 DENY", async () => {
|
|
120
|
+
const r = await agent2Rig({ scenario: "policy_check", cpPolicyCapability: false, policy: (m) => decisionFor(m) });
|
|
121
|
+
assert.ok(!r.link.session.peerHas("policy.check"));
|
|
122
|
+
const { job } = await run(r);
|
|
123
|
+
assert.equal(r.checks.length, 0, "no policy.check frame was sent to a CP that did not announce the capability");
|
|
124
|
+
assert.equal(job.record.policy.status, 503);
|
|
125
|
+
assert.deepEqual([job.record.policy.json.decision, job.record.policy.json.shadow, job.record.policy.json.error], ["DENY", false, "not_negotiated"]);
|
|
126
|
+
await r.stop();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("fail closed: no decision in time -> 504 DENY; the late decision is dropped and counted; a decision nobody asked for is dropped too", async () => {
|
|
130
|
+
let held = null;
|
|
131
|
+
const r = await agent2Rig({ scenario: "policy_check", env: { LOCAL_AGENT_AGENT2_POLICY_TIMEOUT_MS: "1000" }, policy: (m) => { held = m; return null; } });
|
|
132
|
+
const t0 = Date.now();
|
|
133
|
+
const { job } = await run(r);
|
|
134
|
+
assert.equal(job.record.policy.status, 504);
|
|
135
|
+
assert.equal(job.record.policy.json.decision, "DENY");
|
|
136
|
+
assert.equal(job.record.policy.json.error, "timeout");
|
|
137
|
+
assert.ok(Date.now() - t0 >= 900, "it waited the configured time");
|
|
138
|
+
// the answer arrives late
|
|
139
|
+
r.cp.sendData("policy.decision", decisionFor(held), { requestId: held.requestId, workspaceId: held.workspaceId });
|
|
140
|
+
await waitFor(() => r.link.agent2.policyCheck.stats.late === 1);
|
|
141
|
+
r.cp.sendData("policy.decision", decisionFor(held), { requestId: uuid(), workspaceId: "ws-1" });
|
|
142
|
+
await waitFor(() => r.link.agent2.policyCheck.stats.late === 2);
|
|
143
|
+
assert.equal(r.link.agent2.policyCheck.pending, 0);
|
|
144
|
+
assert.deepEqual(r.cp.acksFromLa.filter((x) => x.status === "rejected"), [], "both were acked (no oracle), neither was used");
|
|
145
|
+
await r.stop();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("fail closed: a decision for another request (wrong requestHash) is NOT the answer: 502 DENY at once, counted", async () => {
|
|
149
|
+
const r = await agent2Rig({ scenario: "policy_check", policy: (m) => decisionFor(m, { requestHash: HASH }) });
|
|
150
|
+
const { job } = await run(r);
|
|
151
|
+
assert.equal(job.record.policy.status, 502);
|
|
152
|
+
assert.deepEqual([job.record.policy.json.decision, job.record.policy.json.error], ["DENY", "decision_mismatch"]);
|
|
153
|
+
assert.equal(r.link.agent2.policyCheck.stats.mismatch, 1);
|
|
154
|
+
assert.ok(r.logs.some((l) => /did not match/.test(l)) && !r.logs.join("\n").includes(HASH));
|
|
155
|
+
await r.stop();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("fail closed: the CP refuses the frame (ack rejected) -> a DENY at once, not after the timeout", async () => {
|
|
159
|
+
const r = await agent2Rig({ scenario: "policy_check", env: { LOCAL_AGENT_AGENT2_POLICY_TIMEOUT_MS: "20000" }, policy: null });
|
|
160
|
+
r.cp.rejectTypes["policy.check"] = "protocol_unsupported"; // like a gateway with the feature off: a stray policy.check is refused, no decision will come
|
|
161
|
+
const t0 = Date.now();
|
|
162
|
+
const { job } = await run(r);
|
|
163
|
+
assert.equal(job.record.policy.status, 503);
|
|
164
|
+
assert.match(job.record.policy.json.error, /^rejected/);
|
|
165
|
+
assert.ok(Date.now() - t0 < 5000);
|
|
166
|
+
await r.stop();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("the relay in isolation: no session, not ready, not negotiated, a bad action type, too many in flight, the session closing -> DENY with a reason; never throws", async () => {
|
|
170
|
+
const job = { jobId: uuid(), workspaceId: "ws-1", alias: "vh-x", repoRef: { provider: "github", org: "acme", repo: "app", branch: "main" } };
|
|
171
|
+
const body = { actionType: "APPLY_PATCH", toolName: "apply_patch", requestHash: HASH };
|
|
172
|
+
assert.equal((await createPolicyCheckRelay({ getSession: () => null }).relay({ check: body, job })).json.error, "no_session");
|
|
173
|
+
assert.equal((await createPolicyCheckRelay({ getSession: () => ({ state: "detached", peerHas: () => true }) }).relay({ check: body, job })).json.error, "no_session");
|
|
174
|
+
assert.equal((await createPolicyCheckRelay({ getSession: () => ({ state: "ready", peerHas: () => false }) }).relay({ check: body, job })).json.error, "not_negotiated");
|
|
175
|
+
const never = { state: "ready", peerHas: () => true, enqueue: () => new Promise(() => {}) };
|
|
176
|
+
const rl = createPolicyCheckRelay({ getSession: () => never, timeoutMs: 30_000 });
|
|
177
|
+
assert.equal((await rl.relay({ check: { ...body, actionType: "has space" }, job })).status, 400);
|
|
178
|
+
assert.equal((await rl.relay({ check: null, job })).status, 400);
|
|
179
|
+
assert.equal((await rl.relay({ check: { actionType: "APPLY_PATCH" }, job })).status, 400, "no requestHash: nothing to bind a decision to");
|
|
180
|
+
const open = Array.from({ length: 16 }, () => rl.relay({ check: body, job }));
|
|
181
|
+
assert.equal(rl.pending, 16);
|
|
182
|
+
const extra = await rl.relay({ check: body, job });
|
|
183
|
+
assert.deepEqual([extra.status, extra.json.error], [429, "busy"]);
|
|
184
|
+
rl.close();
|
|
185
|
+
for (const o of await Promise.all(open)) assert.deepEqual([o.status, o.json.decision, o.json.error], [503, "DENY", "closed"]);
|
|
186
|
+
const boom = createPolicyCheckRelay({ getSession: () => ({ state: "ready", peerHas: () => true, enqueue: () => Promise.reject(Object.assign(new Error("x"), { code: "rate_limited" })) }) });
|
|
187
|
+
assert.equal((await boom.relay({ check: body, job })).json.error, "send_rate_limited");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// ------------------------------------------------------------------------------------------------------ the door (bridge)
|
|
191
|
+
|
|
192
|
+
test("the door: only the callback token opens it; only the exact `{type:policy.check,payload}` of PR #308 is served (the hosted `{method,path,body}` shape is not); a tenant/origin/agent claim is dropped before the frame", async () => {
|
|
193
|
+
const r = await agent2Rig({ scenario: "policy", policy: (m) => decisionFor(m) });
|
|
194
|
+
const { job } = await run(r);
|
|
195
|
+
assert.equal(job.record.policy.status, 200, "the request of PR #308, as is, is answered (no 403 path_not_allowed)");
|
|
196
|
+
assert.equal(job.record.policyOtherShape.status, 403, "the hosted shape is refused");
|
|
197
|
+
assert.deepEqual(job.record.policyOtherShape.json.error.code, "path_not_allowed");
|
|
198
|
+
assert.equal(r.checks.length, 1, "only the real question reached the control plane");
|
|
199
|
+
await r.stop();
|
|
200
|
+
const seen = [];
|
|
201
|
+
const r2 = await agent2Rig({ scenario: "stuck", policy: (m) => { seen.push(m); return decisionFor(m); } });
|
|
202
|
+
const a = r2.assign(); await a.acked(); await waitFor(() => r2.fake.jobs.size === 1);
|
|
203
|
+
const h = r2.fake.jobs.get(a.p.jobId).h;
|
|
204
|
+
assert.equal((await h.policyCheck({ actionType: "X", tokenOverride: "z".repeat(43) })).status, 401);
|
|
205
|
+
assert.equal((await h.policyCheck({ actionType: "X", tokenOverride: r2.fake.jobs.get(a.p.jobId).llm.key })).status, 401, "the LLM token is not the door's");
|
|
206
|
+
const ok = await h.policyCheck({ actionType: "APPLY_PATCH", extra: { tenantId: "9d1f6a2c-0000-4000-8000-000000000000", executionOrigin: "hosted", agent: "x", workspaceId: "w" } });
|
|
207
|
+
assert.equal(ok.status, 200);
|
|
208
|
+
assert.equal(seen.length, 1);
|
|
209
|
+
assert.deepEqual(Object.keys(seen[0].payload).sort(), ["actionType", "incidentId", "jobId", "modelAlias", "repoRefs", "requestHash"]);
|
|
210
|
+
await r2.cancel(a.p.jobId);
|
|
211
|
+
await r2.stop();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("the door: another job id, another repository, a malformed question, a wrong envelope -> refused with a code, NOTHING is sent to the control plane (agent2 cannot ask about what is not its job)", async () => {
|
|
215
|
+
const r = await agent2Rig({ scenario: "policy_hostile", policy: (m) => decisionFor(m) });
|
|
216
|
+
const { job } = await run(r);
|
|
217
|
+
const bad = job.record.bad;
|
|
218
|
+
const expected = { otherJob: 403, otherRepo: 403, twoRepos: 403, repoExtraKey: 400, noHash: 400, badHash: 400, badAction: 400, badTool: 400, payloadArray: 403, noPayload: 403, extraTop: 403, otherType: 403 };
|
|
219
|
+
for (const [k, status] of Object.entries(expected)) assert.equal(bad[k].status, status, k);
|
|
220
|
+
assert.equal(r.checks.length, 0, "no frame left this agent");
|
|
221
|
+
for (const k of ["otherJob", "otherRepo", "twoRepos"]) assert.equal(bad[k].json.decision, "DENY", k);
|
|
222
|
+
assert.equal(bad.otherJob.json.error, "job_mismatch");
|
|
223
|
+
assert.equal(bad.otherRepo.json.error, "repo_mismatch");
|
|
224
|
+
assert.deepEqual(r.cp.errorsSent, []);
|
|
225
|
+
await r.stop();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("the question is pinned to the running job: a payload without jobId/repoRefs/modelAlias gets the job's; the job's repository is always the one asked about", async () => {
|
|
229
|
+
const r = await agent2Rig({ scenario: "stuck", policy: (m) => decisionFor(m) });
|
|
230
|
+
const a = r.assign(); await a.acked(); await waitFor(() => r.fake.jobs.size === 1);
|
|
231
|
+
const h = r.fake.jobs.get(a.p.jobId).h;
|
|
232
|
+
const res = await h.policyRaw({ type: "policy.check", payload: { actionType: "OPEN_PR", requestHash: HASH } });
|
|
233
|
+
assert.equal(res.status, 200);
|
|
234
|
+
assert.deepEqual(r.checks[0].payload, { actionType: "OPEN_PR", requestHash: HASH, repoRefs: [{ provider: "github", org: "acme", repo: "app" }], incidentId: "inc-42", jobId: a.p.jobId, modelAlias: "vh-claude-sonnet" });
|
|
235
|
+
await r.cancel(a.p.jobId);
|
|
236
|
+
await r.stop();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("no running job (the job is over, its token revoked) -> 409 DENY; nothing is asked", async () => {
|
|
240
|
+
const r = await agent2Rig({ scenario: "no_change", policy: (m) => decisionFor(m) });
|
|
241
|
+
const a = r.assign(); await a.acked(); await r.done(a.p.jobId);
|
|
242
|
+
await waitFor(() => r.link.agent2.bridge.activeJobs === 0);
|
|
243
|
+
const res = await fetch(`${r.bridgeBase}/agent2/cp-request`, { method: "POST", headers: { authorization: `Bearer ${r.callbackToken}`, "content-type": "application/json" }, body: JSON.stringify({ type: "policy.check", payload: { actionType: "X", requestHash: HASH } }) });
|
|
244
|
+
assert.equal(res.status, 409);
|
|
245
|
+
assert.equal(r.checks.length, 0);
|
|
246
|
+
await r.stop();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("normalizePolicyCheck: the closed payload, pinned to the job; the same job/hash binds the decision (agent2's own requestHash is used, none is invented)", () => {
|
|
250
|
+
const job = { jobId: "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", workspaceId: "ws-1", alias: "vh-x", incidentId: "inc-1", repoRef: { provider: "github", org: "acme", repo: "app", branch: "main" } };
|
|
251
|
+
const ok = normalizePolicyCheck({ actionType: "RUN_SHELL_FREEFORM", toolName: "run_command", requestHash: HASH, modelAlias: "vh-OTHER", tenantId: "t", executionOrigin: "hosted" }, job);
|
|
252
|
+
assert.deepEqual(ok.payload, { actionType: "RUN_SHELL_FREEFORM", toolName: "run_command", requestHash: HASH, repoRefs: [{ provider: "github", org: "acme", repo: "app" }], incidentId: "inc-1", jobId: job.jobId, modelAlias: "vh-x" });
|
|
253
|
+
assert.equal(ok.dropped, 2);
|
|
254
|
+
assert.equal(normalizePolicyCheck({ actionType: "x y", requestHash: HASH }, job).error, "bad_action_type");
|
|
255
|
+
assert.equal(normalizePolicyCheck({ actionType: "X" }, job).error, "bad_request_hash");
|
|
256
|
+
assert.equal(normalizePolicyCheck({ actionType: "X", requestHash: "A".repeat(64) }, job).error, "bad_request_hash", "upper case is not a sha256 of the contract");
|
|
257
|
+
assert.equal(normalizePolicyCheck({ actionType: "X", requestHash: HASH, jobId: "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5e" }, job).error, "job_mismatch");
|
|
258
|
+
assert.equal(normalizePolicyCheck({ actionType: "X", requestHash: HASH, repoRefs: [{ provider: "github", org: "acme", repo: "lib" }] }, job).error, "repo_mismatch");
|
|
259
|
+
assert.equal(normalizePolicyCheck({ actionType: "X", requestHash: HASH, repoRefs: [] }, job).error, "bad_repo_refs");
|
|
260
|
+
assert.equal(normalizePolicyCheck({ actionType: "X", requestHash: HASH, repoRefs: "github.com/acme/app" }, job).error, "bad_repo_refs");
|
|
261
|
+
assert.equal(normalizePolicyCheck({ actionType: "X", requestHash: HASH, repoRefs: [{ provider: "svn", org: "acme", repo: "app" }] }, job).error, "bad_repo_refs");
|
|
262
|
+
assert.equal(normalizePolicyCheck({ actionType: "X", requestHash: HASH, repoRefs: [{ provider: "github", org: "acme", repo: "app" }] }, { ...job, repoRef: undefined }).error, "repo_mismatch", "a repository on a job that has none is not asked about");
|
|
263
|
+
assert.deepEqual(normalizePolicyCheck({ actionType: "X", requestHash: HASH }, { ...job, repoRef: undefined, incidentId: undefined }).payload, { actionType: "X", requestHash: HASH, jobId: job.jobId, modelAlias: "vh-x" });
|
|
264
|
+
assert.equal(normalizePolicyCheck(null, job).error, "bad_request");
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("no secret, request text, path or token of the client reaches the wire, a log, an audit or a stat through the policy door", async () => {
|
|
268
|
+
const r = await agent2Rig({ scenario: "policy_check", policy: (m) => decisionFor(m) });
|
|
269
|
+
const { job } = await run(r);
|
|
270
|
+
const dump = r.everything();
|
|
271
|
+
for (const secret of [r.key, r.apiToken, r.callbackToken, job.llm.key, "SECRET-PATH-CANARY", "acme/app.git", r.root]) assert.ok(!dump.includes(secret), secret.slice(0, 16));
|
|
272
|
+
await r.stop();
|
|
273
|
+
});
|