@loucompanion/forge-bridge 0.1.3-dev.09aeeb7ccadf → 0.1.3-dev.1fe428dd78ab
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 +44 -0
- package/dist/bridge/bridge.base.d.ts +5 -1
- package/dist/bridge/bridge.service.d.ts +36 -2
- package/dist/bridge/bridge.service.js +564 -14
- package/dist/bridge/bridge.types.d.ts +110 -1
- package/dist/cli/cli.help.js +12 -0
- package/dist/cli/cli.service.d.ts +12 -0
- package/dist/cli/cli.service.js +168 -1
- 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 +33 -0
- package/dist/sandbox/image-policy.js +245 -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 +36 -0
- package/dist/sandbox/preflight.js +239 -0
- package/dist/sandbox/process.d.ts +20 -0
- package/dist/sandbox/process.js +124 -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 +83 -0
- package/dist/sandbox/runtime.d.ts +218 -0
- package/dist/sandbox/runtime.js +1672 -0
- package/dist/sandbox/sandbox.types.d.ts +83 -0
- package/dist/sandbox/sandbox.types.js +1 -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 +228 -0
- package/dist/sandbox/workspace.service.d.ts +12 -0
- package/dist/sandbox/workspace.service.js +49 -0
- 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.prompts.d.ts +2 -0
- package/dist/session/provider-cli/provider-cli.prompts.js +31 -0
- package/dist/session/provider-cli/provider-cli.service.d.ts +3 -0
- package/dist/session/provider-cli/provider-cli.service.js +31 -13
- package/dist/session/session.service.js +14 -7
- package/dist/session/session.types.d.ts +41 -1
- package/dist/session/session.types.js +119 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +4 -2
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { RunOnceDispatchPacket } from "../session/session.types.js";
|
|
2
|
+
export interface SandboxContract {
|
|
3
|
+
readonly schemaVersion: 1;
|
|
4
|
+
readonly workingDirectory: "/workspace";
|
|
5
|
+
readonly commands: Readonly<Record<string, string>>;
|
|
6
|
+
readonly caches: readonly SandboxCache[];
|
|
7
|
+
readonly services: readonly SandboxService[];
|
|
8
|
+
readonly environment: readonly string[];
|
|
9
|
+
readonly artifacts: readonly string[];
|
|
10
|
+
readonly logs: readonly string[];
|
|
11
|
+
readonly browser: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface SandboxCache {
|
|
14
|
+
readonly name: string;
|
|
15
|
+
readonly mode: "readonly" | "per_attempt";
|
|
16
|
+
}
|
|
17
|
+
export interface SandboxService {
|
|
18
|
+
readonly type: "postgres" | "redis" | "browser";
|
|
19
|
+
readonly port: number;
|
|
20
|
+
}
|
|
21
|
+
export interface ValidatedSandboxContract {
|
|
22
|
+
readonly contract: SandboxContract;
|
|
23
|
+
readonly normalizedJson: string;
|
|
24
|
+
readonly digest: string;
|
|
25
|
+
}
|
|
26
|
+
export interface SandboxPreflightResult {
|
|
27
|
+
readonly available: boolean;
|
|
28
|
+
readonly healthy: boolean;
|
|
29
|
+
readonly generation: number;
|
|
30
|
+
readonly runtimeVersion: string | null;
|
|
31
|
+
readonly maxConcurrentSandboxes: number;
|
|
32
|
+
readonly availableMemoryBytes: number;
|
|
33
|
+
readonly availableCpuMillis: number;
|
|
34
|
+
readonly availableWorkspaceBytes: number;
|
|
35
|
+
readonly diskLow: boolean;
|
|
36
|
+
readonly unavailableCode: string | null;
|
|
37
|
+
readonly unavailableReason: string | null;
|
|
38
|
+
readonly features: SandboxFeatureMatrix;
|
|
39
|
+
}
|
|
40
|
+
export interface SandboxFeatureMatrix {
|
|
41
|
+
readonly rootless: boolean;
|
|
42
|
+
readonly userNamespaces: boolean;
|
|
43
|
+
readonly cgroupV2: boolean;
|
|
44
|
+
readonly seccomp: boolean;
|
|
45
|
+
readonly lsm: boolean;
|
|
46
|
+
readonly storageQuota: boolean;
|
|
47
|
+
readonly storagePolicy: boolean;
|
|
48
|
+
readonly compose: boolean;
|
|
49
|
+
readonly internalNetwork: boolean;
|
|
50
|
+
readonly buildkit: boolean;
|
|
51
|
+
readonly imagePolicy: boolean;
|
|
52
|
+
readonly securityProfile: boolean;
|
|
53
|
+
readonly egressProxy: boolean;
|
|
54
|
+
}
|
|
55
|
+
export interface SandboxReservation {
|
|
56
|
+
readonly dispatchAttemptId: string;
|
|
57
|
+
readonly leaseId: string;
|
|
58
|
+
readonly fencingToken: string;
|
|
59
|
+
readonly runnerInstanceId: string;
|
|
60
|
+
readonly expiresAt: string;
|
|
61
|
+
readonly reservedUntil: string;
|
|
62
|
+
readonly dispatch: RunOnceDispatchPacket;
|
|
63
|
+
readonly contract?: ValidatedSandboxContract;
|
|
64
|
+
readonly inputCommitSha?: string;
|
|
65
|
+
readonly sandboxConfigCommitSha?: string;
|
|
66
|
+
readonly imageDigest?: string;
|
|
67
|
+
}
|
|
68
|
+
export interface CommandResult {
|
|
69
|
+
readonly exitCode: number;
|
|
70
|
+
readonly stdout: string;
|
|
71
|
+
readonly stderr: string;
|
|
72
|
+
}
|
|
73
|
+
export interface CommandRunner {
|
|
74
|
+
run(command: string, args: readonly string[], options?: CommandOptions): Promise<CommandResult>;
|
|
75
|
+
}
|
|
76
|
+
export interface CommandOptions {
|
|
77
|
+
readonly cwd?: string;
|
|
78
|
+
readonly env?: Readonly<Record<string, string>>;
|
|
79
|
+
readonly stdin?: string | Uint8Array;
|
|
80
|
+
readonly timeoutMs?: number;
|
|
81
|
+
readonly signal?: AbortSignal;
|
|
82
|
+
readonly maxOutputBytes?: number;
|
|
83
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { MonotonicLeaseDeadline } from "./lease-watchdog.js";
|
|
2
|
+
export type SandboxTerminationCode = "sandbox_lease_expired" | "sandbox_wall_timeout" | "sandbox_idle_timeout";
|
|
3
|
+
export declare class WorkerDeadlineSupervisor {
|
|
4
|
+
readonly lease: MonotonicLeaseDeadline;
|
|
5
|
+
readonly hardWallMs: number;
|
|
6
|
+
readonly idleMs: number;
|
|
7
|
+
private readonly startedAtMs;
|
|
8
|
+
private lastActivityMs;
|
|
9
|
+
constructor(lease: MonotonicLeaseDeadline, hardWallMs: number, idleMs: number, startedAtMs?: number);
|
|
10
|
+
touch(nowMs?: number): void;
|
|
11
|
+
termination(nowMs?: number): SandboxTerminationCode | null;
|
|
12
|
+
}
|
|
13
|
+
export declare function terminationMessage(code: SandboxTerminationCode): string;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export class WorkerDeadlineSupervisor {
|
|
2
|
+
lease;
|
|
3
|
+
hardWallMs;
|
|
4
|
+
idleMs;
|
|
5
|
+
startedAtMs;
|
|
6
|
+
lastActivityMs;
|
|
7
|
+
constructor(lease, hardWallMs, idleMs, startedAtMs = performance.now()) {
|
|
8
|
+
this.lease = lease;
|
|
9
|
+
this.hardWallMs = hardWallMs;
|
|
10
|
+
this.idleMs = idleMs;
|
|
11
|
+
this.startedAtMs = startedAtMs;
|
|
12
|
+
if (!Number.isFinite(hardWallMs) || hardWallMs <= 0 || !Number.isFinite(idleMs) || idleMs <= 0) {
|
|
13
|
+
throw new Error("Sandbox watchdog limits must be positive.");
|
|
14
|
+
}
|
|
15
|
+
this.lastActivityMs = startedAtMs;
|
|
16
|
+
}
|
|
17
|
+
touch(nowMs = performance.now()) {
|
|
18
|
+
this.lastActivityMs = Math.max(this.lastActivityMs, nowMs);
|
|
19
|
+
}
|
|
20
|
+
termination(nowMs = performance.now()) {
|
|
21
|
+
if (this.lease.expired(nowMs))
|
|
22
|
+
return "sandbox_lease_expired";
|
|
23
|
+
if (nowMs - this.startedAtMs >= this.hardWallMs)
|
|
24
|
+
return "sandbox_wall_timeout";
|
|
25
|
+
if (nowMs - this.lastActivityMs >= this.idleMs)
|
|
26
|
+
return "sandbox_idle_timeout";
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function terminationMessage(code) {
|
|
31
|
+
switch (code) {
|
|
32
|
+
case "sandbox_lease_expired": return "The durable execution lease expired inside the sandbox.";
|
|
33
|
+
case "sandbox_wall_timeout": return "The sandbox reached its hard wall-clock limit.";
|
|
34
|
+
case "sandbox_idle_timeout": return "The sandbox produced no control or process activity before its idle limit.";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,228 @@
|
|
|
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 && 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
|
+
const applied = watchdog.deadline.apply(JSON.parse(value));
|
|
127
|
+
if (applied)
|
|
128
|
+
watchdog.supervisor.touch();
|
|
129
|
+
return applied;
|
|
130
|
+
})
|
|
131
|
+
.catch(() => false)
|
|
132
|
+
.finally(() => { readingUpdate = false; });
|
|
133
|
+
}, 250) : null;
|
|
134
|
+
monitor?.unref?.();
|
|
135
|
+
child.on("error", error => {
|
|
136
|
+
if (monitor)
|
|
137
|
+
clearInterval(monitor);
|
|
138
|
+
if (killTimer)
|
|
139
|
+
clearTimeout(killTimer);
|
|
140
|
+
reject(error);
|
|
141
|
+
});
|
|
142
|
+
child.on("close", code => {
|
|
143
|
+
if (monitor)
|
|
144
|
+
clearInterval(monitor);
|
|
145
|
+
if (killTimer)
|
|
146
|
+
clearTimeout(killTimer);
|
|
147
|
+
resolve({ exitCode: terminationCode ? 124 : code ?? 1, terminationCode });
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
async function runRequired(command, args) {
|
|
152
|
+
const outcome = await run(command, args);
|
|
153
|
+
if (outcome.exitCode !== 0)
|
|
154
|
+
throw new Error(`${command} failed with exit ${outcome.exitCode}.`);
|
|
155
|
+
}
|
|
156
|
+
async function capture(command, args) {
|
|
157
|
+
return await new Promise((resolve, reject) => {
|
|
158
|
+
const child = spawn(command, args, { stdio: ["ignore", "pipe", "inherit"], shell: false });
|
|
159
|
+
const chunks = [];
|
|
160
|
+
child.stdout.on("data", chunk => chunks.push(Buffer.from(chunk)));
|
|
161
|
+
child.on("error", reject);
|
|
162
|
+
child.on("close", code => code === 0 ? resolve(Buffer.concat(chunks).toString("utf8")) : reject(new Error(`${command} failed with exit ${code}.`)));
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
async function exists(target) { return fs.stat(target).then(() => true).catch(() => false); }
|
|
166
|
+
async function installServiceEnvironment(attemptId) {
|
|
167
|
+
const attemptHex = attemptId.replace(/-/gu, "").toLowerCase();
|
|
168
|
+
const postgresPassword = await readServiceCredential("postgres-password");
|
|
169
|
+
if (postgresPassword)
|
|
170
|
+
process.env.FORGE_TEST_DATABASE = `postgresql://forge:${encodeURIComponent(postgresPassword)}@forge-${attemptHex}-postgres:5432/forge_test`;
|
|
171
|
+
const redisPassword = await readServiceCredential("redis-password");
|
|
172
|
+
if (redisPassword)
|
|
173
|
+
process.env.FORGE_TEST_REDIS = `redis://:${encodeURIComponent(redisPassword)}@forge-${attemptHex}-redis:6379/0`;
|
|
174
|
+
const browserToken = await readServiceCredential("browser-token");
|
|
175
|
+
if (browserToken)
|
|
176
|
+
process.env.FORGE_BROWSER_TOKEN = browserToken;
|
|
177
|
+
}
|
|
178
|
+
async function readServiceCredential(name) {
|
|
179
|
+
const target = `/run/forge-secrets/${name}`;
|
|
180
|
+
const stat = await fs.lstat(target).catch(() => null);
|
|
181
|
+
if (!stat)
|
|
182
|
+
return null;
|
|
183
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size < 32 || stat.size > 256) {
|
|
184
|
+
throw new Error(`Invalid ${name} service credential file.`);
|
|
185
|
+
}
|
|
186
|
+
return (await fs.readFile(target, "utf8")).trim();
|
|
187
|
+
}
|
|
188
|
+
function requiredId(value, label) {
|
|
189
|
+
if (typeof value !== "string" || !/^[0-9a-f-]{32,36}$/i.test(value))
|
|
190
|
+
throw new Error(`Invalid ${label}.`);
|
|
191
|
+
return value;
|
|
192
|
+
}
|
|
193
|
+
function requiredString(value, label) {
|
|
194
|
+
if (typeof value !== "string" || value.length === 0)
|
|
195
|
+
throw new Error(`Invalid ${label}.`);
|
|
196
|
+
return value;
|
|
197
|
+
}
|
|
198
|
+
function requiredLimits(value) {
|
|
199
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
200
|
+
throw new Error("Invalid sandbox watchdog limits.");
|
|
201
|
+
const limits = value;
|
|
202
|
+
if (typeof limits.hardWallMs !== "number" || !Number.isSafeInteger(limits.hardWallMs) || limits.hardWallMs < 60_000 || limits.hardWallMs > 28_800_000
|
|
203
|
+
|| typeof limits.idleMs !== "number" || !Number.isSafeInteger(limits.idleMs) || limits.idleMs < 60_000 || limits.idleMs > limits.hardWallMs) {
|
|
204
|
+
throw new Error("Invalid sandbox watchdog limits.");
|
|
205
|
+
}
|
|
206
|
+
return { hardWallMs: limits.hardWallMs, idleMs: limits.idleMs };
|
|
207
|
+
}
|
|
208
|
+
async function writeTerminationResult(resultPath, code) {
|
|
209
|
+
const message = terminationMessage(code);
|
|
210
|
+
const result = {
|
|
211
|
+
schemaVersion: 1,
|
|
212
|
+
exitCode: 124,
|
|
213
|
+
summary: message,
|
|
214
|
+
events: [{ role: "runner", kind: "error", content: message, eventType: code }]
|
|
215
|
+
};
|
|
216
|
+
const temporary = `${resultPath}.timeout.tmp`;
|
|
217
|
+
await fs.writeFile(temporary, `${JSON.stringify(result)}\n`, { mode: 0o600 });
|
|
218
|
+
await fs.rename(temporary, resultPath);
|
|
219
|
+
}
|
|
220
|
+
function emitTerminationEvent(code) {
|
|
221
|
+
const message = terminationMessage(code);
|
|
222
|
+
process.stdout.write(`${JSON.stringify({ type: "runner_event", event: { role: "runner", kind: "error", content: message, eventType: code } })}\n`);
|
|
223
|
+
process.stderr.write(`${code}: ${message}\n`);
|
|
224
|
+
}
|
|
225
|
+
main().then(code => { process.exitCode = code; }).catch(error => {
|
|
226
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
227
|
+
process.exitCode = 1;
|
|
228
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
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 { normalizeBranchName } from "../worktree/worktree.service.js";
|
|
6
|
+
export class SandboxWorkspaceService {
|
|
7
|
+
workspace;
|
|
8
|
+
bundlePath;
|
|
9
|
+
inputCommitSha;
|
|
10
|
+
git;
|
|
11
|
+
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()) {
|
|
12
|
+
this.workspace = workspace;
|
|
13
|
+
this.bundlePath = bundlePath;
|
|
14
|
+
this.inputCommitSha = inputCommitSha;
|
|
15
|
+
this.git = git;
|
|
16
|
+
}
|
|
17
|
+
async prepare(request) {
|
|
18
|
+
if (!/^[0-9a-f]{40,64}$/i.test(this.inputCommitSha))
|
|
19
|
+
throw new Error("Sandbox input commit SHA is missing or invalid.");
|
|
20
|
+
const stat = await fs.stat(this.workspace).catch(() => null);
|
|
21
|
+
if (!stat?.isDirectory())
|
|
22
|
+
throw new Error("Sandbox workspace volume is unavailable.");
|
|
23
|
+
const entries = await fs.readdir(this.workspace);
|
|
24
|
+
if (entries.length > 0)
|
|
25
|
+
throw new Error("Sandbox workspace must be empty before bundle import.");
|
|
26
|
+
await this.git.run(path.dirname(this.workspace), ["clone", "--no-checkout", "--no-local", this.bundlePath, this.workspace]);
|
|
27
|
+
const actualSha = (await this.git.run(this.workspace, ["rev-parse", "--verify", `${this.inputCommitSha}^{commit}`])).trim();
|
|
28
|
+
if (actualSha.toLowerCase() !== this.inputCommitSha.toLowerCase())
|
|
29
|
+
throw new Error("Imported bundle does not contain the exact recorded input commit.");
|
|
30
|
+
const branchName = normalizeBranchName(request.worktreeKey, `forge/${request.workItem.id.replace(/-/g, "")}`);
|
|
31
|
+
await this.git.run(this.workspace, ["checkout", "-B", branchName, this.inputCommitSha]);
|
|
32
|
+
await this.git.run(this.workspace, ["config", "user.email", "forge-agent@forge.local"]);
|
|
33
|
+
await this.git.run(this.workspace, ["config", "user.name", "Forge Agent"]);
|
|
34
|
+
await this.git.run(this.workspace, ["config", "core.hooksPath", "/dev/null"]);
|
|
35
|
+
const artifacts = filterArtifactsForLane(request.artifacts, request.lane, request.phase);
|
|
36
|
+
if (artifacts.length > 0)
|
|
37
|
+
await injectWorktreeArtifacts(this.workspace, artifacts);
|
|
38
|
+
return {
|
|
39
|
+
repoPath: this.workspace,
|
|
40
|
+
worktreePath: this.workspace,
|
|
41
|
+
branchName,
|
|
42
|
+
baseBranch: request.baseBranch,
|
|
43
|
+
cleanupWorktrees: false
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
async release(_handle) {
|
|
47
|
+
// The host supervisor owns the workspace volume lifecycle.
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -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
|
}
|
|
@@ -38,5 +38,7 @@ export declare function qaBrowserInstruction(dispatch: RunOnceDispatchPacket): s
|
|
|
38
38
|
export declare function storyQaInstruction(dispatch: RunOnceDispatchPacket): string;
|
|
39
39
|
export declare function buildQaPrompt(dispatch: RunOnceDispatchPacket, laneSpec: string): string;
|
|
40
40
|
export declare function buildConflictResolvePrompt(dispatch: RunOnceDispatchPacket, branchName: string): string;
|
|
41
|
+
export declare function buildPullRequestCheckRepairPrompt(dispatch: RunOnceDispatchPacket, branchName: string): string;
|
|
41
42
|
export declare function buildPullRequestCommentsPrompt(dispatch: RunOnceDispatchPacket, branchName: string): string;
|
|
43
|
+
export declare function isPullRequestCheckRepairDispatch(dispatch: RunOnceDispatchPacket): boolean;
|
|
42
44
|
export declare function isPullRequestCommentsDispatch(dispatch: RunOnceDispatchPacket): boolean;
|
|
@@ -467,6 +467,33 @@ Keep the original work item intent intact:
|
|
|
467
467
|
|
|
468
468
|
Finish with a concise summary of the conflicts resolved and verification performed.`;
|
|
469
469
|
}
|
|
470
|
+
export function buildPullRequestCheckRepairPrompt(dispatch, branchName) {
|
|
471
|
+
const pr = dispatch.pullRequest;
|
|
472
|
+
const targetBranch = pr?.targetBranch || dispatch.cycleRun.requestedBase || dispatch.repo.defaultBranch || "main";
|
|
473
|
+
const prLocator = pr
|
|
474
|
+
? `PR #${pr.number}${pr.url ? ` (${pr.url})` : ""}`
|
|
475
|
+
: "the open pull request for this work item";
|
|
476
|
+
return `You are Forge's CI Repair lane running locally in a prepared git worktree.
|
|
477
|
+
|
|
478
|
+
The current branch is the pull request branch ${branchName}. Repair failed required checks on ${prLocator}.
|
|
479
|
+
PR context:
|
|
480
|
+
- Provider: ${pr?.provider || "not provided"}
|
|
481
|
+
- Source branch: ${pr?.sourceBranch || branchName}
|
|
482
|
+
- Target branch: ${targetBranch}
|
|
483
|
+
- Check state: ${pr?.checkState || "unknown"}
|
|
484
|
+
- Required checks: ${pr?.requiredChecksPassed ?? "?"}/${pr?.requiredChecksTotal ?? "?"}
|
|
485
|
+
|
|
486
|
+
This is not a merge-conflict resolution task. Only fetch/merge/rebase ${targetBranch} if needed to inspect the branch, reproduce the failed check, or handle an explicit stale-branch/conflict blocker. The primary objective is to repair CI.
|
|
487
|
+
|
|
488
|
+
Load and follow .forge/commands/operational/ci-repair-prompt.md, including its CI repair phase gate and validation skill. Inspect the failed provider check logs, identify the exact failed workflow job/step, make the smallest scoped repair, and before pushing run the failed job's local workflow-equivalent gate, including pre-test guard/drift steps when available.
|
|
489
|
+
|
|
490
|
+
Keep the original work item intent intact:
|
|
491
|
+
- Work item: ${dispatch.workItem.id}
|
|
492
|
+
- Title: ${dispatch.workItem.title}
|
|
493
|
+
- Acceptance criteria: ${dispatch.workItem.acceptanceCriteria || "Not specified"}
|
|
494
|
+
|
|
495
|
+
Finish with a concise summary of failing checks, changed files, validation commands, pushed commit status, and any remaining provider checks still pending.`;
|
|
496
|
+
}
|
|
470
497
|
export function buildPullRequestCommentsPrompt(dispatch, branchName) {
|
|
471
498
|
const pr = dispatch.pullRequest;
|
|
472
499
|
const targetBranch = pr?.targetBranch || dispatch.cycleRun.requestedBase || dispatch.repo.defaultBranch || "main";
|
|
@@ -492,6 +519,10 @@ Keep the original work item intent intact:
|
|
|
492
519
|
|
|
493
520
|
Finish with a concise summary of comments addressed and verification performed.`;
|
|
494
521
|
}
|
|
522
|
+
export function isPullRequestCheckRepairDispatch(dispatch) {
|
|
523
|
+
return dispatch.cycleRun.kind.trim().toLowerCase() === "pr_checks"
|
|
524
|
+
|| dispatch.execution.role.trim().toLowerCase() === "ci_repair";
|
|
525
|
+
}
|
|
495
526
|
export function isPullRequestCommentsDispatch(dispatch) {
|
|
496
527
|
return dispatch.cycleRun.kind.trim().toLowerCase() === "pr_comments";
|
|
497
528
|
}
|
|
@@ -13,6 +13,7 @@ export declare class RoutingProviderCliService implements ProviderCliService {
|
|
|
13
13
|
constructor(realProvider?: ProviderCliService, deterministicProvider?: ProviderCliService);
|
|
14
14
|
runImplementation(context: ProviderCliRunContext): Promise<ProviderCliRunResult>;
|
|
15
15
|
runArchitect(context: ProviderCliRunContext): Promise<ProviderCliRunResult>;
|
|
16
|
+
runCiRepair(context: ProviderCliRunContext): Promise<ProviderCliRunResult>;
|
|
16
17
|
runConflictResolve(context: ProviderCliRunContext): Promise<ProviderCliRunResult>;
|
|
17
18
|
runQa(context: ProviderCliRunContext): Promise<ProviderCliRunResult>;
|
|
18
19
|
runDraft(context: ProviderCliDraftContext): Promise<ProviderCliRunResult>;
|
|
@@ -25,5 +26,7 @@ export declare class ProcessBackedProviderCliService implements ProviderCliServi
|
|
|
25
26
|
runImplementation(context: ProviderCliRunContext): Promise<ProviderCliRunResult>;
|
|
26
27
|
runArchitect(context: ProviderCliRunContext): Promise<ProviderCliRunResult>;
|
|
27
28
|
runQa(context: ProviderCliRunContext): Promise<ProviderCliRunResult>;
|
|
29
|
+
runCiRepair(context: ProviderCliRunContext): Promise<ProviderCliRunResult>;
|
|
28
30
|
runConflictResolve(context: ProviderCliRunContext): Promise<ProviderCliRunResult>;
|
|
31
|
+
private runPullRequestRepair;
|
|
29
32
|
}
|