cc-ci host: the orchestrator is an opencode agent; no Claude on the box; /secrets convention

Operator 2026-09-07 (evening): Claude sessions stay on notplants-orchestrator;
on the cc-ci host the orchestrator is an opencode agent (agents.toml:
opencode/glm-5.2) steered from https://oc.ci.commoninternet.net, next to the
upgrader/report sessions. claude-install and CLAUDE_BIN are gone from the
modules; launch-upgrader/report print the real UI URL; README "Operating the
orchestrator" rewritten and a "Weekly upgrade run" section added.

Secrets: only cc-ci's, under /secrets/files with runtime paths symlinked
(README §4 table), nothing from other projects — tangled key and the
tailscale line dropped from the host.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FqkQq3CDmFWcQ7u1LzoyRz
This commit is contained in:
2026-09-07 21:27:23 +00:00
co-authored by Claude Fable 5.1
parent 01caa90e76
commit a72a816b48
7 changed files with 66 additions and 70 deletions
+3 -2
View File
@@ -31,8 +31,9 @@ the orchestrator watches from outside.
Reboot resilience is handled by **`cc-ci-loops.service`** (system unit): on boot it logs the reboot Reboot resilience is handled by **`cc-ci-loops.service`** (system unit): on boot it logs the reboot
to `REBOOTS.md` (boot_id-gated) and runs `launch.sh start` with `RESUME_PHASE=1`, so the loops + to `REBOOTS.md` (boot_id-gated) and runs `launch.sh start` with `RESUME_PHASE=1`, so the loops +
watchdog auto-resume the saved phase. The orchestrator session itself is relaunched by watchdog auto-resume the saved phase. The orchestrator session itself is relaunched by
`cc-ci-orchestrator.service` (`agents.py up orchestrator`) — the operator reconnects to it (that's `cc-ci-orchestrator.service` (`agents.py up orchestrator`) as an **opencode agent** the operator
why the startup notification matters). Since 2026-09 the orchestrator runs on the **same Hetzner steers from https://oc.ci.commoninternet.net (no Claude on the cc-ci host; Claude sessions run on
the notplants-orchestrator box and reach cc-ci over ssh). Since 2026-09 the orchestrator runs on the **same Hetzner
host as the cc-ci CI server** (`cc-ci`, public `195.201.88.249`, tailnet `cc-ci`), declared by host as the cc-ci CI server** (`cc-ci`, public `195.201.88.249`, tailnet `cc-ci`), declared by
`nixosConfigurations.cc-ci` in this repo's `flake.nix`, which imports the CI server from the cc-ci `nixosConfigurations.cc-ci` in this repo's `flake.nix`, which imports the CI server from the cc-ci
repo's `nixosModules.cc-ci-server`. `ssh cc-ci` from the loops user therefore goes to loopback. repo's `nixosModules.cc-ci-server`. `ssh cc-ci` from the loops user therefore goes to loopback.
+37 -26
View File
@@ -9,7 +9,7 @@ module from the [cc-ci](https://git.autonomic.zone/recipe-maintainers/cc-ci) rep
| | where | | | where |
|---|---| |---|---|
| Orchestrator loops, timers (weekly upgrader, hourly supervisor) | `nix/modules/cc-ci.nix``nixosModules.cc-ci-orchestrator` | | Orchestrator loops, timers (weekly upgrader, hourly supervisor) | `nix/modules/cc-ci.nix``nixosModules.cc-ci-orchestrator` |
| The host contract those need (loops user, claude/opencode CLIs, opencode web UI) | `nix/modules/orchestrator-host.nix``nixosModules.orchestrator-host` | | The host contract those need (loops user, opencode CLI, opencode web UI) | `nix/modules/orchestrator-host.nix``nixosModules.orchestrator-host` |
| The CI server (swarm, traefik, drone, runner, `!testme` bridge, dashboard, reports, acme-dns) | cc-ci repo `nix/modules/``nixosModules.cc-ci-server` (flake input `cc-ci`) | | The CI server (swarm, traefik, drone, runner, `!testme` bridge, dashboard, reports, acme-dns) | cc-ci repo `nix/modules/``nixosModules.cc-ci-server` (flake input `cc-ci`) |
| The machine: hardware, networking, root keys, firewall + fail2ban | `nix/hosts/cc-ci/``nixosConfigurations.cc-ci` | | The machine: hardware, networking, root keys, firewall + fail2ban | `nix/hosts/cc-ci/``nixosConfigurations.cc-ci` |
| Plans, launch tooling, loop prompts, journal | `cc-ci-plan/` (see `AGENTS.md` for roles) | | Plans, launch tooling, loop prompts, journal | `cc-ci-plan/` (see `AGENTS.md` for roles) |
@@ -205,22 +205,24 @@ git clone https://git.autonomic.zone/recipe-maintainers/cc-ci.git cc-ci-adv #
mkdir -p .cc-ci-logs .sops mkdir -p .cc-ci-logs .sops
``` ```
Then the files below (`install -m600 -o loops -g users`): Secrets live in **`/secrets/files/`** (owned by loops, mode 700 on the directory), the same
convention as the notplants-orchestrator box, with the runtime paths symlinked to them so one
`ls /secrets/files` is the complete inventory. **Only cc-ci's secrets go there** — nothing from
other projects on the box they were copied from (no tailscale, lichen, tangled, b1, borg, …):
secrets do not get spread around. `/secrets/README.txt` lists each file and its purpose.
| file | what | source | | runtime path → `/secrets/files/…` | what | source |
|---|---|---| |---|---|---|
| `/srv/cc-ci/.testenv` | `TS_AUTH_KEY`, `GITEA_PASSWORD` (autonomic-bot), `DOCKERHUB_USERNAME/TOKEN`, model API keys | old host `/secrets/files/cc-ci.testenv`; fresh: create each credential | | `/srv/cc-ci/.testenv` `cc-ci.testenv` | `GITEA_PASSWORD` (autonomic-bot), `DOCKERHUB_USERNAME/TOKEN`, model API keys used by opencode providers. No `TS_AUTH_KEY` (no tailscale here). | old host's copy minus the tailscale line; fresh: create each credential |
| `/srv/cc-ci/upgrader.env` | `LOOP_TIER`, `LOOP_MODEL`, `REPORT_MODEL` for the weekly run (no secrets) | old host, or copy the example in `AGENTS.md` | | `/srv/cc-ci/upgrader.env` (not a secret, lives in the checkout) | `LOOP_TIER`, `LOOP_MODEL`, `REPORT_MODEL` for the weekly run | old host, or copy the example in `AGENTS.md` |
| `/srv/cc-ci/.sops/master-age.txt` | the same master age key as 4b (skills that re-key secrets use it) | old host | | `/srv/cc-ci/.sops/master-age.txt` → `master-age.txt` | the same master age key as 4b (skills that re-key secrets use it); root's `/var/lib/sops-nix/key.txt` is a root-only copy of it | old host |
| `~loops/.ssh/cc-ci-root-ed25519` (+`.pub`) | `ssh cc-ci` as root — to loopback on this host | old host; fresh: `ssh-keygen -t ed25519` and add the pub to `nix/hosts/cc-ci/ssh-keys` | | `~loops/.ssh/cc-ci-root-ed25519` (+`.pub`) → same names | `ssh cc-ci` as root — to loopback on this host | old host; fresh: `ssh-keygen -t ed25519` and add the pub to `nix/hosts/cc-ci/ssh-keys` |
| `~loops/.ssh/autonomic-bot-gitea-ed25519` (+`.pub`) | pushes recipe branches / PRs as `autonomic-bot` | old host; fresh: new key added to the bot's Gitea account | | `~loops/.ssh/autonomic-bot-gitea-ed25519` (+`.pub`) → same names | pushes recipe branches / PRs as `autonomic-bot`; root's `/root/.ssh/` copy clones the private cc-ci-secrets submodule | old host; fresh: new key added to the bot's Gitea account |
| `~loops/.ssh/tangled-ed25519` | optional, tangled.org mirrors | old host | | `~loops/.local/share/opencode/auth.json` → `opencode-auth.json`; `~loops/.config/opencode/opencode.jsonc` (config, not secret) | opencode provider auth + config — the orchestrator AND the weekly upgrader are opencode agents; there is no Claude on this host | old host; fresh: `opencode auth login` as loops |
| `~loops/.claude/` | Claude Code auth + settings + the orchestrator session history | old host (`rsync -a`); fresh: `claude auth login` as loops (device code, interactive) | | `/etc/nginx/oc-htpasswd` (root:nginx copy; plaintext in `/secrets/files/oc-basic-auth.txt`) | basic auth for the opencode UI (`https://oc.ci.commoninternet.net`, via traefik); **nginx refuses to start without it**, and its config check runs as the `nginx` user, so `root:nginx 0640` (the `nginx` group exists after the first rebuild — fix ownership then and `systemctl restart nginx`) | old host (`/secrets/files/oc-basic-auth.txt` has the plaintext), or a new `oc:<bcrypt>` line via `mkpasswd -m bcrypt` |
| `~loops/.local/share/opencode/auth.json`, `~loops/.config/opencode/` | opencode provider auth (the weekly upgrader runs on opencode) | old host; fresh: `opencode auth login` |
| `/etc/nginx/oc-htpasswd` | basic auth for the opencode UI (`https://oc.ci.commoninternet.net`, via traefik); **nginx refuses to start without it**, and its config check runs as the `nginx` user, so `root:nginx 0640` (the `nginx` group exists after the first rebuild — fix ownership then and `systemctl restart nginx`) | old host (`/secrets/files/oc-basic-auth.txt` has the plaintext), or a new `oc:<bcrypt>` line via `mkpasswd -m bcrypt` |
`~loops/.ssh/config` is written by the activation script on first rebuild (`Host cc-ci` → `~loops/.ssh/config` is written by the activation script on first rebuild (`Host cc-ci` →
`127.0.0.1`, `git.autonomic.zone`, `tangled.org`); it is not overwritten if present. `127.0.0.1`, `git.autonomic.zone`); it is not overwritten if present.
## 5. Build and activate ## 5. Build and activate
@@ -340,29 +342,38 @@ stopped on the new host while its directory is copied:
--- ---
# Operating the orchestrator session # Operating the orchestrator
The steering session is a long-lived interactive Claude Code session under tmux with The cc-ci orchestrator on this host is an **opencode agent** (`cc-ci-plan/agents.toml`:
`--remote-control`, so it can be watched and steered from **claude.ai/code** (or the mobile app). backend `opencode`, model `opencode/glm-5.2`), launched on boot by `cc-ci-orchestrator.service`
`cc-ci-orchestrator.service` relaunches it on boot via `cc-ci-plan/agents.py up orchestrator` via `cc-ci-plan/agents.py up orchestrator` into tmux session `cc-ci-orchestrator`, attached to
(backend + model in `cc-ci-plan/agents.toml`). the shared opencode web server. Steer it from the web UI, **https://oc.ci.commoninternet.net**
(basic auth, user `oc`), where the weekly upgrader's and report's sessions show up too.
There is no Claude Code on this host: Claude sessions run on the notplants-orchestrator box and
operate the CI from there over ssh (`Host cc-ci` in that box's loops ssh config → this host).
```bash ```bash
# attach on the host # on the host
sudo -iu loops tmux attach -t cc-ci-orchestrator sudo -iu loops tmux attach -t cc-ci-orchestrator # the agent's terminal
# or resume the conversation by hand in a fresh tmux pane systemctl restart cc-ci-orchestrator # relaunch it (fresh opencode session)
claude --resume autonomous-orchestrator --remote-control "autonomous-orchestrator" --dangerously-skip-permissions sudo -iu loops tmux ls # cc-ci-orchestrator, cc-ci-upgrader (+watchdog) during the weekly run
# already inside a live session and just want the web surface? /remote-control
``` ```
`--resume <name|id>` selects the *conversation* to restore; the `--remote-control "<name>"` value is ## Weekly upgrade run
only the web display label. Don't pass `--fork-session` unless you mean to branch.
`cc-ci-upgrade-all.timer` (Thu 22:00 America/New_York) starts `launch-upgrader.py`, which runs
`/upgrade-all` as an opencode agent (`upgrader.env`: `LOOP_MODEL`/`REPORT_MODEL`) in tmux
`cc-ci-upgrader` with a usage-limit watchdog; the hourly `cc-ci-upgrade-supervisor.timer` drives a
stalled run to completion. Start one by hand with `systemctl start cc-ci-upgrade-all`; follow
`/srv/cc-ci/.cc-ci-logs/cc-ci-upgrader.log`; it ends with `UPGRADE RUN COMPLETE`, a summary in
`.cc-ci-logs/upgrades/upgrade-all-<date>.md`, and a report published to
`https://report.ci.commoninternet.net/week-<date>.html`.
## Kick off / supervise the loops ## Kick off / supervise the loops
```bash ```bash
cd /srv/cc-ci/cc-ci-plan cd /srv/cc-ci/cc-ci-plan
./launch.sh start # Builder + Adversary loops (interactive --remote-control in tmux) + watchdog ./launch.sh start # Builder + Adversary loops + watchdog (all phases are DONE; this just confirms)
./launch.sh status # session + DONE state ./launch.sh status # session + DONE state
./launch.sh logs builder|adversary|watchdog ./launch.sh logs builder|adversary|watchdog
./launch.sh stop ./launch.sh stop
+3 -2
View File
@@ -53,8 +53,9 @@ log_dir = "/srv/cc-ci/.cc-ci-logs"
[[agent]] [[agent]]
name = "orchestrator" # tmux session: cc-ci-orchestrator name = "orchestrator" # tmux session: cc-ci-orchestrator
kind = "persistent" kind = "persistent"
backend = "claude" # operator 2026-09-07: Claude Code under remote-control so they can talk to it backend = "opencode" # operator 2026-09-07 (evening): NO Claude on the cc-ci host — the
model = "claude-fable-5-1" # Fable 5.1 model = "opencode/glm-5.2" # orchestrator is an opencode agent, steered via the opencode web UI
# (https://oc.ci.commoninternet.net). Claude sessions live on notplants-orchestrator.
resume = false # opencode backend has no --resume equivalent (fresh session each launch) resume = false # opencode backend has no --resume equivalent (fresh session each launch)
watch = "heal" # restart if dead / FATAL / backend-mismatch; never stall-reboot watch = "heal" # restart if dead / FATAL / backend-mismatch; never stall-reboot
wake = { interval = 3600, prompt_file = "ai-progress-monitor-prompt.txt" } wake = { interval = 3600, prompt_file = "ai-progress-monitor-prompt.txt" }
+1 -1
View File
@@ -124,7 +124,7 @@ def start(mode, date):
_lu._pin_new_session(_prev_ids) _lu._pin_new_session(_prev_ids)
if BACKEND == "opencode": if BACKEND == "opencode":
if OPENCODE_SHARE: if OPENCODE_SHARE:
log(f" attached to {OPENCODE_SERVER} → http://oc.commoninternet.net +public --share link") log(f" attached to {OPENCODE_SERVER} → https://oc.ci.commoninternet.net +public --share link")
# Watchdog for the report too: it runs on glm-5.2 sharing the same opencode-go budget the # Watchdog for the report too: it runs on glm-5.2 sharing the same opencode-go budget the
# upgrade run just drained, so a 429 stall is likely. Reuse launch-upgrader.py's watchdog, # upgrade run just drained, so a 429 stall is likely. Reuse launch-upgrader.py's watchdog,
# pointed at THIS (cc-ci-report) session with a report-specific marker + resume prompt. It # pointed at THIS (cc-ci-report) session with a report-specific marker + resume prompt. It
+3 -3
View File
@@ -67,7 +67,7 @@ REMOTE_CONTROL = os.environ.get("REMOTE_CONTROL", "1") == "1"
OPENCODE_BIN = os.environ.get("OPENCODE_BIN", "/home/loops/.local/bin/opencode") OPENCODE_BIN = os.environ.get("OPENCODE_BIN", "/home/loops/.local/bin/opencode")
OPENCODE_SERVER = os.environ.get("OPENCODE_SERVER", "http://127.0.0.1:4096") OPENCODE_SERVER = os.environ.get("OPENCODE_SERVER", "http://127.0.0.1:4096")
# Web visibility for the opencode backend: attach the session to the shared opencode # Web visibility for the opencode backend: attach the session to the shared opencode
# web server (viewable at http://oc.commoninternet.net, tailnet-only) AND optionally # web server (viewable at https://oc.ci.commoninternet.net (basic auth)) AND optionally
# create a public opencode.ai --share link. Default both on so the run is monitorable. # create a public opencode.ai --share link. Default both on so the run is monitorable.
OPENCODE_SHARE = os.environ.get("OPENCODE_SHARE", "1") == "1" OPENCODE_SHARE = os.environ.get("OPENCODE_SHARE", "1") == "1"
@@ -220,7 +220,7 @@ def start(mode="use-or-create"):
f"{OPENCODE_BIN} run {model_flag} {share_flag} --attach '{OPENCODE_SERVER}' " f"{OPENCODE_BIN} run {model_flag} {share_flag} --attach '{OPENCODE_SERVER}' "
f"--title '{SESSION}' \"$(cat '{kf}')\"" f"--title '{SESSION}' \"$(cat '{kf}')\""
) )
log(f" attached to {OPENCODE_SERVER} → http://oc.commoninternet.net (tailnet only)" log(f" attached to {OPENCODE_SERVER} → https://oc.ci.commoninternet.net"
+ (" +public --share link (printed in the session)" if OPENCODE_SHARE else "")) + (" +public --share link (printed in the session)" if OPENCODE_SHARE else ""))
else: else:
die(f"unknown LOOP_BACKEND '{BACKEND}' — use 'claude' or 'opencode'") die(f"unknown LOOP_BACKEND '{BACKEND}' — use 'claude' or 'opencode'")
@@ -655,7 +655,7 @@ Model: {MODEL} (LOOP_MODEL or UPGRADER_MODEL env var)
Args: {UPGRADER_ARGS or '<none>'} (UPGRADER_ARGS env var, passed to /upgrade-all) Args: {UPGRADER_ARGS or '<none>'} (UPGRADER_ARGS env var, passed to /upgrade-all)
claude: viewable at claude.ai/code claude: viewable at claude.ai/code
opencode: viewable at http://oc.commoninternet.net server={OPENCODE_SERVER} opencode: viewable at https://oc.ci.commoninternet.net server={OPENCODE_SERVER}
""") """)
+12 -12
View File
@@ -17,7 +17,7 @@
systemd.services.cc-ci-loops = { systemd.services.cc-ci-loops = {
description = "cc-ci Builder/Adversary loops + watchdog (launch.sh start)"; description = "cc-ci Builder/Adversary loops + watchdog (launch.sh start)";
wantedBy = [ "multi-user.target" ]; # enabled after workspace staged (Hetzner cutover) wantedBy = [ "multi-user.target" ]; # enabled after workspace staged (Hetzner cutover)
after = [ "network-online.target" "claude-install.service" ]; after = [ "network-online.target" "opencode-install.service" ];
wants = [ "network-online.target" ]; wants = [ "network-online.target" ];
serviceConfig = { serviceConfig = {
# KillMode=process: this unit only LAUNCHES the tmux server, it does not own it. With the # KillMode=process: this unit only LAUNCHES the tmux server, it does not own it. With the
@@ -32,14 +32,14 @@
# Append one line to REBOOTS.md per genuine reboot (boot_id-gated; not on manual restart). # Append one line to REBOOTS.md per genuine reboot (boot_id-gated; not on manual restart).
ExecStartPre = "${pkgs.bash}/bin/bash /srv/cc-ci/cc-ci-plan/reboot-log.sh"; ExecStartPre = "${pkgs.bash}/bin/bash /srv/cc-ci/cc-ci-plan/reboot-log.sh";
}; };
# CLAUDE_BIN points at the standalone CLI installed by claude-install.service; the loops # The loops backend is persisted in .cc-ci-logs/.loop-backend (opencode on the cc-ci host —
# backend defaults to claude (persisted in .loop-backend). Without this, launch.py's preflight # no Claude there since 2026-09-07). The build phases are all DONE; on boot launch.sh just
# `which(claude)` fails because the systemd `path` below has no /home/loops/.local/bin. # re-confirms that and exits.
environment = { RESUME_PHASE = "1"; HOME = "/home/loops"; CLAUDE_BIN = "/home/loops/.local/bin/claude"; }; environment = { RESUME_PHASE = "1"; HOME = "/home/loops"; };
path = [ pkgs.bash pkgs.tmux pkgs.git pkgs.python3 pkgs.openssh pkgs.nettools ]; path = [ pkgs.bash pkgs.tmux pkgs.git pkgs.python3 pkgs.openssh pkgs.nettools ];
script = '' script = ''
# Put the standalone claude/opencode binaries on PATH. On a cold boot this is the env the # Put the standalone opencode binary on PATH. On a cold boot this is the env the tmux
# tmux server (and thus every agent session) inherits, so bare `claude` resolves everywhere. # server (and thus every agent session) inherits.
export PATH="/home/loops/.local/bin:$PATH" export PATH="/home/loops/.local/bin:$PATH"
[ -x /srv/cc-ci/cc-ci-plan/launch.sh ] && /srv/cc-ci/cc-ci-plan/launch.sh start || \ [ -x /srv/cc-ci/cc-ci-plan/launch.sh ] && /srv/cc-ci/cc-ci-plan/launch.sh start || \
echo "workspace not staged yet skipping loop start" echo "workspace not staged yet skipping loop start"
@@ -50,9 +50,9 @@
# lichen-orchestrator / project-orchestrator above: this unit only LAUNCHES the orchestrator's # lichen-orchestrator / project-orchestrator above: this unit only LAUNCHES the orchestrator's
# tmux session via the agent-orchestrator harness (cc-ci-plan/agents.py); it does not own the # tmux session via the agent-orchestrator harness (cc-ci-plan/agents.py); it does not own the
# session or the tmux server. The orchestrator agent is declared in cc-ci-plan/agents.toml # session or the tmux server. The orchestrator agent is declared in cc-ci-plan/agents.toml
# (backend/model chosen there — Claude Code under Remote Control since 2026-09-07; before that # (backend/model chosen there: an opencode agent, opencode/glm-5.2, attached to the shared
# opencode/glm-5.2 attached to the shared opencode web server, opencode-web.service in # opencode web server opencode-web.service in orchestrator-host.nix — and steered from its
# orchestrator-host.nix, which the upgrader still uses). The harness watchdog (started by # web UI; no Claude on this host, operator 2026-09-07). The harness watchdog (started by
# `agents.py up`) keeps it alive: heal-only (no stall reboots — a persistent supervisor must not # `agents.py up`) keeps it alive: heal-only (no stall reboots — a persistent supervisor must not
# be killed just for idling). Added 2026-08-03 for reboot-resilience. # be killed just for idling). Added 2026-08-03 for reboot-resilience.
systemd.services.cc-ci-orchestrator = { systemd.services.cc-ci-orchestrator = {
@@ -85,7 +85,7 @@
# wantedBy multi-user.target) so it never runs on boot/activation — only on the schedule. # wantedBy multi-user.target) so it never runs on boot/activation — only on the schedule.
systemd.services.cc-ci-upgrade-all = { systemd.services.cc-ci-upgrade-all = {
description = "cc-ci weekly /upgrade-all run (recipe upgrade survey + PRs, never merges)"; description = "cc-ci weekly /upgrade-all run (recipe upgrade survey + PRs, never merges)";
after = [ "network-online.target" "claude-install.service" ]; after = [ "network-online.target" "opencode-install.service" ];
wants = [ "network-online.target" ]; wants = [ "network-online.target" ];
serviceConfig = { serviceConfig = {
Type = "oneshot"; # launch-upgrader.py spawns the cc-ci-upgrader tmux session and returns Type = "oneshot"; # launch-upgrader.py spawns the cc-ci-upgrader tmux session and returns
@@ -103,7 +103,7 @@
# timer fire. Holds no secrets (the tinfoil API key lives in the opencode config / auth.json). # timer fire. Holds no secrets (the tinfoil API key lives in the opencode config / auth.json).
EnvironmentFile = "-/srv/cc-ci/upgrader.env"; EnvironmentFile = "-/srv/cc-ci/upgrader.env";
}; };
environment = { HOME = "/home/loops"; CLAUDE_BIN = "/home/loops/.local/bin/claude"; }; environment = { HOME = "/home/loops"; };
path = [ pkgs.bash pkgs.tmux pkgs.git pkgs.python3 pkgs.openssh pkgs.nettools ]; path = [ pkgs.bash pkgs.tmux pkgs.git pkgs.python3 pkgs.openssh pkgs.nettools ];
script = '' script = ''
export PATH="/home/loops/.local/bin:$PATH" export PATH="/home/loops/.local/bin:$PATH"
+7 -24
View File
@@ -1,6 +1,6 @@
# orchestrator-host.nix — the host contract that nix/modules/cc-ci.nix (the orchestrator's # orchestrator-host.nix — the host contract that nix/modules/cc-ci.nix (the orchestrator's
# loops/timers) silently assumes, made explicit and reusable: the `loops` user the agents run as, # loops/timers) silently assumes, made explicit and reusable: the `loops` user the agents run as,
# the standalone claude/opencode CLIs, the shared opencode web server and its tailnet-only UI, # the standalone opencode CLI, the shared opencode web server and its basic-auth web UI,
# nix-ld so foreign binaries run on NixOS, and the tool set agents reach for. # nix-ld so foreign binaries run on NixOS, and the tool set agents reach for.
# #
# Exported from flake.nix as `nixosModules.orchestrator-host`. A host imports this together with # Exported from flake.nix as `nixosModules.orchestrator-host`. A host imports this together with
@@ -79,12 +79,12 @@ in
commands = [{ command = "ALL"; options = [ "NOPASSWD" ]; }]; commands = [{ command = "ALL"; options = [ "NOPASSWD" ]; }];
}]; }];
# /home/loops/.local/bin holds the standalone claude + opencode binaries; it must be first on # /home/loops/.local/bin holds the standalone opencode binary; it must be first on every PATH
# every PATH (interactive shells, tmux, the systemd units in cc-ci.nix prepend it too). # (interactive shells, tmux, the systemd units in cc-ci.nix prepend it too).
environment.variables.PATH = lib.mkForce environment.variables.PATH = lib.mkForce
"/home/loops/.local/bin:/run/current-system/sw/bin:/run/wrappers/bin:/usr/bin:/bin"; "/home/loops/.local/bin:/run/current-system/sw/bin:/run/wrappers/bin:/usr/bin:/bin";
# ---- nix-ld: the standalone Claude Code / opencode CLIs are foreign dynamic ELF binaries --- # ---- nix-ld: the standalone opencode CLI is a foreign dynamic ELF binary --------------------
programs.nix-ld.enable = true; programs.nix-ld.enable = true;
programs.nix-ld.libraries = with pkgs; [ stdenv.cc.cc.lib zlib openssl curl glibc ]; programs.nix-ld.libraries = with pkgs; [ stdenv.cc.cc.lib zlib openssl curl glibc ];
@@ -123,10 +123,6 @@ Host git.autonomic.zone
User git User git
IdentityFile /home/loops/.ssh/autonomic-bot-gitea-ed25519 IdentityFile /home/loops/.ssh/autonomic-bot-gitea-ed25519
IdentitiesOnly yes IdentitiesOnly yes
Host tangled.org
IdentityFile /home/loops/.ssh/tangled-ed25519
IdentitiesOnly yes
SSHCFG SSHCFG
chmod 600 /home/loops/.ssh/config chmod 600 /home/loops/.ssh/config
chown loops:users /home/loops/.ssh/config chown loops:users /home/loops/.ssh/config
@@ -134,22 +130,9 @@ SSHCFG
''; '';
# ---- standalone CLIs (idempotent installers; re-run on every activation, no-op if present) -- # ---- standalone CLIs (idempotent installers; re-run on every activation, no-op if present) --
systemd.services.claude-install = { # No Claude Code on this host (operator 2026-09-07): the orchestrator and the weekly upgrader
description = "Install Claude Code CLI for loops user (idempotent)"; # are opencode agents; Claude sessions run on the notplants-orchestrator box and reach this
wantedBy = [ "multi-user.target" ]; # host over ssh.
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
serviceConfig = { Type = "oneshot"; RemainAfterExit = true; User = "loops"; Group = "users"; };
environment = { HOME = "/home/loops"; };
path = [ pkgs.curl pkgs.bash pkgs.coreutils pkgs.gnutar pkgs.gzip ];
script = ''
if [ ! -x "$HOME/.local/bin/claude" ]; then
echo "installing Claude Code CLI for loops user..."
curl -fsSL https://claude.ai/install.sh | bash || echo "install failed retry on next activation"
fi
'';
};
systemd.services.opencode-install = { systemd.services.opencode-install = {
description = "Install opencode CLI for loops user (idempotent)"; description = "Install opencode CLI for loops user (idempotent)";
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];