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 53d098b3c85fe42db3efd4efe6ab0a6b57347f40 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 22 Jul 2026 14:11:23 +0000 Subject: [PATCH 2/2] test(aws): add Lightsail E2E deploy-test workflow Addresses the review ask to include at least one end-to-end test for the Lightsail template rather than deferring it. aws-ci.yml only lints; the existing deploy-test.yml deploys the EC2 template only. Adds a standalone deploy-test-lightsail.yml (create -> wait -> assert -> smoke -> destroy). Lightsail is IPv4-native and GH runners are IPv4-only, so this single leg runs the full browser-terminal smoke suite (auth 200, unauth 401, tabbed workspace, ttyd HTML, WebSocket upgrade, live-tmux via scripts/ws_smoke.py). Reuses the OIDC + module-pin conventions; drops the EC2-only bits (ipv4/ipv6 matrix, Spot, SSM, serial console) since the FirstBootDone WaitCondition already gates CREATE_COMPLETE on the first nixos-rebuild switch. DebugSsh=false keeps 22 closed on the public box. BundleId/WebPassword are passed via the step env so the shell never expands the "$12" price annotation as a positional parameter (which would fail AllowedValues validation). Also adds the new workflow to aws-ci.yml trigger paths for symmetry with deploy-test.yml. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Wx9PVLmDbzS35JCN5N85i6 --- .github/workflows/aws-ci.yml | 1 + .github/workflows/deploy-test-lightsail.yml | 365 ++++++++++++++++++++ 2 files changed, 366 insertions(+) create mode 100644 .github/workflows/deploy-test-lightsail.yml diff --git a/.github/workflows/aws-ci.yml b/.github/workflows/aws-ci.yml index 3b3eb66..a4dfcf4 100644 --- a/.github/workflows/aws-ci.yml +++ b/.github/workflows/aws-ci.yml @@ -10,6 +10,7 @@ on: - 'scripts/ws_smoke.py' - '.github/workflows/aws-ci.yml' - '.github/workflows/deploy-test.yml' + - '.github/workflows/deploy-test-lightsail.yml' pull_request: paths: *aws-ci-paths workflow_dispatch: diff --git a/.github/workflows/deploy-test-lightsail.yml b/.github/workflows/deploy-test-lightsail.yml new file mode 100644 index 0000000..d192526 --- /dev/null +++ b/.github/workflows/deploy-test-lightsail.yml @@ -0,0 +1,365 @@ +name: Deploy test — Lightsail (CFN create + destroy) + +# End-to-end test for aws/lightsail-template.yaml. Kept SEPARATE from +# deploy-test.yml because the Lightsail model shares almost nothing with the +# EC2 one operationally: no ipv4/ipv6 matrix (Lightsail is always dual-stack +# with a public IPv4, so a GH runner can always reach it), no Spot, no SSM, +# and no `ec2 get-console-output` for diagnostics. The one thing that IS +# shared — the browser-terminal smoke suite — is reused verbatim, including +# scripts/ws_smoke.py. +# +# Because Lightsail is IPv4-native and GH runners are IPv4-only, this single +# leg runs the FULL connectivity smoke tests (unlike the EC2 template's +# ipv6-outputs leg, which can only assert outputs). The template's +# FirstBootDone WaitCondition gates CREATE_COMPLETE on the first nixos-rebuild +# switch, so a box that never applied its user-data surfaces as CREATE_FAILED +# rather than a black-holed URL — meaning we don't need the EC2 leg's +# serial-console amazon-init probe here. + +on: + push: + branches: [ master ] + paths: + - 'aws/lightsail-template.yaml' + - 'modules/**' + - 'scripts/ws_smoke.py' + - '.github/workflows/deploy-test-lightsail.yml' + workflow_dispatch: + inputs: + region: + # The test account's SCP only allows us-west-2 — other choices are kept + # for forks running against their own account. Must be a Lightsail + # region. + description: Region to deploy into + type: choice + options: + - us-west-2 + - us-east-1 + - eu-central-1 + - eu-west-1 + default: us-west-2 + bundle_id: + # Must be one of the template's annotated AllowedValues (comma-free, so + # the ParameterKey=...,ParameterValue=... shorthand still parses). Only + # the first token reaches Lightsail; the rest is a console label. + description: Lightsail bundle (template AllowedValues form) + type: string + default: small_3_0 (2 vCPU / 2 GiB / 60 GiB SSD / $12 mo) + destroy: + description: Delete the stack at the end (uncheck to keep it up for debugging) + type: boolean + default: true + web_password: + description: >- + WebPassword override (16-64 chars). Empty = random hex. Use to + reproduce auth issues with specific password shapes; it WILL appear + in the run log, so throwaway values only. + type: string + default: '' + +permissions: + id-token: write + contents: read + +concurrency: + group: deploy-test-lightsail-${{ inputs.region || 'us-west-2' }} + cancel-in-progress: false + +jobs: + deploy-test-lightsail: + runs-on: ubuntu-latest + # Fork? Override with a repo-scoped var AGENT_BOX_ENVIRONMENT, or edit the fallback. + environment: ${{ vars.AGENT_BOX_ENVIRONMENT || 'defang-agent-box' }} + # nixos-infect + the initial closure build + reboot is slow on a small + # bundle; the template's WaitCondition allows 45 min. Give the job headroom + # over that plus the smoke-test retry budget. + timeout-minutes: 60 + env: + AWS_REGION: ${{ inputs.region || 'us-west-2' }} + ROLE_ARN: ${{ vars.AWS_ROLE_ARN }} + STACK_NAME: agent-box-e2e-lightsail-${{ github.run_id }} + steps: + - name: Sanity-check required variables + run: | + if [ -z "$ROLE_ARN" ]; then + echo "::error::Missing repo variable: AWS_ROLE_ARN. See aws/README.md." + exit 1 + fi + + - uses: actions/checkout@v5 + + - name: Configure AWS credentials via OIDC + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ env.ROLE_ARN }} + aws-region: ${{ env.AWS_REGION }} + + - name: Generate WebPassword + id: pw + run: | + # 48 hex chars — satisfies the template's ^.{16,64}$ AllowedPattern. + # Dispatch runs may override to reproduce shape-specific auth bugs; + # the override is intentionally not masked. + if [ -n '${{ inputs.web_password }}' ]; then + echo "value=${{ inputs.web_password }}" >> "$GITHUB_OUTPUT" + echo "::notice::Using dispatch-provided WebPassword override." + else + echo "value=$(openssl rand -hex 24)" >> "$GITHUB_OUTPUT" + fi + + - name: Compute module pin (AgentBoxRev + AgentBoxSha256) + id: pin + # AgentBoxRev/AgentBoxSha256 have no Default in the source template (a + # pinned pair; builtins.fetchurl hard-fails on drift) — publish-template + # injects Defaults on the S3 path. Here we pin to the commit that + # triggered this run so the test exercises the module content that just + # landed. Same computation as deploy-test.yml. + run: | + rev="${{ github.sha }}" + sha="sha256-$(curl -sSfL "https://raw.githubusercontent.com/${{ github.repository }}/${rev}/modules/agent-box.nix" \ + | openssl dgst -sha256 -binary | base64)" + echo "rev=$rev" >> "$GITHUB_OUTPUT" + echo "sha=$sha" >> "$GITHUB_OUTPUT" + echo "::notice::Pinned AgentBoxRev=$rev AgentBoxSha256=$sha" + + - name: Create stack + id: create + env: + # Passed via env (not inlined into the script) so the shell never + # expands the "$12" in the bundle's price annotation as a positional + # parameter — that would corrupt the value and fail AllowedValues + # validation. Same guard for a dispatch WebPassword override that may + # contain shell metacharacters. + BUNDLE_ID: ${{ inputs.bundle_id || 'small_3_0 (2 vCPU / 2 GiB / 60 GiB SSD / $12 mo)' }} + WEB_PASSWORD: ${{ steps.pw.outputs.value }} + AGENT_BOX_REV: ${{ steps.pin.outputs.rev }} + AGENT_BOX_SHA: ${{ steps.pin.outputs.sha }} + run: | + # No --capabilities: the Lightsail template creates no IAM. DebugSsh + # stays false — this is a short-lived, publicly-reachable box and + # there is no SSM/console diagnostic path we'd feed the SSH key into, + # so leaving 22 closed costs nothing. AgentsMd/AllowCidr/NixChannel/ + # NixosInfectRev/Agent/UserName ride their template defaults. No comma + # in any value, so the ParameterKey=...,ParameterValue=... shorthand + # still parses (BundleId's spaces/parens are fine). + aws cloudformation create-stack \ + --stack-name "$STACK_NAME" \ + --template-body "file://aws/lightsail-template.yaml" \ + --parameters \ + "ParameterKey=BundleId,ParameterValue=$BUNDLE_ID" \ + "ParameterKey=WebPassword,ParameterValue=$WEB_PASSWORD" \ + "ParameterKey=DebugSsh,ParameterValue=false" \ + "ParameterKey=AgentBoxRev,ParameterValue=$AGENT_BOX_REV" \ + "ParameterKey=AgentBoxSha256,ParameterValue=$AGENT_BOX_SHA" \ + --on-failure DO_NOTHING \ + --tags "Key=Purpose,Value=agent-box-e2e" "Key=RunId,Value=${{ github.run_id }}" + echo "::notice::Waiting for stack ${STACK_NAME} to reach CREATE_COMPLETE (FirstBootDone gates on the first nixos-rebuild switch; up to ~45 min)..." + aws cloudformation wait stack-create-complete --stack-name "$STACK_NAME" + + - name: Dump failed stack state + if: failure() && steps.create.outcome == 'failure' + run: | + # No serial console on Lightsail. The WaitCondition timing out is the + # usual failure (first nixos-rebuild never signaled) — the stack + # events carry the reason. Also dump the instance's own state. + echo "::group::Stack events (CREATE_FAILED only)" + aws cloudformation describe-stack-events --stack-name "$STACK_NAME" \ + --query 'reverse(StackEvents[?ResourceStatus==`CREATE_FAILED`].[LogicalResourceId,ResourceStatusReason])' \ + --output table || true + echo "::endgroup::" + echo "::group::Stack status" + aws cloudformation describe-stacks --stack-name "$STACK_NAME" \ + --query 'Stacks[0].[StackStatus,StackStatusReason]' --output table || true + echo "::endgroup::" + echo "::group::Lightsail instance state" + aws lightsail get-instance-state --instance-name "$STACK_NAME" \ + --query 'state.name' --output text 2>/dev/null || echo "(instance not queryable)" + echo "::endgroup::" + + - name: Fetch outputs + id: outputs + run: | + web_url=$(aws cloudformation describe-stacks --stack-name "$STACK_NAME" \ + --query 'Stacks[0].Outputs[?OutputKey==`WebURL`].OutputValue | [0]' --output text) + public_ip=$(aws cloudformation describe-stacks --stack-name "$STACK_NAME" \ + --query 'Stacks[0].Outputs[?OutputKey==`PublicAddress`].OutputValue | [0]' --output text) + instance_name=$(aws cloudformation describe-stacks --stack-name "$STACK_NAME" \ + --query 'Stacks[0].Outputs[?OutputKey==`InstanceName`].OutputValue | [0]' --output text) + echo "web_url=$web_url" >> "$GITHUB_OUTPUT" + echo "public_ip=$public_ip" >> "$GITHUB_OUTPUT" + echo "instance_name=$instance_name" >> "$GITHUB_OUTPUT" + echo "::notice::Stack up. WebURL=$web_url PublicIP=$public_ip InstanceName=$instance_name" + + - name: Assert outputs are populated + run: | + ip='${{ steps.outputs.outputs.public_ip }}' + url='${{ steps.outputs.outputs.web_url }}' + fail=0 + if [ -z "$ip" ] || [ "$ip" = "None" ]; then + echo "::error::PublicAddress output is empty."; fail=1 + fi + # The host must be present. Also reject any userinfo in the authority: + # Chrome answers the auth challenge with URL userinfo + an EMPTY + # password, and typed credentials can't override it (issue 56). + rest=${url#https://} + authority=${rest%%/*} + case "$authority" in *@*) + echo "::error::WebURL must not contain userinfo (user@): $url"; fail=1 ;; + esac + host=${authority%.sslip.io} + if [ -z "$host" ]; then + echo "::error::WebURL host is blank: $url"; fail=1 + fi + if [ "$fail" = 0 ]; then + echo "::notice::Outputs OK - PublicAddress=$ip host=$host" + fi + exit $fail + + - name: Smoke test — wait for authenticated terminal URL to serve ttyd HTML + env: + WEB_PASSWORD: ${{ steps.pw.outputs.value }} + run: | + url='${{ steps.outputs.outputs.web_url }}' + terminal_url="$url" + # CREATE_COMPLETE means the first switch is done, but Caddy's ACME + # cert issuance can still lag by a couple of minutes. + for i in $(seq 1 60); do + code=$(curl -sS -o /dev/null -w "%{http_code}" --max-time 10 \ + -u "agent:${WEB_PASSWORD}" "$terminal_url" || true) + echo "[$i/60] authenticated GET $terminal_url -> $code" + if [ "$code" = "200" ]; then + echo "::notice::Authenticated terminal URL is serving (HTTP 200) - Caddy + ttyd are up." + break + fi + sleep 10 + done + if [ "$code" != "200" ]; then + echo "::error::Never got 200 from $terminal_url after 10 min." + exit 1 + fi + + - name: Smoke test — verify unauthenticated URL returns 401 + run: | + url='${{ steps.outputs.outputs.web_url }}' + terminal_url="$url" + code=$(curl -sS -o /dev/null -w "%{http_code}" --max-time 5 "$terminal_url" || true) + if [ "$code" = "401" ]; then + echo "::notice::Unauthenticated terminal URL returns 401 as expected." + else + echo "::error::Expected 401 at $terminal_url (no Basic auth), got $code." + exit 1 + fi + + - name: Smoke test — root serves the tabbed workspace behind auth + env: + WEB_PASSWORD: ${{ steps.pw.outputs.value }} + run: | + url='${{ steps.outputs.outputs.web_url }}' + terminal_url="$url" + root_url="${terminal_url%agent/}" + # The root workspace must never be public: it iframes the terminal + # and can create sessions. + code=$(curl -sS -o /dev/null -w "%{http_code}" --max-time 10 "$root_url" || true) + if [ "$code" != "401" ]; then + echo "::error::Expected 401 at $root_url without credentials, got $code." + exit 1 + fi + body=$(curl -sS --max-time 10 -u "agent:${WEB_PASSWORD}" "$root_url" || true) + if echo "$body" | grep -q 'href="https://[^/"]*@'; then + echo "::error::Workspace at $root_url embeds URL userinfo (user@) in an href — Chrome auto-sends an empty password and typed credentials can't override it (issue 56):" + echo "$body" | head -20 + exit 1 + fi + # The tabbed workspace (issue 119): the tab bar carries the ttyd base + # for iframe panes, and each session is a ?tab= link. The iframe + # itself is NOT asserted — it only renders once the session's tmux is + # live, which can lag boot. + if ! echo "$body" | grep -qF 'data-term-base="/agent/"'; then + echo "::error::Workspace at $root_url isn't wired to the ttyd base /agent/ (no data-term-base):" + echo "$body" | head -20 + exit 1 + fi + if echo "$body" | grep -qF 'href="/?tab='; then + echo "::notice::Workspace tab bar lists the sessions (no credentials in the URL)." + else + echo "::error::Workspace at $root_url has no session tabs (href=\"/?tab=\"):" + echo "$body" | head -20 + exit 1 + fi + + - name: Smoke test — verify ttyd HTML body + env: + WEB_PASSWORD: ${{ steps.pw.outputs.value }} + run: | + url='${{ steps.outputs.outputs.web_url }}' + terminal_url="$url" + body=$(curl -sS --max-time 15 -u "agent:${WEB_PASSWORD}" "$terminal_url") + if echo "$body" | grep -qi "ttyd\|terminal"; then + echo "::notice::ttyd HTML served correctly." + else + echo "::error::Response body doesn't look like ttyd:" + echo "$body" | head -20 + exit 1 + fi + + - name: Smoke test — WebSocket upgrade + env: + WEB_PASSWORD: ${{ steps.pw.outputs.value }} + run: | + url='${{ steps.outputs.outputs.web_url }}' + terminal_url="$url" + cookie_jar=$(mktemp) + curl -sS -o /dev/null --max-time 15 -u "agent:${WEB_PASSWORD}" -c "$cookie_jar" "$terminal_url" + # 101 = Switching Protocols (success). Browsers send cookies on the + # upgrade request, so this proves the Basic-auth bootstrap cookie works. + code=$(curl -sS -o /dev/null -w "%{http_code}" --http1.1 --max-time 10 \ + -b "$cookie_jar" \ + -H "Connection: Upgrade" \ + -H "Upgrade: websocket" \ + -H "Sec-WebSocket-Version: 13" \ + -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \ + -H "Sec-WebSocket-Protocol: tty" \ + "${terminal_url}ws" || true) + echo "WebSocket upgrade attempt -> HTTP $code" + if [ "$code" = "101" ]; then + echo "::notice::WebSocket upgrade succeeded (101 Switching Protocols)." + else + echo "::error::WebSocket upgrade failed (got $code, expected 101)." + exit 1 + fi + + - name: Smoke test — tmux session is live (not just ttyd reachable) + env: + WEB_PASSWORD: ${{ steps.pw.outputs.value }} + run: | + # 101 above only proves ttyd upgrades the socket; it says nothing about + # whether `tmux attach` found a session. Attach for real and assert the + # terminal isn't showing tmux's "no sessions". Retries because the + # agent's session can take a moment to come up after boot. + python3 -m pip install --quiet websocket-client + url='${{ steps.outputs.outputs.web_url }}' + for i in $(seq 1 12); do + if python3 scripts/ws_smoke.py "$url" "$WEB_PASSWORD"; then + exit 0 + fi + echo "[$i/12] no live session yet; retrying in 10s..." + sleep 10 + done + echo "::error::Browser terminal never attached to a live tmux session." + exit 1 + + - name: Destroy stack + # Push runs (no inputs) always destroy; dispatched runs honor inputs.destroy. + if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.destroy) }} + run: | + # No Spot requests to cancel (Lightsail has none). delete-stack tears + # down the instance and detaches+deletes the static IP. + echo "Deleting $STACK_NAME..." + aws cloudformation delete-stack --stack-name "$STACK_NAME" + aws cloudformation wait stack-delete-complete --stack-name "$STACK_NAME" || true + + - name: Flag kept stack + if: ${{ always() && github.event_name == 'workflow_dispatch' && !inputs.destroy }} + run: | + echo "::warning::Stack '$STACK_NAME' was kept (destroy=false). It's still billing." + echo "::warning::Delete manually: aws cloudformation delete-stack --stack-name $STACK_NAME --region $AWS_REGION"