From b1aef3ff1ffa54df66982f8c5ea525e5a87042ed Mon Sep 17 00:00:00 2001 From: defangdevs Date: Wed, 22 Jul 2026 05:05:57 -0700 Subject: [PATCH 1/2] feat(aws): add Lightsail CloudFormation template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `aws/lightsail-template.yaml`, a separate (not a toggle) template that runs the same services.agent-box on AWS Lightsail for one flat monthly bundle price — folding compute, SSD, static IPv4, and a multi-TB transfer allowance into a single line item (no separate EBS/IPv4 charges). Lightsail has no NixOS blueprint and CloudFormation can't boot a custom image, so the box launches the Ubuntu 24.04 blueprint and converts itself to NixOS in-place with nixos-infect (pinned by commit). PROVIDER=lightsail is first-class in nixos-infect and imports the same virtualisation/amazon-image.nix the EC2 template uses, so the platform layer is the proven one; the baked configuration.nix is the EC2 config minus Spot/NAT64 plus the Lightsail bits (amazon-image import + GRUB on nvme). selfUpdate, the web-password-hash activation script, the first-boot WaitCondition signal, and the amazon-init disable all carry over. Design notes: - No VPC/subnet/IGW/SG/EIP/IAM — Lightsail manages networking; firewall is the Networking.Ports block (443 always, 22 when DebugSsh=true). - A static IP is always attached (free while attached, stable across stop/start). It attaches after instance creation, so to avoid a dependency cycle the box discovers its settled public IPv4 at runtime for the sslip.io hostname; Outputs report the same address via GetAtt. - Debug access is root SSH with the Lightsail default key (no SSM); infect logs at /var/log/agent-box-infect.log. Validation: cfn-lint passes and the generated configuration.nix parses as valid Nix; aws-ci.yml now lints both templates. The end-to-end nixos-infect bootstrap still needs a live deploy-test (documented as a follow-up, along with S3 publish + a Launch button), so this ships as a draft. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Sw7j9FYnbChZJbYArxbhuo --- .github/workflows/aws-ci.yml | 5 +- aws/README.md | 113 ++++++- aws/lightsail-template.yaml | 569 +++++++++++++++++++++++++++++++++++ 3 files changed, 684 insertions(+), 3 deletions(-) create mode 100644 aws/lightsail-template.yaml diff --git a/.github/workflows/aws-ci.yml b/.github/workflows/aws-ci.yml index da0d1e9..3b3eb66 100644 --- a/.github/workflows/aws-ci.yml +++ b/.github/workflows/aws-ci.yml @@ -5,6 +5,7 @@ on: branches: [ master ] paths: &aws-ci-paths - 'aws/template.yaml' + - 'aws/lightsail-template.yaml' - 'docs/index.html' - 'scripts/ws_smoke.py' - '.github/workflows/aws-ci.yml' @@ -30,8 +31,8 @@ jobs: - name: Install cfn-lint run: python3 -m pip install --quiet cfn-lint - - name: Validate CloudFormation template - run: cfn-lint aws/template.yaml + - name: Validate CloudFormation templates + run: cfn-lint aws/template.yaml aws/lightsail-template.yaml - name: Compile WebSocket smoke helper run: python3 -m py_compile scripts/ws_smoke.py diff --git a/aws/README.md b/aws/README.md index 04ddff6..aaa9de8 100644 --- a/aws/README.md +++ b/aws/README.md @@ -4,7 +4,10 @@ CloudFormation template that provisions a single-agent agent-box host on EC2 with a browser terminal (Caddy + ttyd). The deployment form lets the user choose Claude Code or Codex. -- `template.yaml` - the template. Source of truth; anything else is derived. +- `template.yaml` - the EC2 template. Source of truth; anything else is derived. +- `lightsail-template.yaml` - an alternative that runs the same agent-box on + **AWS Lightsail** for one flat monthly bundle price. See + ["Lightsail variant"](#lightsail-variant-lightsail-templateyaml) below. ## What the template does @@ -277,6 +280,114 @@ rejected by EC2's API but cfn-lint only checks the group description. The template avoids em-dashes anywhere that becomes a rule description to sidestep this. +## Lightsail variant (`lightsail-template.yaml`) + +A separate template (not a toggle on the EC2 one) that runs the same +`services.agent-box` on **AWS Lightsail** instead of EC2. The draw is billing +shape: Lightsail is one flat monthly bundle that folds compute, the SSD, the +attached static IPv4, and a multi-TB transfer allowance into a single price, +with **no separate EBS or public-IPv4 line items**. At the small tier the two +come out within a few percent of each other: + +| | EC2 `t4g.small` (this repo's default region set) | Lightsail `small_3_0` | +| --- | --- | --- | +| vCPU / RAM | 2 / 2 GiB | 2 / 2 GiB | +| Disk | 30 GiB gp3 (billed separately) | 60 GiB SSD (in bundle) | +| Public IPv4 | ~$3.60/mo (billed separately) | included | +| Transfer | 100 GB free, then $0.09/GB | multi-TB included | +| Price | ~$13.4/mo on-demand-equivalent (~$12.6 measured on Spot) | **$12.0/mo flat** | + +So Lightsail slightly undercuts the EC2 on-demand-equivalent and roughly ties +Spot, while bundling 2x the disk and a large transfer allowance and removing +Spot's interruption risk. EC2 keeps the edge on flexibility (arbitrary instance +types, deep Spot discounts, IaC-native networking). + +### How it works (nixos-infect) + +Lightsail has **no NixOS blueprint** and CloudFormation's `AWS::Lightsail::Instance` +cannot boot a custom image — it only takes a public `BlueprintId`. So the box +launches the stock **Ubuntu 24.04** blueprint and converts itself to NixOS +in-place on first boot with [`nixos-infect`](https://github.com/elitak/nixos-infect) +(pinned by commit in the `NixosInfectRev` parameter): + +- The `UserData` script pre-writes `/etc/nixos/configuration.nix` **before** + invoking `nixos-infect`. The script's `makeConf()` early-returns when that + file already exists, so it never clobbers our config. +- `PROVIDER=lightsail` is first-class in `nixos-infect`: it relabels the root + filesystem to `nixos` and its generated config imports + `virtualisation/amazon-image.nix` — **the same module the EC2 template uses**. + Our pre-written config imports that module too, so the platform layer is the + proven one; only the delivery (infect over Ubuntu) is new. +- That baked config is essentially the EC2 template's config minus Spot and + NAT64, plus the Lightsail platform bits (`amazon-image.nix` import, + `boot.loader.grub.device = "/dev/nvme0n1"`). `services.agent-box`, + `selfUpdate`, the web-password-hash activation script, the first-boot + WaitCondition signal, disk-GC watchdog, and the `amazon-init` disable all + carry over unchanged. + +### Differences from the EC2 template + +- **No VPC/subnet/IGW/SG/EIP/IAM** resources — Lightsail manages networking; + the per-instance firewall is the `Networking.Ports` block (443 always, 22 + when `DebugSsh=true`). +- **IPv4-native**, so no `PublicIpv4`/`Nat64` parameters and no NAT64 plumbing. +- **No Spot** (`UseSpot` is gone; `spotInterruption` is disabled in the config). +- **No `RootVolumeSize`** — the SSD is fixed by the bundle; grow by + snapshot-and-restore onto a larger bundle. +- **A static IP is always attached** (free on Lightsail while attached, and + stable across a stop/start), so the `sslip.io` URL keeps working. Because the + attach happens after instance creation, `UserData` can't reference the static + IP without a dependency cycle, so the box discovers its own settled public + IPv4 at runtime (a `sleep` past the attach window + a stability poll) and + bakes `.sslip.io`. The stack `Outputs` report the same address via + `GetAtt StaticIp.IpAddress`. +- **Debug access is root SSH with the Lightsail default key** (`DebugSsh=true` + opens 22), not SSM — Lightsail has no Session Manager, and after infection the + console's browser-SSH (which logs in as `ubuntu`) stops working. Infect logs + land in `/var/log/agent-box-infect.log`. + +### Deploying (CLI) + +The 1-click S3 publish path is not wired up for this template yet (see +below), so deploy it directly. `AgentBoxRev`/`AgentBoxSha256` are a pinned +pair, exactly as for the EC2 template: + +```bash +REV=$(git rev-parse HEAD) # or any pushed agent-box commit +SHA=$(nix store prefetch-file --json \ + "https://raw.githubusercontent.com/defangdevs/agent-box/${REV}/modules/agent-box.nix" \ + | jq -r .hash) + +aws cloudformation deploy \ + --region eu-central-1 \ + --stack-name agent-box-lightsail \ + --template-file aws/lightsail-template.yaml \ + --parameter-overrides \ + WebPassword='<16-64 chars>' \ + AgentBoxRev="$REV" \ + AgentBoxSha256="$SHA" +``` + +The stack blocks on the first-boot `WaitCondition` (timeout 45 min — a small +bundle's initial closure build is slow) and then emits `WebURL`, +`PublicAddress`, `RemoteControlSession`, and an `SshCommand` hint. + +### Validation status + +`cfn-lint` passes and the generated `configuration.nix` parses as valid Nix +(both checked in PR CI via `aws-ci.yml`). The end-to-end `nixos-infect` +bootstrap has **not** yet been exercised by an automated live deploy — treat a +first real launch as the acceptance test. Follow-ups before this is +first-class: + +- A Lightsail leg in `deploy-test.yml` (create stack, assert `WebURL` reachable + over IPv4, tear down). GitHub runners are IPv4-only and Lightsail is + IPv4-native, so unlike the EC2 IPv6-only leg this can smoke-test the live URL. +- S3 publish + a Launch button. `publish-template.yml` currently publishes only + `template.yaml` and scopes the bucket policy to that one object; publishing a + second template means covering both objects in one policy (the two must not + fight over `PutBucketPolicy`). Deferred to keep this PR's diff focused. + ## Refreshing the AMI map NixOS publishes AMI ids at (no diff --git a/aws/lightsail-template.yaml b/aws/lightsail-template.yaml new file mode 100644 index 0000000..bd215ef --- /dev/null +++ b/aws/lightsail-template.yaml @@ -0,0 +1,569 @@ +AWSTemplateFormatVersion: '2010-09-09' + +Metadata: + AWS::CloudFormation::Interface: + ParameterGroups: + - Label: + default: Terminal access + Parameters: + - Agent + - UserName + - WebPassword + - Label: + default: Compute and cost + Parameters: + - BundleId + - Label: + default: Agent instructions + Parameters: + - AgentsMd + - Label: + default: Advanced (rarely needed) + Parameters: + - AllowCidr + - DebugSsh + - NixChannel + - NixosInfectRev + - AgentBoxRev + - AgentBoxSha256 + - AgentNixpkgsUrl + - AgentNixpkgsSha256 + ParameterLabels: + WebPassword: + default: Web terminal password (16-64 chars — save it, AWS masks it) + Agent: + default: Agent CLI + UserName: + default: Linux user the agent runs as + BundleId: + default: Lightsail bundle (size / price) + AllowCidr: + default: IPv4 CIDR allowed to reach 443 (and 22 when DebugSsh=true) + DebugSsh: + default: Open port 22 for root SSH with the Lightsail default key + AgentsMd: + default: AGENTS.md seeded on first agent start + NixChannel: + default: NixOS channel nixos-infect installs + NixosInfectRev: + default: nixos-infect commit pin + AgentBoxRev: + default: agent-box git rev + AgentBoxSha256: + default: agent-box module sha256 +Description: > + agent-box (Lightsail) - 1-click coding-agent host on AWS Lightsail, priced as + one flat monthly bundle (compute + SSD + a static IP + a multi-TB transfer + allowance). Lightsail has no NixOS blueprint, so the box launches from the + Ubuntu 24.04 blueprint and converts itself to NixOS in-place with nixos-infect + (PROVIDER=lightsail imports the same virtualisation/amazon-image.nix the EC2 + template uses), then runs the agent inside a persistent tmux session reachable + through a browser terminal (Caddy + ttyd). Claude Code sessions can also be + driven from the Claude desktop and mobile apps via Remote Control. + Source: https://github.com/defangdevs/agent-box + +Parameters: + Agent: + Type: String + Default: claude + AllowedValues: + - claude + - codex + Description: >- + Agent CLI to run in the persistent tmux session. Supported values: + claude (Claude Code) and codex (OpenAI Codex CLI). Claude-specific + Remote Control settings are ignored when codex is selected. + + UserName: + Type: String + Default: agent + AllowedPattern: ^[a-z_][a-z0-9_-]{0,30}$ + Description: >- + Linux user the agent CLI runs as. Doubles as the browser-terminal + sign-in username (the basic-auth username picks the terminal), the + // path in the WebURL, the home directory (/home/), and + the @ Remote Control session name. Lowercase letters, + digits, "_" and "-", starting with a letter or "_", max 31 chars. + + # The picker annotates each value with vCPU/RAM/price because the + # CloudFormation console renders AllowedValues verbatim — there is no separate + # label field. The Lightsail API only ever sees the first token: the Instance + # resource splits on the first space. No commas in the values, so + # `ParameterKey=BundleId,ParameterValue=...` CLI shorthand still parses. + BundleId: + Type: String + Default: small_3_0 (2 vCPU / 2 GiB / 60 GiB SSD / $12 mo) + AllowedValues: + - micro_3_0 (2 vCPU / 1 GiB / 40 GiB SSD / $7 mo) + - small_3_0 (2 vCPU / 2 GiB / 60 GiB SSD / $12 mo) + - medium_3_0 (2 vCPU / 4 GiB / 80 GiB SSD / $24 mo) + - large_3_0 (2 vCPU / 8 GiB / 160 GiB SSD / $44 mo) + - xlarge_3_0 (4 vCPU / 16 GiB / 320 GiB SSD / $84 mo) + Description: >- + Lightsail bundle (the id before the parenthesis; specs shown for + convenience). The flat price bundles compute, the SSD, the attached + static IPv4, and a multi-TB/month transfer allowance — no separate EBS + or public-IPv4 line items like the EC2 template. All are dual-stack + (IPv4 + IPv6). small_3_0 (2 GiB) is the default and matches a t4g.small + EC2 box: fine for a single light agent, but 2 GiB is tight while + nixos-rebuild evaluates a self-update — the UserData adds a 3 GiB swap + file to carry both the initial infect build and later rebuilds. Pick + medium_3_0 (4 GiB) for comfortable rebuild headroom. Lightsail disks are + fixed by the bundle: to grow later you snapshot and restore onto a larger + bundle (there is no live volume-resize like EBS). + + WebPassword: + Type: String + NoEcho: true + MinLength: 16 + MaxLength: 64 + AllowedPattern: ^.{16,64}$ + Description: >- + Shared secret for the browser terminal. Log in with the UserName chosen + above (default "agent") and this password; Caddy then sets an HttpOnly + Secure cookie so the WebSocket upgrade can authenticate without putting + the secret in the URL. Use any 16-64 characters; password-manager symbols + are supported. AWS masks this field with dots after you type it and never + emits it in stack outputs, so save it somewhere before you launch — there + is no way to recover it later. Even Claude Code stacks that plan to drive + the box via Remote Control need this the first time, to complete `claude + login` in the browser terminal. + + AgentsMd: + Type: String + Default: | + ## This box (AWS / Lightsail) + + - This is an AWS Lightsail instance that converted itself to NixOS on + first boot. The SSD persists but RAM does not: a reboot or stop/start + loses the live tmux session, so save working context to disk under your + home. A static IP is attached, so the public address (and your URL) + survives a stop/start. + - Your URL is derived from the instance's static IP via sslip.io. It is + stable, but always read $AGENT_BOX_URL rather than hard-coding it. + - This bundle has limited RAM; heavy nixos-rebuild self-updates lean on a + swap file. If an update feels slow, that is why. + - Low on disk? Lightsail bundles have a fixed SSD — snapshot the instance + and restore it onto a larger bundle to grow. Scheduled nix garbage + collection reclaims store space in the meantime. + Description: >- + Deployment-specific instructions APPENDED to agent-box's built-in + default AGENTS.md, which the module always seeds to the agent user's + ~/AGENTS.md on first agent start (skipped if the file already exists). + The built-in default already covers the generic guidance — browser- + terminal URL ($AGENT_BOX_URL), the read-only-except-$HOME filesystem, + installing tools with nix, sibling sessions, serving a web app, and + self-update — so this default only adds the Lightsail specifics. Edit or + clear it to taste; whatever you leave here lands below the built-in + default. Content is embedded into the generated configuration.nix as a + Nix indented string, so avoid the sequences `${` and `''` in the body — + Nix will otherwise try to antiquote / close the string mid-file. Plain + $VAR (no braces) is fine. + + AllowCidr: + Type: String + Default: 0.0.0.0/0 + AllowedPattern: ^\d{1,3}(\.\d{1,3}){3}/\d{1,2}$ + Description: >- + IPv4 CIDR allowed to reach 443 (the web terminal) and, when DebugSsh is + true, 22. Default is open. IPv6 ingress to 443 is always ::/0 — tighten + the instance firewall in the Lightsail console after launch if needed. + + DebugSsh: + Type: String + Default: 'true' + AllowedValues: ['true', 'false'] + Description: >- + true (default): open port 22 so you can reach a root shell with the + Lightsail default SSH key (download it from Account -> SSH keys, then + `ssh -i root@`). Lightsail has no SSM Session Manager, + and after the NixOS conversion the browser-based SSH console (which logs + in as the Ubuntu "ubuntu" user) no longer works, so this key-only root + path is the debug hatch for a broken first boot — nixos-infect logs land + in /var/log/agent-box-infect.log. Password auth is disabled either way. + false: leave 22 closed. + + NixChannel: + Type: String + Default: nixos-25.05 + AllowedPattern: ^nixos-[0-9]{2}\.[0-9]{2}$ + Description: >- + NixOS release channel nixos-infect installs as the base system (matches + the repo's stateVersion). The agent CLIs (claude-code, codex) resolve + from their own pinned nixpkgs (AgentNixpkgs*) independently of this. + + NixosInfectRev: + Type: String + Default: 40f62a680bb0e8f2f607d79abfaaecd99d59401c + AllowedPattern: ^[0-9a-f]{40}$ + Description: >- + Commit of elitak/nixos-infect to run, pinned by full SHA (the script is + fetched from raw.githubusercontent.com at this ref). PROVIDER=lightsail is + first-class in this script: it relabels the root filesystem to `nixos` so + the imported amazon-image.nix can mount it. Bump deliberately after review + — an unpinned "master" would silently change the bootstrap. + + AgentBoxRev: + Type: String + Description: >- + Git commit sha of defangdevs/agent-box. The module (a single .nix file) + is fetched from raw.githubusercontent.com at this ref during the first + nixos-rebuild. No Default here on purpose — Rev + Sha256 are a pinned + pair and `builtins.fetchurl` hard-fails on any drift, so we can't ship a + moving default like "master". The 1-click Launch buttons hit the + S3-published copy of this template, into which the publish workflow + injects a Default for both fields at every master push. + + AgentBoxSha256: + Type: String + Description: >- + Nix sha256 of modules/agent-box.nix at AgentBoxRev (SRI form: + sha256-). Must match AgentBoxRev's content exactly. + Regenerate with `nix store prefetch-file + https://raw.githubusercontent.com/defangdevs/agent-box//modules/agent-box.nix` + (no unpack; we fetch the file, not a tarball). See AgentBoxRev for + why there is no Default here. + + AgentNixpkgsUrl: + Type: String + Default: '' + Description: >- + Immutable nixos-unstable channel-release tarball to use for agent CLI + packages (claude-code, codex). The S3-published 1-click template gets a + fresh Default injected by the publish workflow; empty (the source + template's default) falls back to the base channel. When set, + AgentNixpkgsUrl + AgentNixpkgsSha256 are a pinned pair, and + `builtins.fetchTarball` hard-fails on drift. + + AgentNixpkgsSha256: + Type: String + Default: '' + Description: >- + Unpacked Nix sha256 of AgentNixpkgsUrl (the output of + `nix-prefetch-url --unpack `). Must match + AgentNixpkgsUrl exactly; leave empty when AgentNixpkgsUrl is empty. + +Conditions: + OpenSsh: !Equals [!Ref DebugSsh, 'true'] + +Resources: + # The Lightsail instance. Unlike the EC2 template there is no VPC / subnet / + # IGW / route table / security group to build — Lightsail manages all of that + # and the per-instance firewall lives in the Networking block below. The box + # boots the stock Ubuntu 24.04 blueprint and the UserData converts it to NixOS + # in place (see the script). CloudFormation reports CREATE_COMPLETE as soon as + # the instance is running the blueprint; it does NOT wait for UserData, so the + # real first-boot gate is the FirstBootDone WaitCondition below. + Instance: + Type: AWS::Lightsail::Instance + Properties: + InstanceName: !Ref AWS::StackName + AvailabilityZone: !Select [0, !GetAZs ''] + BlueprintId: ubuntu_24_04 + # First token of the annotated picker value (see the BundleId parameter) + # — "small_3_0 (2 vCPU / ...)" -> "small_3_0". + BundleId: !Select [0, !Split [' ', !Ref BundleId]] + Networking: + Ports: + - FromPort: 443 + ToPort: 443 + Protocol: tcp + Cidrs: [!Ref AllowCidr] + Ipv6Cidrs: ['::/0'] + - !If + - OpenSsh + - FromPort: 22 + ToPort: 22 + Protocol: tcp + Cidrs: [!Ref AllowCidr] + - !Ref AWS::NoValue + Tags: + - Key: Name + Value: !Ref AWS::StackName + UserData: + Fn::Sub: + - | + #!/usr/bin/env bash + # agent-box Lightsail bootstrap: turn this Ubuntu box into a NixOS + # agent-box with nixos-infect. Runs once, as root, via Lightsail's + # launch-script mechanism. All output is teed to a log so a failed + # conversion is debuggable over SSH (DebugSsh=true). + set -euxo pipefail + exec > >(tee /var/log/agent-box-infect.log) 2>&1 + + # 0. curl is used below before nixos-infect installs its own deps. + # Ubuntu cloud images ship it, but don't assume. + command -v curl >/dev/null || { apt-get update -y && apt-get install -y curl; } + + # 1. Extra swap. The nix build of the system closure (and later + # nixos-rebuild self-updates) is the OOM risk on 1-2 GiB bundles. + # nixos-infect makes only a 1 GiB swap; pre-make 3 GiB and tell it + # to leave swap alone (NO_SWAP=1). + if ! swapon --show=NAME --noheadings | grep -qx /agent-box-swap; then + fallocate -l 3G /agent-box-swap || dd if=/dev/zero of=/agent-box-swap bs=1M count=3072 + chmod 600 /agent-box-swap + mkswap /agent-box-swap + swapon /agent-box-swap + fi + + # 2. Settle the public IPv4. CloudFormation attaches the static IP a + # minute or two after the instance starts; the sleep clears that + # window and the stability loop then confirms the address stopped + # changing before we bake it into the sslip.io hostname. The whole + # NixOS build/reboot that follows takes far longer than the attach, + # so the settled address is the STATIC one the Outputs also report. + sleep 150 + prev=""; stable=0; ip="" + for _ in $(seq 1 60); do + ip=$(curl -4 -fsS --max-time 5 https://checkip.amazonaws.com | tr -d '[:space:]' || true) + if [ -n "$ip" ] && [ "$ip" = "$prev" ]; then stable=$((stable + 1)); else stable=0; fi + prev="$ip" + [ "$stable" -ge 3 ] && break + sleep 10 + done + [ -n "$ip" ] || { echo "agent-box: no public IPv4 to derive hostname"; exit 1; } + dom="$(echo "$ip" | tr . -).sslip.io" + + # 3. Capture the Lightsail-injected SSH public key before infection. + # The NixOS "lustrate" wipes /home and /root on the first NixOS + # boot, and amazon-init (which would otherwise re-sync keys from + # IMDS) is disabled below, so the key must be baked into the Nix + # config now or root SSH is lost. + key="$(grep -hE '^(ssh|ecdsa|sk-)' /home/ubuntu/.ssh/authorized_keys /root/.ssh/authorized_keys 2>/dev/null | head -n1 || true)" + + # 4. Write /etc/nixos/configuration.nix BEFORE running nixos-infect. + # The script's makeConf() returns early when this file already + # exists, so it will not clobber our config; PROVIDER=lightsail + # still performs the mandatory root-fs relabel. The config mirrors + # the EC2 template's proven agent-box config, minus Spot/NAT64 and + # plus the Lightsail platform bits (amazon-image import + GRUB on + # nvme). @@DOMAIN@@ / @@SSHKEY@@ are spliced by sed in step 5; + # every Nix antiquote is written as ${!...} so CloudFormation's + # Fn::Sub leaves it intact. + mkdir -p /etc/nixos + cat > /etc/nixos/configuration.nix <<'AGENTBOX_NIXCFG' + { config, pkgs, lib, modulesPath, ... }: + let + # Module pin. First boot uses the launch-time parameters; after + # that, agent-box-update.service owns the pin file and advances it, + # so the same configuration.nix keeps working across self-updates. + pin = + if builtins.pathExists /etc/nixos/agent-box-pin.nix + then import /etc/nixos/agent-box-pin.nix + else { rev = "${AgentBoxRev}"; sha256 = "${AgentBoxSha256}"; }; + # Agent-CLI nixpkgs pin (claude-code, codex). Empty URL -> null -> + # fall back to the base channel rather than feeding fetchTarball a + # blank URL and bricking first boot. + agentPin = + if builtins.pathExists /etc/nixos/agent-box-agent-pin.nix + then import /etc/nixos/agent-box-agent-pin.nix + else if "${AgentNixpkgsUrl}" == "" then null + else { url = "${AgentNixpkgsUrl}"; sha256 = "${AgentNixpkgsSha256}"; }; + # raw.githubusercontent.com is dual-stack; the box has IPv4, so the + # single-file module fetches directly with no NAT64 hop. + agent-box-module = builtins.fetchurl { + url = "https://raw.githubusercontent.com/defangdevs/agent-box/${!pin.rev}/modules/agent-box.nix"; + sha256 = pin.sha256; + }; + hostname = "@@DOMAIN@@"; + sshKey = "@@SSHKEY@@"; + # Fn::Sub receives a base64 projection of WebPassword so every + # password-manager symbol stays inert Nix source; decoded only into + # a shell variable in the activation script below. + webPasswordBase64 = "${WebPasswordBase64}"; + in { + imports = [ + "${!modulesPath}/virtualisation/amazon-image.nix" + agent-box-module + ]; + + # Lightsail (Nitro) roots on nvme and boots BIOS/GRUB (no EFI). + # amazon-image.nix mounts / by the label `nixos`, which + # nixos-infect's PROVIDER=lightsail path applied during infection. + boot.loader.grub.device = lib.mkForce "/dev/nvme0n1"; + + services.agent-box = { + enable = true; + agent = "${Agent}"; + users."${UserName}" = { + environment = { TERM = "xterm-256color"; }; + remoteControlName = "${UserName}@${StackName}"; + web.passwordHashFile = "/var/lib/agent-box-web/password-hash"; + # AGENTS.md addendum from the CFN AgentsMd parameter, spliced + # into a Nix indented string; the module appends it below its + # built-in default. The placeholder sits at column 0 after YAML + # strips the block-scalar indent, so multi-line values line up. + agentsMd = '' + ${AgentsMd} + ''; + }; + web = { + enable = true; + domain = hostname; + user = "${UserName}"; + }; + # Lets the agent update the box on request: + # sudo systemctl start agent-box-update.service + selfUpdate = { + enable = true; + rev = pin.rev; + agentNixpkgs = agentPin; + }; + # No Spot on Lightsail — nothing for the interruption monitor to + # watch, so leave it off (the module also self-gates on IMDS + # instance-life-cycle, but keep the deployed config honest). + spotInterruption.enable = false; + }; + + # SSH is the only privileged path in (no SSM on Lightsail). Bake the + # Lightsail default key for root; password auth stays off. + services.openssh.enable = true; + services.openssh.settings.PasswordAuthentication = false; + users.users.root.openssh.authorizedKeys.keys = + lib.optional (sshKey != "") sshKey; + + # First-boot health signal: PUT to the WaitCondition handle once + # this config has actually switched, so a box that never applied + # user-data surfaces as CREATE_FAILED instead of a black-holed URL. + systemd.services.agent-box-first-boot-signal = { + wantedBy = [ "multi-user.target" ]; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + serviceConfig = { Type = "oneshot"; RemainAfterExit = true; }; + script = '' + marker=/var/lib/agent-box-web/first-boot-signaled + [ -e "$marker" ] && exit 0 + ${!pkgs.curl}/bin/curl -s -X PUT -H 'Content-Type:' \ + --retry 10 --retry-delay 15 --retry-all-errors \ + --data-binary '{"Status":"SUCCESS","Reason":"first nixos-rebuild switch complete","UniqueId":"first-boot","Data":"ok"}' \ + "${FirstBootHandle}" && touch "$marker" || true + ''; + }; + + # Materialize the web password hash from the plaintext CFN + # parameter before any service reads it. Subshell so the umask 077 + # does not leak into later activation snippets (they share one + # shell). Hash lives outside /nix/store so it stays private. + system.activationScripts.agent-web-password-hash.text = '' + install -d -m 0700 /var/lib/agent-box-web + if [ ! -s /var/lib/agent-box-web/password-hash ]; then + ( + umask 077 + webPassword="$( + ${!pkgs.coreutils}/bin/printf '%s' ${!lib.escapeShellArg webPasswordBase64} \ + | ${!pkgs.coreutils}/bin/base64 --decode + )" + ${!pkgs.coreutils}/bin/printf '%s\n' "$webPassword" \ + | ${!pkgs.caddy}/bin/caddy hash-password --algorithm argon2id \ + > /var/lib/agent-box-web/password-hash + unset webPassword + ) + chmod 0600 /var/lib/agent-box-web/password-hash + fi + ''; + + # Disk watchdog: the nix store grows on every rebuild / self-update + # and a full root wedges the whole box. GC on a timer plus mid-build + # when free space dips. + nix.gc = { + automatic = true; + options = "--delete-older-than 7d"; + }; + nix.settings = { + min-free = 1073741824; # start GC below 1 GiB free + max-free = 5368709120; # ... and free up to 5 GiB + }; + services.journald.extraConfig = "SystemMaxUse=200M"; + + # The Lightsail user-data is THIS shell script, not a NixOS config. + # amazon-init would try to re-run it on every boot (it starts with + # "#!"), re-triggering infection — disable it, same as the EC2 + # template disables re-applying user-data. + systemd.services.amazon-init.enable = lib.mkForce false; + + system.stateVersion = "${NixChannelVersion}"; + } + AGENTBOX_NIXCFG + + # 5. Splice the runtime-discovered values into the config. + sed -i "s|@@DOMAIN@@|$dom|g; s|@@SSHKEY@@|$key|g" /etc/nixos/configuration.nix + + # 6. Fetch the pinned nixos-infect and run it. makeConf() early-returns + # (our configuration.nix exists), PROVIDER=lightsail relabels the + # root fs to `nixos`, and the script reboots into NixOS at the end. + curl -fsSL "https://raw.githubusercontent.com/elitak/nixos-infect/${NixosInfectRev}/nixos-infect" -o /root/nixos-infect + NO_SWAP=1 PROVIDER=lightsail NIX_CHANNEL="${NixChannel}" bash /root/nixos-infect + - WebPasswordBase64: + Fn::Base64: !Ref WebPassword + StackName: !Ref AWS::StackName + # stateVersion wants "25.05", not "nixos-25.05" — strip the prefix. + NixChannelVersion: !Select [1, !Split ['-', !Ref NixChannel]] + FirstBootHandle: !Ref FirstBootHandle + + # Static IPv4 — free while attached on Lightsail, and stable across a + # stop/start (unlike the bundle's default public IP), so the sslip.io URL + # keeps working. AttachedTo takes the instance NAME (Ref returns it), which + # also orders creation after the instance. UserData does NOT reference this + # resource (it discovers the address at runtime) so there is no dependency + # cycle between the two. + StaticIp: + Type: AWS::Lightsail::StaticIp + Properties: + StaticIpName: !Sub '${AWS::StackName}-ip' + AttachedTo: !Ref Instance + + # First-boot health gate (mirrors the EC2 template's issue-106 gate). The box + # PUTs a success signal after its first nixos-rebuild switch. nixos-infect + # plus the initial closure build and reboot take a while on a small bundle, + # so the timeout is generous. No IPv4/NAT64 conditionals — Lightsail always + # has a public IPv4, so the presigned (IPv4-only) handle is always reachable. + FirstBootHandle: + Type: AWS::CloudFormation::WaitConditionHandle + + FirstBootDone: + Type: AWS::CloudFormation::WaitCondition + DependsOn: Instance + Properties: + Handle: !Ref FirstBootHandle + Count: 1 + Timeout: '2700' + +Outputs: + WebURL: + Description: >- + Browser terminal for the selected agent. Claude Code deployments are also + drivable from the Claude apps via Remote Control after the first login. + First load takes several minutes while the box converts to NixOS and Caddy + issues an ACME cert. Sign in with the UserName (default "agent") and the + WebPassword chosen at deployment time. (The URL deliberately carries no + user@ prefix: Chrome answers the auth challenge with URL userinfo plus an + EMPTY password, and credentials typed into the prompt cannot override the + URL-embedded identity.) + Value: !Sub + - 'https://${Host}.sslip.io/${UserName}/' + - Host: !Join ['-', !Split ['.', !GetAtt StaticIp.IpAddress]] + + RemoteControlSession: + Description: >- + Claude Remote Control session name (@). After + finishing `claude login` once in the browser terminal, the Claude + desktop / mobile apps can drive this session directly (sign in with the + same Claude account). Only meaningful when Agent=claude; Codex has no + Remote Control channel. + Value: !Sub '${UserName}@${AWS::StackName}' + + PublicAddress: + Description: Attached static IPv4 address. + Value: !GetAtt StaticIp.IpAddress + + SshCommand: + Description: >- + Root SSH for debugging (only when DebugSsh=true). Download the Lightsail + default key from the console (Account -> SSH keys) first. + Value: !Sub + - 'ssh -i root@${Ip}' + - Ip: !GetAtt StaticIp.IpAddress + + InstanceName: + Description: Lightsail instance name. + Value: !Ref Instance From f77f98221ad08958cbce404c30fa8e726b04100b Mon Sep 17 00:00:00 2001 From: defangdevs Date: Wed, 22 Jul 2026 06:35:03 -0700 Subject: [PATCH 2/2] feat(build): generate agent-box.nix from split sources (#140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit modules/agent-box.nix is now GENERATED from modules/agent-box.nix.in + modules/src/* by bin/assemble-module.py, which re-embeds assets via @@include:...@@ markers. The deployed fetch is unchanged — CFN user-data and agent-box-update.service still builtins.fetchurl the one self-contained modules/agent-box.nix at a pinned sha256 (issue #51). We only decouple the *source* layout from that single *fetched artifact*, so linters/editors see real files instead of Nix strings. First slice: extract the ~1930-line settings daemon to modules/src/settings-daemon.py (now a lintable, py_compile-able file). The generated module is byte-identical to the previous hand-written one except a 4-line GENERATED banner — verified by diff — so no derivation changes. - bin/assemble-module.py: dependency-free generator; --check mode diffs the committed file against a fresh assembly. - flake: `nix run .#assemble` (regenerate in place) + check module-generated-up-to-date (fails on drift); CI runs the check. - module-single-file check still guards the OUTPUT is sibling-free. - .gitattributes marks the generated file; AGENTS.md documents the workflow. Validated: module-single-file, module-generated-up-to-date, multi-user, and the settings-page VM test (daemon serves + password-change flow) all green. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Sw7j9FYnbChZJbYArxbhuo --- .gitattributes | 5 + .github/workflows/ci.yml | 6 + .gitignore | 1 + AGENTS.md | 6 +- bin/assemble-module.py | 135 ++ flake.nix | 37 + modules/agent-box.nix | 4 + modules/agent-box.nix.in | 2344 ++++++++++++++++++++++++++++++++ modules/src/settings-daemon.py | 1930 ++++++++++++++++++++++++++ 9 files changed, 4466 insertions(+), 2 deletions(-) create mode 100644 .gitattributes create mode 100644 bin/assemble-module.py create mode 100644 modules/agent-box.nix.in create mode 100644 modules/src/settings-daemon.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..7fc29a2 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# modules/agent-box.nix is generated by bin/assemble-module.py from +# modules/agent-box.nix.in + modules/src/* (issue #140). Mark it generated so +# GitHub collapses it in diffs and excludes it from language stats — review the +# sources instead. +modules/agent-box.nix linguist-generated=true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a796a0..41a9f51 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,12 @@ jobs: - name: Check module evaluates as a single file run: nix build -L .#checks.x86_64-linux.module-single-file && cat result + # Issue 140: modules/agent-box.nix is generated from the .in template + + # modules/src/*. Fail (with a diff) if a source edit wasn't followed by + # `nix run .#assemble`, so the committed single file never drifts. + - name: Check generated module is up to date + run: nix build -L .#checks.x86_64-linux.module-generated-up-to-date && cat result + # Eval/build check (issue 132): the module's generated Caddyfile carries # the authenticated //downloads/ file-server route. - name: Check downloads route in generated Caddyfile diff --git a/.gitignore b/.gitignore index 3d7dd65..e07a6c9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ __pycache__/ tests/e2e/test-results/ result +result-* diff --git a/AGENTS.md b/AGENTS.md index f548eee..87b8bf7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,12 +2,14 @@ ## Project Structure & Module Organization -`modules/agent-box.nix` is the portable NixOS module and the repository's main implementation. `flake.nix` exposes the module, VM image, and CI checks. Host examples live in `hosts/`; AWS deployment configuration and operational notes are in `aws/`. Put NixOS integration tests in `tests/*.nix`, live browser tests in `tests/e2e/*.spec.ts`, maintenance utilities in `scripts/`, and website images or static content in `docs/`. +`modules/agent-box.nix` is the portable NixOS module and the repository's main implementation. It is **generated** — do not edit it by hand. The sources are `modules/agent-box.nix.in` (the Nix template) plus the assets under `modules/src/` (e.g. the settings daemon), stitched together by `bin/assemble-module.py` via `@@include:...@@` markers. `flake.nix` exposes the module, VM image, and CI checks. Host examples live in `hosts/`; AWS deployment configuration and operational notes are in `aws/`. Put NixOS integration tests in `tests/*.nix`, live browser tests in `tests/e2e/*.spec.ts`, maintenance utilities in `scripts/`, and website images or static content in `docs/`. -Keep the module self-contained: deployed boxes fetch `modules/agent-box.nix` as a single file, so it must not import sibling files. +Keep the module self-contained: deployed boxes fetch `modules/agent-box.nix` as a single file, so it must not import sibling files. This is why the sources are re-embedded at build time rather than loaded with `readFile` — after editing the template or `modules/src/`, run `nix run .#assemble` and commit the regenerated `modules/agent-box.nix` (CI's `module-generated-up-to-date` check fails on drift). ## Build, Test, and Development Commands +- `nix run .#assemble` regenerates `modules/agent-box.nix` from `modules/agent-box.nix.in` + `modules/src/` (run from the repo root after editing either). +- `nix build -L .#checks.x86_64-linux.module-generated-up-to-date` verifies the committed module matches its sources. - `nix flake metadata` validates flake inputs and basic evaluation. - `nix build .#vm` builds the bootable qcow2 image under `result/`. - `nix build -L .#checks.x86_64-linux.multi-user` runs the quick module/configuration assertion. diff --git a/bin/assemble-module.py b/bin/assemble-module.py new file mode 100644 index 0000000..badc7f6 --- /dev/null +++ b/bin/assemble-module.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Assemble modules/agent-box.nix from modules/agent-box.nix.in + modules/src/*. + +Why this exists +--------------- +Deployed boxes fetch EXACTLY ONE file — aws/template.yaml's user-data and +agent-box-update.service both `builtins.fetchurl .../modules/agent-box.nix` and +pin a single sha256 (issue #51). raw.githubusercontent is dual-stack (IPv6-only +boxes reach it via NAT64) and needs no unpack step on a journal-only first boot. +That constraint is on the *fetched artifact*, not on how we author it. + +So we keep the source split into real, tool-visible assets (a .py the linters +can see, later .js/.css) and mechanically re-embed them into the one committed +file at build time. `modules/agent-box.nix` is GENERATED; edit the `.in` +template and `modules/src/`, then rerun this script (or `nix run .#assemble`). + +Include mechanism +----------------- +A line matching `@@include:@@` (relative to the including +file's directory) is replaced by the target file's contents: + * nested includes in the target are resolved first; + * the target is escaped for the STRING SYNTAX of the including file — Nix + indented-string escaping when the host is a `.nix`/`.in` file, none + otherwise (Python triple-quote hosts store their assets verbatim; the + round-trip guarded by the up-to-date CI check catches an asset needing more); + * every non-blank line is re-indented to the marker's own indentation + (blank lines stay empty), so a dedented, col-0 source file (real Python the + linters accept) round-trips back to its indented in-string form byte-exact. +""" +import argparse +import difflib +import re +import sys +from pathlib import Path + +MARKER = re.compile(r"^(?P[ ]*)@@include:(?P[^@]+)@@[ ]*$") + +BANNER = """\ +# @@GENERATED@@ — DO NOT EDIT THIS FILE BY HAND. +# Source of truth: modules/agent-box.nix.in + modules/src/*. Regenerate with +# nix run .#assemble (or: python3 bin/assemble-module.py) +# CI (`module-generated-up-to-date`) fails if this file is stale. +""" + + +def nix_escape(text: str) -> str: + """Escape a literal for embedding inside a Nix '' indented string. + + Only `''` and `${` are special there (backslashes are literal). Order + matters: escape `''` first, then `${` — the `''${` we emit for `${` must + not be re-processed by the `''` rule.""" + text = text.replace("''", "'''") + text = text.replace("${", "''${") + return text + + +def escaper_for(path: Path): + if path.suffix in (".nix", ".in"): + return nix_escape + return lambda s: s + + +def reindent(text: str, indent: str) -> str: + return "\n".join(indent + line if line else line for line in text.split("\n")) + + +def resolve(path: Path) -> str: + """Return `path` with all its @@include@@ markers expanded. + + Children are escaped for THIS file's string syntax and re-indented to the + marker column; `path`'s own bytes are returned as-is.""" + escape = escaper_for(path) + out = [] + for line in path.read_text().split("\n"): + m = MARKER.match(line) + if not m: + out.append(line) + continue + child = (path.parent / m.group("path")).resolve() + if not child.exists(): + sys.exit(f"assemble-module: {path}: missing include target {child}") + payload = resolve(child) + # A source file's conventional terminal newline is not part of the + # in-string payload — drop exactly one so the marker line is replaced + # in place rather than gaining a trailing blank line. + if payload.endswith("\n"): + payload = payload[:-1] + out.append(reindent(escape(payload), m.group("indent"))) + return "\n".join(out) + + +def assemble(repo: Path) -> str: + template = repo / "modules" / "agent-box.nix.in" + return BANNER + resolve(template) + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument( + "--check", + action="store_true", + help="exit non-zero (with a diff) if the committed file is stale", + ) + ap.add_argument( + "--repo", + type=Path, + default=Path(__file__).resolve().parent.parent, + help="repo root (default: parent of bin/)", + ) + args = ap.parse_args() + out_path = args.repo / "modules" / "agent-box.nix" + generated = assemble(args.repo) + if args.check: + current = out_path.read_text() if out_path.exists() else "" + if current != generated: + diff = difflib.unified_diff( + current.splitlines(keepends=True), + generated.splitlines(keepends=True), + fromfile="modules/agent-box.nix (committed)", + tofile="modules/agent-box.nix (regenerated)", + ) + sys.stderr.writelines(diff) + sys.stderr.write( + "\nmodules/agent-box.nix is stale — run `nix run .#assemble`.\n" + ) + return 1 + print("modules/agent-box.nix is up to date") + return 0 + out_path.write_text(generated) + print(f"wrote {out_path}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/flake.nix b/flake.nix index efa2aa1..e575a29 100644 --- a/flake.nix +++ b/flake.nix @@ -36,6 +36,20 @@ default = image; }; + # `nix run .#assemble` — regenerate the committed modules/agent-box.nix + # from modules/agent-box.nix.in + modules/src/*. Run from the repo root; + # edits the working tree in place. + apps.${system}.assemble = + let + pkgs = nixpkgs.legacyPackages.${system}; + in + { + type = "app"; + program = "${pkgs.writeShellScript "agent-box-assemble" '' + exec ${pkgs.python3}/bin/python3 "$PWD/bin/assemble-module.py" "$@" + ''}"; + }; + # CI validation entrypoints (`nix build .#checks.x86_64-linux.`). # NOTE: prefer these over `nix flake check` — the VM nixosConfiguration is # intentionally bootloader/filesystem-free (the generator supplies them), @@ -194,6 +208,29 @@ } '' printf 'single-file eval OK: %s\n' "$evaluated" > "$out" ''; + + # modules/agent-box.nix is GENERATED from modules/agent-box.nix.in + + # modules/src/* by bin/assemble-module.py (issue #140). Re-run the + # generator in --check mode and fail (printing a diff) if the + # committed file is stale — this is what lets us split the source for + # tooling while still shipping the one self-contained fetched file. + module-generated-up-to-date = + pkgs.runCommand "agent-box-module-generated-up-to-date" + { + nativeBuildInputs = [ pkgs.python3 ]; + assembler = ./bin/assemble-module.py; + template = ./modules/agent-box.nix.in; + committed = ./modules/agent-box.nix; + srcDir = ./modules/src; + } '' + install -d repo/bin repo/modules + cp "$assembler" repo/bin/assemble-module.py + cp "$template" repo/modules/agent-box.nix.in + cp "$committed" repo/modules/agent-box.nix + cp -r "$srcDir" repo/modules/src + python3 repo/bin/assemble-module.py --check --repo repo + touch "$out" + ''; }; }; } diff --git a/modules/agent-box.nix b/modules/agent-box.nix index 16bbe65..b7fba72 100644 --- a/modules/agent-box.nix +++ b/modules/agent-box.nix @@ -1,3 +1,7 @@ +# @@GENERATED@@ — DO NOT EDIT THIS FILE BY HAND. +# Source of truth: modules/agent-box.nix.in + modules/src/*. Regenerate with +# nix run .#assemble (or: python3 bin/assemble-module.py) +# CI (`module-generated-up-to-date`) fails if this file is stale. # CONTRACT: this module must remain a SINGLE self-contained file. Deployed # boxes fetch exactly this one file and import it from a bare store path — # aws/template.yaml's user-data and agent-box-update.service both diff --git a/modules/agent-box.nix.in b/modules/agent-box.nix.in new file mode 100644 index 0000000..750e6d0 --- /dev/null +++ b/modules/agent-box.nix.in @@ -0,0 +1,2344 @@ +# CONTRACT: this module must remain a SINGLE self-contained file. Deployed +# boxes fetch exactly this one file and import it from a bare store path — +# aws/template.yaml's user-data and agent-box-update.service both +# `builtins.fetchurl` .../modules/agent-box.nix and pin one sha256. Any +# `./sibling` reference (readFile, import, path interpolation) evaluates +# against the lone store file, fails first-boot amazon-init *silently* +# (journal-only), and bricks self-update on every already-deployed box +# (issue #51). The `module-single-file` flake check enforces this in CI. +{ config, lib, pkgs, utils, ... }: + +let + cfg = config.services.agent-box; + supportedAgents = [ "claude" "codex" ]; + # Everything a session's `agent` field may name: the agent CLIs plus the + # pseudo-agent "shell" (issue #113) — a plain login shell in a supervised + # tmux session, for manual investigation/clean-up. "shell" is always + # available (nothing to install), so it is appended, never filtered. + sessionKinds = agents: agents ++ [ "shell" ]; + # Base AGENTS.md content seeded into every non-shell session's working + # directory (see users..agentsMd). This is the generic, deployment- + # independent half; users..agentsMd is APPENDED to it, so callers add + # deployment-specific notes (e.g. aws/template.yaml's AgentsMd parameter for + # EC2/CloudFormation specifics) without re-stating the common text. Kept + # verbatim in sync with that template default — the two must only differ by + # such appended specifics. $AGENT_BOX_URL (no braces) stays literal for the + # agent to expand at read time; Nix only antiquotes ''${...}''. + defaultAgentsMd = '' + # agent-box + + You are running inside an agent-box deployment: a coding agent in a + persistent tmux session on a locked-down NixOS host. Your browser + terminal is reachable at $AGENT_BOX_URL (run `echo $AGENT_BOX_URL` to + print it). Share that URL with anyone who needs to view or take over your + session; the sign-in username is your own login name (`whoami`) and the + password was set at deploy time. + + The user connects to this box remotely over the web, so whenever you point + them at something the box serves, give the full absolute URL (build it from + $AGENT_BOX_URL) — never a bare local path or a link relative to the + terminal, which a remote user can't act on. + + ## Your environment + + - Only your home directory is writable — the rest of the filesystem is + read-only (systemd ProtectSystem=strict). Do all work under $HOME; + writes elsewhere fail with a read-only-filesystem error. + - $HOME is SHARED by every one of your tmux sessions (they all run as the + same user and start in $HOME). For parallel work in one repo use + `git worktree` or separate subdirectories, so concurrent sessions don't + clobber each other's checkout. + - Sessions live in RAM: a reboot loses them, so persist anything worth + keeping to disk under $HOME. If an agent exits with an error you land in + a shell for inspection; a clean exit is respawned within ~2s. + - A respawn or reboot starts a fresh context, but each agent keeps its own + conversation transcripts on disk under $HOME — Claude Code under + ~/.claude/projects/ (plus ~/.claude/history.jsonl), Codex under + ~/.codex/sessions/. When you resume after a respawn, or take over a + session another agent was driving, skim the most recent of these to + recover what was in flight before writing any code. + - sudo is a tight allowlist (essentially caddy reload + self-update), not + general root — don't plan around arbitrary sudo. + + ## Tools, secrets, and sibling sessions + + - Install extra tools with nix, e.g. `nix profile add nixpkgs#awscli2` + (no sudo needed; tools land in ~/.nix-profile/bin, already on PATH). + - Your config lives in the directory ~/.config/agent-box/. Secrets and + environment variables go in the file `env` there (KEY=value, one per + line; blank lines and `#` comment lines are ignored, so annotate freely) + or via the settings page, and load on the next session (re)start — e.g. + GH_TOKEN is read automatically, so `git clone https://github.com/...` + just works. + - Manage your own sessions without a rebuild: + `agent-box-session ls|add|rm|restart`. `add` takes an optional name plus + `--agent claude|codex|shell` and `--cwd DIR` — handy for fanning out + work, spinning up a second reviewer agent, or opening a plain shell for + investigation. Listed sessions start within ~2s. + + ## Handing a file to the user + + To let the user download a file you produced (report, build artifact, + archive, image), move or copy it into ~/downloads and give them the full + URL. That directory is served — behind the SAME login as your terminal — + at ''${AGENT_BOX_URL}downloads/ (a browsable index), so a file at + ~/downloads/report.pdf downloads from ''${AGENT_BOX_URL}downloads/report.pdf. + + mv ./report.pdf ~/downloads/ # or cp, to keep the original + + Always hand the user the complete https:// URL. Only files under + ~/downloads are exposed this way; nothing else in your home is reachable + over the web. For unauthenticated sharing, run your own web service and + expose it via ~/sites (see below). + + ## Serving a web app publicly + + Drop a snippet into ~/sites/NAME.caddy that reverse-proxies to a local + port, then reload caddy — no rebuild: + + NAME.example.com { + import acme_alpn_only + reverse_proxy 127.0.0.1:3000 + } + + `sudo systemctl reload caddy.service` picks it up and Caddy gets a Let's + Encrypt cert on first request if DNS for that name points at this box. + Reverse-proxy to your process; don't `file_server` from $HOME (caddy + can't read /home). + + ## Updating + + Update the box's software with: + `sudo systemctl start agent-box-update.service` + (kills the running tmux session — save context first). + ''; + tmuxSocketName = "agent-box"; + runtimeDirectory = name: "agent-box-${name}"; + # ttyd port base; ports are assigned in sorted user-name order (see + # terminalUsers below). + ttydPortBase = 7681; + # The settings daemon listens on a per-user UNIX socket, not localhost TCP: + # a 127.0.0.1 port is reachable by EVERY local user (issue #49 — on a + # multi-agent box, codex could rewrite claude's keys and restart claude's + # agent). systemd creates each socket 0660 :caddy, so only that user + # and the caddy reverse-proxy can connect. + settingsSocketDir = "/run/agent-box-settings"; + settingsSocketOf = name: "${settingsSocketDir}/${name}.sock"; + # The per-user secrets file the settings page (issue #36) manages. User- + # owned, 0600, read by envExecWrapper at every session spawn (issue 89). + userEnvFile = name: "/home/${name}/.config/agent-box/env"; + # Per-user file-drop directory served (behind the terminal's auth) at + # //downloads/ so the agent can hand a file it wrote to the user + # (issue #132). Backed OUTSIDE /home because caddy.service runs with + # ProtectHome=true and cannot read /home; same 0750 :caddy model as + # the ~/sites snippet dir — the user writes, caddy reaches it via its group + # and reads each file through its world-read bit (default 0644). Symlinked + # into $HOME as ~/downloads so the agent never touches /var/lib directly. + downloadsDirOf = name: "/var/lib/agent-box-downloads/${name}"; + + # Session-spawn env loader (issue 89). Sessions are (re)created by the + # long-lived supervisor inside the agent unit, so a unit-level + # EnvironmentFile= snapshot of the user's env file goes stale the moment + # the settings page (or a hand edit) changes it — "restart the sessions + # to apply" silently applied nothing. This wrapper re-reads the file at + # EVERY session spawn and then execs the agent, making the file the + # single live source for those keys (it is deliberately NOT in the + # unit's EnvironmentFile, so a DELETED key disappears on restart too). + # Values are exported literally — never eval'd — so a secret full of + # shell metacharacters can't break or inject anything; one pair of + # surrounding quotes is stripped to match how systemd read the same + # file before. Key charset mirrors the settings daemon's KEY_RE. + envExecWrapper = name: pkgs.writeShellScript "agent-box-${name}-env-exec" '' + FILE=${lib.escapeShellArg (userEnvFile name)} + if [ -r "$FILE" ]; then + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in ('#'*|"") continue ;; (*=*) ;; (*) continue ;; esac + key=''${line%%=*} + case "$key" in (*[!A-Za-z0-9_]*|""|[0-9]*) continue ;; esac + val=''${line#*=} + case "$val" in + \"*\") val=''${val#\"}; val=''${val%\"} ;; + \'*\') val=''${val#\'}; val=''${val%\'} ;; + esac + export "$key=$val" + done < "$FILE" + fi + exec "$@" + ''; + + # Reload command is granted when web is enabled so the agent can add a + # virtual host and reload without root — pooled with the user-supplied + # sudoAllowlist so NoNewPrivileges + sudo rules see the same list. + caddyReloadCmd = "/run/current-system/sw/bin/systemctl reload caddy.service"; + updateStartCmd = "/run/current-system/sw/bin/systemctl start agent-box-update.service"; + # --no-block variant for the settings page's Update button: the daemon must + # answer the HTTP request before the rebuild (possibly) restarts the daemon + # itself. A separate literal because sudoers matches argv exactly. + updateStartNoBlockCmd = "/run/current-system/sw/bin/systemctl start --no-block agent-box-update.service"; + effectiveSudoAllowlist = + cfg.sudoAllowlist + ++ lib.optional cfg.web.enable caddyReloadCmd + ++ lib.optionals cfg.selfUpdate.enable [ updateStartCmd updateStartNoBlockCmd ]; + # Agent CLIs (claude-code, codex) move much faster than any host channel. + # When the host wires selfUpdate.agentNixpkgs (from the pin file the update + # service maintains — see that option), resolve just the agent packages from + # that pinned nixos-unstable snapshot; the rest of the system stays on the + # host nixpkgs. Hydra builds unstable, so this is a binary substitution, not + # a local compile. Null (fresh box, or wiring absent) falls back to host + # pkgs, so eval never breaks. + agentPkgs = + if cfg.selfUpdate.agentNixpkgs != null then + import (builtins.fetchTarball { + url = cfg.selfUpdate.agentNixpkgs.url; + sha256 = cfg.selfUpdate.agentNixpkgs.sha256; + }) { + system = pkgs.stdenv.hostPlatform.system; + # The host's allowUnfreePredicate does not reach a second nixpkgs + # import; allow exactly the bundled agent packages (mirrors the + # host-side default set below). + config.allowUnfreePredicate = pkg: + builtins.elem (lib.getName pkg) [ "claude-code" "codex" ]; + } + else pkgs; + agentPackage = agent: + if cfg.package != null then cfg.package + else if agent == "claude" then agentPkgs.claude-code + else agentPkgs.codex; + installedAgentPackages = map agentPackage cfg.installAgents; + installedCodexPackage = lib.optional (builtins.elem "codex" cfg.installAgents) (agentPackage "codex"); + + agentRuntimePackages = lib.unique ( + installedAgentPackages + ++ [ pkgs.bubblewrap pkgs.tmux pkgs.which sessionCli ] + ++ cfg.extraPackages + ); + # Sessions are RUNTIME data (issue #59): the Nix-declared + # users..sessions (or the legacy per-user agent/… options, which + # stand in for a single session named "main") only SEED + # ~/.config/agent-box/sessions.json on first boot. Afterwards the file is + # authoritative and sessions are created/destroyed WITHOUT a rebuild — + # via the agent-box-session CLI or the settings page. The supervisor + # (mkStart) reconciles tmux sessions against the file and builds each + # agent command at runtime, so per-agent flag logic lives in that script. + seedSessions = name: u: + if u.sessions != { } then u.sessions + else { + main = { + inherit (u) agent skipPermissions remoteControl remoteControlName extraArgs; + inherit (u) workingDirectory; + }; + }; + sessionsSeedFile = name: u: + pkgs.writeText "agent-box-${name}-sessions.json" (builtins.toJSON { + version = 1; + sessions = lib.mapAttrs (sname: s: { + agent = if s.agent != null then s.agent else cfg.agent; + inherit (s) skipPermissions remoteControl extraArgs; + # null → the supervisor derives "@" (main) or + # "-@" at start time. + remoteControlName = s.remoteControlName; + workingDirectory = + if s.workingDirectory != null then s.workingDirectory + else "/home/${name}"; + }) (seedSessions name u); + }); + userSessionsFile = name: "/home/${name}/.config/agent-box/sessions.json"; + + # Runtime session CRUD, shipped on every PATH. Runs as the calling agent + # user: edits the user-owned sessions.json (the supervisor reconciles + # within ~2s) and talks only to the user's own tmux server. No sudo, no + # rebuild — the whole point of issue #59. + sessionCli = pkgs.writeShellScriptBin "agent-box-session" '' + set -eu + JQ=${pkgs.jq}/bin/jq + FILE="$HOME/.config/agent-box/sessions.json" + AGENTS=${lib.escapeShellArg (lib.concatStringsSep " " (sessionKinds cfg.installAgents))} + DEFAULT_AGENT=${lib.escapeShellArg cfg.agent} + export TMUX_TMPDIR="''${TMUX_TMPDIR:-/run/agent-box-$USER}" + + t() { ${pkgs.tmux}/bin/tmux -L ${tmuxSocketName} "$@"; } + usage() { + echo "usage: agent-box-session ls" + echo " agent-box-session add [NAME] [--agent AGENT] [--cwd DIR] [-- EXTRA_ARGS...]" + echo " agent-box-session rm NAME" + echo " agent-box-session restart NAME" + echo "agents: $AGENTS (default: $DEFAULT_AGENT)" + echo "Listed sessions are (re)started by the per-user supervisor within ~2s." + echo "Attach: tmux -L ${tmuxSocketName} attach -t NAME, or the browser terminal //?arg=NAME" + } + valid_name() { + case "$1" in (*[!A-Za-z0-9_-]*|"") return 1 ;; esac + } + ensure_file() { + mkdir -p "$(dirname "$FILE")" + [ -s "$FILE" ] || printf '{"version":1,"sessions":{}}\n' > "$FILE" + } + jq_edit() { + # jq_edit JQ_ARGS... — atomically rewrite FILE through jq. + tmp="$(mktemp "$FILE.XXXXXX")" + if "$JQ" "$@" < "$FILE" > "$tmp"; then + mv "$tmp" "$FILE" + else + rm -f "$tmp" + exit 1 + fi + } + taken() { "$JQ" -e --arg n "$1" '.sessions | has($n)' "$FILE" >/dev/null; } + gen_name() { + # gen_name AGENT — echo a unique session name derived from AGENT: the + # bare name when free ("claude"), else a short random suffix + # ("claude-a3f9"). Callers pass a validated agent, itself a valid name. + a="$1" + taken "$a" || { printf '%s' "$a"; return; } + while :; do + cand="$a-$(printf '%04x' $((RANDOM % 65536)))" + taken "$cand" || { printf '%s' "$cand"; return; } + done + } + + cmd="''${1:-}"; shift || true + case "$cmd" in + ls) + live="$(t list-sessions -F '#S' 2>/dev/null || true)" + printf '%-24s %-8s %s\n' NAME AGENT STATE + if [ -s "$FILE" ]; then + "$JQ" -r '.sessions | to_entries[] | [.key, (.value.agent // "?")] | @tsv' "$FILE" \ + | while IFS="$(printf '\t')" read -r n a; do + state=starting + printf '%s\n' "$live" | grep -qxF "$n" && state=live + printf '%-24s %-8s %s\n' "$n" "$a" "$state" + done + fi + # Live tmux sessions nobody listed (started by hand): show, don't hide. + printf '%s\n' "$live" | while IFS= read -r n; do + [ -n "$n" ] || continue + if [ ! -s "$FILE" ] || ! "$JQ" -e --arg n "$n" '.sessions | has($n)' "$FILE" >/dev/null; then + printf '%-24s %-8s %s\n' "$n" '-' 'unmanaged' + fi + done + ;; + add) + # NAME is optional and positional: a leading non-flag arg is the name, + # otherwise the name is auto-derived from the agent below. + name="" + case "''${1:-}" in + ""|-*) ;; + *) name="$1"; shift; valid_name "$name" || { usage >&2; exit 2; } ;; + esac + agent="$DEFAULT_AGENT"; cwd="" + while [ $# -gt 0 ]; do + case "$1" in + --agent) agent="''${2:?--agent needs a value}"; shift 2 ;; + --cwd) cwd="''${2:?--cwd needs a value}"; shift 2 ;; + --) shift; break ;; + *) echo "unknown option: $1" >&2; usage >&2; exit 2 ;; + esac + done + case " $AGENTS " in + (*" $agent "*) ;; + (*) echo "agent '$agent' is not available (available: $AGENTS)" >&2; exit 2 ;; + esac + ensure_file + [ -n "$name" ] || name="$(gen_name "$agent")" + if taken "$name"; then + echo "session '$name' already exists — 'agent-box-session rm $name' first, or 'restart $name' to bounce it" >&2 + exit 2 + fi + # `--` after --args: jq otherwise still option-parses positional + # args, so a dashed extra arg like --model would error out. + jq_edit --arg n "$name" --arg a "$agent" --arg c "$cwd" \ + '.sessions[$n] = {agent: $a, skipPermissions: true, remoteControl: true, + remoteControlName: null, + workingDirectory: (if $c == "" then null else $c end), + extraArgs: $ARGS.positional}' \ + --args -- "$@" + echo "session '$name' ($agent) added — the supervisor starts it within ~2s" + ;; + rm) + name="''${1:-}" + valid_name "$name" || { usage >&2; exit 2; } + ensure_file + jq_edit --arg n "$name" 'del(.sessions[$n])' + t kill-session -t "=$name" 2>/dev/null || true + echo "session '$name' removed" + ;; + restart) + name="''${1:-}" + valid_name "$name" || { usage >&2; exit 2; } + t kill-session -t "=$name" + echo "session '$name' killed — the supervisor restarts it within ~2s if still listed" + ;; + *) + usage >&2 + exit 2 + ;; + esac + ''; + + # One session = one agent CLI in one tmux session. These options are the + # FIRST-BOOT SEED only (see users..sessions); at runtime the same + # fields live as JSON in ~/.config/agent-box/sessions.json. + sessionOpts = { + options = { + agent = lib.mkOption { + type = lib.types.nullOr (lib.types.enum (sessionKinds supportedAgents)); + default = null; + description = '' + Agent CLI this session runs. When null, uses + services.agent-box.agent. Must be listed in + services.agent-box.installAgents. + + The special value "shell" runs the user's login shell + (users.users..shell) instead of an agent CLI — a + supervised terminal for manual investigation or clean-up. + skipPermissions and remoteControl* are ignored for shell + sessions; extraArgs still applies (e.g. [ "-l" ]). + ''; + }; + skipPermissions = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Pass the agent's autonomy flag (see users..skipPermissions)."; + }; + remoteControl = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Pass --remote-control (claude sessions only; see users..remoteControl)."; + }; + remoteControlName = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = '' + Remote Control session name. When null, defaults to + "@" for the session named "main" and + "-@" otherwise, where is + networking.fqdnOrHostName, or the live kernel hostname when that + is empty at build time. When no hostname is resolvable the "@" + suffix is omitted (just "" / "-"). + ''; + }; + workingDirectory = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Directory the session's agent starts in. Null means the user's home."; + }; + extraArgs = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = "Extra arguments appended to this session's agent invocation."; + }; + }; + }; + + userOpts = { name, ... }: { + options = { + sessions = lib.mkOption { + type = lib.types.attrsOf (lib.types.submodule sessionOpts); + default = { }; + example = lib.literalExpression ''{ main = { }; review = { agent = "codex"; }; scratch = { agent = "shell"; }; }''; + description = '' + Seed sessions for this user — each is an agent CLI (or a plain + login shell, agent = "shell") running in its own tmux session + under the user's single supervised service. + Session names must match [A-Za-z0-9_-]+. + + Sessions are RUNTIME data: this option is written to + ~/.config/agent-box/sessions.json ONLY when that file does not + exist yet (first boot). Afterwards the file is authoritative, and + sessions are added/removed/restarted without a rebuild via the + agent-box-session CLI or the settings page. A later rebuild never + clobbers runtime changes. + + When empty (the default), the per-user agent / skipPermissions / + remoteControl* / workingDirectory / extraArgs options below seed a + single session named "main" — the pre-sessions behaviour. + ''; + }; + agent = lib.mkOption { + type = lib.types.nullOr (lib.types.enum (sessionKinds supportedAgents)); + default = null; + description = '' + Agent CLI to run for this user's default "main" session ("shell" + for a plain login shell — see sessions..agent). When + null, uses services.agent-box.agent. Ignored when + users..sessions is set (set the agent per session there). + ''; + }; + skipPermissions = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Pass the selected agent's autonomy flag, i.e. the agent has full + autonomy inside its shell with no in-tool approval prompts. + Default is `true` because agent-box is designed to be a HEADLESS + agent runner — no human sits at the prompt to answer questions. + + This is autonomy INSIDE the agent CLI, not an OS sandbox. The OS + sandbox is the unprivileged user, the systemd hardening this + module applies, and the tight sudoAllowlist. Set false if you + want each tool invocation to block for approval (only sensible + on a box with a human attached). + ''; + }; + remoteControl = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Pass --remote-control so the session is drivable from the Claude + desktop and mobile apps. Default `true` because "drive it from + your phone" is one of the module's headline features. + + Set false to disable remote-app control for this user — then the + agent is only reachable through the local tmux session (or the + ttyd browser terminal in the AWS variant). + + Applies only to the claude agent. Codex remote/app-server wiring is + separate future work; use extraArgs for explicit Codex flags. + ''; + }; + remoteControlName = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = '' + Remote Control session name, used to correlate the session to this box + from the Claude apps. Keep it shell-safe (no spaces/quotes). When null, + defaults to "@", where is networking.fqdnOrHostName, + or the live kernel hostname when that is empty at build time; the + "@" suffix is omitted entirely when no hostname is resolvable. + ''; + }; + workingDirectory = lib.mkOption { + type = lib.types.str; + default = "/home/${name}"; + description = "Directory the agent starts in."; + }; + extraGroups = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = "Extra groups for this agent user."; + }; + extraArgs = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = "Extra arguments appended to the selected agent invocation."; + }; + environmentFiles = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + example = lib.literalExpression ''[ "/run/secrets/alice-tokens.env" ]''; + description = '' + Extra systemd EnvironmentFile paths for this agent (KEY=value lines, + one per line). Read by systemd as root, so keep them outside the Nix + store (mode 600, root-owned). Prefix a path with '-' to make it + optional. These load in addition to the auto per-user token file. + ''; + }; + environment = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + default = { }; + example = lib.literalExpression ''{ TERM = "xterm-256color"; }''; + description = "Extra (non-secret) environment variables for this agent's service. Merged over the default HOME."; + }; + agentsMd = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = ""; + example = lib.literalExpression '' + ''' + ## This deployment + Deployed via CloudFormation stack "my-stack"; update it from the + AWS console's Update button, not just the self-update service. + ''' + ''; + description = '' + Deployment-specific instructions APPENDED to the built-in default + AGENTS.md (the generic agent-box guidance: browser-terminal URL, + installing tools with nix, self-update). The concatenation is + seeded to /AGENTS.md on agent start, IFF that + file does not already exist — so the agent's own edits or a repo + checkout in workingDirectory never get clobbered. + + The default "" (or any whitespace-only value) appends nothing, so + the built-in default is seeded as-is. Set null to opt out of seeding + entirely (write no AGENTS.md). + Sessions with agent = "shell" are never seeded (no agent reads it + there). + + AGENTS.md is the cross-vendor agent-instructions convention read + natively by codex and opencode, and by claude-code as a fallback + when CLAUDE.md is absent. The agent's systemd env exports + AGENT_BOX_URL whenever this user has a browser terminal (see + web.passwordHashFile), so an AGENTS.md that references that variable + lets the agent answer "where am I reachable?" without hard-coding + the URL. + ''; + }; + web.passwordHashFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + example = "/var/lib/agent-box-web/password-hash-${name}"; + description = '' + Give this user a browser terminal (requires + services.agent-box.web.enable). Path to a file containing an + Argon2id (recommended) or legacy bcrypt hash produced by `caddy + hash-password`; the terminal is served at + https:///${name}/ behind basic auth whose username is + this linux user name and whose password is the one behind this + hash. Root-owned, 0600, outside the Nix store so the plaintext + never lands in a world-readable path. Null (the default) means no + browser terminal for this user. + + Each terminal's ttyd gets a localhost port assigned in sorted + user-name order starting at 7681. The top-level Caddyfile is + module-managed, so adding/removing terminal users is a + nixos-rebuild away — check the assigned ports with + `systemctl cat agent-web-terminal-`. + ''; + }; + }; + }; + + # The per-user SUPERVISOR (issue #59). One hardened unit per user; the tmux + # server and every session — including ones added at runtime — are children + # of this script, so they all inherit the unit's systemd sandboxing. The + # loop is ensure-only: missing listed sessions are (re)created, but the + # supervisor never kills. Destroy goes through the CRUD paths + # (agent-box-session rm / settings page), which delist AND kill — so a + # removed entry stays gone, and an ad-hoc `tmux new` session is left alone. + mkStart = name: u: + let + home = "/home/${name}"; + fqdn = config.networking.fqdnOrHostName; + agentBinCases = lib.concatMapStrings (a: + " ${a}) printf '%s\\n' ${lib.escapeShellArg (lib.getExe (agentPackage a))} ;;\n" + ) cfg.installAgents + # "shell" (issue #113): the user's login shell as a pseudo-agent — + # always resolvable, independent of installAgents. + + " shell) printf '%s\\n' ${lib.escapeShellArg (utils.toShellPath config.users.users.${name}.shell)} ;;\n"; + # AGENTS.md — cross-vendor agent-instructions file (codex, opencode + # native; claude-code as CLAUDE.md fallback). Content lives in the Nix + # store so no in-shell quoting; $AGENT_BOX_URL and other $refs in the + # content stay literal for the agent to expand at read time. Seeded + # per session in start_session below. + # null opts out entirely; otherwise seed the built-in default with the + # per-user addendum appended. A whitespace-only addendum (incl. the "" + # default and the lone "\n" that aws/template.yaml's block-scalar splice + # yields for an empty AgentsMd) appends nothing — the base is seeded as-is. + agentsMdFile = + if u.agentsMd == null then null + else pkgs.writeText "agent-box-${name}-agents.md" + (defaultAgentsMd + + lib.optionalString (builtins.match "[[:space:]]*" u.agentsMd == null) + "\n${u.agentsMd}"); + in + pkgs.writeShellScript "agent-box-${name}-start" '' + set -u + JQ=${pkgs.jq}/bin/jq + TMUX="${pkgs.tmux}/bin/tmux -L ${tmuxSocketName}" + SESSIONS_FILE=${lib.escapeShellArg (userSessionsFile name)} + + # First boot only: seed the Nix-declared sessions. The file is RUNTIME + # data afterwards — a rebuild must never clobber sessions the user + # added or removed while the box was live. + mkdir -p ${home}/.config/agent-box + if [ ! -s "$SESSIONS_FILE" ]; then + install -m 0600 ${sessionsSeedFile name u} "$SESSIONS_FILE" + fi + +${lib.optionalString (installedCodexPackage != [ ]) '' + # Codex remote-control pairing currently requires the standalone + # installer layout at ~/.codex/packages/standalone/current/codex. + # Mirror that fixed path to the Nix-provided Codex so pairing works + # without a curl-installed second copy. + mkdir -p ${home}/.codex/packages/standalone/agent-box-current + ln -sfn ${lib.escapeShellArg (lib.getExe (lib.head installedCodexPackage))} \ + ${home}/.codex/packages/standalone/agent-box-current/codex + ln -sfn agent-box-current ${home}/.codex/packages/standalone/current +''} + + seed_json() { + # seed_json FILE JQ_ARGS... — jq-edit FILE in place, creating it + # if missing. A file jq can't parse is left untouched: the dialog + # comes back, but the agent still starts. + file=$1; shift + [ -s "$file" ] || printf '{}' > "$file" + if $JQ "$@" "$file" > "$file.seed-tmp" 2>/dev/null; then + mv "$file.seed-tmp" "$file" + else + rm -f "$file.seed-tmp" + fi + } + + # Pre-accept claude-code's one-time startup dialogs. A fresh home + # otherwise parks the session on interactive prompts — the folder-trust + # dialog ("Is this a project you trust?") and, when running with + # --dangerously-skip-permissions, the Bypass Permissions warning (whose + # default answer is "No, exit"). On a headless box nobody is at the + # terminal to answer them, and Remote Control can't drive a session + # that is stuck on a dialog, so the ONLY interactive step left should + # be the one-time OAuth login. claude persists both acceptances in + # per-user state files, which it round-trips (read-modify-write), so + # values seeded before first launch survive login/onboarding: + # ~/.claude.json projects..hasTrustDialogAccepted + # ~/.claude/settings.json skipDangerousModePermissionPrompt + # Runs before every claude session start (idempotent), which also + # covers upstream's occasional failure to persist an interactive + # acceptance (anthropics/claude-code issue 36403). Codex has no such + # dialogs. $1 = working directory, $2 = skipPermissions (true/false). + seed_claude_state() { + mkdir -p ${home}/.claude + seed_json ${home}/.claude.json --arg wd "$1" \ + '.projects[$wd] = ((.projects[$wd] // {}) + {hasTrustDialogAccepted: true, hasCompletedProjectOnboarding: true})' + if [ "$2" = true ]; then + seed_json ${home}/.claude/settings.json \ + '.skipDangerousModePermissionPrompt = true' + fi + } + + agent_bin() { + case "$1" in +${agentBinCases} *) return 1 ;; + esac + } + + start_session() { + sname=$1 + sjson="$($JQ -c --arg s "$sname" '.sessions[$s] // empty' "$SESSIONS_FILE")" || return 0 + [ -n "$sjson" ] || return 0 + agent="$($JQ -r '.agent // empty' <<<"$sjson")" + if ! bin="$(agent_bin "$agent")"; then + echo "session '$sname': agent '$agent' is not installed (see installAgents) — skipping" >&2 + return 0 + fi + wd="$($JQ -r '.workingDirectory // empty' <<<"$sjson")" + [ -n "$wd" ] || wd=${home} + skip="$($JQ -r 'if .skipPermissions == false then "false" else "true" end' <<<"$sjson")" + rc="$($JQ -r 'if .remoteControl == false then "false" else "true" end' <<<"$sjson")" + rcname="$($JQ -r '.remoteControlName // empty' <<<"$sjson")" + # Build the command with printf %q so runtime-provided fields + # (extraArgs, remoteControlName, cwd) can't inject into the tmux + # command line — the runtime equivalent of lib.escapeShellArg. + cmd="$(printf '%q' "$bin")" + if [ "$skip" = true ]; then + case "$agent" in + claude) cmd="$cmd --dangerously-skip-permissions" ;; + codex) cmd="$cmd --dangerously-bypass-approvals-and-sandbox" ;; + esac + fi + if [ "$agent" = claude ] && [ "$rc" = true ]; then + if [ -z "$rcname" ]; then + # Host suffix for the derived "[-]@" name. + # ${fqdn} is config.networking.fqdnOrHostName, fixed at build + # time — but it is "" when networking.hostName is unset, which + # used to leave a dangling trailing "@" (e.g. "agent-devs@"). + # Fall back to the live kernel hostname, and drop "@" + # entirely when even that is empty rather than emitting a bare + # "@". read is a bash builtin, so this needs nothing on PATH. + host=${fqdn} + if [ -z "$host" ] && [ -r /proc/sys/kernel/hostname ]; then + read -r host < /proc/sys/kernel/hostname || host= + fi + rcbase=${name} + [ "$sname" = main ] || rcbase=${name}-$sname + if [ -n "$host" ]; then + rcname="$rcbase@$host" + else + rcname="$rcbase" + fi + fi + cmd="$cmd --remote-control $(printf '%q' "$rcname")" + fi + while IFS= read -r xarg; do + cmd="$cmd $(printf '%q' "$xarg")" + done < <($JQ -r '.extraArgs // [] | .[]' <<<"$sjson") + if [ "$agent" = claude ]; then + # Upstream claude-code bug: the client persists only + # channelsEnabled to ~/.claude/remote-settings.json, losing the + # org's channel-plugin allowlist; the next launch trusts the stale + # cache and silently drops every channel notification. Clearing + # the cache before each claude launch forces a full policy fetch. + rm -f ${home}/.claude/remote-settings.json + seed_claude_state "$wd" "$skip" + fi +${lib.optionalString (agentsMdFile != null) '' + # Seed AGENTS.md into the session's working directory IFF absent, so + # the agent's own edits or a repo checkout there never get clobbered. + # Not for shell sessions: no agent reads it there, and scratch dirs + # shouldn't get littered. + if [ "$agent" != shell ] && [ ! -e "$wd/AGENTS.md" ]; then + mkdir -p "$wd" + install -m 0644 ${agentsMdFile} "$wd/AGENTS.md" + fi +''} # The env-exec wrapper loads ~/.config/agent-box/env NOW — at spawn + # time, not unit start — then execs the agent (issue 89), so + # settings-page secrets land on the next session (re)start. + # `|| exec bash` gives a POST-MORTEM shell ONLY on non-zero agent + # exit — the dead session stays attachable for inspection and is NOT + # respawned over (the wrapper execs the agent, so the exit status + # is the agent's). A clean exit lets the session die; the reconcile + # loop below then starts a fresh agent within ~2s. Shell sessions + # get no post-mortem fallback — the command IS a shell, and exiting + # it should hand you a fresh one (via the reconcile loop), not a + # nested inspection bash. + postmortem=" || exec ${pkgs.bashInteractive}/bin/bash" + [ "$agent" = shell ] && postmortem="" + $TMUX new-session -d -s "$sname" -c "$wd" \ + "${envExecWrapper name} $cmd$postmortem" + } + + # Reconcile forever; systemd stop tears the whole tree down (ExecStop + # kill-server + cgroup kill), Restart=always revives a crashed loop. + while true; do + while IFS= read -r sname; do + case "$sname" in + (*[!A-Za-z0-9_-]*|"") continue ;; + esac + $TMUX has-session -t "=$sname" 2>/dev/null || start_session "$sname" + done < <($JQ -r '.sessions | keys[]' "$SESSIONS_FILE" 2>/dev/null) + sleep 2 + done + ''; +in +{ + options.services.agent-box = { + enable = lib.mkEnableOption "reproducible multi-user coding agent host"; + + agent = lib.mkOption { + type = lib.types.enum supportedAgents; + default = "claude"; + description = "Default agent CLI to run. Supported values: claude, codex."; + }; + + package = lib.mkOption { + type = lib.types.nullOr lib.types.package; + default = null; + defaultText = lib.literalExpression ''null (pkgs.claude-code for agent = "claude"; pkgs.codex for agent = "codex")''; + description = "Package to run for every agent user. Leave null to use the selected agent's default package."; + }; + + installAgents = lib.mkOption { + type = lib.types.listOf (lib.types.enum supportedAgents); + default = supportedAgents; + description = '' + Agent CLIs installed on the box — system PATH and every agent unit's + PATH — independently of what any session currently runs, so a + runtime `agent-box-session add --agent codex` needs no rebuild. + Sessions may only use agents listed here. Default: all supported + agents. + ''; + }; + + users = lib.mkOption { + type = lib.types.attrsOf (lib.types.submodule userOpts); + default = { }; + example = lib.literalExpression ''{ alice = { }; bob = { remoteControlName = "bob-box"; }; }''; + description = "Agent users to provision. Each gets an unprivileged account and its own tmux-backed agent service."; + }; + + sudoAllowlist = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + example = lib.literalExpression ''[ "/run/current-system/sw/bin/systemctl reload caddy.service" ]''; + description = '' + Passwordless sudo commands granted to every agent user. This is the ONLY + elevated power the agents get — keep it a tight, explicit allowlist rather + than blanket root. + ''; + }; + + extraPackages = lib.mkOption { + type = lib.types.listOf lib.types.package; + default = [ ]; + example = lib.literalExpression "with pkgs; [ git ripgrep jq ]"; + description = "Extra packages placed on each agent's PATH."; + }; + + environmentFiles = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = "Extra systemd EnvironmentFile paths applied to every agent (see per-user environmentFiles)."; + }; + + tokenDir = lib.mkOption { + type = lib.types.str; + default = "/etc/agent-box"; + description = '' + Directory holding optional per-agent token files. Each agent + auto-loads /.env if it exists — so adding a token like + GH_TOKEN is just: drop a KEY=value line into that file (mode 600), no + rebuild required. + ''; + }; + + manageTokenDir = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Create tokenDir (root-owned) via tmpfiles so token files can be dropped in."; + }; + + protectMemory = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Guard the box against agent-driven memory exhaustion: compressed + zram swap, earlyoom as an OOM backstop, and a raised OOMScoreAdjust + on the agent units so the kernel and earlyoom sacrifice agent work + before sshd/caddy/the SSM agent. A small swapless box under memory + pressure never OOM-kills — reclaim keeps "succeeding" by evicting + clean page-cache pages (including running programs' own code), + which immediately refault from disk, and the whole userspace + livelocks (issue 62). Every knob below is set with mkDefault, so + hosts can tune individual pieces — e.g. add disk swap on top via + the standard swapDevices option. + ''; + }; + + web = { + enable = lib.mkEnableOption '' + browser terminals (one ttyd per user with web.passwordHashFile set) + fronted by Caddy with basic-auth-to-cookie web auth — the basic-auth + username is the linux user name, so logging in picks the terminal. + The vhost root (/) serves the primary user's (web.user) terminal + workspace — one tab per tmux session — behind that same auth; other + users' terminals live at //. The top-level + Caddyfile is module-managed (regenerated every rebuild); each agent + user's own virtual hosts live in ~/sites/*.caddy (a symlink to + /var/lib/agent-box-sites//, which caddy can read) and land + with `sudo systemctl reload caddy.service` + ''; + + domain = lib.mkOption { + type = lib.types.str; + example = "1-2-3-4.sslip.io"; + description = '' + Public hostname for the browser terminal. Used to seed + /var/lib/caddy/Caddyfile the first time only — subsequent edits are + preserved. Set this to whatever DNS name resolves to the host + (sslip.io on AWS, a custom domain on bare metal, etc). + ''; + }; + + user = lib.mkOption { + type = lib.types.str; + default = "agent"; + description = '' + Which services.agent-box.users entry administers Caddy: it is + added to the caddy group (so it can edit /var/lib/caddy/Caddyfile) + and granted passwordless sudo for `systemctl reload caddy.service`. + Which users get a browser terminal is separate — set + users..web.passwordHashFile per user. + ''; + }; + + fail2ban = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Ban IPs that repeatedly fail the browser terminal's basic auth + (fail2ban jail watching Caddy's access log in the journal). Only + counts requests that actually carried credentials, so the 401 a + browser gets before showing the login prompt doesn't score against + visitors. The module-managed Caddyfile includes the `log` directive + the jail needs; per-user snippet files under ~/sites/ share the + same journal stream if they include `log` too. Whitelist trusted + networks with services.fail2ban.ignoreIP. Also brings fail2ban's + default sshd jail along. + ''; + }; + }; + + selfUpdate = { + enable = lib.mkEnableOption '' + an agent-triggerable self-update service. When enabled, every agent + user's sudo allowlist gains exactly + `systemctl start agent-box-update.service` (plus its --no-block + variant, used by the settings page's Update button) — a root oneshot + that fast-forwards the box to the upstream repo's latest + default-branch commit by rewriting `pinFile` (and, when agentNixpkgs + is wired, advances `agentPinFile` to the latest nixos-unstable + channel release so agent CLIs stay fresh) and running + `nixos-rebuild switch`. + The privilege boundary is trigger-only: no arguments, environment or + paths cross sudo; the update source and logic are fixed in the unit. + NOTE: the rebuild restarts agent services, so running sessions die — + agents should save their working context before triggering it. + Release signature verification is future work (tracked upstream); + until then the updater trusts the pinned GitHub repo as published, + hash-pinning only what it fetched + ''; + + repo = lib.mkOption { + type = lib.types.str; + default = "defangdevs/agent-box"; + description = "GitHub owner/repo the update service pulls from."; + }; + + rev = lib.mkOption { + type = lib.types.str; + example = "0f96eebfda54d9e7cc90cdda9a5b30f04b95c1df"; + description = '' + Git rev of `repo` this configuration was built from — wire it to + the same value that pins the module fetch (see pinFile). Used as + the ancestry baseline: the updater refuses any target that is not + strictly ahead of this rev, so history rewrites and replays of + older (possibly vulnerable) revisions don't apply. + ''; + }; + + pinFile = lib.mkOption { + type = lib.types.str; + default = "/etc/nixos/agent-box-pin.nix"; + description = '' + File the updater atomically rewrites with + `{ rev = "..."; sha256 = "..."; }`. The host configuration must + import the module at exactly this pin when the file exists (see + aws/template.yaml for the reference wiring); otherwise the update + rebuilds against a stale module and silently no-ops. + ''; + }; + + agentNixpkgs = lib.mkOption { + type = lib.types.nullOr (lib.types.submodule { + options = { + url = lib.mkOption { + type = lib.types.str; + example = "https://releases.nixos.org/nixos/unstable/nixos-25.11pre850000.abcdef123456/nixexprs.tar.xz"; + description = "Channel-release tarball of the nixpkgs snapshot to resolve agent CLIs from."; + }; + sha256 = lib.mkOption { + type = lib.types.str; + description = "nix-prefetch-url --unpack hash of `url`."; + }; + }; + }); + default = null; + description = '' + A second, faster-moving nixpkgs snapshot (a nixos-unstable + channel-release tarball) that only the agent CLI packages + (claude-code, codex) are resolved from — the rest of the system + stays on the host's own nixpkgs. When null, agent packages come + from the module's regular `pkgs`. The update service advances this + pin by rewriting `agentPinFile`; the host configuration must import + that file when it exists and wire it back into this option (same + pathExists dance as pinFile — see aws/template.yaml). Kept as an + option rather than a file read inside the module so the module + stays pure for flake evaluation. + ''; + }; + + agentPinFile = lib.mkOption { + type = lib.types.str; + default = "/etc/nixos/agent-box-agent-pin.nix"; + description = '' + File the updater atomically rewrites with + `{ url = "..."; sha256 = "..."; }` — the latest nixos-unstable + channel release, feeding agentNixpkgs on the next eval. + ''; + }; + }; + + spotInterruption = { + # Defaults to true (not mkEnableOption): the monitor is self-gating and + # harmless on a non-Spot box, so the safe default is "on" — a Spot box + # that forgets to set it must not silently lose agent context (issue #20, + # the failure that motivated this). Set false to omit the unit entirely; + # aws/template.yaml ties it to the UseSpot parameter for that reason. + enable = lib.mkOption { + type = lib.types.bool; + default = true; + example = false; + description = '' + Whether to enable graceful handling of EC2 Spot interruptions + (issue #20). A small root monitor polls the instance metadata + service (IMDS) for a scheduled interruption; when AWS posts one + (~2 min notice) it injects a "save your context now" prompt into + every live agent session (via tmux send-keys, so it works for any + agent CLI — claude, codex, …), waits spotInterruption.gracePeriod + for the agents to write continuity notes to disk, then cleanly + stops the per-user agent units so a cleanly-exited session is NOT + respawned during the shutdown window. + + It deliberately does NOT power the box off itself. This deployment + runs as a PERSISTENT Spot request with interruptionBehavior=stop, + and ONLY an AWS-initiated interruption-stop is auto-restarted when + capacity returns — a guest-initiated shutdown/stop counts as a + MANUAL stop, which AWS leaves stopped until someone starts it by + hand. So we let AWS perform the stop (its stop drives a graceful + systemd shutdown that flushes buffers anyway) and only prepare for + it. + + Defaults to true and is harmless to leave enabled on a non-Spot + (on-demand) box: the monitor reads instance-life-cycle at startup + and exits immediately when it is not "spot" (or when IMDS is + unreachable, e.g. a dev rig). + ''; + }; + + gracePeriod = lib.mkOption { + type = lib.types.ints.unsigned; + default = 75; + description = '' + Seconds to wait, after notifying the agents, before stopping the + agent units. The AWS notice lead time is ~120 s, so the default + leaves headroom for the agents to react and for the units to stop + before AWS forcibly stops the instance. Notification is immediate; + this only controls how long the agents get uninterrupted to write + before their sessions are torn down. + ''; + }; + + pollInterval = lib.mkOption { + type = lib.types.ints.positive; + default = 5; + description = '' + Seconds between IMDS polls for spot/instance-action. The notice + lead time is ~120 s, so 5 s adds negligible detection lag. + ''; + }; + + message = lib.mkOption { + type = lib.types.str; + default = "⚠️ SPOT INTERRUPTION: AWS will stop this box within ~2 minutes and this in-memory session will be lost (the box restarts later with the SAME disk, IP, and TLS cert). Save any important working context, decisions, and next steps to disk under your home NOW (e.g. update your MEMORY/notes files) so you can resume after restart."; + description = '' + Single-line prompt injected into each live agent session on + interruption. Keep it ONE line: it is typed with tmux send-keys + and then submitted with a separate Enter — an embedded newline + would submit it prematurely in most agent TUIs. + ''; + }; + }; + }; + + config = lib.mkIf cfg.enable (lib.mkMerge [{ + assertions = [{ + assertion = cfg.users != { }; + message = "services.agent-box.enable is true but no users are defined in services.agent-box.users."; + } { + assertion = cfg.installAgents != [ ]; + message = "services.agent-box.installAgents must not be empty."; + }] ++ lib.concatLists (lib.mapAttrsToList (name: u: + lib.concatLists (lib.mapAttrsToList (sname: s: [ + { + # Session names land in tmux -t targets, URLs and env-ish contexts; + # the same regex is enforced at runtime by the supervisor, the CLI + # and the settings daemon. + assertion = builtins.match "[A-Za-z0-9_-]+" sname != null; + message = "services.agent-box.users.${name}: session name \"${sname}\" must match [A-Za-z0-9_-]+."; + } + { + assertion = builtins.elem (if s.agent != null then s.agent else cfg.agent) cfg.installAgents; + message = "services.agent-box.users.${name}: session \"${sname}\" uses agent \"${if s.agent != null then s.agent else cfg.agent}\", which is not in services.agent-box.installAgents."; + } + { + # Cheap sanity check on a string that lands in a shell command; + # deeper escaping happens at runtime via printf %q in mkStart. + assertion = s.remoteControlName == null || ( + s.remoteControlName != "" + && !(lib.hasInfix "\n" s.remoteControlName) + && !(lib.hasInfix "\r" s.remoteControlName) + ); + message = "services.agent-box.users.${name}: session \"${sname}\"'s remoteControlName must be non-empty and free of newlines."; + } + ]) (seedSessions name u))) cfg.users); + + # Claude Code is unfree; allow just the bundled supported agent packages + # (host can override). + nixpkgs.config.allowUnfreePredicate = + lib.mkDefault (pkg: builtins.elem (lib.getName pkg) [ "claude-code" "codex" ]); + + # Agents self-serve tools with `nix profile add nixpkgs#` (see the + # agent unit's path below) — that flake-ref syntax needs both features. + # List settings merge, so hosts can still extend this. + nix.settings.experimental-features = [ "nix-command" "flakes" ]; + + # `git clone https://github.com/...` authenticates with the user's + # GH_TOKEN out of the box: gh's credential helper reads it from the + # environment (the token files above export it into agent sessions). + # System-level /etc/gitconfig, so it works in every session without + # touching the user's ~/.gitconfig; no token, no envs -> helper emits + # nothing and git proceeds anonymously. + programs.git = { + enable = true; + config = { + credential."https://github.com".helper = "!${pkgs.gh}/bin/gh auth git-credential"; + credential."https://gist.github.com".helper = "!${pkgs.gh}/bin/gh auth git-credential"; + }; + }; + + users.users = lib.mapAttrs (name: u: { + isNormalUser = true; + home = "/home/${name}"; + createHome = true; + extraGroups = u.extraGroups; + # Overridable so a host config can pick another shell (e.g. pkgs.zsh) — + # "shell" sessions (issue #113) run whatever this resolves to. Priority + # 900: mkDefault would TIE with the isNormalUser->useDefaultShell + # mkDefault from users-groups.nix (the option merges uniquely, so a tie + # is an eval error); 900 beats that default, a plain host definition + # (priority 100) still beats us. + shell = lib.mkOverride 900 pkgs.bashInteractive; + }) cfg.users; + + environment.systemPackages = agentRuntimePackages; + + systemd.services = lib.mapAttrs' (name: u: + lib.nameValuePair "agent-box-${name}" { + description = "Coding agent sessions (tmux) for ${name}"; + wantedBy = [ "multi-user.target" ]; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + # System services get a minimal PATH; give the agent an explicit toolset. + # The user's nix-profile bin goes first so `nix profile add` tools are + # visible without a rebuild. It must be in the unit's PATH (not just a + # BASH_ENV hook): agent CLIs commonly snapshot their startup PATH and re-export + # it in every tool shell, clobbering anything BASH_ENV prepended. + # NOTE: `path` entries go through makeBinPath, which appends /bin — + # so list the profile ROOT, not its bin dir ('.../bin' became the + # nonexistent '.../bin/bin' and silently dropped nix-profile tools). + # /run/wrappers is added when the agent has any sudo allowlist entries, + # so the setuid `sudo` wrapper (which lives at /run/wrappers/bin/sudo, + # NOT on the default systemd unit PATH) resolves in agent tool shells. + # config.nix.package puts the `nix` CLI itself on the agent PATH — + # /run/current-system/sw/bin is NOT on systemd unit PATHs, so without + # it `nix profile add` is unreachable from agent tool shells. + path = [ "/home/${name}/.nix-profile" config.nix.package ] + ++ agentRuntimePackages + ++ [ pkgs.bashInteractive pkgs.coreutils pkgs.git pkgs.gh ] + ++ lib.optional (effectiveSudoAllowlist != [ ]) "/run/wrappers"; + # TMUX_TMPDIR puts the control socket under the /run RuntimeDirectory + # below instead of /tmp. PrivateTmp (in serviceConfig) gives this unit a + # PRIVATE /tmp, so a socket there would be invisible to the separate + # process that attaches (the AWS ttyd service, or `sudo -u tmux`). + # /run/agent-box- is a normal host path both sides can reach. + # Attach with: env TMUX_TMPDIR=/run/agent-box- tmux -L agent-box attach -t main + # + # AGENT_BOX_URL: the user's browser-terminal URL, exported only when + # this user actually has a terminal (web.enable + web.passwordHashFile). + # An AGENTS.md (see users..agentsMd) can reference it so any + # agent — claude-code, codex, opencode — can answer "where am I + # reachable?" without hard-coding the URL, which is useful because + # the hostname is a spot-restart away from changing. + environment = + { HOME = "/home/${name}"; TMUX_TMPDIR = "/run/${runtimeDirectory name}"; } + // (lib.optionalAttrs (cfg.web.enable && u.web.passwordHashFile != null) { + AGENT_BOX_URL = "https://${cfg.web.domain}/${name}/"; + }) + // u.environment; + serviceConfig = { + User = name; + # ExecStart's mkStart is the session supervisor (issue #59): it + # reconciles tmux sessions against the user-owned sessions.json + # forever. Individual session restarts happen inside the loop; + # Restart=always only backstops a crashed supervisor. + Type = "exec"; + Restart = "always"; + RestartSec = "2s"; + ExecStart = mkStart name u; + # Stopping the unit stops every session: kill the whole per-user + # tmux server (the supervisor loop dies with the cgroup). + ExecStop = "${pkgs.tmux}/bin/tmux -L ${tmuxSocketName} kill-server"; + # Holds the tmux control socket (see TMUX_TMPDIR above). 0700 so only + # the agent user can reach its own socket; ExecStop/attachers run as + # the same user. Persist across restarts so an in-flight attach isn't + # racing the dir's teardown when Restart=always cycles the agent. + RuntimeDirectory = runtimeDirectory name; + RuntimeDirectoryMode = "0700"; + RuntimeDirectoryPreserve = true; + # Custom tokens (GH_TOKEN, etc.) land here. The '-' makes the per-user + # file optional so the agent starts even before any token is dropped in. + # NOTE: the settings page's user-owned ~/.config/agent-box/env is + # deliberately NOT listed here. Unit env is a snapshot from unit + # start, and sessions are respawned by the long-lived supervisor — + # so browser-added secrets never reached restarted sessions, and + # deleted keys never left (issue 89). The supervisor's env-exec + # wrapper reads that file at every session spawn instead. The + # tokenDir file stays unit-level: it's root-owned 0600, which the + # agent user can't read at spawn time — the settings page's + # "Restart all" bounces this whole unit (the daemon SIGTERMs the + # supervisor, its own uid; Restart=always below), so token drops + # apply there without sudo. + EnvironmentFile = cfg.environmentFiles + ++ [ "-${cfg.tokenDir}/${name}.env" ] + ++ u.environmentFiles; + + # Systemd hardening. The OS boundary has to stay meaningful even + # though the agent runs with its in-tool approval prompts disabled. + # This is the containment the agent CLI deliberately opts out of. + PrivateTmp = true; + PrivateDevices = true; # keeps pty subsystem; blocks /dev/mem etc. + ProtectSystem = "strict"; # entire fs read-only except explicit RW paths + ReadWritePaths = [ "/home/${name}" ]; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + ProtectClock = true; + RestrictSUIDSGID = true; + RestrictRealtime = true; + LockPersonality = true; + # NoNewPrivileges would break the sudoAllowlist escape hatch (sudo + # is setuid root; NNP blocks the euid transition). Enable it only + # when the effective allowlist is empty — with a non-empty allowlist + # (from cfg.sudoAllowlist or the web-implied caddy reload) we've + # traded some containment for scoped elevation as a host choice. + NoNewPrivileges = effectiveSudoAllowlist == [ ]; + } // lib.optionalAttrs cfg.protectMemory { + # Sacrifice agent work first under memory pressure: the kernel OOM + # killer and earlyoom both weigh oom_score_adj, so a runaway agent + # process dies before sshd/caddy/SSM — and Restart=always brings + # the session back fresh instead of leaving a frozen box. + OOMScoreAdjust = lib.mkDefault 500; + }; + } + ) cfg.users; + + systemd.tmpfiles.rules = lib.mkIf cfg.manageTokenDir [ + # The dir itself is only ever traversed by root (systemd reads + # EnvironmentFile= as root, before the service process starts as the + # agent user), so 0700 is enough. Was 0755, which leaked filenames + # (= usernames) to anyone on the box. + "d ${cfg.tokenDir} 0700 root root - -" + # Also enforce 0600 on any existing *.env files so a hand-created file + # with lax perms gets corrected on next tmpfiles run. + "Z ${cfg.tokenDir}/*.env 0600 root root - -" + ]; + + # One-shot migration for boxes crossing the claude-box -> agent-box + # rename (issue 70): live state — the web password hash + cookie + # secrets, per-user caddy snippet dirs, the settings page's env + + # sessions.json, dropped-in token files, and the self-update AGENT + # pin — moves from the old-name paths exactly once. No-op on fresh + # boxes and after migration. Runs before switch-to-configuration + # applies tmpfiles/units, but tolerate a pre-created empty target dir + # anyway (rmdir only succeeds when empty, so real state never loses). + system.activationScripts.agent-box-rename-migration = lib.stringAfter [ "users" "groups" ] ('' + _abox_migrate() { + [ -e "$1" ] || return 0 + [ ! -d "$2" ] || rmdir "$2" 2>/dev/null || true + [ -e "$2" ] || mv -T "$1" "$2" + } + _abox_migrate /var/lib/claude-box-web /var/lib/agent-box-web + _abox_migrate /var/lib/claude-box-sites /var/lib/agent-box-sites + _abox_migrate /etc/claude-box ${lib.escapeShellArg cfg.tokenDir} + '' + + lib.optionalString cfg.selfUpdate.enable '' + # The MODULE pin is deliberately NOT migrated: an old pin file holds a + # pre-rename rev, where modules/agent-box.nix does not exist — carrying + # it over would 404 the next rebuild's fetchurl. A host config crossing + # the rename must bake a fresh post-rename starting pin; the stale + # claude-box-pin.nix stays behind as a dead file. The AGENT pin (a + # nixos-unstable snapshot url+sha) is rename-agnostic and safe to keep. + _abox_migrate /etc/nixos/claude-box-agent-pin.nix ${lib.escapeShellArg cfg.selfUpdate.agentPinFile} + '' + + lib.concatMapStrings (name: '' + _abox_migrate /home/${name}/.config/claude-box /home/${name}/.config/agent-box + '') (lib.attrNames cfg.users)); + + security.sudo.extraRules = lib.mkIf (effectiveSudoAllowlist != [ ]) [{ + users = lib.attrNames cfg.users; + # NOPASSWD only — no SETENV. SETENV lets the caller alter env vars + # visible to the sudo'd command, which broadens the surface for no + # gain given the allowlist is meant to be tight and command-scoped. + commands = map (command: { inherit command; options = [ "NOPASSWD" ]; }) effectiveSudoAllowlist; + }]; + } (lib.mkIf cfg.protectMemory { + # Memory protection (issue 62). The incident that motivated this: a + # swapless 2 GB box under agent memory pressure never OOM-killed — + # reclaim kept "succeeding" by evicting clean page-cache pages + # (including running programs' own code), which immediately refaulted + # from disk (~80 GB/h of reads), and every userspace process froze for + # hours while EC2 status checks stayed green. Swap gives reclaim + # somewhere cheap to go; earlyoom kills the largest offender BEFORE + # the livelock; the agent units' OOMScoreAdjust (above) points both + # killers at agent work first. All mkDefault — hosts can tune. + zramSwap = { + enable = lib.mkDefault true; + algorithm = lib.mkDefault "zstd"; + # Cap on UNCOMPRESSED bytes stored; at zstd's typical ~3:1 a full + # device costs ~1/3 of RAM, so 100% is safe and doubles headroom. + memoryPercent = lib.mkDefault 100; + }; + # Canonical zram tuning: swap early and eagerly (compressed swap is + # far cheaper than dropping hot page cache), and no readahead on swap + # faults (meaningless on a RAM-backed device). + boot.kernel.sysctl = { + "vm.swappiness" = lib.mkDefault 180; + "vm.page-cluster" = lib.mkDefault 0; + }; + services.earlyoom = { + enable = lib.mkDefault true; + # Kill the biggest process when free RAM and free swap both dip + # under the (default 10%) thresholds — i.e. act where the kernel + # OOM killer wouldn't, which is exactly the livelock window. + # Patterns match comm names, truncated by the kernel to 15 chars — + # hence "amazon-ssm-agen". Never pick the management plane or the + # tmux server (that would take every session down, not just the + # offender); prefer the agent CLI processes, which their unit's + # Restart=always respawns. + extraArgs = lib.mkDefault [ + "--avoid" "^(sshd|systemd|systemd-.*|caddy|ttyd|tmux.*|amazon-ssm-agen|ssm-.*|nix-daemon)$" + "--prefer" "^(node|claude|codex)$" + ]; + }; + # systemd-oomd overlaps earlyoom (two daemons racing to kill under + # pressure); keep exactly one, explicitly. + systemd.oomd.enable = lib.mkDefault false; + }) (lib.mkIf cfg.selfUpdate.enable { + # Agent-triggerable box update. The agents' only power here is the + # allowlisted `sudo systemctl start agent-box-update.service` (see + # updateStartCmd) — a trigger with no arguments, so everything below + # (source repo, pin file, rebuild) is fixed at build time and immutable + # in the store. Verifying releases against an offline signing key is + # tracked upstream (defangdevs/agent-box issue 46); until then this + # trusts the pinned repo as GitHub serves it. + systemd.services.agent-box-update = { + description = "Fast-forward agent-box to upstream HEAD and rebuild"; + # No wantedBy — on-demand only, via the agents' sudo rule (or root). + path = [ pkgs.curl pkgs.jq pkgs.openssl pkgs.coreutils pkgs.util-linux pkgs.nix ]; + environment = { + REPO = cfg.selfUpdate.repo; + CURRENT_REV = cfg.selfUpdate.rev; + PIN_FILE = cfg.selfUpdate.pinFile; + AGENT_PIN_FILE = cfg.selfUpdate.agentPinFile; + # nixos-rebuild resolves via NIX_PATH, which systemd units + # don't inherit; point it at root's channel (the NixOS AMI default). + NIX_PATH = "nixpkgs=/nix/var/nix/profiles/per-user/root/channels/nixos:nixos-config=/etc/nixos/configuration.nix"; + }; + serviceConfig.Type = "oneshot"; + script = '' + set -euo pipefail + api() { curl -fsSL -H 'Accept: application/vnd.github+json' "$1"; } + + # --- what would change? ----------------------------------------- + target="$(api "https://api.github.com/repos/$REPO/commits/HEAD" | jq -r .sha)" + update_module=1 + if [ "$target" = "$CURRENT_REV" ]; then + update_module=0 + else + # Fast-forward only: a target that isn't strictly ahead of the + # running rev means upstream history was rewritten or an older + # (possibly vulnerable) rev is being replayed — refuse both. + status="$(api "https://api.github.com/repos/$REPO/compare/$CURRENT_REV...$target" | jq -r .status)" + if [ "$status" != "ahead" ]; then + echo "refusing update: $target is '$status' of running rev $CURRENT_REV (need fast-forward)" >&2 + exit 1 + fi + fi + + # Agent CLI pin: latest nixos-unstable channel release. + # channels.nixos.org redirects to the immutable releases.nixos.org + # URL — pin that, so the pin file stays reproducible. + release="$(curl -fsSLo /dev/null -w '%{url_effective}' https://channels.nixos.org/nixos-unstable)" + tarball="''${release%/}/nixexprs.tar.xz" + update_agent=1 + if [ -e "$AGENT_PIN_FILE" ] && grep -qF "$tarball" "$AGENT_PIN_FILE"; then + update_agent=0 + fi + + if [ "$update_module" = 0 ] && [ "$update_agent" = 0 ]; then + echo "already current: module at $target, agent nixpkgs at $release" + exit 0 + fi + + # --- write the pins (with backups, so failure rolls back exactly + # what this run changed) ------------------------------------------ + if [ "$update_module" = 1 ]; then + module="$(mktemp)" + trap 'rm -f "$module"' EXIT + curl -fsSL "https://raw.githubusercontent.com/$REPO/$target/modules/agent-box.nix" -o "$module" + sha="sha256-$(openssl dgst -sha256 -binary "$module" | base64)" + if [ -e "$PIN_FILE" ]; then + cp "$PIN_FILE" "$PIN_FILE.prev" + fi + printf '{ rev = "%s"; sha256 = "%s"; }\n' "$target" "$sha" > "$PIN_FILE.tmp" + mv "$PIN_FILE.tmp" "$PIN_FILE" + fi + + if [ "$update_agent" = 1 ]; then + # nix-prefetch-url --unpack both hashes the tarball and pre-warms + # the store path fetchTarball wants, so the rebuild that follows + # doesn't download it a second time. + agent_sha="$(nix-prefetch-url --unpack "$tarball")" + if [ -e "$AGENT_PIN_FILE" ]; then + cp "$AGENT_PIN_FILE" "$AGENT_PIN_FILE.prev" + fi + printf '{ url = "%s"; sha256 = "%s"; }\n' "$tarball" "$agent_sha" > "$AGENT_PIN_FILE.tmp" + mv "$AGENT_PIN_FILE.tmp" "$AGENT_PIN_FILE" + fi + + wall "agent-box: updating (module: $REPO@$target, agent nixpkgs: $release) — agent sessions will restart if their services changed." || true + if /run/current-system/sw/bin/nixos-rebuild switch; then + wall "agent-box: update to $target applied." || true + else + # Roll back exactly the pins this run touched so the next trigger + # retries cleanly instead of believing the failed state is current. + if [ "$update_module" = 1 ]; then + if [ -e "$PIN_FILE.prev" ]; then + mv "$PIN_FILE.prev" "$PIN_FILE" + else + rm -f "$PIN_FILE" + fi + fi + if [ "$update_agent" = 1 ]; then + if [ -e "$AGENT_PIN_FILE.prev" ]; then + mv "$AGENT_PIN_FILE.prev" "$AGENT_PIN_FILE" + else + rm -f "$AGENT_PIN_FILE" + fi + fi + wall "agent-box: update to $target FAILED — pins rolled back, system unchanged. See: journalctl -u agent-box-update" || true + exit 1 + fi + ''; + }; + }) (lib.mkIf (cfg.enable && cfg.web.enable) ( + let + webUser = cfg.web.user; + # Users that get a browser terminal, in sorted order (attrNames sorts) — + # port assignment below depends on that order being deterministic. + terminalUsers = lib.filter (n: cfg.users.${n}.web.passwordHashFile != null) (lib.attrNames cfg.users); + # Whose terminal workspace the vhost ROOT serves (the / page): web.user + # if it has a terminal, else the first terminal user. Null only when no + # user has a terminal at all (then the vhost serves nothing anyway). + rootUser = + if lib.elem webUser terminalUsers then webUser + else if terminalUsers != [ ] then lib.head terminalUsers + else null; + portOf = lib.listToAttrs (lib.imap0 (i: n: lib.nameValuePair n (ttydPortBase + i)) terminalUsers); + # Public URL base path for a user's settings page (Caddy does not strip + # a prefix, so the daemon matches this full path). + settingsBaseOf = n: "/${n}/settings"; + hashFileOf = n: toString cfg.users.${n}.web.passwordHashFile; + # Root-only password rotator for one terminal user (issue #91). The + # settings daemon deliberately remains unprivileged, so it invokes this + # no-argument helper through a per-user sudo rule and sends the old/new + # passwords over stdin. User and file paths are compiled into the store + # script: callers cannot redirect the root write toward another user's + # hash (or any other path). + passwordHelperOf = name: + pkgs.writers.writePython3Bin "agent-box-password-${name}" { + libraries = [ + pkgs.python3Packages.argon2-cffi + pkgs.python3Packages.bcrypt + ]; + flakeIgnore = [ "E501" "E302" "E305" ]; + } '' + import argon2 + import bcrypt + import fcntl + import json + import os + import secrets + import subprocess + import sys + import tempfile + + HASH_FILE = ${builtins.toJSON (hashFileOf name)} + COOKIE_FILE = ${builtins.toJSON "/var/lib/agent-box-web/cookie-secret-${name}"} + AUTH_ENV_FILE = "/run/agent-box-web/env" + AUTH_ENV_LOCK = "/run/agent-box-web/password-change.lock" + ENV_SUFFIX = ${builtins.toJSON (envName name)} + CADDY = ${builtins.toJSON "${pkgs.caddy}/bin/caddy"} + SYSTEMCTL = "/run/current-system/sw/bin/systemctl" + NEW_HASH_ALGORITHM = "argon2id" + + + def fail(code, message): + # Never include supplied passwords in output. The daemon logs only + # the return code, but this also keeps direct invocations safe. + sys.stderr.write("agent-box password helper: " + message + "\n") + raise SystemExit(code) + + + def atomic_replace(path, data): + """Atomically replace a root secret, preserving owner and mode.""" + directory = os.path.dirname(path) + try: + st = os.stat(path) + uid, gid, mode = st.st_uid, st.st_gid, st.st_mode & 0o777 + except FileNotFoundError: + uid, gid, mode = 0, 0, 0o600 + fd, tmp = tempfile.mkstemp(dir=directory, prefix=".password.") + try: + os.fchmod(fd, mode) + os.fchown(fd, uid, gid) + with os.fdopen(fd, "wb") as fh: + fh.write(data) + fh.flush() + os.fsync(fh.fileno()) + os.replace(tmp, path) + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise + + + def valid_password(password): + return 16 <= len(password) <= 64 and not any( + char in password for char in "\r\n" + ) + + + def password_matches(password, encoded): + """Verify both legacy bcrypt and current Argon2id hashes.""" + if encoded.startswith(b"$argon2id$"): + try: + return argon2.PasswordHasher().verify( + encoded.decode("ascii"), password + ) + except argon2.exceptions.VerifyMismatchError: + return False + except ( + argon2.exceptions.InvalidHashError, + argon2.exceptions.VerificationError, + UnicodeError, + ): + fail(5, "could not validate current password") + if encoded.startswith((b"$2a$", b"$2b$", b"$2y$")): + try: + return bcrypt.checkpw(password.encode("utf-8"), encoded) + except (UnicodeError, ValueError): + fail(5, "could not validate current password") + fail(5, "unsupported current password hash") + + + def hash_password(password): + """Use Caddy's recommended algorithm and parameter defaults.""" + try: + proc = subprocess.run( + [CADDY, "hash-password", "--algorithm", NEW_HASH_ALGORITHM], + input=(password + "\n").encode("utf-8"), + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) + encoded = proc.stdout.decode("ascii").strip() + except (OSError, UnicodeError, subprocess.CalledProcessError): + fail(5, "could not hash new password") + if not encoded.startswith("$argon2id$"): + fail(5, "password hash used an unexpected algorithm") + return encoded + + + def update_auth_env(new_hash, new_cookie): + """Replace this user's values in Caddy's runtime env.""" + replacements = { + "WEB_PASSWORD_ALGORITHM_" + ENV_SUFFIX: NEW_HASH_ALGORITHM, + "WEB_PASSWORD_HASH_" + ENV_SUFFIX: new_hash, + "WEB_COOKIE_SECRET_" + ENV_SUFFIX: new_cookie, + } + try: + with open(AUTH_ENV_FILE, "r", encoding="utf-8") as fh: + lines = fh.read().splitlines() + except OSError: + fail(5, "could not read web authentication environment") + output = [] + seen = set() + for line in lines: + key = line.split("=", 1)[0] + if key in replacements: + output.append(key + "=" + replacements[key]) + seen.add(key) + else: + output.append(line) + for key in sorted(set(replacements) - seen): + output.append(key + "=" + replacements[key]) + atomic_replace(AUTH_ENV_FILE, ("\n".join(output) + "\n").encode("utf-8")) + + + def main(): + try: + request = json.load(sys.stdin) + except (UnicodeError, ValueError): + fail(4, "invalid request") + if not isinstance(request, dict) or set(request) != {"previous", "new"}: + fail(4, "invalid request") + previous = request["previous"] + new = request["new"] + if not isinstance(previous, str) or not isinstance(new, str): + fail(4, "invalid request") + if not valid_password(new) or new == previous: + fail(4, "invalid new password") + + try: + with open(HASH_FILE, "rb") as fh: + current_hash = fh.read().strip() + matches = password_matches(previous, current_hash) + except OSError: + fail(5, "could not validate current password") + if not matches: + fail(2, "current password is incorrect") + + new_hash = hash_password(new) + new_cookie = secrets.token_hex(32) + + # Serialize password changes across users while updating the two + # persistent secrets and their root-owned runtime projection. + # Cookie auth bypasses basic auth after the first login, so rotating + # the cookie secret signs out every browser. + try: + lock_fd = os.open(AUTH_ENV_LOCK, os.O_CREAT | os.O_RDWR, 0o600) + with os.fdopen(lock_fd, "w") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + atomic_replace(HASH_FILE, (new_hash + "\n").encode("ascii")) + atomic_replace(COOKIE_FILE, (new_cookie + "\n").encode("ascii")) + update_auth_env(new_hash, new_cookie) + + # systemd re-reads EnvironmentFile for ExecReload, so the + # running listener adopts the new credentials without a + # restart or dropping the in-flight settings response. + subprocess.run( + [SYSTEMCTL, "reload", "caddy.service"], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except (OSError, subprocess.CalledProcessError): + fail(5, "password saved but web authentication reload failed") + + + if __name__ == "__main__": + main() + ''; + passwordHelperCmdOf = name: + "${passwordHelperOf name}/bin/agent-box-password-${name}"; + # The settings daemon script (issue #36). Python-3-stdlib only — no + # third-party deps — so it stays tiny and auditable. Runs as the agent + # user; writes ~/.config/agent-box/env (0600) and restarts the agent by + # killing its tmux session. Full rationale in the script header below. + # + # INLINE ON PURPOSE (issue #51): deployed boxes fetch this module as a + # SINGLE file (see the contract at the top of this file), so the script + # cannot live in a ./sibling. If you edit it, keep it free of the two + # Nix indented-string specials (two consecutive single-quotes, and + # dollar-brace) or escape them per the Nix manual. + settingsDaemon = pkgs.writers.writePython3Bin "agent-box-settings" { + # No external libraries; skip flake8 style gate (the script is + # formatted for readability, not lint-perfection) but keep syntax + # checking that writePython3Bin does by compiling. + flakeIgnore = [ "E501" "E302" "E305" "W503" "E226" ]; + } '' + @@include:src/settings-daemon.py@@ + ''; + # Per-connection tmux attach for ttyd (issue #59). ttyd runs with + # --url-arg, so //?arg= passes as $1 — ONE + # ttyd per user serves every session, including runtime-created ones. + # Strict allowlist on the client-supplied argument: session-name + # charset AND an existing tmux session; anything else prints the live + # list and exits (ttyd spawns a fresh instance per connection). + # ttyd/xterm supports OSC 8, but xterm-256color cannot advertise that + # through terminfo. Tell tmux explicitly so it forwards stored + # hyperlinks instead of redrawing only their visible labels. + attachScript = name: pkgs.writeShellScript "agent-box-${name}-attach" '' + set -u + T="${pkgs.tmux}/bin/tmux -T hyperlinks -L ${tmuxSocketName}" + want="''${1:-}" + case "$want" in (*[!A-Za-z0-9_-]*) want="" ;; esac + if [ -n "$want" ]; then + if $T has-session -t "=$want" 2>/dev/null; then + exec $T attach -t "=$want" + fi + echo "no session named '$want'. Live sessions:" + $T list-sessions -F ' #S' 2>/dev/null || echo " (none)" + echo "create it with: agent-box-session add $want" + sleep 5 + exit 1 + fi + # No session requested: prefer "main", else the first live session. + if $T has-session -t "=main" 2>/dev/null; then + exec $T attach -t "=main" + fi + first="$($T list-sessions -F '#S' 2>/dev/null | ${pkgs.coreutils}/bin/head -n 1)" + if [ -n "$first" ]; then + exec $T attach -t "=$first" + fi + echo "no live sessions yet — the supervisor may still be starting them." + sleep 5 + exit 1 + ''; + # Per-user env var suffix for the Caddyfile placeholders; linux user + # names may contain chars that are invalid in env var names. + envName = n: lib.toUpper (lib.stringAsChars (c: if builtins.match "[a-zA-Z0-9]" c != null then c else "_") n); + + # Prefix every non-blank line — Nix indented strings strip the common + # leading whitespace, so composed fragments need explicit re-indenting. + indent = prefix: text: lib.concatMapStrings + (line: if line == "" then "\n" else prefix + line + "\n") + (lib.init (lib.splitString "\n" text)); + + terminalCaddyBlock = name: '' + # ${name}'s terminal. Cookie first — browsers refuse to attach basic + # auth credentials to WebSocket upgrades — then basic auth with the + # linux user name as the login name. + redir /${name} /${name}/ + # ${name}'s settings page (issue #36). Same auth surface as the + # terminal (cookie-or-basic-auth, same user name), just a different + # upstream — the settings daemon over its user+caddy-only unix socket + # (issue #49). More specific than /${name}/* below, so Caddy routes + # it here first. + handle /${name}/settings* { + @cookie_settings_${name} header_regexp Cookie "(^|; )__Host-agent_box_auth_${name}={$WEB_COOKIE_SECRET_${envName name}}(;|$)" + handle @cookie_settings_${name} { + reverse_proxy unix/${settingsSocketOf name} + } + handle { + route { + basic_auth {$WEB_PASSWORD_ALGORITHM_${envName name}} ${name} { + ${name} {$WEB_PASSWORD_HASH_${envName name}} + } + header >Set-Cookie "__Host-agent_box_auth_${name}={$WEB_COOKIE_SECRET_${envName name}}; Path=/; Max-Age=2592000; HttpOnly; Secure; SameSite=Strict" + reverse_proxy unix/${settingsSocketOf name} + } + } + } + # ${name}'s file drop (issue #132): a browsable static index of + # ~/downloads, served from the caddy-readable /var/lib backing dir + # (caddy.service can't read /home). Same cookie-or-basic auth as the + # terminal, and MORE specific than /${name}/* below so Caddy routes it + # here first. The agent hands the user a ${name}/downloads/ URL. + redir /${name}/downloads /${name}/downloads/ + handle /${name}/downloads/* { + @cookie_dl_${name} header_regexp Cookie "(^|; )__Host-agent_box_auth_${name}={$WEB_COOKIE_SECRET_${envName name}}(;|$)" + handle @cookie_dl_${name} { + uri strip_prefix /${name}/downloads + root * ${downloadsDirOf name} + file_server browse + } + handle { + route { + basic_auth {$WEB_PASSWORD_ALGORITHM_${envName name}} ${name} { + ${name} {$WEB_PASSWORD_HASH_${envName name}} + } + header >Set-Cookie "__Host-agent_box_auth_${name}={$WEB_COOKIE_SECRET_${envName name}}; Path=/; Max-Age=2592000; HttpOnly; Secure; SameSite=Strict" + uri strip_prefix /${name}/downloads + root * ${downloadsDirOf name} + file_server browse + } + } + } + handle /${name}/* { + @cookie_${name} header_regexp Cookie "(^|; )__Host-agent_box_auth_${name}={$WEB_COOKIE_SECRET_${envName name}}(;|$)" + handle @cookie_${name} { + reverse_proxy 127.0.0.1:${toString portOf.${name}} + } + handle { + route { + basic_auth {$WEB_PASSWORD_ALGORITHM_${envName name}} ${name} { + ${name} {$WEB_PASSWORD_HASH_${envName name}} + } + header >Set-Cookie "__Host-agent_box_auth_${name}={$WEB_COOKIE_SECRET_${envName name}}; Path=/; Max-Age=2592000; HttpOnly; Secure; SameSite=Strict" + reverse_proxy 127.0.0.1:${toString portOf.${name}} + } + } + } + ''; + + rootBlock = name: '' + # Anything else, including /: ${name}'s tabbed terminal workspace + # (one tab per session, panes iframing /${name}/?arg=; plus + # the /sessions/* CRUD routes), served by the settings daemon behind + # the SAME cookie-or-basic auth as the terminal — session CRUD must + # never be reachable unauthenticated. + # This replaces the old unauthenticated picker (single-user boxes are + # the norm now); with it gone — and the public sessions.json it fed + # removed — nothing on this vhost is served without auth. Other + # users' terminals stay at // as before. No userinfo in any + # href (issue 56): Chrome answers the basic-auth challenge with URL + # userinfo + an EMPTY password, and credentials typed into the + # prompt cannot override the URL-embedded identity. + handle { + @cookie_root header_regexp Cookie "(^|; )__Host-agent_box_auth_${name}={$WEB_COOKIE_SECRET_${envName name}}(;|$)" + handle @cookie_root { + reverse_proxy unix/${settingsSocketOf name} + } + handle { + route { + basic_auth {$WEB_PASSWORD_ALGORITHM_${envName name}} ${name} { + ${name} {$WEB_PASSWORD_HASH_${envName name}} + } + header >Set-Cookie "__Host-agent_box_auth_${name}={$WEB_COOKIE_SECRET_${envName name}}; Path=/; Max-Age=2592000; HttpOnly; Secure; SameSite=Strict" + reverse_proxy unix/${settingsSocketOf name} + } + } + } + ''; + + # Rendered Caddyfile. Module-managed (regenerated every rebuild) — safe + # to keep in the world-readable Nix store because it only holds + # {$ENV} placeholders, never secrets. + # + # Self-serve extension point: the trailing per-user `import` lines + # (one per agent user, since the Caddyfile `import` directive rejects + # multi-wildcard globs like `*/*.caddy`) pick up snippet files. Each + # agent user has a caddy-readable directory at + # /var/lib/agent-box-sites// symlinked from ~/sites, so the agent + # can add a virtual host by writing ~/sites/.caddy and + # running `sudo systemctl reload caddy.service`. No nixos-rebuild + # needed. Snippets should REVERSE-PROXY to a localhost port rather than + # serve files from $HOME — caddy.service runs with ProtectHome=true and + # can't read /home. See the comment block at the top of the rendered + # file below (agents will read that from the running box). + managedCaddyfile = pkgs.writeText "agent-box-caddyfile" ('' + # This file is module-managed by services.agent-box — edits here get + # OVERWRITTEN on the next nixos-rebuild. To add your own virtual host, + # drop a *.caddy snippet into ~/sites/ (which is a symlink into + # /var/lib/agent-box-sites//, a caddy-readable location) and + # reload with: sudo systemctl reload caddy.service + # + # Recommended snippet shape — reverse-proxy to a localhost port your + # agent runs, NOT `file_server /home//...`. caddy.service has + # ProtectHome=true, so it cannot read files under /home; use file_server + # only against a path outside /home (e.g. /var/lib/agent-box-sites//public): + # + # foo.example.com { + # import acme_alpn_only # Let's Encrypt via TLS-ALPN-01 + # reverse_proxy 127.0.0.1:3000 + # } + # + # New hosts get a Let's Encrypt cert on first request as long as DNS + # for that hostname points at this box. + + (acme_alpn_only) { + tls { + issuer acme { + disable_http_challenge + } + } + } + + ${cfg.web.domain} { + # Access log to the journal — the fail2ban jail counts 401s here. + log + import acme_alpn_only + header { + Cache-Control "no-store" + X-Content-Type-Options "nosniff" + } + + '' + + lib.concatMapStringsSep "\n" (name: indent " " (terminalCaddyBlock name)) terminalUsers + + "\n" + + lib.optionalString (rootUser != null) (indent " " (rootBlock rootUser)) + + "}\n\n" + + '' + # Per-user snippet directories. Each agent user's ~/sites/ symlinks + # here. Adding a file below and running `sudo systemctl reload + # caddy.service` is the whole workflow — no nixos-rebuild required. + # One import per user: Caddyfile's `import` directive only accepts a + # single `*` per pattern, so we can't collapse this to `*/*.caddy`. + '' + + lib.concatMapStringsSep "" (name: "import /var/lib/agent-box-sites/${name}/*.caddy\n") (lib.attrNames cfg.users)); + + # Reads each terminal user's (already-hashed) password from their + # passwordHashFile, mints a persistent per-user cookie secret if + # absent, and writes everything into /run/agent-box-web/env for Caddy + # to consume as environment variables (WEB_PASSWORD_ALGORITHM_ / + # WEB_PASSWORD_HASH_ / WEB_COOKIE_SECRET_). Runs BEFORE + # caddy every boot. + webAuthSecretsService = { + description = "Prepare browser terminal auth env for Caddy"; + before = [ "caddy.service" ]; + requiredBy = [ "caddy.service" ]; + path = [ pkgs.coreutils pkgs.openssl ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + script = '' + set -euo pipefail + umask 077 + tmp="$(mktemp /run/agent-box-web/env.XXXXXX)" + '' + lib.concatMapStrings (name: '' + if [ ! -s /var/lib/agent-box-web/cookie-secret-${name} ]; then + openssl rand -hex 32 > /var/lib/agent-box-web/cookie-secret-${name} + fi + hash="$(cat ${lib.escapeShellArg (hashFileOf name)})" + case "$hash" in + '$argon2id$'*) algorithm=argon2id ;; + '$2a$'*|'$2b$'*|'$2y$'*) algorithm=bcrypt ;; + *) echo "unsupported password hash for ${name}" >&2; exit 1 ;; + esac + { + printf 'WEB_COOKIE_SECRET_${envName name}=%s\n' "$(cat /var/lib/agent-box-web/cookie-secret-${name})" + printf 'WEB_PASSWORD_ALGORITHM_${envName name}=%s\n' "$algorithm" + printf 'WEB_PASSWORD_HASH_${envName name}=%s\n' "$hash" + } >> "$tmp" + '') terminalUsers + '' + chmod 0600 "$tmp" + mv "$tmp" /run/agent-box-web/env + ''; + }; + in + { + assertions = [ + { + assertion = cfg.users ? ${webUser}; + message = + "services.agent-box.web.user = \"${webUser}\" but that user " + + "isn't defined in services.agent-box.users."; + } + { + assertion = terminalUsers != [ ]; + message = + "services.agent-box.web.enable is true but no user has " + + "web.passwordHashFile set, so no terminal would be served."; + } + { + assertion = lib.length (lib.unique (map envName terminalUsers)) == lib.length terminalUsers; + message = + "services.agent-box: web-terminal user names must stay distinct " + + "after sanitizing to env-var form ([A-Z0-9_])."; + } + ]; + + # The top-level Caddyfile is module-managed (see managedCaddyfile above); + # each agent user's own virtual hosts live in per-user snippet files at + # /var/lib/agent-box-sites//*.caddy, symlinked into their $HOME + # as ~/sites/. Reload via the sudo rule added to effectiveSudoAllowlist. + + networking.firewall.allowedTCPPorts = [ 443 ]; + + systemd.tmpfiles.rules = [ + "d /var/lib/agent-box-web 0700 root root - -" + "d /run/agent-box-web 0700 root root - -" + # Snippet dirs: parent is world-traversable so caddy (primary group + # `caddy`) can reach the per-user subdirectories, which are 0750 + # :caddy — the user writes, caddy reads, other agent users on + # the box can't peek. Kept OUTSIDE /var/lib/agent-box-web (0700) so + # caddy's `import` can traverse without loosening the secrets dir. + "d /var/lib/agent-box-sites 0755 root root - -" + # File-drop dirs (issue #132), same layout/permissions rationale as the + # snippet dirs above: parent world-traversable so caddy can reach the + # per-user 0750 :caddy subdirs it serves at //downloads/. + "d /var/lib/agent-box-downloads 0755 root root - -" + # Settings daemon sockets live here (issue #49). World-traversable is + # fine: the per-user socket files themselves are 0660 :caddy + # (created by systemd, see systemd.sockets below), and connecting + # requires write permission on the socket file. + "d ${settingsSocketDir} 0755 root root - -" + ] ++ lib.concatMap (name: [ + "d /var/lib/agent-box-sites/${name} 0750 ${name} caddy - -" + # ~/sites -> the caddy-readable snippet dir. L+ replaces a stale + # symlink/file if the target differs from ours (idempotent across + # renames). Users edit through this link and never touch /var/lib. + "L+ /home/${name}/sites - - - - /var/lib/agent-box-sites/${name}" + # ~/downloads -> the caddy-readable file-drop dir served at + # //downloads/ (issue #132). Same L+/symlink rationale as ~/sites. + "d ${downloadsDirOf name} 0750 ${name} caddy - -" + "L+ /home/${name}/downloads - - - - ${downloadsDirOf name}" + ]) (lib.attrNames cfg.users) + # The settings page's env dir, per terminal user. User-owned 0700 so + # only the agent user (and root) can read it; the settings daemon runs + # as that user and writes env (0600) inside. Created here so the daemon + # and the agent unit's optional EnvironmentFile both have a stable path + # even before the user saves any key. + ++ lib.map (name: + "d /home/${name}/.config/agent-box 0700 ${name} ${name} - -" + ) terminalUsers; + + services.caddy = { + enable = true; + # Module-managed. Store path is world-readable but holds only ENV + # placeholders, no secrets. Per-user extensions land via the trailing + # `import /var/lib/agent-box-sites/*/*.caddy`. + configFile = managedCaddyfile; + }; + + # The settings daemon for each terminal user may invoke only that user's + # argument-free password helper as root. Keeping these as separate rules + # (instead of the global agent sudoAllowlist) prevents one agent user from + # rotating another user's web credentials on a multi-user box. + security.sudo.extraRules = map (name: { + users = [ name ]; + commands = [{ + # In sudoers, a literal empty argument string means the command may + # only be run with no arguments (omitting it would allow any argv). + command = "${passwordHelperCmdOf name} \"\""; + options = [ "NOPASSWD" ]; + }]; + }) terminalUsers; + + # Brute-force protection: count 401s on the terminal vhost that carried + # an Authorization header (Caddy logs it as ["REDACTED"] when present), + # i.e. actual wrong-password attempts — a browser's credential-less + # first request also 401s but is not counted. + services.fail2ban = lib.mkIf cfg.web.fail2ban { + enable = true; + jails.agent-web-auth = { + filter.Definition = { + failregex = ''^.*"logger":"http\.log\.access".*"client_ip":"".*"Authorization":\["REDACTED"\].*"status":401''; + journalmatch = "_SYSTEMD_UNIT=caddy.service"; + }; + settings = { + backend = "systemd"; + port = "http,https"; + maxretry = 5; + findtime = "10m"; + bantime = "1h"; + }; + }; + }; + + # ttyd per terminal user — attaches to that user's tmux session over an + # internal port; --base-path keeps each terminal (and its /ws endpoint) + # under // so one vhost can serve them all. + # TMUX_TMPDIR must match the agent unit's RuntimeDirectory (the agent + # runs with PrivateTmp, so the socket lives in /run, not /tmp). + systemd.services = { + agent-web-auth-secrets = webAuthSecretsService; + caddy.serviceConfig.EnvironmentFile = "/run/agent-box-web/env"; + } // lib.listToAttrs (map (name: lib.nameValuePair "agent-web-terminal-${name}" { + description = "Browser terminal (ttyd) attached to ${name}'s tmux"; + after = [ "agent-box-${name}.service" "network-online.target" ]; + wants = [ "network-online.target" ]; + wantedBy = [ "multi-user.target" ]; + environment.TMUX_TMPDIR = "/run/${runtimeDirectory name}"; + serviceConfig = { + User = name; + Restart = "always"; + RestartSec = "5s"; + ExecStart = lib.concatStringsSep " " [ + "${pkgs.ttyd}/bin/ttyd" + "--writable" + # ?arg= in the URL becomes $1 of the attach wrapper — + # session-level deep links from the root sessions page (issue #59). + "--url-arg" + "-p" (toString portOf.${name}) + "-i" "127.0.0.1" + "-b" "/${name}" + "-t" "disableLeaveAlert=true" + "-t" "titleFixed=${name}@${cfg.web.domain}" + (toString (attachScript name)) + ]; + }; + }) terminalUsers) + # Settings daemon (issue #36), one per terminal user. Runs AS the agent + # user (no root, no privilege boundary): it only writes that user's own + # ~/.config/agent-box/env and kills that user's own tmux session. The + # agent unit's Restart=always then reloads it with the fresh env. + # Listens via socket activation on the systemd-owned unix socket + # (issue #49) — the same-named .socket unit below; requires/after kept + # explicit per this repo's explicit-over-implied-config convention. + // lib.listToAttrs (map (name: lib.nameValuePair "agent-box-settings-${name}" { + description = "Per-user secrets settings page for ${name}"; + after = [ "network-online.target" "agent-box-settings-${name}.socket" ]; + requires = [ "agent-box-settings-${name}.socket" ]; + wants = [ "network-online.target" ]; + wantedBy = [ "multi-user.target" ]; + # TMUX_TMPDIR must match the agent unit's RuntimeDirectory so the + # daemon can reach the (PrivateTmp) tmux socket to restart the agent. + environment = { + TMUX_TMPDIR = "/run/${runtimeDirectory name}"; + AGENT_BOX_SETTINGS_USER = name; + AGENT_BOX_SETTINGS_ENV_FILE = userEnvFile name; + AGENT_BOX_SETTINGS_BASE = settingsBaseOf name; + AGENT_BOX_TMUX_SOCKET = tmuxSocketName; + AGENT_BOX_TMUX_TMPDIR = "/run/${runtimeDirectory name}"; + AGENT_BOX_TMUX_BIN = "${pkgs.tmux}/bin/tmux"; + AGENT_BOX_SESSIONS_FILE = userSessionsFile name; + AGENT_BOX_AGENTS = lib.concatStringsSep "," (sessionKinds cfg.installAgents); + AGENT_BOX_DEFAULT_AGENT = cfg.agent; + AGENT_BOX_PASSWORD_CMD = + "/run/wrappers/bin/sudo -n ${passwordHelperCmdOf name}"; + } // lib.optionalAttrs (name == rootUser) { + # This daemon also serves the vhost root: GET / is the session + # manager (Caddy proxies it here behind the user's auth) and the + # session CRUD routes move to /sessions/*. + AGENT_BOX_HOME = "1"; + } // lib.optionalAttrs cfg.selfUpdate.enable { + # --no-block so the daemon's HTTP response goes out before the + # rebuild (possibly) restarts the daemon itself. + AGENT_BOX_UPDATE_CMD = "/run/wrappers/bin/sudo -n ${updateStartNoBlockCmd}"; + # Running rev + repo, rendered on the Update card as a GitHub + # commit link and used for its non-blocking compare request. + AGENT_BOX_REPO = cfg.selfUpdate.repo; + AGENT_BOX_REV = cfg.selfUpdate.rev; + # Read-only handles for the {BASE}/status progress endpoint the page + # long-polls after "Update box": the update oneshot to watch and an + # unprivileged `systemctl show` binary to read its state with (no + # sudo — unlike AGENT_BOX_UPDATE_CMD, which triggers the run). Only + # wired when selfUpdate is on, so the status endpoint omits the update + # block otherwise. + AGENT_BOX_UPDATE_UNIT = "agent-box-update.service"; + AGENT_BOX_SYSTEMCTL = "/run/current-system/sw/bin/systemctl"; + }; + serviceConfig = { + User = name; + Restart = "always"; + RestartSec = "5s"; + ExecStart = "${settingsDaemon}/bin/agent-box-settings"; + # Hardening: the daemon itself remains unable to read the root-owned + # web secrets. /var/lib/agent-box-web is writable in the mount + # namespace only so the sudo'd password helper can atomically replace + # this user's hash and cookie; normal Unix permissions still deny the + # unprivileged daemon. + ProtectSystem = "strict"; + ReadWritePaths = [ + "/home/${name}" + "/run/${runtimeDirectory name}" + "/var/lib/agent-box-web" + ]; + ProtectHome = false; + PrivateDevices = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + RestrictSUIDSGID = true; + RestrictRealtime = true; + LockPersonality = true; + # Setuid sudo needs privilege escalation, which NNP vetoes. The + # daemon always has its per-user, argument-free password helper; with + # selfUpdate enabled it also has the argument-free update trigger. + NoNewPrivileges = false; + }; + }) terminalUsers); + + # The settings daemon's listening sockets (issue #49). systemd (root) + # binds each unix socket with exact ownership BEFORE the daemon starts: + # 0660 :caddy means only that user and the caddy reverse-proxy + # can connect — unlike the previous 127.0.0.1: listener, which + # every local user could reach. The daemon adopts the socket through + # socket activation (LISTEN_FDS, fd 3). + systemd.sockets = lib.listToAttrs (map (name: lib.nameValuePair "agent-box-settings-${name}" { + description = "Settings page socket for ${name}"; + wantedBy = [ "sockets.target" ]; + socketConfig = { + ListenStream = settingsSocketOf name; + SocketUser = name; + SocketGroup = "caddy"; + SocketMode = "0660"; + }; + }) terminalUsers); + } + )) + + (lib.mkIf cfg.spotInterruption.enable { + # EC2 Spot interruption handler (issue #20). One root monitor polls + # IMDS; on a scheduled interruption it prompts every live agent session + # to persist its working context, then cleanly stops the agent units. + # See services.agent-box.spotInterruption for the full rationale (esp. + # why it does NOT stop/poweroff the box itself under a persistent Spot + # request — only AWS's interruption-stop is auto-restarted). + systemd.services.agent-box-spot-monitor = { + description = "EC2 Spot interruption handler for agent-box sessions"; + wantedBy = [ "multi-user.target" ]; + # IMDS is link-local (169.254.169.254), up before any network config, + # so no network-online ordering is needed. + serviceConfig = { + Type = "simple"; + # A clean exit (exit 0) means "handled" or "not a Spot box" — do NOT + # revive it. on-failure still recovers a crashed monitor before any + # interruption arrives. + Restart = "on-failure"; + RestartSec = "5s"; + ExecStart = pkgs.writeShellScript "agent-box-spot-monitor" '' + set -u + CURL="${pkgs.curl}/bin/curl -sS --max-time 3" + IMDS=http://169.254.169.254/latest + TMUX=${pkgs.tmux}/bin/tmux + JQ=${pkgs.jq}/bin/jq + RUNUSER=${pkgs.util-linux}/bin/runuser + SYSTEMCTL=${pkgs.systemd}/bin/systemctl + ENV=${pkgs.coreutils}/bin/env + SYNC=${pkgs.coreutils}/bin/sync + SOCK=${tmuxSocketName} + USERS=${lib.escapeShellArg (lib.concatStringsSep " " (lib.attrNames cfg.users))} + GRACE=${toString cfg.spotInterruption.gracePeriod} + POLL=${toString cfg.spotInterruption.pollInterval} + MSG=${lib.escapeShellArg cfg.spotInterruption.message} + + # Fresh IMDSv2 session token (v1 may be disabled). Prints nothing + # and returns non-zero if IMDS is unreachable. + imds_token() { + $CURL -X PUT "$IMDS/api/token" \ + -H "X-aws-ec2-metadata-token-ttl-seconds: 300" + } + imds_get() { # imds_get TOKEN PATH + $CURL -H "X-aws-ec2-metadata-token: $1" "$IMDS/$2" + } + imds_code() { # imds_code TOKEN PATH -> HTTP status only + $CURL -o /dev/null -w '%{http_code}' \ + -H "X-aws-ec2-metadata-token: $1" "$IMDS/$2" + } + + # Run tmux against a user's own control socket, AS that user. + user_tmux() { # user_tmux USER ARGS... + u=$1; shift + $RUNUSER -u "$u" -- \ + "$ENV" TMUX_TMPDIR="/run/agent-box-$u" "$TMUX" -L "$SOCK" "$@" + } + + notify_user() { + u=$1 + sf="/home/$u/.config/agent-box/sessions.json" + user_tmux "$u" list-sessions -F '#{session_name}' 2>/dev/null \ + | while IFS= read -r s; do + agent=$($JQ -r --arg s "$s" \ + '.sessions[$s].agent // ""' "$sf" 2>/dev/null) + # Target "=NAME:" — an EXACT session match resolved to its + # active pane. Bare "=NAME" is a session target that send-keys + # rejects as a pane ("can't find pane"); plain "NAME" would + # prefix-match (session "dev" could hit "devs"). + if [ "$agent" = shell ]; then + # A shell EXECUTES typed text as a command, so prefix '#' to + # make the notice a harmless no-op comment the operator still + # sees in scrollback. No Escape: there is no in-flight + # generation to interrupt in a shell, and Escape+'#' would + # trigger readline's meta bindings. + user_tmux "$u" send-keys -t "=$s:" -l -- "# $MSG" \ + && user_tmux "$u" send-keys -t "=$s:" Enter + continue + fi + # Agent session. A single Escape interrupts any in-flight + # generation/tool call so the notice is handled NOW instead of + # queued behind a long operation that may outlast the ~2 min + # window. Only one — a double Escape opens claude's history + # picker. Then a short settle before typing: sent too fast, the + # TUI (still returning to its prompt) swallows the first chars. + user_tmux "$u" send-keys -t "=$s:" Escape + sleep 1 + # -l types MSG literally; a separate Enter submits it. + user_tmux "$u" send-keys -t "=$s:" -l -- "$MSG" \ + && user_tmux "$u" send-keys -t "=$s:" Enter + done + } + + # Startup gate: only Spot instances can be interrupted. Exit 0 (no + # restart) when this is on-demand, or when IMDS is unreachable (a + # dev rig with the option left enabled). + tok=$(imds_token) || { echo "spot-monitor: IMDS unreachable; nothing to monitor" >&2; exit 0; } + [ -n "$tok" ] || { echo "spot-monitor: no IMDS token; nothing to monitor" >&2; exit 0; } + lifecycle=$(imds_get "$tok" meta-data/instance-life-cycle) + if [ "$lifecycle" != spot ]; then + echo "spot-monitor: instance-life-cycle=''${lifecycle:-unknown}, not spot; nothing to monitor" >&2 + exit 0 + fi + echo "spot-monitor: watching IMDS for interruption (poll ''${POLL}s, grace ''${GRACE}s)" >&2 + + while true; do + # Token TTL is 300s but re-minting each loop is cheap and robust + # to expiry; skip this round if IMDS momentarily hiccups. + tok=$(imds_token) && [ -n "$tok" ] || { sleep "$POLL"; continue; } + # 404 until an interruption is scheduled; 200 = committed (~2 min). + if [ "$(imds_code "$tok" meta-data/spot/instance-action)" = 200 ]; then + action=$(imds_get "$tok" meta-data/spot/instance-action) + echo "spot-monitor: interruption scheduled ($action) — notifying agents" >&2 + for u in $USERS; do notify_user "$u"; done + $SYNC + echo "spot-monitor: waiting ''${GRACE}s for agents to save context" >&2 + sleep "$GRACE" + echo "spot-monitor: stopping agent units (no respawn)" >&2 + for u in $USERS; do + $SYSTEMCTL stop "agent-box-$u.service" || true + done + $SYNC + echo "spot-monitor: prepared; leaving the stop to AWS (preserves persistent-Spot auto-restart)" >&2 + exit 0 + fi + sleep "$POLL" + done + ''; + }; + }; + })]); +} diff --git a/modules/src/settings-daemon.py b/modules/src/settings-daemon.py new file mode 100644 index 0000000..d75c64e --- /dev/null +++ b/modules/src/settings-daemon.py @@ -0,0 +1,1930 @@ +# Per-user settings daemon for agent-box (issue #36). +# (Run via pkgs.writers.writePython3Bin, which supplies the interpreter +# shebang; no #! line here so it stays lint-clean.) +# +# Runs AS THE AGENT USER (no root) — it only ever touches files the user +# already owns and only kills the user's own tmux session, so it crosses no +# privilege boundary. One instance per web-terminal user, bound to +# 127.0.0.1:; Caddy reverse-proxies https:////settings* +# to it INSIDE that user's existing basic-auth block, so there is no new +# auth surface (see modules/agent-box.nix). +# +# Purpose: let the end user add/remove agent secrets (GH_TOKEN, +# ANTHROPIC_API_KEY, ...) WITHOUT a nixos-rebuild and WITHOUT ever typing the +# secret into the agent chat/terminal (which would leak into the transcript, +# tmux scrollback, and model context). The secret path is +# browser -> TLS (Caddy) -> this daemon -> ~/.config/agent-box/env (0600). +# +# The UI lists key NAMES only; it never renders a stored value. "Apply" +# kills the user's tmux sessions (same uid, via the PrivateTmp socket +# under TMUX_TMPDIR); the supervisor in the agent unit brings them +# back with the fresh environment. +# +# Sessions (issue 59): the daemon is also the web CRUD surface for the +# user-owned sessions.json — add/delete/restart sessions, managed on +# every user's settings page. For the primary web user +# (AGENT_BOX_HOME=1) the vhost root (/) additionally serves a tabbed +# terminal workspace (issue 119): one tab per session, each pane an +# iframe onto the per-session ttyd URL. The reconcile/respawn +# logic deliberately does NOT live here (a daemon crash or restart +# must never take the agent sessions down): the daemon only writes the +# file and kills the user's own tmux sessions; the supervisor in the +# hardened agent unit does the starting. +# +# Deliberately Python-3-stdlib only: no third-party imports, so it stays +# tiny and auditable and needs nothing beyond pkgs.python3. +# +# Listening (issue #49): under the module, systemd socket-activates the +# daemon on a pre-bound unix socket (0660 :caddy — only the user and +# the caddy reverse-proxy can connect; localhost TCP was reachable by every +# local user). Without LISTEN_FDS (dev rigs, e2e runs) it falls back to +# binding 127.0.0.1:$AGENT_BOX_SETTINGS_PORT itself. +# +# Configuration comes from the environment (set by the systemd unit): +# AGENT_BOX_SETTINGS_USER the linux user name (display only) +# AGENT_BOX_SETTINGS_ENV_FILE path to the env file to manage +# AGENT_BOX_SETTINGS_BASE URL base path, e.g. /alice/settings +# AGENT_BOX_SETTINGS_PORT dev fallback TCP port on 127.0.0.1 +# (ignored when socket-activated) +# AGENT_BOX_TMUX_SOCKET tmux -L socket name (e.g. agent-box) +# AGENT_BOX_TMUX_TMPDIR TMUX_TMPDIR the agent's socket lives under +# AGENT_BOX_TMUX_BIN absolute path to the tmux binary +# AGENT_BOX_SESSIONS_FILE path to the user's sessions.json +# AGENT_BOX_HOME "1" = also serve the tabbed terminal +# workspace at / (primary web user) +# AGENT_BOX_AGENTS comma-separated installed agent CLIs +# AGENT_BOX_DEFAULT_AGENT agent preselected in the add form +# AGENT_BOX_PASSWORD_CMD no-argument sudo command that verifies +# and replaces this user's web password + +import html +import http.server +import json +import os +import re +import secrets +import signal +import socket +import subprocess +import sys +import tempfile +import urllib.parse + +USER = os.environ.get("AGENT_BOX_SETTINGS_USER", "agent") +ENV_FILE = os.environ["AGENT_BOX_SETTINGS_ENV_FILE"] +BASE = os.environ.get("AGENT_BOX_SETTINGS_BASE", "/settings").rstrip("/") +PORT = int(os.environ.get("AGENT_BOX_SETTINGS_PORT", "8080")) +TMUX_SOCKET = os.environ.get("AGENT_BOX_TMUX_SOCKET", "agent-box") +TMUX_TMPDIR = os.environ.get("AGENT_BOX_TMUX_TMPDIR", "") +TMUX_BIN = os.environ.get("AGENT_BOX_TMUX_BIN", "tmux") +# Sessions (issue 59): the daemon is the web CRUD surface for the +# user-owned sessions.json; the supervisor inside the agent unit +# reconciles tmux against it (starts within ~2s). The daemon only +# ever writes the file and kills the user's own tmux sessions. +SESSIONS_FILE = os.environ.get("AGENT_BOX_SESSIONS_FILE", "") +# Primary web user's daemon (Caddy proxies the vhost root here, behind +# the same cookie-or-basic auth as the terminal): GET / renders the +# tabbed terminal workspace and session CRUD lives at /sessions/*. +# The settings page keeps the session manager list plus secrets + +# danger zone. +HOME = os.environ.get("AGENT_BOX_HOME", "") == "1" +# Where session CRUD routes live, and the page they redirect back to. +SESS_BASE = "" if HOME else BASE +SESS_PAGE = "/" if HOME else BASE + "/" +AGENTS = [a for a in os.environ.get("AGENT_BOX_AGENTS", "claude").split(",") if a] +DEFAULT_AGENT = os.environ.get("AGENT_BOX_DEFAULT_AGENT", "claude") +# Full sudo command line that triggers the box update (issue 54). Empty +# when selfUpdate is off, which hides the Update card and 404s the route. +UPDATE_CMD = os.environ.get("AGENT_BOX_UPDATE_CMD", "") +# Running agent-box git rev + GitHub owner/repo (set alongside +# UPDATE_CMD when selfUpdate is on) — shown on the Update card and +# used by its non-blocking GitHub update check. +REPO = os.environ.get("AGENT_BOX_REPO", "") +REV = os.environ.get("AGENT_BOX_REV", "") +# Read-only handles for the {BASE}/status progress endpoint the page +# polls after triggering an update: the update oneshot's unit name and +# a systemctl binary to `show` its state with. Both empty unless +# selfUpdate is on (no unit to watch) or on dev rigs without systemd; +# the endpoint then simply omits the update block. Querying unit state +# is unprivileged — no sudo, unlike the trigger in UPDATE_CMD. +UPDATE_UNIT = os.environ.get("AGENT_BOX_UPDATE_UNIT", "") +SYSTEMCTL = os.environ.get("AGENT_BOX_SYSTEMCTL", "") +# Per-user, no-argument privileged helper (issue 91). Passwords are sent +# as JSON on stdin, never argv or environment, and helper output is never +# reflected into HTTP responses. +PASSWORD_CMD = os.environ.get("AGENT_BOX_PASSWORD_CMD", "") + +# Env var names: POSIX-ish. Must start with a letter or underscore and +# contain only letters, digits, underscores. This is what a shell / systemd +# EnvironmentFile will accept as a variable name. +KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +# Session names: same charset the supervisor and CLI enforce (they +# land in tmux -t targets and URLs). +SESSION_RE = re.compile(r"^[A-Za-z0-9_-]{1,32}$") + +# The agent user's home. A session's working directory defaults to it +# and the working-directory picker (below) browses within it: the +# daemon runs AS the user with ProtectHome=false, so it could read the +# whole tree, but a session only ever runs somewhere the user owns and +# confining the picker to $HOME keeps the web surface from doubling as +# a filesystem browser. systemd sets $HOME for User=; fall back to the +# passwd entry so a bare dev invocation still resolves it. +HOME_DIR = os.path.realpath( + os.environ.get("HOME") or os.path.expanduser("~" + USER) +) + + +def resolve_browse_dir(raw): + """Map a user-typed directory prefix to an absolute path CONFINED + to HOME_DIR, or None if it escapes. "", "~" and "~/" mean HOME; + "~/x" and absolute paths are honoured; anything else is relative to + HOME. Only the directory portion is resolved — the caller lists its + immediate children. realpath collapses .. and symlinks BEFORE the + containment check, so neither can climb out of HOME.""" + raw = (raw or "").strip() + if raw in ("", "~", "~/"): + return HOME_DIR + if raw.startswith("~/"): + candidate = os.path.join(HOME_DIR, raw[2:]) + elif raw.startswith("/"): + candidate = raw + else: + candidate = os.path.join(HOME_DIR, raw) + candidate = os.path.realpath(candidate) + if candidate == HOME_DIR or candidate.startswith(HOME_DIR + os.sep): + return candidate + return None + + +def list_subdirs(abs_dir): + """Immediate subdirectory names of abs_dir, sorted case-folded and + capped. is_dir() follows symlinks (a symlinked checkout is a valid + cwd); an unreadable or non-directory path yields [].""" + try: + entries = list(os.scandir(abs_dir)) + except OSError: + return [] + names = [] + for entry in entries: + try: + if entry.is_dir(): + names.append(entry.name) + except OSError: + continue + names.sort(key=str.lower) + return names[:500] + + +def resolve_session_cwd(raw): + """Turn the add form's working-directory field into the value + stored in sessions.json, or raise ValueError with a user-facing + message. HOME (the "~" default) is stored as None so the supervisor + keeps its default-to-home behaviour and the file stays portable; + any other directory is stored as an absolute path. The path must + already exist — tmux new-session -c fails on a missing cwd.""" + abs_dir = resolve_browse_dir(raw) + if abs_dir is None: + raise ValueError("Working directory must be inside your home directory.") + if not os.path.isdir(abs_dir): + raise ValueError("Working directory does not exist: %s" % raw.strip()) + return None if abs_dir == HOME_DIR else abs_dir + + +def valid_password(password): + """Accept password-manager symbols; form fields cannot contain LF/CR.""" + return 16 <= len(password) <= 64 and not any( + char in password for char in "\r\n" + ) + + +def read_keys(): + """Return the sorted list of KEY names currently in the env file. + + Values are intentionally never returned — the UI must not be able to + surface a stored secret. + """ + keys = [] + try: + with open(ENV_FILE, "r", encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key = line.split("=", 1)[0].strip() + if KEY_RE.match(key): + keys.append(key) + except FileNotFoundError: + pass + # De-dup preserving the last occurrence's position is unnecessary; names + # are what matter, so sort for a stable UI. + return sorted(set(keys)) + + +def load_pairs(): + """Return an ordered dict-ish list of (key, rawvalue) for rewriting. + + Used only internally when mutating the file; values never leave the + process. + """ + pairs = [] + try: + with open(ENV_FILE, "r", encoding="utf-8") as fh: + for line in fh: + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in stripped: + continue + key, val = stripped.split("=", 1) + key = key.strip() + if KEY_RE.match(key): + pairs.append((key, val)) + except FileNotFoundError: + pass + return pairs + + +def write_pairs(pairs): + """Atomically write pairs to ENV_FILE at mode 0600. + + Writes to a temp file in the same directory (so os.replace is atomic on + the same filesystem) then renames over the target. + """ + directory = os.path.dirname(ENV_FILE) or "." + os.makedirs(directory, mode=0o700, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=directory, prefix=".env.") + try: + os.fchmod(fd, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write("# Managed by agent-box settings page. KEY=value, one per line.\n") + fh.write("# Do not add secrets by hand here unless you know what you are doing.\n") + for key, val in pairs: + fh.write(f"{key}={val}\n") + os.replace(tmp, ENV_FILE) + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise + + +def set_key(key, value): + pairs = [(k, v) for (k, v) in load_pairs() if k != key] + pairs.append((key, value)) + write_pairs(pairs) + + +def delete_key(key): + pairs = [(k, v) for (k, v) in load_pairs() if k != key] + write_pairs(pairs) + + +def read_sessions(): + """Return the raw sessions dict from SESSIONS_FILE ({} on any problem). + + Values are kept as-is for read-modify-write; callers that render or + publish names filter through SESSION_RE themselves. + """ + try: + with open(SESSIONS_FILE, "r", encoding="utf-8") as fh: + data = json.load(fh) + except (OSError, ValueError): + return {} + sessions = data.get("sessions") if isinstance(data, dict) else None + if not isinstance(sessions, dict): + return {} + result = {} + for k, v in sessions.items(): + if isinstance(k, str) and isinstance(v, dict): + result[k] = v + return result + + +def gen_session_name(agent, sessions): + """Auto-generate a unique session name from the agent CLI name. + + The first session for an agent gets the bare name ("claude"); a + later one that would collide gets a short random suffix + ("claude-a3f9") to stay unique yet readable. Users rarely care what + a session is called (rename at runtime via /rename), so this spares + them inventing one. `agent` is always one of AGENTS (or "shell"), + so it already matches SESSION_RE. + """ + if agent not in sessions: + return agent + for _ in range(1000): + candidate = "%s-%s" % (agent, secrets.token_hex(2)) + if candidate not in sessions: + return candidate + # Astronomically unlikely fallback: a longer token can't be taken. + return ("%s-%s" % (agent, secrets.token_hex(8)))[:32] + + +def write_sessions(sessions): + """Atomically rewrite SESSIONS_FILE (0600) with the given dict. + + Same tempfile-in-directory + os.replace dance as write_pairs. The + supervisor in the agent unit picks the change up within ~2s. + """ + directory = os.path.dirname(SESSIONS_FILE) or "." + os.makedirs(directory, mode=0o700, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=directory, prefix=".sessions.") + try: + os.fchmod(fd, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as fh: + json.dump({"version": 1, "sessions": sessions}, fh, indent=2) + fh.write("\n") + os.replace(tmp, SESSIONS_FILE) + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise + + +def tmux(*args): + """Run a tmux command against the user's own server; None on OSError.""" + env = dict(os.environ) + if TMUX_TMPDIR: + env["TMUX_TMPDIR"] = TMUX_TMPDIR + try: + return subprocess.run( + [TMUX_BIN, "-L", TMUX_SOCKET] + list(args), + env=env, + check=False, + capture_output=True, + text=True, + ) + except OSError as exc: + # Missing/unrunnable tmux binary must not 500 the request. + sys.stderr.write("tmux: %s\n" % exc) + return None + + +def live_sessions(): + proc = tmux("list-sessions", "-F", "#S") + if proc is None or proc.returncode != 0: + return set() + return {line for line in proc.stdout.splitlines() if line} + + +def kill_session(name): + """Kill one tmux session. The supervisor recreates it if it is still + listed in sessions.json (= restart); delisting first makes it stay + gone (= destroy).""" + tmux("kill-session", "-t", "=" + name) + + +def find_supervisor_pids(): + """PIDs of this user's session supervisor — the agent unit's main + process (the mkStart store script). Matched by an argv element + ending in "agent-box--start", restricted to our own uid.""" + marker = "agent-box-%s-start" % USER + uid = os.getuid() + pids = [] + for entry in os.listdir("/proc"): + if not entry.isdigit(): + continue + try: + if os.stat("/proc/" + entry).st_uid != uid: + continue + with open("/proc/%s/cmdline" % entry, "rb") as fh: + argv = fh.read().split(b"\0") + except OSError: + continue # process raced away + if any(a.decode("utf-8", "replace").endswith(marker) for a in argv): + pids.append(int(entry)) + return pids + + +def restart_all(): + """Bounce the WHOLE agent unit, no sudo needed: SIGTERM the + supervisor (the unit's main process, our own uid). systemd then + tears the session tree down and Restart=always brings the unit + back with freshly read EnvironmentFiles — unit env is a + start-time snapshot, so this is the only lever that applies + root-dropped tokenDir changes (issue 89). Per-session restarts + stay cheap: the spawn wrapper re-reads the user env file anyway. + Dev rigs without the unit fall back to bouncing the sessions.""" + pids = find_supervisor_pids() + if not pids: + for name in read_sessions(): + if SESSION_RE.match(name): + kill_session(name) + return + for pid in pids: + try: + os.kill(pid, signal.SIGTERM) + except OSError as exc: + sys.stderr.write("restart_all: pid %d: %s\n" % (pid, exc)) + + +def update_box(): + """Trigger the box update oneshot via the allowlisted sudo command. + --no-block (baked into UPDATE_CMD) means this returns immediately; + the rebuild may later restart this very daemon. + """ + try: + proc = subprocess.run( + UPDATE_CMD.split(), + check=False, + capture_output=True, + ) + # rc only — never log request bodies or command output wholesale. + sys.stderr.write("update_box: trigger rc=%d\n" % proc.returncode) + except OSError as exc: + sys.stderr.write("update_box: %s\n" % exc) + + +def update_service_state(): + """Read-only state of the box update oneshot, for the UI progress + line. Returns None when self-update is off (no unit wired) or + systemctl is unavailable (dev rigs) — the caller then omits the + update block. `since` is the run's monotonic start time (usec since + boot, 0 if it never ran): the page captures it before triggering + and waits for a strictly newer value, which is stable even though + the rebuild may restart this daemon (same boot). No privilege + needed — `systemctl show` is a world-readable query.""" + if not UPDATE_UNIT or not SYSTEMCTL: + return None + try: + proc = subprocess.run( + [SYSTEMCTL, "show", UPDATE_UNIT, "--property", + "ActiveState,Result,ExecMainStartTimestampMonotonic"], + check=False, + capture_output=True, + text=True, + ) + except OSError: + return None + if proc.returncode != 0: + return None + props = {} + for line in proc.stdout.splitlines(): + key, _, value = line.partition("=") + props[key] = value + try: + since = int(props.get("ExecMainStartTimestampMonotonic", "0") or "0") + except ValueError: + since = 0 + return { + "active": props.get("ActiveState", ""), + "result": props.get("Result", ""), + "since": since, + } + + +def session_counts(): + """How many configured sessions are currently live — the signal the + page watches to confirm a 'Restart all' has bounced and recovered.""" + configured = [n for n in read_sessions() if SESSION_RE.match(n)] + live = live_sessions() + return { + "configured": len(configured), + "live": sum(1 for n in configured if n in live), + } + + +def status_payload(): + """Compact JSON the settings page long-polls for restart/update + progress. Never includes secret values or command output.""" + payload = {"rev": REV, "sessions": session_counts()} + update = update_service_state() + if update is not None: + payload["update"] = update + return payload + + +def change_password(previous, new): + """Ask the root helper to verify and rotate the web credentials. + + Return 0 on success, 2 for a wrong current password, and another + nonzero value for an operational failure. Passwords cross sudo on + stdin only; neither argv, the environment nor the journal sees them. + """ + try: + proc = subprocess.run( + PASSWORD_CMD.split(), + input=json.dumps({"previous": previous, "new": new}), + text=True, + check=False, + capture_output=True, + ) + sys.stderr.write("change_password: helper rc=%d\n" % proc.returncode) + return proc.returncode + except OSError as exc: + sys.stderr.write("change_password: %s\n" % exc) + return 5 + + +# Page skeleton. HEAD_TPL and BODY go through str.format (hence no +# literal braces in them); STYLE and SCRIPT are plain strings so CSS/JS +# braces need no doubling. The layout mirrors GitHub's environment- +# secrets settings: section header with an action button on the right, +# then a bordered table (header row + one row per item) with icon +# buttons per row. SCRIPT is progressive enhancement only — without JS +# the plain form POST + 303 redirect flow still works, the add/edit +# forms just render expanded. +HEAD_TPL = """ + + + + +{title} +""" + +STYLE = """ +""" + +# The session manager
on the settings page (every user, +# including the primary one — the HOME root page is the tabbed +# terminal workspace, not a manager). {action_base} is SESS_BASE, so +# the forms post to wherever the session routes actually live; the +# hidden back=settings field makes their redirects land back here +# rather than on SESS_PAGE (issue #119). +SESSIONS_SECTION_TPL = """
+
+

Sessions

+ +
+

Each session is one agent CLI in its own terminal + tab. New sessions start within a few seconds — no rebuild, + no sudo. Click a session to open its terminal.

+
+
+ +
+ + + + + + + +
+

Working directory — where the agent + starts. Defaults to your home directory (~); type + to browse folders one level at a time.

+
+
+
{sessions}
+
""" + +# Root page (HOME mode): a tabbed terminal workspace (issue #119) — +# one tab per session, the active one shown in an iframe onto the +# existing per-session ttyd URL (//?arg=; same origin, +# so the auth cookie and its WebSocket upgrade work unchanged). Tabs +# are plain ?tab= links so the page works without JS (each click +# re-renders with the other terminal); SCRIPT upgrades that to +# client-side switching with background tabs kept attached. Session +# CRUD beyond "add" lives on the settings page. +HOME_BODY = """ + +
+
+
+ + + + + + + +
+

Working directory — where the agent starts. + Defaults to your home directory (~); type to browse + folders one level at a time.

+
+
+
{message}
+
{pane}
+ + +""" + +BODY = """
+ + + GitHub + + ← terminal +

Settings for {user}

+
{message}
+ {sessions_section} +
+
+

Environment secrets

+ +
+

Secrets are passed to your agent sessions as environment + variables (e.g. GH_TOKEN, ANTHROPIC_API_KEY). + They are written to a private file only your agent can read — + never shown here, never typed into the chat. Restart sessions to + apply changes.

+
+
+
+ + + +
+

The value is write-only — saving replaces any + existing value for that key. This page never displays stored values.

+
+
+
{keys}
+
+ {password_section} +
+

Danger zone

+
    +
  • + Restart all sessions + Restarts the whole agent service: every + session comes back with the current secrets and token files. + Live sessions are killed — unsaved in-flight work is lost. + +
    + +
    +
  • + {update_row} +
+
+
+ +""" + +PASSWORD_SECTION = """
+
+

Account

+ +
+

Change the password used to sign in to this browser + terminal. All signed-in browsers will be logged out.

+
+
+
+ + + +

Use 16–64 characters. Symbols generated + by password managers are supported.

+
+
+
+
+
""" + +UPDATE_ROW = """
  • + Update box + Fetches the latest agent-box release and agent + CLI versions, then rebuilds the system. Takes a few minutes; sessions + restart if their software changed.{update_line} +
    + +
    +
  • """ + +# Octicons (MIT) inlined so the page stays a single self-contained +# response. +ICON_LOCK = ( + '' +) +ICON_PENCIL = ( + '' +) +ICON_TRASH = ( + '' +) + +# Progressive enhancement: submit forms via fetch and patch the three +# swap regions (message, secrets list, sessions list) in place, so +# changes show up without a page reload; poll briefly while a session +# is still "starting" so the state flips to "live" on its own. The +# inline confirm() guards run before the submit event reaches us — a +# dismissed dialog cancels the event, so we only see accepted ones. +SCRIPT = """ +""" + + +def render_keys(keys): + base = html.escape(BASE) + rows = [] + for key in keys: + safe = html.escape(key) + rows.append( + f'
  • {ICON_LOCK}{safe}' + f'' + f'' + f'
    ' + f'' + f'
    ' + f'
  • ' + ) + body = "".join(rows) if rows else '
  • No secrets yet.
  • ' + return '
    • Name
    • ' + body + "
    " + + +def display_cwd(value): + """Compact working-directory label for a session row: "~" for the + default (stored None), and an absolute path is shown home-relative + (~/foo) when it sits under HOME.""" + if not value: + return "~" + if value == HOME_DIR: + return "~" + if value.startswith(HOME_DIR + os.sep): + return "~/" + value[len(HOME_DIR) + 1:] + return value + + +def render_sessions(): + entries = {n: v for n, v in read_sessions().items() if SESSION_RE.match(n)} + base = html.escape(SESS_BASE) + user = urllib.parse.quote(USER, safe="") + if not entries: + body = '
  • No sessions defined.
  • ' + else: + live = live_sessions() + items = [] + for name in sorted(entries): + safe = html.escape(name) + agent = html.escape(str(entries[name].get("agent") or "?")) + cwd = html.escape(display_cwd(entries[name].get("workingDirectory"))) + state = "live" if name in live else "starting" + items.append( + # The name deep-links into the terminal via ttyd's + # ?arg= session selector. No userinfo in the href + # (issue 56). SESSION_RE names are URL-safe as-is. + f'
  • ' + f'{safe}' + f'{agent}' + f'{cwd}' + f'{state}' + f'' + f'
    ' + f'' + f'' + f'
    ' + f'
    ' + f'' + f'' + f'
    ' + f'
  • ' + ) + body = "".join(items) + return '
    • Session
    • ' + body + "
    " + + +def render_agent_options(): + items = [] + for agent in AGENTS: + sel = " selected" if agent == DEFAULT_AGENT else "" + safe = html.escape(agent) + items.append(f'') + return "".join(items) + + +def render_update_line(): + """Running rev plus a progressively enhanced GitHub update status. + + REV is a full git sha; the label shows the usual short form. Empty + when the module didn't pass a rev (selfUpdate off — but then the + whole Update card is hidden anyway). Without JavaScript, the user + still gets a direct GitHub comparison link. + """ + if not REV: + return "" + label = f"{html.escape(REV[:12])}" + if REPO: + url = html.escape(f"https://github.com/{REPO}/commit/{REV}") + label = f'{label}' + line = " Currently at " + label + "." + if not REPO: + return line + repo = html.escape(REPO) + rev = html.escape(REV) + compare_url = html.escape(f"https://github.com/{REPO}/compare/{REV}...HEAD") + return ( + line + + f' ' + f'Check GitHub for changes.' + ) + + +def render_sessions_section(): + return SESSIONS_SECTION_TPL.format( + action_base=html.escape(SESS_BASE), + agents=render_agent_options(), + sessions=render_sessions(), + ) + + +def render_tabs(names, live, selected): + """The workspace tab bar. File order, not sorted: sessions.json + preserves insertion order, so a new session appears as the + rightmost tab, like any terminal app. The dot-only .state span + reuses the list styling (its ::before is the dot).""" + items = [] + for name in names: + safe = html.escape(name) + cur = ' aria-current="page"' if name == selected else "" + state = "live" if name in live else "starting" + items.append( + f'' + f'{safe}' + ) + if not items: + items.append('No sessions yet.') + return "".join(items) + + +def render_pane(selected, live): + """The server-rendered pane: only the SELECTED session, and only + when its tmux session is already live — the ttyd attach wrapper + greets a not-yet-started session with an error and exits, so a + starting session gets a placeholder instead (SCRIPT swaps in the + iframe once the state flips; without JS, reloading does).""" + if selected is None: + return '
    No session selected.
    ' + safe = html.escape(selected) + if selected not in live: + return (f'
    ' + f'{safe} is starting… reload in a few seconds.
    ') + user = urllib.parse.quote(USER, safe="") + # SESSION_RE names are URL-safe as-is. + return (f'') + + +def render_page(message=""): + msg_html = f'
    {html.escape(message)}
    ' if message else "" + return ( + HEAD_TPL.format(title="Settings — " + html.escape(USER)) + + STYLE + + BODY.format( + user=html.escape(USER), + base=html.escape(BASE), + keys=render_keys(read_keys()), + # Every user, primary included: the HOME root page is the + # terminal workspace, so session CRUD lives here. + sessions_section=render_sessions_section(), + message=msg_html, + password_section=( + PASSWORD_SECTION.format(base=html.escape(BASE)) + if PASSWORD_CMD else "" + ), + update_row=( + UPDATE_ROW.format(base=html.escape(BASE), update_line=render_update_line()) + if UPDATE_CMD else "" + ), + ) + + SCRIPT + ) + + +def render_home(message="", selected=None): + entries = {n: v for n, v in read_sessions().items() if SESSION_RE.match(n)} + names = list(entries) + if selected not in entries: + selected = "main" if "main" in entries else (names[0] if names else None) + live = live_sessions() + msg_html = f'
    {html.escape(message)}
    ' if message else "" + return ( + HEAD_TPL.format(title="Agent Box — " + html.escape(USER)) + + STYLE + + HOME_BODY.format( + base=html.escape(BASE), + action_base=html.escape(SESS_BASE), + term_base="/%s/" % urllib.parse.quote(USER, safe=""), + tabs=render_tabs(names, live, selected), + pane=render_pane(selected, live), + agents=render_agent_options(), + message=msg_html, + ) + + SCRIPT + ) + + +class Handler(http.server.BaseHTTPRequestHandler): + server_version = "agent-box-settings/1" + + def _under_base(self, path): + """True if request path is BASE or under BASE. Caddy strips nothing, + so we match the full public path.""" + return path == BASE or path == BASE + "/" or path.startswith(BASE + "/") + + def _send_html(self, body, status=200): + data = body.encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.send_header("Cache-Control", "no-store") + self.send_header("X-Content-Type-Options", "nosniff") + self.end_headers() + self.wfile.write(data) + + def _send_json(self, obj, status=200): + data = json.dumps(obj).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.send_header("Cache-Control", "no-store") + self.send_header("X-Content-Type-Options", "nosniff") + self.end_headers() + self.wfile.write(data) + + def _redirect(self, query="", page=None): + target = (page or BASE + "/") + (("?" + query) if query else "") + self.send_response(303) + self.send_header("Location", target) + self.send_header("Content-Length", "0") + self.end_headers() + + OK_MESSAGES = { + "saved": "Key saved. Restart the sessions to apply.", + "deleted": "Key deleted. Restart the sessions to apply.", + "restarted": "Restart of all sessions requested.", + "session_added": "Session added — it starts within a few seconds.", + "session_deleted": "Session deleted.", + "session_restarted": "Session restart requested.", + "update": "Box update started — the system rebuilds in the " + "background and this page may briefly go away.", + "password_changed": "Password changed. Sign in with your new password.", + } + + def do_GET(self): + parsed = urllib.parse.urlparse(self.path) + params = urllib.parse.parse_qs(parsed.query) + # Working-directory autocomplete (issue #131): the add-session + # form asks the daemon to list one directory level at a time, + # so the browser never sees the filesystem — only the confined + # child names for the level being typed. GET-only, read-only, + # no state change (so no CSRF concern); auth is Caddy's job for + # the whole vhost. Handled before the BASE routing below since + # in HOME mode SESS_BASE is "" and this path is not under BASE. + if parsed.path.rstrip("/") == SESS_BASE + "/sessions/dirs": + abs_dir = resolve_browse_dir(params.get("path", [""])[0]) + if abs_dir is None: + self._send_json({"ok": False, "dirs": []}) + else: + self._send_json({"ok": True, "dirs": list_subdirs(abs_dir)}) + return + message = "" + if "ok" in params: + message = self.OK_MESSAGES.get(params["ok"][0], "") + if HOME and parsed.path == "/": + # ?tab= selects the rendered tab (also the no-JS + # switching mechanism); anything invalid falls back to the + # default selection inside render_home. + tab = (params.get("tab", [""])[0]).strip() + self._send_html(render_home(message, tab if SESSION_RE.match(tab) else None)) + return + if not self._under_base(parsed.path): + self._send_html("

    404

    ", status=404) + return + # Progress feed the page long-polls after a restart/update + # (read-only, same auth block as the page it lives under). + if parsed.path.rstrip("/") == BASE + "/status": + self._send_json(status_payload()) + return + self._send_html(render_page(message)) + + def _read_form(self): + length = int(self.headers.get("Content-Length", "0") or "0") + raw = self.rfile.read(length).decode("utf-8") if length else "" + return urllib.parse.parse_qs(raw) + + def _same_origin(self): + """Reject cross-site state-changing POSTs (issue #117). + + Every POST route here mutates state (secrets, sessions, the + box update). Auth alone does not stop CSRF: the __Host- cookie + is SameSite=Strict, but the basic-auth fallback has no SameSite + equivalent, and browsers reattach cached basic credentials to + cross-site requests — so a lured, basic-authenticated operator + could be forced to e.g. inject a GH_TOKEN via /set. + + Browsers always send Sec-Fetch-Site; a genuine form post from + our own page is "same-origin". Anything a browser labels + cross-site or same-site (sibling *.sslip.io hosts are + same-site but different owners) is refused. Older browsers + that omit Sec-Fetch-Site still send Origin, which we compare + against the target Host (Caddy forwards both unchanged). A + request with neither header is not a browser navigation and + carries no ambient victim credentials (curl, the e2e harness), + so it is allowed.""" + site = self.headers.get("Sec-Fetch-Site") + if site is not None: + return site == "same-origin" + origin = self.headers.get("Origin") + if origin: + host = self.headers.get("Host", "") + return origin in ("https://" + host, "http://" + host) + return True + + def _sess_page(self, form): + """Where a /sessions/* POST redirects back to: the settings + page when the form carried back=settings (the session manager + section lives there for every user now), else SESS_PAGE (the + HOME workspace's own add form).""" + back = form.get("back", [""])[0] + return BASE + "/" if back == "settings" else SESS_PAGE + + def do_POST(self): + parsed = urllib.parse.urlparse(self.path) + path = parsed.path.rstrip("/") + if not self._same_origin(): + self._send_html( + "

    403

    Cross-site request blocked.

    ", + status=403, + ) + return + form = self._read_form() + if path == BASE + "/password" and PASSWORD_CMD: + previous = form.get("previous_password", [""])[0] + new = form.get("new_password", [""])[0] + confirm = form.get("confirm_password", [""])[0] + if new != confirm: + self._send_html( + render_page("New password and confirmation do not match."), + status=400, + ) + return + if not valid_password(new): + self._send_html( + render_page("New password must be 16–64 characters and " + "cannot contain a line break."), + status=400, + ) + return + if new == previous: + self._send_html( + render_page("New password must differ from the current password."), + status=400, + ) + return + result = change_password(previous, new) + if result == 2: + self._send_html( + render_page("Current password is incorrect."), status=403 + ) + return + if result != 0: + self._send_html( + render_page("Could not update the password. Try again or " + "check the settings service journal."), + status=500, + ) + return + self._redirect("ok=password_changed") + elif path == BASE + "/set": + key = (form.get("key", [""])[0]).strip() + value = form.get("value", [""])[0] + if not KEY_RE.match(key): + self._send_html( + render_page("Invalid key name. Use letters, digits and " + "underscores; do not start with a digit."), + status=400, + ) + return + set_key(key, value) + self._redirect("ok=saved") + elif path == BASE + "/delete": + key = (form.get("key", [""])[0]).strip() + if KEY_RE.match(key): + delete_key(key) + self._redirect("ok=deleted") + elif path == SESS_BASE + "/sessions/add": + back_page = self._sess_page(form) + # Error pages re-render the page the form came from. + render = render_home if (HOME and back_page == SESS_PAGE) else render_page + name = (form.get("name", [""])[0]).strip() + agent = (form.get("agent", [""])[0]).strip() or DEFAULT_AGENT + if agent not in AGENTS: + self._send_html( + render("Unknown agent. Available: " + ", ".join(AGENTS)), + status=400, + ) + return + if name and not SESSION_RE.match(name): + self._send_html( + render("Invalid session name. Use letters, digits, " + "dash and underscore (max 32 chars)."), + status=400, + ) + return + # Working directory (issue #131): the field defaults to + # "~" (home); resolve_session_cwd stores that as None (the + # supervisor's default) and any other path as an absolute + # directory it has confirmed exists inside HOME. + try: + cwd = resolve_session_cwd(form.get("cwd", [""])[0]) + except ValueError as exc: + self._send_html(render(str(exc)), status=400) + return + sessions = read_sessions() + # Blank name → derive one from the agent (issue: autogen names). + if not name: + name = gen_session_name(agent, sessions) + if name in sessions: + # Silently overwriting would reset the stored config + # (agent, cwd, extraArgs) to defaults — issue 100. + self._send_html( + render("Session '%s' already exists. Delete it " + "first, or use Restart to bounce it." % name), + status=409, + ) + return + sessions[name] = { + "agent": agent, + "skipPermissions": True, + "remoteControl": True, + "remoteControlName": None, + "workingDirectory": cwd, + "extraArgs": [], + } + write_sessions(sessions) + # On the workspace, land on the new session's tab (name is + # SESSION_RE-validated, so URL-safe as-is). + query = "ok=session_added" + if HOME and back_page == SESS_PAGE: + query += "&tab=" + name + self._redirect(query, back_page) + elif path == SESS_BASE + "/sessions/delete": + name = (form.get("name", [""])[0]).strip() + if SESSION_RE.match(name): + sessions = read_sessions() + sessions.pop(name, None) + write_sessions(sessions) + kill_session(name) + self._redirect("ok=session_deleted", self._sess_page(form)) + elif path == SESS_BASE + "/sessions/restart": + name = (form.get("name", [""])[0]).strip() + if SESSION_RE.match(name): + kill_session(name) + self._redirect("ok=session_restarted", self._sess_page(form)) + elif path == BASE + "/restart": + # Full unit bounce (see restart_all): re-reads unit-level + # EnvironmentFiles, which per-session restarts can't. + restart_all() + self._redirect("ok=restarted") + elif path == BASE + "/update" and UPDATE_CMD: + update_box() + self._redirect("ok=update") + else: + self._send_html("

    404

    ", status=404) + + def address_string(self): + # AF_UNIX peers have no (host, port) client_address — the base class + # would IndexError on the empty string it gets instead. + if isinstance(self.client_address, tuple) and self.client_address: + return super().address_string() + return "unix" + + def log_message(self, fmt, *args): + # Keep the journal quiet-ish; never log form bodies (would leak + # secrets). Only method + path + status, which BaseHTTPRequestHandler + # already restricts to. + sys.stderr.write("%s - %s\n" % (self.address_string(), fmt % args)) + + +# Per the systemd socket-activation protocol, inherited listening sockets +# start at fd 3 (after stdin/stdout/stderr). +SD_LISTEN_FDS_START = 3 + + +def make_server(): + if int(os.environ.get("LISTEN_FDS", "0") or "0") >= 1: + # Socket-activated (the module's only mode, issue #49): adopt the + # unix socket systemd pre-bound with 0660 :caddy permissions. + # bind_and_activate=False skips bind/listen; the placeholder address + # is never bound. + server = http.server.ThreadingHTTPServer( + ("127.0.0.1", 0), Handler, bind_and_activate=False + ) + server.socket = socket.socket(fileno=SD_LISTEN_FDS_START) + # server_bind() never ran; set the attributes it would have set. + server.server_name = "agent-box-settings" + server.server_port = 0 + return server + # Dev fallback for LAN rigs / e2e runs outside the module. + return http.server.ThreadingHTTPServer(("127.0.0.1", PORT), Handler) + + +def main(): + make_server().serve_forever() + + +if __name__ == "__main__": + main()