import { spawn } from "child_process"; import { Transport, AbortError, CommandFailedError, MAX_SHELL_OUTPUT_LENGTH, ShellOutput, TransportError, } from "./transport-common.ts"; export async function manageContainer(args: string[]) { console.log("Spawning Docker container..."); const { stdout } = await new Promise<{ stdout: string; }>((resolve, reject) => { const stdout: string[] = []; let error = false; const child = spawn("docker", ["run", ...args], { stdio: ["ignore", "pipe", "inherit"], }); child.on("error", e => { error = true; reject(e); }); child.stdout.on("data", data => stdout.push(data)); child.on("close", code => { if (code != null && code !== 0) { if (!error) reject("Command exited with non-zero exit code: " + code); } else if (!error) { resolve({ stdout: stdout.join(""), }); } }); }); const name = stdout.trim(); return { container: name, close: async () => { spawn("docker", ["kill", name]); }, }; } function randomSuffix() { return `${Date.now()}_${Math.random().toString(16)}`; } async function runDockerCommand( container: string, command: string[], timeout: number, signal: AbortSignal, ): Promise { const dockerCmd = ["docker", "exec", container, "/bin/sh", "-c", command.join(" ")]; return new Promise((resolve, reject) => { const child = spawn(dockerCmd[0], dockerCmd.slice(1), { timeout, stdio: ["ignore", "pipe", "pipe"], }); const output = new ShellOutput(); let aborted = false; let killed = false; const killChild = () => { if (killed) return; killed = true; // Try graceful termination first child.kill("SIGTERM"); // Fallback to SIGKILL if it doesn't exit quickly setTimeout(() => { try { child.kill("SIGKILL"); } catch {} }, 500).unref?.(); }; const onAbort = () => { aborted = true; killChild(); }; if (signal.aborted) onAbort(); signal.addEventListener("abort", onAbort); const cleanup = () => { signal.removeEventListener("abort", onAbort); }; child.stdout.on("data", data => { if (!output.append(data)) killChild(); }); child.stderr.on("data", data => { if (!output.append(data)) killChild(); }); child.on("close", code => { cleanup(); if (aborted) { reject(new AbortError()); return; } const commandOutput = output.getOutput(); if (commandOutput == null) { reject( new CommandFailedError( `Command output exceeded the ${MAX_SHELL_OUTPUT_LENGTH} character limit and was terminated.`, ), ); return; } if (code === 0) { resolve(commandOutput); } else { if (code == null) { reject( new CommandFailedError( `Command timed out. output: ${commandOutput}`, ), ); } else { reject( new CommandFailedError( `Command exited with code: ${code} output: ${commandOutput}`, code, ), ); } } }); child.on("error", err => { cleanup(); if (aborted) { reject(new AbortError()); return; } reject(new CommandFailedError(`Command failed: ${err.message}`)); }); }); } type DockerTarget = | { type: "container"; container: string; } | { type: "image"; image: Awaited>; }; export class DockerTransport implements Transport { private readonly _container: string; cwd: string; private constructor( private readonly _target: DockerTarget, cwd: string, ) { if (this._target.type === "image") this._container = this._target.image.container; else this._container = this._target.container; this.cwd = cwd; } static async create(target: DockerTarget): Promise { const container = target.type === "image" ? target.image.container : target.container; const cwd = await runDockerCommand(container, ["pwd"], 5000, new AbortController().signal); return new DockerTransport(target, cwd.trim()); } async close() { if (this._target.type === "image") await this._target.image.close(); } private async dockerExec( signal: AbortSignal, command: string[], timeout: number, ): Promise { return runDockerCommand(this._container, command, timeout, signal); } async writeFile(signal: AbortSignal, file: string, contents: string): Promise { // Create a temporary file with the contents const tempFile = `/tmp/octo_write_${randomSuffix()}`; // First, write the contents to the temp file using a base64 to avoid shellescape issues const base64Contents = Buffer.from(contents).toString("base64"); await this.dockerExec(signal, [`echo '${base64Contents}' | base64 -d > '${tempFile}'`], 5000); try { // Ensure directory exists const dirPath = file.substring(0, file.lastIndexOf("/")); if (dirPath) { await this.mkdir(signal, dirPath); } // Move the temp file to the target location await this.dockerExec(signal, ["mv", tempFile, file], 5000); } catch (e) { // Clean up temp file if anything fails try { await this.dockerExec(signal, ["rm", "-f", tempFile], 5000); } catch {} throw e; } } async readFile(signal: AbortSignal, file: string): Promise { try { const output = await this.dockerExec(signal, ["cat", file], 10000); return output; } catch (e) { throw new TransportError(`Could not read file ${file}: ${e}`); } } async modTime(signal: AbortSignal, file: string): Promise { try { const output = await this.dockerExec(signal, ["stat", "-c", "%Y", file], 5000); const timestamp = parseInt(output.trim()); return timestamp * 1000; // Convert seconds to milliseconds } catch (e) { throw new TransportError(`Could not get modified time for ${file}: ${e}`); } } async resolvePath(signal: AbortSignal, path: string): Promise { const output = await this.dockerExec(signal, ["readlink", "-f", path], 5000); return output.trim(); } async mkdir(signal: AbortSignal, dirpath: string): Promise { await this.dockerExec(signal, ["mkdir", "-p", dirpath], 5000); } async readdir( signal: AbortSignal, dirpath: string, ): Promise< Array<{ entry: string; isDirectory: boolean; }> > { try { const output = await this.dockerExec(signal, ["ls", "-la", dirpath], 5000); const lines = output.trim().split("\n").slice(1); // Skip "total" line const entries = []; for (const line of lines) { const parts = line.trim().split(/\s+/); if (parts.length < 9) continue; const permissions = parts[0]; const name = parts[8]; // Skip "." and ".." entries if (name === "." || name === "..") continue; const isDirectory = permissions.startsWith("d"); entries.push({ entry: name, isDirectory, }); } return entries; } catch (e) { throw new TransportError(`Could not read directory ${dirpath}: ${e}`); } } async pathExists(signal: AbortSignal, file: string): Promise { try { await this.dockerExec(signal, ["test", "-e", file], 5000); return true; } catch { return false; } } async isDirectory(signal: AbortSignal, file: string): Promise { try { await this.dockerExec(signal, ["test", "-d", file], 5000); return true; } catch { return false; } } async shell(signal: AbortSignal, command: string, timeout: number): Promise { return await this.dockerExec(signal, [command], timeout); } }