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,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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|