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.
This commit is contained in:
Hunter Bown
2026-06-10 16:20:57 -07:00
parent b23067bacd
commit 5483e1553d
7 changed files with 620 additions and 0 deletions
+143
View File
@@ -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@<IP>:/tmp/
rm /tmp/cw-secrets.env
ssh root@<IP> '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@<IP>` 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 <N> --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-<N> -b agent/<N>-<slug> origin/main
cd /opt/whalebro/worktrees/issue-<N>
# 4. Execute (run inside a tmux session for SSH-disconnect safety)
. /opt/whalebro/codewhale/scripts/remote-smoke/agent-session.sh
gh issue view <N> --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 "<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).
+15
View File
@@ -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}"
+104
View File
@@ -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"
+40
View File
@@ -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."
+107
View File
@@ -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"
+49
View File
@@ -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."
+162
View File
@@ -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"