Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ jobs:
- name: Typecheck
run: npm run typecheck

- name: ShellCheck
run: |
find bin/ setup.sh start.sh -type f \( -name '*.sh' -o -name 'hornet-safe-bash' -o -name 'hornet-docker' \) \
| xargs shellcheck -s bash -S warning

test:
runs-on: ubuntu-latest
steps:
Expand Down
83 changes: 83 additions & 0 deletions .github/workflows/integration.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
name: Integration

on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:

concurrency:
group: integration-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true

jobs:
integration:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- distro: ubuntu
image: ubuntu-24-04-x64
setup_script: bin/ci/setup-ubuntu.sh
# To add Arch (or other distros), add entries here:
# - distro: arch
# image: arch-linux-x64 # or a custom snapshot ID
# setup_script: bin/ci/setup-arch.sh

name: ${{ matrix.distro }}
timeout-minutes: 10

steps:
- uses: actions/checkout@v4

- name: Generate ephemeral SSH key
run: |
mkdir -p ~/.ssh
ssh-keygen -t ed25519 -f ~/.ssh/ci_key -N "" -q

- name: Create droplet
id: droplet
env:
DO_API_TOKEN: ${{ secrets.DO_API_TOKEN }}
run: |
output=$(bash bin/ci/droplet.sh create \
"ci-${{ matrix.distro }}-${{ github.run_id }}" \
"${{ matrix.image }}" \
~/.ssh/ci_key.pub)
echo "$output" >> "$GITHUB_OUTPUT"
echo "$output"

- name: Wait for SSH
env:
DO_API_TOKEN: ${{ secrets.DO_API_TOKEN }}
run: |
bash bin/ci/droplet.sh wait-ssh \
"${{ steps.droplet.outputs.DROPLET_IP }}" \
~/.ssh/ci_key

- name: Upload source
run: |
tar czf /tmp/hornet-src.tar.gz \
--exclude=node_modules --exclude=.git .
scp -o StrictHostKeyChecking=no -o BatchMode=yes \
-i ~/.ssh/ci_key \
/tmp/hornet-src.tar.gz \
"root@${{ steps.droplet.outputs.DROPLET_IP }}:/tmp/hornet-src.tar.gz"

- name: Setup and test
run: |
bash bin/ci/droplet.sh run \
"${{ steps.droplet.outputs.DROPLET_IP }}" \
~/.ssh/ci_key \
"${{ matrix.setup_script }}"

- name: Cleanup
if: always()
env:
DO_API_TOKEN: ${{ secrets.DO_API_TOKEN }}
run: |
bash bin/ci/droplet.sh destroy \
"${{ steps.droplet.outputs.DROPLET_ID }}" \
"${{ steps.droplet.outputs.SSH_KEY_ID }}"
38 changes: 38 additions & 0 deletions .pi/todos/20e26efc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"id": "20e26efc",
"title": "CI job: run setup + tests on fresh Ubuntu droplet per PR",
"tags": [
"infra",
"ci",
"ubuntu"
],
"status": "done",
"created_at": "2026-02-17T02:30:52.375Z"
}

## Result
Implemented and verified end-to-end. Workflow will activate once PR merges to main (GH Actions limitation: new workflow files in PRs don't trigger until they exist on the base branch).

### What was built
- **`bin/ci/droplet.sh`** — reusable DO droplet lifecycle: `create`, `destroy`, `wait-ssh`, `run`
- **`bin/ci/setup-ubuntu.sh`** — Ubuntu-specific: prereqs, setup.sh, test suite
- **`.github/workflows/integration.yml`** — matrix-based workflow (Ubuntu now, extensible)

### Design
- **Ephemeral everything**: fresh droplet + SSH key generated per run, destroyed on cleanup
- **Single secret**: `DO_API_TOKEN` (set in GitHub repo)
- **Matrix**: `include` array with `distro`, `image`, `setup_script` — add Arch/other by adding entries
- **Concurrency group**: one run at a time per PR
- **Always cleanup**: `if: always()` destroys droplet + SSH key even on failure

### Local test results (3 full cycles)
- Create droplet: ~15s
- SSH ready: ~10s
- Prereqs + setup + deploy: ~90s
- Tests (5 suites): ~10s
- Destroy: instant
- **Total: ~2 minutes per run**
- All 5 test suites pass on fresh Ubuntu 24.04

### PR
https://github.com/modem-dev/hornet/pull/10
32 changes: 32 additions & 0 deletions .pi/todos/cb931656.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"id": "cb931656",
"title": "Verify hornet setup on Ubuntu droplet (manual)",
"tags": [
"infra",
"ubuntu",
"ci"
],
"status": "done",
"created_at": "2026-02-17T02:30:39.055Z"
}

## Result
Verified on DigitalOcean Ubuntu 24.04 droplet (4GB RAM, 2 vCPU).

### Bugs found and fixed
1. **`setup.sh` — shared repo permissions**: ran `git config` as `hornet_agent` on admin's repo → agent can't access admin home. Fix: run as `$ADMIN_USER` instead.
2. **`setup.sh` — harden-permissions path**: called source copy (`$REPO_DIR/bin/harden-permissions.sh`) which agent can't access. Fix: use deployed copy (`$HORNET_HOME/runtime/bin/`).
3. **`setup.sh` — CWD inheritance**: `sudo -u hornet_agent` inherits root's CWD, causing `find`/`git` failures. Fix: `cd /tmp` at top of script.
4. **`harden-permissions.sh` — sessions dir**: `find` on non-existent sessions dir fails under `set -e`. Fix: wrap in `if [ -d ... ]` guard.
5. **`hornet-safe-bash` — `grep -P`**: Perl regex not reliably available on Ubuntu. Fix: all patterns converted to `grep -E`.

### Verification results
- ✅ `setup.sh` completes cleanly (user, Node, pi, firewall, hidepid, deploy, harden)
- ✅ All 5 test suites pass (tool-guard, bridge security, extension scanner, safe-bash, log redaction)
- ✅ Varlock validates env schema
- ✅ `start.sh` boots pi agent (fails at API auth with dummy keys — expected)
- ✅ Firewall active with all rules
- ✅ Process isolation working

### PR
https://github.com/modem-dev/hornet/pull/10
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ bin/ security & operations scripts
harden-permissions.sh filesystem hardening (runs on boot)
scan-extensions.mjs extension static analysis
redact-logs.sh secret scrubber for session logs
ci/ CI integration scripts
droplet.sh ephemeral DigitalOcean droplet lifecycle (create/destroy/ssh)
setup-ubuntu.sh Ubuntu droplet: prereqs + setup + tests
hooks/
pre-commit blocks agent from modifying security files in git
pi/
Expand Down Expand Up @@ -110,6 +113,7 @@ Add new test files to `bin/test.sh` — don't scatter test invocations across CI
- Skills are deployed from `pi/skills/` → agent's `~/.pi/agent/skills/`.
- Agent commits operational learnings to its own skills dir (not back to source).
- **When changing behavior, update all docs.** Check and update: `README.md`, `CONFIGURATION.md`, skill files (`pi/skills/*/SKILL.md`), and `AGENTS.md`. Inline code examples in docs must match the actual implementation.
- **No distro-specific commands.** Scripts must work on both Arch and Ubuntu (and any standard Linux). Use `grep -E` (not `grep -P`), POSIX-compatible tools, and avoid package manager calls (`pacman`, `apt`, etc.). If a package is needed, document it as a prerequisite rather than auto-installing it.

## Security Notes

Expand Down
175 changes: 175 additions & 0 deletions bin/ci/droplet.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
#!/bin/bash
# Manage ephemeral DigitalOcean droplets for CI.
#
# Usage:
# bin/ci/droplet.sh create <name> <image> <ssh_pub_key_file>
# bin/ci/droplet.sh destroy <droplet_id> [ssh_key_id]
# bin/ci/droplet.sh wait-ssh <ip> <ssh_private_key_file>
# bin/ci/droplet.sh run <ip> <ssh_private_key_file> <script>
#
# Requires: DO_API_TOKEN env var
#
# create: Registers SSH key with DO, creates droplet, polls until active.
# Outputs: DROPLET_ID=xxx DROPLET_IP=xxx SSH_KEY_ID=xxx
# destroy: Deletes droplet and (optionally) SSH key from DO.
# wait-ssh: Polls until SSH is reachable (up to 120s).
# run: Executes a script on the droplet via SSH.

set -euo pipefail

DO_API="https://api.digitalocean.com/v2"
REGION="${DO_REGION:-tor1}"
SIZE="${DO_SIZE:-s-2vcpu-4gb}"

die() { echo "❌ $1" >&2; exit 1; }

require_token() {
if [ -z "${DO_API_TOKEN:-}" ]; then
die "DO_API_TOKEN not set"
fi
}

do_api() {
local method="$1" endpoint="$2"
shift 2
curl -s -X "$method" \
-H "Authorization: Bearer $DO_API_TOKEN" \
-H "Content-Type: application/json" \
"$DO_API/$endpoint" "$@"
}

# ── create <name> <image> <ssh_pub_key_file> ─────────────────────────────────
cmd_create() {
require_token
local name="${1:?Usage: droplet.sh create <name> <image> <ssh_pub_key_file>}"
local image="${2:?}"
local pub_key_file="${3:?}"

[ -f "$pub_key_file" ] || die "SSH public key not found: $pub_key_file"

local pub_key
pub_key=$(cat "$pub_key_file")

# Register ephemeral SSH key
local key_name
key_name="ci-${name}-$(date +%s)"
local key_result
key_result=$(do_api POST "account/keys" -d "{\"name\":\"$key_name\",\"public_key\":\"$pub_key\"}")

local ssh_key_id
ssh_key_id=$(echo "$key_result" | python3 -c "import json,sys; print(json.load(sys.stdin)['ssh_key']['id'])" 2>/dev/null) \
|| die "Failed to register SSH key: $key_result"

echo " SSH key registered: $ssh_key_id ($key_name)" >&2

# Create droplet
local create_result
create_result=$(do_api POST "droplets" -d "{
\"name\": \"$name\",
\"region\": \"$REGION\",
\"size\": \"$SIZE\",
\"image\": \"$image\",
\"ssh_keys\": [$ssh_key_id],
\"backups\": false,
\"monitoring\": false
}")

local droplet_id
droplet_id=$(echo "$create_result" | python3 -c "import json,sys; print(json.load(sys.stdin)['droplet']['id'])" 2>/dev/null) \
|| die "Failed to create droplet: $create_result"

echo " Droplet created: $droplet_id (polling for IP...)" >&2

# Poll until active with public IP
local ip="none"
for i in $(seq 1 60); do
local data
data=$(do_api GET "droplets/$droplet_id")
local status
status=$(echo "$data" | python3 -c "import json,sys; print(json.load(sys.stdin)['droplet']['status'])")
ip=$(echo "$data" | python3 -c "
import json,sys
d=json.load(sys.stdin)['droplet']
v4=[n for n in d['networks']['v4'] if n['type']=='public']
print(v4[0]['ip_address'] if v4 else 'none')
" 2>/dev/null || echo "none")

if [ "$status" = "active" ] && [ "$ip" != "none" ]; then
echo " Droplet active: $ip" >&2
break
fi
sleep 3
done

if [ "$ip" = "none" ]; then
die "Droplet $droplet_id never became active"
fi

# Output for GitHub Actions $GITHUB_OUTPUT or eval
echo "DROPLET_ID=$droplet_id"
echo "DROPLET_IP=$ip"
echo "SSH_KEY_ID=$ssh_key_id"
}

# ── destroy <droplet_id> [ssh_key_id] ────────────────────────────────────────
cmd_destroy() {
require_token
local droplet_id="${1:-}"
local ssh_key_id="${2:-}"

if [ -n "$droplet_id" ]; then
local http_code
http_code=$(do_api DELETE "droplets/$droplet_id" -o /dev/null -w "%{http_code}")
if [ "$http_code" = "204" ]; then
echo " Droplet $droplet_id destroyed" >&2
else
echo " ⚠️ Droplet destroy returned $http_code (may already be gone)" >&2
fi
fi

if [ -n "$ssh_key_id" ]; then
local http_code
http_code=$(do_api DELETE "account/keys/$ssh_key_id" -o /dev/null -w "%{http_code}")
if [ "$http_code" = "204" ]; then
echo " SSH key $ssh_key_id deleted" >&2
else
echo " ⚠️ SSH key delete returned $http_code (may already be gone)" >&2
fi
fi
}

# ── wait-ssh <ip> <ssh_private_key_file> ──────────────────────────────────────
cmd_wait_ssh() {
local ip="${1:?Usage: droplet.sh wait-ssh <ip> <ssh_private_key_file>}"
local key_file="${2:?}"

echo " Waiting for SSH on $ip..." >&2
for i in $(seq 1 40); do
if ssh -o StrictHostKeyChecking=no -o ConnectTimeout=3 -o BatchMode=yes \
-i "$key_file" "root@$ip" true 2>/dev/null; then
echo " SSH ready ($((i * 3))s)" >&2
return 0
fi
sleep 3
done
die "SSH not reachable on $ip after 120s"
}

# ── run <ip> <ssh_private_key_file> <script> ──────────────────────────────────
cmd_run() {
local ip="${1:?Usage: droplet.sh run <ip> <ssh_private_key_file> <script>}"
local key_file="${2:?}"
local script="${3:?}"

ssh -o StrictHostKeyChecking=no -o BatchMode=yes \
-i "$key_file" "root@$ip" bash -s < "$script"
}

# ── Dispatch ──────────────────────────────────────────────────────────────────
case "${1:-}" in
create) shift; cmd_create "$@" ;;
destroy) shift; cmd_destroy "$@" ;;
wait-ssh) shift; cmd_wait_ssh "$@" ;;
run) shift; cmd_run "$@" ;;
*) die "Usage: droplet.sh {create|destroy|wait-ssh|run} ..." ;;
esac
Loading