Something went wrong. Try again.
A collaborative coding-agent orchestrator for atproto radl.app
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222import { spawn } from 'node:child_process'
export interface ContainerMount { source: string target: string readOnly: boolean}
export type ContainerOutputStream = 'stdout' | 'stderr'
export interface ContainerSpec { label: string image: string argv: string[] env: Record<string, string> mounts: ContainerMount[] network?: string /** `--add-host` entries (e.g. `host.docker.internal:host-gateway`), needed on Linux for the * dev-only tcp turn transport to reach the host; Docker Desktop resolves it natively already. */ extraHosts?: string[] workdir?: string timeoutMs: number tmpfs?: string[] pidsLimit?: number memory?: string user?: string readOnlyRootfs?: boolean /** Called as the container writes output, so a long-running agent remains observable. */ onOutput?: (stream: ContainerOutputStream, chunk: string) => void}
export interface ContainerRunResult { exitCode: number timedOut: boolean /** Bounded process output, retained by the caller for turn diagnostics. */ stdout?: string stderr?: string}
export interface ContainerRunner { run(spec: ContainerSpec): Promise<ContainerRunResult> listByLabel(label: string): Promise<string[]> kill(idOrName: string): Promise<void>}
const DEFAULT_PIDS_LIMIT = 512const DEFAULT_USER = '1000:1000'const MAX_CAPTURED_OUTPUT = 64 * 1024
/** The non-root harness user needs a private, writable home on a read-only root filesystem. */export const RADIAL_HOME_TMPFS = '/home/radial:uid=1000,gid=1000,mode=700'
/** A managed-auth turn runs as the daemon's host uid/gid so its 0600 scratch auth file is usable. */export function radialHomeTmpfs(uid: number, gid: number): string { return `/home/radial:uid=${uid},gid=${gid},mode=700`}
/** Keep the tail: command failures conventionally print their useful diagnosis last. */function appendCapturedOutput(existing: string, chunk: Uint8Array, decoder: TextDecoder): string { const combined = existing + decoder.decode(chunk) if (combined.length <= MAX_CAPTURED_OUTPUT) return combined return `[... output truncated; retaining last ${MAX_CAPTURED_OUTPUT} characters ...]\n${combined.slice(-MAX_CAPTURED_OUTPUT)}`}
/** * Pure `docker run` argv builder (design §13): dropped capabilities, no-new-privileges, a pids * cap, a non-root user, and a read-only rootfs by default. `--name` mirrors `--label` so a * timed-out run can be killed by name (docker does not support killing by label directly). */export function dockerRunArgs(spec: ContainerSpec): string[] { if (spec.network?.toLowerCase() === 'host' || /^container:/i.test(spec.network ?? '')) throw new Error('host and container networks are not permitted for Radial containers') const args: string[] = ['run', '--rm', '--name', spec.label] args.push('--cap-drop=ALL') args.push('--security-opt', 'no-new-privileges') args.push('--pids-limit', String(spec.pidsLimit ?? DEFAULT_PIDS_LIMIT)) if (spec.memory !== undefined) args.push('--memory', spec.memory) args.push('--user', spec.user ?? DEFAULT_USER) if (spec.readOnlyRootfs !== false) args.push('--read-only') for (const entry of spec.tmpfs ?? []) args.push('--tmpfs', entry) args.push('--label', spec.label) if (spec.network !== undefined) args.push('--network', spec.network) for (const entry of spec.extraHosts ?? []) args.push('--add-host', entry) if (spec.workdir !== undefined) args.push('-w', spec.workdir) for (const mount of spec.mounts) { args.push('-v', `${mount.source}:${mount.target}${mount.readOnly ? ':ro' : ''}`) } for (const key of Object.keys(spec.env)) args.push('-e', key) args.push(spec.image, ...spec.argv) return args}
function execDocker(args: string[], env?: Record<string, string>): Promise<{ code: number; stdout: string; stderr: string }> { return new Promise((resolvePromise, reject) => { const child = spawn('docker', args, env ? { env } : undefined) const decoder = new TextDecoder() let stdout = '' let stderr = '' child.stdout?.on('data', (chunk) => { stdout += decoder.decode(chunk) }) child.stderr?.on('data', (chunk) => { stderr += decoder.decode(chunk) }) child.on('error', reject) child.on('close', (code) => resolvePromise({ code: code ?? -1, stdout, stderr })) })}
/** * Real Docker execution. Docker is not available in this development environment, so only * `dockerRunArgs` and construction are unit-tested — `run`/`listByLabel`/`kill` are exercised in * the real-docker end-to-end test instead. */export class DockerRunner implements ContainerRunner { async run(spec: ContainerSpec): Promise<ContainerRunResult> { const args = dockerRunArgs(spec) return new Promise((resolvePromise, reject) => { const env: Record<string, string> = {} for (const [key, value] of Object.entries(process.env)) if (value !== undefined) env[key] = value Object.assign(env, spec.env) const child = spawn('docker', args, { env }) const stdoutCaptureDecoder = new TextDecoder() const stderrCaptureDecoder = new TextDecoder() const stdoutLiveDecoder = new TextDecoder() const stderrLiveDecoder = new TextDecoder() let stdout = '' let stderr = '' let settled = false const timer = setTimeout(() => { if (settled) return settled = true // Await the kill before resolving: `runTurn`'s teardown (closing the socket, removing // runDir) happens immediately after `run` resolves, and must never race a container that // is still in the process of being killed. `settled` is already true above, so the // 'close' event the kill triggers on `child` below is a no-op, not a double-settle. void (async () => { try { await this.kill(spec.label) } catch (error) { reject(error instanceof Error ? error : new Error(String(error))) return } resolvePromise({ exitCode: -1, timedOut: true, stdout, stderr }) })() }, spec.timeoutMs) child.stdout?.on('data', (chunk: Uint8Array) => { stdout = appendCapturedOutput(stdout, chunk, stdoutCaptureDecoder) spec.onOutput?.('stdout', stdoutLiveDecoder.decode(chunk, { stream: true })) }) child.stderr?.on('data', (chunk: Uint8Array) => { stderr = appendCapturedOutput(stderr, chunk, stderrCaptureDecoder) spec.onOutput?.('stderr', stderrLiveDecoder.decode(chunk, { stream: true })) }) child.on('error', (error) => { if (settled) return settled = true clearTimeout(timer) reject(error) }) child.on('close', (code) => { if (settled) return settled = true clearTimeout(timer) resolvePromise({ exitCode: code ?? -1, timedOut: false, stdout, stderr }) }) }) }
async listByLabel(label: string): Promise<string[]> { const result = await execDocker(['ps', '-aq', '--filter', `label=${label}`]) if (result.code !== 0) throw new Error(`docker ps failed (exit ${result.code}): ${result.stderr}`) return result.stdout .split('\n') .map((line) => line.trim()) .filter((line) => line.length > 0) }
async kill(idOrName: string): Promise<void> { const result = await execDocker(['kill', idOrName]) if (result.code !== 0 && !/no such container/i.test(result.stderr)) { throw new Error(`docker kill failed (exit ${result.code}): ${result.stderr}`) } }}
/** * In-memory stand-in for a container runner: `run` simply invokes the injected function (turn * tests play a fake harness against `spec.env.RADIAL_SIDECAR_SOCKET`). `listByLabel`/`kill` track * started instances so orphan-reconciliation logic can be exercised without Docker. */export class FakeContainerRunner implements ContainerRunner { readonly #fn: (spec: ContainerSpec) => Promise<ContainerRunResult> readonly #started = new Map<string, Set<string>>() #counter = 0
constructor(fn: (spec: ContainerSpec) => Promise<ContainerRunResult>) { this.#fn = fn }
async run(spec: ContainerSpec): Promise<ContainerRunResult> { this.#counter += 1 const id = `${spec.label}#${this.#counter}` const set = this.#started.get(spec.label) ?? new Set<string>() set.add(id) this.#started.set(spec.label, set) return this.#fn(spec) }
async listByLabel(label: string): Promise<string[]> { return [...(this.#started.get(label) ?? [])] }
async kill(idOrName: string): Promise<void> { for (const [label, set] of this.#started) { if (set.delete(idOrName)) { if (set.size === 0) this.#started.delete(label) return } } }}