From 5483e1553da82efb198292e37458c76ecf09b11e Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Wed, 10 Jun 2026 16:20:57 -0700 Subject: [PATCH 1/2] feat(remote-smoke): bump to v0.8.57, add gh CLI, swapfile, agent-session.sh, autonomous loop docs (#3022) - setup-vm.sh: bump RELEASE_TAG default to v0.8.57, add gh CLI install step (official APT repo) and 4G swapfile creation (idempotent) - agent-session.sh: new sourceable helper that exports the provider key from /etc/codewhale/runtime.env for interactive agent sessions - README.md: update version refs, add agent-session.sh to layout, add Autonomous agent loop section with full pick->PR commands The droplet ops (binary upgrade, PAT setup, first end-to-end issue run) are documented as the next steps for the operator. --- scripts/remote-smoke/README.md | 143 ++++++++++++++++ scripts/remote-smoke/agent-session.sh | 15 ++ .../remote-smoke/aws-lightsail/provision.sh | 104 +++++++++++ .../remote-smoke/aws-lightsail/teardown.sh | 40 +++++ .../remote-smoke/digitalocean/provision.sh | 107 ++++++++++++ scripts/remote-smoke/digitalocean/teardown.sh | 49 ++++++ scripts/remote-smoke/setup-vm.sh | 162 ++++++++++++++++++ 7 files changed, 620 insertions(+) create mode 100644 scripts/remote-smoke/README.md create mode 100755 scripts/remote-smoke/agent-session.sh create mode 100755 scripts/remote-smoke/aws-lightsail/provision.sh create mode 100755 scripts/remote-smoke/aws-lightsail/teardown.sh create mode 100755 scripts/remote-smoke/digitalocean/provision.sh create mode 100755 scripts/remote-smoke/digitalocean/teardown.sh create mode 100755 scripts/remote-smoke/setup-vm.sh diff --git a/scripts/remote-smoke/README.md b/scripts/remote-smoke/README.md new file mode 100644 index 00000000..11d8fc4f --- /dev/null +++ b/scripts/remote-smoke/README.md @@ -0,0 +1,143 @@ +# Remote-workbench smoke lab (EXPERIMENTAL) + +Status: experimental smoke-lab scripts for the US-first remote-workbench lane +(issue #1990). Not part of the supported install paths until the smoke passes +and this graduates into a documented setup. + +This concretizes `docs/REMOTE_VM_US.md`: a cheap US VPS running the CodeWhale +runtime on `127.0.0.1` plus the Telegram long-polling bridge, reusing the +provider-agnostic Ubuntu scripts under `scripts/tencent-lighthouse/` (audited: +nothing in them is Tencent-specific). + +## Layout + +- `setup-vm.sh` — provider-agnostic. Run on any fresh Ubuntu 24.04 VM: + bootstrap + prebuilt v0.8.57 release binaries (sha256-verified, no Rust + build) + `gh` CLI + 4G swapfile + Telegram bridge services + secrets + + validator + doctor. +- `digitalocean/provision.sh`, `digitalocean/teardown.sh` — active lane. + Chosen over AWS Lightsail for auth simplicity: one API token vs IAM + credential setup (#1990 allows "a clearly documented better alternative"). +- `aws-lightsail/provision.sh`, `aws-lightsail/teardown.sh` — kept as the + AWS alternative; same flow, needs `aws configure` first. +- `agent-session.sh` — sourceable helper for interactive/tmux agent sessions + as the `codewhale` user. Sources `/etc/codewhale/runtime.env` so the + provider key is available outside of systemd. + +Both provisioners print the API-reported monthly price and require a typed +`yes` before creating anything billable, and both teardowns end with a +leftover-billable-resources check. + +## Who this lane is for (China note) + +Telegram is blocked in mainland China and DigitalOcean has no China +datacenters (cross-border routes are slow; DO IP ranges are frequently +GFW-affected). Mainland-based users should use the existing Tencent +Lighthouse HK + Feishu/Lark lane (`docs/TENCENT_CLOUD_REMOTE_FIRST.md`) +instead — that is exactly why it exists. This lane is for users outside +mainland China. + +## Security model + +- Runtime API binds `127.0.0.1:7878` only; the only inbound port anywhere is + SSH (cloud firewall + ufw, both default to caller-IP /32 where supported). +- Telegram uses outbound long polling — no webhook, no public ingress. +- Telegram chats are allowlisted (`TELEGRAM_CHAT_ALLOWLIST`); unlisted chats + are refused. `TELEGRAM_ALLOW_UNLISTED=true` only for first pairing. +- Secrets travel as a chmod-600 file over scp, land in `/etc/codewhale/*.env` + (0640 root:codewhale), and the transfer file is shredded. Never in argv, + shell history, or logs. + +## Run order — DigitalOcean (from the laptop) + +```bash +# 0. once: create an API token (Web UI -> API -> Generate New Token, write +# scope), then in a real terminal: doctl auth init (paste token) + +# 1. provision (asks before billing starts) +bash scripts/remote-smoke/digitalocean/provision.sh +# defaults: sfo3, s-1vcpu-2gb (~$12/mo), ubuntu-24-04-x64, ~/.ssh/id_ed25519.pub + +# 2. secrets file (never commit; values from BotFather / provider console) +umask 077 && cat > /tmp/cw-secrets.env <<'EOF' +TELEGRAM_BOT_TOKEN=... +CODEWHALE_PROVIDER=deepseek +PROVIDER_KEY_NAME=DEEPSEEK_API_KEY +PROVIDER_KEY_VALUE=... +TELEGRAM_CHAT_ALLOWLIST=... # optional; empty enables first-pairing mode +EOF + +# 3. push secrets + installer, run it (DO Ubuntu images log in as root) +scp /tmp/cw-secrets.env scripts/remote-smoke/setup-vm.sh root@:/tmp/ +rm /tmp/cw-secrets.env +ssh root@ 'SECRETS_FILE=/tmp/cw-secrets.env bash /tmp/setup-vm.sh' + +# 4. phone smoke per docs/REMOTE_VM_US.md "First Smoke Test" + +# 5. teardown when done (stops billing) +bash scripts/remote-smoke/digitalocean/teardown.sh +``` + +For AWS Lightsail substitute step 0 with `aws configure`, step 1/5 with the +`aws-lightsail/` scripts, and ssh as `ubuntu@` with `sudo` in step 3. + +## Cost + +Billed hourly until destroyed. DO `s-1vcpu-2gb` ≈ $12/mo (~$0.018/h); +1 vCPU / 2 GB is enough because the VM downloads release binaries instead of +compiling Rust. A same-day smoke costs well under $1. Bigger options for a +longer-lived host: `s-2vcpu-2gb` (~$18/mo), `s-2vcpu-4gb` (~$24/mo, the +docs/REMOTE_VM_US.md default spec). + +## Known sharp edges (from the 2026-06-09 audit) + +- The Rust binary reads only `DEEPSEEK_RUNTIME_TOKEN`/`--auth-token` and + `--port`; the `CODEWHALE_RUNTIME_*` names in `/etc/codewhale/runtime.env` + work because the systemd unit expands them into flags. Don't start + `codewhale serve` by hand and expect the env file to apply. +- `codewhale-runtime.service` hard-fails activation if + `/home/codewhale/.codewhale` or `/home/codewhale/.deepseek` don't exist + (`ReadWritePaths`); `setup-vm.sh` pre-creates them. +- Both binaries are required (`codewhale` delegates to `codewhale-tui`). +- Exactly one bridge process per bot token — a second poller causes endless + Telegram 409s. Stop any local bridge before starting the VM one. +- `/interrupt` is queued behind an active streaming turn (known limitation, + documented in `docs/REMOTE_SETUP_DESIGN.md` hardening table). + +## Autonomous agent loop (#3022) + +Once the droplet is provisioned and `gh` is authenticated with a +fine-grained PAT (scoped to Hmbown/CodeWhale: Contents RW, Issues RW, +PRs RW, Metadata R), an agent can work the full pick→PR loop headless. + +```bash +# 1. Pick an agent-ready issue +gh issue list --repo Hmbown/CodeWhale --milestone v0.8.58 \ + --label agent-ready --state open --json number,title,url + +# 2. Claim it +gh issue edit --add-label agent-in-progress --remove-label agent-ready + +# 3. Isolate in a worktree +git -C /opt/whalebro/codewhale fetch origin +git -C /opt/whalebro/codewhale worktree add \ + /opt/whalebro/worktrees/issue- -b agent/- origin/main +cd /opt/whalebro/worktrees/issue- + +# 4. Execute (run inside a tmux session for SSH-disconnect safety) +. /opt/whalebro/codewhale/scripts/remote-smoke/agent-session.sh +gh issue view --json body -q .body | \ + codewhale exec --auto --output-format stream-json "$(cat)" + +# 5. Verify (run the issue's Verification block verbatim) +# 6. Deliver +gh pr create --repo Hmbown/CodeWhale --base main \ + --title "" --body "Closes #<N>" --label v0.8.58 + +# 7. On blockage: swap label to needs-human + comment +gh issue edit <N> --add-label needs-human --remove-label agent-in-progress +``` + +See `docs/AGENT_RUNNER.md` for the full protocol including safety rules +(PR-only delivery, no force-push, secrets never in argv/history/logs, +one worktree per issue). diff --git a/scripts/remote-smoke/agent-session.sh b/scripts/remote-smoke/agent-session.sh new file mode 100755 index 00000000..5cf7a304 --- /dev/null +++ b/scripts/remote-smoke/agent-session.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Source into an interactive agent shell (tmux, ssh) to export the provider +# key and set defaults that systemd normally handles via EnvironmentFile=. +# +# Usage (as the codewhale user): +# . /opt/whalebro/codewhale/scripts/remote-smoke/agent-session.sh +# codewhale models # should list deepseek-v4-pro +# gh auth status # should show the fine-grained PAT +# +# The runtime.env file is 0640 root:codewhale, readable by the codewhale user. +set -a +# shellcheck disable=SC1091 +. /etc/codewhale/runtime.env +set +a +export CODEWHALE_MODEL="${CODEWHALE_MODEL:-deepseek-v4-pro}" diff --git a/scripts/remote-smoke/aws-lightsail/provision.sh b/scripts/remote-smoke/aws-lightsail/provision.sh new file mode 100755 index 00000000..536e216d --- /dev/null +++ b/scripts/remote-smoke/aws-lightsail/provision.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# EXPERIMENTAL — AWS Lightsail smoke-lab provisioning for the CodeWhale +# remote workbench (issue #1990). Creates ONE Ubuntu 24.04 Lightsail +# instance with SSH-only firewall. Prints every step and the monthly price +# from the Lightsail API, then requires an explicit "yes" before creating +# anything that costs money. +# +# Usage: +# AWS_REGION=us-east-1 bash scripts/aws-lightsail/provision.sh +# +# Tunables (env): +# INSTANCE_NAME default codewhale-smoke +# BUNDLE_ID default medium_3_0 (2 vCPU / 4 GB — docs/REMOTE_VM_US.md default) +# BLUEPRINT_ID default ubuntu_24_04 +# SSH_PUBKEY default ~/.ssh/id_ed25519.pub (imported as key pair) +# RESTRICT_SSH_TO_MY_IP default true (firewall cidr = caller IP /32) +set -euo pipefail + +INSTANCE_NAME="${INSTANCE_NAME:-codewhale-smoke}" +BUNDLE_ID="${BUNDLE_ID:-medium_3_0}" +BLUEPRINT_ID="${BLUEPRINT_ID:-ubuntu_24_04}" +SSH_PUBKEY="${SSH_PUBKEY:-$HOME/.ssh/id_ed25519.pub}" +KEY_PAIR_NAME="${KEY_PAIR_NAME:-${INSTANCE_NAME}-key}" +RESTRICT_SSH_TO_MY_IP="${RESTRICT_SSH_TO_MY_IP:-true}" + +command -v aws >/dev/null || { echo "aws CLI is required" >&2; exit 1; } +aws sts get-caller-identity >/dev/null || { echo "aws is not authenticated; run 'aws configure' or 'aws sso login'" >&2; exit 1; } + +REGION="${AWS_REGION:-$(aws configure get region || true)}" +[[ -n "${REGION}" ]] || { echo "Set AWS_REGION (e.g. us-east-1)" >&2; exit 1; } + +echo "== Preflight ==" +aws lightsail get-blueprints --region "$REGION" \ + --query "blueprints[?blueprintId=='${BLUEPRINT_ID}'].[blueprintId,name]" --output text \ + | grep -q . || { echo "Blueprint ${BLUEPRINT_ID} not found in ${REGION}" >&2; exit 1; } + +PRICE=$(aws lightsail get-bundles --region "$REGION" \ + --query "bundles[?bundleId=='${BUNDLE_ID}'].price | [0]" --output text) +SPECS=$(aws lightsail get-bundles --region "$REGION" \ + --query "bundles[?bundleId=='${BUNDLE_ID}'].[cpuCount,ramSizeInGb,diskSizeInGb] | [0]" --output text) +[[ "$PRICE" != "None" && -n "$PRICE" ]] || { echo "Bundle ${BUNDLE_ID} not found in ${REGION}" >&2; exit 1; } + +[[ -f "$SSH_PUBKEY" ]] || { echo "SSH public key not found: $SSH_PUBKEY" >&2; exit 1; } + +echo "Region: $REGION" +echo "Instance: $INSTANCE_NAME" +echo "Blueprint: $BLUEPRINT_ID" +echo "Bundle: $BUNDLE_ID (vCPU/RAM-GB/Disk-GB: $SPECS)" +echo "Monthly price: \$$PRICE USD (billed hourly until deleted)" +echo "SSH key: $SSH_PUBKEY -> key pair '$KEY_PAIR_NAME'" +echo +read -r -p "Create this instance and start billing? Type 'yes' to proceed: " CONFIRM +[[ "$CONFIRM" == "yes" ]] || { echo "Aborted; nothing created."; exit 1; } + +echo "== Import SSH key pair ==" +if ! aws lightsail get-key-pair --region "$REGION" --key-pair-name "$KEY_PAIR_NAME" >/dev/null 2>&1; then + aws lightsail import-key-pair --region "$REGION" \ + --key-pair-name "$KEY_PAIR_NAME" \ + --public-key-base64 "$(base64 < "$SSH_PUBKEY")" >/dev/null + echo "imported $KEY_PAIR_NAME" +else + echo "key pair $KEY_PAIR_NAME already exists; reusing" +fi + +echo "== Create instance ==" +AZ=$(aws lightsail get-regions --include-availability-zones --region "$REGION" \ + --query "regions[?name=='${REGION}'].availabilityZones[0].zoneName | [0]" --output text) +aws lightsail create-instances --region "$REGION" \ + --instance-names "$INSTANCE_NAME" \ + --availability-zone "$AZ" \ + --blueprint-id "$BLUEPRINT_ID" \ + --bundle-id "$BUNDLE_ID" \ + --key-pair-name "$KEY_PAIR_NAME" >/dev/null +echo "created $INSTANCE_NAME in $AZ; waiting for running state..." + +for _ in $(seq 1 60); do + STATE=$(aws lightsail get-instance-state --region "$REGION" --instance-name "$INSTANCE_NAME" \ + --query 'state.name' --output text 2>/dev/null || echo pending) + [[ "$STATE" == "running" ]] && break + sleep 5 +done +[[ "${STATE:-}" == "running" ]] || { echo "instance did not reach running state" >&2; exit 1; } + +echo "== Firewall: SSH only ==" +CIDR="0.0.0.0/0" +if [[ "$RESTRICT_SSH_TO_MY_IP" == "true" ]]; then + MYIP=$(curl -fsS https://checkip.amazonaws.com | tr -d '\n') + CIDR="${MYIP}/32" +fi +aws lightsail put-instance-public-ports --region "$REGION" \ + --instance-name "$INSTANCE_NAME" \ + --port-infos "fromPort=22,toPort=22,protocol=tcp,cidrs=${CIDR}" >/dev/null +echo "open ports replaced with: 22/tcp from ${CIDR} (everything else closed)" + +IP=$(aws lightsail get-instance --region "$REGION" --instance-name "$INSTANCE_NAME" \ + --query 'instance.publicIpAddress' --output text) +echo +echo "== Done ==" +echo "Instance: $INSTANCE_NAME ($REGION, $STATE)" +echo "Public IP: $IP" +echo "SSH: ssh -i ${SSH_PUBKEY%.pub} ubuntu@${IP}" +echo +echo "Teardown when finished (stops billing):" +echo " AWS_REGION=$REGION bash scripts/aws-lightsail/teardown.sh" diff --git a/scripts/remote-smoke/aws-lightsail/teardown.sh b/scripts/remote-smoke/aws-lightsail/teardown.sh new file mode 100755 index 00000000..dc1242d5 --- /dev/null +++ b/scripts/remote-smoke/aws-lightsail/teardown.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# EXPERIMENTAL — tears down the CodeWhale Lightsail smoke lab and verifies +# nothing billable is left behind (instance, key pair, static IPs, disks, +# snapshots). Safe to re-run; prints what it finds before deleting. +set -euo pipefail + +INSTANCE_NAME="${INSTANCE_NAME:-codewhale-smoke}" +KEY_PAIR_NAME="${KEY_PAIR_NAME:-${INSTANCE_NAME}-key}" +REGION="${AWS_REGION:-$(aws configure get region || true)}" +[[ -n "${REGION}" ]] || { echo "Set AWS_REGION" >&2; exit 1; } + +echo "== Current Lightsail resources in ${REGION} ==" +aws lightsail get-instances --region "$REGION" \ + --query 'instances[].[name,state.name,bundleId]' --output table || true + +if aws lightsail get-instance --region "$REGION" --instance-name "$INSTANCE_NAME" >/dev/null 2>&1; then + read -r -p "Delete instance '${INSTANCE_NAME}'? Type 'yes': " CONFIRM + [[ "$CONFIRM" == "yes" ]] || { echo "Aborted."; exit 1; } + aws lightsail delete-instance --region "$REGION" --instance-name "$INSTANCE_NAME" >/dev/null + echo "deleted instance ${INSTANCE_NAME}" +else + echo "instance ${INSTANCE_NAME} not found (already deleted?)" +fi + +if aws lightsail get-key-pair --region "$REGION" --key-pair-name "$KEY_PAIR_NAME" >/dev/null 2>&1; then + aws lightsail delete-key-pair --region "$REGION" --key-pair-name "$KEY_PAIR_NAME" >/dev/null + echo "deleted key pair ${KEY_PAIR_NAME}" +fi + +echo "== Leftover billable resources check ==" +echo "-- static IPs (billed when unattached):" +aws lightsail get-static-ips --region "$REGION" --query 'staticIps[].[name,isAttached]' --output table +echo "-- extra disks:" +aws lightsail get-disks --region "$REGION" --query 'disks[].[name,state]' --output table +echo "-- instance snapshots:" +aws lightsail get-instance-snapshots --region "$REGION" --query 'instanceSnapshots[].[name,state]' --output table +echo "-- remaining instances:" +aws lightsail get-instances --region "$REGION" --query 'instances[].[name,state.name]' --output table +echo +echo "If all tables above are empty, Lightsail billing for this lab is fully stopped." diff --git a/scripts/remote-smoke/digitalocean/provision.sh b/scripts/remote-smoke/digitalocean/provision.sh new file mode 100755 index 00000000..89076e27 --- /dev/null +++ b/scripts/remote-smoke/digitalocean/provision.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# EXPERIMENTAL — DigitalOcean smoke-lab provisioning for the CodeWhale +# remote workbench (issue #1990 "clearly documented better alternative" +# clause). Creates ONE Ubuntu 24.04 droplet plus a cloud firewall that +# allows inbound SSH only. Prints the monthly price from the DO API and +# requires a typed "yes" before creating anything billable. +# +# Auth: doctl must be authenticated. Either +# doctl auth init # paste token interactively, or +# export DIGITALOCEAN_ACCESS_TOKEN=... # doctl reads this env var +# +# Usage: +# bash scripts/remote-smoke/digitalocean/provision.sh +# +# Tunables (env): +# DROPLET_NAME default codewhale-smoke +# DO_REGION default sfo3 (San Francisco) +# DROPLET_SIZE default s-1vcpu-2gb (~$12/mo; prebuilt binaries mean no +# Rust build, so 1 vCPU / 2 GB is enough for the smoke. +# Use s-2vcpu-2gb/s-2vcpu-4gb for a longer-lived host.) +# DROPLET_IMAGE default ubuntu-24-04-x64 +# SSH_PUBKEY default ~/.ssh/id_ed25519.pub (imported if not present) +# RESTRICT_SSH_TO_MY_IP default true (firewall source = caller IP /32) +set -euo pipefail + +DROPLET_NAME="${DROPLET_NAME:-codewhale-smoke}" +DO_REGION="${DO_REGION:-sfo3}" +DROPLET_SIZE="${DROPLET_SIZE:-s-1vcpu-2gb}" +DROPLET_IMAGE="${DROPLET_IMAGE:-ubuntu-24-04-x64}" +SSH_PUBKEY="${SSH_PUBKEY:-$HOME/.ssh/id_ed25519.pub}" +SSH_KEY_NAME="${SSH_KEY_NAME:-${DROPLET_NAME}-key}" +FIREWALL_NAME="${FIREWALL_NAME:-${DROPLET_NAME}-ssh-only}" +RESTRICT_SSH_TO_MY_IP="${RESTRICT_SSH_TO_MY_IP:-true}" + +command -v doctl >/dev/null || { echo "doctl is required (brew install doctl)" >&2; exit 1; } +doctl account get >/dev/null || { echo "doctl is not authenticated; run 'doctl auth init' or set DIGITALOCEAN_ACCESS_TOKEN" >&2; exit 1; } +[[ -f "$SSH_PUBKEY" ]] || { echo "SSH public key not found: $SSH_PUBKEY" >&2; exit 1; } + +echo "== Preflight ==" +[[ "$(doctl compute region list --format Slug,Available --no-header | awk -v r="$DO_REGION" '$1 == r {print $2}')" == "true" ]] \ + || { echo "Region ${DO_REGION} not available" >&2; exit 1; } + +read -r PRICE VCPUS MEM DISK < <(doctl compute size list \ + --format Slug,PriceMonthly,VCPUs,Memory,Disk --no-header \ + | awk -v s="$DROPLET_SIZE" '$1 == s {print $2, $3, $4, $5}') +[[ -n "${PRICE:-}" ]] || { echo "Size ${DROPLET_SIZE} not found" >&2; exit 1; } + +echo "Region: $DO_REGION" +echo "Droplet: $DROPLET_NAME" +echo "Image: $DROPLET_IMAGE" +echo "Size: $DROPLET_SIZE (${VCPUS} vCPU / ${MEM} MB RAM / ${DISK} GB disk)" +echo "Monthly price: \$$PRICE USD (billed hourly until destroyed)" +echo "SSH key: $SSH_PUBKEY -> '$SSH_KEY_NAME'" +echo "Firewall: $FIREWALL_NAME (inbound 22/tcp only)" +echo +read -r -p "Create this droplet and start billing? Type 'yes' to proceed: " CONFIRM +[[ "$CONFIRM" == "yes" ]] || { echo "Aborted; nothing created."; exit 1; } + +echo "== Import SSH key ==" +KEY_ID=$(doctl compute ssh-key list --format ID,Name --no-header | awk -v n="$SSH_KEY_NAME" '$2 == n {print $1; exit}') +if [[ -z "$KEY_ID" ]]; then + KEY_ID=$(doctl compute ssh-key import "$SSH_KEY_NAME" --public-key-file "$SSH_PUBKEY" --format ID --no-header) + echo "imported $SSH_KEY_NAME (id $KEY_ID)" +else + echo "key $SSH_KEY_NAME already exists (id $KEY_ID); reusing" +fi + +echo "== Create droplet ==" +doctl compute droplet create "$DROPLET_NAME" \ + --region "$DO_REGION" \ + --image "$DROPLET_IMAGE" \ + --size "$DROPLET_SIZE" \ + --ssh-keys "$KEY_ID" \ + --tag-name codewhale-smoke \ + --wait >/dev/null +DROPLET_ID=$(doctl compute droplet list --format ID,Name --no-header | awk -v n="$DROPLET_NAME" '$2 == n {print $1; exit}') +IP=$(doctl compute droplet get "$DROPLET_ID" --format PublicIPv4 --no-header) +echo "created $DROPLET_NAME (id $DROPLET_ID, $IP)" + +echo "== Cloud firewall: SSH only ==" +SRC="0.0.0.0/0,address:::/0" +if [[ "$RESTRICT_SSH_TO_MY_IP" == "true" ]]; then + MYIP=$(curl -fsS https://api.ipify.org) + SRC="${MYIP}/32" +fi +if ! doctl compute firewall list --format Name --no-header | grep -qx "$FIREWALL_NAME"; then + doctl compute firewall create \ + --name "$FIREWALL_NAME" \ + --inbound-rules "protocol:tcp,ports:22,address:${SRC}" \ + --outbound-rules "protocol:tcp,ports:all,address:0.0.0.0/0,address:::/0 protocol:udp,ports:all,address:0.0.0.0/0,address:::/0 protocol:icmp,address:0.0.0.0/0,address:::/0" \ + --droplet-ids "$DROPLET_ID" >/dev/null + echo "firewall $FIREWALL_NAME created: inbound 22/tcp from ${SRC}, all else blocked" +else + FW_ID=$(doctl compute firewall list --format ID,Name --no-header | awk -v n="$FIREWALL_NAME" '$2 == n {print $1; exit}') + doctl compute firewall add-droplets "$FW_ID" --droplet-ids "$DROPLET_ID" + echo "existing firewall $FIREWALL_NAME attached" +fi + +echo +echo "== Done ==" +echo "Droplet: $DROPLET_NAME ($DO_REGION)" +echo "Public IP: $IP" +echo "SSH: ssh -i ${SSH_PUBKEY%.pub} root@${IP}" +echo " (DO Ubuntu images log in as root, not ubuntu)" +echo +echo "Teardown when finished (stops billing):" +echo " bash scripts/remote-smoke/digitalocean/teardown.sh" diff --git a/scripts/remote-smoke/digitalocean/teardown.sh b/scripts/remote-smoke/digitalocean/teardown.sh new file mode 100755 index 00000000..0d791acc --- /dev/null +++ b/scripts/remote-smoke/digitalocean/teardown.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# EXPERIMENTAL — tears down the CodeWhale DigitalOcean smoke lab and lists +# anything billable that remains (droplets, volumes, snapshots, reserved +# IPs). Safe to re-run; prints what it finds before deleting. +set -euo pipefail + +DROPLET_NAME="${DROPLET_NAME:-codewhale-smoke}" +SSH_KEY_NAME="${SSH_KEY_NAME:-${DROPLET_NAME}-key}" +FIREWALL_NAME="${FIREWALL_NAME:-${DROPLET_NAME}-ssh-only}" + +command -v doctl >/dev/null || { echo "doctl is required" >&2; exit 1; } +doctl account get >/dev/null || { echo "doctl is not authenticated" >&2; exit 1; } + +echo "== Current droplets ==" +doctl compute droplet list --format ID,Name,Status,Region,SizeSlug + +DROPLET_ID=$(doctl compute droplet list --format ID,Name --no-header | awk -v n="$DROPLET_NAME" '$2 == n {print $1; exit}') +if [[ -n "$DROPLET_ID" ]]; then + read -r -p "Destroy droplet '${DROPLET_NAME}' (id ${DROPLET_ID})? Type 'yes': " CONFIRM + [[ "$CONFIRM" == "yes" ]] || { echo "Aborted."; exit 1; } + doctl compute droplet delete "$DROPLET_ID" --force + echo "destroyed droplet ${DROPLET_NAME}" +else + echo "droplet ${DROPLET_NAME} not found (already destroyed?)" +fi + +FW_ID=$(doctl compute firewall list --format ID,Name --no-header | awk -v n="$FIREWALL_NAME" '$2 == n {print $1; exit}') +if [[ -n "$FW_ID" ]]; then + doctl compute firewall delete "$FW_ID" --force + echo "deleted firewall ${FIREWALL_NAME}" +fi + +KEY_ID=$(doctl compute ssh-key list --format ID,Name --no-header | awk -v n="$SSH_KEY_NAME" '$2 == n {print $1; exit}') +if [[ -n "$KEY_ID" ]]; then + doctl compute ssh-key delete "$KEY_ID" --force + echo "deleted ssh key ${SSH_KEY_NAME}" +fi + +echo "== Leftover billable resources check ==" +echo "-- droplets:" +doctl compute droplet list --format ID,Name,Status +echo "-- volumes:" +doctl compute volume list --format ID,Name,Size +echo "-- snapshots:" +doctl compute snapshot list --format ID,Name,ResourceType +echo "-- reserved IPs (billed when unassigned):" +doctl compute reserved-ip list --format IP,DropletID +echo +echo "If all lists above are empty, DigitalOcean billing for this lab is fully stopped." diff --git a/scripts/remote-smoke/setup-vm.sh b/scripts/remote-smoke/setup-vm.sh new file mode 100755 index 00000000..33312166 --- /dev/null +++ b/scripts/remote-smoke/setup-vm.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +# EXPERIMENTAL — one-shot CodeWhale + Telegram bridge setup for a fresh +# AWS Lightsail Ubuntu 24.04 VM (issue #1990 smoke lane). +# +# Run ON THE VM as root: +# sudo SECRETS_FILE=/tmp/cw-secrets.env bash setup-vm.sh +# +# SECRETS_FILE is a chmod-600 env file you scp'd up, containing: +# TELEGRAM_BOT_TOKEN=... # from @BotFather +# CODEWHALE_PROVIDER=deepseek # or arcee / xiaomi-mimo / ... +# PROVIDER_KEY_NAME=DEEPSEEK_API_KEY +# PROVIDER_KEY_VALUE=... +# TELEGRAM_CHAT_ALLOWLIST=123456789 # optional; empty = first-pairing mode +# The file is shredded after the values land in /etc/codewhale/*.env. +# +# Reuses the repo's existing provider-agnostic scripts: +# scripts/tencent-lighthouse/bootstrap-ubuntu.sh +# scripts/tencent-lighthouse/install-services.sh (CODEWHALE_BRIDGE=telegram) +# scripts/tencent-lighthouse/doctor.sh +# Uses prebuilt release binaries instead of a Rust build. +set -euo pipefail + +RELEASE_TAG="${RELEASE_TAG:-v0.8.57}" +REPO_URL="${REPO_URL:-https://github.com/Hmbown/CodeWhale.git}" +REPO_BRANCH="${REPO_BRANCH:-main}" +SECRETS_FILE="${SECRETS_FILE:-/tmp/cw-secrets.env}" + +[[ "$EUID" -eq 0 ]] || { echo "run as root (sudo)" >&2; exit 1; } +[[ -f "$SECRETS_FILE" ]] || { echo "SECRETS_FILE not found: $SECRETS_FILE" >&2; exit 1; } + +# shellcheck disable=SC1090 +. "$SECRETS_FILE" +: "${TELEGRAM_BOT_TOKEN:?missing in SECRETS_FILE}" +: "${CODEWHALE_PROVIDER:?missing in SECRETS_FILE}" +: "${PROVIDER_KEY_NAME:?missing in SECRETS_FILE}" +: "${PROVIDER_KEY_VALUE:?missing in SECRETS_FILE}" +TELEGRAM_CHAT_ALLOWLIST="${TELEGRAM_CHAT_ALLOWLIST:-}" + +echo "== [1/8] clone repo (${REPO_BRANCH}) ==" +apt-get update -q +apt-get install -y -q git curl ca-certificates +if [[ ! -d /tmp/codewhale/.git ]]; then + git clone --depth 1 --branch "$REPO_BRANCH" "$REPO_URL" /tmp/codewhale +fi + +echo "== [2/8] bootstrap (user, dirs, packages, ufw, env skeletons) ==" +CODEWHALE_REPO_URL="$REPO_URL" CODEWHALE_REPO_BRANCH="$REPO_BRANCH" \ + bash /tmp/codewhale/scripts/tencent-lighthouse/bootstrap-ubuntu.sh + +echo "== [3/8] install prebuilt ${RELEASE_TAG} binaries (no Rust build) ==" +# The systemd unit hardcodes /home/codewhale/.cargo/bin/codewhale, so we put +# the release binaries exactly there. +BIN_DIR=/home/codewhale/.cargo/bin +install -d -o codewhale -g codewhale "$BIN_DIR" +BASE="https://github.com/Hmbown/CodeWhale/releases/download/${RELEASE_TAG}" +TMP=$(mktemp -d) +curl -fsSL -o "$TMP/codewhale" "$BASE/codewhale-linux-x64" +curl -fsSL -o "$TMP/codewhale-tui" "$BASE/codewhale-tui-linux-x64" +curl -fsSL -o "$TMP/sha256.txt" "$BASE/codewhale-artifacts-sha256.txt" +( cd "$TMP" + grep -E ' (codewhale|codewhale-tui)-linux-x64$' sha256.txt \ + | sed 's/codewhale-linux-x64/codewhale/; s/codewhale-tui-linux-x64/codewhale-tui/' \ + | sha256sum -c - ) +install -m 0755 -o codewhale -g codewhale "$TMP/codewhale" "$BIN_DIR/codewhale" +install -m 0755 -o codewhale -g codewhale "$TMP/codewhale-tui" "$BIN_DIR/codewhale-tui" +rm -rf "$TMP" +sudo -u codewhale "$BIN_DIR/codewhale" --version +sudo -u codewhale "$BIN_DIR/codewhale-tui" --version + +echo "== [4/8] install services (telegram bridge) ==" +CODEWHALE_BRIDGE=telegram bash /tmp/codewhale/scripts/tencent-lighthouse/install-services.sh + +echo "== [5/8] write secrets into /etc/codewhale/*.env ==" +RUNTIME_ENV=/etc/codewhale/runtime.env +BRIDGE_ENV=/etc/codewhale/telegram-bridge.env +RUNTIME_TOKEN="dst_$(openssl rand -hex 24)" + +set_kv() { # file key value (replace or append; never echoes the value) + local file="$1" key="$2" value="$3" + if grep -qE "^${key}=" "$file"; then + # use | delimiter; tokens never contain | + sed -i "s|^${key}=.*|${key}=${value}|" "$file" + else + printf '%s=%s\n' "$key" "$value" >> "$file" + fi +} +set_kv "$RUNTIME_ENV" CODEWHALE_RUNTIME_TOKEN "$RUNTIME_TOKEN" +set_kv "$RUNTIME_ENV" CODEWHALE_PROVIDER "$CODEWHALE_PROVIDER" +set_kv "$RUNTIME_ENV" "$PROVIDER_KEY_NAME" "$PROVIDER_KEY_VALUE" +set_kv "$BRIDGE_ENV" CODEWHALE_RUNTIME_TOKEN "$RUNTIME_TOKEN" +set_kv "$BRIDGE_ENV" TELEGRAM_BOT_TOKEN "$TELEGRAM_BOT_TOKEN" +if [[ -n "$TELEGRAM_CHAT_ALLOWLIST" ]]; then + set_kv "$BRIDGE_ENV" TELEGRAM_CHAT_ALLOWLIST "$TELEGRAM_CHAT_ALLOWLIST" + set_kv "$BRIDGE_ENV" TELEGRAM_ALLOW_UNLISTED false +else + echo "[warn] no TELEGRAM_CHAT_ALLOWLIST given: enabling first-pairing mode" + echo "[warn] (TELEGRAM_ALLOW_UNLISTED=true). DM the bot /status, copy the" + echo "[warn] chat_id into TELEGRAM_CHAT_ALLOWLIST, set ALLOW_UNLISTED=false," + echo "[warn] then: systemctl restart codewhale-telegram-bridge" + set_kv "$BRIDGE_ENV" TELEGRAM_ALLOW_UNLISTED true +fi +chmod 0640 "$RUNTIME_ENV" "$BRIDGE_ENV" +chown root:codewhale "$RUNTIME_ENV" "$BRIDGE_ENV" +shred -u "$SECRETS_FILE" +echo "secrets written; $SECRETS_FILE shredded" + +echo "== [5b/8] install gh CLI (for autonomous agent PR workflow) ==" +if ! command -v gh &>/dev/null; then + apt-get install -y -q software-properties-common + # cli.github.com recommends the official APT repo for Ubuntu + (type -p wget &>/dev/null || apt-get install -y -q wget) + mkdir -p -m 755 /etc/apt/keyrings + wget -qO- https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + | tee /etc/apt/keyrings/githubcli-archive-keyring.gpg >/dev/null + chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + | tee /etc/apt/sources.list.d/github-cli.list >/dev/null + apt-get update -q + apt-get install -y -q gh +fi + +echo "== [5c/8] create 4G swapfile (idempotent) ==" +if [[ ! -f /swapfile ]]; then + fallocate -l 4G /swapfile + chmod 600 /swapfile + mkswap /swapfile + swapon /swapfile + echo '/swapfile none swap sw 0 0' >> /etc/fstab + echo "swapfile created and activated" +else + echo "swapfile already exists, skipping" +fi + +echo "== [6/8] pre-create runtime ReadWritePaths (unit fails without them) ==" +install -d -o codewhale -g codewhale -m 0700 \ + /home/codewhale/.codewhale /home/codewhale/.deepseek + +echo "== [7/8] validate config ==" +sudo -u codewhale node /opt/codewhale/telegram-bridge/scripts/validate-config.mjs \ + --env "$BRIDGE_ENV" --runtime-env "$RUNTIME_ENV" \ + --workspace-root /opt/whalebro --check-filesystem + +echo "== [8/8] start + doctor ==" +systemctl start codewhale-runtime +for _ in $(seq 1 20); do + curl -fsS --max-time 2 http://127.0.0.1:7878/health >/dev/null 2>&1 && break + sleep 1 +done +curl -fsS http://127.0.0.1:7878/health; echo +systemctl start codewhale-telegram-bridge +sleep 3 +CODEWHALE_BRIDGE=telegram bash /tmp/codewhale/scripts/tencent-lighthouse/doctor.sh + +echo +echo "== Setup complete. Phone smoke checklist (docs/REMOTE_VM_US.md): ==" +echo " 1. DM the bot: /status" +echo " 2. /menu (tappable controls)" +echo " 3. prompt: summarize git status in /opt/whalebro/codewhale" +echo " 4. /threads then a Resume button" +echo " 5. trigger a shell approval; test Allow/Deny buttons and /allow|/deny" +echo " 6. /interrupt during an active turn" +echo " 7. sudo reboot; confirm both services return: systemctl status codewhale-runtime codewhale-telegram-bridge" From 9de6c9d1252e0fec4aa07cd62ba064627bc0c313 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 11 Jun 2026 02:31:34 +0000 Subject: [PATCH 2/2] docs(remote-smoke): add gh auth setup-git + git identity to the autonomous-loop setup; qualify the AGENT_RUNNER.md cross-reference (file lands in #3043) with an on-branch fallback (#3022) Co-Authored-By: Claude <noreply@anthropic.com> https://claude.ai/code/session_018zaP8vUfTAsrE38L6h6fw5 --- scripts/remote-smoke/README.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/scripts/remote-smoke/README.md b/scripts/remote-smoke/README.md index 11d8fc4f..e5f02f27 100644 --- a/scripts/remote-smoke/README.md +++ b/scripts/remote-smoke/README.md @@ -110,6 +110,15 @@ Once the droplet is provisioned and `gh` is authenticated with a fine-grained PAT (scoped to Hmbown/CodeWhale: Contents RW, Issues RW, PRs RW, Metadata R), an agent can work the full pick→PR loop headless. +One-time git wiring after `gh auth login` so pushes use the PAT and +commits have a stable identity: + +```bash +gh auth setup-git +git config --global user.name "whalebro-agent" +git config --global user.email "whalebro-agent@users.noreply.github.com" +``` + ```bash # 1. Pick an agent-ready issue gh issue list --repo Hmbown/CodeWhale --milestone v0.8.58 \ @@ -138,6 +147,7 @@ gh pr create --repo Hmbown/CodeWhale --base main \ gh issue edit <N> --add-label needs-human --remove-label agent-in-progress ``` -See `docs/AGENT_RUNNER.md` for the full protocol including safety rules -(PR-only delivery, no force-push, secrets never in argv/history/logs, -one worktree per issue). +See `docs/AGENT_RUNNER.md` (added by #3043; until that lands, the design +background lives in `docs/rfcs/REMOTE_SETUP_DESIGN.md`) for the full +protocol including safety rules (PR-only delivery, no force-push, secrets +never in argv/history/logs, one worktree per issue).