@loucompanion/forge-bridge 0.1.3-dev.b3bce6ba65a6 → 0.1.3-dev.ba7d36726eda
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 +5 -1
- package/dist/bridge/bridge.service.d.ts +52 -3
- package/dist/bridge/bridge.service.js +776 -19
- package/dist/bridge/bridge.types.d.ts +131 -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 +46 -0
- package/dist/cli/cli.service.d.ts +42 -2
- package/dist/cli/cli.service.js +350 -23
- package/dist/cli/local-config.d.ts +4 -0
- package/dist/cli/local-config.js +39 -0
- package/dist/runtime/attempt-node-runtime-launcher.d.ts +15 -0
- package/dist/runtime/attempt-node-runtime-launcher.js +50 -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 +74 -0
- package/dist/runtime/attempt-process-supervisor.js +290 -0
- package/dist/runtime/host-listener-inspector.d.ts +28 -0
- package/dist/runtime/host-listener-inspector.js +185 -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 +39 -0
- package/dist/runtime/host-runtime-contract.js +295 -0
- package/dist/runtime/host-runtime-lease.d.ts +122 -0
- package/dist/runtime/host-runtime-lease.js +1241 -0
- package/dist/runtime/host-runtime-promotion.d.ts +21 -0
- package/dist/runtime/host-runtime-promotion.js +128 -0
- package/dist/runtime/node-host-runtime-adapter.d.ts +44 -0
- package/dist/runtime/node-host-runtime-adapter.js +205 -0
- package/dist/runtime/playwright-host-runtime-adapter.d.ts +20 -0
- package/dist/runtime/playwright-host-runtime-adapter.js +136 -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 +45 -0
- package/dist/sandbox/preflight.js +300 -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 +67 -0
- package/dist/session/phase-completion.artifacts.js +23 -2
- package/dist/session/phase-completion.helpers.js +6 -1
- package/dist/session/phase-completion.runtime.js +3 -1
- 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 +20 -9
- package/dist/session/provider-cli/provider-cli.process.d.ts +3 -0
- package/dist/session/provider-cli/provider-cli.process.js +65 -14
- package/dist/session/provider-cli/provider-cli.prompts.d.ts +14 -2
- package/dist/session/provider-cli/provider-cli.prompts.js +99 -15
- 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 +4 -1
- package/dist/session/provider-cli/provider-cli.service.js +167 -28
- package/dist/session/provider-cli/provider-cli.types.d.ts +2 -0
- package/dist/session/session-redaction.d.ts +3 -0
- package/dist/session/session-redaction.js +23 -0
- package/dist/session/session.base.d.ts +4 -0
- package/dist/session/session.service.d.ts +6 -1
- package/dist/session/session.service.js +184 -38
- package/dist/session/session.types.d.ts +58 -1
- package/dist/session/session.types.js +172 -2
- 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 +40 -0
- package/dist/shared/process/host-process-supervisor.js +195 -0
- package/dist/shared/provider-capabilities.d.ts +1 -0
- package/dist/shared/provider-capabilities.js +3 -0
- package/dist/shared/redaction/redaction.js +6 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- 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 +6 -2
- package/dist/worktree/worktree.service.js +60 -43
- package/dist/worktree/worktree.types.d.ts +3 -1
- package/package.json +6 -3
- package/runtime/windows-job-supervisor.ps1 +287 -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,67 @@
|
|
|
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
|
+
await injectWorktreeArtifacts(this.workspace, artifacts);
|
|
43
|
+
const hydratedPriorPhaseArtifactCount = await hydratePriorPhaseArtifacts(this.workspace, request.priorPhaseArtifacts);
|
|
44
|
+
return {
|
|
45
|
+
repoPath: this.workspace,
|
|
46
|
+
worktreePath: this.workspace,
|
|
47
|
+
branchName,
|
|
48
|
+
baseBranch: request.baseBranch,
|
|
49
|
+
cleanupWorktrees: false,
|
|
50
|
+
hydratedPriorPhaseArtifactCount
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
async release(_handle) {
|
|
54
|
+
// The host supervisor owns the workspace volume lifecycle.
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export function sandboxInputRefFromBundle(bundleHeads, inputCommitSha) {
|
|
58
|
+
const expectedSha = inputCommitSha.toLowerCase();
|
|
59
|
+
const matches = bundleHeads
|
|
60
|
+
.split(/\r?\n/u)
|
|
61
|
+
.map(line => /^([0-9a-f]{40}|[0-9a-f]{64}) (refs\/forge\/inputs\/[0-9a-f]{32})$/iu.exec(line.trim()))
|
|
62
|
+
.filter((match) => Boolean(match) && match[1].toLowerCase() === expectedSha);
|
|
63
|
+
if (matches.length !== 1) {
|
|
64
|
+
throw new Error("Sandbox input bundle does not expose the exact recorded input ref.");
|
|
65
|
+
}
|
|
66
|
+
return matches[0][2];
|
|
67
|
+
}
|
|
@@ -424,7 +424,7 @@ async function synthesizeQaCycleVerdictPayload(config, payload, status) {
|
|
|
424
424
|
]);
|
|
425
425
|
merged.browser_checks = dedupeUnknownArray([
|
|
426
426
|
...qaArray(payload, "browser_checks", payloadEvidence),
|
|
427
|
-
...evidenceGates.flatMap((gate) => qaArray(gate, "browser_checks"))
|
|
427
|
+
...evidenceGates.flatMap((gate) => qaArray(gate, "browser_checks").map(normalizeInheritedQaBrowserCheck))
|
|
428
428
|
]);
|
|
429
429
|
merged.sidecars = dedupeUnknownArray([
|
|
430
430
|
...qaArray(payload, "sidecars", payloadEvidence),
|
|
@@ -516,6 +516,27 @@ function dedupeStrings(values) {
|
|
|
516
516
|
}
|
|
517
517
|
return deduped;
|
|
518
518
|
}
|
|
519
|
+
function normalizeInheritedQaBrowserCheck(browserCheck) {
|
|
520
|
+
if (!browserCheck || typeof browserCheck !== "object" || Array.isArray(browserCheck)) {
|
|
521
|
+
return browserCheck;
|
|
522
|
+
}
|
|
523
|
+
const record = browserCheck;
|
|
524
|
+
if (optionalString(record, "summary")) {
|
|
525
|
+
return record;
|
|
526
|
+
}
|
|
527
|
+
const target = optionalString(record, "url") ?? optionalString(record, "route") ?? "the configured browser target";
|
|
528
|
+
const status = optionalString(record, "status") ?? "recorded";
|
|
529
|
+
return {
|
|
530
|
+
...record,
|
|
531
|
+
summary: `Inherited browser check ${status} for ${target}.`
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
function normalizeSha256ContentHash(contentHash) {
|
|
535
|
+
const normalized = contentHash.trim().toLowerCase();
|
|
536
|
+
return normalized.startsWith("sha256:")
|
|
537
|
+
? normalized.slice("sha256:".length)
|
|
538
|
+
: normalized;
|
|
539
|
+
}
|
|
519
540
|
async function validateQaSidecarReferences(worktreePath, workItemId, sidecars, ownerType, ownerId) {
|
|
520
541
|
if (sidecars.length === 0) {
|
|
521
542
|
return [];
|
|
@@ -557,7 +578,7 @@ async function validateQaSidecarReferences(worktreePath, workItemId, sidecars, o
|
|
|
557
578
|
throw new Error(`sidecars[${index}].bytes does not match the referenced file.`);
|
|
558
579
|
}
|
|
559
580
|
const actualHash = await sha256File(fullPath);
|
|
560
|
-
if (actualHash !== contentHash
|
|
581
|
+
if (actualHash !== normalizeSha256ContentHash(contentHash)) {
|
|
561
582
|
throw new Error(`sidecars[${index}].content_hash does not match the referenced file.`);
|
|
562
583
|
}
|
|
563
584
|
validated.push({
|
|
@@ -626,7 +626,12 @@ export function forgeBridgeCliBinPath() {
|
|
|
626
626
|
return fileURLToPath(new URL("../cli/bin.js", import.meta.url));
|
|
627
627
|
}
|
|
628
628
|
export function tomlString(value) {
|
|
629
|
-
|
|
629
|
+
if (value.includes("'''")) {
|
|
630
|
+
throw new Error("Codex MCP configuration values cannot contain three consecutive apostrophes.");
|
|
631
|
+
}
|
|
632
|
+
// Codex config overrides pass through npm's PowerShell shim on Windows.
|
|
633
|
+
// Literal TOML strings survive that boundary; basic-string double quotes do not.
|
|
634
|
+
return `'''${value}'''`;
|
|
630
635
|
}
|
|
631
636
|
export function tomlStringArray(values) {
|
|
632
637
|
return `[${values.map(tomlString).join(", ")}]`;
|
|
@@ -82,7 +82,9 @@ export function codexMcpConfigArgs(runtime) {
|
|
|
82
82
|
"-c",
|
|
83
83
|
`${prefix}.command=${tomlString(runtime.command)}`,
|
|
84
84
|
"-c",
|
|
85
|
-
`${prefix}.args=${tomlStringArray(runtime.args)}
|
|
85
|
+
`${prefix}.args=${tomlStringArray(runtime.args)}`,
|
|
86
|
+
"-c",
|
|
87
|
+
`${prefix}.default_tools_approval_mode=${tomlString("approve")}`
|
|
86
88
|
];
|
|
87
89
|
for (const [key, value] of Object.entries(runtime.env)) {
|
|
88
90
|
args.push("-c", `${prefix}.env.${key}=${tomlString(value)}`);
|
|
@@ -18,7 +18,7 @@ export declare function isDebugCompletionPhaseForProvider(phase: CompletionPhase
|
|
|
18
18
|
export declare function emitQaArtifactRecorded(emit: ProviderCliRunContext["emit"], worktreePath: string, artifact: RunnerDispatchArtifact): Promise<void>;
|
|
19
19
|
export declare function readQaCompletionArtifactBundle(worktreePath: string, workItemId: string, ownerType?: string | null, ownerId?: string | null): Promise<RunnerDispatchArtifact | null>;
|
|
20
20
|
export declare function hasCompleteQaArtifactBundle(worktreePath: string, workItemId: string, ownerType?: string | null, ownerId?: string | null): Promise<boolean>;
|
|
21
|
-
export declare function enforceQaBrowserLaunchTargetGate(dispatch: RunOnceDispatchPacket, artifact: RunnerDispatchArtifact): void
|
|
21
|
+
export declare function enforceQaBrowserLaunchTargetGate(dispatch: RunOnceDispatchPacket, artifact: RunnerDispatchArtifact): Promise<void>;
|
|
22
22
|
export declare function validateQaBundleSidecarReferences(worktreePath: string, workItemId: string, ownerType?: string | null, ownerId?: string | null): Promise<void>;
|
|
23
23
|
export declare function emitQaStructuredEvents(emit: ProviderCliRunContext["emit"], artifact: RunnerDispatchArtifact): Promise<void>;
|
|
24
24
|
interface QaGateArtifactSnapshot {
|
|
@@ -5,6 +5,7 @@ import { ensureNoSymbolicLinkPathSegments, isPathUnderRoot, sha256File } from ".
|
|
|
5
5
|
import { hasLaunchTarget } from "./provider-cli.qa-sidecars.js";
|
|
6
6
|
import { clip, isNodeError, messageFromError, normalizedString, parseJsonLine, stringField } from "./provider-output-state.js";
|
|
7
7
|
const developCyclePhaseOrder = ["implementation", "self_review", "validation"];
|
|
8
|
+
const nonPublishableWorktreePaths = [".forge", ".local"];
|
|
8
9
|
export async function emitDraftGateArtifacts(emit, worktreePath, workItemId) {
|
|
9
10
|
const draftArtifactFiles = {
|
|
10
11
|
draft_intent: "draft.intent.json",
|
|
@@ -147,7 +148,9 @@ export async function cleanupCompletionTools(completionTools, emit) {
|
|
|
147
148
|
}
|
|
148
149
|
export async function commitWorktreeChanges(git, worktreePath, workItemId) {
|
|
149
150
|
await git.run(worktreePath, ["add", "-A"]);
|
|
150
|
-
|
|
151
|
+
for (const reservedPath of nonPublishableWorktreePaths) {
|
|
152
|
+
await git.run(worktreePath, ["reset", "--", reservedPath]).catch(() => "");
|
|
153
|
+
}
|
|
151
154
|
const changedFiles = (await git.run(worktreePath, ["diff", "--cached", "--name-only"]))
|
|
152
155
|
.split(/\r?\n/)
|
|
153
156
|
.map((line) => line.trim())
|
|
@@ -237,18 +240,39 @@ export async function hasCompleteQaArtifactBundle(worktreePath, workItemId, owne
|
|
|
237
240
|
}
|
|
238
241
|
return true;
|
|
239
242
|
}
|
|
240
|
-
export function enforceQaBrowserLaunchTargetGate(dispatch, artifact) {
|
|
243
|
+
export async function enforceQaBrowserLaunchTargetGate(dispatch, artifact) {
|
|
241
244
|
const qaBrowser = dispatch.qaBrowser;
|
|
242
245
|
if (!qaBrowser?.required || hasLaunchTarget(qaBrowser.launchTarget ?? null)) {
|
|
243
246
|
return;
|
|
244
247
|
}
|
|
248
|
+
const evidence = await readJsonObjectFile(path.join(path.dirname(artifact.path), qaArtifactFileNames.evidence));
|
|
249
|
+
if (hasPassingRepositoryPlaywrightEvidence(evidence)) {
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
245
252
|
const verdict = parseJsonLine(artifact.verdictJson) ?? {};
|
|
246
253
|
const status = normalizedString(stringField(verdict, "status") ?? artifact.status ?? "");
|
|
247
254
|
const blockerReason = normalizedString(stringField(verdict, "blocker_reason") ?? "");
|
|
248
255
|
if (status !== "blocked" || blockerReason !== "missing_app_launch_target") {
|
|
249
|
-
throw new Error("QA browser testing is required but
|
|
256
|
+
throw new Error("QA browser testing is required but neither an app launch target nor passing repository Playwright evidence was provided; provider must return blocked with blocker_reason missing_app_launch_target.");
|
|
250
257
|
}
|
|
251
258
|
}
|
|
259
|
+
function hasPassingRepositoryPlaywrightEvidence(evidence) {
|
|
260
|
+
const passingBrowserCheck = arrayField(evidence, "browser_checks").some(check => normalizedString(stringField(check, "status") ?? "") === "pass"
|
|
261
|
+
&& Boolean(stringField(check, "url") ?? stringField(check, "route")));
|
|
262
|
+
if (!passingBrowserCheck) {
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
return arrayField(evidence, "commands").some(command => {
|
|
266
|
+
const status = normalizedString(stringField(command, "status") ?? "");
|
|
267
|
+
const exitCode = command.exit_code;
|
|
268
|
+
const description = [stringField(command, "command"), stringField(command, "summary")]
|
|
269
|
+
.filter((value) => Boolean(value))
|
|
270
|
+
.join(" ");
|
|
271
|
+
return status === "pass"
|
|
272
|
+
&& exitCode === 0
|
|
273
|
+
&& /(?:playwright|test:e2e|e2e(?:\s|:|$)|browser(?:\s|-)?test)/iu.test(description);
|
|
274
|
+
});
|
|
275
|
+
}
|
|
252
276
|
export async function validateQaBundleSidecarReferences(worktreePath, workItemId, ownerType, ownerId) {
|
|
253
277
|
const artifactRoot = ownerType && ownerId
|
|
254
278
|
? artifactRootForOwner(worktreePath, ownerType, ownerId)
|
|
@@ -444,7 +468,7 @@ export class LiveQaArtifactEmitter {
|
|
|
444
468
|
if (!artifact) {
|
|
445
469
|
return;
|
|
446
470
|
}
|
|
447
|
-
enforceQaBrowserLaunchTargetGate(this.dispatch, artifact);
|
|
471
|
+
await enforceQaBrowserLaunchTargetGate(this.dispatch, artifact);
|
|
448
472
|
this.emittedBundle = true;
|
|
449
473
|
await emitQaArtifactRecorded(this.emit, this.worktreePath, artifact);
|
|
450
474
|
await emitQaStructuredEvents(this.emit, artifact);
|
|
@@ -2,6 +2,7 @@ import type { ProviderCliDraftContext, ProviderCliRunContext, ProviderCliRunResu
|
|
|
2
2
|
export interface ProviderCliService {
|
|
3
3
|
runImplementation(context: ProviderCliRunContext): Promise<ProviderCliRunResult>;
|
|
4
4
|
runArchitect(context: ProviderCliRunContext): Promise<ProviderCliRunResult>;
|
|
5
|
+
runCiRepair(context: ProviderCliRunContext): Promise<ProviderCliRunResult>;
|
|
5
6
|
runConflictResolve(context: ProviderCliRunContext): Promise<ProviderCliRunResult>;
|
|
6
7
|
runQa(context: ProviderCliRunContext): Promise<ProviderCliRunResult>;
|
|
7
8
|
runDraft(context: ProviderCliDraftContext): Promise<ProviderCliRunResult>;
|
|
@@ -8,5 +8,7 @@ export declare class DeterministicProviderCliService implements ProviderCliServi
|
|
|
8
8
|
runImplementation(context: ProviderCliRunContext): Promise<ProviderCliRunResult>;
|
|
9
9
|
runArchitect(context: ProviderCliRunContext): Promise<ProviderCliRunResult>;
|
|
10
10
|
runQa(context: ProviderCliRunContext): Promise<ProviderCliRunResult>;
|
|
11
|
+
runCiRepair(context: ProviderCliRunContext): Promise<ProviderCliRunResult>;
|
|
11
12
|
runConflictResolve(context: ProviderCliRunContext): Promise<ProviderCliRunResult>;
|
|
13
|
+
private runPullRequestRepair;
|
|
12
14
|
}
|
|
@@ -5,7 +5,7 @@ import { buildSpecBody, selectLaneCommand } from "../../worktree/artifact-inject
|
|
|
5
5
|
import { completionPhaseFromDispatch, debugCompletionPhaseOrder, expectedCompletionToolName, expectedDebugCompletionToolName, expectedQaCompletionToolName, qaCompletionGateOrder, readCompletionArtifact, usesDebugCycleCompletion, usesDevelopCycleCompletion, writeCompletionArtifact } from "../phase-completion.js";
|
|
6
6
|
import { emitArchitectStructuredEvents, emitCompletionArtifactRecorded, emitQaArtifactRecorded, emitQaGateAccepted, emitQaStructuredEvents, isDebugCompletionPhaseForProvider, nextQaGate, phaseCompleteEventType, qaArtifactRootForDispatch, readQaCompletionArtifactBundle, readQaGateArtifact } from "./provider-cli.artifacts.js";
|
|
7
7
|
import { deterministicBrowserCheck, deterministicBrowserSidecar, hasLaunchTarget, writeQaSidecarFiles } from "./provider-cli.qa-sidecars.js";
|
|
8
|
-
import { buildProviderCliCommand, formatProviderCliCommand, isPullRequestCommentsDispatch, singleLine } from "./provider-cli.prompts.js";
|
|
8
|
+
import { buildProviderCliCommand, formatProviderCliCommand, isPullRequestCheckRepairDispatch, isPullRequestCommentsDispatch, singleLine } from "./provider-cli.prompts.js";
|
|
9
9
|
import { estimatedDeterministicUsage, exactRedactionSecrets } from "./provider-cli.runtime.js";
|
|
10
10
|
import { buildProviderUsageEvent } from "./provider-usage.js";
|
|
11
11
|
import { deterministicArchitectPayload, deterministicCompletionPayload, deterministicQaGatePayload } from "./provider-cli.deterministic-payloads.js";
|
|
@@ -202,23 +202,34 @@ export class DeterministicProviderCliService {
|
|
|
202
202
|
artifacts: [qaArtifact]
|
|
203
203
|
};
|
|
204
204
|
}
|
|
205
|
+
async runCiRepair(context) {
|
|
206
|
+
return await this.runPullRequestRepair(context, "ci_repair");
|
|
207
|
+
}
|
|
205
208
|
async runConflictResolve(context) {
|
|
209
|
+
return await this.runPullRequestRepair(context, "conflict_resolve");
|
|
210
|
+
}
|
|
211
|
+
async runPullRequestRepair(context, route) {
|
|
206
212
|
const { dispatch, worktree } = context;
|
|
213
|
+
const repairChecks = route === "ci_repair" || isPullRequestCheckRepairDispatch(dispatch);
|
|
207
214
|
const addressComments = isPullRequestCommentsDispatch(dispatch);
|
|
208
|
-
await this.git.run(worktree.worktreePath, ["commit", "--allow-empty", "-m", `forge bridge ${addressComments ? "pr comments" : "conflict resolve"} for ${dispatch.workItem.id.slice(0, 8)}`]);
|
|
215
|
+
await this.git.run(worktree.worktreePath, ["commit", "--allow-empty", "-m", `forge bridge ${repairChecks ? "ci repair" : addressComments ? "pr comments" : "conflict resolve"} for ${dispatch.workItem.id.slice(0, 8)}`]);
|
|
209
216
|
await context.emit(buildProviderUsageEvent(dispatch, estimatedDeterministicUsage()));
|
|
210
217
|
await context.emit({
|
|
211
218
|
role: "assistant",
|
|
212
219
|
kind: "message",
|
|
213
|
-
content:
|
|
214
|
-
? `Deterministic
|
|
215
|
-
:
|
|
216
|
-
|
|
220
|
+
content: repairChecks
|
|
221
|
+
? `Deterministic CI repair completed for ${worktree.branchName}.`
|
|
222
|
+
: addressComments
|
|
223
|
+
? `Deterministic PR comment addressing completed for ${worktree.branchName}.`
|
|
224
|
+
: `Deterministic conflict resolve completed for ${worktree.branchName}.`,
|
|
225
|
+
eventType: repairChecks ? "pr_checks_complete" : addressComments ? "pr_comments_complete" : "conflict_resolve_complete"
|
|
217
226
|
});
|
|
218
227
|
return {
|
|
219
|
-
summary:
|
|
220
|
-
? `forge-bridge
|
|
221
|
-
:
|
|
228
|
+
summary: repairChecks
|
|
229
|
+
? `forge-bridge repaired PR checks for ${worktree.branchName}.`
|
|
230
|
+
: addressComments
|
|
231
|
+
? `forge-bridge addressed PR comments for ${worktree.branchName}.`
|
|
232
|
+
: `forge-bridge resolved conflicts for ${worktree.branchName}.`
|
|
222
233
|
};
|
|
223
234
|
}
|
|
224
235
|
}
|
|
@@ -5,6 +5,8 @@ export interface ProviderProcessRunRequest {
|
|
|
5
5
|
readonly args: readonly string[];
|
|
6
6
|
readonly cwd: string;
|
|
7
7
|
readonly env?: Readonly<Record<string, string>>;
|
|
8
|
+
readonly stdin?: string;
|
|
9
|
+
readonly captureOutput?: boolean;
|
|
8
10
|
}
|
|
9
11
|
export interface ProviderProcessRunResult {
|
|
10
12
|
readonly exitCode: number;
|
|
@@ -27,5 +29,6 @@ export declare function buildProviderChildEnvironment(requestEnv?: Readonly<Reco
|
|
|
27
29
|
export declare class StreamingProviderProcessRunner implements ProviderProcessRunner {
|
|
28
30
|
run(request: ProviderProcessRunRequest, handlers?: ProviderProcessHandlers, signal?: AbortSignal): Promise<ProviderProcessRunResult>;
|
|
29
31
|
}
|
|
32
|
+
export declare function resolveCodexNpmDirectLaunch(commandPath: string, args: readonly string[]): LaunchCommand | null;
|
|
30
33
|
export declare function chooseWindowsCommandCandidate(candidates: readonly string[]): string | null;
|
|
31
34
|
export {};
|