diff --git a/docker/agent.Dockerfile b/docker/agent.Dockerfile new file mode 100644 index 00000000..dff176ab --- /dev/null +++ b/docker/agent.Dockerfile @@ -0,0 +1,50 @@ +# Codeman agent base image (built locally by scripts/build-agent-image.mjs). +# +# Contains the agent toolchain (node + the CLIs + git/tmux/ripgrep) but NO +# secrets: credentials are delivered at RUNTIME via bind mounts (~/.claude etc.) +# or name-only `docker exec --env`, never baked in, so `docker save` exports stay +# secret-free. tmux is a HARD prerequisite (the in-container tmux is what makes a +# reconnect durable), so it is installed here and probed before launch. +# +# HOME is made writable by an ARBITRARY host uid via the OpenShift "gid 0, +# group-writable" convention: on Linux we run `--user :0`, so the agent +# uid is the host uid (workspace files stay host-owned) while gid 0 keeps $HOME +# writable even though the uid is not the baked 1000. +FROM node:22-bookworm-slim + +# Base toolchain. `curl` is needed for the hook callbacks (`curl -sk $CODEMAN_API_URL`), +# `procps` for `ps`, `tmux` for the durable in-container session. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + git \ + tmux \ + ripgrep \ + curl \ + ca-certificates \ + less \ + procps \ + openssh-client \ + && rm -rf /var/lib/apt/lists/* + +# The agent CLIs (all four backends Codeman supports). Pinning is left to the +# rebuild cadence (see docs/docker-cases-plan.md, user-decision 2). +RUN npm install -g \ + @anthropic-ai/claude-code \ + @openai/codex \ + @google/gemini-cli \ + opencode-ai \ + && npm cache clean --force + +# `agent` user (uid 1000, gid 0) with an arbitrary-uid-writable HOME. +ENV HOME=/home/agent +RUN useradd -u 1000 -g 0 -m -d /home/agent -s /bin/bash agent \ + && mkdir -p /home/agent/.npm /home/agent/.cache /home/agent/.config /home/agent/.codeman \ + && chgrp -R 0 /home/agent \ + && chmod -R g=u /home/agent + +USER 1000:0 +WORKDIR /home/agent + +# Codeman overrides the command with `sleep infinity` at create time; this is the +# fallback so a hand-run container also idles rather than exiting. +CMD ["sleep", "infinity"] diff --git a/scripts/build-agent-image.mjs b/scripts/build-agent-image.mjs new file mode 100644 index 00000000..00602d24 --- /dev/null +++ b/scripts/build-agent-image.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node +/** + * Build the Codeman agent base image locally (decision: "build locally on first + * use", see docs/docker-cases-plan.md). No registry account required. + * + * Usage: + * node scripts/build-agent-image.mjs [--engine docker|podman] [--image ] [--no-cache] + * + * Defaults: engine=docker (falls back to podman if docker is absent), + * image=codeman/agent:base + */ +import { spawn, spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(__dirname, '..'); +const DOCKERFILE = join(REPO_ROOT, 'docker', 'agent.Dockerfile'); +const DEFAULT_IMAGE = 'codeman/agent:base'; + +function parseArgs(argv) { + const args = { image: DEFAULT_IMAGE, engine: undefined, noCache: false }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--image') args.image = argv[++i]; + else if (a === '--engine') args.engine = argv[++i]; + else if (a === '--no-cache') args.noCache = true; + else if (a === '-h' || a === '--help') args.help = true; + } + return args; +} + +function engineAvailable(engine) { + const r = spawnSync(engine, ['--version'], { stdio: 'ignore' }); + return r.status === 0; +} + +function resolveEngine(preferred) { + if (preferred) { + if (!engineAvailable(preferred)) { + console.error(`[build-agent-image] engine "${preferred}" not found on PATH`); + process.exit(1); + } + return preferred; + } + if (engineAvailable('docker')) return 'docker'; + if (engineAvailable('podman')) return 'podman'; + console.error('[build-agent-image] neither docker nor podman found on PATH. Install one and retry.'); + process.exit(1); +} + +const args = parseArgs(process.argv.slice(2)); +if (args.help) { + console.log('Usage: node scripts/build-agent-image.mjs [--engine docker|podman] [--image ] [--no-cache]'); + process.exit(0); +} + +const engine = resolveEngine(args.engine); +const buildArgs = ['build', '-f', DOCKERFILE, '-t', args.image]; +if (args.noCache) buildArgs.push('--no-cache'); +buildArgs.push(REPO_ROOT); + +console.log(`[build-agent-image] ${engine} ${buildArgs.join(' ')}`); +const child = spawn(engine, buildArgs, { stdio: 'inherit' }); +child.on('exit', (code) => { + if (code === 0) { + console.log(`\n[build-agent-image] built ${args.image}. Docker cases can now launch.`); + } else { + console.error(`\n[build-agent-image] build failed (exit ${code}).`); + } + process.exit(code ?? 1); +});