ai-kodu-runner
ai-kodu-runner is a small native Rust worker that runs versioned jobs in one-shot Linux containers. On Linux it uses Docker Engine; on macOS Docker Desktop is used through the same Docker Engine API. OpenCode, Node, Flutter and browser tooling belong in prebuilt job images, not in the runner.
Quick start
Install Rust stable and Docker Desktop/Engine, then:
Runnable examples are documented in examples/cases/README.md: a successful Alpine job, per-job secret injection, an expected failure, and a Flutter/OpenCode verifier template.
Product direction, compatibility, and architectural decisions are documented
in docs/ROADMAP.md, docs/SECURITY_MODEL.md,
docs/SUPPORT.md, docs/VERSIONING.md, and
docs/decisions/. In particular, ADR 0002 defines the
Community/Enterprise boundary, ADR 0003 defines durable Enterprise delivery and
trust boundaries, and ADR 0004 makes the threat model an implementation gate.
For local and self-hosted CI operation, see docs/COMMUNITY_OPERATIONS.md.
The public HTTP contract is published as OpenAPI 3.1
with versioned JSON schemas.
The local example uses the current directory as a workspace and network: none. For workspace.kind = local, the path must be inside configured work_dir. archive_url accepts HTTPS tar archives only; absolute paths, .., symlinks and hardlinks are rejected.
Git workspaces clone and check out the requested branch, then fetch
base_branch so agents can inspect the implementation diff. The optional
head_sha and base_sha fields make checkout validation immutable: the job
fails during preparation if either fetched revision differs. publish_mode
controls runner-managed commits and accepts disabled, if_changed (the
backward-compatible default), or required. A clean if_changed workspace is
a successful no-op; a clean required workspace fails explicitly.
Commands
ai-kodu-runner doctor
ai-kodu-runner run --job ./job.json [--config ./runner.toml]
ai-kodu-runner daemon --config ./runner.toml
ai-kodu-runner cleanup --config ./runner.toml
ai-kodu-runner version
doctor pings Docker, reports host/API/capabilities, and creates/removes a disposable Alpine container. The daemon leases jobs from the HTTP control plane, sends Bearer-authenticated completion requests, and stops cleanly on Ctrl-C. Docker container architecture is configured with [docker].platform and defaults to linux/amd64; RUNNER_PLATFORM overrides it for local or host-specific deployments. Values must use os/architecture format, optionally with a variant such as linux/arm64/v8.
Architecture and safety
The Cargo workspace is split into runner-protocol, runner-core, and
executor-docker; the root package keeps the Community CLI, daemon and HTTP
adapter. Executor and ControlPlane are the extension points for future
backends. Before execution, the factory admits a job only when the selected
executor satisfies its isolation and capability requirements; the Community
Docker executor supports container isolation only.
Every job gets a temporary copied workspace, managed labels, a private network when requested, a read-only root filesystem, /tmp tmpfs, dropped capabilities, no-new-privileges, CPU/memory/PID limits, bounded logs, and a timeout. The runner never accepts a Docker HostConfig from the server. It does not mount the host Docker socket, use privileged mode, host network/PID/IPC, arbitrary devices, or arbitrary host mounts. Only configured environment variable names may cross the boundary, and values are never written to the SQLite journal.
Workspace staging prunes generated directories named .cache, .ai-kodu-runner,
.dart_tool, .runner-cache, build, and node_modules at any depth. They must
be recreated by setup commands or the toolchain inside the isolated job. This
keeps dependency caches and OpenCode state out of source snapshots.
The default root filesystem is read-only. The Docker executor automatically
provides ephemeral writable tmpfs mounts for /tmp and the standard
/home/opencode config, state, data, npm, and pub-cache directories. A job may
still explicitly set "writable_rootfs": true when its image requires writes
outside those paths, for example when Flutter updates its SDK cache under
/usr/local/flutter/bin/cache. Such a relaxation is visible in the JobSpec and
should only be allowed for trusted, pinned images.
Tool images keep HOME and all XDG directories under /home/opencode, never
under /workspace. Project opencode.json files disable snapshots for
non-interactive jobs. Downloads that are worth reusing should be baked into the
image or provided by a future runner-managed cache volume; OpenCode session DBs,
logs, locks, and snapshot repositories must not be shared as dependency caches.
The sample one-shot images use OPENCODE_DB=:memory:; remove that override only
when a product flow explicitly resumes an OpenCode session from durable storage.
Task-specific secrets can be supplied through JobSpec.secrets, for example { "name": "OPENAI_API_KEY", "value": "..." }. The value is held in memory and injected only into that job's container. Known runner-managed values are redacted from failure diagnostics; arbitrary secrets printed by user code cannot be reliably detected. Secret names must be allowed by runner policy and are size-limited. secret_ref is part of the protocol for external secret resolvers, but the Community executor rejects it closed because it cannot resolve references safely. Failed results may include a typed failure with a stable kind and code. The legacy environment_from_runner field remains supported for local secrets supplied by the runner process.
The SQLite journal records the explicit lifecycle received → preparing → running → collecting → completed|failed|cancelled|timed_out → destroying → destroyed. Cleanup filters Docker resources by both ai-kodu-runner.managed=true and the local runner ID, so another runner's resources are out of scope. Janitor/cleanup is deliberately conservative; resources are not removed solely because they have a similar name.
For local run, set attempt to 0 (or omit it). The runner allocates the next
attempt number for that job_id from SQLite, so repeated runs create separate
directories such as artifacts/<job_id>/1, artifacts/<job_id>/2, and so on.
Daemon jobs keep the attempt number supplied by the control plane.
For production, use immutable digest-pinned images in daemon jobs and a control plane that authenticates leases and makes completion idempotent. The HTTP adapter uses bounded connect/request timeouts, renews leases with periodic typed heartbeats, cancels work after an explicit server decision or consecutive heartbeat failures, and sends a stable Idempotency-Key derived from (job_id, attempt) for bounded completion retries. Community persists pending completions in its local SQLite journal and replays them on daemon startup; a shared durable completion queue remains an Enterprise control-plane concern.
Agent + verifier workflow
JobSpec.workflow is the generic feedback loop. The runner does not know whether the
workspace contains PHP, Flutter, React, Rust or anything else. It executes only the
argv arrays declared by the job:
"workflow":
The agent runs first, then verifiers run independently. Failed verifier output is
written to feedback_file; the next agent iteration receives that file through the
job's command. A job is completed only when all required verifiers pass. An optional
workflow.publish argv command runs after all checks pass.
Workflow jobs may also declare disposable backend services. Services are started
on the job's private Docker network before setup, and are removed after artifact
collection. They never publish ports on the host. A service healthcheck is an argv
array executed from the runner until it succeeds:
"services":
Services require network.mode = "bridge"; the application reaches them by
their aliases (db, redis, etc.). Runner-level mandatory verifiers can be
configured under [security].mandatory_verifiers in runner.toml. They run in
addition to job verifiers and cannot be omitted by a JobSpec.
Browser and device testing
Browser tests are supported by putting Playwright or Cypress and the required browser binaries into the job image, then declaring the test command as a verifier. A browser service can also be started as a service container when the application is tested over the private job network.
An Android emulator is different: it normally needs KVM and /dev/kvm, a
privileged or specially configured container, and often a nested-virtualization
capable host. The current runner deliberately does not expose devices or allow
privileged containers, so it should not run Android emulators inside ordinary
jobs. The safe extension is a separate device executor/worker with an explicit
capability such as android-emulator, isolated device allocation, timeout and
cleanup. The job then calls that worker or uses an external device farm. iOS
simulators require a macOS worker and cannot be provided by a Linux Docker
container.
Artifact export is bounded by both limits.max_artifact_bytes and
limits.max_artifact_files. Broad patterns such as "**" do not descend into
generated/cache directories. A precise prefix such as "build/**" can opt in to
a required generated tree. Prefer narrow source and report patterns so collection
does not duplicate dependency caches after every job.
Workspace preparation is bounded by limits.max_workspace_bytes,
limits.max_workspace_files, and (for HTTPS tar archives)
limits.max_archive_download_bytes. Archive traversal, links and device/FIFO
entries are rejected. Preparation also observes the job cancellation token and
deadline, so a stalled Git or archive source cannot run outside the job budget.
Failure diagnostics contain only bounded, known-secret-redacted stdout/stderr;
prompt, AGENTS.md, and feedback files are never copied into diagnostics.
Git and merge requests remain provider-neutral: use setup for clone/fetch/checkout
and publish for push or a provider CLI such as glab mr create/gh pr create.
Credentials must be supplied as per-job secrets or runner-approved environment names;
never put tokens in clone URLs or prompts. The image must contain the selected Git or
provider CLI. This keeps the Rust runner independent of GitLab, GitHub and project
languages.
Flutter verifier job
examples/flutter-agent-job-local.json is the runnable local version of that
workflow. The image must contain Flutter, Dart, OpenCode, Node/npm and the verifier
CLIs. Its setup phase installs the official Flutter and Dart skills into the project
for OpenCode. The agent then creates or updates AGENTS.md itself; this is used
instead of relying on the interactive /init TUI command.
Verify the image before running the job:
The workflow also has an explicit optional initialize phase. It runs a separate
non-interactive opencode run before coding and is responsible only for creating or
updating AGENTS.md; the main agent then receives that context.
The project OpenCode config enables Dart LSP with "lsp": true. The Flutter image
already provides the dart executable, so OpenCode can start its built-in Dart
server when it opens .dart files. LSP assists with diagnostics and navigation;
the authoritative acceptance checks remain flutter analyze, formatting, tests
and builds.
The Context7 MCP configuration is project-local OpenCode configuration; keep its key
out of Git and inject it through JobSpec.secrets or approved runner environment
names. The remote MCP endpoint requires bridge networking.
Development checks
The unit tests cover policy clamping, lifecycle transitions and archive/path safety. Docker-dependent checks should be run when Docker is available and are expected to skip in CI environments without a daemon.
License and third-party components
This project is licensed under the Apache License, Version 2.0. See
LICENSE.md. The main third-party components and tool-image
dependencies are listed in docs/THIRD_PARTY_NOTICES.md.
The published Docker images contain software from their base images and package managers. Their exact dependency inventory may change with pinned runtime and base-image versions; releases should be accompanied by the CI-generated SBOM.