import { spawn } from "child_process"; import { Transport, AbortError, CommandFailedError, 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)}`; } type DockerTarget = { type: "container", container: string, } | { type: "image", image: Awaited>, }; export class DockerTransport implements Transport { private readonly _container: string; constructor(private readonly _target: DockerTarget) { if(this._target.type === "image") this._container = this._target.image.container; else this._container = this._target.container; } async close() { if(this._target.type === "image") await this._target.image.close(); } private async dockerExec(signal: AbortSignal, command: string[], timeout: number): Promise { const dockerCmd = ["docker", "exec", this._container, "/bin/sh", "-c", command.join(" ")]; return new Promise((resolve, reject) => { const child = spawn(dockerCmd[0], dockerCmd.slice(1), { timeout, stdio: ['ignore', 'pipe', 'pipe'] }); let output = ''; let aborted = false; const onAbort = () => { aborted = 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?.(); }; if (signal.aborted) onAbort(); signal.addEventListener('abort', onAbort); const cleanup = () => { signal.removeEventListener('abort', onAbort); }; child.stdout.on('data', (data) => { output += data.toString(); }); child.stderr.on('data', (data) => { output += data.toString(); }); child.on('close', (code) => { cleanup(); if (aborted) { reject(new AbortError()); return; } if (code === 0) { resolve(output); } else { if(code == null) { reject(new CommandFailedError( `Command timed out. output: ${output}`)); } else { reject(new CommandFailedError( `Command exited with code: ${code} output: ${output}`)); } } }); child.on('error', (err) => { cleanup(); if (aborted) { reject(new AbortError()); return; } reject(new CommandFailedError(`Command failed: ${err.message}`)); }); }); } async writeFile(signal: AbortSignal, file: string, contents: string): Promise { // Create a temporary file with the contents const tempFile = `/tmp/laisscodex_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, [ "/bin/sh", "-c", `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> { 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); } }