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,269 @@
1
+ /**
2
+ * supervisor.js - keeps agent2 (the packaged agent loop, profile local-agent) available for the runner (F3-08c, Local Agent half).
3
+ *
4
+ * spawn (LOCAL_AGENT_AGENT2_CMD) this agent starts the process, with a minimal environment and a NEW random API token at every start; waits for
5
+ * `/local/v1/health`; probes it while it runs; restarts it (at the next job) after a crash, at most `maxRestarts`
6
+ * times inside `restartWindowMs`, then reports `unavailable` instead of looping; kills it above `maxRssBytes`;
7
+ * stops it with the agent (SIGTERM, then SIGKILL after `stopGraceMs`).
8
+ * attach (LOCAL_AGENT_AGENT2_URL) agent2 already runs (sidecar); only health is checked. The token is the one it was started with.
9
+ *
10
+ * The environment of the child is an ALLOWLIST (PATH, HOME, JAVA_HOME, locale, proxies, the git/ssh variables the machine's own credentials need) plus
11
+ * what this module sets and what the operator named in LOCAL_AGENT_AGENT2_ENV_PASSTHROUGH (never a name that looks like a secret). Nothing else of this
12
+ * agent's environment reaches agent2: not the LLM key (it lives in the holder, in memory), not LOCAL_AGENT_TOKEN's siblings, not the signing keys.
13
+ * stdout/stderr of the child are drained and DROPPED: they may hold customer content; agent2's own logging is configured in agent2.
14
+ */
15
+ import { spawn as nodeSpawn, execFile } from "node:child_process";
16
+ import crypto from "node:crypto";
17
+ import { EventEmitter } from "node:events";
18
+ import fs from "node:fs";
19
+ import os from "node:os";
20
+ import path from "node:path";
21
+ import { createAgent2Api } from "./api-client.js";
22
+ import { isSecretEnvName } from "../../../utils/child-env.js";
23
+
24
+ const ALLOW = Object.freeze(["PATH", "HOME", "USER", "LOGNAME", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "TMPDIR", "TMP", "TEMP", "JAVA_HOME",
25
+ "SSH_AUTH_SOCK", "GIT_ASKPASS", "GIT_SSH", "GIT_SSH_COMMAND", "GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM", "GIT_CONFIG_NOSYSTEM", "GIT_TERMINAL_PROMPT",
26
+ "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy", "SSL_CERT_FILE", "SSL_CERT_DIR", "SYSTEMROOT", "PATHEXT", "COMSPEC"]);
27
+
28
+ export class Agent2UnavailableError extends Error {
29
+ constructor(code) { super(code); this.name = "Agent2UnavailableError"; this.code = code; }
30
+ }
31
+
32
+ const defaultSleep = (ms) => new Promise((r) => { const t = setTimeout(r, ms); if (t.unref) t.unref(); });
33
+ const defaultRss = (pid) => new Promise((resolve) => {
34
+ execFile("ps", ["-o", "rss=", "-p", String(pid)], { timeout: 3_000 }, (err, out) => {
35
+ const kb = err ? 0 : Number.parseInt(String(out).trim(), 10);
36
+ resolve(Number.isFinite(kb) && kb > 0 ? kb * 1024 : 0);
37
+ });
38
+ });
39
+
40
+ /** The full command line of a process, or null when it cannot be told (no `ps`, no such process). */
41
+ const defaultCmdline = (pid) => new Promise((resolve) => {
42
+ execFile("ps", ["-ww", "-o", "command=", "-p", String(pid)], { timeout: 3_000 }, (err, out) => {
43
+ const t = err ? "" : String(out).trim();
44
+ resolve(t || null);
45
+ });
46
+ });
47
+
48
+ const isAlive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return !!e && e.code === "EPERM"; } };
49
+
50
+ // Children still running when THIS process exits normally (SIGTERM/SIGINT handlers, process.exit): ended synchronously, best effort. A SIGKILL of this agent skips
51
+ // 'exit' handlers: that case is the pidfile's (the next start ends the orphan).
52
+ const liveChildren = new Set();
53
+ let exitHooked = false;
54
+ function hookExit() {
55
+ if (exitHooked) return;
56
+ exitHooked = true;
57
+ process.once("exit", () => { for (const c of liveChildren) { try { c.kill("SIGKILL"); } catch { /* gone */ } } });
58
+ }
59
+
60
+ /** The environment agent2 starts with (pure, so a test can pin it). */
61
+ export function agent2ChildEnv({ config, env, tenantId, token, bridgeBase, callbackToken }) {
62
+ const out = {};
63
+ for (const k of ALLOW) if (env[k] !== undefined) out[k] = env[k];
64
+ for (const k of config.passthrough || []) if (env[k] !== undefined && !isSecretEnvName(k)) out[k] = env[k];
65
+ Object.assign(out, {
66
+ AGENT2_LOCAL_MODE: "true",
67
+ AGENT2_LOCAL_BIND: "127.0.0.1",
68
+ AGENT2_LOCAL_PORT: String(config.apiPort),
69
+ AGENT2_LOCAL_API_TOKEN: token,
70
+ AGENT2_LOCAL_TENANT_ID: tenantId,
71
+ AGENT2_LLM_BASE_URL: `${bridgeBase}/v1`, // the loopback bridge: DLP + the LLM client of this agent, never the AI gateway directly
72
+ // agent2 reaches this agent's /workspace API THROUGH the bridge, which signs for it (internal marker) and adds the API's X-Agent-Token itself: the token agent2 holds
73
+ // here is the bridge's callback token, NOT LOCAL_AGENT_TOKEN, and it opens nothing but the bridge
74
+ AGENT2_LA_WORKSPACE_URL: bridgeBase,
75
+ AGENT2_LA_WORKSPACE_TOKEN: callbackToken,
76
+ AGENT2_LA_CALLBACK_URL: bridgeBase, // POST {url}/agent2/cp-request: the PolicyGate door (-> policy.check over the v2 channel)
77
+ AGENT2_LA_CALLBACK_TOKEN: callbackToken,
78
+ DEEPDEBUG_POLICYGATE_TIMEOUT_MS: String(config.policyCheckTimeoutMs + 3_000), // agent2 must wait longer than the channel does (default 3 s would cut a decision off)
79
+ });
80
+ if (config.maxHeapMb > 0) out.JAVA_TOOL_OPTIONS = `-Xmx${config.maxHeapMb}m`;
81
+ if (config.approvalWaitSeconds > 0) out.AGENT2_LOCAL_APPROVAL_WAIT_SECONDS = String(config.approvalWaitSeconds);
82
+ return out;
83
+ }
84
+
85
+ export function createAgent2Supervisor({ config, tenantId, bridge, env = process.env, log = console, spawnImpl = nodeSpawn, fetchImpl, now = Date.now, sleep = defaultSleep, rssOf = defaultRss, cmdlineOf = defaultCmdline }) {
86
+ const emitter = new EventEmitter();
87
+ const warn = (m) => (log.warn || log.log).call(log, m);
88
+ const info = (m) => (log.info || log.log).call(log, m);
89
+ const stats = { starts: 0, exits: 0, healthFailed: 0, killedForRss: 0, startFailures: 0, refusedRestartLimit: 0, orphansEnded: 0, orphanChecksSkipped: 0 };
90
+ const cmdSha = config.mode === "spawn" ? crypto.createHash("sha256").update(JSON.stringify(config.cmd)).digest("hex") : "";
91
+ const pidFile = config.mode === "spawn" ? (config.pidFile || path.join(env.HOME || env.USERPROFILE || os.homedir(), ".deepdebug", "agent2-runner.pid")) : null;
92
+ const startTimes = [];
93
+ let state = "idle"; // idle | starting | ready | down | stopped
94
+ let child = null;
95
+ let target = config.mode === "attach" ? { baseUrl: config.url, token: config.token } : null;
96
+ let starting = null;
97
+ let healthTimer = null;
98
+ let healthFails = 0;
99
+ let lastExitAt = 0;
100
+ let stopping = false;
101
+
102
+ // ---- the pidfile: a child that outlived this agent (SIGKILL, crash) would keep the API port and an old token; the next start ends it
103
+ function writePidFile(pid) {
104
+ if (!pidFile || !pid) return;
105
+ try {
106
+ fs.mkdirSync(path.dirname(pidFile), { recursive: true, mode: 0o700 });
107
+ fs.writeFileSync(pidFile, JSON.stringify({ pid, cmdSha, startedAt: now() }), { mode: 0o600 });
108
+ } catch { warn("[la-agent2] could not write the pidfile of agent2; a restart of this agent will not be able to end an orphan"); }
109
+ }
110
+ function removePidFile(onlyPid) {
111
+ if (!pidFile) return;
112
+ try {
113
+ if (onlyPid) { const cur = JSON.parse(fs.readFileSync(pidFile, "utf8")); if (cur && cur.pid !== onlyPid) return; }
114
+ fs.rmSync(pidFile, { force: true });
115
+ } catch { /* nothing to remove */ }
116
+ }
117
+ /**
118
+ * An agent2 left by a previous run of this agent: ended (SIGTERM, then SIGKILL after the grace) only when the pid is alive AND its command line is the command
119
+ * this agent starts (a reused pid of another program is never touched). Where the command line cannot be read (no `ps`) nothing is killed.
120
+ */
121
+ async function reapOrphan() {
122
+ if (!pidFile) return;
123
+ let rec;
124
+ try { rec = JSON.parse(fs.readFileSync(pidFile, "utf8")); } catch { return; }
125
+ const pid = rec && Number.isSafeInteger(rec.pid) && rec.pid > 1 ? rec.pid : 0;
126
+ if (!pid || pid === process.pid || !isAlive(pid)) { removePidFile(); return; }
127
+ const line = await cmdlineOf(pid);
128
+ if (line === null) { stats.orphanChecksSkipped++; warn("[la-agent2] the pidfile names a live process but its command line cannot be read; it is left alone"); return; }
129
+ if (rec.cmdSha !== cmdSha || !config.cmd.slice(0, 8).every((a) => line.includes(a))) { removePidFile(); return; } // not ours (any more): the pid was reused
130
+ warn("[la-agent2] an agent2 from a previous run is still alive; ending it");
131
+ try { process.kill(pid, "SIGTERM"); } catch { /* gone */ }
132
+ const deadline = now() + config.stopGraceMs;
133
+ while (isAlive(pid) && now() < deadline) await sleep(50);
134
+ if (isAlive(pid)) { try { process.kill(pid, "SIGKILL"); } catch { /* gone */ } await sleep(100); }
135
+ if (isAlive(pid)) throw new Agent2UnavailableError("orphan_alive");
136
+ stats.orphansEnded++;
137
+ removePidFile();
138
+ }
139
+
140
+ const api = createAgent2Api({ getTarget: () => { if (!target) throw new Agent2UnavailableError("agent2_down"); return target; }, requestTimeoutMs: config.requestTimeoutMs, ...(fetchImpl ? { fetchImpl } : {}) });
141
+
142
+ function markDown(why) {
143
+ if (state === "down" || state === "stopped") return;
144
+ state = "down";
145
+ if (config.mode === "spawn") target = null;
146
+ emitter.emit("down", { why });
147
+ }
148
+
149
+ function startHealthTimer() {
150
+ clearInterval(healthTimer);
151
+ healthFails = 0;
152
+ healthTimer = setInterval(async () => {
153
+ if (state !== "ready") return;
154
+ if (await api.health()) healthFails = 0;
155
+ else if (++healthFails >= config.healthFailures) {
156
+ stats.healthFailed++;
157
+ warn("[la-agent2] agent2 stopped answering its health probe");
158
+ if (child) { try { child.kill("SIGKILL"); } catch { /* gone */ } }
159
+ markDown("health");
160
+ return;
161
+ }
162
+ if (config.mode === "spawn" && config.maxRssBytes > 0 && child && child.pid) {
163
+ const rss = await rssOf(child.pid);
164
+ if (rss > config.maxRssBytes) { stats.killedForRss++; warn("[la-agent2] agent2 is above LOCAL_AGENT_AGENT2_MAX_RSS_BYTES; stopping it (a new one starts at the next job)"); try { child.kill("SIGKILL"); } catch { /* gone */ } markDown("rss"); }
165
+ }
166
+ }, config.healthIntervalMs);
167
+ if (healthTimer.unref) healthTimer.unref();
168
+ }
169
+
170
+ async function waitHealthy(proc) {
171
+ const deadline = now() + config.startTimeoutMs;
172
+ for (;;) {
173
+ if (stopping) throw new Agent2UnavailableError("stopped");
174
+ if (proc && proc.exited) throw new Agent2UnavailableError("exited_during_start");
175
+ if (await api.health()) return;
176
+ if (now() >= deadline) throw new Agent2UnavailableError("start_timeout");
177
+ await sleep(Math.min(250, Math.max(10, config.startTimeoutMs / 20)));
178
+ }
179
+ }
180
+
181
+ async function startAttach() {
182
+ state = "starting";
183
+ try { await waitHealthy(null); } catch (e) { state = "down"; stats.startFailures++; throw e; }
184
+ state = "ready";
185
+ startHealthTimer();
186
+ }
187
+
188
+ async function startSpawn() {
189
+ const t = now();
190
+ while (startTimes.length && t - startTimes[0] > config.restartWindowMs) startTimes.shift();
191
+ if (startTimes.length >= config.maxRestarts + 1) { stats.refusedRestartLimit++; throw new Agent2UnavailableError("restart_limit"); }
192
+ if (startTimes.length > 0) { const wait = Math.max(0, lastExitAt + config.restartBackoffMs * startTimes.length - t); if (wait > 0) await sleep(wait); }
193
+ startTimes.push(now());
194
+ state = "starting";
195
+ stats.starts++;
196
+ try { await reapOrphan(); } catch (e) { state = "down"; stats.startFailures++; throw e; }
197
+ const token = crypto.randomBytes(32).toString("base64url"); // a NEW secret per start: nothing from a previous process works
198
+ const callbackToken = crypto.randomBytes(32).toString("base64url");
199
+ const { baseUrl: bridgeBase } = await bridge.listen();
200
+ bridge.setCallbackToken(callbackToken);
201
+ const childEnv = agent2ChildEnv({ config, env, tenantId, token, bridgeBase, callbackToken });
202
+ const proc = { exited: false };
203
+ let c;
204
+ try {
205
+ c = spawnImpl(config.cmd[0], config.cmd.slice(1), { env: childEnv, stdio: ["ignore", "pipe", "pipe"], shell: false, windowsHide: true });
206
+ } catch { state = "down"; stats.startFailures++; throw new Agent2UnavailableError("spawn_failed"); }
207
+ child = c;
208
+ liveChildren.add(c); hookExit();
209
+ writePidFile(c.pid);
210
+ for (const s of [c.stdout, c.stderr]) { if (s && typeof s.on === "function") { s.on("data", () => {}); s.on("error", () => {}); } } // drained and dropped
211
+ c.on("error", () => { proc.exited = true; liveChildren.delete(c); });
212
+ c.on("exit", (code, signal) => {
213
+ proc.exited = true;
214
+ stats.exits++;
215
+ lastExitAt = now();
216
+ liveChildren.delete(c);
217
+ removePidFile(c.pid);
218
+ if (child === c) child = null;
219
+ if (!stopping) warn(`[la-agent2] agent2 exited (code ${code ?? "none"}, signal ${signal ?? "none"})`);
220
+ markDown("exit");
221
+ });
222
+ info(`[la-agent2] started agent2 (pid ${c.pid ?? "?"})`);
223
+ target = { baseUrl: `http://127.0.0.1:${config.apiPort}`, token };
224
+ try { await waitHealthy(proc); } catch (e) {
225
+ stats.startFailures++;
226
+ try { c.kill("SIGKILL"); } catch { /* gone */ }
227
+ state = "down"; target = null;
228
+ throw e;
229
+ }
230
+ if (proc.exited) { state = "down"; target = null; throw new Agent2UnavailableError("exited_during_start"); }
231
+ state = "ready";
232
+ startHealthTimer();
233
+ }
234
+
235
+ return {
236
+ api, stats,
237
+ on: (ev, fn) => emitter.on(ev, fn),
238
+ off: (ev, fn) => emitter.off(ev, fn),
239
+ get state() { return state; },
240
+ get pid() { return child ? child.pid : null; },
241
+ /** Resolves when agent2 answers its health probe (starting it first in spawn mode). Throws Agent2UnavailableError(code). */
242
+ async ensureReady() {
243
+ if (stopping) throw new Agent2UnavailableError("stopped");
244
+ if (state === "ready") {
245
+ if (await api.health()) return;
246
+ if (child) { try { child.kill("SIGKILL"); } catch { /* gone */ } }
247
+ markDown("health");
248
+ }
249
+ if (!starting) starting = (config.mode === "attach" ? startAttach() : startSpawn()).finally(() => { starting = null; });
250
+ await starting;
251
+ },
252
+ /** Stops the child (SIGTERM, then SIGKILL after the grace) and every timer. */
253
+ async stop() {
254
+ stopping = true;
255
+ state = "stopped";
256
+ clearInterval(healthTimer);
257
+ const c = child;
258
+ child = null;
259
+ if (!c) return;
260
+ await new Promise((resolve) => {
261
+ let done = false;
262
+ const fin = () => { if (!done) { done = true; clearTimeout(t); resolve(); } };
263
+ const t = setTimeout(() => { try { c.kill("SIGKILL"); } catch { /* gone */ } fin(); }, config.stopGraceMs); // not unref'd: whoever awaits stop() waits at most stopGraceMs
264
+ c.once("exit", fin);
265
+ try { if (!c.kill("SIGTERM")) fin(); } catch { fin(); }
266
+ });
267
+ },
268
+ };
269
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * git-exec.js - the ONE way the workspace materializer runs git (LA-2). `execFile` with an argument array (no shell), an explicit cwd, a timeout, an abort
3
+ * signal, a bounded output buffer and a minimal environment. A credential never travels in an argument, in the URL or in `.git/config`: it comes from what
4
+ * this machine already has (the user's credential helper, ssh-agent, GH_TOKEN of this process) or, optionally, from a local token FILE read by a GIT_ASKPASS
5
+ * script git itself runs (the agent never holds the token in a variable).
6
+ *
7
+ * Nothing of git's output leaves this module except `classifyGitFailure()`: a CODE (auth, not_found, branch_missing, timeout, aborted, failed). The raw stderr
8
+ * can name a URL or a user and is never logged, audited or thrown.
9
+ */
10
+ import { execFile } from "node:child_process";
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+
14
+ const KEEP_ENV = Object.freeze(["PATH", "HOME", "USERPROFILE", "LANG", "TMPDIR", "TEMP", "TMP", "SYSTEMROOT", "SSH_AUTH_SOCK", "GIT_SSH_COMMAND", "GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM",
15
+ "XDG_CONFIG_HOME", "HTTPS_PROXY", "HTTP_PROXY", "NO_PROXY", "https_proxy", "http_proxy", "no_proxy", "GH_TOKEN", "GITHUB_TOKEN", "GITLAB_TOKEN", "GH_CONFIG_DIR", "GH_HOST"]);
16
+
17
+ /** Minimal env for git: the names above from this process (what a credential helper needs), never prompts, never the agent's own secrets. */
18
+ export function gitEnv(env = process.env, { askpass = null, tokenFile = null } = {}) {
19
+ const out = {};
20
+ for (const k of KEEP_ENV) if (env[k] !== undefined) out[k] = env[k];
21
+ out.GIT_TERMINAL_PROMPT = "0";
22
+ out.GCM_INTERACTIVE = "never";
23
+ out.LC_ALL = "C";
24
+ if (askpass && tokenFile) { out.GIT_ASKPASS = askpass; out.LA_GIT_TOKEN_FILE = tokenFile; out.SSH_ASKPASS = askpass; }
25
+ return out;
26
+ }
27
+
28
+ /** A 0700 shell script that answers git's askpass questions from a token file: the username is a constant, the password is the file's content. POSIX only. */
29
+ export function ensureAskpassScript(dir) {
30
+ const file = path.join(dir, ".vh-git-askpass.sh");
31
+ const body = "#!/bin/sh\ncase \"$1\" in\n Username*|username*) printf '%s\\n' 'x-access-token' ;;\n *) cat -- \"$LA_GIT_TOKEN_FILE\" ;;\nesac\n";
32
+ let current = null;
33
+ try { current = fs.readFileSync(file, "utf8"); } catch { current = null; }
34
+ if (current !== body) { const tmp = `${file}.${process.pid}.tmp`; fs.writeFileSync(tmp, body, { mode: 0o700 }); fs.renameSync(tmp, file); }
35
+ fs.chmodSync(file, 0o700);
36
+ return file;
37
+ }
38
+
39
+ /** The config every git call gets: no exotic transports (a URL is built by us, https only). */
40
+ export const SAFE_GIT_CONFIG = Object.freeze(["-c", "protocol.ext.allow=never", "-c", "protocol.file.allow=never", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null"]);
41
+
42
+ /**
43
+ * @param {string[]} args after the safe config
44
+ * @param {{cwd?:string, env:object, timeoutMs:number, signal?:AbortSignal, extraConfig?:string[]}} o
45
+ * @returns {Promise<{ok:true, stdout:string} | {ok:false, code:string}>} never throws, never returns stderr
46
+ */
47
+ export function runGit(args, { cwd, env, timeoutMs, signal, extraConfig = [] }) {
48
+ return new Promise((resolve) => {
49
+ if (signal && signal.aborted) { resolve({ ok: false, code: "aborted" }); return; }
50
+ let child;
51
+ try {
52
+ child = execFile("git", [...SAFE_GIT_CONFIG, ...extraConfig, ...args], { cwd, env, timeout: timeoutMs, signal, maxBuffer: 1024 * 1024, windowsHide: true, encoding: "utf8" }, (err, stdout, stderr) => {
53
+ if (!err) { resolve({ ok: true, stdout: String(stdout || "") }); return; }
54
+ resolve({ ok: false, code: classifyGitFailure(err, String(stderr || ""), signal) });
55
+ });
56
+ } catch { resolve({ ok: false, code: "failed" }); return; }
57
+ void child;
58
+ });
59
+ }
60
+
61
+ /** A failure CODE from the exit state and stderr. stderr is read here and nowhere else. */
62
+ export function classifyGitFailure(err, stderr, signal) {
63
+ if (signal && signal.aborted) return "aborted";
64
+ if (err && (err.killed || err.code === "ETIMEDOUT" || err.signal === "SIGTERM")) return "timeout";
65
+ if (err && err.code === "ENOENT") return "git_missing";
66
+ const s = stderr.toLowerCase();
67
+ if (/authentication failed|could not read username|could not read password|terminal prompts disabled|permission denied \(publickey|invalid username or password|http 40[13]|returned error: 40[13]|access denied|bad credentials/.test(s)) return "auth";
68
+ if (/remote branch .* not found|couldn't find remote ref|could not find remote branch/.test(s)) return "branch_missing";
69
+ if (/repository not found|not found|does not exist|http 404|returned error: 404/.test(s)) return "not_found";
70
+ return "failed";
71
+ }
@@ -8,13 +8,14 @@
8
8
  import { dlpConfigFromEnv } from "../../dlp/index.js";
9
9
  import { createJobManager } from "./manager.js";
10
10
  import { createSmokeRunner } from "./smoke-runner.js";
11
+ import { resolveMaterializeConfig, createWorkspaceMaterializer } from "./workspace-materializer.js";
11
12
 
12
13
  export const JOBS_CAPABILITIES = Object.freeze(["job.assign"]);
13
14
 
14
- export function installJobs({ link, tenantId, wsManager, getWorkspaceRoot, config, env = process.env, log = console, runner = null, onResult = null, now = Date.now, memoryUsage, sleep }) {
15
- if (dlpConfigFromEnv(env).mode !== "enforce") throw new Error("job.assign needs LOCAL_AGENT_DLP_MODE=enforce");
15
+ /** A workspace "served here": open in the manager (of this tenant, or of none yet) or the default root. -> { root } | null */
16
+ export function makeWorkspaceResolver({ wsManager, getWorkspaceRoot, tenantId }) {
16
17
  const tenant = String(tenantId).toLowerCase();
17
- const resolveWorkspace = (workspaceId) => {
18
+ return (workspaceId) => {
18
19
  if (typeof workspaceId !== "string" || !workspaceId) return null;
19
20
  const listed = (wsManager?.list?.() || []).find((w) => w && w.id === workspaceId);
20
21
  if (listed) {
@@ -24,9 +25,18 @@ export function installJobs({ link, tenantId, wsManager, getWorkspaceRoot, confi
24
25
  if (workspaceId === "default") { const root = getWorkspaceRoot(); return root ? { root } : null; }
25
26
  return null;
26
27
  };
28
+ }
29
+
30
+ export function installJobs({ link, tenantId, wsManager, getWorkspaceRoot, config, env = process.env, log = console, runner = null, onResult = null, now = Date.now, memoryUsage, sleep }) {
31
+ if (dlpConfigFromEnv(env).mode !== "enforce") throw new Error("job.assign needs LOCAL_AGENT_DLP_MODE=enforce");
32
+ const tenant = String(tenantId).toLowerCase();
33
+ const resolveWorkspace = makeWorkspaceResolver({ wsManager, getWorkspaceRoot, tenantId: tenant });
27
34
  const theRunner = runner || (config.runner === "smoke" ? createSmokeRunner({ maxFileBytes: config.smokeMaxFileBytes }) : null);
28
35
  if (!theRunner) throw new Error("job.assign needs a runner");
29
- const manager = createJobManager({ session: link.session, tenantId: tenant, resolveWorkspace, config, env, llm: link.llm || null,
36
+ // LA-2: LOCAL_AGENT_JOBS_AUTO_WORKSPACE (default off) lets this agent clone an allowlisted workspace itself, with the customer's own credentials
37
+ const mat = resolveMaterializeConfig(env, (m) => (log.warn || log.log).call(log, m));
38
+ const materializer = mat.enabled && wsManager ? createWorkspaceMaterializer({ config: mat, tenantId: tenant, wsManager, audit: null, env, log }) : null;
39
+ const manager = createJobManager({ session: link.session, tenantId: tenant, resolveWorkspace, materializer, config, env, llm: link.llm || null,
30
40
  readFile: link.content ? link.content.readAllowedFile : null, runner: theRunner, onResult, log, now, ...(memoryUsage ? { memoryUsage } : {}), ...(sleep ? { sleep } : {}) });
31
41
  link.session.setHandler("job.assign", (ctx) => manager.handleAssign(ctx));
32
42
  link.session.setHandler("job.cancel", (ctx) => manager.handleCancel(ctx));
@@ -58,12 +58,13 @@ export function validateRunner(r) {
58
58
  && (r.supportsOptions === undefined || typeof r.supportsOptions === "boolean");
59
59
  }
60
60
 
61
- export function createJobManager({ session, tenantId, resolveWorkspace, config, env = process.env, llm = null, readFile = null, runner = null, onResult = null, audit = null,
61
+ export function createJobManager({ session, tenantId, resolveWorkspace, materializer = null, config, env = process.env, llm = null, readFile = null, runner = null, onResult = null, audit = null,
62
62
  log = console, now = Date.now, memoryUsage = () => process.memoryUsage().rss, events = null, sleep }) {
63
63
  const tenant = String(tenantId).toLowerCase();
64
64
  const auditor = audit || createAudit({ file: config.auditFile, now });
65
65
  const ev = events || createJobEvents({ session, config, audit: auditor, now, ...(sleep ? { sleep } : {}) });
66
66
  const warn = (m) => (log.warn || log.log).call(log, m);
67
+ if (materializer && typeof materializer.bindAudit === "function") materializer.bindAudit(auditor);
67
68
  const stats = { received: 0, accepted: 0, duplicate: 0, queued: 0, rejected: {}, finished: { succeeded: 0, failed: 0, cancelled: 0 }, cancels: 0, cancelInvalid: 0, cancelledBeforeAssign: 0,
68
69
  optionsInvalid: 0, optionsUnsupported: 0, abandoned: 0 };
69
70
  const active = new Map(); // jobId -> record (queued + running)
@@ -77,7 +78,8 @@ export function createJobManager({ session, tenantId, resolveWorkspace, config,
77
78
  const note = (event, extra) => { try { auditor.record({ event, ...extra }); } catch { /* the audit never breaks the data path */ } };
78
79
  const reject = (rec, code) => { stats.rejected[code] = (stats.rejected[code] || 0) + 1; note("job_rejected", { code, ...rec }); return { reject: code }; };
79
80
 
80
- const snapshot = (rec, over = {}) => ({ jobId: rec.jobId, state: rec.state, phase: rec.phase, iteration: rec.iteration, filesRead: rec.filesRead, filesChanged: rec.filesChanged,
81
+ // `waiting` (a runner that waits for a person: ADR-0010) shows as `waiting_approval` while the job runs; every other state is the job's own
82
+ const snapshot = (rec, over = {}) => ({ jobId: rec.jobId, state: rec.state === "running" && rec.waiting ? "waiting_approval" : rec.state, phase: rec.phase, iteration: rec.iteration, filesRead: rec.filesRead, filesChanged: rec.filesChanged,
81
83
  tokensIn: rec.tokensIn, tokensOut: rec.tokensOut, costUsd: rec.costUsd, durationMs: Math.max(0, now() - rec.acceptedAt), ...over });
82
84
  const tokensUsed = (rec) => rec.tokensIn + rec.tokensOut;
83
85
  const publish = (rec) => { if (rec.state === "running" || rec.state === "queued") ev.progress(rec, snapshot(rec)); };
@@ -154,20 +156,23 @@ export function createJobManager({ session, tenantId, resolveWorkspace, config,
154
156
  if (!llm || !llm.client) { finish(rec, null, Object.assign(new Error("no llm"), { jobErrorCode: "llm_error" })); return; }
155
157
  if (!llm.holder.held || !llm.holder.matches(rec.job.llm.keyRef)) { finish(rec, null, Object.assign(new Error("no key"), { jobErrorCode: "llm_error" })); return; }
156
158
  }
159
+ async function llmCall(kind, opts) {
160
+ if (rec.abortReason || rec.finished) throw new LlmClientError("cancelled");
161
+ const r = await llm.client[kind]({ ...opts, model: opts.model ?? rec.job.llm.alias, signal: rec.ac.signal, job: { jobId: rec.jobId, agent: theRunner.name }, tokensLeft: Math.max(0, rec.budget.tokens - tokensUsed(rec)) });
162
+ rec.tokensIn += r.usage.promptTokens;
163
+ rec.tokensOut += r.usage.completionTokens;
164
+ if (r.costUsd !== null && r.costUsd !== undefined) rec.costUsd = (rec.costUsd || 0) + r.costUsd;
165
+ publish(rec);
166
+ if (tokensUsed(rec) > rec.budget.tokens) abort(rec, "budget_exceeded"); // the next call is refused at equality (client: budget_exceeded); only an overshoot of the provider aborts
167
+ return r;
168
+ }
157
169
  const ctx = Object.freeze({
158
170
  signal: rec.ac.signal,
159
171
  workspace: Object.freeze({ id: rec.workspaceId }),
160
172
  llm: Object.freeze({
161
- async chat(opts = {}) {
162
- if (rec.abortReason || rec.finished) throw new LlmClientError("cancelled");
163
- const r = await llm.client.chat({ ...opts, model: opts.model ?? rec.job.llm.alias, signal: rec.ac.signal, job: { jobId: rec.jobId, agent: theRunner.name }, tokensLeft: Math.max(0, rec.budget.tokens - tokensUsed(rec)) });
164
- rec.tokensIn += r.usage.promptTokens;
165
- rec.tokensOut += r.usage.completionTokens;
166
- if (r.costUsd !== null && r.costUsd !== undefined) rec.costUsd = (rec.costUsd || 0) + r.costUsd;
167
- publish(rec);
168
- if (tokensUsed(rec) > rec.budget.tokens) abort(rec, "budget_exceeded"); // the next call is refused at equality (client: budget_exceeded); only an overshoot of the provider aborts
169
- return r;
170
- },
173
+ chat: (opts = {}) => llmCall("chat", opts),
174
+ /** F3-08c: one turn of the Anthropic Messages API (tools, content blocks) - what the packaged agent loop speaks. Same gates, same budget. */
175
+ messages: (opts = {}) => llmCall("messages", opts),
171
176
  }),
172
177
  async readFile(ref, opts = {}) {
173
178
  // an abandoned, cancelled or timed-out job reads nothing more (no descriptor opened, no buffer built), and what a read that was already
@@ -183,11 +188,15 @@ export function createJobManager({ session, tenantId, resolveWorkspace, config,
183
188
  if (rec.finished) return;
184
189
  if (p.phase) rec.phase = p.phase;
185
190
  for (const k of ["iteration", "filesRead", "filesChanged"]) if (Number.isSafeInteger(p[k]) && p[k] >= 0) rec[k] = p[k];
191
+ if (typeof p.waitingApproval === "boolean") rec.waiting = p.waitingApproval;
186
192
  publish(rec);
187
193
  },
188
194
  budget: () => ({ tokensLeft: Math.max(0, rec.budget.tokens - tokensUsed(rec)), msLeft: Math.max(0, rec.startedAt + secondsMs - now()) }),
189
195
  });
190
- Promise.resolve().then(() => theRunner.start(rec.job, ctx)).then(
196
+ // LA-2: a workspace this agent does not serve yet is cloned HERE (inside the job, never inside the 5 s ack), with the customer's own credentials, only for an
197
+ // allowlisted repo; a failure is job `failed`/`infra` (a code, never git's text). The runner then finds the workspace in the manager like any other.
198
+ const prepared = rec.materialize ? Promise.resolve().then(() => materializer.ensure(rec.workspaceId, rec.job.repoRef, rec.ac.signal)) : Promise.resolve();
199
+ prepared.then(() => { if (rec.abortReason || rec.finished) return { outcome: "cancelled" }; return theRunner.start(rec.job, ctx); }).then(
191
200
  (outcome) => finish(rec, outcome && typeof outcome === "object" ? outcome : null, null),
192
201
  (err) => finish(rec, null, err || new Error("runner")),
193
202
  ).catch(() => {});
@@ -213,7 +222,12 @@ export function createJobManager({ session, tenantId, resolveWorkspace, config,
213
222
  if (active.has(p.jobId) || recent.has(p.jobId)) { stats.duplicate++; note("job_duplicate", base); return { accept: true }; }
214
223
  if (cancelledEarly.has(p.jobId)) { stats.cancelledBeforeAssign++; note("job_assign_after_cancel", base); return { accept: true }; } // the CP already gave this job up: never started
215
224
  const ws = typeof ctx.workspaceId === "string" && ctx.workspaceId ? resolveWorkspace(ctx.workspaceId) : null;
216
- if (!ws || !ws.root) return reject(base, "unauthorized");
225
+ let materialize = false;
226
+ if (!ws || !ws.root) {
227
+ // LA-2 (flag LOCAL_AGENT_JOBS_AUTO_WORKSPACE, default off): not served here, but it may be materialized: allowlisted repo, usable credential, same tenant
228
+ if (!materializer || !materializer.admit(ctx.workspaceId, p.repoRef)) return reject(base, "unauthorized");
229
+ materialize = true;
230
+ }
217
231
  // contract 2.1 `options`: only a runner that declared it handles them; a malformed object (the session's schema check should have caught it) runs nothing
218
232
  const opts = normalizeJobOptions(p.options, config);
219
233
  if (!opts.ok) { stats.optionsInvalid++; return reject(base, "schema_violation"); }
@@ -228,8 +242,8 @@ export function createJobManager({ session, tenantId, resolveWorkspace, config,
228
242
 
229
243
  const budget = { tokens: Math.min(p.budget.tokens, config.maxTokens), seconds: Math.min(p.budget.seconds, config.maxSeconds) };
230
244
  const rec = {
231
- jobId: p.jobId, short: short(p.jobId), kind: p.kind, workspaceId: ctx.workspaceId, state: "queued", phase: "init", iteration: 0, filesRead: 0, filesChanged: 0,
232
- tokensIn: 0, tokensOut: 0, costUsd: null, acceptedAt: now(), startedAt: 0, ac: new AbortController(), abortReason: null, finished: false, ev: null, holdsSlot: false,
245
+ jobId: p.jobId, short: short(p.jobId), kind: p.kind, workspaceId: ctx.workspaceId, state: "queued", materialize, phase: "init", iteration: 0, filesRead: 0, filesChanged: 0,
246
+ tokensIn: 0, tokensOut: 0, costUsd: null, waiting: false, acceptedAt: now(), startedAt: 0, ac: new AbortController(), abortReason: null, finished: false, ev: null, holdsSlot: false,
233
247
  reservedBytes: need, deadlineTimer: null, graceTimer: null, budget,
234
248
  job: Object.freeze({ jobId: p.jobId, kind: p.kind, workspaceId: ctx.workspaceId, incidentRef: p.incidentRef, repoRef: p.repoRef, policy: p.policy, budget: Object.freeze({ ...budget }), llm: p.llm, inputs: p.inputs || [], options: opts.options }),
235
249
  };