# CI with Fenzi https://fenzi.dev/docs/ci An agent in a CI job has nobody at its terminal. Fenzi runs it in a sandbox on the runner, under the same policy a laptop runs. Its commits come back as a branch for review, and the job's summary names every call the policy refused. Command: fenzi run --headless · GitHub Action · Free and Team ## Why a runner **A laptop is the developer's machine. A runner is the job's.** On a laptop the developer administers the sandbox runtime. On a GitHub-hosted runner the job installs it, runs one agent, and the machine goes away with the job. On Team the policy is the signed revision every laptop runs, and nobody in the job can approve a request. Claude Code, Codex, OpenCode and Grok today. Agents run with an API key; a subscription sign-in needs a person at a browser, so it does not work here. ## The GitHub Action The action installs `fenzi` and the sandbox runtime at the release `fenzi` is built for, signs in to your team when you give it a token, runs the agent, and keeps the result as an artifact. .github/workflows/agent.yml ```yaml on: workflow_dispatch: schedule: - cron: '0 6 * * 1' permissions: contents: read jobs: agent: runs-on: ubuntu-24.04 timeout-minutes: 45 steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: fenzi-inc/fenzi/action@main env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} with: agent: claude task-file: .github/agent-task.md timeout: 30m team-token: ${{ secrets.FENZI_TEAM_TOKEN }} ``` Pin the action to a commit rather than `main`. The model key goes in the step's `env`, from a secret. It reaches the sandbox as the agent's key does on a laptop: the agent sees a placeholder. Leave out `team-token` to run on Free, with the `fenzi.yaml` in the repository or `developer-default`. It runs on GitHub's Ubuntu runners, and on Linux runners of your own with Docker. Not on macOS runners: the runtime's installer there needs Homebrew and a password. ## Without the action Any CI system that gives you a Linux machine with Docker can run the same commands. ```bash fenzi setup --runtime ``` ```bash fenzi run --headless --task task.md --timeout 30m --out fenzi-out claude ``` `setup --runtime` installs the pinned runtime if it is missing, with no questions, and waits for its gateway. On a runner that keeps an older patch, it upgrades only when no sandbox is running, because the upgrade restarts the gateway; otherwise it keeps the installed release and says which sandboxes held it back. `--task` takes a file, or `-` for standard input; `--task-text` takes the task itself. The task goes to the agent as its prompt. Example output ``` requests nobody answers them on Free; a request the agent files stays refused and fails the run start claude --permission-mode bypassPermissions -p agent exited with status 0 log saved, fenzi log git 2 commits by Claude Code on fenzi/web-4f2a, now in your repository main is unchanged until fenzi apply changes 3 files staged, not applied result finished, exit 0 refused 1 call POST api.github.com/markdown: L7_REQUEST deny POST api.github.com:443/markdown reason=POST /markdown not permitted by policy sandbox fenzi-web-4f2a, deleted summary summary.json and summary.md, and the branch in branch.bundle written to fenzi-out ``` ## The agent's own mode Each agent runs in the non-interactive mode it already has. Its own permission prompts are off, because nobody would answer them. The sandbox's policy decides what each command can reach, as it does in every mode. | Agent | Starts as | | --- | --- | | Claude Code | `claude --permission-mode bypassPermissions -p ` | | Codex | `codex … exec --skip-git-repo-check ` | | OpenCode | `opencode run --auto ` | | Grok | `grok --permission-mode bypassPermissions --single=` | To keep the agent's own check as well, pass the flag after the agent's name: `fenzi run --headless --task task.md claude -- --permission-mode auto`. ## Requests while nobody watches **Nothing approves itself.** When the agent needs a host the policy does not allow, it can file a request, as it does on a laptop. - **On Team** the request goes to the team console while the job runs, and a reviewer answers it there. `--request-timeout 10m` bounds the wait: with no answer by then, Fenzi refuses the request with a reason the agent reads, and the agent carries on without the access. Without it, a request waits until the time limit. - **On Free** nobody can answer. The run says so at the start, and the agent is told not to file requests. The policy refuses what it always refuses, and nothing more. A request that never got an answer fails the run with exit status 6, so the job shows it. ## Time limit and exit status `--timeout` covers the whole run, 30 minutes unless you say otherwise. At the limit the agent is stopped, its commits and files still come back, and the sandbox is deleted. | Status | Means | | --- | --- | | `0` | The agent finished. | | `1`, `2`, `3` | The run did not start: an error, a policy the prover refused, or a gateway that is not ready. Without a verified team policy the run does not start. | | `4` | The agent exited with an error of its own. | | `5` | The time limit came first. | | `6` | A request had no answer. | Refused calls do not fail the run by themselves: refusing them is the policy's job. They are listed in the summary. ## What comes back The agent's commits land as a branch in the runner's checkout, through the same round trip as on a laptop ([Workspace and changes](https://fenzi.dev/docs/workspace)). The checkout itself is never changed. `--out` writes three files, and the action keeps them as the job's artifact: - `summary.md`, the job summary: the result, the policy revision, the branch, every refused call, and every request with its answer. - `summary.json`, the same as data. - `branch.bundle`, the agent's commits. `git fetch branch.bundle :` brings them into any clone. Pushing the branch is up to your workflow, with its own token, in a later step. The sandbox never holds a git token. The round trip needs the whole history. `actions/checkout` clones one commit unless you set `fetch-depth: 0`; the action fetches the rest itself when it can. A shallow clone otherwise comes back as files, and the summary says so. ## The team token A runner signs in with a token, because nobody can open a browser on it. Make one on your own machine. You approve the code in the browser, so the token belongs to you. ```bash fenzi login --token-for github-actions | gh secret set FENZI_TEAM_TOKEN ``` The token goes to standard output and is not stored on your machine, which stays signed in as it was. The console lists the runner as a machine of yours. Runs with the token count against your seat; there is no separate price for CI. Signing the machine out in the console ends the token. In the job, the token reaches `fenzi login --token-stdin` on standard input, never on a command line. The runner is signed out when the step ends. ## Not done yet - The runtime and all four agents have started on a GitHub-hosted runner, with the sandbox's kernel rules enforced. A full unattended run with a real model key, a refused call and a request answered in the console has not been recorded on a runner yet. - The console does not yet show which job filed a request. - A run record the pull request can check comes later. **What it calls upstream** The agent starts with `openshell sandbox exec --tty`, as an interactive run does. While it runs, `openshell rule get --status pending` every five seconds; a request that waited past `--request-timeout` gets `openshell rule reject` with a reason. Nothing calls `rule approve` without a person.