Olixero CI Agents
This directory contains the source for a dedicated, Dockerized Gitea runner that uses OpenCode and an OpenAI ChatGPT Pro subscription to plan and implement issues.
The runner handles two issue labels:
agent:plan: create a plan, independently review it, revise it up to three times, and publish the accepted plan as one issue comment.agent:implement: read the accepted plan, implement it on a stable branch, independently review and revise the diff up to three times, then create or update a pull request.
The generated pull request is built and tested by the repository's existing PR workflow on a different runner. The credential-bearing agent runner never executes model-modified project code.
Security Model
- The runner is dedicated to this workflow and registers only
agentic:host. - Host mode means jobs execute inside the runner container, not on the physical Docker host.
- The Docker socket is not mounted.
- OpenCode receives a read-only Gitea token. The Gitea write token exists only in the claim and publish steps, when OpenCode is not running.
- All OpenCode agents deny shell commands, subagents, interactive questions, and external-directory access.
- The implementation agent cannot edit
.gitea,.ci-agents,.opencode,AGENTS.md, or.gitmodules. The publisher validates paths again before committing. - The OpenCode configuration and compiled publisher are baked into
/opt/ci-agentsin the image. Workflows never execute automation code from the checkout. - Runner capacity and workflow concurrency are both one because OpenAI OAuth refresh tokens can rotate.
- Issue text, comments, repository files, and web results are treated as untrusted input.
Do not use this runner for pull-request workflows, arbitrary repositories, or unrelated jobs.
Files
.ci-agents/
├── Dockerfile Custom Gitea runner image
├── compose.yaml Runner and one-shot registration services
├── runner-config.yaml Capacity-one host runner configuration
├── opencode/ Immutable CI-only OpenCode config and agents
├── src/ TypeScript claim/run/publish orchestration
├── bin/git-askpass.sh Immutable Git credential helper
├── package.json
├── package-lock.json
└── .env.example
.gitea/workflows/issue-agents.yml
Developers running OpenCode normally do not load .ci-agents/opencode. The workflow explicitly selects the image copy with OPENCODE_CONFIG_DIR=/opt/ci-agents/opencode while disabling project and external configuration discovery.
Prerequisites
- Gitea 1.26 or newer. The current instance was verified as 1.26.4.
- Docker Engine with Compose v2, or a compatible Podman deployment.
- A repository-scoped Gitea runner registration token. Do not register this credential-bearing runner at organization or instance scope.
- A dedicated Gitea account or token with repository read access for OpenCode and Gitea MCP.
- An OpenAI account with an active ChatGPT Pro subscription.
- Outbound HTTPS access to OpenAI, Gitea, Exa,
mcp.grep.app, npm, Docker Hub, andgitea.comwhile building.
The image pins:
- Gitea Runner
1.0.8 - OpenCode CLI and SDK
1.17.18 - Gitea MCP
1.3.0 - Node.js 24 from the pinned runner's Alpine base (Node.js 22 is used to compile the TypeScript bundle)
1. Prepare Configuration
Run from root:
cp .env.example .env
Edit .env:
GITEA_INSTANCE_URL=https://git.krtss.de
GITEA_RUNNER_NAME=olixero-agentic
CI_AGENT_IMAGE=olixero-ci-agent-runner:1.0.0
RUNNER_DATA_DIR=/srv/olixero-ci-agent/runner
OPENCODE_DATA_DIR=/srv/olixero-ci-agent/opencode
CACHE_DIR=/srv/olixero-ci-agent/cache
RUNNER_TOKEN_FILE=./secrets/runner-token
Create the bind-mount directories for the fixed container identity 10001:10001:
sudo install -d -o 10001 -g 10001 -m 0700 \
/srv/olixero-ci-agent/runner \
/srv/olixero-ci-agent/opencode \
/srv/olixero-ci-agent/cache
Create the temporary registration-token file:
install -d -m 0700 secrets
install -m 0600 /dev/null secrets/runner-token
Paste one Gitea runner registration token into secrets/runner-token. The directory and .env are ignored by Git.
2. Build The Runner Image
docker compose build --pull runner
The build verifies the official SHA-256 checksum for the architecture-specific Gitea MCP binary. Both linux/amd64 and linux/arm64 are supported.
3. Register The Runner Once
docker compose --profile register run --rm register
Confirm in Gitea that the runner is online or idle and has exactly this label:
agentic:host
After successful registration, erase the reusable registration token while leaving the file present for Compose validation:
: > secrets/runner-token
chmod 0600 secrets/runner-token
The registration itself persists in ${RUNNER_DATA_DIR}/.runner.
The one-shot registration container runs as root only so it can read the mode-0600 Compose secret, then changes .runner ownership to the daemon identity 10001:10001. The long-running service remains non-root.
4. Authenticate OpenCode With ChatGPT Pro
Do this while the runner is stopped or before its first start. The interactive helper uses the same image, UID, environment, and OpenCode data mount as the daemon:
docker compose run --rm --no-deps \
--entrypoint /usr/local/bin/opencode \
runner auth login \
--provider openai \
--method "ChatGPT Pro/Plus (headless)"
OpenCode prints a URL and device code:
Go to: https://auth.openai.com/codex/device
Enter code: ...
Waiting for authorization...
Open the URL on another computer, sign in to the intended Pro account, enter the code, approve access, and wait for the container to report success. No browser or inbound callback port is needed on the homelab host.
The credential is stored on the host at:
${OPENCODE_DATA_DIR}/opencode/auth.json
Verify that it is owned by 10001:10001 with mode 0600:
sudo stat -c '%u %g %a %n' \
/srv/olixero-ci-agent/opencode/opencode/auth.json
Verify credential discovery:
docker compose run --rm --no-deps \
--entrypoint /usr/local/bin/opencode \
runner auth list
List subscription models:
docker compose run --rm --no-deps \
--entrypoint /usr/local/bin/opencode \
runner models openai
The checked-in agents use openai/gpt-5.4. If that model is not listed, update the model fields in opencode/opencode.json and opencode/agents/*.md, rebuild the image, and repeat the smoke test.
Make one live request:
docker compose run --rm --no-deps \
--entrypoint /usr/local/bin/opencode \
runner run --model openai/gpt-5.4 \
"Reply with exactly: OPENCODE_CI_AUTH_OK"
OpenCode automatically refreshes access tokens and writes rotated refresh tokens back to this mount. Do not restore a static credential before jobs, copy Codex's ~/.codex/auth.json, or put auth.json in Gitea secrets, caches, images, or artifacts.
To reauthenticate later:
docker compose stop runner
docker compose run --rm --no-deps \
--entrypoint /usr/local/bin/opencode \
runner auth login \
--provider openai \
--method "ChatGPT Pro/Plus (headless)"
docker compose up -d runner
5. Configure Gitea Repository Secrets And Variables
Create a dedicated read-only personal access token for the account used by OpenCode. It needs to read this repository, issues, comments, pull requests, and source. It must not be able to write repository content, comments, labels, or pull requests.
Create a second token for a dedicated CI bot account. It needs to write repository branches, issue comments and labels, and pull requests. It must be a normal bot PAT rather than Gitea's built-in Actions token: Gitea suppresses workflow triggers caused by its Actions identity, while pull requests created by the normal bot must trigger the separate test workflow.
Add these repository Actions secrets:
| Secret | Required | Purpose |
|---|---|---|
CI_AGENT_READ_TOKEN |
Yes | Read-only Gitea REST, Git fetch, and Gitea MCP access |
CI_AGENT_WRITE_TOKEN |
Yes | Claim labels/comments, push generated branches, and create or update pull requests |
The workflow passes CI_AGENT_WRITE_TOKEN only to immutable claim and publish code from the runner image. OpenCode receives only CI_AGENT_READ_TOKEN.
Add the repository Actions variable CI_AGENT_ALLOWED_ACTORS as a comma-separated list of Gitea logins allowed to trigger agents:
alice,bob
If this variable is empty, any user who can add repository labels can trigger an agent run.
6. Create Labels
Create these repository labels exactly:
| Label | Required | Behavior |
|---|---|---|
agent:plan |
Yes | Starts planning and is removed when claimed |
agent:implement |
Yes | Starts implementation and is removed when claimed |
agent:generated |
Recommended | Added to generated pull requests when present |
agent:blocked |
Recommended | Added to issues after a failed run when present |
agent:plan-ready |
Recommended | Added after an accepted plan when present |
Optional result labels are best-effort. Their absence does not fail a successful run.
7. Start The Runner
docker compose up -d runner
docker compose logs -f runner
The main runner service does not mount the registration-token secret and has no Docker socket. Verify in Gitea that it is attached only to the Olixero repository, not to an organization or the whole instance.
Operation
Plan an issue
- Create or update an issue with the complete feature requirements.
- Add
agent:plan. - The workflow removes the trigger label and maintains one status comment.
- A planning agent creates a plan and an independent reviewer accepts it or requests revisions.
- After at most three review cycles, the accepted plan is persisted in one bot-authored comment with a hidden digest marker.
If the issue or default branch changes before publishing, the result is rejected as stale. Re-add agent:plan.
Implement an accepted plan
- Ensure the issue has an accepted CI-agent plan comment.
- Add
agent:implement. - The implementation agent edits a stable branch named
agent/issue-<number>-p<digest>. - An independent reviewer accepts the diff or sends it back for revision, up to three cycles.
- The immutable publisher validates paths, commits without hooks, pushes without force, and creates or updates one pull request.
- The normal PR workflow runs build and tests on a runner that has no OpenAI OAuth credential.
Changing the issue after planning invalidates the accepted plan. Run agent:plan again before implementation.
Retry a failure
Inspect the status comment and workflow log, correct the issue, remove agent:blocked if desired, and re-add the trigger label. Stable markers, branch names, and PR lookup prevent normal retries from creating duplicate comments or pull requests.
Updating The Automation
All active automation is baked into the image. After changing .ci-agents:
npm ci
npm run check
docker compose build --pull runner
docker compose up -d --force-recreate runner
Run the npm commands from .ci-agents. Increment CI_AGENT_IMAGE for deployments where retaining previous images is useful.
Never configure the workflow to execute .ci-agents/src, install dependencies from the checkout, or load .ci-agents/opencode directly. The image copy is the trust boundary.
Troubleshooting
Check the runner:
docker compose ps
docker compose logs runner
Check OpenCode authentication:
docker compose run --rm --no-deps \
--entrypoint /usr/local/bin/opencode \
runner auth list
Check MCP startup with the workflow environment available:
docker compose run --rm --no-deps \
-e OPENCODE_CONFIG_DIR=/opt/ci-agents/opencode \
-e OPENCODE_DISABLE_PROJECT_CONFIG=true \
-e OPENCODE_DISABLE_EXTERNAL_SKILLS=true \
-e GITEA_SERVER_URL=https://git.krtss.de \
-e GITEA_READ_TOKEN='<read-only-token>' \
--entrypoint /usr/local/bin/opencode \
runner mcp list
Do not place tokens directly in persistent Compose files or shell history. The final command above is diagnostic syntax; prefer a temporary environment file with mode 0600.
If OpenCode reports that ChatGPT Pro/Plus (headless) is unavailable, confirm the image version, leave default plugins enabled, rebuild, and check the interactive method list with opencode auth login --provider openai.