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