README
¶
contextmatrix-runner
[!WARNING]
This project is deprecated and no longer maintained. It was frozen when ContextMatrix added multi-user support, which the runner never gained: a ContextMatrix instance running the default
auth.mode: multirejects the runner at startup. It runs only in single-user (auth.mode: none) ContextMatrix instances and receives no new features. Use the contextmatrix-agent task backend instead.ContextMatrix support for this backend ends at the
runner-eostag of the contextmatrix repo — the last commit that can drive this runner. Later ContextMatrix releases speak protocol ≥ v0.8.0 and have removed the runner backend entirely.
A self-hosted runner that receives webhooks from ContextMatrix and spawns disposable Docker containers to execute autonomous AI tasks using Claude Code.
Architecture
HMAC-signed webhooks
┌──────────────────────────────────────────┐
│ ▼
┌──────────────┐ │ ┌───────────────────┐ ┌──────────────────────┐
│ Web UI │──┘ │ contextmatrix │ │ contextmatrix-runner │
│ (Run Now) │─────│ (REST API) │───►│ │
└──────────────┘ │ │ │ Docker containers │
│ POST /mcp │◄───│ (Claude Code) │
│ (MCP tools) │ │ │
└───────────────────┘ └──────────────────────┘
▲ │
│ MCP (Bearer auth) │
└─────────────────────────┘
ContextMatrix stores cards, manages state, and sends webhooks. It never touches code repositories.
contextmatrix-runner receives trigger/kill/stop-all webhooks, spawns disposable Docker containers per task. Each container runs Claude Code in headless mode, which connects back to ContextMatrix via MCP tools to claim cards, execute work, and report completion.
Prerequisites
- Go 1.26+
- Docker (daemon running)
- A running ContextMatrix instance
- A GitHub account with either a GitHub App or a fine-grained personal access token
Quick Start
# Build the runner
make build
# Build the worker Docker image
make docker-worker
# Copy the example config
cp config.yaml.example config.yaml
# Edit config.yaml with your settings (see Configuration below)
# Run the runner
./contextmatrix-runner -config config.yaml
Service Management
svc.sh manages contextmatrix-runner as a systemd user service. No root access
is required — it uses systemctl --user.
Prerequisite: Build the binary first (make build).
./svc.sh install # Generate service file, reload daemon, enable on login
./svc.sh install --dry-run # Print the generated unit file without touching systemd
./svc.sh start # Start the service
./svc.sh stop # Stop the service
./svc.sh status # Show service status
./svc.sh print # Print the generated unit file to stdout
./svc.sh verify # Print the unit and run `systemd-analyze --user verify`
# plus a grep-check that the expected hardening
# directives are present
./svc.sh uninstall # Stop, disable, and remove the service file
install writes the unit file to
~/.config/systemd/user/contextmatrix-runner.service. WorkingDirectory is set
to the directory containing svc.sh, and ExecStart passes --config pointing
at ${XDG_CONFIG_HOME:-~/.config}/contextmatrix-runner/config.yaml — the same
file redeploy.sh pins the worker digest into, so the service and the redeploy
flow always agree on one config.
redeploy.sh automates an in-place update: it rebuilds the binary and worker
image, pins the new image digest into that config, and restarts the service.
Upgrading from an older install where
config.yamllived next to the binary: move it to~/.config/contextmatrix-runner/config.yaml(creating the directory if needed) and re-run./svc.sh install.
The unit file is configured for graceful shutdown on stop: KillMode=mixed and
TimeoutStopSec=60 give running containers time to complete before being
force-killed. The service restarts on failure with exponential backoff:
Restart=on-failure, RestartSec=10, RestartSteps=5,
RestartMaxDelaySec=300 — capping a flaky-Docker restart storm at a five-minute
interval.
After=docker.service is intentionally not declared. The runner is a
systemctl --user unit, and the per-user systemd manager cannot gate on system
units like docker.service. Instead, the runner's preflight loop and /readyz
probe wait for dockerd to come up before the runner reports itself ready, so
ordering is enforced at the application level rather than at the unit level.
To validate the script with shellcheck:
make lint-sh
GitHub Setup
The runner requires credentials to generate a git token for each container. Two methods are supported — configure exactly one.
| Method | When to use |
|---|---|
| GitHub App | Recommended for most setups. Short-lived, repo-scoped tokens; private key never leaves the host. |
| Personal Access Token (PAT) | Use in GitHub Enterprise environments where App creation is restricted, or for quick testing. |
No inbound connections required. The runner only makes outbound HTTPS calls
to the GitHub API (api.github.com or your enterprise endpoint). It works on a
local LAN with no public domain.
GitHub App Setup
The runner uses a GitHub App to generate short-lived installation tokens for git operations inside containers. This is the most secure approach: the App's private key stays on the runner host, and only ephemeral tokens (valid for 1 hour) enter containers.
Step 1: Create the GitHub App
- Go to GitHub Settings → Developer settings → GitHub Apps → New GitHub App
- Fill in:
- GitHub App name: e.g.,
contextmatrix-runner(must be globally unique) - Homepage URL: anything (e.g.,
https://github.com/mhersson/contextmatrix-runner) - Webhook: uncheck Active (we don't receive webhooks from GitHub)
- GitHub App name: e.g.,
- Under Repository permissions, set:
- Contents: Read & Write (for clone and push)
- Pull requests: Read & Write (for creating PRs)
- Under Where can this GitHub App be installed?: select Only on this account
- Click Create GitHub App
- Note the App ID shown on the settings page
Step 2: Generate a Private Key
- On the App's settings page, scroll to Private keys
- Click Generate a private key
- A
.pemfile will be downloaded — save it securely (e.g.,~/.config/contextmatrix-runner/app.pem) - Set permissions:
chmod 600 ~/.config/contextmatrix-runner/app.pem
Step 3: Install the App
- Go to GitHub Settings → Applications (or the App's settings → Install App)
- Click Install next to your account/organization
- Choose Only select repositories and pick the repos you want the runner to access
- Click Install
- Note the Installation ID from the URL:
https://github.com/settings/installations/{INSTALLATION_ID}
You can add or remove repositories from the installation at any time through the GitHub UI — no runner configuration changes needed.
Step 4: Configure the Runner
Add the App credentials to your config.yaml:
github:
auth_mode: "app"
app:
app_id: 123456
installation_id: 78901234
private_key_path: "/home/you/.config/contextmatrix-runner/app.pem"
# For GitHub Enterprise Cloud with Data Residency (GHEC-DR) or GHES:
# host: "acme.ghe.com" # Env: CMR_GITHUB_HOST
# api_base_url: "https://api.acme.ghe.com" # Env: CMR_GITHUB_API_BASE_URL
For GitHub Enterprise, set github.host to the enterprise hostname. The runner
derives github.api_base_url as https://<host>/api/v3 (the standard GHES
pattern) when only host is set. Override api_base_url explicitly for non-
standard layouts such as GHEC-DR (https://api.acme.ghe.com). Leave both empty
for standard github.com. The git host inside containers is derived
automatically from the repo URL, so no extra git configuration is required. Set
the matching github.host (or github.api_base_url) in ContextMatrix so both
sides target the same enterprise instance.
For end-to-end setup of a GitHub App or PAT covering both the runner and the contextmatrix server, see contextmatrix's topology guide.
GitHub PAT Setup
Use a fine-grained personal access token when you cannot create a GitHub App (common in GitHub Enterprise environments with restricted App creation).
Note: PAT is mutually exclusive with github.app.* — configure exactly one
via github.auth_mode.
Step 1: Create a Fine-Grained PAT
- Go to GitHub Settings → Developer settings → Personal access tokens → Fine-grained tokens → Generate new token
- Set a descriptive name (e.g.,
contextmatrix-runner) and an expiration - Under Repository access, select the repositories the runner should access
- Under Repository permissions, set:
- Contents: Read & Write (for clone and push)
- Pull requests: Read & Write (for creating PRs)
- Click Generate token and copy it immediately
Step 2: Configure the Runner
Add the token to your config.yaml:
github:
auth_mode: "pat"
pat:
token: "github_pat_..." # Env: CMR_GITHUB_PAT_TOKEN
Or set it via environment variables:
export CMR_GITHUB_AUTH_MODE="pat"
export CMR_GITHUB_PAT_TOKEN="github_pat_..."
Leave the github.app.* fields at their defaults (zero/empty) when using a PAT.
Configuration
All fields can be overridden with environment variables using the CMR_ prefix.
See config.yaml.example for the fully-commented template.
# HTTP port for receiving webhooks from ContextMatrix.
# Env: CMR_PORT
port: 9090
# Admin port for Prometheus /metrics and /ready (bound to 127.0.0.1 only).
# 0 = disabled (default). Set a non-zero port (e.g. 9091) to enable scraping.
# Env: CMR_ADMIN_PORT
admin_port: 0
# Base URL of the ContextMatrix instance (for status callbacks).
# Env: CMR_CONTEXTMATRIX_URL
contextmatrix_url: "http://localhost:8080"
# URL that spawned containers use to reach ContextMatrix. Injected as CM_MCP_URL.
# Defaults to contextmatrix_url when unset. Override when containers need a
# different address (e.g. host.docker.internal).
# Env: CMR_CONTAINER_CONTEXTMATRIX_URL
# container_contextmatrix_url: "http://host.docker.internal:8080"
# Shared HMAC-SHA256 secret. Must match runner.api_key in ContextMatrix config.
# At least 32 characters.
# Env: CMR_API_KEY
api_key: "your-shared-secret-here-at-least-32-chars"
# Default Docker image for worker containers. MUST be @sha256:... pinned
# (enforced by Validate; mutable tags are rejected).
# Env: CMR_BASE_IMAGE
base_image: "contextmatrix/worker@sha256:<digest>"
# Allowlist of permitted Docker images. When set, only listed images may be
# used (including runner_image overrides from trigger payloads). When empty,
# only base_image is permitted. Every entry MUST also be @sha256:... pinned.
allowed_images: []
# When to pull the image: always, never, if-not-present.
# Use "never" or "if-not-present" for locally-built images.
# Env: CMR_IMAGE_PULL_POLICY
image_pull_policy: "never"
# Maximum simultaneous containers.
# Env: CMR_MAX_CONCURRENT
max_concurrent: 3
# Force-kill containers after this duration.
# Env: CMR_CONTAINER_TIMEOUT
container_timeout: "2h"
# Memory limit per container in bytes. Default: 8 GiB.
# Env: CMR_CONTAINER_MEMORY_LIMIT
container_memory_limit: 8589934592
# Maximum number of PIDs per container. Default: 512.
# Env: CMR_CONTAINER_PIDS_LIMIT
container_pids_limit: 512
# Claude Code authentication — at least one of the three options below is required.
# Priority order (highest to lowest): claude_auth_dir > claude_oauth_token > anthropic_api_key.
# Only the highest-priority configured method is passed to each container.
# Path to host's ~/.claude/ directory. OAuth tokens are mounted read-only into containers.
# Requires running `claude login` on the host first.
# Env: CMR_CLAUDE_AUTH_DIR
claude_auth_dir: "/home/you/.claude"
# Long-lived OAuth token generated by running `claude setup-token` on the host.
# Injected as CLAUDE_CODE_OAUTH_TOKEN env var in containers. Valid for ~1 year.
# Env: CMR_CLAUDE_OAUTH_TOKEN
claude_oauth_token: ""
# Anthropic API key. Injected as ANTHROPIC_API_KEY env var in containers.
# Note: API key usage incurs additional cost on top of your subscription.
# Env: CMR_ANTHROPIC_API_KEY
anthropic_api_key: ""
# Raw JSON written to /home/user/.claude/settings.json inside each container.
# Use this to configure Claude Code behaviour. Must be valid JSON if set.
# If invalid, the runner exits on startup.
# Env: CMR_CLAUDE_SETTINGS
# claude_settings: '{"includeCoAuthoredBy":false}'
# GitHub authentication. Configure auth_mode and the matching sub-block.
github:
auth_mode: "app" # CMR_GITHUB_AUTH_MODE — "app" (recommended) or "pat"
host: "" # CMR_GITHUB_HOST — GHE/GHEC-DR hostname; empty for github.com
api_base_url: "" # CMR_GITHUB_API_BASE_URL — override for non-standard enterprise URLs
# GitHub App credentials (required when auth_mode is "app").
app:
app_id: 0 # CMR_GITHUB_APP_ID
installation_id: 0 # CMR_GITHUB_INSTALLATION_ID
private_key_path: "" # CMR_GITHUB_PRIVATE_KEY_PATH
# Fine-grained PAT (required when auth_mode is "pat").
pat:
token: "" # CMR_GITHUB_PAT_TOKEN
# Log level: debug, info, warn, error.
# Env: CMR_LOG_LEVEL
log_level: "info"
Task skills
The runner can mount a curated set of Claude Code skills (Go, TypeScript/React,
etc.) into worker containers. ContextMatrix server resolves which skills apply
per card and sends the list in the /trigger payload; the runner copies the
resolved subset into ~/.claude/skills/ for Claude Code to discover.
One-time setup
# On the runner host:
sudo mkdir -p /var/lib/contextmatrix/task-skills
sudo chown <runner-user>:<runner-user> /var/lib/contextmatrix/task-skills
git clone <upstream-skills-repo> /var/lib/contextmatrix/task-skills
Config
# config.yaml
task_skills_dir: /var/lib/contextmatrix/task-skills
Sync model
The runner runs git pull --ff-only on task_skills_dir before each
/trigger. No cron. Pull failures log and continue with the existing local
clone — the trigger never aborts because of a sync issue.
Local-dev / single-host setup
When the runner and the CM server share a host, both task_skills_dir configs
can point at the same path. No upstream remote needed; edit in place.
Deployment profiles
The runner operates in one of two modes, set via deployment_profile in
config.yaml (or CMR_DEPLOYMENT_PROFILE env var). The default is
production.
| Profile | Behaviour |
|---|---|
production |
All validators enforced at full strength. |
dev |
Five validators relaxed for local single-box setups. Every relaxation logs a startup or per-request line — nothing is silent. |
Dev-mode relaxations
-
Unpinned image tags accepted —
base_image/allowed_imagesmay use mutable tags (e.g.contextmatrix/worker:latest). Each unpinned reference logs on startup:level=WARN msg="dev profile: accepting unpinned image reference" field=base_image image=... -
Wider HMAC replay window —
webhook_replay_skew_secondsdefaults to 86400 (24 h) instead of 330 s, so a laptop that slept still processes webhooks. Listed in the startup applied-defaults line:level=INFO msg="dev profile: applied defaults" defaults=[webhook_replay_skew_seconds=86400 image_pull_policy=if-not-present] -
Local image pull policy —
image_pull_policydefaults toif-not-presentinstead ofnever, so a freshly built worker image is pulled if missing. Shown in the same applied-defaults line as the skew default above. -
Configurable HMAC skew — the skew from
webhook_replay_skew_secondsis threaded into HMAC verification, so the wider dev-mode window takes effect. -
Chat resume files staged in tmp — when staging a chat session's rehydration files, the runner first tries
secrets_dir(default/var/run/cm-runner/secrets). If that directory is not writable (e.g. asystemctl --userservice running as a regular user), dev mode falls back to a subdir of the OS temp dir, with a WARN:level=WARN msg="dev profile: secrets_dir not writable, falling back to tmp for resume files" secrets_dir=... tmp_dir=... error=...The resume payload is operator-supplied transcript text, not a credential. Production mode fails closed on this error instead.
Secrets delivery: bind mount vs. env var
By default the runner stages the shared secrets file under secrets_dir
(default /var/run/cm-runner/secrets, should be tmpfs) and bind-mounts it
read-only into every worker at /run/cm-secrets/. Setting secrets_dir to an
empty string switches the runner to env-var delivery: secrets are folded
directly into each container's environment instead of a bind mount.
This is selected by the empty config value at boot, not by a write failure, and
it is not a dev-profile relaxation — it is reachable from any deployment.
It is unsupported in production: secrets land in the container's
HostConfig.Env, so docker inspect exposes them to anyone with read access to
the daemon. ApplyDefaults seeds the default path, so a real production config
never hits this fallback unless secrets_dir is explicitly cleared.
Minimal dev-mode config
A first-time user needs only these fields:
deployment_profile: dev
contextmatrix_url: "https://your-cm-instance"
api_key: "your-shared-secret-at-least-32-chars"
base_image: "contextmatrix/worker:latest"
anthropic_api_key: "sk-ant-..." # or claude_auth_dir / claude_oauth_token
github:
auth_mode: "app"
app:
app_id: 123456
installation_id: 78901234
private_key_path: "/path/to/app.pem"
ContextMatrix Configuration
On the ContextMatrix side, configure the runner connection in config.yaml:
# MCP endpoint authentication — bearer token for the /mcp endpoint.
# When set, the runner passes this to containers so Claude Code can
# authenticate with ContextMatrix via MCP tools.
mcp_api_key: "your-mcp-bearer-token"
# Runner integration.
runner:
enabled: true
url: "http://localhost:9090" # Runner's address
api_key: "same-shared-secret-as-runner" # Must match runner's api_key
Per-project overrides in .board.yaml:
remote_execution:
enabled: true # Enable for this project
runner_image: "my-org/custom-worker:v2" # Optional custom image
Container Lifecycle
- User clicks Run Now on an autonomous card in the ContextMatrix web UI
- ContextMatrix sends a signed
/triggerwebhook to the runner - Runner generates a git credential token (GitHub App installation token or PAT)
- Runner pulls the Docker image and starts a hardened container with:
- Debian bookworm-slim base with Go 1.26, Node.js 25, GitHub CLI, golangci-lint, gopls, and typescript-language-server (for Claude Code's LSP integration)
- Claude Code CLI pre-installed
- MCP config pointing to ContextMatrix
- Git credential token for clone/push operations
- Claude Code auth (OAuth dir mount, OAuth token, or API key — highest-priority configured method only)
- All capabilities dropped, no-new-privileges, memory and PID limits
- Claude Code runs the
run-autonomousworkflow:- Claims the card via MCP
- Clones the repo, plans, executes, reviews, documents
- Creates a feature branch and PR
- Completes the card via MCP
complete_task
- Container exits, runner cleans up
On kill: Container is destroyed immediately. All uncommitted work is discarded.
Worker Image
The worker image (docker/Dockerfile.worker) is Debian-based and runs
everything as a non-root user account (UID 1000). No privilege escalation or
dropping occurs — the Dockerfile sets USER user before the entrypoint.
LSP plugins
The worker image includes language servers that Claude Code can use via its built-in LSP plugins for richer code intelligence (go-to-definition, diagnostics, find references):
| Language | Binary | Plugin name (enabledPlugins key) |
|---|---|---|
| Go | gopls |
gopls-lsp@claude-plugins-official |
| TypeScript/JavaScript | typescript-language-server |
typescript-lsp@claude-plugins-official |
To enable, set enabledPlugins in claude_settings:
claude_settings: '{"enabledPlugins":{"gopls-lsp@claude-plugins-official":true,"typescript-lsp@claude-plugins-official":true}}'
The entrypoint allowlists Claude Code's LSP built-in tool in all modes, so
plugins work out of the box once enabled.
Adding support for a new language: install the LSP binary in
Dockerfile.worker (pin the version via an ARG), add the matching plugin name
to enabledPlugins in claude_settings, and rebuild the worker image. No
entrypoint or allowlist changes are needed — all plugins route through the
single LSP tool entry.
What happens inside the container
entrypoint.sh runs as user and performs these steps:
-
Auth setup — applies the highest-priority configured auth method:
claude_auth_dir: copies OAuth tokens from the mounted/claude-authinto~/.claude/claude_oauth_token: token is injected asCLAUDE_CODE_OAUTH_TOKENenv var at container creation time (no entrypoint logic needed)anthropic_api_key: injected asANTHROPIC_API_KEYenv var at container creation time (no entrypoint logic needed)
If
claude_settingsis configured, writes it to~/.claude/settings.jsonafter the optional auth-dir copy, so it always takes precedence.Also writes
.claude.json(MCP config),.netrc(GitHub token), and.gitconfig. -
Clone — clones the project repository into
/home/user/workspace. Whenbase_branchis set in the trigger payload, the clone uses-b <branch>so work starts from the correct base. The Claude Code prompt is also extended with an instruction to target that branch when creating PRs (gh pr create --base <branch>). -
Execute —
exec clauderuns Claude Code in headless mode, which connects to ContextMatrix via MCP tools to claim the card, execute the work, and report completion.
Webhook Protocol
All webhooks are signed with HMAC-SHA256 using a shared secret.
| Direction | Endpoint | Purpose |
|---|---|---|
| CM → Runner | POST /trigger |
Start a task |
| CM → Runner | POST /kill |
Stop a specific task |
| CM → Runner | POST /stop-all |
Stop all tasks (or per-project) |
| CM → Runner | POST /message |
Send a user message to an interactive session |
| CM → Runner | POST /promote |
Promote interactive session to autonomous |
| CM → Runner | POST /end-session |
Close container stdin so claude exits on EOF |
| CM → Runner | POST /chat/start |
Start a long-lived chat container for a global chat session. |
| CM → Runner | POST /chat/end |
End a tracked chat container (close stdin, force-stop, drop tracker entry). |
| Runner → CM | POST /api/runner/status |
Report container status |
| Runner → CM | POST /api/runner/skill-engaged |
Report that the agent engaged a task skill |
Signatures: X-Signature-256: sha256={hex}, X-Webhook-Timestamp: {unix-ts}.
HMAC is computed over method + "\n" + uri + "\n" + timestamp + "." + body,
where uri is the request-target form (path plus ?rawquery when present).
Method and URI are bound into the signature so a valid signature for one
endpoint cannot be replayed against another, and same-second GETs to the same
path with different query strings produce distinct signatures. The maximum
clock skew is webhook_replay_skew_seconds (default 330 s; 86400 s in the dev
profile).
Status callback values: running (container started), failed (error or
non-zero exit), completed (clean exit).
Trigger payload fields
| Field | Type | Required | Description |
|---|---|---|---|
card_id |
string | yes | Card identifier (e.g. PROJECT-019) |
project |
string | yes | Project name |
repo_url |
string | yes | Repository URL. HTTPS (https://github.com/org/repo) is the supported form; ssh:// URLs are accepted and rewritten to HTTPS. |
mcp_api_key |
string | no | Bearer token for MCP authentication |
base_branch |
string | no | Branch to clone and target for PRs. Defaults to the repo's default branch when omitted. |
runner_image |
string | no | Docker image override. Must be in allowed_images when that list is non-empty. |
interactive |
bool | no | When true, runs Claude in stream-json HITL mode and attaches to container stdin. Use /message and /promote to interact. |
model |
string | no | Model ID for the orchestrator (e.g. claude-sonnet-4-6). Passed through to the container environment. |
task_skills |
array | no | Resolved list of task-skill names (subset of CM's task_skills_dir). Runner copies the matching skill dirs into ~/.claude/skills/ inside the container before launching Claude Code. Pointer-typed on the wire so null/omitted is distinguished from an explicit empty list. |
API Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /trigger |
HMAC | Start a container for a card |
| POST | /kill |
HMAC | Kill a specific container (idempotent; 200 on already-stopped) |
| POST | /stop-all |
HMAC | Kill all containers (207 Multi-Status on partial failure) |
| POST | /message |
HMAC | Send a user message to an interactive (HITL) session |
| POST | /promote |
HMAC | Promote an interactive session to autonomous mode |
| POST | /end-session |
HMAC | Close stdin of an interactive container; claude exits on EOF |
| POST | /chat/start |
HMAC | Start a long-lived chat container for a global chat session (409 if the session already has a container, 429 when the chat concurrency cap is reached). |
| POST | /chat/end |
HMAC | End a tracked chat container: close stdin, force-stop, drop tracker entry (404 if no container is tracked). |
| GET | /logs |
HMAC | SSE log stream. Optional, mutually-exclusive query filters: ?project=<name> (card-mode / project-scoped) or ?session_id=<id> (chat-mode); supplying both is a 400, no filter streams every entry. Browser EventSource cannot send headers, so this endpoint must be proxied through a server that attaches the HMAC signature. |
| GET | /containers |
HMAC | List currently-running worker containers. Returns ListContainersResponse (one entry per container with container_id, card_id, project, state, RFC3339 started_at, and a tracked flag). CM uses this to age-cap runaway containers and to detect tracker drift (tracked=false while state="running" indicates an orphan). |
| GET | /health |
none | Health check |
| GET | /readyz |
none | Readiness probe (503 during preflight or drain) |
Response envelope
Every 2xx response is a SuccessResponse:
{ "ok": true, "message": "...", "message_id": "..." }
Every non-2xx response (except /logs SSE and the /readyz probe shape) is an
ErrorResponse:
{ "ok": false, "code": "invalid_field", "message": "invalid card_id" }
code is a stable enum — clients should branch on code, not on message
text. /stop-all returns a custom StopAllResponse with per-card results.
Error codes
| Code | Status | Endpoint(s) | Meaning |
|---|---|---|---|
invalid_json |
400 | all mutating endpoints | request body was not valid JSON |
invalid_field |
400 | all mutating endpoints | a field failed validation (message names the field) |
unauthorized |
401 | all HMAC-guarded endpoints | HMAC auth failed (missing header, bad sig, expired, …) |
not_found |
404 | /message, /promote, /end-session |
no container tracked for (project, card_id) |
conflict |
409 / 403 | /trigger, /message, /promote, /end-session |
state conflict (already tracked, non-interactive, …). /promote returns 403 + conflict when CM reports the card's autonomous flag is not set (fail-closed). |
duplicate |
409 | all HMAC-guarded endpoints | HMAC signature replay detected |
stdin_closed |
410 | /message, /promote |
session has ended (stdin was once attached, now closed) |
too_large |
413 | /message |
content exceeds 8192 bytes |
limit_reached |
429 | /trigger |
max_concurrent reached |
internal |
500 | any | server-side bug; details logged, never echoed |
upstream_failure |
502 | /promote |
CM verify-autonomous call failed |
draining |
503 | /trigger, /message, /promote, /end-session |
graceful shutdown in progress |
Raw err.Error() strings, HMAC-failure reasons, upstream response bodies, and
unmarshal byte offsets are never echoed to clients — they are logged server-side
only.
Security Model
Running AI agents in containers is a security boundary. The runner enforces defense-in-depth so that a compromised or misbehaving agent cannot escalate beyond its container.
Container hardening
Every container is launched with the following restrictions:
- All Linux capabilities dropped (
CapDrop: ALL). The container process runs with zero special privileges — it cannot modify network interfaces, mount filesystems, load kernel modules, or perform any other privileged operation, even as UID 0. - No new privileges (
no-new-privilegessecurity option). Prevents privilege escalation via setuid/setgid binaries inside the container. - Memory limit (default 8 GiB, configurable via
container_memory_limit). Prevents a runaway process from exhausting host memory. - PID limit (default 512, configurable via
container_pids_limit). Prevents fork bombs from consuming all host PIDs. - Image allowlist (
allowed_images). When set, only explicitly listed images may be used. When empty, only the configuredbase_imageis permitted. This prevents trigger payloads from requesting execution of arbitrary images. - Disposable containers. Each task gets a fresh environment, destroyed after completion. No state persists between runs.
Authentication and secrets
- HMAC-SHA256 webhook signing in both directions (shared secret, never transmitted)
- GitHub App tokens: short-lived (1 hour), repo-scoped, only ephemeral tokens enter containers. PAT alternative: the static token is injected as an env var per-container — use a token with minimal repository scope and set an expiration
- Read-only mounts: OAuth token directory mounted read-only when using
claude_auth_dir; long-lived OAuth tokens and API keys injected as env vars when using the other auth methods - Human-only controls: only humans can trigger Run/Stop from the CM web UI
- No inbound connections: runner only makes outbound calls to GitHub API and ContextMatrix
Troubleshooting
Container fails with "generate git token" error
GitHub App (auth_mode: "app"):
- Verify
github.app.private_key_pathpoints to a valid PEM file - Verify
github.app.app_idandgithub.app.installation_idare correct - Check that the GitHub App is installed on the target repositories
PAT (auth_mode: "pat"):
- Verify
github.pat.token(orCMR_GITHUB_PAT_TOKEN) is set and non-empty - Check the token has not expired and has
Contents: Read & WriteandPull requests: Read & Writepermissions on the target repositories
Container fails with git clone error
- Verify the repo URL in the ContextMatrix project config matches an installed repo
- The container only authenticates over HTTPS.
https://github.com/org/repois the supported form;ssh://URLs are accepted at webhook validation but rewritten to HTTPS before the container clones. SCP-style URLs (git@github.com:org/repo) are rejected at validation - Check that the GitHub App has "Contents: Read & Write" permission
- If the token expired (>1 hour task), retry — the new container gets a fresh token
"container limit reached" (HTTP 429)
- Increase
max_concurrentin the runner config - Or wait for running containers to finish
Runner can't connect to ContextMatrix for callbacks
- Verify
contextmatrix_urlis reachable from the runner host - Check that
api_keymatchesrunner.api_keyin ContextMatrix config
Containers can't reach ContextMatrix MCP endpoint
- The runner automatically adds a
host.docker.internalmapping to all containers, so this hostname works on both Docker Desktop and Linux - Verify
container_contextmatrix_urlin the runner config useshost.docker.internalor the host's LAN IP — notlocalhost - If it still fails, check Docker networking and firewall rules
Files in workspace owned by wrong user after container exits
The worker container runs as UID 1000. If the host user running the runner has a different UID, files created inside bind-mounted volumes are owned by UID 1000 on the host. This only matters for bind mounts — the default disposable container filesystem is discarded on exit.
Orphan containers after runner crash
The runner automatically cleans up orphan containers on startup (identified by
the contextmatrix.runner=true label). ContextMatrix detects the heartbeat
timeout (default 30 minutes) and marks the card as stalled.
Directories
¶
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
contextmatrix-runner
command
Command contextmatrix-runner receives webhooks from ContextMatrix and spawns disposable Docker containers to execute autonomous tasks.
|
Command contextmatrix-runner receives webhooks from ContextMatrix and spawns disposable Docker containers to execute autonomous tasks. |
|
internal
|
|
|
callback
Package callback sends HMAC-signed status callbacks to ContextMatrix.
|
Package callback sends HMAC-signed status callbacks to ContextMatrix. |
|
chatproto
Package chatproto holds constants shared between the chat webhook handler (CM's contract) and the container manager (host-side payload writer).
|
Package chatproto holds constants shared between the chat webhook handler (CM's contract) and the container manager (host-side payload writer). |
|
config
Package config handles loading and validation of runner configuration.
|
Package config handles loading and validation of runner configuration. |
|
container
Package container manages Docker container lifecycle for task execution.
|
Package container manages Docker container lifecycle for task execution. |
|
logbroadcast
Package logbroadcast provides a thread-safe fan-out broadcaster for runner log entries.
|
Package logbroadcast provides a thread-safe fan-out broadcaster for runner log entries. |
|
logparser
Package logparser processes Claude Code's stream-json output and logs relevant events using structured logging.
|
Package logparser processes Claude Code's stream-json output and logs relevant events using structured logging. |
|
metrics
Package metrics defines the Prometheus metric set exposed by the runner.
|
Package metrics defines the Prometheus metric set exposed by the runner. |
|
preflight
Package preflight runs the dependency smoke-tests the runner performs at startup and periodically until they succeed.
|
Package preflight runs the dependency smoke-tests the runner performs at startup and periodically until they succeed. |
|
secretenv
Package secretenv is the single source of truth for the list of environment-variable names whose values are runner-managed secrets.
|
Package secretenv is the single source of truth for the list of environment-variable names whose values are runner-managed secrets. |
|
tracing
Package tracing initialises an OpenTelemetry tracer provider.
|
Package tracing initialises an OpenTelemetry tracer provider. |
|
tracker
Package tracker maintains a thread-safe mapping of running containers.
|
Package tracker maintains a thread-safe mapping of running containers. |