Merge PR #3044 from Hmbown: remote-smoke droplet loop — gh CLI, swapfile, agent-session bumps
feat(remote-smoke): bump to v0.8.57, add gh CLI, swapfile, agent-session.sh, autonomous loop docs
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
# 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.
|
||||
|
||||
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 \
|
||||
--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` (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).
|
||||
Executable
+15
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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."
|
||||
Executable
+162
@@ -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"
|
||||
Reference in New Issue
Block a user