# 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, # the standalone claude/opencode CLIs, the shared opencode web server and its tailnet-only UI, # 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 # `nixosModules.cc-ci-orchestrator`; the combined CI-server + orchestrator host (`#cc-ci`) also # imports recipe-maintainers/cc-ci's `nixosModules.cc-ci-server`. # # History: until 2026-09 this lived (twice, drifting) in nix/hosts/cc-ci-orchestrator-hetzner/ # configuration.nix here and in notplants-nix's hosts/notplants-orchestrator/configuration.nix, # the shared agent box that also ran lichen + project-orchestrator. The cc-ci half moved to its # own host; this file is that half. { config, lib, pkgs, ... }: let cfg = config.cc-ci-orchestrator; in { options.cc-ci-orchestrator = { ciSshHost = lib.mkOption { type = lib.types.str; default = "127.0.0.1"; example = "100.95.31.88"; description = '' Where `ssh cc-ci` (used by every skill and script that drives the CI server) connects to, as root with ~loops/.ssh/cc-ci-root-ed25519. On the combined host the CI server IS this machine, so the default is loopback; a standalone orchestrator points it at the CI server's tailnet address. ''; }; opencodeUiPort = lib.mkOption { type = lib.types.port; default = 8443; description = '' TLS port of the nginx front door for the opencode web UI. Not 443: on the combined host Traefik (docker swarm) owns 80/443. The port is not opened in the firewall, so it is reachable only over the trusted tailscale interface. ''; }; opencodeUiHost = lib.mkOption { type = lib.types.str; default = "oc.commoninternet.net"; description = "nginx server_name for the opencode web UI (self-signed, basic auth)."; }; }; config = { # ---- the loops user ------------------------------------------------------------------- # claude sessions run as non-root (--dangerously-skip-permissions is refused for root). users.users.loops = { isNormalUser = true; uid = 1000; # fixed: workspace files are rsynced between hosts by uid home = "/home/loops"; shell = pkgs.bash; extraGroups = [ "wheel" "docker" ]; }; security.sudo.wheelNeedsPassword = false; security.sudo.extraRules = [{ users = [ "loops" ]; commands = [{ command = "ALL"; options = [ "NOPASSWD" ]; }]; }]; # /home/loops/.local/bin holds the standalone claude + opencode binaries; it must be first on # every PATH (interactive shells, tmux, the systemd units in cc-ci.nix prepend it too). environment.variables.PATH = lib.mkForce "/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 --- programs.nix-ld.enable = true; programs.nix-ld.libraries = with pkgs; [ stdenv.cc.cc.lib zlib openssl curl glibc ]; # ---- the toolbox every agent on this box gets ---------------------------------------- # Bar for adding something: an agent doing ordinary work would otherwise waste a turn # discovering it is absent. environment.systemPackages = with pkgs; [ git tmux python3 jq curl cacert gnused gawk coreutils gnugrep findutils util-linux nettools openssh age sops ssh-to-age wget gnutar gzip unzip zip xz ripgrep fd tree file less which procps psmisc htop lsof strace ncdu dnsutils socat netcat-gnu iproute2 iputils openssl gnumake gcc pkg-config yq-go diffutils patch rsync bubblewrap ]; # ---- ssh config for the loops user: `ssh cc-ci` = the CI server (root) ----------------- # Written only if absent so a manual customisation survives rebuilds. system.activationScripts.loopsSshConfig = '' mkdir -p /home/loops/.ssh && chown loops:users /home/loops/.ssh && chmod 700 /home/loops/.ssh if [ ! -f /home/loops/.ssh/config ]; then cat > /home/loops/.ssh/config <<'SSHCFG' Host cc-ci HostName ${cfg.ciSshHost} User root IdentityFile /home/loops/.ssh/cc-ci-root-ed25519 IdentitiesOnly yes StrictHostKeyChecking accept-new ServerAliveInterval 30 Host git.autonomic.zone HostName git.autonomic.zone Port 2222 User git IdentityFile /home/loops/.ssh/autonomic-bot-gitea-ed25519 IdentitiesOnly yes Host tangled.org IdentityFile /home/loops/.ssh/tangled-ed25519 IdentitiesOnly yes SSHCFG chmod 600 /home/loops/.ssh/config chown loops:users /home/loops/.ssh/config fi ''; # ---- standalone CLIs (idempotent installers; re-run on every activation, no-op if present) -- systemd.services.claude-install = { description = "Install Claude Code CLI for loops user (idempotent)"; wantedBy = [ "multi-user.target" ]; 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 = { description = "Install opencode CLI for loops user (idempotent)"; wantedBy = [ "multi-user.target" ]; 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 pkgs.unzip ]; script = '' if [ ! -x "$HOME/.local/bin/opencode" ]; then echo "installing opencode CLI for loops user..." curl -fsSL https://opencode.ai/install | bash || echo "install failed — retry on next activation" fi ''; }; # ---- opencode web server: one shared instance the opencode-backed agents attach to ------- # Provider creds come from /srv/cc-ci/.testenv (out of band, see README). systemd.services.opencode-web = { description = "opencode web server for cc-ci agents"; wantedBy = [ "multi-user.target" ]; after = [ "network-online.target" "tailscaled.service" "opencode-install.service" ]; wants = [ "network-online.target" ]; serviceConfig = { Type = "simple"; User = "loops"; Group = "users"; WorkingDirectory = "/srv/cc-ci-orch/cc-ci"; EnvironmentFile = [ "-/srv/cc-ci/cc-ci/.env.public" "/srv/cc-ci/.testenv" ]; ExecStartPre = "${pkgs.coreutils}/bin/rm -rf /tmp/opencode"; ExecStart = "/home/loops/.local/bin/opencode serve --hostname 127.0.0.1 --port 4096"; Restart = "on-failure"; RestartSec = "5s"; }; environment = { HOME = "/home/loops"; PATH = lib.mkForce "/run/wrappers/bin:/home/loops/.local/bin:/run/current-system/sw/bin:/usr/bin:/bin:/etc/profiles/per-user/loops/bin:/nix/var/nix/profiles/default/bin"; }; path = [ pkgs.bash pkgs.coreutils pkgs.git pkgs.python3 pkgs.openssh pkgs.tmux pkgs.nettools ]; }; # ---- tailnet-only nginx front door for the opencode UI ------------------------------- # Self-signed cert + basic auth, both created out of band (a store path would be world # readable) — see README "Secrets to stage". nginx FAILS TO START if they are missing. # /etc/nginx/oc-selfsigned.crt root:nginx 0644 # /etc/nginx/oc-selfsigned.key root:nginx 0640 # /etc/nginx/oc-htpasswd root:nginx 0640 (`oc:`; plaintext in /secrets) services.nginx = { enable = true; recommendedProxySettings = true; virtualHosts.${cfg.opencodeUiHost} = { listen = [ { addr = "0.0.0.0"; port = cfg.opencodeUiPort; ssl = true; } ]; # onlySSL flags the vhost as SSL so the module renders ssl_certificate for the listener. onlySSL = true; sslCertificate = "/etc/nginx/oc-selfsigned.crt"; sslCertificateKey = "/etc/nginx/oc-selfsigned.key"; basicAuthFile = "/etc/nginx/oc-htpasswd"; locations."/" = { proxyPass = "http://127.0.0.1:4096"; proxyWebsockets = true; }; }; }; }; }