@loucompanion/forge-bridge 0.1.3-dev.d1ca67076aa9 → 0.1.3-dev.eeefdb0d19c1
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/README.md +78 -0
- package/dist/bridge/bridge.base.d.ts +6 -1
- package/dist/bridge/bridge.service.d.ts +60 -3
- package/dist/bridge/bridge.service.js +962 -22
- package/dist/bridge/bridge.types.d.ts +172 -1
- package/dist/cleanup/cleanup.service.d.ts +13 -2
- package/dist/cleanup/cleanup.service.js +435 -3
- package/dist/cleanup/cleanup.types.d.ts +10 -0
- package/dist/cleanup/index.d.ts +1 -1
- package/dist/cleanup/index.js +1 -1
- package/dist/cleanup/internals.d.ts +2 -2
- package/dist/cleanup/internals.js +1 -1
- package/dist/cli/cli.help.js +47 -1
- package/dist/cli/cli.service.d.ts +42 -2
- package/dist/cli/cli.service.js +351 -22
- package/dist/cli/local-config.d.ts +6 -0
- package/dist/cli/local-config.js +47 -3
- package/dist/runtime/attempt-node-runtime-launcher.d.ts +17 -0
- package/dist/runtime/attempt-node-runtime-launcher.js +101 -0
- package/dist/runtime/attempt-process-supervisor-registry.d.ts +20 -0
- package/dist/runtime/attempt-process-supervisor-registry.js +64 -0
- package/dist/runtime/attempt-process-supervisor.d.ts +86 -0
- package/dist/runtime/attempt-process-supervisor.js +343 -0
- package/dist/runtime/generated-output-reset.d.ts +10 -0
- package/dist/runtime/generated-output-reset.js +103 -0
- package/dist/runtime/host-listener-inspector.d.ts +44 -0
- package/dist/runtime/host-listener-inspector.js +324 -0
- package/dist/runtime/host-port-broker.d.ts +76 -0
- package/dist/runtime/host-port-broker.js +333 -0
- package/dist/runtime/host-runtime-contract.d.ts +47 -0
- package/dist/runtime/host-runtime-contract.js +700 -0
- package/dist/runtime/host-runtime-lease.d.ts +133 -0
- package/dist/runtime/host-runtime-lease.js +1460 -0
- package/dist/runtime/host-runtime-promotion.d.ts +23 -0
- package/dist/runtime/host-runtime-promotion.js +159 -0
- package/dist/runtime/node-host-runtime-adapter.d.ts +51 -0
- package/dist/runtime/node-host-runtime-adapter.js +268 -0
- package/dist/runtime/playwright-host-runtime-adapter.d.ts +20 -0
- package/dist/runtime/playwright-host-runtime-adapter.js +220 -0
- package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
- package/dist/runtime/runtime-isolation-lease.js +158 -0
- package/dist/sandbox/command-runner.d.ts +4 -0
- package/dist/sandbox/command-runner.js +47 -0
- package/dist/sandbox/contract.d.ts +5 -0
- package/dist/sandbox/contract.js +276 -0
- package/dist/sandbox/event-budget.d.ts +11 -0
- package/dist/sandbox/event-budget.js +27 -0
- package/dist/sandbox/image-policy.d.ts +35 -0
- package/dist/sandbox/image-policy.js +247 -0
- package/dist/sandbox/index.d.ts +5 -0
- package/dist/sandbox/index.js +5 -0
- package/dist/sandbox/lease-watchdog.d.ts +18 -0
- package/dist/sandbox/lease-watchdog.js +67 -0
- package/dist/sandbox/preflight.d.ts +54 -0
- package/dist/sandbox/preflight.js +335 -0
- package/dist/sandbox/process.d.ts +20 -0
- package/dist/sandbox/process.js +209 -0
- package/dist/sandbox/provider-auth/codex.d.ts +26 -0
- package/dist/sandbox/provider-auth/codex.js +212 -0
- package/dist/sandbox/repository-lock.d.ts +5 -0
- package/dist/sandbox/repository-lock.js +119 -0
- package/dist/sandbox/runtime.d.ts +297 -0
- package/dist/sandbox/runtime.js +2338 -0
- package/dist/sandbox/sandbox.types.d.ts +86 -0
- package/dist/sandbox/sandbox.types.js +1 -0
- package/dist/sandbox/smoke.d.ts +76 -0
- package/dist/sandbox/smoke.js +380 -0
- package/dist/sandbox/worker-supervisor.d.ts +13 -0
- package/dist/sandbox/worker-supervisor.js +36 -0
- package/dist/sandbox/worker.d.ts +1 -0
- package/dist/sandbox/worker.js +225 -0
- package/dist/sandbox/workspace.service.d.ts +13 -0
- package/dist/sandbox/workspace.service.js +72 -0
- package/dist/session/managed-attempt-tools.d.ts +12 -0
- package/dist/session/managed-attempt-tools.js +350 -0
- package/dist/session/phase-completion.artifacts.js +32 -4
- package/dist/session/phase-completion.d.ts +4 -2
- package/dist/session/phase-completion.helpers.js +27 -3
- package/dist/session/phase-completion.js +12 -3
- package/dist/session/phase-completion.prompts.d.ts +3 -3
- package/dist/session/phase-completion.prompts.js +19 -5
- package/dist/session/phase-completion.runtime.d.ts +12 -1
- package/dist/session/phase-completion.runtime.js +127 -7
- package/dist/session/phase-completion.schemas.js +108 -3
- package/dist/session/phase-completion.validation.d.ts +4 -1
- package/dist/session/phase-completion.validation.js +106 -2
- package/dist/session/provider-cli/ci-repair-readiness.d.ts +8 -0
- package/dist/session/provider-cli/ci-repair-readiness.js +90 -0
- package/dist/session/provider-cli/provider-cli.artifacts.d.ts +1 -1
- package/dist/session/provider-cli/provider-cli.artifacts.js +28 -4
- package/dist/session/provider-cli/provider-cli.base.d.ts +1 -0
- package/dist/session/provider-cli/provider-cli.deterministic.d.ts +2 -0
- package/dist/session/provider-cli/provider-cli.deterministic.js +66 -9
- package/dist/session/provider-cli/provider-cli.process.d.ts +17 -0
- package/dist/session/provider-cli/provider-cli.process.js +223 -14
- package/dist/session/provider-cli/provider-cli.prompts.d.ts +16 -2
- package/dist/session/provider-cli/provider-cli.prompts.js +202 -18
- package/dist/session/provider-cli/provider-cli.qa-sidecars.js +1 -0
- package/dist/session/provider-cli/provider-cli.runtime.d.ts +1 -1
- package/dist/session/provider-cli/provider-cli.runtime.js +3 -2
- package/dist/session/provider-cli/provider-cli.service.d.ts +7 -3
- package/dist/session/provider-cli/provider-cli.service.js +399 -98
- package/dist/session/provider-cli/provider-cli.types.d.ts +6 -0
- package/dist/session/provider-cli/provider-output-state.d.ts +2 -0
- package/dist/session/provider-cli/provider-output-state.js +30 -2
- package/dist/session/session-redaction.d.ts +3 -0
- package/dist/session/session-redaction.js +23 -0
- package/dist/session/session.base.d.ts +6 -1
- package/dist/session/session.service.d.ts +8 -2
- package/dist/session/session.service.js +463 -58
- package/dist/session/session.types.d.ts +107 -1
- package/dist/session/session.types.js +226 -3
- package/dist/shared/path/path.js +3 -1
- package/dist/shared/process/host-process-recovery.d.ts +12 -0
- package/dist/shared/process/host-process-recovery.js +78 -0
- package/dist/shared/process/host-process-supervisor.d.ts +57 -0
- package/dist/shared/process/host-process-supervisor.js +282 -0
- package/dist/shared/process/process.d.ts +1 -0
- package/dist/shared/process/process.js +1 -0
- package/dist/shared/process/windows-launch.d.ts +1 -1
- package/dist/shared/process/windows-launch.js +6 -3
- package/dist/shared/provider-capabilities.d.ts +1 -0
- package/dist/shared/provider-capabilities.js +3 -0
- package/dist/shared/redaction/redaction.js +10 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worktree/artifact-injector.d.ts +3 -2
- package/dist/worktree/artifact-injector.js +43 -4
- package/dist/worktree/dependency-warmer.d.ts +21 -1
- package/dist/worktree/dependency-warmer.js +138 -5
- package/dist/worktree/index.d.ts +1 -0
- package/dist/worktree/index.js +1 -0
- package/dist/worktree/prior-phase-artifact-hydrator.d.ts +2 -0
- package/dist/worktree/prior-phase-artifact-hydrator.js +136 -0
- package/dist/worktree/worktree-paths.d.ts +4 -0
- package/dist/worktree/worktree-paths.js +30 -0
- package/dist/worktree/worktree.base.d.ts +3 -0
- package/dist/worktree/worktree.service.d.ts +7 -2
- package/dist/worktree/worktree.service.js +72 -44
- package/dist/worktree/worktree.types.d.ts +3 -1
- package/package.json +6 -3
- package/runtime/windows-job-supervisor.ps1 +356 -0
- package/runtime/windows-listener-owner.ps1 +33 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { MonotonicLeaseDeadline } from "./lease-watchdog.js";
|
|
7
|
+
import { terminationMessage, WorkerDeadlineSupervisor } from "./worker-supervisor.js";
|
|
8
|
+
const controlRoot = "/control";
|
|
9
|
+
const runtimeRoot = "/run/forge";
|
|
10
|
+
const workspace = process.env.FORGE_WORKSPACE ?? "/workspace";
|
|
11
|
+
async function main() {
|
|
12
|
+
const dispatchText = await readBoundedStdin(16 * 1024 * 1024);
|
|
13
|
+
const bootstrap = JSON.parse(dispatchText);
|
|
14
|
+
if (bootstrap.schemaVersion !== 1 || !bootstrap.dispatch || typeof bootstrap.leaseControlKey !== "string") {
|
|
15
|
+
throw new Error("Invalid sandbox bootstrap packet.");
|
|
16
|
+
}
|
|
17
|
+
const dispatch = bootstrap.dispatch;
|
|
18
|
+
const sandbox = dispatch.sandbox;
|
|
19
|
+
const attemptId = requiredId(sandbox?.dispatchAttemptId, "dispatchAttemptId");
|
|
20
|
+
const lease = dispatch.lease;
|
|
21
|
+
const fencingToken = requiredId(lease?.fencingToken, "fencingToken");
|
|
22
|
+
const leaseDeadline = new MonotonicLeaseDeadline(attemptId, fencingToken, requiredString(lease?.expiresAt, "expiresAt"), bootstrap.leaseControlKey);
|
|
23
|
+
const limits = requiredLimits(bootstrap.limits);
|
|
24
|
+
await installServiceEnvironment(attemptId);
|
|
25
|
+
await fs.mkdir(runtimeRoot, { recursive: true, mode: 0o700 });
|
|
26
|
+
await fs.mkdir(path.join(controlRoot, "output"), { recursive: true, mode: 0o700 });
|
|
27
|
+
const dispatchPath = path.join(runtimeRoot, "dispatch.json");
|
|
28
|
+
const resultPath = path.join(controlRoot, "output", "result.json");
|
|
29
|
+
await fs.writeFile(dispatchPath, JSON.stringify(dispatch), { mode: 0o600 });
|
|
30
|
+
const entrypoint = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../cli/bin.js");
|
|
31
|
+
const runOutcome = await run(process.execPath, [entrypoint, "run-once", "--dispatch", dispatchPath, "--result", resultPath], {
|
|
32
|
+
deadline: leaseDeadline,
|
|
33
|
+
updatePath: path.join(runtimeRoot, "lease-update.json"),
|
|
34
|
+
supervisor: new WorkerDeadlineSupervisor(leaseDeadline, limits.hardWallMs, limits.idleMs)
|
|
35
|
+
});
|
|
36
|
+
await fs.rm(dispatchPath, { force: true });
|
|
37
|
+
if (runOutcome.terminationCode) {
|
|
38
|
+
await writeTerminationResult(resultPath, runOutcome.terminationCode);
|
|
39
|
+
}
|
|
40
|
+
let resultCommitSha = null;
|
|
41
|
+
let bundleSha256 = null;
|
|
42
|
+
let bundleBytes = 0;
|
|
43
|
+
const bundlePath = path.join(controlRoot, "output", "result.bundle");
|
|
44
|
+
if (!runOutcome.terminationCode && runOutcome.exitCode === 0 && await exists(path.join(workspace, ".git"))) {
|
|
45
|
+
resultCommitSha = (await capture("git", ["-C", workspace, "rev-parse", "HEAD"])).trim();
|
|
46
|
+
const resultRef = `refs/forge/results/${attemptId.replace(/-/g, "")}`;
|
|
47
|
+
await runRequired("git", ["-C", workspace, "update-ref", resultRef, resultCommitSha]);
|
|
48
|
+
try {
|
|
49
|
+
await runRequired("git", ["-C", workspace, "bundle", "create", bundlePath, resultRef]);
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
await run("git", ["-C", workspace, "update-ref", "-d", resultRef]);
|
|
53
|
+
}
|
|
54
|
+
const bundle = await fs.readFile(bundlePath);
|
|
55
|
+
bundleBytes = bundle.length;
|
|
56
|
+
bundleSha256 = createHash("sha256").update(bundle).digest("hex");
|
|
57
|
+
}
|
|
58
|
+
const resultBytes = await fs.readFile(resultPath);
|
|
59
|
+
const manifest = {
|
|
60
|
+
schemaVersion: 1,
|
|
61
|
+
dispatchAttemptId: attemptId,
|
|
62
|
+
executionLeaseId: sandbox?.executionLeaseId,
|
|
63
|
+
fencingToken: dispatch.lease?.fencingToken,
|
|
64
|
+
runnerInstanceId: sandbox?.runnerInstanceId,
|
|
65
|
+
inputCommitSha: sandbox?.inputCommitSha,
|
|
66
|
+
resultCommitSha,
|
|
67
|
+
branch: dispatch.lease?.worktreeKey,
|
|
68
|
+
contractDigest: sandbox?.contractDigest,
|
|
69
|
+
imageDigest: sandbox?.imageDigest,
|
|
70
|
+
result: { path: "result.json", bytes: resultBytes.length, sha256: createHash("sha256").update(resultBytes).digest("hex") },
|
|
71
|
+
bundle: bundleSha256 ? { path: "result.bundle", bytes: bundleBytes, sha256: bundleSha256 } : null,
|
|
72
|
+
workerExitCode: runOutcome.exitCode,
|
|
73
|
+
workerExitClassification: runOutcome.terminationCode ?? "agent_exit"
|
|
74
|
+
};
|
|
75
|
+
const temporary = path.join(controlRoot, "output", "manifest.json.tmp");
|
|
76
|
+
await fs.writeFile(temporary, `${JSON.stringify(manifest)}\n`, { mode: 0o600 });
|
|
77
|
+
await fs.rename(temporary, path.join(controlRoot, "output", "manifest.json"));
|
|
78
|
+
return runOutcome.exitCode;
|
|
79
|
+
}
|
|
80
|
+
async function readBoundedStdin(limit) {
|
|
81
|
+
const chunks = [];
|
|
82
|
+
let total = 0;
|
|
83
|
+
for await (const chunk of process.stdin) {
|
|
84
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
85
|
+
total += buffer.length;
|
|
86
|
+
if (total > limit)
|
|
87
|
+
throw new Error("Dispatch bootstrap exceeds 16 MiB.");
|
|
88
|
+
chunks.push(buffer);
|
|
89
|
+
}
|
|
90
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
91
|
+
}
|
|
92
|
+
async function run(command, args, watchdog) {
|
|
93
|
+
return await new Promise((resolve, reject) => {
|
|
94
|
+
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"], shell: false });
|
|
95
|
+
let readingUpdate = false;
|
|
96
|
+
let killTimer = null;
|
|
97
|
+
let terminationCode = null;
|
|
98
|
+
const terminate = (code) => {
|
|
99
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
100
|
+
return;
|
|
101
|
+
terminationCode ??= code;
|
|
102
|
+
emitTerminationEvent(terminationCode);
|
|
103
|
+
child.kill("SIGTERM");
|
|
104
|
+
killTimer = setTimeout(() => child.kill("SIGKILL"), 10_000);
|
|
105
|
+
killTimer.unref?.();
|
|
106
|
+
};
|
|
107
|
+
child.stdout.on("data", chunk => {
|
|
108
|
+
watchdog?.supervisor.touch();
|
|
109
|
+
process.stdout.write(chunk);
|
|
110
|
+
});
|
|
111
|
+
child.stderr.on("data", chunk => {
|
|
112
|
+
watchdog?.supervisor.touch();
|
|
113
|
+
process.stderr.write(chunk);
|
|
114
|
+
});
|
|
115
|
+
const monitor = watchdog ? setInterval(() => {
|
|
116
|
+
const reason = watchdog.supervisor.termination();
|
|
117
|
+
if (reason) {
|
|
118
|
+
terminate(reason);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (readingUpdate)
|
|
122
|
+
return;
|
|
123
|
+
readingUpdate = true;
|
|
124
|
+
fs.readFile(watchdog.updatePath, "utf8")
|
|
125
|
+
.then(value => {
|
|
126
|
+
return watchdog.deadline.apply(JSON.parse(value));
|
|
127
|
+
})
|
|
128
|
+
.catch(() => false)
|
|
129
|
+
.finally(() => { readingUpdate = false; });
|
|
130
|
+
}, 250) : null;
|
|
131
|
+
monitor?.unref?.();
|
|
132
|
+
child.on("error", error => {
|
|
133
|
+
if (monitor)
|
|
134
|
+
clearInterval(monitor);
|
|
135
|
+
if (killTimer)
|
|
136
|
+
clearTimeout(killTimer);
|
|
137
|
+
reject(error);
|
|
138
|
+
});
|
|
139
|
+
child.on("close", code => {
|
|
140
|
+
if (monitor)
|
|
141
|
+
clearInterval(monitor);
|
|
142
|
+
if (killTimer)
|
|
143
|
+
clearTimeout(killTimer);
|
|
144
|
+
resolve({ exitCode: terminationCode ? 124 : code ?? 1, terminationCode });
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
async function runRequired(command, args) {
|
|
149
|
+
const outcome = await run(command, args);
|
|
150
|
+
if (outcome.exitCode !== 0)
|
|
151
|
+
throw new Error(`${command} failed with exit ${outcome.exitCode}.`);
|
|
152
|
+
}
|
|
153
|
+
async function capture(command, args) {
|
|
154
|
+
return await new Promise((resolve, reject) => {
|
|
155
|
+
const child = spawn(command, args, { stdio: ["ignore", "pipe", "inherit"], shell: false });
|
|
156
|
+
const chunks = [];
|
|
157
|
+
child.stdout.on("data", chunk => chunks.push(Buffer.from(chunk)));
|
|
158
|
+
child.on("error", reject);
|
|
159
|
+
child.on("close", code => code === 0 ? resolve(Buffer.concat(chunks).toString("utf8")) : reject(new Error(`${command} failed with exit ${code}.`)));
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
async function exists(target) { return fs.stat(target).then(() => true).catch(() => false); }
|
|
163
|
+
async function installServiceEnvironment(attemptId) {
|
|
164
|
+
const attemptHex = attemptId.replace(/-/gu, "").toLowerCase();
|
|
165
|
+
const postgresPassword = await readServiceCredential("postgres-password");
|
|
166
|
+
if (postgresPassword)
|
|
167
|
+
process.env.FORGE_TEST_DATABASE = `postgresql://forge:${encodeURIComponent(postgresPassword)}@forge-${attemptHex}-postgres:5432/forge_test`;
|
|
168
|
+
const redisPassword = await readServiceCredential("redis-password");
|
|
169
|
+
if (redisPassword)
|
|
170
|
+
process.env.FORGE_TEST_REDIS = `redis://:${encodeURIComponent(redisPassword)}@forge-${attemptHex}-redis:6379/0`;
|
|
171
|
+
const browserToken = await readServiceCredential("browser-token");
|
|
172
|
+
if (browserToken)
|
|
173
|
+
process.env.FORGE_BROWSER_TOKEN = browserToken;
|
|
174
|
+
}
|
|
175
|
+
async function readServiceCredential(name) {
|
|
176
|
+
const target = `/run/forge-secrets/${name}`;
|
|
177
|
+
const stat = await fs.lstat(target).catch(() => null);
|
|
178
|
+
if (!stat)
|
|
179
|
+
return null;
|
|
180
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size < 32 || stat.size > 256) {
|
|
181
|
+
throw new Error(`Invalid ${name} service credential file.`);
|
|
182
|
+
}
|
|
183
|
+
return (await fs.readFile(target, "utf8")).trim();
|
|
184
|
+
}
|
|
185
|
+
function requiredId(value, label) {
|
|
186
|
+
if (typeof value !== "string" || !/^[0-9a-f-]{32,36}$/i.test(value))
|
|
187
|
+
throw new Error(`Invalid ${label}.`);
|
|
188
|
+
return value;
|
|
189
|
+
}
|
|
190
|
+
function requiredString(value, label) {
|
|
191
|
+
if (typeof value !== "string" || value.length === 0)
|
|
192
|
+
throw new Error(`Invalid ${label}.`);
|
|
193
|
+
return value;
|
|
194
|
+
}
|
|
195
|
+
function requiredLimits(value) {
|
|
196
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
197
|
+
throw new Error("Invalid sandbox watchdog limits.");
|
|
198
|
+
const limits = value;
|
|
199
|
+
if (typeof limits.hardWallMs !== "number" || !Number.isSafeInteger(limits.hardWallMs) || limits.hardWallMs < 60_000 || limits.hardWallMs > 28_800_000
|
|
200
|
+
|| typeof limits.idleMs !== "number" || !Number.isSafeInteger(limits.idleMs) || limits.idleMs < 60_000 || limits.idleMs > limits.hardWallMs) {
|
|
201
|
+
throw new Error("Invalid sandbox watchdog limits.");
|
|
202
|
+
}
|
|
203
|
+
return { hardWallMs: limits.hardWallMs, idleMs: limits.idleMs };
|
|
204
|
+
}
|
|
205
|
+
async function writeTerminationResult(resultPath, code) {
|
|
206
|
+
const message = terminationMessage(code);
|
|
207
|
+
const result = {
|
|
208
|
+
schemaVersion: 1,
|
|
209
|
+
exitCode: 124,
|
|
210
|
+
summary: message,
|
|
211
|
+
events: [{ role: "runner", kind: "error", content: message, eventType: code }]
|
|
212
|
+
};
|
|
213
|
+
const temporary = `${resultPath}.timeout.tmp`;
|
|
214
|
+
await fs.writeFile(temporary, `${JSON.stringify(result)}\n`, { mode: 0o600 });
|
|
215
|
+
await fs.rename(temporary, resultPath);
|
|
216
|
+
}
|
|
217
|
+
function emitTerminationEvent(code) {
|
|
218
|
+
const message = terminationMessage(code);
|
|
219
|
+
process.stdout.write(`${JSON.stringify({ type: "runner_event", event: { role: "runner", kind: "error", content: message, eventType: code } })}\n`);
|
|
220
|
+
process.stderr.write(`${code}: ${message}\n`);
|
|
221
|
+
}
|
|
222
|
+
main().then(code => { process.exitCode = code; }).catch(error => {
|
|
223
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
224
|
+
process.exitCode = 1;
|
|
225
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { GitClient } from "../shared/git/git.js";
|
|
2
|
+
import type { WorktreeService } from "../worktree/worktree.base.js";
|
|
3
|
+
import type { WorktreeHandle, WorktreePrepareRequest } from "../worktree/worktree.types.js";
|
|
4
|
+
export declare class SandboxWorkspaceService implements WorktreeService {
|
|
5
|
+
private readonly workspace;
|
|
6
|
+
private readonly bundlePath;
|
|
7
|
+
private readonly inputCommitSha;
|
|
8
|
+
private readonly git;
|
|
9
|
+
constructor(workspace?: string, bundlePath?: string, inputCommitSha?: string, git?: GitClient);
|
|
10
|
+
prepare(request: WorktreePrepareRequest): Promise<WorktreeHandle>;
|
|
11
|
+
release(_handle: WorktreeHandle): Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
export declare function sandboxInputRefFromBundle(bundleHeads: string, inputCommitSha: string): string;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { GitClient } from "../shared/git/git.js";
|
|
4
|
+
import { injectWorktreeArtifacts, filterArtifactsForLane } from "../worktree/artifact-injector.js";
|
|
5
|
+
import { hydratePriorPhaseArtifacts } from "../worktree/prior-phase-artifact-hydrator.js";
|
|
6
|
+
import { clearRunArtifactRoots, normalizeBranchName } from "../worktree/worktree.service.js";
|
|
7
|
+
export class SandboxWorkspaceService {
|
|
8
|
+
workspace;
|
|
9
|
+
bundlePath;
|
|
10
|
+
inputCommitSha;
|
|
11
|
+
git;
|
|
12
|
+
constructor(workspace = process.env.FORGE_WORKSPACE ?? "/workspace", bundlePath = process.env.FORGE_INPUT_BUNDLE ?? "/control/input/repo.bundle", inputCommitSha = process.env.FORGE_INPUT_COMMIT_SHA ?? "", git = new GitClient()) {
|
|
13
|
+
this.workspace = workspace;
|
|
14
|
+
this.bundlePath = bundlePath;
|
|
15
|
+
this.inputCommitSha = inputCommitSha;
|
|
16
|
+
this.git = git;
|
|
17
|
+
}
|
|
18
|
+
async prepare(request) {
|
|
19
|
+
if (!/^[0-9a-f]{40,64}$/i.test(this.inputCommitSha))
|
|
20
|
+
throw new Error("Sandbox input commit SHA is missing or invalid.");
|
|
21
|
+
const stat = await fs.stat(this.workspace).catch(() => null);
|
|
22
|
+
if (!stat?.isDirectory())
|
|
23
|
+
throw new Error("Sandbox workspace volume is unavailable.");
|
|
24
|
+
const entries = await fs.readdir(this.workspace);
|
|
25
|
+
if (entries.length > 0)
|
|
26
|
+
throw new Error("Sandbox workspace must be empty before bundle import.");
|
|
27
|
+
const bundleHeads = await this.git.run(path.dirname(this.workspace), ["bundle", "list-heads", this.bundlePath]);
|
|
28
|
+
const inputRef = sandboxInputRefFromBundle(bundleHeads, this.inputCommitSha);
|
|
29
|
+
await this.git.run(path.dirname(this.workspace), ["init", this.workspace]);
|
|
30
|
+
await this.git.run(this.workspace, ["fetch", "--no-tags", "--no-write-fetch-head", this.bundlePath, `${inputRef}:refs/forge/input`]);
|
|
31
|
+
const actualSha = (await this.git.run(this.workspace, ["rev-parse", "--verify", `${this.inputCommitSha}^{commit}`])).trim();
|
|
32
|
+
if (actualSha.toLowerCase() !== this.inputCommitSha.toLowerCase())
|
|
33
|
+
throw new Error("Imported bundle does not contain the exact recorded input commit.");
|
|
34
|
+
const branchName = normalizeBranchName(request.worktreeKey, `forge/${request.workItem.id.replace(/-/g, "")}`);
|
|
35
|
+
await this.git.run(this.workspace, ["checkout", "-B", branchName, this.inputCommitSha]);
|
|
36
|
+
await this.git.run(this.workspace, ["config", "user.email", "forge-agent@forge.local"]);
|
|
37
|
+
await this.git.run(this.workspace, ["config", "user.name", "Forge Agent"]);
|
|
38
|
+
await this.git.run(this.workspace, ["config", "core.hooksPath", "/dev/null"]);
|
|
39
|
+
await clearRunArtifactRoots(this.workspace, request);
|
|
40
|
+
const artifacts = filterArtifactsForLane(request.artifacts, request.lane, request.phase);
|
|
41
|
+
if (artifacts.length > 0) {
|
|
42
|
+
const trackedForgePaths = new Set((await this.git.run(this.workspace, ["ls-files", "-z", "--", ".forge"]))
|
|
43
|
+
.split("\0")
|
|
44
|
+
.filter(Boolean)
|
|
45
|
+
.map(value => value.replaceAll("\\", "/")));
|
|
46
|
+
await injectWorktreeArtifacts(this.workspace, artifacts, trackedForgePaths);
|
|
47
|
+
}
|
|
48
|
+
const hydratedPriorPhaseArtifactCount = await hydratePriorPhaseArtifacts(this.workspace, request.priorPhaseArtifacts);
|
|
49
|
+
return {
|
|
50
|
+
repoPath: this.workspace,
|
|
51
|
+
worktreePath: this.workspace,
|
|
52
|
+
branchName,
|
|
53
|
+
baseBranch: request.baseBranch,
|
|
54
|
+
cleanupWorktrees: false,
|
|
55
|
+
hydratedPriorPhaseArtifactCount
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
async release(_handle) {
|
|
59
|
+
// The host supervisor owns the workspace volume lifecycle.
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export function sandboxInputRefFromBundle(bundleHeads, inputCommitSha) {
|
|
63
|
+
const expectedSha = inputCommitSha.toLowerCase();
|
|
64
|
+
const matches = bundleHeads
|
|
65
|
+
.split(/\r?\n/u)
|
|
66
|
+
.map(line => /^([0-9a-f]{40}|[0-9a-f]{64}) (refs\/forge\/inputs\/[0-9a-f]{32})$/iu.exec(line.trim()))
|
|
67
|
+
.filter((match) => Boolean(match) && match[1].toLowerCase() === expectedSha);
|
|
68
|
+
if (matches.length !== 1) {
|
|
69
|
+
throw new Error("Sandbox input bundle does not expose the exact recorded input ref.");
|
|
70
|
+
}
|
|
71
|
+
return matches[0][2];
|
|
72
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare const managedAttemptToolNames: readonly ["forge.run_managed_command", "run_managed_command", "forge.start_managed_command", "start_managed_command", "forge.get_managed_command", "get_managed_command", "forge.reset_generated_outputs", "reset_generated_outputs"];
|
|
2
|
+
export declare function isManagedAttemptTool(name: string): boolean;
|
|
3
|
+
export declare function managedAttemptToolDefinition(name: string): unknown;
|
|
4
|
+
export interface ManagedAttemptToolResult {
|
|
5
|
+
readonly content: readonly {
|
|
6
|
+
readonly type: "text";
|
|
7
|
+
readonly text: string;
|
|
8
|
+
}[];
|
|
9
|
+
readonly structuredContent: Readonly<Record<string, unknown>>;
|
|
10
|
+
readonly isError?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export declare function callManagedAttemptTool(name: string, input: unknown, worktreePath: string): Promise<ManagedAttemptToolResult>;
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { resetGeneratedOutputs } from "../runtime/generated-output-reset.js";
|
|
5
|
+
import { StreamingProviderProcessRunner } from "./provider-cli/provider-cli.process.js";
|
|
6
|
+
const managedCommandNames = new Set(["forge.run_managed_command", "run_managed_command"]);
|
|
7
|
+
const startManagedCommandNames = new Set(["forge.start_managed_command", "start_managed_command"]);
|
|
8
|
+
const getManagedCommandNames = new Set(["forge.get_managed_command", "get_managed_command"]);
|
|
9
|
+
const resetGeneratedOutputNames = new Set(["forge.reset_generated_outputs", "reset_generated_outputs"]);
|
|
10
|
+
const maximumCapturedCharacters = 1_000_000;
|
|
11
|
+
const maximumSynchronousWaitMs = 240_000;
|
|
12
|
+
const managedCommandLaunchTimeoutMs = 60_000;
|
|
13
|
+
const terminalManagedCommandStatuses = new Set([
|
|
14
|
+
"pass",
|
|
15
|
+
"fail",
|
|
16
|
+
"timed_out",
|
|
17
|
+
"timeout_termination_failed",
|
|
18
|
+
"runner_error",
|
|
19
|
+
]);
|
|
20
|
+
const managedCommandTasks = new Map();
|
|
21
|
+
export const managedAttemptToolNames = [
|
|
22
|
+
"forge.run_managed_command",
|
|
23
|
+
"run_managed_command",
|
|
24
|
+
"forge.start_managed_command",
|
|
25
|
+
"start_managed_command",
|
|
26
|
+
"forge.get_managed_command",
|
|
27
|
+
"get_managed_command",
|
|
28
|
+
"forge.reset_generated_outputs",
|
|
29
|
+
"reset_generated_outputs",
|
|
30
|
+
];
|
|
31
|
+
export function isManagedAttemptTool(name) {
|
|
32
|
+
return managedCommandNames.has(name)
|
|
33
|
+
|| startManagedCommandNames.has(name)
|
|
34
|
+
|| getManagedCommandNames.has(name)
|
|
35
|
+
|| resetGeneratedOutputNames.has(name);
|
|
36
|
+
}
|
|
37
|
+
export function managedAttemptToolDefinition(name) {
|
|
38
|
+
if (managedCommandNames.has(name) || startManagedCommandNames.has(name)) {
|
|
39
|
+
return {
|
|
40
|
+
name,
|
|
41
|
+
description: startManagedCommandNames.has(name)
|
|
42
|
+
? "Start a long command in an attempt-owned process tree and return a durable command ID immediately. Poll forge.get_managed_command until it reaches a terminal status."
|
|
43
|
+
: "Run a short command in an attempt-owned process tree. Commands with a timeout above four minutes automatically start asynchronously; poll forge.get_managed_command for their durable result.",
|
|
44
|
+
inputSchema: {
|
|
45
|
+
type: "object",
|
|
46
|
+
additionalProperties: false,
|
|
47
|
+
required: ["command", "args"],
|
|
48
|
+
properties: {
|
|
49
|
+
command: { type: "string", minLength: 1 },
|
|
50
|
+
args: { type: "array", items: { type: "string" } },
|
|
51
|
+
cwd: { type: "string", description: "Worktree-relative directory; defaults to the worktree root." },
|
|
52
|
+
timeout_ms: { type: "integer", minimum: 1000, maximum: 7200000, default: 2700000 },
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
if (getManagedCommandNames.has(name)) {
|
|
58
|
+
return {
|
|
59
|
+
name,
|
|
60
|
+
description: "Read the durable status and final receipt for a command started by Forge's managed command runner.",
|
|
61
|
+
inputSchema: {
|
|
62
|
+
type: "object",
|
|
63
|
+
additionalProperties: false,
|
|
64
|
+
required: ["command_id"],
|
|
65
|
+
properties: {
|
|
66
|
+
command_id: { type: "string", minLength: 1 },
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
name,
|
|
73
|
+
description: "Quarantine known untracked generated test-output directories into this attempt's private output directory.",
|
|
74
|
+
inputSchema: { type: "object", additionalProperties: false, properties: {} },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
export async function callManagedAttemptTool(name, input, worktreePath) {
|
|
78
|
+
if (resetGeneratedOutputNames.has(name)) {
|
|
79
|
+
const outputDirectory = requiredAttemptEnvironment("FORGE_TEST_OUTPUT_DIR");
|
|
80
|
+
const result = await resetGeneratedOutputs(worktreePath, outputDirectory);
|
|
81
|
+
return {
|
|
82
|
+
content: [{
|
|
83
|
+
type: "text",
|
|
84
|
+
text: result.quarantinedPaths.length === 0
|
|
85
|
+
? "Forge found no stale generated output in the source worktree."
|
|
86
|
+
: `Forge quarantined ${result.quarantinedPaths.length} generated-output director${result.quarantinedPaths.length === 1 ? "y" : "ies"}: ${result.quarantinedPaths.join(", ")}.`,
|
|
87
|
+
}],
|
|
88
|
+
structuredContent: {
|
|
89
|
+
status: "pass",
|
|
90
|
+
quarantined_paths: result.quarantinedPaths,
|
|
91
|
+
quarantine_directory: result.quarantineDirectory,
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (getManagedCommandNames.has(name)) {
|
|
96
|
+
return await readManagedCommand(input);
|
|
97
|
+
}
|
|
98
|
+
if (!managedCommandNames.has(name) && !startManagedCommandNames.has(name)) {
|
|
99
|
+
throw new Error(`Unknown managed attempt tool: ${name}`);
|
|
100
|
+
}
|
|
101
|
+
const record = asRecord(input);
|
|
102
|
+
const command = requiredInputString(record, "command");
|
|
103
|
+
const args = requiredStringArray(record, "args");
|
|
104
|
+
const timeoutMs = optionalTimeout(record.timeout_ms);
|
|
105
|
+
const cwd = await containedWorkingDirectory(worktreePath, record.cwd);
|
|
106
|
+
const logDirectory = requiredAttemptEnvironment("FORGE_LOG_DIR");
|
|
107
|
+
await fs.mkdir(path.join(logDirectory, "managed-commands"), { recursive: true, mode: 0o700 });
|
|
108
|
+
const commandId = randomUUID();
|
|
109
|
+
const metadataPath = path.join(logDirectory, "managed-commands", `${commandId}.json`);
|
|
110
|
+
const receiptPath = managedCommandReceiptPath(logDirectory, commandId);
|
|
111
|
+
const invocation = {
|
|
112
|
+
commandId,
|
|
113
|
+
command,
|
|
114
|
+
args,
|
|
115
|
+
cwd,
|
|
116
|
+
timeoutMs,
|
|
117
|
+
logDirectory,
|
|
118
|
+
metadataPath,
|
|
119
|
+
receiptPath,
|
|
120
|
+
startedAt: Date.now(),
|
|
121
|
+
};
|
|
122
|
+
if (startManagedCommandNames.has(name) || timeoutMs > maximumSynchronousWaitMs) {
|
|
123
|
+
const started = managedCommandResult({
|
|
124
|
+
...invocation,
|
|
125
|
+
timedOut: false,
|
|
126
|
+
status: "starting",
|
|
127
|
+
exitCode: null,
|
|
128
|
+
stdout: "",
|
|
129
|
+
stderr: "",
|
|
130
|
+
});
|
|
131
|
+
await persistManagedCommandReceipt(receiptPath, started.structuredContent);
|
|
132
|
+
const task = executeManagedCommand(invocation);
|
|
133
|
+
managedCommandTasks.set(commandId, task);
|
|
134
|
+
void task.finally(() => managedCommandTasks.delete(commandId)).catch(() => undefined);
|
|
135
|
+
return {
|
|
136
|
+
...started,
|
|
137
|
+
content: [{
|
|
138
|
+
type: "text",
|
|
139
|
+
text: `Forge managed command ${commandId} started asynchronously. Poll forge.get_managed_command with this command_id until status is terminal.`,
|
|
140
|
+
}],
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
return await executeManagedCommand(invocation);
|
|
144
|
+
}
|
|
145
|
+
async function executeManagedCommand(invocation) {
|
|
146
|
+
const environment = inheritedEnvironment({
|
|
147
|
+
FORGE_RUNTIME_MODE: "host",
|
|
148
|
+
FORGE_RESOURCE_LEASE_ID: requiredAttemptEnvironment("FORGE_RESOURCE_LEASE_ID"),
|
|
149
|
+
FORGE_DISPATCH_ATTEMPT_ID: requiredAttemptEnvironment("FORGE_DISPATCH_ATTEMPT_ID"),
|
|
150
|
+
// This nested command owns a distinct supervisor identity. A command-local
|
|
151
|
+
// fence avoids exposing the parent attempt's fencing capability to Codex's
|
|
152
|
+
// MCP subprocess configuration while preserving exact-tree cleanup proof.
|
|
153
|
+
FORGE_FENCING_TOKEN: randomUUID(),
|
|
154
|
+
FORGE_LOG_DIR: invocation.logDirectory,
|
|
155
|
+
FORGE_TEST_OUTPUT_DIR: requiredAttemptEnvironment("FORGE_TEST_OUTPUT_DIR"),
|
|
156
|
+
FORGE_PROCESS_SUPERVISOR_ID: `managed-${invocation.commandId.replaceAll("-", "")}`,
|
|
157
|
+
FORGE_PROCESS_SUPERVISOR_METADATA_PATH: invocation.metadataPath,
|
|
158
|
+
});
|
|
159
|
+
let stdout = "";
|
|
160
|
+
let stderr = "";
|
|
161
|
+
const controller = new AbortController();
|
|
162
|
+
let timedOut = false;
|
|
163
|
+
let timer = null;
|
|
164
|
+
const launchTimer = setTimeout(() => {
|
|
165
|
+
timedOut = true;
|
|
166
|
+
controller.abort(new Error(`managed_command_launch_timeout: process supervisor was not ready within ${managedCommandLaunchTimeoutMs}ms.`));
|
|
167
|
+
}, managedCommandLaunchTimeoutMs);
|
|
168
|
+
try {
|
|
169
|
+
const result = await new StreamingProviderProcessRunner().run({
|
|
170
|
+
command: invocation.command,
|
|
171
|
+
args: invocation.args,
|
|
172
|
+
cwd: invocation.cwd,
|
|
173
|
+
env: environment,
|
|
174
|
+
captureOutput: false,
|
|
175
|
+
}, {
|
|
176
|
+
onStarted: async () => {
|
|
177
|
+
clearTimeout(launchTimer);
|
|
178
|
+
const running = managedCommandResult({
|
|
179
|
+
...invocation,
|
|
180
|
+
timedOut: false,
|
|
181
|
+
status: "running",
|
|
182
|
+
exitCode: null,
|
|
183
|
+
stdout,
|
|
184
|
+
stderr,
|
|
185
|
+
});
|
|
186
|
+
await persistManagedCommandReceipt(invocation.receiptPath, running.structuredContent);
|
|
187
|
+
timer = setTimeout(() => {
|
|
188
|
+
timedOut = true;
|
|
189
|
+
controller.abort(new Error(`managed_command_timeout: exceeded ${invocation.timeoutMs}ms.`));
|
|
190
|
+
}, invocation.timeoutMs);
|
|
191
|
+
},
|
|
192
|
+
onStdoutLine: (line) => { stdout = appendBounded(stdout, line); },
|
|
193
|
+
onStderrLine: (line) => { stderr = appendBounded(stderr, line); },
|
|
194
|
+
}, controller.signal);
|
|
195
|
+
const status = timedOut ? "timed_out" : result.exitCode === 0 ? "pass" : "fail";
|
|
196
|
+
const receipt = managedCommandResult({ ...invocation, timedOut, status, exitCode: result.exitCode, stdout, stderr });
|
|
197
|
+
await persistManagedCommandReceipt(invocation.receiptPath, receipt.structuredContent);
|
|
198
|
+
return receipt;
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
202
|
+
stderr = appendBounded(stderr, message);
|
|
203
|
+
const receipt = managedCommandResult({
|
|
204
|
+
...invocation,
|
|
205
|
+
timedOut,
|
|
206
|
+
status: timedOut ? "timeout_termination_failed" : "runner_error",
|
|
207
|
+
exitCode: null,
|
|
208
|
+
stdout,
|
|
209
|
+
stderr,
|
|
210
|
+
});
|
|
211
|
+
await persistManagedCommandReceipt(invocation.receiptPath, receipt.structuredContent);
|
|
212
|
+
return receipt;
|
|
213
|
+
}
|
|
214
|
+
finally {
|
|
215
|
+
clearTimeout(launchTimer);
|
|
216
|
+
if (timer)
|
|
217
|
+
clearTimeout(timer);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
async function readManagedCommand(input) {
|
|
221
|
+
const commandId = requiredInputString(asRecord(input), "command_id");
|
|
222
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(commandId)) {
|
|
223
|
+
throw new Error("managed_command_invalid: command_id must be a UUID returned by Forge.");
|
|
224
|
+
}
|
|
225
|
+
const receiptPath = managedCommandReceiptPath(requiredAttemptEnvironment("FORGE_LOG_DIR"), commandId);
|
|
226
|
+
let receipt;
|
|
227
|
+
try {
|
|
228
|
+
receipt = JSON.parse(await fs.readFile(receiptPath, "utf8"));
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
if (error.code === "ENOENT") {
|
|
232
|
+
throw new Error(`managed_command_not_found: no durable receipt exists for ${commandId}.`, { cause: error });
|
|
233
|
+
}
|
|
234
|
+
throw error;
|
|
235
|
+
}
|
|
236
|
+
const status = typeof receipt.status === "string" ? receipt.status : "unknown";
|
|
237
|
+
const stdout = typeof receipt.stdout === "string" ? receipt.stdout : "";
|
|
238
|
+
const stderr = typeof receipt.stderr === "string" ? receipt.stderr : "";
|
|
239
|
+
return {
|
|
240
|
+
content: [{
|
|
241
|
+
type: "text",
|
|
242
|
+
text: terminalManagedCommandStatuses.has(status)
|
|
243
|
+
? `Forge managed command ${commandId} is ${status} (exit ${receipt.exit_code ?? "unavailable"}).\nstdout:\n${stdout || "(empty)"}\nstderr:\n${stderr || "(empty)"}`
|
|
244
|
+
: `Forge managed command ${commandId} is ${status}. Poll this tool again after the command advances.`,
|
|
245
|
+
}],
|
|
246
|
+
structuredContent: receipt,
|
|
247
|
+
isError: terminalManagedCommandStatuses.has(status) && status !== "pass",
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
function managedCommandReceiptPath(logDirectory, commandId) {
|
|
251
|
+
return path.join(logDirectory, "managed-commands", `${commandId}.receipt.json`);
|
|
252
|
+
}
|
|
253
|
+
async function persistManagedCommandReceipt(receiptPath, receipt) {
|
|
254
|
+
const temporaryPath = `${receiptPath}.${randomUUID()}.tmp`;
|
|
255
|
+
await fs.writeFile(temporaryPath, `${JSON.stringify(receipt, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
256
|
+
await fs.rename(temporaryPath, receiptPath);
|
|
257
|
+
}
|
|
258
|
+
function managedCommandResult(values) {
|
|
259
|
+
const elapsedMs = Date.now() - values.startedAt;
|
|
260
|
+
return {
|
|
261
|
+
content: [{
|
|
262
|
+
type: "text",
|
|
263
|
+
text: `Forge managed command ${values.commandId} ${values.status} after ${elapsedMs}ms (exit ${values.exitCode ?? "unavailable"}).\nstdout:\n${values.stdout || "(empty)"}\nstderr:\n${values.stderr || "(empty)"}`,
|
|
264
|
+
}],
|
|
265
|
+
structuredContent: {
|
|
266
|
+
command_id: values.commandId,
|
|
267
|
+
status: values.status,
|
|
268
|
+
command: [values.command, ...values.args],
|
|
269
|
+
cwd: values.cwd,
|
|
270
|
+
timeout_ms: values.timeoutMs,
|
|
271
|
+
elapsed_ms: elapsedMs,
|
|
272
|
+
timed_out: values.timedOut,
|
|
273
|
+
exit_code: values.exitCode,
|
|
274
|
+
stdout: values.stdout,
|
|
275
|
+
stderr: values.stderr,
|
|
276
|
+
supervisor_metadata_path: values.metadataPath,
|
|
277
|
+
receipt_path: values.receiptPath,
|
|
278
|
+
process_tree_terminated: values.status === "timed_out" ? true : values.timedOut ? false : undefined,
|
|
279
|
+
},
|
|
280
|
+
isError: terminalManagedCommandStatuses.has(values.status) && values.status !== "pass",
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
async function containedWorkingDirectory(worktreePath, input) {
|
|
284
|
+
const root = await fs.realpath(path.resolve(worktreePath));
|
|
285
|
+
const relative = input === undefined ? "." : requiredStandaloneString(input, "cwd");
|
|
286
|
+
if (path.isAbsolute(relative))
|
|
287
|
+
throw new Error("managed_command_invalid: cwd must be worktree-relative.");
|
|
288
|
+
const target = await fs.realpath(path.resolve(root, relative));
|
|
289
|
+
const containment = path.relative(root, target);
|
|
290
|
+
if (containment.startsWith(`..${path.sep}`) || path.isAbsolute(containment)) {
|
|
291
|
+
throw new Error("managed_command_invalid: cwd resolves outside the source worktree.");
|
|
292
|
+
}
|
|
293
|
+
return target;
|
|
294
|
+
}
|
|
295
|
+
function inheritedEnvironment(overrides) {
|
|
296
|
+
const env = {};
|
|
297
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
298
|
+
if (typeof value === "string" && !looksSensitiveEnvironmentName(key))
|
|
299
|
+
env[key] = value;
|
|
300
|
+
}
|
|
301
|
+
return { ...env, ...overrides };
|
|
302
|
+
}
|
|
303
|
+
function looksSensitiveEnvironmentName(name) {
|
|
304
|
+
const normalized = name.trim().toUpperCase();
|
|
305
|
+
return normalized.includes("SECRET")
|
|
306
|
+
|| normalized.includes("TOKEN")
|
|
307
|
+
|| normalized.includes("PASSWORD")
|
|
308
|
+
|| normalized.includes("CREDENTIAL")
|
|
309
|
+
|| normalized.endsWith("_API_KEY")
|
|
310
|
+
|| normalized === "OPENAI_API_KEY";
|
|
311
|
+
}
|
|
312
|
+
function requiredAttemptEnvironment(name) {
|
|
313
|
+
const value = process.env[name]?.trim();
|
|
314
|
+
if (!value)
|
|
315
|
+
throw new Error(`managed_attempt_tool_unavailable: ${name} is not configured.`);
|
|
316
|
+
return value;
|
|
317
|
+
}
|
|
318
|
+
function asRecord(value) {
|
|
319
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
320
|
+
throw new Error("managed_command_invalid: input must be an object.");
|
|
321
|
+
return value;
|
|
322
|
+
}
|
|
323
|
+
function requiredInputString(record, name) {
|
|
324
|
+
return requiredStandaloneString(record[name], name);
|
|
325
|
+
}
|
|
326
|
+
function requiredStandaloneString(value, name) {
|
|
327
|
+
if (typeof value !== "string" || !value.trim())
|
|
328
|
+
throw new Error(`managed_command_invalid: ${name} is required.`);
|
|
329
|
+
return value.trim();
|
|
330
|
+
}
|
|
331
|
+
function requiredStringArray(record, name) {
|
|
332
|
+
const value = record[name];
|
|
333
|
+
if (!Array.isArray(value) || value.some(entry => typeof entry !== "string")) {
|
|
334
|
+
throw new Error(`managed_command_invalid: ${name} must be a string array.`);
|
|
335
|
+
}
|
|
336
|
+
return value;
|
|
337
|
+
}
|
|
338
|
+
function optionalTimeout(value) {
|
|
339
|
+
if (value === undefined)
|
|
340
|
+
return 2_700_000;
|
|
341
|
+
if (!Number.isSafeInteger(value) || value < 1_000 || value > 7_200_000) {
|
|
342
|
+
throw new Error("managed_command_invalid: timeout_ms must be an integer from 1000 through 7200000.");
|
|
343
|
+
}
|
|
344
|
+
return value;
|
|
345
|
+
}
|
|
346
|
+
function appendBounded(current, line) {
|
|
347
|
+
if (current.length >= maximumCapturedCharacters)
|
|
348
|
+
return current;
|
|
349
|
+
return `${current}${line}\n`.slice(0, maximumCapturedCharacters);
|
|
350
|
+
}
|