@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.
Files changed (51) hide show
  1. package/README.md +44 -0
  2. package/dist/bridge/bridge.base.d.ts +5 -1
  3. package/dist/bridge/bridge.service.d.ts +36 -2
  4. package/dist/bridge/bridge.service.js +564 -14
  5. package/dist/bridge/bridge.types.d.ts +110 -1
  6. package/dist/cli/cli.help.js +12 -0
  7. package/dist/cli/cli.service.d.ts +12 -0
  8. package/dist/cli/cli.service.js +168 -1
  9. package/dist/sandbox/command-runner.d.ts +4 -0
  10. package/dist/sandbox/command-runner.js +47 -0
  11. package/dist/sandbox/contract.d.ts +5 -0
  12. package/dist/sandbox/contract.js +276 -0
  13. package/dist/sandbox/event-budget.d.ts +11 -0
  14. package/dist/sandbox/event-budget.js +27 -0
  15. package/dist/sandbox/image-policy.d.ts +33 -0
  16. package/dist/sandbox/image-policy.js +245 -0
  17. package/dist/sandbox/index.d.ts +5 -0
  18. package/dist/sandbox/index.js +5 -0
  19. package/dist/sandbox/lease-watchdog.d.ts +18 -0
  20. package/dist/sandbox/lease-watchdog.js +67 -0
  21. package/dist/sandbox/preflight.d.ts +36 -0
  22. package/dist/sandbox/preflight.js +239 -0
  23. package/dist/sandbox/process.d.ts +20 -0
  24. package/dist/sandbox/process.js +124 -0
  25. package/dist/sandbox/provider-auth/codex.d.ts +26 -0
  26. package/dist/sandbox/provider-auth/codex.js +212 -0
  27. package/dist/sandbox/repository-lock.d.ts +5 -0
  28. package/dist/sandbox/repository-lock.js +83 -0
  29. package/dist/sandbox/runtime.d.ts +218 -0
  30. package/dist/sandbox/runtime.js +1672 -0
  31. package/dist/sandbox/sandbox.types.d.ts +83 -0
  32. package/dist/sandbox/sandbox.types.js +1 -0
  33. package/dist/sandbox/worker-supervisor.d.ts +13 -0
  34. package/dist/sandbox/worker-supervisor.js +36 -0
  35. package/dist/sandbox/worker.d.ts +1 -0
  36. package/dist/sandbox/worker.js +228 -0
  37. package/dist/sandbox/workspace.service.d.ts +12 -0
  38. package/dist/sandbox/workspace.service.js +49 -0
  39. package/dist/session/provider-cli/provider-cli.base.d.ts +1 -0
  40. package/dist/session/provider-cli/provider-cli.deterministic.d.ts +2 -0
  41. package/dist/session/provider-cli/provider-cli.deterministic.js +20 -9
  42. package/dist/session/provider-cli/provider-cli.prompts.d.ts +2 -0
  43. package/dist/session/provider-cli/provider-cli.prompts.js +31 -0
  44. package/dist/session/provider-cli/provider-cli.service.d.ts +3 -0
  45. package/dist/session/provider-cli/provider-cli.service.js +31 -13
  46. package/dist/session/session.service.js +14 -7
  47. package/dist/session/session.types.d.ts +41 -1
  48. package/dist/session/session.types.js +119 -2
  49. package/dist/version.d.ts +1 -1
  50. package/dist/version.js +1 -1
  51. package/package.json +4 -2
@@ -0,0 +1,67 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ export function createLeaseUpdate(dispatchAttemptId, fencingToken, expiresAt, controlKey) {
3
+ return {
4
+ schemaVersion: 1,
5
+ dispatchAttemptId,
6
+ fencingToken,
7
+ expiresAt,
8
+ mac: leaseMac(dispatchAttemptId, fencingToken, expiresAt, controlKey)
9
+ };
10
+ }
11
+ export class MonotonicLeaseDeadline {
12
+ dispatchAttemptId;
13
+ fencingToken;
14
+ controlKey;
15
+ expiresAtMs;
16
+ deadlineMs;
17
+ constructor(dispatchAttemptId, fencingToken, initialExpiresAt, controlKey, wallNowMs = Date.now(), monotonicNowMs = performance.now()) {
18
+ this.dispatchAttemptId = dispatchAttemptId;
19
+ this.fencingToken = fencingToken;
20
+ this.controlKey = controlKey;
21
+ this.expiresAtMs = parsedExpiry(initialExpiresAt);
22
+ this.deadlineMs = monotonicNowMs + Math.max(0, this.expiresAtMs - wallNowMs);
23
+ }
24
+ apply(update, wallNowMs = Date.now(), monotonicNowMs = performance.now()) {
25
+ if (!isSignedLeaseUpdate(update)
26
+ || update.dispatchAttemptId !== this.dispatchAttemptId
27
+ || update.fencingToken !== this.fencingToken
28
+ || parsedExpiry(update.expiresAt) <= this.expiresAtMs)
29
+ return false;
30
+ const expected = leaseMac(update.dispatchAttemptId, update.fencingToken, update.expiresAt, this.controlKey);
31
+ if (!safeEqual(expected, update.mac))
32
+ return false;
33
+ this.expiresAtMs = parsedExpiry(update.expiresAt);
34
+ this.deadlineMs = monotonicNowMs + Math.max(0, this.expiresAtMs - wallNowMs);
35
+ return true;
36
+ }
37
+ expired(monotonicNowMs = performance.now()) {
38
+ return monotonicNowMs >= this.deadlineMs;
39
+ }
40
+ }
41
+ function leaseMac(dispatchAttemptId, fencingToken, expiresAt, controlKey) {
42
+ return createHmac("sha256", controlKey)
43
+ .update(`${dispatchAttemptId}\n${fencingToken}\n${expiresAt}`, "utf8")
44
+ .digest("base64url");
45
+ }
46
+ function safeEqual(left, right) {
47
+ const leftBytes = Buffer.from(left, "utf8");
48
+ const rightBytes = Buffer.from(right, "utf8");
49
+ return leftBytes.length === rightBytes.length && timingSafeEqual(leftBytes, rightBytes);
50
+ }
51
+ function parsedExpiry(value) {
52
+ const parsed = Date.parse(value);
53
+ if (!Number.isFinite(parsed))
54
+ throw new Error("Lease expiry is invalid.");
55
+ return parsed;
56
+ }
57
+ function isSignedLeaseUpdate(value) {
58
+ if (!value || typeof value !== "object" || Array.isArray(value))
59
+ return false;
60
+ const update = value;
61
+ return update.schemaVersion === 1
62
+ && typeof update.dispatchAttemptId === "string"
63
+ && typeof update.fencingToken === "string"
64
+ && typeof update.expiresAt === "string"
65
+ && Number.isFinite(Date.parse(update.expiresAt))
66
+ && typeof update.mac === "string";
67
+ }
@@ -0,0 +1,36 @@
1
+ import type { CommandRunner, SandboxPreflightResult } from "./sandbox.types.js";
2
+ export interface DockerPreflightOptions {
3
+ readonly dockerExecutable?: string;
4
+ readonly dockerContext?: string;
5
+ readonly stateRoot?: string;
6
+ readonly runnerInstanceId?: string;
7
+ readonly maxConcurrentSandboxes?: number;
8
+ readonly diskHighWatermarkPercent?: number;
9
+ readonly diskLowWatermarkPercent?: number;
10
+ readonly minimumWorkspaceBytes?: number;
11
+ readonly requireRootless?: boolean;
12
+ }
13
+ export declare class DockerPreflight {
14
+ private readonly commands;
15
+ private readonly cacheMs;
16
+ private generation;
17
+ private cached;
18
+ private cacheAt;
19
+ private readonly options;
20
+ private readonly docker;
21
+ private readonly ownership;
22
+ private diskPressureLatched;
23
+ constructor(options?: DockerPreflightOptions | string, commands?: CommandRunner, cacheMs?: number);
24
+ current(): SandboxPreflightResult | null;
25
+ refresh(): Promise<SandboxPreflightResult>;
26
+ release(): Promise<void>;
27
+ run(force?: boolean): Promise<SandboxPreflightResult>;
28
+ private inspect;
29
+ }
30
+ export declare function evaluateDiskPressure(availableBytes: number, totalBytes: number, minimumAvailableBytes: number, highWatermarkPercent: number, lowWatermarkPercent: number, pressureLatched: boolean): boolean;
31
+ export declare function storagePolicyAvailable(storageQuota: boolean, environment?: NodeJS.ProcessEnv): boolean;
32
+ export declare class DockerContextOwnershipLock {
33
+ private lockPath;
34
+ acquire(contextName?: string): Promise<void>;
35
+ release(): Promise<void>;
36
+ }
@@ -0,0 +1,239 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { createHash } from "node:crypto";
5
+ import { BoundedCommandRunner } from "./command-runner.js";
6
+ const minimumWorkspaceBytes = 10 * 1024 * 1024 * 1024;
7
+ export class DockerPreflight {
8
+ commands;
9
+ cacheMs;
10
+ generation = 0;
11
+ cached = null;
12
+ cacheAt = 0;
13
+ options;
14
+ docker;
15
+ ownership = new DockerContextOwnershipLock();
16
+ diskPressureLatched = false;
17
+ constructor(options = {}, commands = new BoundedCommandRunner(), cacheMs = 30_000) {
18
+ this.commands = commands;
19
+ this.cacheMs = cacheMs;
20
+ this.options = typeof options === "string" ? { dockerExecutable: options } : options;
21
+ this.docker = this.options.dockerExecutable ?? process.env.FORGE_DOCKER_EXECUTABLE ?? "docker";
22
+ }
23
+ current() {
24
+ return this.cached;
25
+ }
26
+ async refresh() {
27
+ await fs.mkdir(this.options.stateRoot ?? path.join(os.homedir(), ".forge", "bridge", "sandboxes"), { recursive: true, mode: 0o700 });
28
+ const result = await this.run(true);
29
+ if (result.healthy || result.unavailableCode === "docker_disk_low") {
30
+ await this.ownership.acquire(this.options.dockerContext);
31
+ }
32
+ return result;
33
+ }
34
+ async release() {
35
+ await this.ownership.release();
36
+ }
37
+ async run(force = false) {
38
+ if (!force && this.cached && Date.now() - this.cacheAt < this.cacheMs)
39
+ return this.cached;
40
+ this.generation += 1;
41
+ const result = await this.inspect().catch((error) => unavailable(this.generation, "docker_unreachable", error instanceof Error ? error.message : String(error)));
42
+ this.cached = result;
43
+ this.cacheAt = Date.now();
44
+ return result;
45
+ }
46
+ async inspect() {
47
+ if (!new Set(["linux", "darwin", "win32"]).has(process.platform)) {
48
+ return unavailable(this.generation, "platform_unsupported", `Docker sandboxes are not supported on ${process.platform}.`);
49
+ }
50
+ const dockerArgs = this.options.dockerContext ? ["--context", this.options.dockerContext] : [];
51
+ const infoResult = await this.commands.run(this.docker, [...dockerArgs, "info", "--format", "{{json .}}"], { timeoutMs: 10_000, maxOutputBytes: 512 * 1024 });
52
+ if (infoResult.exitCode !== 0)
53
+ return unavailable(this.generation, "docker_unreachable", singleLine(infoResult.stderr || infoResult.stdout || "Docker daemon is unreachable."));
54
+ const info = parseInfo(infoResult.stdout);
55
+ if (String(info.OSType ?? "").toLowerCase() !== "linux")
56
+ return unavailable(this.generation, "docker_linux_required", "Forge sandbox images require a Linux Docker daemon.");
57
+ const securityOptions = strings(info.SecurityOptions).map((value) => value.toLowerCase());
58
+ const rootless = securityOptions.some((value) => value.includes("rootless"));
59
+ const userNamespaces = rootless || securityOptions.some((value) => value.includes("userns"));
60
+ const cgroupV2 = String(info.CgroupVersion ?? "") === "2";
61
+ const seccomp = securityOptions.some((value) => value.includes("seccomp"));
62
+ const lsm = securityOptions.some((value) => value.includes("apparmor") || value.includes("selinux"));
63
+ const storageDriver = String(info.Driver ?? "").toLowerCase();
64
+ const storageQuota = storageDriver === "overlay2" && Boolean(info.DriverStatus?.some?.((entry) => JSON.stringify(entry).toLowerCase().includes("project quota")));
65
+ const desktopDeveloperMode = process.platform !== "linux" && process.env.FORGE_ALLOW_DOCKER_DESKTOP_SANDBOX === "true";
66
+ const requireRootless = this.options.requireRootless ?? process.env.FORGE_SANDBOX_REQUIRE_ROOTLESS !== "false";
67
+ if (!rootless && !desktopDeveloperMode && requireRootless) {
68
+ return unavailable(this.generation, "docker_rootless_required", "Docker daemon is reachable but is not running rootless.", {
69
+ rootless, userNamespaces, cgroupV2, seccomp, lsm, storageQuota, storagePolicy: false, compose: false, internalNetwork: true, buildkit: false, imagePolicy: false, securityProfile: false, egressProxy: false
70
+ });
71
+ }
72
+ const composeResult = await this.commands.run(this.docker, [...dockerArgs, "compose", "version", "--short"], { timeoutMs: 5_000, maxOutputBytes: 16 * 1024 }).catch(() => null);
73
+ const compose = composeResult?.exitCode === 0;
74
+ const buildxResult = await this.commands.run(this.docker, [...dockerArgs, "buildx", "version"], { timeoutMs: 5_000, maxOutputBytes: 16 * 1024 }).catch(() => null);
75
+ const buildxHelp = await this.commands.run(this.docker, [...dockerArgs, "buildx", "build", "--help"], { timeoutMs: 5_000, maxOutputBytes: 256 * 1024 }).catch(() => null);
76
+ const buildxPruneHelp = await this.commands.run(this.docker, [...dockerArgs, "buildx", "prune", "--help"], { timeoutMs: 5_000, maxOutputBytes: 256 * 1024 }).catch(() => null);
77
+ const containerUpdateHelp = await this.commands.run(this.docker, [...dockerArgs, "container", "update", "--help"], { timeoutMs: 5_000, maxOutputBytes: 256 * 1024 }).catch(() => null);
78
+ const buildkit = buildxResult?.exitCode === 0
79
+ && buildxHelp?.exitCode === 0
80
+ && ["--resource", "--ulimit", "--shm-size"].every(flag => buildxHelp.stdout.includes(flag))
81
+ && buildxPruneHelp?.exitCode === 0
82
+ && ["--max-used-space", "--min-free-space", "--reserved-space"].every(flag => buildxPruneHelp.stdout.includes(flag))
83
+ && containerUpdateHelp?.exitCode === 0
84
+ && containerUpdateHelp.stdout.includes("--pids-limit");
85
+ const imagePolicy = process.env.FORGE_SANDBOX_IMAGE_POLICY_ENFORCED === "true"
86
+ && Boolean(process.env.FORGE_SANDBOX_SBOM_EXECUTABLE?.trim())
87
+ && Boolean(process.env.FORGE_SANDBOX_VULNERABILITY_EXECUTABLE?.trim())
88
+ && Boolean(process.env.FORGE_SANDBOX_PROVENANCE_SIGNING_KEY?.trim())
89
+ && Boolean(process.env.FORGE_SANDBOX_SIGNATURE_EXECUTABLE?.trim())
90
+ && Boolean(process.env.FORGE_SANDBOX_SIGNATURE_PUBLIC_KEY?.trim())
91
+ && process.env.FORGE_SANDBOX_BUILD_EGRESS_ENFORCED === "true";
92
+ const securityProfile = Boolean(process.env.FORGE_SANDBOX_SECCOMP_PROFILE?.trim())
93
+ && Boolean(process.env.FORGE_SANDBOX_LSM_SECURITY_OPT?.trim())
94
+ && seccomp && lsm;
95
+ const egressProxy = process.env.FORGE_SANDBOX_EGRESS_ENFORCED === "true"
96
+ && /@sha256:[0-9a-f]{64}$/iu.test(process.env.FORGE_SANDBOX_EGRESS_PROXY_IMAGE?.trim() ?? "");
97
+ const storagePolicy = storagePolicyAvailable(storageQuota);
98
+ const configuredStateRoot = this.options.stateRoot ?? path.join(os.homedir(), ".forge", "bridge", "sandboxes");
99
+ const dockerRoot = process.platform === "linux" && typeof info.DockerRootDir === "string" && path.isAbsolute(info.DockerRootDir)
100
+ ? info.DockerRootDir
101
+ : configuredStateRoot;
102
+ const disk = await diskSpace(dockerRoot);
103
+ const requiredWorkspaceBytes = this.options.minimumWorkspaceBytes ?? minimumWorkspaceBytes;
104
+ const highWatermark = Math.min(99, Math.max(1, this.options.diskHighWatermarkPercent ?? 90));
105
+ const lowWatermark = Math.min(highWatermark - 1, Math.max(0, this.options.diskLowWatermarkPercent ?? highWatermark - 10));
106
+ const diskLow = evaluateDiskPressure(disk.available, disk.total, requiredWorkspaceBytes, highWatermark, lowWatermark, this.diskPressureLatched);
107
+ this.diskPressureLatched = diskLow;
108
+ const availableMemoryBytes = os.freemem();
109
+ const cpuCount = Math.max(1, os.availableParallelism?.() ?? os.cpus().length);
110
+ const availableCpuMillis = Math.max(0, (cpuCount - 1) * 1000);
111
+ const memorySlots = Math.max(0, Math.floor((availableMemoryBytes - 2 * 1024 ** 3) / (4 * 1024 ** 3)));
112
+ const cpuSlots = Math.max(0, Math.floor((cpuCount - 1) / 2));
113
+ const configuredMax = this.options.maxConcurrentSandboxes ?? positiveInteger(process.env.FORGE_MAX_CONCURRENT_SANDBOXES, 4);
114
+ const maxConcurrentSandboxes = diskLow ? 0 : Math.max(1, Math.min(4, configuredMax, Math.max(1, cpuSlots), Math.max(1, memorySlots)));
115
+ const features = { rootless, userNamespaces, cgroupV2, seccomp, lsm, storageQuota, storagePolicy, compose, internalNetwork: true, buildkit, imagePolicy, securityProfile, egressProxy };
116
+ return {
117
+ available: !diskLow,
118
+ healthy: !diskLow,
119
+ generation: this.generation,
120
+ runtimeVersion: `docker/${String(info.ServerVersion ?? "unknown")}${rootless ? "-rootless" : "-desktop"}`,
121
+ maxConcurrentSandboxes,
122
+ availableMemoryBytes,
123
+ availableCpuMillis,
124
+ availableWorkspaceBytes: disk.available,
125
+ diskLow,
126
+ unavailableCode: diskLow ? "docker_disk_low" : null,
127
+ unavailableReason: diskLow ? `Docker workspace has only ${disk.available} bytes available.` : null,
128
+ features,
129
+ };
130
+ }
131
+ }
132
+ export function evaluateDiskPressure(availableBytes, totalBytes, minimumAvailableBytes, highWatermarkPercent, lowWatermarkPercent, pressureLatched) {
133
+ if (availableBytes < minimumAvailableBytes)
134
+ return true;
135
+ if (!(totalBytes > 0))
136
+ return true;
137
+ const usedPercent = (1 - availableBytes / totalBytes) * 100;
138
+ if (usedPercent >= highWatermarkPercent)
139
+ return true;
140
+ return pressureLatched && usedPercent > lowWatermarkPercent;
141
+ }
142
+ export function storagePolicyAvailable(storageQuota, environment = process.env) {
143
+ return storageQuota || environment.FORGE_SANDBOX_DEDICATED_DISK === "true";
144
+ }
145
+ export class DockerContextOwnershipLock {
146
+ lockPath = null;
147
+ async acquire(contextName) {
148
+ if (this.lockPath)
149
+ return;
150
+ contextName = contextName?.trim() || process.env.DOCKER_CONTEXT?.trim() || process.env.DOCKER_HOST?.trim() || "default";
151
+ const key = createHash("sha256").update(contextName).digest("hex").slice(0, 24);
152
+ const lockPath = path.join(os.tmpdir(), `forge-docker-context-${key}.lock`);
153
+ await fs.mkdir(path.dirname(lockPath), { recursive: true });
154
+ try {
155
+ const handle = await fs.open(lockPath, "wx", 0o600);
156
+ await handle.writeFile(JSON.stringify({ pid: process.pid, contextName, acquiredAt: new Date().toISOString() }));
157
+ await handle.sync();
158
+ await handle.close();
159
+ }
160
+ catch (error) {
161
+ const owner = await readLock(lockPath);
162
+ if (owner?.pid && !processExists(owner.pid)) {
163
+ await fs.rm(lockPath, { force: true });
164
+ return await this.acquire(contextName);
165
+ }
166
+ throw new Error(`docker_context_owned: Docker context '${contextName}' is already owned by Forge runner pid ${owner?.pid ?? "unknown"}.`, { cause: error });
167
+ }
168
+ this.lockPath = lockPath;
169
+ }
170
+ async release() {
171
+ if (!this.lockPath)
172
+ return;
173
+ await fs.rm(this.lockPath, { force: true });
174
+ this.lockPath = null;
175
+ }
176
+ }
177
+ function parseInfo(stdout) {
178
+ try {
179
+ const value = JSON.parse(stdout.trim());
180
+ if (!value || typeof value !== "object" || Array.isArray(value))
181
+ throw new Error("not an object");
182
+ return value;
183
+ }
184
+ catch (error) {
185
+ throw new Error("Docker returned an invalid info document.", { cause: error });
186
+ }
187
+ }
188
+ function unavailable(generation, code, reason, features = {
189
+ rootless: false, userNamespaces: false, cgroupV2: false, seccomp: false, lsm: false, storageQuota: false, storagePolicy: false, compose: false, internalNetwork: false, buildkit: false, imagePolicy: false, securityProfile: false, egressProxy: false
190
+ }) {
191
+ return {
192
+ available: false,
193
+ healthy: false,
194
+ generation,
195
+ runtimeVersion: null,
196
+ maxConcurrentSandboxes: 0,
197
+ availableMemoryBytes: os.freemem(),
198
+ availableCpuMillis: Math.max(0, (os.availableParallelism?.() ?? os.cpus().length) * 1000),
199
+ availableWorkspaceBytes: 0,
200
+ diskLow: code === "docker_disk_low",
201
+ unavailableCode: code,
202
+ unavailableReason: singleLine(reason),
203
+ features,
204
+ };
205
+ }
206
+ async function diskSpace(inputPath) {
207
+ const stats = await fs.statfs(inputPath, { bigint: true }).catch(() => null);
208
+ if (!stats)
209
+ return { available: 0, total: 0 };
210
+ return {
211
+ available: safeNumber(stats.bavail * stats.bsize),
212
+ total: safeNumber(stats.blocks * stats.bsize),
213
+ };
214
+ }
215
+ function safeNumber(value) {
216
+ return Number(value > BigInt(Number.MAX_SAFE_INTEGER) ? BigInt(Number.MAX_SAFE_INTEGER) : value);
217
+ }
218
+ function strings(value) {
219
+ return Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
220
+ }
221
+ function positiveInteger(value, fallback) {
222
+ const parsed = Number(value);
223
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
224
+ }
225
+ function singleLine(value) {
226
+ return value.replace(/[\r\n\t]+/gu, " ").trim().slice(0, 1000);
227
+ }
228
+ async function readLock(lockPath) {
229
+ return await fs.readFile(lockPath, "utf8").then((value) => JSON.parse(value)).catch(() => null);
230
+ }
231
+ function processExists(pid) {
232
+ try {
233
+ process.kill(pid, 0);
234
+ return true;
235
+ }
236
+ catch {
237
+ return false;
238
+ }
239
+ }
@@ -0,0 +1,20 @@
1
+ export interface SandboxProcessRequest {
2
+ readonly command: string;
3
+ readonly args: readonly string[];
4
+ readonly cwd?: string;
5
+ readonly input?: string | Buffer;
6
+ readonly timeoutMs?: number;
7
+ readonly maxOutputBytes?: number;
8
+ readonly signal?: AbortSignal;
9
+ readonly env?: NodeJS.ProcessEnv;
10
+ readonly onStdoutLine?: (line: string) => void | Promise<void>;
11
+ readonly terminateOnOutputLimit?: boolean;
12
+ }
13
+ export interface SandboxProcessResult {
14
+ readonly exitCode: number;
15
+ readonly stdout: string;
16
+ readonly stderr: string;
17
+ readonly truncated: boolean;
18
+ }
19
+ export declare function runSandboxProcess(request: SandboxProcessRequest): Promise<SandboxProcessResult>;
20
+ export declare function requireSandboxProcess(request: SandboxProcessRequest): Promise<SandboxProcessResult>;
@@ -0,0 +1,124 @@
1
+ import { spawn } from "node:child_process";
2
+ import { StringDecoder } from "node:string_decoder";
3
+ export async function runSandboxProcess(request) {
4
+ const limit = Math.max(1024, request.maxOutputBytes ?? 1024 * 1024);
5
+ return await new Promise((resolve, reject) => {
6
+ const child = spawn(request.command, [...request.args], {
7
+ cwd: request.cwd,
8
+ env: request.env,
9
+ shell: false,
10
+ windowsHide: true,
11
+ stdio: [request.input === undefined ? "ignore" : "pipe", "pipe", "pipe"]
12
+ });
13
+ let stdout = Buffer.alloc(0);
14
+ let stderr = Buffer.alloc(0);
15
+ let truncated = false;
16
+ let settled = false;
17
+ let stdoutText = "";
18
+ const stdoutDecoder = new StringDecoder("utf8");
19
+ let stdoutLineChain = Promise.resolve();
20
+ let stdoutLineError = null;
21
+ const append = (current, chunk) => {
22
+ if (current.length >= limit) {
23
+ truncated = true;
24
+ return current;
25
+ }
26
+ const remaining = limit - current.length;
27
+ if (chunk.length > remaining) {
28
+ truncated = true;
29
+ if (request.terminateOnOutputLimit)
30
+ terminate(`sandbox_log_limit: process output exceeded ${limit} bytes`);
31
+ }
32
+ return Buffer.concat([current, chunk.subarray(0, remaining)]);
33
+ };
34
+ child.stdout.on("data", (chunk) => {
35
+ stdout = append(stdout, chunk);
36
+ if (!request.onStdoutLine)
37
+ return;
38
+ stdoutText += stdoutDecoder.write(chunk);
39
+ if (Buffer.byteLength(stdoutText, "utf8") > 1024 * 1024 && !stdoutText.includes("\n")) {
40
+ stdoutLineError = new Error("sandbox process emitted an oversized stdout line");
41
+ terminate("oversized stdout line");
42
+ return;
43
+ }
44
+ const lines = stdoutText.split(/\r?\n/u);
45
+ stdoutText = lines.pop() ?? "";
46
+ if (lines.length === 0)
47
+ return;
48
+ child.stdout.pause();
49
+ for (const line of lines) {
50
+ stdoutLineChain = stdoutLineChain.then(async () => {
51
+ if (Buffer.byteLength(line, "utf8") > 1024 * 1024)
52
+ throw new Error("sandbox process emitted an oversized stdout line");
53
+ await request.onStdoutLine(line);
54
+ });
55
+ }
56
+ stdoutLineChain = stdoutLineChain
57
+ .catch(error => {
58
+ stdoutLineError = error;
59
+ terminate("stdout event handler failed");
60
+ })
61
+ .finally(() => {
62
+ if (!settled)
63
+ child.stdout.resume();
64
+ });
65
+ });
66
+ child.stderr.on("data", (chunk) => { stderr = append(stderr, chunk); });
67
+ child.on("error", (error) => {
68
+ if (!settled) {
69
+ settled = true;
70
+ reject(error);
71
+ }
72
+ });
73
+ const terminate = (reason) => {
74
+ if (child.exitCode !== null)
75
+ return;
76
+ child.kill("SIGTERM");
77
+ setTimeout(() => child.kill("SIGKILL"), 5_000).unref?.();
78
+ if (!settled) {
79
+ // Completion is still resolved by close so stderr remains available.
80
+ stderr = append(stderr, Buffer.from(`\n${reason}\n`));
81
+ }
82
+ };
83
+ const timeout = request.timeoutMs
84
+ ? setTimeout(() => terminate(`process timeout after ${request.timeoutMs}ms`), request.timeoutMs)
85
+ : null;
86
+ timeout?.unref?.();
87
+ const abort = () => terminate(String(request.signal?.reason ?? "process aborted"));
88
+ request.signal?.addEventListener("abort", abort, { once: true });
89
+ if (request.input !== undefined) {
90
+ child.stdin.end(request.input);
91
+ }
92
+ child.on("close", async (code) => {
93
+ if (settled)
94
+ return;
95
+ settled = true;
96
+ if (timeout)
97
+ clearTimeout(timeout);
98
+ request.signal?.removeEventListener("abort", abort);
99
+ if (request.onStdoutLine)
100
+ stdoutText += stdoutDecoder.end();
101
+ if (request.onStdoutLine && stdoutText) {
102
+ stdoutLineChain = stdoutLineChain.then(() => request.onStdoutLine(stdoutText));
103
+ }
104
+ await stdoutLineChain.catch(error => { stdoutLineError ??= error; });
105
+ if (stdoutLineError) {
106
+ reject(stdoutLineError);
107
+ return;
108
+ }
109
+ resolve({
110
+ exitCode: code ?? 1,
111
+ stdout: stdout.toString("utf8"),
112
+ stderr: stderr.toString("utf8"),
113
+ truncated
114
+ });
115
+ });
116
+ });
117
+ }
118
+ export async function requireSandboxProcess(request) {
119
+ const result = await runSandboxProcess(request);
120
+ if (result.exitCode !== 0) {
121
+ throw new Error(`${request.command} ${request.args[0] ?? ""} failed: ${result.stderr.trim() || result.stdout.trim() || `exit ${result.exitCode}`}`);
122
+ }
123
+ return result;
124
+ }
@@ -0,0 +1,26 @@
1
+ export interface SandboxAuthDoctorResult {
2
+ readonly available: boolean;
3
+ readonly code: "ok" | "sandbox_auth_unavailable";
4
+ readonly message: string;
5
+ readonly authPath?: string;
6
+ }
7
+ export interface SandboxProviderCredentialFile {
8
+ readonly name: string;
9
+ readonly contentBase64: string;
10
+ }
11
+ export interface SandboxPreparedProviderAuth {
12
+ readonly files: readonly SandboxProviderCredentialFile[];
13
+ readonly containerMountPath: string;
14
+ readonly writable: boolean;
15
+ }
16
+ export interface SandboxProviderAuthAdapter {
17
+ readonly provider: "codex";
18
+ readonly authKind: "bridge_cli";
19
+ readonly containerMountPath: string;
20
+ readonly writable: boolean;
21
+ doctor(environment?: NodeJS.ProcessEnv): SandboxAuthDoctorResult;
22
+ prepareAttemptAuth(environment?: NodeJS.ProcessEnv): Promise<SandboxPreparedProviderAuth>;
23
+ }
24
+ export declare const codexSandboxProviderAuthAdapter: SandboxProviderAuthAdapter;
25
+ export declare function sandboxProviderAuthAdapterFor(provider: string, authKind: string): SandboxProviderAuthAdapter | null;
26
+ export declare function sandboxProviderAuthUnavailableMessage(provider: string, authKind: string): string | null;