Create a docker image with all the need to be a runner #13
Labels
No labels
ai::retry
ai::retry_once
ai::retry_until_green
ai::validate_plan
status::blocked
status::done
status::functional_analysis
status::in_ci
status::in_development
status::in_review
status::technical_analysis
status::waiting_info
status::waiting_validation
type::analysis
type::bug
type::question
type::task
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Alchemic.Studio/Alfred#13
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
In the
runnerdirectory, create aDockerfileand adocker-composeconfiguration 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-composefile 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.
runner/directory holds aDockerfileand adocker-composeconfiguration 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/instanceit is declared as the host runner (@host, §17.7). It is not a per-runrunner_imagesimage (§17.3).gh, GitLabglaband Forgejofj.linux/amd64andlinux/arm64.~/.claude,~/.claude.json,~/.codex,~/.config/opencode,~/.copilot,~/.config/gh,~/.config/glab-cli, thefjconfiguration and~/.gitconfig..envfile.~/.sshis not mapped. git uses HTTPS with the forge tokens.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.127.0.0.1. It is protected by a token taken from.env, or generated and printed in the container logs when.envdoes not set one.--versionand that the interface responds. Nothing is pushed to a registry.Acceptance criteria
docker compose upinrunner/, with a.envthat gives the Alfred database and Redis connection, builds and starts the runner. No other manual step is needed.@hostrunner.gh,glab,fj, Claude Code, Codex, OpenCode, Copilot CLI and Grok Build are all present in the container, and each answers--version..env.~/.sshis not mounted into the container.127.0.0.1only. It lists every component with its installed status, version and configured status..envsets no token, one is generated and printed in the container logs.linux/amd64andlinux/arm64.Specification updates required
runner/. Per-run runner images are still not shipped.runner/docker-compose. It should cover the mapped configurations, the setup interface and how that interface is protected.Thanks @SebastienDenooz, I am on it. I will post my analysis here.
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.@host) (default)runner_images(§17.3) that contains only the agent CLIs; forge CLIs, compose mappings and a web interface would not applyq2 — What does the container run besides the agent and forge CLIs?
docker compose upgives a working instanceq3 — What is the "web interface available on startup"? Alfred already shows agent detection and runner status on /instance (§17.3, §17.7).
q4 — What does "configure a component that is not set up" mean in the interface?
claude login,gh auth login) from the interface and show its outputdocker 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.q6 — How should local configurations be mapped in docker-compose?
.env, so logins made in the interface persist on the host (default)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).
q8 — How is the setup interface protected? It can write credentials.
.env(or one generated and printed in the container logs) (default)q9 — How are CLI versions chosen, and for which platforms?
q10 — Should CI publish the image?
--version, the interface answers) and push nothing (default)You can also answer every question at once in a comment of your own.
No answer arrived in time, so I applied the defaults:
/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-runnerworker-onlystandalone-setup-uistatus-and-tokensgh), GitLab (glab) and Forgejo (fj), and local agents Claude Code, Codex, OpenCode, Copilot CLI and Grok Build. →all-supportedrw-overridableno-sshlocalhost-tokenpinned-multiarchbuild-and-test-onlyPlan
Plan: dockerized host runner (
runner/)1. What the repository already decides
These facts shape the design:
php artisan horizon(config/horizon.php, queuesdefault, analysis, dev, ci, background) and needsphpredis(REDIS_CLIENT=phpredis). The scheduler runs asphp artisan schedule:work. The HTTP server never inspects the host (§3.2), so the container runs only Horizon, the scheduler and the setup UI.gethostname()(§17.7,app/Sandbox/HostRuntime.php:870). The container needs a stable hostname. Without one, every recreate makes runs fail withruntime_unavailable.posix_getpwuid,app/Support/HostBinaryFinder.php:156,HostRuntime.php:878), not from$HOME. The worker's OS user must have/home/alfredas its passwd home, and the mounts must target that directory.config/alfred.phphost_links/host_login_markers):~/.claude,~/.claude.json,~/.codex/auth.json,~/.local/share/opencode/auth.jsonand~/.grok/auth.json(.lock). The functional analysis lists~/.config/opencodeand leaves out~/.grok. I map both OpenCode directories (~/.config/opencodefor the user's CLI config,~/.local/share/opencodefor the login Alfred uses) and add~/.grok. Without these, a host login would not be "configured" for Alfred, which would break AC4.gh/glab/fjlogins 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 asGH_TOKEN/GITLAB_TOKENin the worker environment.>=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.CONTAINER_RUNTIME=noneandHOST_RUNNER_ENABLED=true.systemd-runis 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)runner/Dockerfilerunner/docker-compose.ymlrunnerservice: build context..,dockerfile: runner/Dockerfile,env_file: .env, stablehostname, mounts, named volumes, port127.0.0.1:${RUNNER_SETUP_PORT:-8790}:8080runner/.env.exampleAPP_KEY,MASTER_KEY,DB_*,REDIS_*,CACHE_STORE=redis,QUEUE_CONNECTION=redis),RUNNER_HOSTNAME,PUID/PGID,RUNNER_SETUP_TOKEN,RUNNER_SETUP_PORT, oneRUNNER_MOUNT_*variable per mapped path,RUNNER_CREDENTIALS_DIRrunner/.gitignore.env,credentials/runner/docker/entrypoint.shalfredUID/GID withPUID/PGID, chowns the named volumes, resolves the setup token (from env, or generates one withopenssl rand, stores it in/run/alfred-runner/setup-tokenwith mode 0600 and prints it in the logs), flags mounted files that Docker created as directories, thenexec gosu alfredwith the subcommand. Subcommands:run(default: supervisord) andsetup-ui(UI only, used by the smoke test)runner/docker/supervisord.confhorizon,schedulerandsetup-ui. The unix socket is owned byalfred.horizongets a longstopwaitsecsso a restart lets the running runs finishrunner/docker/with-credentialscredentials.env(mode 0600, in the mappedRUNNER_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, …), soconfig/alfred.phpreads them throughenv()runner/setup/public/index.phpphp -S 0.0.0.0:8080. It does not boot Laravel, so the Alfred admin and webhooks are never exposed on this portrunner/setup/bootstrap.phprunner/setup/src(the image installs Composer with--no-dev)runner/setup/src/…AlfredRunner\Setup(details in §4)runner/tests/smoke.shrunner/tests/compose.ci.ymlpostgres:16andredis:7, plus temporary host directories for the mountsrunner/README.mdRepository 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/"toautoload-dev, so tests and Larastan see the classes.phpstan.neon: addrunner/setup/srctopaths..gitlab-ci.yml,.github/workflows/ci.yml,.forgejo/workflows/ci.yml: newrunner-imagejob (§5). Update the header comments that say the pipelines "run the same four checks".docs/specification.md:runner/ships a dockerized host runner setup, and that per-run runner images are still not shipped..envoverrides, that~/.sshis 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 sharedAPP_KEY/MASTER_KEY/DB/Redis/cache, and the absence of systemd limits.README.md: a short "Running the worker with Docker" pointer torunner/README.md.3. The image (
runner/Dockerfile)assetsstage (node:24,--platform=$BUILDPLATFORM):npm ci && npm run build, so any Blade view a job renders findspublic/build/manifest.json.vendorstage (composer:2):composer install --no-dev --optimize-autoloader.fjstage: prebuilt forgejo-cli binary perTARGETARCHif Codeberg publishes one for both arches. Otherwisecargo install forgejo-cli --version $FJ_VERSION, cross-compiled from$BUILDPLATFORM(no QEMU-emulated Rust build).php:8.4-cli-bookworm):scripts/ci/install-toolchain.sh, pluspecl install redis.git,openssh-client,build-essential,unzip,curl,jq,supervisor,gosu,tini, Node.js 24.ghandglabfrom their release archives, picked byTARGETARCHand checked against the published checksums.fjcopied from its stage.npm install -g(prefix/usr/local, so they sit on the finder'sPATH):@anthropic-ai/claude-code@$CLAUDE_CODE_VERSION,@openai/codex@$CODEX_VERSION,opencode-ai@$OPENCODE_VERSION,@github/copilot@$COPILOT_VERSION.$GROK_VERSION. The exact package and arm64 availability must be checked in the docs (context7) before writing that line./app. Useralfredwith passwd home/home/alfred.ENTRYPOINT ["tini","--","/usr/local/bin/entrypoint"].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:-~/…}.~/.claude,~/.claude.json/home/alfred/.claude,/home/alfred/.claude.json~/.codex/home/alfred/.codex~/.config/opencode,~/.local/share/opencode/home/alfred~/.copilot/home/alfred/.copilot~/.grok/home/alfred/.grok~/.config/gh,~/.config/glab-cli/home/alfred~/.local/share/forgejo-cli, to verify against forgejo-cli'sProjectDirs)/home/alfred~/.gitconfig/home/alfred/.gitconfig./credentials/home/alfred/.config/alfred-runnerREPOS_BASE_DIR(/var/lib/alfred/repositories) and/app/storage, which holds the per-run homes and the logs.~/.sshmount. The smoke test asserts this.restart: unless-stopped.hostname: ${RUNNER_HOSTNAME:-alfred-runner}.environment:block forcesHOST_RUNNER_ENABLED=true,CONTAINER_RUNTIME=noneandREPOS_BASE_DIR.4. Setup UI (
runner/setup/src, namespaceAlfredRunner\Setup)The UI is small, has no framework and is easy to test. Each class has one job:
Process/CommandRunner(interface) andProcess/ProcRunner:proc_openwith 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).ps:configure()writesGhCligh auth status --hostname Hexits 0gh auth login --with-token --hostname HGlabCliglab auth statusglab auth login --hostname H --stdinFjClifj auth list(or its keys file) is not emptyfjkey add, exact syntax to verifyClaudeCode.claude/.credentials.jsonexists, orANTHROPIC_API_KEYis incredentials.envANTHROPIC_API_KEYintocredentials.envCodex.codex/auth.jsonexistscodex login --with-api-keyOpenCode.local/share/opencode/auth.json{provider: {type: api, key}}into that file, keeping other providers, mode 0600CopilotCOPILOT_GITHUB_TOKENis incredentials.envcredentials.envGrokBuild.grok/auth.json, orXAI_API_KEYXAI_API_KEYintocredentials.envComponentCatalogue: the list above. Binaries and login markers are duplicated fromconfig/alfred.phpbecause the UI does not boot Laravel. A test guards against drift (§6).CredentialsFile: reads and writesKEY=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 runssupervisorctl restart horizon schedulerand shows "worker restarting".Http/TokenGuard: the expected token isRUNNER_SETUP_TOKENor the generated token file. A login form takes the token; the guard compares it withhash_equalsand sets an HttpOnlySameSite=Strictcookie 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 anOrigin/Hostcheck.Http/App: routesGET /(status table),POST /components/{id}(configure),GET /healthz(unauthenticated, returns onlyok, 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-imagejob, same steps in all three pipelines)docker compose -f runner/docker-compose.yml configwithrunner/.env.examplecopied to.env, to validate the compose file.docker buildx build --platform linux/amd64,linux/arm64 -f runner/Dockerfile ., with QEMU viadocker/setup-qemu-action/tonistiigi/binfmt.--loadamd64, then runrunner/tests/smoke.sh:--version, inside the image.setup-uiwith a known token;/healthzreturns 200,/without the token returns 401,/status.jsonwith the token returns 200 and lists all 8 components. Start again with no token and checkdocker logsprints a generated one.~/.ssh:test ! -e /home/alfred/.sshinside the container, anddocker compose configcontains no.ssh.compose.ci.ymlbrings up postgres and redis,docker compose run --rm runner php artisan migrate --force, thenup -d, then poll untilphp artisan horizon:statusreports running and the scheduler program isRUNNING.docker compose restart, and it still shows as configured.Per platform:
docker/setup-buildx-action.docker:27with adocker:27-dindservice. This needs a privileged runner.runs-on: dockerwith thedocker:27-cliimage and the runner's Docker socket or DinD.The existing
checksjob stays as it is, and the new Pest tests run inside it.6. Tests to add (run by
php artisan testin 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 fakeCommandRunnerand a temp HOME:configure(): exact argv, secret only on stdin, files written with mode 0600;tests/Unit/RunnerSetup/CredentialsFileTest.php: allow-list, injection rejection, atomic write, permissions, existing keys kept.tests/Unit/RunnerSetup/CatalogueMatchesAlfredConfigTest.php: binaries and login markers equalconfig('alfred.agents.*'); forge binaries matchforge_cli_min_versions.tests/Unit/RunnerSetup/DockerfilePinsTest.php: parses theARG *_VERSION=defaults and checks each against the semver ranges and forge minimums withcomposer/semver.tests/Unit/RunnerSetup/ComposeFileTest.php:runner/docker-compose.yml(symfony/yaml is already present through Laravel, otherwise a regex check);${RUNNER_MOUNT_…:-…}and read-write, no.sshappears, the port is bound to127.0.0.1;hostname,HOST_RUNNER_ENABLED=trueandCONTAINER_RUNTIME=noneare set;RUNNER_MOUNT_*is listed in.env.example.tests/Feature/RunnerSetup/HttpTest.php: drivesHttp/Appwith request objects. Unauthenticated → 401 or login form; POST without CSRF → 419; configure → redirect, status flips;/healthzexposes nothing.tests/Browser/RunnerSetup/SetupUiTest.php(Playwright through pest-plugin-browser):php -S 127.0.0.1:<free port> runner/setup/public/index.phpwith 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);afterAll. This reuses the approach oftests/Support/PlaywrightServerReaper.php.docker compose uplocally, openhttp://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
docker buildandsmoke.sh..env.example,.dockerignore, and the compose/pins/catalogue drift tests.feat(runner): …test(runner): …ci(runner): …docs(spec): …8. Risks and mitigations
~/.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 totouchboth files. AC1 still holds for hosts where they already exist.PUID/PGID(default 1000) are aligned in the entrypoint, and.env.exampledocumentsid -u/id -g.schedule:work, there are two worker hosts, which §3.3 forbids. Documented in the README and §17.7.docker compose build) alongside the instance. Documented.stopwaitsecs, set above the longest run timeout. The UI says so before saving.cache-from type=ghaon GitHub). If CI time becomes a problem, the job runs only whenrunner/**,composer.lockorapp/**change.authsub-commands) are not certain from memory. They must be checked against current docs (context7 or the official sites) before the Dockerfile and theconfigure()argv are written.The implementation plan is ready for validation. Add the ai::validate_plan label to let me start.