Bootstrap Agent Workspaces
One-time steps to bring up agent-workspaces on ringtail. Ordinary
redeploys are just mise run provision-ringtail; these steps cover the state
that lives outside git (the Forgejo bot user, the agent’s OAuth login, and
Claude’s first-run consent) — see the status note below — and must be done by
a human.
Status (2026-08-19): the containerized stage of the agent workspace — the
agent-wspod and theagent-ws-*/agent-repos-inithost services — is retired, superseded by talos. The Forgejo-bot steps (§1–2) and theagentuser still apply; the Claude install / OAuth / start-services steps (§4–6) are historical, retained for the pre-talos record.
Do these in order. Steps 1–2 can happen before the config is deployed;
steps 4–6 require the agent user to exist (i.e. after the first
nixos-rebuild).
1. Create the Forgejo bot user
Status: already done (2026-07-08) — the
agentsuser exists and has the bot public key. This section documents how, for reproduction/rotation.
On indri (Forgejo admin CLI), create a non-admin user named agents. Forgejo
runs as erichblume with its work path at /Users/erichblume/forgejo:
ssh indri
FJ=/Users/erichblume/code/3rd/forgejo/forgejo
WP=/Users/erichblume/forgejo; CFG=$WP/custom/conf/app.ini
"$FJ" admin user create --username agents --email blume.erich+agents@gmail.com \
--random-password --must-change-password=false --work-path "$WP" --config "$CFG"(Email is a +agents gmail alias because eblu.me has no working MX yet.)
Then, in the Forgejo web UI as an admin:
- Add the public key from the
agents-forgejo-botvault item (see agents-forgejo-bot) to theagentsuser’s SSH keys.
Do not grant repo access here. Collaborations come from
repos.json(argocd/manifests/talos/), reconciled by the Agent Repo Access workflow, which is authoritative in both directions — a repo absent from that file has itsagentscollaboration removed, so a grant clicked in the web UI is reverted on the next run. The same file drives the pod’s clone loop, which is the point: a missing grant and a typo look identical from inside the pod (Forgejo 404s rather than 403s on a private repo the bot cannot see). To share a repo, editrepos.jsonand open a PR. See agents-forgejo-bot.
agentsandblumeopsare pinned read-only. The reconciler refuses awriterequest for either, andrepos.jsoncannot override it — that fence lives in reviewed code. Both are clonedfork: the bot pushes branches toagents/<repo>and opens cross-repo PRs, so a human merge sits in front of the instructions every future session boots with and in front of blumeops CI (and therefore its deploy secrets). Every other pooled repo iswrite+canonical; write ≠ deploy, since the gates are the blumeops 1Password vault and the root-only kubeconfig, not repo permissions. See agent-workspaces §“blumeops: author-only, not a server”.
mainis intentionally not branch-protected against the bot. A username push-whitelist rejects CI’s automatic Forgejo Actions token (Forgejo #11159), which breaks the release workflows. The bot can push tomain; the boundary is convention (open PRs) plus the fact that the bot holds no deploy credentials. See agent-workspaces §Isolation.
1b. Create the agents Forgejo PAT (for PR creation)
Status: done (2026-07-08) — item
agents-forgejo-tokenin the agents vault.
Agents open PRs with tea, which needs a token. Mint it as the agents
user (not admin): a Forgejo PAT can’t exceed its owner’s permissions, so an
agents-owned token is structurally bounded to the repos agents collaborates on
— the ownership bound is the guardrail, not the scope list.
ssh indri
FJ=/Users/erichblume/code/3rd/forgejo/forgejo
WP=/Users/erichblume/forgejo; CFG=$WP/custom/conf/app.ini
"$FJ" admin user generate-access-token --username agents \
--scopes write:repository,write:issue --token-name agent-pr \
--work-path "$WP" --config "$CFG"Store it concealed in the agents vault as agents-forgejo-token →
api-token (op item create --vault agents --category "API Credential" --title agents-forgejo-token "api-token[password]=<tok>"). The workspace launcher reads
it via the op shim and both exports FORGEJO_TOKEN and seeds tea’s config, so
PR creation has no blumeops-vault dependency. Scopes are write:repository
write:issue(tea’spr createneeds the issue scope — PRs are issues in Forgejo) — neverwrite:admin/write:organization/sudo.
2. Confirm vault items exist
The agents vault must contain (created during the prototype, 2026-07-08):
agents-forgejo-bot— SSH keypair (private key,public keyfields).agents-forgejo-token— Forgejo PAT (api-tokenfield,write:repository).- The
agents-ringtail Service Accountitem lives in the blumeops vault and holds theagents-ringtail-rwtoken (read/write onagentsonly).
3. Deploy the config
mise run provision-ringtailThis writes /etc/agents/op-token and /etc/agents/ssh/id_ed25519, and
creates the agent user. (The agent-repos-init + agent-ws-* host services
it used to install are retired — see the status note above.)
4. Install Claude Code for the agent user
Remote Control moves fast, so we use the official self-updating installer rather than nixpkgs (see agent-workspaces “Known warts”):
ssh ringtail
sudo -u agent -H bash -lc 'curl -fsSL https://claude.ai/install.sh | bash'
# verify:
sudo -u agent -H bash -lc '~/.local/bin/claude --version'5. Log in (OAuth) — interactively, then again every 5 days
Remote Control needs a full-scope subscription OAuth login: the credential
an interactive claude auth login writes to ~/.claude/.credentials.json on
the PVC. It is the only credential Remote Control accepts — a
claude setup-token / CLAUDE_CODE_OAUTH_TOKEN token is inference-only and
claude refuses to start Remote Control with one (v0.16.0 shipped that and
crash-looped; see Authentication).
This is not a one-time step: the login’s refresh token expires ~29
days after login. Seed it by performing one rotation, and the recurring 21-day
“Rotate Claude OAuth login” heph chore (Blumeops project) kept it alive from
then on. (The dedicated rotation runbook retired with the agent-ws pod.)
Two pieces of first-run state live outside the credential and still need seeding on a fresh PVC:
- Remote Control consent is per-config-dir and MUST be seeded, or every
service blocks invisibly. On first
remote-controllaunch Claude promptsEnable Remote Control? (y/n). A non-interactive service can’t answer it, so it hangs at the prompt — and because the launcher wraps Claude inscript, the hung process still reportsactive (running)to systemd with 0 restarts. It looks healthy but never connects (this bit us: the only visible environment was a stale ghost from an earlier run). The consent is stored in<config-dir>/.claude.jsonas"remoteDialogSeen": true. Seed it either by:- running
~/.local/bin/claude remote-controlonce interactively and answeringy+Enter (confirm it printsConnected), or - pre-seeding non-interactively: set
"remoteDialogSeen": trueat the top level of.claude.json(same edit pass as the trust flags below), which is the reproducible path. It persists on disk (survives reboots/re-provision), but a freshagenthome needs it re-seeded.
- running
- Trust is per-directory (
~/.claude.json→projects["<path>"].hasTrustDialogAccepted). A login trusts only its own cwd — with the single home-base workspace that is the only cwd that needs it (~agent/code/personal/agents). If the workspace cwd ever moves, pre-seed the new path (sethasTrustDialogAccepted=true, thenchown agent:agent ~agent/.claude.json && chmod 600 ~agent/.claude.json) or the service crash-loops on the untrusted dir.
6. Start the services
Historical. The
agent-ws-*host services are retired (superseded by talos); the commands below are the pre-retirement record.
ssh ringtail 'sudo systemctl start agent-repos-init.service'
ssh ringtail 'sudo systemctl start "agent-ws-*"'
ssh ringtail 'systemctl status "agent-ws-*" --no-pager'Open the Code tab in the Claude mobile app — ringtail-agent should appear
online. Tapping it and starting a session spawns an isolated worktree of the
agents home-base repo, with the sibling repos alongside at
~/code/personal/.
7. Seed the heph spoke (one-time)
The agent runs a hephd spoke (see The heph spoke (deliberately in-boundary)).
The mechanical parts — cargo install via mise, the services, owner adoption
(--owner-id) — are source-controlled and come up on provision-ringtail. These
are the irreducible secret/identity steps a human does once. They are fiddlier
than they look; read the gotchas.
-
Give
heph-agentsa password + MFA — logged in ASheph-agents. The blueprint creates theheph-agentsuser (heph-scoped group, notadmins). Set its password (ak shell→u.set_password(...), or the UI) and store it in the blumeops vault (itemheph-agents-login) — the human reads blumeops, not the agents vault. Then, in a private/incognito window (see the gotcha), sign in tohttps://authentik.ops.eblu.measheph-agentsand complete the forced TOTP enrollment (MFA is enforced on the default flow — appropriate, this identity reaches your tasks). Save the TOTP secret intoheph-agents-login. -
Seed the token — approve the device code in that same
heph-agentssession. Confirm the build finished (systemctl status agent-heph-install), then:# heph-token-save is a nix store path; resolve it from the spoke unit: SAVE=$(ssh ringtail 'systemctl show agent-heph-spoke -p ExecStart --value' \ | grep -oE '/nix/store/[a-z0-9]+-heph-token-save/bin/heph-token-save') ssh ringtail "sudo -u agent -H env HOME=/home/agent ~agent/.cargo/bin/heph auth login \ --hub-url http://indri.tail8d86e.ts.net:8787 \ --issuer https://authentik.ops.eblu.me/application/o/heph/ \ --client-id heph --no-browser --token-save-cmd $SAVE"Approve the printed URL in the incognito
heph-agentssession. On successheph-token-savewrites the token toop://agents/heph-spoke-token/token.THE trap: the device flow authorizes as whoever the browser is logged into. If you open the URL in your normal browser (logged in as you), it silently binds you as the hub owner and puts your token in the agents vault — not what you want. Always approve from a session logged in as
heph-agents. Device codes also expire in a few minutes, so do the TOTP enrollment (step 1) first, then the approval is a single click. If they keep expiring, pull the current code straight from Authentik:DeviceToken.objects.filter(provider__name="Heph").order_by("-pk").first().user_code. -
Record the authorized sub for the hub. Decode the token’s
sub(ahashed_user_id) and store it in the blumeops vault (heph-agents-sub/sub):# as the agent; base64URL needs padding — python is more reliable than `base64 -d` op read op://agents/heph-spoke-token/token \ | python3 -c 'import sys,json,base64; t=json.load(sys.stdin)["access_token"].split(".")[1]; t+="="*(-len(t)%4); print(json.loads(base64.urlsafe_b64decode(t))["sub"])' op item create --vault blumeops --category "API Credential" \ --title heph-agents-sub "sub[text]=<the-sub>" -
Authorize on the hub, then (re)start the spoke on a clean store.
mise run provision-indri -- --tags heph # hub picks up --authorized-sub # the spoke's fresh store minted its own owner_id before adoption; reset it so # it re-pulls under the adopted --owner-id: ssh ringtail 'sudo systemctl stop agent-heph-spoke; \ sudo rm -f /home/agent/.local/share/heph/heph.db*; \ sudo systemctl start agent-heph-spoke' ssh ringtail 'sudo -u agent -H env HOME=/home/agent ~agent/.cargo/bin/heph list --project Blumeops'Expect your real Blumeops tasks.
heph sync --statusshould showauth_failure=false, last_error=null.
Revoke by disabling the
heph-agentsAuthentik user, or by removing theheph-agents-subvault item and re-provisioning indri (drops it from--authorized-sub) — either cuts the spoke without touching your own logins.
Other gotchas banked while bootstrapping this:
- If
git push(or any forge SSH) hangs, it’s an unapproved 1Password SSH-agent biometric prompt, not the network. nix build/nixos-rebuild --flake git+https://…?ref=maincaches the ref; use--refresh(or?rev=<full-sha>) to pick up a just-pushed commit.- Owner model:
heph-agentsis only the login credential (revocable). The hub still has one owner — you — and the spoke adopts yourowner_id(--owner-id) so the agent works your actual nodes. It is not a second owner.
Verifying the secrets path
From a spawned session (or sudo -u agent), plain op should work read/write
against the agents vault only:
op vault list # exactly: agents
op item get agent-test-secret --vault agents # reads
Teardown / rollback
Revert the PR and mise run provision-ringtail; then
ssh ringtail 'sudo userdel -r agent' if you want the home gone. Remove the
agents Forgejo user and revoke the service account in 1Password if retiring
the capability entirely.
Related
- agent-workspaces — design & operations
- agents-forgejo-bot — the bot identity