Create a docker image with all the need to be a runner #13

Open
opened 2026-11-03 16:26:35 +00:00 by SebastienDenooz · 4 comments

In the runner directory, create a Dockerfile and a docker-compose configuration that allow for the complete setup of a runner via Docker.

The image must include the CLIs for all compatible platforms (forges), as well as all compatible agents—such as Claude Code, Codex, Copilot, etc.

All configurations for these agents and CLIs must be mapped within the docker-compose file to link to your local configuration, allowing them to be used directly.

Upon starting the container, a web interface should be available to configure any components that have not yet been set up. This interface will also serve to verify and display the current configuration status of each component.

Functional analysis

Expected behaviour

No answers arrived by the deadline, so the defaults for q1–q10 apply.

  • What it is (q1, q2): a new runner/ directory holds a Dockerfile and a docker-compose configuration for a dockerized host runner. This is a long-running container that runs only the Alfred queue worker (and scheduler). It connects to an existing Alfred instance's database and Redis, which are set through environment variables in docker-compose. On /instance it is declared as the host runner (@host, §17.7). It is not a per-run runner_images image (§17.3).
  • What the image contains (q5, q9):
    • All three supported forge CLIs: GitHub gh, GitLab glab and Forgejo fj.
    • All five supported local agent CLIs: Claude Code, Codex, OpenCode, Copilot CLI and Grok Build.
    • git, an SSH client and common build tools.
    • Each CLI version is pinned as a build argument with a default value.
    • The image builds for linux/amd64 and linux/arm64.
  • Configuration mapping (q6, q7):
    • docker-compose bind-mounts the usual host configuration paths read-write: ~/.claude, ~/.claude.json, ~/.codex, ~/.config/opencode, ~/.copilot, ~/.config/gh, ~/.config/glab-cli, the fj configuration and ~/.gitconfig.
    • Each path can be overridden in a .env file.
    • Logins already made on the host work in the container without extra steps.
    • Anything configured from the container persists on the host.
    • ~/.ssh is not mapped. git uses HTTPS with the forge tokens.
  • Setup interface (q3, q4, q8):
    • On startup, the container serves its own small web interface on a dedicated port, separate from the Alfred application.
    • For each component, the interface shows whether it is installed, its version, and whether it is configured/authenticated.
    • For a component that is not set up, the user can enter tokens or API keys (for example GH_TOKEN, GITLAB_TOKEN, Forgejo token, ANTHROPIC_API_KEY, OPENAI_API_KEY). These are written to the mapped configuration. Interactive or device-code logins are not part of this scope.
    • The interface is published only on 127.0.0.1. It is protected by a token taken from .env, or generated and printed in the container logs when .env does not set one.
  • CI (q10): GitLab CI and Forgejo/GitHub Actions build the image and run a smoke test. The test checks that every CLI answers --version and that the interface responds. Nothing is pushed to a registry.

Acceptance criteria

  1. Running docker compose up in runner/, with a .env that gives the Alfred database and Redis connection, builds and starts the runner. No other manual step is needed.
  2. The container runs the Alfred queue worker and scheduler against the configured instance. That instance can use it as its @host runner.
  3. gh, glab, fj, Claude Code, Codex, OpenCode, Copilot CLI and Grok Build are all present in the container, and each answers --version.
  4. A CLI that is already logged in on the host shows as configured in the container, through the read-write compose mappings. Each mapped path can be overridden in .env.
  5. ~/.ssh is not mounted into the container.
  6. Once the container starts, the setup interface is reachable on 127.0.0.1 only. It lists every component with its installed status, version and configured status.
  7. A request to the interface without the token is refused. When .env sets no token, one is generated and printed in the container logs.
  8. A token or API key entered in the interface for an unconfigured component is written to the mapped configuration. The component then shows as configured, also after a container restart, and the value is present on the host.
  9. CLI versions are build arguments with pinned defaults. The image builds for linux/amd64 and linux/arm64.
  10. Both GitLab CI and Forgejo/GitHub Actions build the image and run the smoke test (CLI versions answer, interface responds). Neither pipeline publishes the image.

Specification updates required

  • §17.3 says "No image ships with the application". This needs a note that the repository now ships a dockerized host runner setup in runner/. Per-run runner images are still not shipped.
  • §17.7 (host runner) needs a section on running the host runner through runner/docker-compose. It should cover the mapped configurations, the setup interface and how that interface is protected.
  • §17.1 and §17.2 are unchanged. Forge CLIs and mapped home configurations live only in the host-runner container, never in a per-run container.
In the `runner` directory, create a `Dockerfile` and a `docker-compose` configuration that allow for the complete setup of a runner via Docker. The image must include the CLIs for all compatible platforms (forges), as well as all compatible agents—such as Claude Code, Codex, Copilot, etc. All configurations for these agents and CLIs must be mapped within the `docker-compose` file to link to your local configuration, allowing them to be used directly. Upon starting the container, a web interface should be available to configure any components that have not yet been set up. This interface will also serve to verify and display the current configuration status of each component. <!-- agent:section:start --> ## Functional analysis ### Expected behaviour No answers arrived by the deadline, so the defaults for q1–q10 apply. - **What it is (q1, q2):** a new `runner/` directory holds a `Dockerfile` and a `docker-compose` configuration for a **dockerized host runner**. This is a long-running container that runs only the Alfred **queue worker (and scheduler)**. It connects to an existing Alfred instance's database and Redis, which are set through environment variables in docker-compose. On `/instance` it is declared as the host runner (`@host`, §17.7). It is not a per-run `runner_images` image (§17.3). - **What the image contains (q5, q9):** - All three supported forge CLIs: GitHub `gh`, GitLab `glab` and Forgejo `fj`. - All five supported local agent CLIs: Claude Code, Codex, OpenCode, Copilot CLI and Grok Build. - git, an SSH client and common build tools. - Each CLI version is pinned as a build argument with a default value. - The image builds for `linux/amd64` and `linux/arm64`. - **Configuration mapping (q6, q7):** - docker-compose bind-mounts the usual host configuration paths read-write: `~/.claude`, `~/.claude.json`, `~/.codex`, `~/.config/opencode`, `~/.copilot`, `~/.config/gh`, `~/.config/glab-cli`, the `fj` configuration and `~/.gitconfig`. - Each path can be overridden in a `.env` file. - Logins already made on the host work in the container without extra steps. - Anything configured from the container persists on the host. - `~/.ssh` is **not** mapped. git uses HTTPS with the forge tokens. - **Setup interface (q3, q4, q8):** - On startup, the container serves its own small web interface on a dedicated port, separate from the Alfred application. - For each component, the interface shows whether it is installed, its version, and whether it is configured/authenticated. - For a component that is not set up, the user can enter tokens or API keys (for example `GH_TOKEN`, `GITLAB_TOKEN`, Forgejo token, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`). These are written to the mapped configuration. Interactive or device-code logins are not part of this scope. - The interface is published only on `127.0.0.1`. It is protected by a token taken from `.env`, or generated and printed in the container logs when `.env` does not set one. - **CI (q10):** GitLab CI and Forgejo/GitHub Actions build the image and run a smoke test. The test checks that every CLI answers `--version` and that the interface responds. Nothing is pushed to a registry. ### Acceptance criteria 1. Running `docker compose up` in `runner/`, with a `.env` that gives the Alfred database and Redis connection, builds and starts the runner. No other manual step is needed. 2. The container runs the Alfred queue worker and scheduler against the configured instance. That instance can use it as its `@host` runner. 3. `gh`, `glab`, `fj`, Claude Code, Codex, OpenCode, Copilot CLI and Grok Build are all present in the container, and each answers `--version`. 4. A CLI that is already logged in on the host shows as configured in the container, through the read-write compose mappings. Each mapped path can be overridden in `.env`. 5. `~/.ssh` is not mounted into the container. 6. Once the container starts, the setup interface is reachable on `127.0.0.1` only. It lists every component with its installed status, version and configured status. 7. A request to the interface without the token is refused. When `.env` sets no token, one is generated and printed in the container logs. 8. A token or API key entered in the interface for an unconfigured component is written to the mapped configuration. The component then shows as configured, also after a container restart, and the value is present on the host. 9. CLI versions are build arguments with pinned defaults. The image builds for `linux/amd64` and `linux/arm64`. 10. Both GitLab CI and Forgejo/GitHub Actions build the image and run the smoke test (CLI versions answer, interface responds). Neither pipeline publishes the image. ### Specification updates required - §17.3 says "No image ships with the application". This needs a note that the repository now ships a dockerized **host runner** setup in `runner/`. Per-run runner images are still not shipped. - §17.7 (host runner) needs a section on running the host runner through `runner/docker-compose`. It should cover the mapped configurations, the setup interface and how that interface is protected. - §17.1 and §17.2 are unchanged. Forge CLIs and mapped home configurations live only in the host-runner container, never in a per-run container. <!-- agent:key=accec8b387ba:kind=description --> <!-- agent:section:end -->
Owner

Thanks @SebastienDenooz, I am on it. I will post my analysis here.

Thanks @SebastienDenooz, I am on it. I will post my analysis here. <!-- agent:key=8268cda1ebff:kind=acknowledgement -->
Owner

I need a few answers before going further. Reply in a comment; without an answer by 2026-11-04 16:28 UTC, I will apply the defaults.

q1 — Which runner does this image provide? The spec separates two kinds (§17.1, §17.3). A "runner image" is a throwaway container that the engine starts for each run: it sees only /workspace, has no home directory, and the forge CLIs are not inside it. The "host runner" is the machine where the queue worker runs, reusing the agent CLIs and logins installed there (§17.7). Mapping local configs, including forge CLIs and serving a web interface on startup all fit the second kind better: a host for Alfred's worker, packaged with Docker.

  • dockerized-host-runner — A long-running container that runs the Alfred queue worker with forge CLIs, agent CLIs and mapped logins; declared on /instance as the host runner (@host) (default)
  • runner-image — A per-run image declared in runner_images (§17.3) that contains only the agent CLIs; forge CLIs, compose mappings and a web interface would not apply
  • both — One Dockerfile with two targets: a slim runner image and a full worker-host image built from it

q2 — What does the container run besides the agent and forge CLIs?

  • worker-only — Only the queue worker (and scheduler), connecting to an existing Alfred instance's database and Redis, set through environment variables in docker-compose (default)
  • full-app — The whole Alfred application (web, worker, scheduler) plus whatever database and Redis services compose needs, so a single docker compose up gives a working instance
  • tools-only — No Alfred code: only the CLIs and the setup interface; another process uses the container

q3 — What is the "web interface available on startup"? Alfred already shows agent detection and runner status on /instance (§17.3, §17.7).

  • standalone-setup-ui — A small interface served by the container on its own port, separate from the Alfred application (default)
  • alfred-instance-pages — No new interface: extend Alfred's existing /instance pages to show and fix the status of each component on this runner

q4 — What does "configure a component that is not set up" mean in the interface?

  • status-and-tokens — Show each component's status (installed, version, authenticated) and let the user enter tokens or API keys (GH_TOKEN, GITLAB_TOKEN, ANTHROPIC_API_KEY, OPENAI_API_KEY…), which are written to the mapped configuration (default)
  • interactive-login — Also run each CLI's interactive login (device-code or browser flows such as claude login, gh auth login) from the interface and show its output
  • status-only — Only display the status and print the command the user should run themselves (docker compose exec …)

q5 — Which components must the image contain? The code currently supports forges GitHub (gh), GitLab (glab) and Forgejo (fj), and local agents Claude Code, Codex, OpenCode, Copilot CLI and Grok Build.

  • all-supported — All three forge CLIs and all five agent CLIs, plus git, ssh client and common build tools (default)
  • subset — Only a subset that you will list

q6 — How should local configurations be mapped in docker-compose?

  • rw-overridable — Read-write bind mounts of the usual host paths (~/.claude, ~/.claude.json, ~/.codex, ~/.config/opencode, ~/.copilot, ~/.config/gh, ~/.config/glab-cli, the fj config, ~/.gitconfig), each path overridable in a .env, so logins made in the interface persist on the host (default)
  • ro-mounts — Read-only bind mounts; anything set up in the interface is stored in a named Docker volume instead
  • named-volumes — No host mapping; everything lives in named volumes and is set up through the interface

q7 — Should ~/.ssh be mapped too? Today the host runner deliberately keeps forge logins and SSH keys out of the run's environment (§17.7).

  • no-ssh — Do not map ~/.ssh; git goes over HTTPS with the forge tokens (default)
  • ssh-ro — Map ~/.ssh read-only for git over SSH

q8 — How is the setup interface protected? It can write credentials.

  • localhost-token — Published only on 127.0.0.1 and protected by a token from .env (or one generated and printed in the container logs) (default)
  • localhost-open — Published only on 127.0.0.1 with no authentication
  • alfred-auth — Instance-admin login through Alfred (only possible if q3 is alfred-instance-pages or q2 is full-app)

q9 — How are CLI versions chosen, and for which platforms?

  • pinned-multiarch — Versions pinned as build arguments with defaults, image built for linux/amd64 and linux/arm64 (default)
  • latest-amd64 — Latest versions at build time, linux/amd64 only

q10 — Should CI publish the image?

  • build-and-test-only — GitLab CI and Forgejo/GitHub Actions build the image and run a smoke test (each CLI responds to --version, the interface answers) and push nothing (default)
  • publish — Also push tagged images to a registry you specify

You can also answer every question at once in a comment of your own.

I need a few answers before going further. Reply in a comment; without an answer by 2026-11-04 16:28 UTC, I will apply the defaults. <!-- agent:questions:block=13 --> **q1 — Which runner does this image provide? The spec separates two kinds (§17.1, §17.3). A "runner image" is a throwaway container that the engine starts for each run: it sees only `/workspace`, has no home directory, and the forge CLIs are not inside it. The "host runner" is the machine where the queue worker runs, reusing the agent CLIs and logins installed there (§17.7). Mapping local configs, including forge CLIs and serving a web interface on startup all fit the second kind better: a host for Alfred's worker, packaged with Docker.** <!-- agent:q=q1 --> - [x] dockerized-host-runner — A long-running container that runs the Alfred queue worker with forge CLIs, agent CLIs and mapped logins; declared on /instance as the host runner (`@host`) *(default)* - [ ] runner-image — A per-run image declared in `runner_images` (§17.3) that contains only the agent CLIs; forge CLIs, compose mappings and a web interface would not apply - [ ] both — One Dockerfile with two targets: a slim runner image and a full worker-host image built from it **q2 — What does the container run besides the agent and forge CLIs?** <!-- agent:q=q2 --> - [x] worker-only — Only the queue worker (and scheduler), connecting to an existing Alfred instance's database and Redis, set through environment variables in docker-compose *(default)* - [ ] full-app — The whole Alfred application (web, worker, scheduler) plus whatever database and Redis services compose needs, so a single `docker compose up` gives a working instance - [ ] tools-only — No Alfred code: only the CLIs and the setup interface; another process uses the container **q3 — What is the "web interface available on startup"? Alfred already shows agent detection and runner status on /instance (§17.3, §17.7).** <!-- agent:q=q3 --> - [x] standalone-setup-ui — A small interface served by the container on its own port, separate from the Alfred application *(default)* - [ ] alfred-instance-pages — No new interface: extend Alfred's existing /instance pages to show and fix the status of each component on this runner **q4 — What does "configure a component that is not set up" mean in the interface?** <!-- agent:q=q4 --> - [x] status-and-tokens — Show each component's status (installed, version, authenticated) and let the user enter tokens or API keys (GH_TOKEN, GITLAB_TOKEN, ANTHROPIC_API_KEY, OPENAI_API_KEY…), which are written to the mapped configuration *(default)* - [ ] interactive-login — Also run each CLI's interactive login (device-code or browser flows such as `claude login`, `gh auth login`) from the interface and show its output - [ ] status-only — Only display the status and print the command the user should run themselves (`docker compose exec …`) **q5 — Which components must the image contain? The code currently supports forges GitHub (`gh`), GitLab (`glab`) and Forgejo (`fj`), and local agents Claude Code, Codex, OpenCode, Copilot CLI and Grok Build.** <!-- agent:q=q5 --> - [x] all-supported — All three forge CLIs and all five agent CLIs, plus git, ssh client and common build tools *(default)* - [ ] subset — Only a subset that you will list **q6 — How should local configurations be mapped in docker-compose?** <!-- agent:q=q6 --> - [x] rw-overridable — Read-write bind mounts of the usual host paths (~/.claude, ~/.claude.json, ~/.codex, ~/.config/opencode, ~/.copilot, ~/.config/gh, ~/.config/glab-cli, the fj config, ~/.gitconfig), each path overridable in a `.env`, so logins made in the interface persist on the host *(default)* - [ ] ro-mounts — Read-only bind mounts; anything set up in the interface is stored in a named Docker volume instead - [ ] named-volumes — No host mapping; everything lives in named volumes and is set up through the interface **q7 — Should ~/.ssh be mapped too? Today the host runner deliberately keeps forge logins and SSH keys out of the run's environment (§17.7).** <!-- agent:q=q7 --> - [x] no-ssh — Do not map ~/.ssh; git goes over HTTPS with the forge tokens *(default)* - [ ] ssh-ro — Map ~/.ssh read-only for git over SSH **q8 — How is the setup interface protected? It can write credentials.** <!-- agent:q=q8 --> - [x] localhost-token — Published only on 127.0.0.1 and protected by a token from `.env` (or one generated and printed in the container logs) *(default)* - [ ] localhost-open — Published only on 127.0.0.1 with no authentication - [ ] alfred-auth — Instance-admin login through Alfred (only possible if q3 is alfred-instance-pages or q2 is full-app) **q9 — How are CLI versions chosen, and for which platforms?** <!-- agent:q=q9 --> - [x] pinned-multiarch — Versions pinned as build arguments with defaults, image built for linux/amd64 and linux/arm64 *(default)* - [ ] latest-amd64 — Latest versions at build time, linux/amd64 only **q10 — Should CI publish the image?** <!-- agent:q=q10 --> - [x] build-and-test-only — GitLab CI and Forgejo/GitHub Actions build the image and run a smoke test (each CLI responds to `--version`, the interface answers) and push nothing *(default)* - [ ] publish — Also push tagged images to a registry you specify You can also answer every question at once in a comment of your own. <!-- agent:key=4b50f5536b33:kind=questions -->
Owner

No answer arrived in time, so I applied the defaults:

  • q1 Which runner does this image provide? The spec separates two kinds (§17.1, §17.3). A "runner image" is a throwaway container that the engine starts for each run: it sees only /workspace, has no home directory, and the forge CLIs are not inside it. The "host runner" is the machine where the queue worker runs, reusing the agent CLIs and logins installed there (§17.7). Mapping local configs, including forge CLIs and serving a web interface on startup all fit the second kind better: a host for Alfred's worker, packaged with Docker. → dockerized-host-runner
  • q2 What does the container run besides the agent and forge CLIs? → worker-only
  • q3 What is the "web interface available on startup"? Alfred already shows agent detection and runner status on /instance (§17.3, §17.7). → standalone-setup-ui
  • q4 What does "configure a component that is not set up" mean in the interface? → status-and-tokens
  • q5 Which components must the image contain? The code currently supports forges GitHub (gh), GitLab (glab) and Forgejo (fj), and local agents Claude Code, Codex, OpenCode, Copilot CLI and Grok Build. → all-supported
  • q6 How should local configurations be mapped in docker-compose? → rw-overridable
  • q7 Should ~/.ssh be mapped too? Today the host runner deliberately keeps forge logins and SSH keys out of the run's environment (§17.7). → no-ssh
  • q8 How is the setup interface protected? It can write credentials. → localhost-token
  • q9 How are CLI versions chosen, and for which platforms? → pinned-multiarch
  • q10 Should CI publish the image? → build-and-test-only
No answer arrived in time, so I applied the defaults: - **q1** Which runner does this image provide? The spec separates two kinds (§17.1, §17.3). A "runner image" is a throwaway container that the engine starts for each run: it sees only `/workspace`, has no home directory, and the forge CLIs are not inside it. The "host runner" is the machine where the queue worker runs, reusing the agent CLIs and logins installed there (§17.7). Mapping local configs, including forge CLIs and serving a web interface on startup all fit the second kind better: a host for Alfred's worker, packaged with Docker. → `dockerized-host-runner` - **q2** What does the container run besides the agent and forge CLIs? → `worker-only` - **q3** What is the "web interface available on startup"? Alfred already shows agent detection and runner status on /instance (§17.3, §17.7). → `standalone-setup-ui` - **q4** What does "configure a component that is not set up" mean in the interface? → `status-and-tokens` - **q5** Which components must the image contain? The code currently supports forges GitHub (`gh`), GitLab (`glab`) and Forgejo (`fj`), and local agents Claude Code, Codex, OpenCode, Copilot CLI and Grok Build. → `all-supported` - **q6** How should local configurations be mapped in docker-compose? → `rw-overridable` - **q7** Should ~/.ssh be mapped too? Today the host runner deliberately keeps forge logins and SSH keys out of the run's environment (§17.7). → `no-ssh` - **q8** How is the setup interface protected? It can write credentials. → `localhost-token` - **q9** How are CLI versions chosen, and for which platforms? → `pinned-multiarch` - **q10** Should CI publish the image? → `build-and-test-only` <!-- agent:key=b755ad002e0c:kind=defaults_applied -->
Owner

Plan

Plan: dockerized host runner (runner/)

1. What the repository already decides

These facts shape the design:

  • The worker is Horizon on Redis. It runs php artisan horizon (config/horizon.php, queues default, analysis, dev, ci, background) and needs phpredis (REDIS_CLIENT=phpredis). The scheduler runs as php artisan schedule:work. The HTTP server never inspects the host (§3.2), so the container runs only Horizon, the scheduler and the setup UI.
  • The host runner is bound to gethostname() (§17.7, app/Sandbox/HostRuntime.php:870). The container needs a stable hostname. Without one, every recreate makes runs fail with runtime_unavailable.
  • Home comes from the user database (posix_getpwuid, app/Support/HostBinaryFinder.php:156, HostRuntime.php:878), not from $HOME. The worker's OS user must have /home/alfred as its passwd home, and the mounts must target that directory.
  • Paths Alfred actually reads (config/alfred.php host_links / host_login_markers): ~/.claude, ~/.claude.json, ~/.codex/auth.json, ~/.local/share/opencode/auth.json and ~/.grok/auth.json(.lock). The functional analysis lists ~/.config/opencode and leaves out ~/.grok. I map both OpenCode directories (~/.config/opencode for the user's CLI config, ~/.local/share/opencode for the login Alfred uses) and add ~/.grok. Without these, a host login would not be "configured" for Alfred, which would break AC4.
  • Forge CLIs. Alfred injects each organization's token on every call (§15.2) and never relies on a CLI login. So the mapped gh/glab/fj logins are only for the operator's own use and for the status shown in the UI. That fits the spec and needs no code change. Forge tokens entered in the UI are therefore written through each CLI's own login store, never as GH_TOKEN/GITLAB_TOKEN in the worker environment.
  • Version ranges to respect when pinning defaults: claude >=2.0 <3.0, codex >=0.40 <1.0, opencode >=1.0 <3.0, copilot >=0.0.330 <2.0, grok >=0.1 <2.0, gh >=2.40.0, glab >=1.36.0, fj >=0.3.0.
  • In the container: CONTAINER_RUNTIME=none and HOST_RUNNER_ENABLED=true. systemd-run is absent, so host runs execute without resource limits. The spec already allows this ("a missing systemd never fails a run").

2. Files to add or change

runner/ (new)

File Content
runner/Dockerfile Multi-stage, multi-arch (details in §3)
runner/docker-compose.yml The runner service: build context .., dockerfile: runner/Dockerfile, env_file: .env, stable hostname, mounts, named volumes, port 127.0.0.1:${RUNNER_SETUP_PORT:-8790}:8080
runner/.env.example Alfred connection (APP_KEY, MASTER_KEY, DB_*, REDIS_*, CACHE_STORE=redis, QUEUE_CONNECTION=redis), RUNNER_HOSTNAME, PUID/PGID, RUNNER_SETUP_TOKEN, RUNNER_SETUP_PORT, one RUNNER_MOUNT_* variable per mapped path, RUNNER_CREDENTIALS_DIR
runner/.gitignore .env, credentials/
runner/docker/entrypoint.sh Runs as root: aligns the alfred UID/GID with PUID/PGID, chowns the named volumes, resolves the setup token (from env, or generates one with openssl rand, stores it in /run/alfred-runner/setup-token with mode 0600 and prints it in the logs), flags mounted files that Docker created as directories, then exec gosu alfred with the subcommand. Subcommands: run (default: supervisord) and setup-ui (UI only, used by the smoke test)
runner/docker/supervisord.conf Programs horizon, scheduler and setup-ui. The unix socket is owned by alfred. horizon gets a long stopwaitsecs so a restart lets the running runs finish
runner/docker/with-credentials Wrapper that sources credentials.env (mode 0600, in the mapped RUNNER_CREDENTIALS_DIR) and then execs the worker or scheduler. This is where instance API keys entered in the UI land (ANTHROPIC_API_KEY, OPENAI_API_KEY, XAI_API_KEY, COPILOT_GITHUB_TOKEN, …), so config/alfred.php reads them through env()
runner/setup/public/index.php Front controller of the setup UI, served by php -S 0.0.0.0:8080. It does not boot Laravel, so the Alfred admin and webhooks are never exposed on this port
runner/setup/bootstrap.php Tiny PSR-4 autoloader for runner/setup/src (the image installs Composer with --no-dev)
runner/setup/src/… Namespace AlfredRunner\Setup (details in §4)
runner/tests/smoke.sh Docker smoke test, shared by the three pipelines (§5)
runner/tests/compose.ci.yml Compose override for CI: adds postgres:16 and redis:7, plus temporary host directories for the mounts
runner/README.md Quick start, mount table, token, upgrading, caveats

Repository root

  • .dockerignore (new): vendor, node_modules, .env, storage/logs/*, storage/framework/*, .git, runner/.env, runner/credentials, tests/Browser/Screenshots.
  • composer.json: add "AlfredRunner\\Setup\\": "runner/setup/src/" to autoload-dev, so tests and Larastan see the classes.
  • phpstan.neon: add runner/setup/src to paths.
  • .gitlab-ci.yml, .github/workflows/ci.yml, .forgejo/workflows/ci.yml: new runner-image job (§5). Update the header comments that say the pipelines "run the same four checks".
  • docs/specification.md:
    • §17.3: the "No image ships with the application" paragraph gets a note that runner/ ships a dockerized host runner setup, and that per-run runner images are still not shipped.
    • §17.7: new sub-section "Dockerized host runner". It covers: what the container runs, the stable hostname, the mapped configurations and their .env overrides, that ~/.ssh is not mapped, credentials.env, the setup UI and its protection (127.0.0.1 only, token, CSRF), that the main instance must not also run a worker or scheduler (§3.3: single worker host), the shared APP_KEY/MASTER_KEY/DB/Redis/cache, and the absence of systemd limits.
    • §3.2: one line pointing to it.
    • §17.1 and §17.2 stay unchanged.
  • README.md: a short "Running the worker with Docker" pointer to runner/README.md.

3. The image (runner/Dockerfile)

  • assets stage (node:24, --platform=$BUILDPLATFORM): npm ci && npm run build, so any Blade view a job renders finds public/build/manifest.json.
  • vendor stage (composer:2): composer install --no-dev --optimize-autoloader.
  • fj stage: prebuilt forgejo-cli binary per TARGETARCH if Codeberg publishes one for both arches. Otherwise cargo install forgejo-cli --version $FJ_VERSION, cross-compiled from $BUILDPLATFORM (no QEMU-emulated Rust build).
  • Final stage (php:8.4-cli-bookworm):
    • Same PHP extensions as scripts/ci/install-toolchain.sh, plus pecl install redis.
    • git, openssh-client, build-essential, unzip, curl, jq, supervisor, gosu, tini, Node.js 24.
    • gh and glab from their release archives, picked by TARGETARCH and checked against the published checksums.
    • fj copied from its stage.
    • Agent CLIs installed with npm install -g (prefix /usr/local, so they sit on the finder's PATH): @anthropic-ai/claude-code@$CLAUDE_CODE_VERSION, @openai/codex@$CODEX_VERSION, opencode-ai@$OPENCODE_VERSION, @github/copilot@$COPILOT_VERSION.
    • Grok Build from its official distribution at $GROK_VERSION. The exact package and arm64 availability must be checked in the docs (context7) before writing that line.
    • Alfred code copied to /app. User alfred with passwd home /home/alfred.
    • ENTRYPOINT ["tini","--","/usr/local/bin/entrypoint"].
  • Pinned versions: ARG GH_VERSION=… GLAB_VERSION=… FJ_VERSION=… CLAUDE_CODE_VERSION=… CODEX_VERSION=… OPENCODE_VERSION=… COPILOT_VERSION=… GROK_VERSION=…, each default inside the ranges in §1.

Compose mounts

All mounts are read-write; each host side is ${RUNNER_MOUNT_X:-~/…}.

Host path (default) Container path
~/.claude, ~/.claude.json /home/alfred/.claude, /home/alfred/.claude.json
~/.codex /home/alfred/.codex
~/.config/opencode, ~/.local/share/opencode same under /home/alfred
~/.copilot /home/alfred/.copilot
~/.grok /home/alfred/.grok
~/.config/gh, ~/.config/glab-cli same under /home/alfred
fj data dir (~/.local/share/forgejo-cli, to verify against forgejo-cli's ProjectDirs) same under /home/alfred
~/.gitconfig /home/alfred/.gitconfig
./credentials /home/alfred/.config/alfred-runner
  • Named volumes for REPOS_BASE_DIR (/var/lib/alfred/repositories) and /app/storage, which holds the per-run homes and the logs.
  • No ~/.ssh mount. The smoke test asserts this.
  • restart: unless-stopped.
  • hostname: ${RUNNER_HOSTNAME:-alfred-runner}.
  • The compose environment: block forces HOST_RUNNER_ENABLED=true, CONTAINER_RUNTIME=none and REPOS_BASE_DIR.

4. Setup UI (runner/setup/src, namespace AlfredRunner\Setup)

The UI is small, has no framework and is easy to test. Each class has one job:

  • Process/CommandRunner (interface) and Process/ProcRunner: proc_open with an argv array (never a shell), stdin, a timeout and an explicit env. Tests swap in a fake.
  • Components/Component (interface): id(), label(), kind() (forge or agent), status(): ComponentStatus (installed, path, version, configured, message), fields() (inputs the form needs), configure(array $input).
  • One class per component. The status checks mirror Alfred's own checks, and secrets go through stdin only, never argv, so they don't show in ps:
Component Configured when configure() writes
GhCli gh auth status --hostname H exits 0 gh auth login --with-token --hostname H
GlabCli glab auth status glab auth login --hostname H --stdin
FjCli fj auth list (or its keys file) is not empty fj key add, exact syntax to verify
ClaudeCode .claude/.credentials.json exists, or ANTHROPIC_API_KEY is in credentials.env ANTHROPIC_API_KEY into credentials.env
Codex .codex/auth.json exists codex login --with-api-key
OpenCode .local/share/opencode/auth.json Merges {provider: {type: api, key}} into that file, keeping other providers, mode 0600
Copilot COPILOT_GITHUB_TOKEN is in credentials.env That token into credentials.env
GrokBuild .grok/auth.json, or XAI_API_KEY XAI_API_KEY into credentials.env
  • ComponentCatalogue: the list above. Binaries and login markers are duplicated from config/alfred.php because the UI does not boot Laravel. A test guards against drift (§6).
  • CredentialsFile: reads and writes KEY=value. Names are allow-listed, values containing a newline, NUL or quote are rejected, writes are atomic (temp file + rename) with mode 0600. After a write, the UI runs supervisorctl restart horizon scheduler and shows "worker restarting".
  • Http/TokenGuard: the expected token is RUNNER_SETUP_TOKEN or the generated token file. A login form takes the token; the guard compares it with hash_equals and sets an HttpOnly SameSite=Strict cookie holding an HMAC of the token. Any request without that cookie gets 401 (JSON) or the login form (HTML). There is no ?token= in URLs, so the token stays out of logs.
  • Http/Csrf: an HMAC token in a hidden field on every POST, plus an Origin/Host check.
  • Http/App: routes GET / (status table), POST /components/{id} (configure), GET /healthz (unauthenticated, returns only ok, used by the compose healthcheck), GET /status.json (authenticated).
  • View/Page: server-rendered HTML with every value escaped. Secrets are never echoed back; the page shows only "configured" and the source (login or key).

5. CI (runner-image job, same steps in all three pipelines)

  1. docker compose -f runner/docker-compose.yml config with runner/.env.example copied to .env, to validate the compose file.
  2. Multi-arch build without push: docker buildx build --platform linux/amd64,linux/arm64 -f runner/Dockerfile ., with QEMU via docker/setup-qemu-action / tonistiigi/binfmt.
  3. Build and --load amd64, then run runner/tests/smoke.sh:
    • CLIs: every one of the 8 CLIs answers --version, inside the image.
    • UI: start setup-ui with a known token; /healthz returns 200, / without the token returns 401, /status.json with the token returns 200 and lists all 8 components. Start again with no token and check docker logs prints a generated one.
    • No ~/.ssh: test ! -e /home/alfred/.ssh inside the container, and docker compose config contains no .ssh.
    • Worker: compose.ci.yml brings up postgres and redis, docker compose run --rm runner php artisan migrate --force, then up -d, then poll until php artisan horizon:status reports running and the scheduler program is RUNNING.
    • Persistence (AC8): POST an OpenCode key through the UI (with cookie and CSRF); the file appears in the host temp dir. docker compose restart, and it still shows as configured.
  4. Nothing is pushed.

Per platform:

  • GitHub: docker/setup-buildx-action.
  • GitLab: docker:27 with a docker:27-dind service. This needs a privileged runner.
  • Forgejo: runs-on: docker with the docker:27-cli image and the runner's Docker socket or DinD.

The existing checks job stays as it is, and the new Pest tests run inside it.

6. Tests to add (run by php artisan test in every pipeline)

  • tests/Unit/RunnerSetup/TokenGuardTest.php: missing, wrong and right token; cookie HMAC; fallback to the generated token file; constant-time comparison.
  • tests/Unit/RunnerSetup/Components/*Test.php, one per component, with a fake CommandRunner and a temp HOME:
    • status: absent, installed with a parsed version, configured and not configured;
    • configure(): exact argv, secret only on stdin, files written with mode 0600;
    • OpenCode: the merge keeps the other providers.
  • tests/Unit/RunnerSetup/CredentialsFileTest.php: allow-list, injection rejection, atomic write, permissions, existing keys kept.
  • tests/Unit/RunnerSetup/CatalogueMatchesAlfredConfigTest.php: binaries and login markers equal config('alfred.agents.*'); forge binaries match forge_cli_min_versions.
  • tests/Unit/RunnerSetup/DockerfilePinsTest.php: parses the ARG *_VERSION= defaults and checks each against the semver ranges and forge minimums with composer/semver.
  • tests/Unit/RunnerSetup/ComposeFileTest.php:
    • parses runner/docker-compose.yml (symfony/yaml is already present through Laravel, otherwise a regex check);
    • every mapping is ${RUNNER_MOUNT_…:-…} and read-write, no .ssh appears, the port is bound to 127.0.0.1;
    • hostname, HOST_RUNNER_ENABLED=true and CONTAINER_RUNTIME=none are set;
    • every RUNNER_MOUNT_* is listed in .env.example.
  • tests/Feature/RunnerSetup/HttpTest.php: drives Http/App with request objects. Unauthenticated → 401 or login form; POST without CSRF → 419; configure → redirect, status flips; /healthz exposes nothing.
  • tests/Browser/RunnerSetup/SetupUiTest.php (Playwright through pest-plugin-browser):
    • starts php -S 127.0.0.1:<free port> runner/setup/public/index.php with HOME set to a temp dir and PATH set to stub CLIs (tests/Fixtures/runner-setup/bin/{gh,glab,fj,claude,codex,opencode,copilot,grok}, which record stdin and fake their status);
    • flow: refused without the token → log in → the 8 rows show their versions → enter a GitHub token → the row turns configured → reload, still configured;
    • stops the server in afterAll. This reuses the approach of tests/Support/PlaywrightServerReaper.php.
  • Manual check with chrome-devtools MCP: run docker compose up locally, open http://127.0.0.1:8790, go through the login and configure flow, then check that a host login made beforehand shows as configured.

7. Implementation order

  1. Setup UI classes, with their unit and feature tests.
  2. Browser test.
  3. Dockerfile and entrypoint/supervisord, verified locally with docker build and smoke.sh.
  4. Compose file, .env.example, .dockerignore, and the compose/pins/catalogue drift tests.
  5. CI jobs in all three pipelines.
  6. Spec (§17.3, §17.7, §3.2) and READMEs.
  7. Commits follow Conventional Commits:
    • feat(runner): …
    • test(runner): …
    • ci(runner): …
    • docs(spec): …

8. Risks and mitigations

  • Bind-mounted files that don't exist on the host (~/.claude.json, ~/.gitconfig): Docker creates a directory at that path on the host. The entrypoint detects it, logs the fix, and the UI shows the component as "misconfigured: remove the directory and create the file". The README tells users on a fresh host to touch both files. AC1 still holds for hosts where they already exist.
  • UID mismatch on read-write mounts would make files unwritable or root-owned on the host. PUID/PGID (default 1000) are aligned in the entrypoint, and .env.example documents id -u/id -g.
  • Duplicate scheduler or second worker: if the main instance keeps running Horizon or schedule:work, there are two worker hosts, which §3.3 forbids. Documented in the README and §17.7.
  • Version skew: the image bakes in the Alfred code of the checkout it was built from, so it must be rebuilt (docker compose build) alongside the instance. Documented.
  • Restarting the worker after a credential change could cut a running run. Supervisor sends SIGTERM and Horizon finishes its jobs within stopwaitsecs, set above the longest run timeout. The UI says so before saving.
  • Arm64 builds under QEMU are slow, especially npm installs and Rust if fj has no prebuilt binary. Mitigation: a cross-compiled fj stage and the BuildKit cache (cache-from type=gha on GitHub). If CI time becomes a problem, the job runs only when runner/**, composer.lock or app/** change.
  • CI Docker availability: GitLab needs a privileged DinD runner; Forgejo depends on how the runner is configured (socket or DinD). The job may need runner-side setup that cannot be done from the repository.
  • Grok Build and fj packaging (package name, arm64 builds, config path, the exact auth sub-commands) are not certain from memory. They must be checked against current docs (context7 or the official sites) before the Dockerfile and the configure() argv are written.
  • Security: the UI writes credentials and the container is a non-sandboxed host runner (§17.7 warning). It is published on 127.0.0.1 only, behind the token, CSRF and an Origin check, never runs a shell, takes secrets only on stdin, never echoes them back, and writes files with mode 0600.

The implementation plan is ready for validation. Add the ai::validate_plan label to let me start.

## Plan # Plan: dockerized host runner (`runner/`) ## 1. What the repository already decides These facts shape the design: - **The worker is Horizon on Redis.** It runs `php artisan horizon` (`config/horizon.php`, queues `default, analysis, dev, ci, background`) and needs `phpredis` (`REDIS_CLIENT=phpredis`). The scheduler runs as `php artisan schedule:work`. The HTTP server never inspects the host (§3.2), so the container runs only Horizon, the scheduler and the setup UI. - **The host runner is bound to `gethostname()`** (§17.7, `app/Sandbox/HostRuntime.php:870`). The container needs a **stable hostname**. Without one, every recreate makes runs fail with `runtime_unavailable`. - **Home comes from the user database** (`posix_getpwuid`, `app/Support/HostBinaryFinder.php:156`, `HostRuntime.php:878`), not from `$HOME`. The worker's OS user must have `/home/alfred` as its passwd home, and the mounts must target that directory. - **Paths Alfred actually reads** (`config/alfred.php` `host_links` / `host_login_markers`): `~/.claude`, `~/.claude.json`, `~/.codex/auth.json`, `~/.local/share/opencode/auth.json` and `~/.grok/auth.json(.lock)`. The functional analysis lists `~/.config/opencode` and leaves out `~/.grok`. I map both OpenCode directories (`~/.config/opencode` for the user's CLI config, `~/.local/share/opencode` for the login Alfred uses) and add `~/.grok`. Without these, a host login would not be "configured" for Alfred, which would break AC4. - **Forge CLIs.** Alfred injects each organization's token on every call (§15.2) and never relies on a CLI login. So the mapped `gh`/`glab`/`fj` logins are only for the operator's own use and for the status shown in the UI. That fits the spec and needs no code change. Forge tokens entered in the UI are therefore written through each CLI's own login store, **never as `GH_TOKEN`/`GITLAB_TOKEN` in the worker environment**. - **Version ranges to respect when pinning defaults:** claude `>=2.0 <3.0`, codex `>=0.40 <1.0`, opencode `>=1.0 <3.0`, copilot `>=0.0.330 <2.0`, grok `>=0.1 <2.0`, gh `>=2.40.0`, glab `>=1.36.0`, fj `>=0.3.0`. - **In the container:** `CONTAINER_RUNTIME=none` and `HOST_RUNNER_ENABLED=true`. `systemd-run` is absent, so host runs execute without resource limits. The spec already allows this ("a missing systemd never fails a run"). ## 2. Files to add or change ### `runner/` (new) | File | Content | |---|---| | `runner/Dockerfile` | Multi-stage, multi-arch (details in §3) | | `runner/docker-compose.yml` | The `runner` service: build context `..`, `dockerfile: runner/Dockerfile`, `env_file: .env`, stable `hostname`, mounts, named volumes, port `127.0.0.1:${RUNNER_SETUP_PORT:-8790}:8080` | | `runner/.env.example` | Alfred connection (`APP_KEY`, `MASTER_KEY`, `DB_*`, `REDIS_*`, `CACHE_STORE=redis`, `QUEUE_CONNECTION=redis`), `RUNNER_HOSTNAME`, `PUID`/`PGID`, `RUNNER_SETUP_TOKEN`, `RUNNER_SETUP_PORT`, one `RUNNER_MOUNT_*` variable per mapped path, `RUNNER_CREDENTIALS_DIR` | | `runner/.gitignore` | `.env`, `credentials/` | | `runner/docker/entrypoint.sh` | Runs as root: aligns the `alfred` UID/GID with `PUID`/`PGID`, chowns the named volumes, resolves the setup token (from env, or generates one with `openssl rand`, stores it in `/run/alfred-runner/setup-token` with mode 0600 and prints it in the logs), flags mounted files that Docker created as directories, then `exec gosu alfred` with the subcommand. Subcommands: `run` (default: supervisord) and `setup-ui` (UI only, used by the smoke test) | | `runner/docker/supervisord.conf` | Programs `horizon`, `scheduler` and `setup-ui`. The unix socket is owned by `alfred`. `horizon` gets a long `stopwaitsecs` so a restart lets the running runs finish | | `runner/docker/with-credentials` | Wrapper that sources `credentials.env` (mode 0600, in the mapped `RUNNER_CREDENTIALS_DIR`) and then execs the worker or scheduler. This is where instance API keys entered in the UI land (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `XAI_API_KEY`, `COPILOT_GITHUB_TOKEN`, …), so `config/alfred.php` reads them through `env()` | | `runner/setup/public/index.php` | Front controller of the setup UI, served by `php -S 0.0.0.0:8080`. It does not boot Laravel, so the Alfred admin and webhooks are never exposed on this port | | `runner/setup/bootstrap.php` | Tiny PSR-4 autoloader for `runner/setup/src` (the image installs Composer with `--no-dev`) | | `runner/setup/src/…` | Namespace `AlfredRunner\Setup` (details in §4) | | `runner/tests/smoke.sh` | Docker smoke test, shared by the three pipelines (§5) | | `runner/tests/compose.ci.yml` | Compose override for CI: adds `postgres:16` and `redis:7`, plus temporary host directories for the mounts | | `runner/README.md` | Quick start, mount table, token, upgrading, caveats | ### Repository root - `.dockerignore` (new): `vendor`, `node_modules`, `.env`, `storage/logs/*`, `storage/framework/*`, `.git`, `runner/.env`, `runner/credentials`, `tests/Browser/Screenshots`. - `composer.json`: add `"AlfredRunner\\Setup\\": "runner/setup/src/"` to `autoload-dev`, so tests and Larastan see the classes. - `phpstan.neon`: add `runner/setup/src` to `paths`. - `.gitlab-ci.yml`, `.github/workflows/ci.yml`, `.forgejo/workflows/ci.yml`: new `runner-image` job (§5). Update the header comments that say the pipelines "run the same four checks". - `docs/specification.md`: - **§17.3:** the "No image ships with the application" paragraph gets a note that `runner/` ships a dockerized **host runner** setup, and that per-run runner images are still not shipped. - **§17.7:** new sub-section "Dockerized host runner". It covers: what the container runs, the stable hostname, the mapped configurations and their `.env` overrides, that `~/.ssh` is not mapped, `credentials.env`, the setup UI and its protection (127.0.0.1 only, token, CSRF), that the main instance must not also run a worker or scheduler (§3.3: single worker host), the shared `APP_KEY`/`MASTER_KEY`/DB/Redis/cache, and the absence of systemd limits. - **§3.2:** one line pointing to it. - §17.1 and §17.2 stay unchanged. - `README.md`: a short "Running the worker with Docker" pointer to `runner/README.md`. ## 3. The image (`runner/Dockerfile`) - **`assets` stage** (`node:24`, `--platform=$BUILDPLATFORM`): `npm ci && npm run build`, so any Blade view a job renders finds `public/build/manifest.json`. - **`vendor` stage** (`composer:2`): `composer install --no-dev --optimize-autoloader`. - **`fj` stage:** prebuilt forgejo-cli binary per `TARGETARCH` if Codeberg publishes one for both arches. Otherwise `cargo install forgejo-cli --version $FJ_VERSION`, cross-compiled from `$BUILDPLATFORM` (no QEMU-emulated Rust build). - **Final stage** (`php:8.4-cli-bookworm`): - Same PHP extensions as `scripts/ci/install-toolchain.sh`, plus `pecl install redis`. - `git`, `openssh-client`, `build-essential`, `unzip`, `curl`, `jq`, `supervisor`, `gosu`, `tini`, Node.js 24. - `gh` and `glab` from their release archives, picked by `TARGETARCH` and checked against the published checksums. - `fj` copied from its stage. - Agent CLIs installed with `npm install -g` (prefix `/usr/local`, so they sit on the finder's `PATH`): `@anthropic-ai/claude-code@$CLAUDE_CODE_VERSION`, `@openai/codex@$CODEX_VERSION`, `opencode-ai@$OPENCODE_VERSION`, `@github/copilot@$COPILOT_VERSION`. - Grok Build from its official distribution at `$GROK_VERSION`. The exact package and arm64 availability must be checked in the docs (context7) before writing that line. - Alfred code copied to `/app`. User `alfred` with passwd home `/home/alfred`. - `ENTRYPOINT ["tini","--","/usr/local/bin/entrypoint"]`. - **Pinned versions:** `ARG GH_VERSION=… GLAB_VERSION=… FJ_VERSION=… CLAUDE_CODE_VERSION=… CODEX_VERSION=… OPENCODE_VERSION=… COPILOT_VERSION=… GROK_VERSION=…`, each default inside the ranges in §1. ### Compose mounts All mounts are read-write; each host side is `${RUNNER_MOUNT_X:-~/…}`. | Host path (default) | Container path | |---|---| | `~/.claude`, `~/.claude.json` | `/home/alfred/.claude`, `/home/alfred/.claude.json` | | `~/.codex` | `/home/alfred/.codex` | | `~/.config/opencode`, `~/.local/share/opencode` | same under `/home/alfred` | | `~/.copilot` | `/home/alfred/.copilot` | | `~/.grok` | `/home/alfred/.grok` | | `~/.config/gh`, `~/.config/glab-cli` | same under `/home/alfred` | | fj data dir (`~/.local/share/forgejo-cli`, to verify against forgejo-cli's `ProjectDirs`) | same under `/home/alfred` | | `~/.gitconfig` | `/home/alfred/.gitconfig` | | `./credentials` | `/home/alfred/.config/alfred-runner` | - Named volumes for `REPOS_BASE_DIR` (`/var/lib/alfred/repositories`) and `/app/storage`, which holds the per-run homes and the logs. - **No `~/.ssh` mount.** The smoke test asserts this. - `restart: unless-stopped`. - `hostname: ${RUNNER_HOSTNAME:-alfred-runner}`. - The compose `environment:` block forces `HOST_RUNNER_ENABLED=true`, `CONTAINER_RUNTIME=none` and `REPOS_BASE_DIR`. ## 4. Setup UI (`runner/setup/src`, namespace `AlfredRunner\Setup`) The UI is small, has no framework and is easy to test. Each class has one job: - `Process/CommandRunner` (interface) and `Process/ProcRunner`: `proc_open` with an argv array (never a shell), stdin, a timeout and an explicit env. Tests swap in a fake. - `Components/Component` (interface): `id()`, `label()`, `kind()` (forge or agent), `status(): ComponentStatus` (installed, path, version, configured, message), `fields()` (inputs the form needs), `configure(array $input)`. - One class per component. The status checks mirror Alfred's own checks, and secrets go through **stdin only**, never argv, so they don't show in `ps`: | Component | Configured when | `configure()` writes | |---|---|---| | `GhCli` | `gh auth status --hostname H` exits 0 | `gh auth login --with-token --hostname H` | | `GlabCli` | `glab auth status` | `glab auth login --hostname H --stdin` | | `FjCli` | `fj auth list` (or its keys file) is not empty | `fj` key add, exact syntax to verify | | `ClaudeCode` | `.claude/.credentials.json` exists, or `ANTHROPIC_API_KEY` is in `credentials.env` | `ANTHROPIC_API_KEY` into `credentials.env` | | `Codex` | `.codex/auth.json` exists | `codex login --with-api-key` | | `OpenCode` | `.local/share/opencode/auth.json` | Merges `{provider: {type: api, key}}` into that file, keeping other providers, mode 0600 | | `Copilot` | `COPILOT_GITHUB_TOKEN` is in `credentials.env` | That token into `credentials.env` | | `GrokBuild` | `.grok/auth.json`, or `XAI_API_KEY` | `XAI_API_KEY` into `credentials.env` | - `ComponentCatalogue`: the list above. Binaries and login markers are duplicated from `config/alfred.php` because the UI does not boot Laravel. A test guards against drift (§6). - `CredentialsFile`: reads and writes `KEY=value`. Names are allow-listed, values containing a newline, NUL or quote are rejected, writes are atomic (temp file + rename) with mode 0600. After a write, the UI runs `supervisorctl restart horizon scheduler` and shows "worker restarting". - `Http/TokenGuard`: the expected token is `RUNNER_SETUP_TOKEN` or the generated token file. A login form takes the token; the guard compares it with `hash_equals` and sets an HttpOnly `SameSite=Strict` cookie holding an HMAC of the token. Any request without that cookie gets 401 (JSON) or the login form (HTML). There is no `?token=` in URLs, so the token stays out of logs. - `Http/Csrf`: an HMAC token in a hidden field on every POST, plus an `Origin`/`Host` check. - `Http/App`: routes `GET /` (status table), `POST /components/{id}` (configure), `GET /healthz` (unauthenticated, returns only `ok`, used by the compose healthcheck), `GET /status.json` (authenticated). - `View/Page`: server-rendered HTML with every value escaped. Secrets are never echoed back; the page shows only "configured" and the source (login or key). ## 5. CI (`runner-image` job, same steps in all three pipelines) 1. `docker compose -f runner/docker-compose.yml config` with `runner/.env.example` copied to `.env`, to validate the compose file. 2. Multi-arch build without push: `docker buildx build --platform linux/amd64,linux/arm64 -f runner/Dockerfile .`, with QEMU via `docker/setup-qemu-action` / `tonistiigi/binfmt`. 3. Build and `--load` amd64, then run `runner/tests/smoke.sh`: - **CLIs:** every one of the 8 CLIs answers `--version`, inside the image. - **UI:** start `setup-ui` with a known token; `/healthz` returns 200, `/` without the token returns 401, `/status.json` with the token returns 200 and lists all 8 components. Start again with no token and check `docker logs` prints a generated one. - **No `~/.ssh`:** `test ! -e /home/alfred/.ssh` inside the container, and `docker compose config` contains no `.ssh`. - **Worker:** `compose.ci.yml` brings up postgres and redis, `docker compose run --rm runner php artisan migrate --force`, then `up -d`, then poll until `php artisan horizon:status` reports running and the scheduler program is `RUNNING`. - **Persistence (AC8):** POST an OpenCode key through the UI (with cookie and CSRF); the file appears in the host temp dir. `docker compose restart`, and it still shows as configured. 4. Nothing is pushed. Per platform: - **GitHub:** `docker/setup-buildx-action`. - **GitLab:** `docker:27` with a `docker:27-dind` service. This needs a privileged runner. - **Forgejo:** `runs-on: docker` with the `docker:27-cli` image and the runner's Docker socket or DinD. The existing `checks` job stays as it is, and the new Pest tests run inside it. ## 6. Tests to add (run by `php artisan test` in every pipeline) - `tests/Unit/RunnerSetup/TokenGuardTest.php`: missing, wrong and right token; cookie HMAC; fallback to the generated token file; constant-time comparison. - `tests/Unit/RunnerSetup/Components/*Test.php`, one per component, with a fake `CommandRunner` and a temp HOME: - status: absent, installed with a parsed version, configured and not configured; - `configure()`: exact argv, secret only on stdin, files written with mode 0600; - OpenCode: the merge keeps the other providers. - `tests/Unit/RunnerSetup/CredentialsFileTest.php`: allow-list, injection rejection, atomic write, permissions, existing keys kept. - `tests/Unit/RunnerSetup/CatalogueMatchesAlfredConfigTest.php`: binaries and login markers equal `config('alfred.agents.*')`; forge binaries match `forge_cli_min_versions`. - `tests/Unit/RunnerSetup/DockerfilePinsTest.php`: parses the `ARG *_VERSION=` defaults and checks each against the semver ranges and forge minimums with `composer/semver`. - `tests/Unit/RunnerSetup/ComposeFileTest.php`: - parses `runner/docker-compose.yml` (symfony/yaml is already present through Laravel, otherwise a regex check); - every mapping is `${RUNNER_MOUNT_…:-…}` and read-write, no `.ssh` appears, the port is bound to `127.0.0.1`; - `hostname`, `HOST_RUNNER_ENABLED=true` and `CONTAINER_RUNTIME=none` are set; - every `RUNNER_MOUNT_*` is listed in `.env.example`. - `tests/Feature/RunnerSetup/HttpTest.php`: drives `Http/App` with request objects. Unauthenticated → 401 or login form; POST without CSRF → 419; configure → redirect, status flips; `/healthz` exposes nothing. - `tests/Browser/RunnerSetup/SetupUiTest.php` (Playwright through pest-plugin-browser): - starts `php -S 127.0.0.1:<free port> runner/setup/public/index.php` with HOME set to a temp dir and PATH set to stub CLIs (`tests/Fixtures/runner-setup/bin/{gh,glab,fj,claude,codex,opencode,copilot,grok}`, which record stdin and fake their status); - flow: refused without the token → log in → the 8 rows show their versions → enter a GitHub token → the row turns configured → reload, still configured; - stops the server in `afterAll`. This reuses the approach of `tests/Support/PlaywrightServerReaper.php`. - **Manual check with chrome-devtools MCP:** run `docker compose up` locally, open `http://127.0.0.1:8790`, go through the login and configure flow, then check that a host login made beforehand shows as configured. ## 7. Implementation order 1. Setup UI classes, with their unit and feature tests. 2. Browser test. 3. Dockerfile and entrypoint/supervisord, verified locally with `docker build` and `smoke.sh`. 4. Compose file, `.env.example`, `.dockerignore`, and the compose/pins/catalogue drift tests. 5. CI jobs in all three pipelines. 6. Spec (§17.3, §17.7, §3.2) and READMEs. 7. Commits follow Conventional Commits: - `feat(runner): …` - `test(runner): …` - `ci(runner): …` - `docs(spec): …` ## 8. Risks and mitigations - **Bind-mounted files that don't exist on the host** (`~/.claude.json`, `~/.gitconfig`): Docker creates a *directory* at that path on the host. The entrypoint detects it, logs the fix, and the UI shows the component as "misconfigured: remove the directory and create the file". The README tells users on a fresh host to `touch` both files. AC1 still holds for hosts where they already exist. - **UID mismatch on read-write mounts** would make files unwritable or root-owned on the host. `PUID`/`PGID` (default 1000) are aligned in the entrypoint, and `.env.example` documents `id -u`/`id -g`. - **Duplicate scheduler or second worker:** if the main instance keeps running Horizon or `schedule:work`, there are two worker hosts, which §3.3 forbids. Documented in the README and §17.7. - **Version skew:** the image bakes in the Alfred code of the checkout it was built from, so it must be rebuilt (`docker compose build`) alongside the instance. Documented. - **Restarting the worker after a credential change** could cut a running run. Supervisor sends SIGTERM and Horizon finishes its jobs within `stopwaitsecs`, set above the longest run timeout. The UI says so before saving. - **Arm64 builds under QEMU are slow,** especially npm installs and Rust if fj has no prebuilt binary. Mitigation: a cross-compiled fj stage and the BuildKit cache (`cache-from type=gha` on GitHub). If CI time becomes a problem, the job runs only when `runner/**`, `composer.lock` or `app/**` change. - **CI Docker availability:** GitLab needs a privileged DinD runner; Forgejo depends on how the runner is configured (socket or DinD). The job may need runner-side setup that cannot be done from the repository. - **Grok Build and fj packaging** (package name, arm64 builds, config path, the exact `auth` sub-commands) are not certain from memory. They must be checked against current docs (context7 or the official sites) before the Dockerfile and the `configure()` argv are written. - **Security:** the UI writes credentials and the container is a non-sandboxed host runner (§17.7 warning). It is published on 127.0.0.1 only, behind the token, CSRF and an Origin check, never runs a shell, takes secrets only on stdin, never echoes them back, and writes files with mode 0600. The implementation plan is ready for validation. Add the ai::validate_plan label to let me start. <!-- agent:key=ab11f329bdc6:kind=plan -->
Sign in to join this conversation.
No milestone
No project
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Alchemic.Studio/Alfred#13
No description provided.