Coding Agent Runner

Windshift can run AI coding agents. Assign a work item to an agent. Windshift checks out the repository, runs the agent in an ephemeral container, pushes the run branch, and opens a draft pull request.

How execution works. The Windshift server orchestrates each run. It prepares and dispatches the run, but never starts an agent container. A separate windshift-runner claims work from a runner pool. Its Docker daemon starts one fresh agent container for each job, pushes the result branch, and reports the result.

SCM, LLM, and secret credentials stay on the Windshift server. Windshift brokers them to the agent at run time. The runner never receives them.

You can place runners in two ways:

  • On the same host as Windshift: Run windshift-runner beside Windshift in one Compose file. Connect both services through an internal Docker network. See Run a runner on the same host. This setup uses one machine and keeps agent execution out of the Windshift process.
  • On separate runner hosts: Use one or more dedicated machines. Docker and untrusted code then stay off the Windshift server, and you can scale across a pool.

Run a runner as a systemd service or Docker container. In both cases, it starts one fresh sandboxed agent container for each job.

This guide assumes you have already installed and are running Windshift.

Before you start

You need:

  • A Docker daemon on the runner host. Agent jobs run in ephemeral containers. The Windshift server does not need Docker.
  • A Git connection in the workspace so the agent can clone and push a repository.
  • An LLM connection in the workspace if the agent uses a managed model.
  • An agent binding in the workspace. A run starts when a work item is assigned to the bound agent or when the agent is @mentioned in a comment. You can also attach a skills library and custom instructions. See Coding Agents for workspace settings.

Set up a runner

Every runner host joins a runner pool and pulls work from Windshift. The simplest setup runs one runner on the same host. See Run a runner on the same host. Use dedicated runner hosts to keep Docker and untrusted code off the Windshift server or to scale execution.

The runner creates the egress network. It starts each agent container in a dedicated Docker network, coding-agent-egress by default. If the network does not exist, the runner creates it on first start. A plain bridge does not filter egress, so the runner logs a warning. The same-host Compose setup uses an internal network instead. It needs no firewall rules.

Run a runner as either:

  • A Docker container that talks to the host Docker daemon.
  • A systemd service on the host.

In both cases, the runner uses the host Docker daemon to start windshift-agent containers. The runner is not the agent. It claims jobs, prepares checkouts, starts containers, pushes result branches, and reports results.

1. Create a runner pool and registration token

In Windshift, an admin creates a runner pool and mints a registration token (wsrt_…). The token is a one-time bootstrap secret. On first start, the runner exchanges it for a per-instance credential (wsrc_…). The runner then stores and reuses that credential after restarts.

Use one registration token for each runner. For immutable deployments, inject an existing per-instance credential with WSRUNNER_CREDENTIAL.

2. Run the runner with Docker

This is the simplest remote-runner setup. The runner container uses the mounted socket to ask the host Docker daemon to start sibling agent containers.

services:
  windshift-runner:
    image: ghcr.io/windshiftapp/windshift-runner:latest
    restart: unless-stopped
    environment:
      WS_API_URL: https://windshift.example.com/api
      WSRUNNER_REGISTRATION_TOKEN: wsrt_your_one_time_token
      WSRUNNER_IMAGE: ghcr.io/windshiftapp/windshift-agent:latest
      # Optional display name shown in Windshift:
      # WSRUNNER_NAME: runner-1
    volumes:
      # Lets the runner start sibling agent containers on the host daemon.
      - /var/run/docker.sock:/var/run/docker.sock
      # Must be the same path on host and in the runner container.
      - /var/lib/windshift-runner:/var/lib/windshift-runner
    # Required on SELinux-enforcing hosts (Fedora, RHEL, CentOS Stream) —
    # see the SELinux note below before first start.
    # security_opt:
    #   - label=disable

SELinux hosts (Fedora / RHEL / CentOS Stream): read this before first start. SELinux blocks runner writes to /var/lib/windshift-runner, even when the container runs as root. The failure is permission denied. The audit log shows the actual SELinux denial.

The runner can still register, but it cannot save its per-instance credential. Registration tokens are single-use. The first start consumes the token, the credential is lost on restart, and registration then fails with 401 until you mint a new token.

Run the container with security_opt: ["label=disable"] in Compose or --security-opt label=disable with docker run. The usual :z volume relabel is not sufficient. The runner also needs the Docker socket, and relabeling /var/run/docker.sock affects the host daemon. Socket access still fails at the first job claim.

Disabling label confinement for this container is the standard Docker-out-of-Docker setup on SELinux hosts. The agent containers keep their own sandbox with a read-only root, dropped capabilities, and restricted network.

Start the runner:

docker compose up -d

The agent is not a long-running service. The runner starts a fresh sandboxed agent container for each job. The host pulls WSRUNNER_IMAGE when it first needs it.

The /var/lib/windshift-runner mount must use the same absolute path on the host and in the runner container. The host Docker daemon bind-mounts each prepared checkout into the agent container at /workspace.

Podman also works. With rootful Podman, point the socket mount to the Podman socket:

    volumes:
      - /run/podman/podman.sock:/var/run/docker.sock
      - /var/lib/windshift-runner:/var/lib/windshift-runner

Rootless Podman also works but needs extra care. It remaps user IDs, so the agent /workspace mount can have permission issues. Rootful Podman is simpler.

Run a runner on the same host

When the runner shares a host with Dockerized Windshift, put both services in one Compose file. Connect them through an internal Docker network. Agent containers use the same network.

An internal network has no route outside the host. Agents and the runner can reach Windshift, which brokers LLM and git traffic. They cannot reach anything else. Docker network isolation is the egress policy. You do not need a firewalld or iptables allowlist. Runner traffic does not use the public URL.

services:
  windshift:
    image: ghcr.io/windshiftapp/windshift:latest
    ports:
      - "8080:8080"
    tmpfs:
      - /tmp:exec,size=64M
    environment:
      - BASE_URL=https://windshift.example.com
      - SSO_SECRET=${SSO_SECRET}
      # In-network API address; the broker URLs handed to agent containers
      # resolve on the internal network below. Must end in /api.
      - CODING_AGENT_WS_API_URL=http://windshift:8080/api
    volumes:
      - windshift-data:/data
    networks:
      - default
      - coding-agent
    restart: unless-stopped

  windshift-runner:
    image: ghcr.io/windshiftapp/windshift-runner:latest
    environment:
      # In-Compose control plane. Plaintext HTTP is acceptable only because
      # this traffic never leaves the host's Docker bridge.
      WS_API_URL: http://windshift:8080/api
      WSRUNNER_ALLOW_INSECURE: "1"
      WSRUNNER_REGISTRATION_TOKEN: ${WSRUNNER_REGISTRATION_TOKEN}
      WSRUNNER_IMAGE: ghcr.io/windshiftapp/windshift-agent:latest
    volumes:
      # Lets the runner start sibling agent containers on the host daemon.
      - /var/run/docker.sock:/var/run/docker.sock
      # Must be the same path on host and in the runner container.
      - /var/lib/windshift-runner:/var/lib/windshift-runner
    networks:
      - coding-agent
    depends_on:
      - windshift
    restart: unless-stopped
    # SELinux-enforcing hosts: see the SELinux note above.
    # security_opt:
    #   - label=disable

networks:
  # Agent containers are spawned into this network — the name matches the
  # runner's default. "internal: true" means containers on it can reach each
  # other but nothing outside the host.
  coding-agent:
    name: coding-agent-egress
    internal: true

volumes:
  windshift-data:

Bootstrap in this order:

  1. Start Windshift: docker compose up -d windshift.
  2. Mint the pool registration token in the admin runner-pool view.
  3. Add it to .env as WSRUNNER_REGISTRATION_TOKEN.
  4. Start the remaining services: docker compose up -d.

Notes:

  • WSRUNNER_ALLOW_INSECURE=1 is safe only in this setup. Control-plane traffic stays on the host Docker bridge. Never use it across hosts.
  • The internal network does not affect image pulls. The host Docker daemon pulls WSRUNNER_IMAGE, not the runner container.
  • The runner can control the host Docker daemon. This setup trades the isolation of a dedicated runner host for convenience. Windshift does not access Docker. Only the runner does.

3. Or install the runner as a systemd service

Use the systemd installation when you prefer native host processes or do not want to containerize the runner.

Each host needs:

  • Linux with systemd
  • Docker
  • git
  • Outbound HTTPS to your Windshift server

Download windshift-runner, windshift-triage, and the runner installer from your Windshift release. Then run:

sudo ./install.sh --bin-dir ./dist

The installer creates a systemd service, its user, and its working directory. It installs windshift-runner and windshift-triage. It writes the configuration file at /etc/windshift-runner/runner.env.

Edit /etc/windshift-runner/runner.env:

WS_API_URL=https://windshift.example.com/api
WSRUNNER_REGISTRATION_TOKEN=wsrt_your_one_time_token
WSRUNNER_IMAGE=ghcr.io/windshiftapp/windshift-agent:latest

Then pull the agent image and start the service:

docker pull ghcr.io/windshiftapp/windshift-agent:latest
sudo systemctl start windshift-runner
journalctl -u windshift-runner -f

The runner registers and saves its per-instance credential. It then claims and runs jobs from its pool.

Runner configuration reference

Variable Required Default Purpose
WS_API_URL - Windshift API base URL ending in /api (for example https://host/api). The runner control plane and brokers live here. This is not /rest/api/v1 or the bare host URL. HTTPS is required unless WSRUNNER_ALLOW_INSECURE=1 is set for development.
WSRUNNER_REGISTRATION_TOKEN first bootstrap - One-time pool registration token (wsrt_…). The runner exchanges it for a per-instance credential. It does not need the token after it stores the credential.
WSRUNNER_CREDENTIAL optional - Inject an existing per-instance runner credential (wsrc_…) instead of registering with a token. Use this for immutable deployments.
WSRUNNER_CREDENTIAL_FILE optional <cache>/credential Path where the runner stores/reuses its per-instance credential.
WSRUNNER_IMAGE - Agent container image to run, usually ghcr.io/windshiftapp/windshift-agent:latest.
WSRUNNER_NAME hostname Name shown for this runner.
WSRUNNER_DOCKER docker Docker-compatible CLI to invoke.
WSRUNNER_TRIAGE_BIN windshift-triage Path to the triage helper used for git prepare/push.
WSRUNNER_CACHE_ROOT /var/lib/windshift-runner/cache Host-local bare-clone cache. Keep it under the same-path bind mount when running the runner in Docker.
WSRUNNER_POLL_INTERVAL 2s How often to check for work when idle.
WSRUNNER_HEARTBEAT_INTERVAL 30s How often to renew the runner lease.
WSRUNNER_INITIAL_PROMPT server prompt Emergency fallback only; normal jobs receive the server-managed prompt in the claim.
WSRUNNER_ALLOW_INSECURE unset Set to 1 to allow a plaintext http:// WS_API_URL. Only for local development, or for the same-host Compose setup where runner traffic never leaves the host's Docker bridge.
WSRUNNER_ALLOW_UNLABELED_IMAGE unset Set to 1 to accept an agent image that lacks the agent-contract label. The runner otherwise refuses to start an unlabeled image. Use only for custom images you build yourself.

Security

  • SCM and LLM credentials stay on the Windshift server. Remote runners authenticate with a per-instance credential and per-run tokens. Windshift brokers inject provider credentials server-side.
  • The agent container has no raw provider credentials. It uses short-lived run tokens and broker URLs.
  • Each run is sandboxed. Agents run as a non-root user in a container with a read-only root filesystem, dropped capabilities, tmpfs scratch space, and the configured Docker network.
  • A run can push only its own branch (agent-runs/run-<id>). The git proxy permits pushes only to the granted ref.
  • Docker access is high privilege. The runner controls the Docker daemon on its host, which effectively grants root access. Use dedicated disposable runner hosts. The Windshift server does not need Docker access.
  • Registration tokens are single-use. Remove WSRUNNER_REGISTRATION_TOKEN after first bootstrap when your deployment process permits it. The runner uses its persisted per-instance credential after restart.

Troubleshooting

  • Runs never start: Confirm that the pool exists and WSRUNNER_IMAGE is set. Make sure the runner Docker daemon can pull the agent image.
  • Runner says WS_API_URL must be https://: Use an HTTPS URL that ends in /api. Or set WSRUNNER_ALLOW_INSECURE=1 only for local development or same-host Compose.
  • Agent cannot reach Windshift: Check WS_API_URL and runner outbound connectivity. If agent containers cannot reach BASE_URL, set CODING_AGENT_WS_API_URL on the Windshift server. localhost inside a container is not the Windshift server.
  • Permission denied talking to Docker: Make sure the runner process or container can access the Docker daemon.
  • Remote git prep or push fails: Confirm that windshift-triage and git are available. Make sure the runner can reach WS_API_URL over HTTPS.
  • Runs fail with prepare checkout: ... setup askpass: stat /tmp: no such file or directory: The runner host or container has no usable /tmp. Mount tmpfs: ["/tmp:exec,size=64M"] on the runner. The runner uses this location for git scratch space. Without exec, git cannot execute the askpass credential helper. See The /tmp tmpfs mount.
  • Runner asks for a registration token after restart: Make sure WSRUNNER_CREDENTIAL_FILE is writable and persistent. In Docker, mount /var/lib/windshift-runner as a volume.
  • Runner logs warning: could not persist credential ... permission denied and gets 401 after restart (Fedora/RHEL/CentOS): SELinux blocked writes to /var/lib/windshift-runner, even when the container runs as root. Recreate the container with --security-opt label=disable. Then mint a new registration token because the previous registration consumed the old token without saving the credential. The lease reaper auto-revokes the orphaned instance.

See Environment Variables for the full configuration reference. See Coding Agents to connect editor agents such as Claude Code or Cursor to the ws CLI.