deepdebug-local-agent 1.10.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/.github/workflows/build-byoc-image.yml +9 -11
  2. package/AGENTS.md +1 -1
  3. package/CHANGELOG.md +5 -0
  4. package/CLAUDE.md +1 -1
  5. package/cloudbuild-full.yaml +2 -1
  6. package/docs/byoc-agent-image.md +8 -7
  7. package/docs/examples/agent2-runner/docker-compose.yml +53 -0
  8. package/docs/la-agent2-runner.md +115 -0
  9. package/docs/la-jobs-and-llm.md +14 -1
  10. package/docs/la-protocol-v2.md +4 -2
  11. package/env.example +6 -0
  12. package/package.json +1 -1
  13. package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +8 -4
  14. package/src/la-protocol-v2/contract/README.md +51 -2
  15. package/src/la-protocol-v2/contract/envelope.schema.json +3 -1
  16. package/src/la-protocol-v2/contract/index.json +18 -2
  17. package/src/la-protocol-v2/contract/policy.check.schema.json +97 -0
  18. package/src/la-protocol-v2/contract/policy.decision.schema.json +119 -0
  19. package/src/la-protocol-v2/jobs/agent2/api-client.js +150 -0
  20. package/src/la-protocol-v2/jobs/agent2/approvals.js +114 -0
  21. package/src/la-protocol-v2/jobs/agent2/bridge.js +299 -0
  22. package/src/la-protocol-v2/jobs/agent2/config.js +128 -0
  23. package/src/la-protocol-v2/jobs/agent2/frames.js +61 -0
  24. package/src/la-protocol-v2/jobs/agent2/policy-check.js +170 -0
  25. package/src/la-protocol-v2/jobs/agent2/runner.js +258 -0
  26. package/src/la-protocol-v2/jobs/agent2/supervisor.js +269 -0
  27. package/src/la-protocol-v2/jobs/git-exec.js +71 -0
  28. package/src/la-protocol-v2/jobs/install.js +14 -4
  29. package/src/la-protocol-v2/jobs/manager.js +30 -16
  30. package/src/la-protocol-v2/jobs/workspace-materializer.js +244 -0
  31. package/src/la-protocol-v2/llm/client.js +133 -12
  32. package/src/la-protocol-v2/llm/config.js +2 -0
  33. package/src/la-protocol-v2/runtime.js +36 -3
  34. package/src/server.js +4 -0
  35. package/src/utils/approval-token.js +3 -2
  36. package/test/contract/la-protocol-v2/vectors/policy.check.vectors.json +908 -0
  37. package/test/contract/la-protocol-v2/vectors/policy.decision.vectors.json +826 -0
  38. package/test/la-protocol-v2/agent2-approval-discovery.test.js +114 -0
  39. package/test/la-protocol-v2/agent2-config.test.js +103 -0
  40. package/test/la-protocol-v2/agent2-orphan.test.js +118 -0
  41. package/test/la-protocol-v2/agent2-policy-check.test.js +273 -0
  42. package/test/la-protocol-v2/agent2-real-e2e.test.js +89 -0
  43. package/test/la-protocol-v2/agent2-runner.test.js +510 -0
  44. package/test/la-protocol-v2/agent2-supervisor.test.js +185 -0
  45. package/test/la-protocol-v2/agent2-workspace-door.test.js +142 -0
  46. package/test/la-protocol-v2/agent2-workspace-real-server.test.js +137 -0
  47. package/test/la-protocol-v2/contract-v21.test.js +2 -2
  48. package/test/la-protocol-v2/contract.test.js +8 -7
  49. package/test/la-protocol-v2/helpers/agent2-rig.js +96 -0
  50. package/test/la-protocol-v2/helpers/fake-agent2-cli.js +22 -0
  51. package/test/la-protocol-v2/helpers/fake-agent2.js +280 -0
  52. package/test/la-protocol-v2/helpers/fake-la-api.js +29 -0
  53. package/test/la-protocol-v2/helpers/fake-llm.js +11 -0
  54. package/test/la-protocol-v2/helpers/reference-cp.js +6 -3
  55. package/test/la-protocol-v2/helpers/spawn-agent.js +4 -3
  56. package/test/la-protocol-v2/helpers/supervisor-exit-script.js +15 -0
  57. package/test/la-protocol-v2/llm-messages.test.js +132 -0
  58. package/test/la-protocol-v2/workspace-materializer.test.js +341 -0
@@ -0,0 +1,341 @@
1
+ // LA-2: the Local Agent materializes a workspace itself for a job.assign (flag LOCAL_AGENT_JOBS_AUTO_WORKSPACE, default off). Allowlist (fail closed),
2
+ // customer-side credentials only, tenant gate, cache safety (symlink, foreign dir), no secret anywhere, one clone at a time, job manager integration.
3
+ // Real git is used against a local bare repository (the test seam allows the file transport; production never does).
4
+ import { test } from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import fs from "node:fs";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+ import crypto from "node:crypto";
10
+ import { execFileSync } from "node:child_process";
11
+ import { createWorkspaceMaterializer, resolveMaterializeConfig, parseAllowlist, repoTarget, MaterializeError } from "../../src/la-protocol-v2/jobs/workspace-materializer.js";
12
+ import { classifyGitFailure, gitEnv, ensureAskpassScript, SAFE_GIT_CONFIG } from "../../src/la-protocol-v2/jobs/git-exec.js";
13
+ import { createJobManager } from "../../src/la-protocol-v2/jobs/manager.js";
14
+ import { resolveJobsConfig } from "../../src/la-protocol-v2/jobs/config.js";
15
+ import { createAudit } from "../../src/la-protocol-v2/content-handlers/audit.js";
16
+ import { WorkspaceManager } from "../../src/workspace-manager.js";
17
+ import { WORKSPACE_SIDECAR } from "../../src/utils/open-gate.js";
18
+
19
+ const TENANT = "3f2b8c1e-5a4d-4e7b-9c21-0a1b2c3d4e5f";
20
+ const OTHER = "9a9a9a9a-5a4d-4e7b-9c21-0a1b2c3d4e5f";
21
+ const WS = "11111111-2222-4333-8444-555555555555";
22
+ const SECRET = "ghp_PLANTEDsecretTOKEN0123456789abcdef";
23
+ const REF = { provider: "github", org: "acme", repo: "app", branch: "main" };
24
+ const noop = () => {};
25
+ const tmp = (p) => fs.mkdtempSync(path.join(os.tmpdir(), `la-mat-${p}-`));
26
+ const git = (cwd, ...a) => execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", ...a], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
27
+
28
+ /** A bare "remote" with one commit on main (and one on `dev`); returns its path. */
29
+ function makeRemote() {
30
+ const bare = tmp("remote");
31
+ const work = tmp("seed");
32
+ git(bare, "init", "--bare", "-b", "main", ".");
33
+ git(work, "init", "-b", "main", ".");
34
+ fs.writeFileSync(path.join(work, "a.txt"), "one\n");
35
+ git(work, "add", "."); git(work, "commit", "-m", "one");
36
+ git(work, "remote", "add", "origin", bare);
37
+ git(work, "push", "origin", "main");
38
+ return { bare, work, push(text) { fs.writeFileSync(path.join(work, "a.txt"), text); git(work, "commit", "-am", "more"); git(work, "push", "origin", "main"); } };
39
+ }
40
+
41
+ function setup({ allow = "github.com/acme/app", remote = null, env = {}, runGit = undefined, tenant = TENANT } = {}) {
42
+ const cache = tmp("cache");
43
+ const warn = [];
44
+ const cfg = resolveMaterializeConfig({ LOCAL_AGENT_JOBS_AUTO_WORKSPACE: "true", LOCAL_AGENT_ALLOWED_REPOS: allow, LOCAL_AGENT_WORKSPACE_CACHE: cache, ...env }, (m) => warn.push(m));
45
+ const wsManager = new WorkspaceManager();
46
+ const audit = createAudit({});
47
+ const seams = remote ? { remoteUrl: () => remote.bare, gitConfig: ["-c", "protocol.file.allow=always"] } : {};
48
+ if (runGit) seams.runGit = runGit;
49
+ const mat = createWorkspaceMaterializer({ config: cfg, tenantId: tenant, wsManager, audit, env: { PATH: process.env.PATH, HOME: process.env.HOME, ...(env.PROC || {}) }, seams });
50
+ return { mat, cfg, cache, wsManager, audit, warn };
51
+ }
52
+
53
+ // ---------------------------------------------------------------- config / parsing
54
+
55
+ test("config: off by default; on needs the flag; an empty allowlist warns and admits nothing", () => {
56
+ for (const v of [undefined, "", "false", "0", "nope"]) assert.deepEqual(resolveMaterializeConfig({ LOCAL_AGENT_JOBS_AUTO_WORKSPACE: v, LOCAL_AGENT_ALLOWED_REPOS: "github.com/acme/app" }), { enabled: false });
57
+ const w = [];
58
+ const c = resolveMaterializeConfig({ LOCAL_AGENT_JOBS_AUTO_WORKSPACE: "true" }, (m) => w.push(m));
59
+ assert.equal(c.enabled, true);
60
+ assert.match(w.join("\n"), /empty/);
61
+ const { mat } = setup({ allow: "" });
62
+ assert.equal(mat.admit(WS, REF), false);
63
+ assert.equal(mat.stats.refused.repo_not_allowed, 1);
64
+ });
65
+
66
+ test("allowlist parsing: exact and org/*; garbage (scheme, @, .., bare *, host/*) is dropped", () => {
67
+ const a = parseAllowlist("github.com/Acme/App, github.com/corp/*\nhttps://github.com/x/y git@github.com:x/y.git github.com/../x github.com/o/.. * github.com/* # c");
68
+ assert.deepEqual([...a.exact], ["github.com/acme/app"]);
69
+ assert.deepEqual([...a.orgs], ["github.com/corp"]);
70
+ assert.ok(a.invalid >= 6);
71
+ });
72
+
73
+ test("repoTarget: closed provider list, strict segments, branch rules, no credentials in the URL", () => {
74
+ const ok = repoTarget(REF);
75
+ assert.equal(ok.url, "https://github.com/acme/app.git");
76
+ assert.ok(!/@/.test(ok.url));
77
+ for (const bad of [{ ...REF, org: ".." }, { ...REF, repo: "." }, { ...REF, org: "a/b" }, { ...REF, org: "a@b" }, { ...REF, repo: "x y" }, { ...REF, provider: "azure" }, { ...REF, provider: "other" },
78
+ { ...REF, provider: "evil.com" }, { ...REF, branch: "-x" }, { ...REF, branch: "a..b" }, { ...REF, branch: "a//b" }, { ...REF, branch: "x.lock" }, { ...REF, branch: "/x" }, { ...REF, branch: "a b" }, null, {}]) assert.equal(repoTarget(bad), null, JSON.stringify(bad));
79
+ assert.equal(repoTarget({ ...REF, provider: "gitlab" }).url, "https://gitlab.com/acme/app.git");
80
+ });
81
+
82
+ test("git-exec: failure classification by code only; env is minimal and never carries the agent's own secrets", () => {
83
+ assert.equal(classifyGitFailure({}, "fatal: Authentication failed for 'https://x/'"), "auth");
84
+ assert.equal(classifyGitFailure({}, "fatal: could not read Username for 'https://github.com': terminal prompts disabled"), "auth");
85
+ assert.equal(classifyGitFailure({}, "fatal: Remote branch zz not found in upstream origin"), "branch_missing");
86
+ assert.equal(classifyGitFailure({}, "remote: Repository not found."), "not_found");
87
+ assert.equal(classifyGitFailure({ killed: true }, ""), "timeout");
88
+ assert.equal(classifyGitFailure({}, "boom"), "failed");
89
+ const ac = new AbortController(); ac.abort();
90
+ assert.equal(classifyGitFailure({}, "", ac.signal), "aborted");
91
+ const e = gitEnv({ PATH: "/bin", HOME: "/h", LOCAL_AGENT_TOKEN: "x", DEEPDEBUG_API_KEY: "y", LOCAL_AGENT_LLM_KEY: "z", SSH_AUTH_SOCK: "/s" });
92
+ assert.deepEqual(Object.keys(e).sort(), ["GCM_INTERACTIVE", "GIT_TERMINAL_PROMPT", "HOME", "LC_ALL", "PATH", "SSH_AUTH_SOCK"]);
93
+ assert.ok(SAFE_GIT_CONFIG.includes("protocol.ext.allow=never") && SAFE_GIT_CONFIG.includes("protocol.file.allow=never"));
94
+ });
95
+
96
+ // ---------------------------------------------------------------- admission
97
+
98
+ test("admit: allowlisted repo ok; org/* ok; repo outside the allowlist, bad ids and 'default' are refused with a code", () => {
99
+ const { mat, audit } = setup({ allow: "github.com/acme/app,github.com/corp/*" });
100
+ assert.equal(mat.admit(WS, REF), true);
101
+ assert.equal(mat.admit(WS, { ...REF, org: "corp", repo: "anything" }), true);
102
+ assert.equal(mat.admit(WS, { ...REF, org: "evil" }), false);
103
+ assert.equal(mat.admit(WS, { ...REF, repo: "other" }), false);
104
+ assert.equal(mat.admit(WS, { ...REF, org: "..", repo: ".." }), false);
105
+ for (const id of ["default", "../x", "a/b", "", ".hidden", "x".repeat(101), null, 5]) assert.equal(mat.admit(id, REF), false, String(id));
106
+ assert.equal(mat.admit(WS, null), false);
107
+ assert.ok(mat.stats.refused.repo_not_allowed >= 2);
108
+ assert.ok(mat.stats.refused.bad_workspace_id >= 7);
109
+ const trail = JSON.stringify(audit.tail(100));
110
+ assert.ok(!trail.includes("evil") && !trail.includes(WS), "audit holds short hashes and codes, not names");
111
+ });
112
+
113
+ test("admit: an id already open (here or for another tenant) is never replaced", async () => {
114
+ const { mat, wsManager, cache } = setup();
115
+ const d = path.join(cache, "other"); fs.mkdirSync(d);
116
+ await wsManager.open(WS, d, { tenantId: OTHER });
117
+ assert.equal(mat.admit(WS, REF), false);
118
+ assert.equal(mat.stats.refused.already_open_elsewhere, 1);
119
+ });
120
+
121
+ test("admit: a cache entry that is a symlink, a directory without registration, or one of ANOTHER tenant is refused", () => {
122
+ const { mat, cache } = setup();
123
+ const outside = tmp("outside");
124
+ fs.symlinkSync(outside, path.join(cache, WS));
125
+ assert.equal(mat.admit(WS, REF), false);
126
+ assert.equal(mat.stats.refused.cache_entry_unsafe, 1);
127
+ const ws2 = "22222222-2222-4333-8444-555555555555";
128
+ fs.mkdirSync(path.join(cache, ws2));
129
+ assert.equal(mat.admit(ws2, REF), false);
130
+ assert.equal(mat.stats.refused.foreign_directory, 1);
131
+ const ws3 = "33333333-2222-4333-8444-555555555555";
132
+ const d3 = path.join(fs.realpathSync(cache), ws3); fs.mkdirSync(d3);
133
+ fs.writeFileSync(path.join(d3, WORKSPACE_SIDECAR), JSON.stringify({ id: ws3, tenantId: OTHER, root: d3, openedAt: "x" }));
134
+ assert.equal(mat.admit(ws3, REF), false);
135
+ assert.equal(mat.stats.refused.tenant_mismatch, 1);
136
+ assert.deepEqual(fs.readdirSync(outside), [], "nothing was written through the symlink");
137
+ });
138
+
139
+ test("admit: a configured token file that cannot be read is a clear refusal (credential_unavailable); without a token file the machine's own credentials are used", () => {
140
+ const { mat } = setup({ env: { LOCAL_AGENT_GIT_TOKEN_FILE: "/nonexistent/token" } });
141
+ assert.equal(mat.admit(WS, REF), false);
142
+ assert.equal(mat.stats.refused.credential_unavailable, 1);
143
+ assert.equal(setup().mat.admit(WS, REF), true);
144
+ });
145
+
146
+ // ---------------------------------------------------------------- real clone against a local bare repository
147
+
148
+ test("ensure: clones into the cache, registers the workspace under the SESSION tenant, leaves no token and no credential in .git/config", async () => {
149
+ const remote = makeRemote();
150
+ const { mat, wsManager, cache, audit } = setup({ remote });
151
+ assert.equal(mat.admit(WS, REF), true);
152
+ const r = await mat.ensure(WS, REF);
153
+ assert.equal(r.root, path.join(fs.realpathSync(cache), WS));
154
+ assert.equal(fs.readFileSync(path.join(r.root, "a.txt"), "utf8"), "one\n");
155
+ const w = wsManager.list().find((x) => x.id === WS);
156
+ assert.equal(w.tenantId, TENANT);
157
+ const cfg = fs.readFileSync(path.join(r.root, ".git", "config"), "utf8");
158
+ assert.ok(!/@|token|password|x-access-token/i.test(cfg.replace(/user\.email/g, "")), "config has no credential");
159
+ assert.deepEqual(fs.readdirSync(cache).filter((n) => n.startsWith(".tmp-")), [], "no temp clone left");
160
+ assert.equal(mat.stats.cloned, 1);
161
+ assert.ok(audit.tail().some((e) => e.event === "workspace_ready"));
162
+ });
163
+
164
+ test("ensure: second call fetches and fast-forwards a CLEAN clone; a DIRTY one is kept untouched", async () => {
165
+ const remote = makeRemote();
166
+ const { mat } = setup({ remote });
167
+ const r = await mat.ensure(WS, REF);
168
+ remote.push("two\n");
169
+ await mat.ensure(WS, REF);
170
+ assert.equal(fs.readFileSync(path.join(r.root, "a.txt"), "utf8"), "two\n");
171
+ fs.writeFileSync(path.join(r.root, "a.txt"), "local work\n");
172
+ remote.push("three\n");
173
+ await mat.ensure(WS, REF);
174
+ assert.equal(fs.readFileSync(path.join(r.root, "a.txt"), "utf8"), "local work\n", "previous job's work is never reset");
175
+ assert.equal(mat.stats.reused, 2);
176
+ });
177
+
178
+ test("ensure: a missing branch fails with a code, leaves no directory behind and nothing registered", async () => {
179
+ const remote = makeRemote();
180
+ const { mat, wsManager, cache } = setup({ remote });
181
+ await assert.rejects(() => mat.ensure(WS, { ...REF, branch: "nope" }), (e) => e instanceof MaterializeError && e.code === "branch_missing" && e.jobErrorCode === "infra" && !/nope|acme|bare|remote/.test(e.message));
182
+ assert.deepEqual(fs.readdirSync(cache), []);
183
+ assert.equal(wsManager.list().length, 0);
184
+ });
185
+
186
+ test("ensure: concurrent jobs for the same workspace share ONE clone; two workspaces are serialized (one clone at a time)", async () => {
187
+ const remote = makeRemote();
188
+ let active = 0, peak = 0, clones = 0;
189
+ const real = (await import("../../src/la-protocol-v2/jobs/git-exec.js")).runGit;
190
+ const spy = async (args, o) => { const isClone = args[0] === "clone"; if (isClone) { clones++; active++; peak = Math.max(peak, active); await new Promise((r) => setTimeout(r, 40)); } try { return await real(args, o); } finally { if (isClone) active--; } };
191
+ const { mat } = setup({ remote, allow: "github.com/acme/*", runGit: spy });
192
+ const WS2 = "44444444-2222-4333-8444-555555555555";
193
+ await Promise.all([mat.ensure(WS, REF), mat.ensure(WS, REF), mat.ensure(WS2, REF)]);
194
+ assert.equal(clones, 2);
195
+ assert.equal(peak, 1);
196
+ });
197
+
198
+ test("ensure: a workspace of another tenant on disk, a symlinked cache entry and a repo outside the allowlist are refused even if admit() was skipped", async () => {
199
+ const remote = makeRemote();
200
+ const { mat, cache } = setup({ remote });
201
+ const outside = tmp("outside");
202
+ fs.symlinkSync(outside, path.join(cache, WS));
203
+ await assert.rejects(() => mat.ensure(WS, REF), (e) => e.code === "cache_entry_unsafe");
204
+ assert.deepEqual(fs.readdirSync(outside), []);
205
+ await assert.rejects(() => mat.ensure("55555555-2222-4333-8444-555555555555", { ...REF, org: "evil" }), (e) => e.code === "repo_not_allowed");
206
+ const other = setup({ remote, tenant: OTHER });
207
+ const first = await mat.ensure("66666666-2222-4333-8444-555555555555", REF);
208
+ fs.cpSync(first.root, path.join(other.cache, "66666666-2222-4333-8444-555555555555"), { recursive: true }); // same sidecar, tenant TENANT, found by OTHER's agent
209
+ await assert.rejects(() => other.mat.ensure("66666666-2222-4333-8444-555555555555", REF), (e) => e.code === "tenant_mismatch" || e.code === "foreign_directory");
210
+ });
211
+
212
+ test("ensure: a clone above the size limit is removed (too_large); an abort before start does not run git", async () => {
213
+ const remote = makeRemote();
214
+ const { mat, cache } = setup({ remote, env: { LOCAL_AGENT_WORKSPACE_MAX_BYTES: String(1024 * 1024) } });
215
+ fs.writeFileSync(path.join(remote.work, "big.bin"), crypto.randomBytes(1200 * 1024));
216
+ git(remote.work, "add", "."); git(remote.work, "commit", "-m", "big"); git(remote.work, "push", "origin", "main");
217
+ await assert.rejects(() => mat.ensure(WS, REF), (e) => e.code === "too_large");
218
+ assert.deepEqual(fs.readdirSync(cache), []);
219
+ const ac = new AbortController(); ac.abort();
220
+ await assert.rejects(() => mat.ensure("77777777-2222-4333-8444-555555555555", REF, ac.signal), (e) => e.code === "aborted");
221
+ });
222
+
223
+ // ---------------------------------------------------------------- secrets
224
+
225
+ test("secrets: the token file is only passed by PATH (askpass), never in args, env values, logs, audit or errors; the askpass script holds no token", async () => {
226
+ const dir = tmp("tok");
227
+ const tokenFile = path.join(dir, "token");
228
+ fs.writeFileSync(tokenFile, SECRET, { mode: 0o600 });
229
+ const calls = [];
230
+ const fake = async (args, o) => { calls.push({ args, env: o.env }); return { ok: false, code: "auth" }; };
231
+ const logs = [];
232
+ const { mat, audit, cache } = setup({ env: { LOCAL_AGENT_GIT_TOKEN_FILE: tokenFile }, runGit: fake });
233
+ await assert.rejects(() => mat.ensure(WS, REF), (e) => e.code === "auth" && !e.message.includes(SECRET));
234
+ const everything = JSON.stringify({ calls, audit: audit.tail(100), logs, stats: mat.stats });
235
+ assert.ok(!everything.includes(SECRET));
236
+ assert.equal(calls[0].env.LA_GIT_TOKEN_FILE, tokenFile);
237
+ assert.ok(calls[0].env.GIT_ASKPASS && calls[0].env.GIT_TERMINAL_PROMPT === "0");
238
+ assert.ok(!calls[0].args.some((a) => /@|ghp_|x-access-token/.test(a)), "no credential in any git argument");
239
+ const script = fs.readFileSync(ensureAskpassScript(fs.realpathSync(cache)), "utf8");
240
+ assert.ok(!script.includes(SECRET));
241
+ assert.equal(fs.statSync(path.join(fs.realpathSync(cache), ".vh-git-askpass.sh")).mode & 0o777, 0o700);
242
+ });
243
+
244
+ test("secrets: with a real git the askpass script answers from the file (user constant, password = file content) without the agent holding it", () => {
245
+ const dir = tmp("ask");
246
+ const tokenFile = path.join(dir, "token");
247
+ fs.writeFileSync(tokenFile, SECRET + "\n", { mode: 0o600 });
248
+ const s = ensureAskpassScript(dir);
249
+ const env = { PATH: process.env.PATH, LA_GIT_TOKEN_FILE: tokenFile };
250
+ assert.equal(execFileSync(s, ["Username for 'https://github.com': "], { env, encoding: "utf8" }).trim(), "x-access-token");
251
+ assert.equal(execFileSync(s, ["Password for 'https://x-access-token@github.com': "], { env, encoding: "utf8" }).trim(), SECRET);
252
+ });
253
+
254
+ // ---------------------------------------------------------------- job manager
255
+
256
+ const uuid = () => crypto.randomUUID();
257
+ const tick = (ms = 5) => new Promise((r) => setTimeout(r, ms));
258
+ const until = async (cond, ms = 3000) => { const t0 = Date.now(); while (!cond()) { if (Date.now() - t0 > ms) throw new Error("until: timed out"); await tick(5); } };
259
+ const params = (over = {}) => ({ jobId: uuid(), kind: "fix", repoRef: REF, policy: { operationMode: "assist", automationLevel: 2, ceiling: "assist" }, budget: { tokens: 50_000, seconds: 60 },
260
+ llm: { alias: "a", keyRef: "ab".repeat(32) }, inputs: [], ...over });
261
+ function mgr({ materializer, resolveWorkspace = () => null }) {
262
+ const frames = [];
263
+ const session = { enqueue: async (type, payload) => { frames.push({ type, payload }); return { acked: Promise.resolve() }; } };
264
+ const started = [];
265
+ const runner = { name: "r", needsLlm: false, start(job) { started.push(job.jobId); return Promise.resolve({ outcome: "no_change" }); }, cancel() {} };
266
+ const config = resolveJobsConfig({ LOCAL_AGENT_JOBS: "true", LOCAL_AGENT_JOBS_EVENT_MIN_MS: "50", LOCAL_AGENT_JOBS_TERMINAL_RETRY_MS: "10" }, noop);
267
+ const m = createJobManager({ session, tenantId: TENANT, resolveWorkspace, materializer, config, env: { LOCAL_AGENT_DLP_MODE: "enforce" }, runner, log: { warn: noop } });
268
+ return { m, frames, started };
269
+ }
270
+
271
+ test("manager: without a materializer (flag off) a workspace not served here is rejected unauthorized, as before", () => {
272
+ const { m, started } = mgr({ materializer: null });
273
+ const r = m.handleAssign({ params: params(), workspaceId: WS });
274
+ assert.deepEqual(r, { reject: "unauthorized" });
275
+ assert.equal(started.length, 0);
276
+ });
277
+
278
+ test("manager: a repo outside the allowlist is rejected unauthorized and nothing is cloned", () => {
279
+ const { mat } = setup({ allow: "github.com/other/thing" });
280
+ let ensured = 0;
281
+ const spy = { admit: (...a) => mat.admit(...a), ensure: () => { ensured++; }, bindAudit() {} };
282
+ const { m, started } = mgr({ materializer: spy });
283
+ assert.deepEqual(m.handleAssign({ params: params(), workspaceId: WS }), { reject: "unauthorized" });
284
+ assert.equal(ensured, 0);
285
+ assert.equal(started.length, 0);
286
+ });
287
+
288
+ test("manager: an allowlisted workspace is accepted, materialized INSIDE the job (after the ack), then the runner starts", async () => {
289
+ const order = [];
290
+ let release;
291
+ const gate = new Promise((r) => { release = r; });
292
+ const materializer = { admit: () => true, bindAudit() {}, ensure: async (id, ref) => { order.push(["ensure", id, ref.repo]); await gate; order.push(["ensured"]); return { root: "/x" }; } };
293
+ const { m, started, frames } = mgr({ materializer });
294
+ const p = params();
295
+ assert.deepEqual(m.handleAssign({ params: p, workspaceId: WS }), { accept: true });
296
+ await until(() => order.length === 1);
297
+ assert.equal(started.length, 0, "the runner waits for the clone");
298
+ release();
299
+ await until(() => started.length === 1);
300
+ assert.deepEqual(order.map((o) => o[0]), ["ensure", "ensured"]);
301
+ await until(() => frames.some((f) => f.type === "job.result"));
302
+ });
303
+
304
+ test("manager: a failed materialization (no credential, auth) ends the job failed/infra with a code only; the runner never starts", async () => {
305
+ const materializer = { admit: () => true, bindAudit() {}, ensure: async () => { throw new MaterializeError("auth"); } };
306
+ const { m, started, frames } = mgr({ materializer });
307
+ const p = params();
308
+ assert.deepEqual(m.handleAssign({ params: p, workspaceId: WS }), { accept: true });
309
+ await until(() => frames.some((f) => f.type === "job.result"));
310
+ const res = frames.find((f) => f.type === "job.result").payload;
311
+ assert.equal(res.outcome, "failed");
312
+ const fin = frames.filter((f) => f.type === "job.event").pop().payload;
313
+ assert.equal(fin.state, "failed");
314
+ assert.equal(fin.errorCode, "infra");
315
+ assert.equal(started.length, 0);
316
+ assert.ok(!JSON.stringify(frames).includes("auth"), "the wire carries infra, not the cause");
317
+ });
318
+
319
+ test("manager: job.cancel while the workspace is being cloned aborts the clone and the runner never starts", async () => {
320
+ let sig = null;
321
+ const materializer = { admit: () => true, bindAudit() {}, ensure: (id, ref, signal) => new Promise((_, rej) => { sig = signal; signal.addEventListener("abort", () => rej(new MaterializeError("aborted"))); }) };
322
+ const { m, started, frames } = mgr({ materializer });
323
+ const p = params();
324
+ m.handleAssign({ params: p, workspaceId: WS });
325
+ await until(() => sig);
326
+ m.handleCancel({ params: { jobId: p.jobId, reason: "user" } });
327
+ await until(() => frames.some((f) => f.type === "job.result"));
328
+ assert.equal(frames.find((f) => f.type === "job.result").payload.outcome, "cancelled");
329
+ assert.equal(started.length, 0);
330
+ });
331
+
332
+ test("manager + real materializer: a workspace of another tenant already open here is not touched (resolver refuses, materializer refuses)", async () => {
333
+ const { mat, wsManager, cache } = setup();
334
+ const d = path.join(cache, "x"); fs.mkdirSync(d);
335
+ await wsManager.open(WS, d, { tenantId: OTHER });
336
+ const resolve = (id) => { const w = wsManager.list().find((x) => x.id === id); return w && String(w.tenantId).toLowerCase() === TENANT ? { root: w.root } : null; };
337
+ const { m, started } = mgr({ materializer: mat, resolveWorkspace: resolve });
338
+ assert.deepEqual(m.handleAssign({ params: params(), workspaceId: WS }), { reject: "unauthorized" });
339
+ assert.equal(started.length, 0);
340
+ assert.equal(wsManager.list().find((x) => x.id === WS).tenantId, OTHER);
341
+ });