src / container / engine.ts
/**
* @file container/engine.ts
* Container lifecycle engine — creates, starts, stops, and executes
* commands inside the model's dedicated Linux computer.
*
* Supports Docker and Podman interchangeably via the detected runtime.
*/
import { execFile, spawn } from "child_process";
import { promisify } from "util";
import { mkdirSync, readFileSync, writeFileSync, existsSync } from "fs";
import { homedir } from "os";
import { join } from "path";
import { detectRuntime } from "./runtime";
import {
CONTAINER_NAME_PREFIX,
CONTAINER_WORKDIR,
CONTAINER_SHELL,
CONTAINER_SHELL_ALPINE,
CONTAINER_ENV_VARS,
DEFAULT_MAX_OUTPUT_BYTES,
MAX_OUTPUT_BYTES,
PACKAGE_PRESETS,
PACKAGE_PRESETS_ALPINE,
} from "../constants";
import type {
RuntimeInfo,
ContainerCreateOptions,
ContainerInfo,
ExecResult,
EnvironmentInfo,
ProcessInfo,
} from "../types";
import type { ContainerImage, ContainerState, NetworkMode } from "../constants";
const execAsync = promisify(execFile);
function toDockerPath(hostPath: string): string {
if (process.platform !== "win32") return hostPath;
return hostPath
.replace(/^([A-Za-z]):\\/, (_, d) => `//${d.toLowerCase()}/`)
.replace(/\\/g, "/");
}
/** Shell-escape a string for use inside single quotes. */
function q(p: string): string {
return p.replace(/'/g, "'\\''");
}
function getRuntimeEnv(): NodeJS.ProcessEnv {
const base = process.env.PATH ?? "";
const extra =
process.platform === "win32"
? [
"C:\\Program Files\\Docker\\Docker\\resources\\bin",
"C:\\Program Files\\Docker\\Docker\\resources",
]
: [
"/usr/bin",
"/usr/local/bin",
"/usr/lib/podman",
"/usr/libexec/podman",
"/bin",
];
const sep = process.platform === "win32" ? ";" : ":";
return {
...process.env,
PATH: [base, ...extra].filter(Boolean).join(sep),
};
}
function ensurePodmanConfig(): void {
try {
const configDir = join(homedir(), ".config", "containers");
const configPath = join(configDir, "containers.conf");
let existing = "";
if (existsSync(configPath)) {
existing = readFileSync(configPath, "utf-8");
}
const needsDNS = !existing.includes("dns_servers");
const needsHelperDir = !existing.includes("helper_binaries_dir");
if (!needsDNS && !needsHelperDir) return;
mkdirSync(configDir, { recursive: true });
let updated = existing;
if (needsHelperDir) {
const line =
'helper_binaries_dir = ["/usr/bin", "/usr/local/bin", "/usr/lib/podman"]';
updated = updated.includes("[network]")
? updated.replace("[network]", `[network]\n${line}`)
: updated + `\n[network]\n${line}\n`;
}
if (needsDNS) {
const line = 'dns_servers = ["8.8.8.8", "8.8.4.4"]';
updated = updated.includes("[containers]")
? updated.replace("[containers]", `[containers]\n${line}`)
: updated + `\n[containers]\n${line}\n`;
}
writeFileSync(configPath, updated, "utf-8");
console.log("[lms-computer] Auto-configured Podman containers.conf.");
} catch (err) {
console.warn("[lms-computer] Could not write Podman config:", err);
}
}
let runtime: RuntimeInfo | null = null;
let containerName: string = "";
let containerReady: boolean = false;
let initPromise: Promise<void> | null = null;
let currentNetwork: NetworkMode = "none";
interface ShellSession {
proc: ReturnType<typeof spawn>;
write: (data: string) => void;
kill: () => void;
}
let shellSession: ShellSession | null = null;
const SENTINEL = `__LMS_DONE_${Math.random().toString(36).slice(2)}__`;
const SENTINEL_NL = SENTINEL + "\n";
/**
* Truncate large output by keeping the head and tail with an omission
* notice in the middle — like Claude's own bash tool.
* The beginning (setup) and end (result/error) are almost always what matters.
*/
function smartTruncate(
text: string,
maxBytes: number,
): { text: string; truncated: boolean; linesOmitted: number } {
if (Buffer.byteLength(text, "utf-8") <= maxBytes) {
return { text, truncated: false, linesOmitted: 0 };
}
const lines = text.split("\n");
const headBudget = Math.floor(maxBytes * 0.45);
const tailBudget = Math.floor(maxBytes * 0.45);
const headLines: string[] = [];
let headUsed = 0;
for (const line of lines) {
const lb = Buffer.byteLength(line + "\n", "utf-8");
if (headUsed + lb > headBudget) break;
headLines.push(line);
headUsed += lb;
}
const tailLines: string[] = [];
let tailUsed = 0;
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i];
const lb = Buffer.byteLength(line + "\n", "utf-8");
if (tailUsed + lb > tailBudget) break;
tailLines.unshift(line);
tailUsed += lb;
}
const omitted = lines.length - headLines.length - tailLines.length;
if (omitted <= 0) {
return {
text: text.slice(0, maxBytes) + "\n… [output truncated]",
truncated: true,
linesOmitted: 0,
};
}
const joined = [
...headLines,
"",
`… [${omitted} lines omitted — use ReadFile with startLine/endLine to inspect the full output] …`,
"",
...tailLines,
].join("\n");
return { text: joined, truncated: true, linesOmitted: omitted };
}
function shellFor(image: string): string {
return image.startsWith("alpine") ? CONTAINER_SHELL_ALPINE : CONTAINER_SHELL;
}
function startShellSession(): ShellSession {
if (!runtime) throw new Error("Runtime not initialized");
const isAlpine = containerName.includes("alpine");
const shell = isAlpine ? CONTAINER_SHELL_ALPINE : CONTAINER_SHELL;
const proc = spawn(
runtime.path,
["exec", "-i", "-w", CONTAINER_WORKDIR, containerName, shell],
{ stdio: ["pipe", "pipe", "pipe"], env: getRuntimeEnv() },
);
const init = [
"export PS1=''",
"export PS2=''",
"export TERM=xterm-256color",
`cd ${CONTAINER_WORKDIR}`,
`[ -f ~/.bashrc ] && source ~/.bashrc 2>/dev/null || true`,
"",
].join("\n");
proc.stdin?.write(init);
const session: ShellSession = {
proc,
write: (data: string) => proc.stdin?.write(data),
kill: () => {
try { proc.kill("SIGKILL"); } catch { /* ignore */ }
},
};
proc.on("exit", () => { if (shellSession === session) shellSession = null; });
proc.on("error", () => { if (shellSession === session) shellSession = null; });
return session;
}
async function execInSession(
command: string,
timeoutSeconds: number,
maxOutputBytes: number,
): Promise<ExecResult> {
if (!shellSession || shellSession.proc.exitCode !== null || shellSession.proc.killed) {
shellSession = startShellSession();
await new Promise((r) => setTimeout(r, 100));
}
const session = shellSession;
const start = Date.now();
const effectiveMax = Math.min(maxOutputBytes, MAX_OUTPUT_BYTES);
return new Promise<ExecResult>((resolve) => {
let rawStdout = "";
let stderr = "";
let done = false;
const cleanup = () => {
session.proc.stdout?.removeListener("data", onStdout);
session.proc.stderr?.removeListener("data", onStderr);
clearTimeout(timer);
};
const finish = (timedOut: boolean, killed: boolean) => {
if (done) return;
done = true;
cleanup();
let exitCode = 0;
const exitMatch = rawStdout.match(/\nEXIT_CODE:(\d+)\n?$/);
if (exitMatch) {
exitCode = parseInt(exitMatch[1], 10);
rawStdout = rawStdout.slice(0, exitMatch.index);
}
rawStdout = rawStdout.replace(new RegExp(SENTINEL + "\\n?$"), "").trimEnd();
const { text: stdout, truncated, linesOmitted } = smartTruncate(rawStdout, effectiveMax);
resolve({
exitCode: killed ? 137 : exitCode,
stdout,
stderr: stderr.slice(0, effectiveMax),
timedOut,
durationMs: Date.now() - start,
truncated,
originalSize: linesOmitted > 0 ? rawStdout.length : undefined,
});
};
const onStdout = (chunk: Buffer) => {
if (done) return;
rawStdout += chunk.toString("utf-8");
if (rawStdout.includes(SENTINEL_NL) || rawStdout.endsWith(SENTINEL)) {
finish(false, false);
}
};
const onStderr = (chunk: Buffer) => {
if (done) return;
if (stderr.length < effectiveMax) stderr += chunk.toString("utf-8");
};
const timer = setTimeout(() => {
if (done) return;
session.kill();
shellSession = null;
finish(true, true);
}, timeoutSeconds * 1000);
session.proc.stdout?.on("data", onStdout);
session.proc.stderr?.on("data", onStderr);
const wrapped = `${command}\necho "EXIT_CODE:$?"\necho "${SENTINEL}"\n`;
session.write(wrapped);
});
}
async function run(args: string[], timeoutMs: number = 30_000): Promise<string> {
if (!runtime) throw new Error("Runtime not initialized");
const { stdout } = await execAsync(runtime.path, args, {
timeout: timeoutMs,
maxBuffer: MAX_OUTPUT_BYTES,
env: getRuntimeEnv(),
});
return stdout.trim();
}
async function getContainerState(): Promise<ContainerState> {
try {
const out = await run(["inspect", containerName, "--format", "{{.State.Status}}"]);
const status = out.trim().toLowerCase();
if (status === "running") return "running";
if (["exited", "stopped", "created", "paused", "dead"].includes(status)) return "stopped";
return "error";
} catch {
return "not_found";
}
}
function buildRunArgs(opts: ContainerCreateOptions): string[] {
const args: string[] = [
"run", "-d",
"--name", opts.name,
"--hostname", "lms-computer",
...(opts.network !== "podman-default" ? ["--network", opts.network] : []),
...(opts.network !== "none" ? ["--dns", "8.8.8.8", "--dns", "8.8.4.4"] : []),
"-w", "/root",
];
if (opts.cpuLimit > 0) args.push("--cpus", String(opts.cpuLimit));
if (opts.memoryLimitMB > 0) {
args.push("--memory", `${opts.memoryLimitMB}m`);
// wsl2 kernel often lacks swap cgroup accounting so
if (process.platform !== "win32") {
args.push("--memory-swap", `${opts.memoryLimitMB}m`);
}
}
for (const [k, v] of Object.entries(opts.envVars)) args.push("-e", `${k}=${v}`);
for (const pf of opts.portForwards) { const t = pf.trim(); if (t) args.push("-p", t); }
if (opts.hostMountPath) args.push("-v", `${toDockerPath(opts.hostMountPath)}:/mnt/shared`);
args.push(opts.image, "tail", "-f", "/dev/null");
return args;
}
async function setupContainer(
image: ContainerImage,
preset: string,
hasNetwork: boolean = false,
): Promise<void> {
const isAlpine = image.startsWith("alpine");
const shell = shellFor(image);
await execAsync(
runtime!.path,
[
"exec", containerName, shell, "-c",
`mkdir -p ${CONTAINER_WORKDIR} && chown root:root ${CONTAINER_WORKDIR} && ` +
`touch ~/.bashrc ~/.profile && ` +
`grep -q 'source ~/.bashrc' ~/.profile 2>/dev/null || ` +
`echo 'source ~/.bashrc 2>/dev/null || true' >> ~/.profile`,
],
{ timeout: 30_000, env: getRuntimeEnv() },
);
if (preset === "none" || !hasNetwork) return;
const packages = isAlpine
? (PACKAGE_PRESETS_ALPINE[preset] ?? [])
: (PACKAGE_PRESETS[preset] ?? []);
if (packages.length === 0) return;
const installCmd = isAlpine
? `apk add --no-cache ${packages.join(" ")}`
: `DEBIAN_FRONTEND=noninteractive apt-get update -qq && apt-get install -y --no-install-recommends ${packages.join(" ")} && rm -rf /var/lib/apt/lists/*`;
await execAsync(runtime!.path, ["exec", containerName, shell, "-c", installCmd], {
timeout: 300_000,
env: getRuntimeEnv(),
}).catch((e: Error) => {
console.warn("[lms-computer] Package install failed (non-fatal):", e.message);
});
}
export interface EnsureReadyOptions {
image: ContainerImage;
network: NetworkMode;
cpuLimit: number;
memoryLimitMB: number;
diskLimitMB: number;
autoInstallPreset: string;
portForwards: string;
hostMountPath: string;
persistenceMode: string;
}
export async function ensureReady(opts: EnsureReadyOptions): Promise<void> {
if (containerReady) return;
if (initPromise) { await initPromise; return; }
initPromise = (async () => {
if (!runtime) {
runtime = await detectRuntime();
containerName = `${CONTAINER_NAME_PREFIX}-main`;
if (runtime.kind === "podman") ensurePodmanConfig();
}
const state = await getContainerState();
if (state === "running") { containerReady = true; return; }
if (opts.persistenceMode === "ephemeral" && state !== "not_found") {
try { await run(["stop", containerName], 15_000); } catch { }
try { await run(["rm", "-f", containerName], 10_000); } catch { }
}
if (state === "stopped") {
try {
await run(["start", containerName]);
containerReady = true;
return;
} catch (err: any) {
const msg: string = err?.message ?? "";
if (msg.includes("workdir") || msg.includes("does not exist") ||
msg.includes("netns") || msg.includes("mount runtime")) {
try { await run(["rm", "-f", containerName], 10_000); } catch { }
} else { throw err; }
}
}
try { await run(["pull", opts.image], 300_000); } catch { }
const portForwards = opts.portForwards
? opts.portForwards.split(",").map((s) => s.trim()).filter(Boolean)
: [];
let setupNetwork: NetworkMode | "podman-default" = "none";
if (runtime?.kind === "docker") {
setupNetwork = opts.network === "none" ? "none" : "bridge";
} else if (runtime?.kind === "podman" && opts.network !== "none") {
setupNetwork = "podman-default";
}
const createArgs = buildRunArgs({
image: opts.image,
name: containerName,
network: setupNetwork,
cpuLimit: opts.cpuLimit,
memoryLimitMB: opts.memoryLimitMB,
diskLimitMB: opts.diskLimitMB,
workdir: CONTAINER_WORKDIR,
envVars: CONTAINER_ENV_VARS,
portForwards,
hostMountPath: opts.hostMountPath || null,
});
// --storage-opt is not supported on docker desktop win (overlayfs)
const diskOptArgs = [...createArgs];
if (opts.diskLimitMB > 0 && process.platform !== "win32") {
diskOptArgs.splice(diskOptArgs.indexOf(opts.image), 0,
"--storage-opt", `size=${opts.diskLimitMB}m`);
}
const stripResourceFlags = (args: string[]): string[] =>
args.filter((a, i, arr) => {
const prev = arr[i - 1] ?? "";
return (
a !== "--memory" && a !== "--memory-swap" &&
a !== "--cpus" && a !== "--storage-opt" &&
!prev.match(/^--(memory|memory-swap|cpus|storage-opt)$/)
);
});
const tryRun = async (args: string[]): Promise<void> => {
try {
await run(args, 60_000);
} catch (err: any) {
const msg: string = err?.message ?? "";
if (msg.includes("storage-opt") || msg.includes("backingFS") || msg.includes("overlay.size")) {
console.warn("[lms-computer] Disk quota not supported, retrying without --storage-opt.");
const noStorage = args.filter((a, i, arr) =>
a !== "--storage-opt" && !(arr[i - 1] === "--storage-opt")
);
await tryRun(noStorage);
} else if (msg.includes("memory") || msg.includes("cpus") ||
msg.includes("cgroup") || msg.includes("resource")) {
console.warn("[lms-computer] Resource limits not supported, retrying without them.");
await run(stripResourceFlags(createArgs), 60_000);
} else {
throw err;
}
}
};
await tryRun(diskOptArgs);
const hasNetworkForSetup = setupNetwork !== "none";
await setupContainer(opts.image, opts.autoInstallPreset, hasNetworkForSetup);
if (opts.network === "none" && setupNetwork !== "none") {
try {
await run(["network", "disconnect", setupNetwork, containerName], 10_000);
} catch { /* best effort */ }
}
currentNetwork = setupNetwork !== "none" ? opts.network : "none";
containerReady = true;
})();
try {
await initPromise;
} finally {
initPromise = null;
}
}
export async function exec(
command: string,
timeoutSeconds: number,
maxOutputBytes: number = DEFAULT_MAX_OUTPUT_BYTES,
workdir?: string,
): Promise<ExecResult> {
if (!runtime || !containerReady) throw new Error("Container not ready. Call ensureReady() first.");
const cmdToRun = workdir && workdir !== CONTAINER_WORKDIR
? `cd ${workdir} && ${command}` : command;
return execInSession(cmdToRun, timeoutSeconds, maxOutputBytes);
}
export async function writeFile(filePath: string, content: string): Promise<void> {
if (!runtime || !containerReady) throw new Error("Container not ready.");
return new Promise<void>((resolve, reject) => {
const shell = containerName.includes("alpine") ? CONTAINER_SHELL_ALPINE : CONTAINER_SHELL;
const proc = spawn(runtime!.path, ["exec", "-i", containerName, shell, "-c", `cat > '${q(filePath)}'`], {
timeout: 15_000, stdio: ["pipe", "ignore", "pipe"], env: getRuntimeEnv(),
});
let stderr = "";
proc.stderr?.on("data", (chunk: Buffer) => { stderr += chunk.toString(); });
proc.on("close", (code) => { code === 0 ? resolve() : reject(new Error(`Write failed (exit ${code}): ${stderr}`)); });
proc.on("error", reject);
proc.stdin?.write(content);
proc.stdin?.end();
});
}
export async function appendFile(filePath: string, content: string): Promise<void> {
if (!runtime || !containerReady) throw new Error("Container not ready.");
return new Promise<void>((resolve, reject) => {
const shell = containerName.includes("alpine") ? CONTAINER_SHELL_ALPINE : CONTAINER_SHELL;
const proc = spawn(runtime!.path, ["exec", "-i", containerName, shell, "-c", `cat >> '${q(filePath)}'`], {
timeout: 15_000, stdio: ["pipe", "ignore", "pipe"], env: getRuntimeEnv(),
});
let stderr = "";
proc.stderr?.on("data", (chunk: Buffer) => { stderr += chunk.toString(); });
proc.on("close", (code) => { code === 0 ? resolve() : reject(new Error(`Append failed (exit ${code}): ${stderr}`)); });
proc.on("error", reject);
proc.stdin?.write(content);
proc.stdin?.end();
});
}
export async function readFile(
filePath: string,
maxBytes: number,
startLine?: number,
endLine?: number,
): Promise<{ content: string; totalLines: number }> {
if (!runtime || !containerReady) throw new Error("Container not ready.");
const qp = q(filePath);
const totalResult = await exec(`wc -l < '${qp}' 2>/dev/null || echo 0`, 5);
const totalLines = parseInt(totalResult.stdout.trim(), 10) || 0;
let cmd: string;
if (startLine !== undefined && endLine !== undefined) {
cmd = `sed -n '${startLine},${endLine}p' '${qp}'`;
} else if (startLine !== undefined) {
cmd = `tail -n +${startLine} '${qp}'`;
} else {
cmd = `cat '${qp}'`;
}
const result = await exec(cmd, 10, maxBytes);
if (result.exitCode !== 0) throw new Error(`Read failed: ${result.stderr || "file not found"}`);
return { content: result.stdout, totalLines };
}
/**
* Replace one or more exact strings in a file.
* All replacements are applied in order and the file is written once.
* Each oldStr must appear exactly once.
*/
export async function strReplaceInFile(
filePath: string,
replacements: Array<{ oldStr: string; newStr: string }>,
): Promise<{ replacements: number }> {
if (!runtime || !containerReady) throw new Error("Container not ready.");
const qp = q(filePath);
const readResult = await exec(`cat '${qp}'`, 10, MAX_OUTPUT_BYTES);
if (readResult.exitCode !== 0) throw new Error(`File not found: ${filePath}`);
let content = readResult.stdout;
for (const { oldStr, newStr } of replacements) {
const occurrences = content.split(oldStr).length - 1;
if (occurrences === 0) {
throw new Error(
`String not found in ${filePath}:\n"${oldStr.slice(0, 80)}"\n` +
`Hint: use ReadFile to view the current contents before editing.`,
);
}
if (occurrences > 1) {
throw new Error(
`String appears ${occurrences} times in ${filePath} — it must be unique.\n` +
`Hint: include more surrounding context to make the match unique.`,
);
}
content = content.replace(oldStr, newStr);
}
await writeFile(filePath, content);
return { replacements: replacements.length };
}
export async function insertLinesInFile(
filePath: string,
afterLine: number,
content: string,
): Promise<void> {
if (!runtime || !containerReady) throw new Error("Container not ready.");
const qp = q(filePath);
const readResult = await exec(`cat '${qp}'`, 10, MAX_OUTPUT_BYTES);
if (readResult.exitCode !== 0) throw new Error(`File not found: ${filePath}`);
const lines = readResult.stdout.split("\n");
const insertLines = content.split("\n");
const clampedLine = Math.max(0, Math.min(afterLine, lines.length));
lines.splice(clampedLine, 0, ...insertLines);
await writeFile(filePath, lines.join("\n"));
}
export async function moveFile(src: string, dest: string): Promise<void> {
if (!runtime || !containerReady) throw new Error("Container not ready.");
const destDir = dest.includes("/") ? dest.slice(0, dest.lastIndexOf("/")) : ".";
const result = await exec(
`mkdir -p '${q(destDir)}' 2>/dev/null; mv '${q(src)}' '${q(dest)}'`, 10);
if (result.exitCode !== 0) throw new Error(`Move failed: ${result.stderr || "unknown error"}`);
}
export async function copyFile(src: string, dest: string): Promise<void> {
if (!runtime || !containerReady) throw new Error("Container not ready.");
const destDir = dest.includes("/") ? dest.slice(0, dest.lastIndexOf("/")) : ".";
const result = await exec(
`mkdir -p '${q(destDir)}' 2>/dev/null; cp -r '${q(src)}' '${q(dest)}'`, 10);
if (result.exitCode !== 0) throw new Error(`Copy failed: ${result.stderr || "unknown error"}`);
}
export async function searchInFiles(
pattern: string,
dir: string,
options: { ignoreCase?: boolean; glob?: string; maxResults?: number } = {},
): Promise<{ matches: string; count: number; truncated: boolean }> {
if (!runtime || !containerReady) throw new Error("Container not ready.");
const { ignoreCase, glob, maxResults = 200 } = options;
const flags = [
"-rn", "--color=never",
ignoreCase ? "-i" : "",
glob ? `--include='${glob}'` : "",
`--exclude-dir='.git' --exclude-dir='node_modules' --exclude-dir='.cache'`,
].filter(Boolean).join(" ");
const escapedPattern = q(pattern);
const cmd = `grep ${flags} '${escapedPattern}' '${q(dir)}' 2>/dev/null | head -${maxResults + 1}`;
const result = await exec(cmd, 15, DEFAULT_MAX_OUTPUT_BYTES);
const lines = result.stdout.trim() ? result.stdout.trim().split("\n") : [];
const truncated = lines.length > maxResults;
const displayLines = truncated ? lines.slice(0, maxResults) : lines;
return {
matches: displayLines.join("\n") || "(no matches)",
count: displayLines.length,
truncated,
};
}
export async function setEnvVar(key: string, value: string): Promise<void> {
if (!runtime || !containerReady) throw new Error("Container not ready.");
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
throw new Error(
`Invalid env var name: "${key}". Must match [A-Za-z_][A-Za-z0-9_]*`,
);
}
const escaped = q(value);
const cmd =
`sed -i '/^export ${key}=/d' ~/.bashrc 2>/dev/null; ` +
`echo "export ${key}='${escaped}'" >> ~/.bashrc; ` +
`export ${key}='${escaped}'`;
const result = await exec(cmd, 5);
if (result.exitCode !== 0) throw new Error(`Failed to set env var: ${result.stderr}`);
}
interface BgEntry {
stdout: string;
stderr: string;
done: boolean;
exitCode: number | null;
proc: ReturnType<typeof spawn> | null;
command: string;
startedAt: number;
}
const bgLogs = new Map<number, BgEntry>();
export async function execBackground(
command: string,
timeoutSeconds: number,
): Promise<{ handleId: number; pid: number }> {
if (!runtime || !containerReady) throw new Error("Container not ready.");
const shell = containerName.includes("alpine") ? CONTAINER_SHELL_ALPINE : CONTAINER_SHELL;
const handleId = Date.now();
const entry: BgEntry = {
stdout: "", stderr: "", done: false, exitCode: null, proc: null,
command, startedAt: Date.now(),
};
bgLogs.set(handleId, entry);
const proc = spawn(runtime.path, ["exec", containerName, shell, "-c", command], {
stdio: ["ignore", "pipe", "pipe"], env: getRuntimeEnv(),
});
entry.proc = proc;
const cap = MAX_OUTPUT_BYTES * 4;
proc.stdout?.on("data", (chunk: Buffer) => {
if (entry.stdout.length < cap) entry.stdout += chunk.toString("utf-8");
});
proc.stderr?.on("data", (chunk: Buffer) => {
if (entry.stderr.length < cap) entry.stderr += chunk.toString("utf-8");
});
const killTimer = setTimeout(() => {
if (!entry.done) {
try { proc.kill("SIGKILL"); } catch { }
entry.done = true;
entry.exitCode = 137;
entry.proc = null;
}
}, timeoutSeconds * 1_000);
proc.on("close", (code) => {
entry.done = true;
entry.exitCode = code;
entry.proc = null;
clearTimeout(killTimer);
});
return { handleId, pid: proc.pid ?? -1 };
}
/**
* Read buffered output from a background process.
* Pass `fromOffset` (the `nextOffset` from the previous call) to receive
* only new stdout since the last read — avoids duplicate output on repeated polling.
*/
export function readBgLogs(
handleId: number,
maxBytes: number = DEFAULT_MAX_OUTPUT_BYTES,
fromOffset: number = 0,
): {
stdout: string;
stderr: string;
done: boolean;
exitCode: number | null;
found: boolean;
nextOffset: number;
} {
const entry = bgLogs.get(handleId);
if (!entry) {
return { stdout: "", stderr: "", done: true, exitCode: null, found: false, nextOffset: 0 };
}
const newStdout = entry.stdout.slice(fromOffset, fromOffset + maxBytes);
const nextOffset = fromOffset + newStdout.length;
return {
stdout: newStdout || "(no new output since last read)",
stderr: entry.stderr.slice(-maxBytes) || "",
done: entry.done,
exitCode: entry.exitCode,
found: true,
nextOffset,
};
}
/**
* Kill a background process by handle ID.
* Sends SIGTERM then SIGKILL after 2 s if the process hasn't exited.
*/
export function killBgProcess(handleId: number): {
found: boolean;
alreadyDone: boolean;
} {
const entry = bgLogs.get(handleId);
if (!entry) return { found: false, alreadyDone: false };
if (entry.done) return { found: true, alreadyDone: true };
if (entry.proc) {
try {
entry.proc.kill("SIGTERM");
setTimeout(() => {
if (!entry.done && entry.proc) {
try { entry.proc.kill("SIGKILL"); } catch { }
}
}, 2_000);
} catch { }
}
return { found: true, alreadyDone: false };
}
/**
* List all background processes (running or recently finished).
* Used by the preprocessor to inject context about active jobs.
*/
export function listBgProcesses(): Array<{
handleId: number;
command: string;
running: boolean;
exitCode: number | null;
runtimeSecs: number;
}> {
const out = [];
for (const [handleId, entry] of bgLogs.entries()) {
out.push({
handleId,
command: entry.command.length > 60 ? entry.command.slice(0, 57) + "…" : entry.command,
running: !entry.done,
exitCode: entry.exitCode,
runtimeSecs: Math.round((Date.now() - entry.startedAt) / 1000),
});
}
return out.sort((a, b) => b.handleId - a.handleId).slice(0, 10);
}
export async function copyToContainer(hostPath: string, containerPath: string): Promise<void> {
if (!runtime) throw new Error("Runtime not initialized.");
await run(["cp", hostPath, `${containerName}:${containerPath}`], 60_000);
}
export async function copyFromContainer(containerPath: string, hostPath: string): Promise<void> {
if (!runtime) throw new Error("Runtime not initialized.");
await run(["cp", `${containerName}:${containerPath}`, hostPath], 60_000);
}
export async function getEnvironmentInfo(
network: boolean,
diskLimitMB: number = 0,
): Promise<EnvironmentInfo> {
const infoScript = `
echo "OS=$(cat /etc/os-release 2>/dev/null | grep PRETTY_NAME | cut -d= -f2 | tr -d '"')"
echo "KERNEL=$(uname -r)"
echo "ARCH=$(uname -m)"
echo "HOSTNAME=$(hostname)"
echo "UPTIME=$(uptime -p 2>/dev/null || uptime)"
DISK_USED_KB=$(du -sk ${CONTAINER_WORKDIR} 2>/dev/null | awk '{print $1}' || echo 0)
echo "DISK_USED_KB=\$DISK_USED_KB"
echo "DISK_FREE_RAW=$(df -k ${CONTAINER_WORKDIR} 2>/dev/null | tail -1 | awk '{print $4}')"
MEM_LIMIT_BYTES=\$(cat /sys/fs/cgroup/memory.max 2>/dev/null || cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null || echo '')
MEM_USAGE_BYTES=\$(cat /sys/fs/cgroup/memory.current 2>/dev/null || cat /sys/fs/cgroup/memory/memory.usage_in_bytes 2>/dev/null || echo '')
if [ -n "\$MEM_LIMIT_BYTES" ] && [ "\$MEM_LIMIT_BYTES" != "max" ] && [ "\$MEM_LIMIT_BYTES" -lt 9000000000000 ] 2>/dev/null; then
MEM_TOTAL_H=\$(awk "BEGIN{printf \"%.0fMiB\", \$MEM_LIMIT_BYTES/1048576}")
MEM_USED_H=\$(awk "BEGIN{printf \"%.0fMiB\", \${MEM_USAGE_BYTES:-0}/1048576}")
MEM_FREE_H=\$(awk "BEGIN{printf \"%.0fMiB\", (\$MEM_LIMIT_BYTES-\${MEM_USAGE_BYTES:-0})/1048576}")
else
MEM_TOTAL_H=\$(free -h 2>/dev/null | grep Mem | awk '{print \$2}' || echo 'N/A')
MEM_USED_H=\$(free -h 2>/dev/null | grep Mem | awk '{print \$3}' || echo 'N/A')
MEM_FREE_H=\$(free -h 2>/dev/null | grep Mem | awk '{print \$4}' || echo 'N/A')
fi
echo "MEM_FREE=\$MEM_FREE_H"
echo "MEM_TOTAL=\$MEM_TOTAL_H"
echo "PYTHON=$(python3 --version 2>/dev/null || echo '')"
echo "NODE=$(node --version 2>/dev/null || echo '')"
echo "GCC=$(gcc --version 2>/dev/null | head -1 || echo '')"
echo "TOOLS=$(which git curl wget vim nano python3 node npm gcc make cmake pip3 2>/dev/null | xargs -I{} basename {} | tr '\\n' ',')"
`.trim();
const result = await exec(infoScript, 10);
const lines = result.stdout.split("\n");
const get = (prefix: string): string => {
const line = lines.find((l) => l.startsWith(prefix + "="));
return line?.slice(prefix.length + 1)?.trim() ?? "N/A";
};
const diskUsedKB = parseInt(get("DISK_USED_KB") || "0", 10);
const diskFreeRawKB = parseInt(get("DISK_FREE_RAW") || "0", 10);
let diskTotal: string;
let diskFree: string;
const toMiB = (kb: number) =>
kb >= 1024 * 1024 ? `${(kb / 1024 / 1024).toFixed(1)}GiB` : `${Math.round(kb / 1024)}MiB`;
if (diskLimitMB > 0) {
const diskLimitKB = diskLimitMB * 1024;
diskTotal = toMiB(diskLimitKB);
diskFree = toMiB(Math.max(0, diskLimitKB - diskUsedKB));
} else {
diskFree = toMiB(diskFreeRawKB);
diskTotal = "N/A";
}
return {
os: get("OS"), kernel: get("KERNEL"), arch: get("ARCH"),
hostname: get("HOSTNAME"), uptime: get("UPTIME"),
diskFree, diskTotal,
memoryFree: get("MEM_FREE"), memoryTotal: get("MEM_TOTAL"),
pythonVersion: get("PYTHON") || null,
nodeVersion: get("NODE") || null,
gccVersion: get("GCC") || null,
installedTools: get("TOOLS").split(",").filter(Boolean),
workdir: CONTAINER_WORKDIR,
networkEnabled: network,
};
}
export async function listProcesses(): Promise<ProcessInfo[]> {
const result = await exec("ps aux --no-headers 2>/dev/null || ps aux 2>/dev/null", 5);
if (result.exitCode !== 0) return [];
return result.stdout
.split("\n")
.filter((line) => line.trim() && !line.includes("ps aux"))
.map((line) => {
const parts = line.trim().split(/\s+/);
return {
pid: parseInt(parts[1] ?? "0", 10),
user: parts[0] ?? "?",
cpu: parts[2] ?? "0",
memory: parts[3] ?? "0",
started: parts[8] ?? "?",
command: parts.slice(10).join(" ") || parts.slice(3).join(" "),
};
})
.filter((p) => p.pid > 0);
}
export async function killProcess(pid: number, signal: string = "SIGTERM"): Promise<boolean> {
const result = await exec(`kill -${signal} ${pid} 2>&1`, 5);
return result.exitCode === 0;
}
export async function stopContainer(remove: boolean = false): Promise<void> {
if (!runtime) return;
if (shellSession) { shellSession.kill(); shellSession = null; }
try { await run(["stop", containerName], 15_000); } catch { }
if (remove) { try { await run(["rm", "-f", containerName], 10_000); } catch { } }
containerReady = false;
}
export async function destroyContainer(): Promise<void> {
await stopContainer(true);
containerReady = false;
currentNetwork = "none";
initPromise = null;
}
export async function restartContainer(): Promise<void> {
if (!runtime) throw new Error("Runtime not initialized.");
if (shellSession) { shellSession.kill(); shellSession = null; }
try { await run(["stop", containerName], 15_000); } catch { }
await run(["start", containerName], 30_000);
containerReady = true;
}
export async function getContainerInfo(): Promise<ContainerInfo> {
if (!runtime) throw new Error("Runtime not initialized.");
const state = await getContainerState();
if (state === "not_found") {
return {
id: "", name: containerName, state: "not_found", image: "", created: "",
uptime: null, cpuUsage: null, memoryUsage: null, diskUsage: null, networkMode: "", ports: []
};
}
try {
const format = "{{.Id}}\t{{.Config.Image}}\t{{.Created}}\t{{.State.Status}}\t{{.HostConfig.NetworkMode}}";
const out = await run(["inspect", containerName, "--format", format]);
const [id, image, created, , networkMode] = out.split("\t");
let cpuUsage: string | null = null;
let memoryUsage: string | null = null;
if (state === "running") {
try {
const stats = await run(
["stats", containerName, "--no-stream", "--format", "{{.CPUPerc}}\t{{.MemUsage}}"],
10_000,
);
const [cpu, mem] = stats.split("\t");
cpuUsage = cpu?.trim() ?? null;
memoryUsage = mem?.trim() ?? null;
} catch { /* stats not available */ }
}
return {
id: id?.slice(0, 12) ?? "", name: containerName, state,
image: image ?? "", created: created ?? "",
uptime: state === "running" ? "running" : null,
cpuUsage, memoryUsage, diskUsage: null,
networkMode: networkMode ?? "", ports: [],
};
} catch {
return {
id: "", name: containerName, state, image: "", created: "",
uptime: null, cpuUsage: null, memoryUsage: null, diskUsage: null, networkMode: "", ports: []
};
}
}
export async function updateNetwork(
mode: NetworkMode,
opts: Parameters<typeof ensureReady>[0],
): Promise<void> {
const hadContainer = (await getContainerState()) !== "not_found";
if (!hadContainer) return;
const tempImage = `${containerName}-state:latest`;
if (opts.persistenceMode === "persistent") {
try { await run(["commit", containerName, tempImage], 60_000); } catch { }
}
await destroyContainer();
const useImage = opts.persistenceMode === "persistent" ? tempImage : opts.image;
containerReady = false;
await ensureReady({ ...opts, network: mode, image: useImage as any });
if (opts.persistenceMode === "persistent") {
try { await run(["rmi", tempImage], 10_000); } catch { }
}
}
export function isReady(): boolean { return containerReady; }
export function resetShellSession(): void {
if (shellSession) { shellSession.kill(); shellSession = null; }
}
export async function verifyHealth(): Promise<void> {
if (!containerReady) return;
try {
const state = await getContainerState();
if (state !== "running") {
containerReady = false;
currentNetwork = "none";
if (shellSession) { shellSession.kill(); shellSession = null; }
}
} catch {
containerReady = false;
currentNetwork = "none";
if (shellSession) { shellSession.kill(); shellSession = null; }
}
}
export function getContainerName(): string { return containerName; }