Stage 2 writes the first label, and nothing about the label protocol can be
dogfooded until an identity exists that is allowed to. Provisioned now rather
than deferred.
account tireless-poller (tireless-poller@lair.cafe), no ssh key — it never
clones anything
team lair/tireless-poller: Issues=write, PullRequests=read, Code=none,
everything else none, scoped to named repos
token write:issue, read:repository, read:user
This is the one place an org team earns its keep. Collaborator permissions in
Gitea are repo-wide, so granting issue write that way would hand this account
code push too — the unit-level team grants labelling without it.
The units now load different environment files, and that is not tidiness. The
runner spawns coding agents as subprocesses, and subprocesses inherit the
environment, so a label-capable token in the runner's environment is a token
every agent run can read. poller.env is loaded only by tireless-poller.service,
which spawns nothing.
Be precise about what that buys: the separation is between processes, not users.
Both units run as `tireless`, so the runner's uid can still read poller.env even
though its process never loads it. An agent would have to go looking rather than
find it handed over in `env` — a real difference, not a boundary. Closing it
means separate service accounts or LoadCredential=; recorded as a stage 8 item so
it is a known gap rather than an assumed guarantee.
Verified each identity can do its own job and not the other's:
tireless tireless-poller
add label 403 200
fork repo works 403
read issues 200 200
One measurement caveat recorded in §6.4: the poller can still read repository
contents despite Code=none, because lair/tireless is public. The unit permission
bites on private repos — do not read that as the grant being wrong.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013TxK1CWPkFXqdcXMJ4hVe6
Reverses the org team added an hour ago. It was solving the wrong problem.
Measured on the bare account, with no team and no collaborator role: pushing to
its own fork works, opening a cross-repo pull request works, commenting works,
creating an issue works. Pushing to a lair repo is denied and so is deleting it.
Exactly one §2.2 capability is out of reach — applying a label — and that is the
one that must not be bought by granting code write.
So the runner gets nothing at all, which is a better property than a carefully
scoped grant: there is no permission to review, no team to audit, and no rule to
forget on the next repo added. Adding a repo to its remit is now one step, a
fork, rather than three.
Labelling moves to a separate identity, which lines up with the split the two
units already have. The runner records state in Postgres — already the authority
per §2.2 — and the poller reconciles labels onto the forge under its own account
with issue write and no code access. The token that can touch issues is held by
the process that never runs an agent; the token held by the process running
unattended agents can only push to a repository nobody depends on.
That also removes a silent failure I found while testing this. Creating an issue
*with* labels as an unprivileged user returns 201 and drops the labels — no
error, an issue that never gets picked up. A Plan job stamping inherited
admission on its children would have failed precisely that way and looked fine.
Children are now created bare, with their intended labels recorded in Postgres
for the poller to apply.
The second identity is provisioned with stage 2, when the first label is
written. Nothing before then needs it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013TxK1CWPkFXqdcXMJ4hVe6
The design had tireless pushing `tireless/*` branches into the repo it was
working on, with branch protection denying it `main`. It now works the way an
outside contributor does: pushes only to its own fork, reaches the real
repository through a pull request, and has no write access to that repository at
all.
That is a stronger guarantee than the one it replaces. Branch protection is a
rule that can be edited, applies per repo, and is easy to forget on the next repo
added; a bot with no push permission cannot write to `main` whatever anyone
forgets.
The wrinkle is the label protocol. Labels are a write on the issues unit, and
Gitea collaborator permissions are repo-wide, so granting issue write as a
collaborator would hand back the code push the fork exists to avoid. Access is
therefore an org team with unit-level permissions -- lair/tireless-agent, with
Issues=write, PullRequests=write, Code=read, everything else none, scoped to
named repositories rather than the whole org.
Provisioned and verified end to end rather than assumed:
push to the fork succeeds
push to lair/tireless "User permission denied for writing."
label an issue 200 / 204
cross-repo PR from fork opened as `tireless`
delete the repository 403
Two credentials with different blast radii: an ssh key on the account for git
transport, and an API token scoped write:issue + write:repository + read:user for
issues and PRs. known_hosts is pre-seeded, because an unattended git must not
prompt and accept-new would trust whatever answered first.
Stage 4 gains the consequence: two remotes, and a fork that has to be checked for
staleness before branching.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013TxK1CWPkFXqdcXMJ4hVe6
The documented command fails:
npm error Error: spawn sh EACCES
npm error path: '/home/grenade'
`-H` sets HOME, but sudo leaves the working directory where it was invoked —
the operator's own 0700 home, which the service account cannot read. npx then
fails spawning its `sh -c claude` there. The error names npm and a package, so
it reads like a broken install rather than a directory permission, which is a
bad first experience of the one step in stage 0 that has to be done by hand.
Add the cd, use `bash -c` rather than `sudo -iu` (the account's shell is nologin
by design), explain why both are needed, and point at CLAUDE_PACKAGE so the
version logged in with cannot drift from the version the runner npx's.
Verified on bob: the pinned package now runs as the service account and reports
2.1.220.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013TxK1CWPkFXqdcXMJ4hVe6
Run 4 failed at `ship artifacts` with `sudo: unrecognized option '--server'`.
`R="--rsync-path=sudo rsync --mkpath"` expanded unquoted as `rsync $R` splits
into three arguments — `--rsync-path=sudo`, plus a stray `rsync` that rsync
reads as a source path — so the remote end ran `sudo --server`. Use an array.
The dashboard step quoted it inline and was unaffected, which is why only half
the deploy was broken.
Every rsync destination and every sudo command in `apply system state` has now
been exercised directly against bob as gitea_ci, rather than by another six
minute round trip: seven rsync targets, sysusers, restorecon, firewalld and
daemon-reload all pass.
That surfaced the second fault. restorecon was given /var/lib/tireless, which
does not exist on a fresh host: infra-setup.sh tried to create it before
systemd-sysusers had created the account to own it, so the attempt always raced
and always lost. Declare StateDirectory=tireless on all three units instead —
systemd creates the directory, owns it as the service user and labels it — and
drop the path from restorecon, the grant, and infra-setup.
Refs #9
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013TxK1CWPkFXqdcXMJ4hVe6
sudo matches an argument vector exactly, so the sudoers line and the command
the workflow runs have to agree character for character. Moving the dashboard
to the proxy shortened the workflow's restorecon to five paths but left the
grant listing six, which denies it.
/var/www/tireless was never bob's to relabel in any case — the proxy's own
grant covers it, and that one is scoped to static files alone.
Refs #9
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013TxK1CWPkFXqdcXMJ4hVe6
Three faults, any one of which would have failed the first deploy — found by
reading the house conventions in ~/git/architecture rather than by running it.
`runs-on: fedora-43-rust` is not a registered runner label. The catalogue in
gitea-runners.md §2 has `rust`; the job would never have been scheduled. Also
collapse build and deploy into one job: the `rust` image descends from
runner-fedora-44 and carries node, ssh and rsync, so the artifact upload and
download — on actions/*-artifact@v3, EOL upstream — bought nothing but a
round-trip and a directory-structure assumption. rpm.lair.cafe's working deploy
is single-job for the same reason.
The nginx vhost bound `listen 443 ssl`. TCP 443 on hanzalova belongs to the
stream SNI router (reverse-proxies.md §4-5, and the live bench.internal.conf
confirms it), so the vhost would never have been reached — the router answers
first with whichever certificate its default branch holds. `nginx -t` passes
either way, which is what makes this worth catching by reading rather than by
deploying. Now `listen 127.0.0.1:14443 ssl proxy_protocol;`.
Cert paths pointed at the host identity cert under /etc/pki/tls. A per-service
name needs its own cert (internal-tls.md §2): the host cert's SAN is bob's FQDN,
not tireless.internal, so verification would have failed. Now
/etc/nginx/tls/{cert,key}/tireless.internal.pem, minted with --san and renewed
by step@tireless.timer.
infra-setup.sh now provisions the ingress rather than describing it: mints the
cert through the JWK provisioner (removing the credential even on failure),
installs the vhost via sites-available + symlink, and registers the
split-horizon record on BOTH routers — a record on one router NXDOMAINs at the
other site. opn-cli has no reconfigure verb, so the apply is a direct API POST;
without it the name resolves only whenever Unbound next happens to reload.
Also fold the stale-bindings check into the workflow (closes the CI half of #8)
and let the runner unit fail without failing the deploy, since it correctly
refuses to start until the interactive login exists.
Refs #9
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013TxK1CWPkFXqdcXMJ4hVe6
Three artefacts disagreed about where nginx runs. design.md §6.2 and the vhost
both said the hanzalova proxy; the API bound 127.0.0.1 and the workflow rsynced
the dashboard to bob. That combination deploys green and then serves nothing,
since a proxy on another host cannot reach bob's loopback.
Resolve it the way design.md already stated: nginx on the proxy, dashboard
shipped there, API bound 0.0.0.0 behind firewalld and the mesh. The health probe
now runs from the proxy over the mesh rather than from bob's loopback, so it
fails when firewalld is closed instead of passing regardless. infra-setup.sh
grows a proxy grant scoped to static files alone, and the nginx vhost install as
a manual step — it needs a certificate, and nothing was telling the operator to
install it at all.
npm run lint had never run: eslint 9 needs a flat config and there was none. Add
it, ignoring the ts-rs generated bindings, and run it in CI so it stays true.
Untrack dashboard/tsconfig.tsbuildinfo, a build artifact that would have put a
spurious diff in every pull request tireless opens.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013TxK1CWPkFXqdcXMJ4hVe6
helexa#179's application-owned system prompts let tireless shape both ends of
the cc->oc handoff, so the "will a 27B execute an Opus plan" risk becomes a
tunable rather than a hope.
Three prompts, versioned as one set: plan.cc.md tells Claude Code it is writing
for a literal, absent reader; implement.oc.md tells OpenCode to execute exactly
that and report rather than improvise; implement.cc.md covers unplanned issues.
PromptSet::load refuses a mismatched set, and tests assert the prompts mention
every section ChildSpec requires.
The middle is validated, not trusted. plan::validate rejects a plan before any
implementation job is enqueued unless every child carries a runnable acceptance
command (a stopping condition) and a non-empty out-of-scope list (a boundary) --
the two sections a small model needs and a human reader does not. Dangling and
cyclic dependencies are caught too, and implementation_order derives the start
order.
cc uses --append-system-prompt, never --system-prompt: replacing Claude Code's
default discards the tool-use scaffolding that makes it a coding agent. Pin
bumped to 2.1.220, the version this flag surface was verified against.
The oc path depends on helexa#179's passthrough guarantee, which is still open
and unverified for qwen3 arch templating. Stage 5 now opens with a PONG probe
rather than debugging it through a failed implementation run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DHhHtohxcdk1PL3tfnYJdH
Autonomous issue-to-PR driver for Claude Code and OpenCode, structured per
lair/architecture generic.md.
Workspace: entities/core/data/agent library crates plus api, worker and cli
binaries. Two pieces of real logic land with tests — lane routing (cc for
judgement, oc for specification) and the limit governor.
Constraints encoded as code rather than comments:
- agents are spawned as vendor binaries; tireless never calls a provider API
- ANTHROPIC_API_KEY is never set by tireless, only passed through
- assert_not_anthropic refuses to start an OpenCode lane pointed at Anthropic
- every run passes the governor; provider rate-limit signals win over our own
accounting
Deployment assets target bob.hanzalova.internal:23296 (registered in
port-allocations.md), fronted by hanzalova at tireless.internal.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DHhHtohxcdk1PL3tfnYJdH