diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..fc9213c --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,31 @@ +name: tests + +on: + push: + pull_request: + +jobs: + unit-contract: + name: unit + contract + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + - name: Install shellcheck + bats + run: sudo apt-get update && sudo apt-get install -y shellcheck bats + - name: Unit tests + run: npm run test:unit + - name: Contract tests + run: npm run test:contract + + e2e: + name: docker e2e + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install bats + run: sudo apt-get update && sudo apt-get install -y bats + - name: Docker e2e + run: npm run test:e2e diff --git a/Dockerfile b/Dockerfile index 4e222a9..82c61f5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,12 +7,38 @@ WORKDIR /app COPY package.json ./ RUN npm install --omit=dev --prefer-online && npm cache clean --force +# Belt-and-suspenders PATH fix: alphaclaw spawns `openclaw` by bare name and +# inherits its PATH. The ENV below is the primary fix; these shims keep the +# binaries resolvable even if a future change drops or reorders the ENV, and +# `openclaw --version` fails the build early if the install is broken. +RUN printf '#!/bin/sh\nexec /app/node_modules/.bin/openclaw "$@"\n' > /usr/bin/openclaw \ + && printf '#!/bin/sh\nexec /app/node_modules/.bin/alphaclaw "$@"\n' > /usr/bin/alphaclaw \ + && chmod +x /usr/bin/openclaw /usr/bin/alphaclaw \ + && ln -sf /app/node_modules/.bin/openclaw /usr/local/bin/openclaw \ + && ln -sf /app/node_modules/.bin/alphaclaw /usr/local/bin/alphaclaw \ + && /usr/bin/openclaw --version + +COPY start.sh /start.sh +COPY failure-server.js /failure-server.js +RUN chmod +x /start.sh + ENV PATH="/app/node_modules/.bin:$PATH" ENV ALPHACLAW_ROOT_DIR=/data -RUN mkdir -p /data +# Route temp onto the persistent disk instead of the container's ephemeral +# /tmp, so a 24/7 service doesn't churn/fill the ephemeral layer. Only the +# standard temp env vars are set — bare /tmp itself is deliberately left +# untouched (no symlink/bind-mount), so code that hardcodes /tmp keeps working. +# NOTE: /data is a runtime-mounted disk, so this build-time mkdir is shadowed +# at runtime — start.sh recreates /data/tmp on boot. Kept for image +# self-consistency. +ENV TMPDIR=/data/tmp +ENV TEMP=/data/tmp +ENV TMP=/data/tmp + +RUN mkdir -p /data/tmp && chmod 1777 /data/tmp EXPOSE 3000 ENTRYPOINT ["/usr/bin/tini", "--"] -CMD ["alphaclaw", "start"] +CMD ["/start.sh"] diff --git a/README.md b/README.md index 1fe3303..33a7a64 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,31 @@ First time you DM the bot, it sends a pairing request. Approve it in the setup U The channel's env var is empty or missing. Go to the Envars tab, add the token, and save. The channel will be automatically enabled in the config. +### If alphaclaw crashes at boot + +The container boots through `start.sh`, which runs `alphaclaw start` and — if it ever exits — falls back to a tiny failure-status HTTP server instead of crash-looping. The deploy stays **Live**, `/health` keeps answering 200, and the Render **Shell** tab stays reachable so you can debug: + +```sh +cat /data/start.log # full boot log (persisted on the disk) +echo $PATH # what PATH alphaclaw inherits +alphaclaw start # reproduce the failure with live output +``` + +The failure page itself never renders logs or env values (they can contain secrets) — it only points you to the Shell tab. + +### Container crash-looping (debug mode) + +If even the fallback isn't enough (e.g. you need a fully inert container), swap the Dockerfile `CMD` to the bundled `debug-start.sh`: + +```dockerfile +COPY debug-start.sh /debug-start.sh +RUN chmod +x /debug-start.sh +# ... (other directives) ... +CMD ["/debug-start.sh"] +``` + +`debug-start.sh` binds port 3000 with a bare listener so Render marks the deploy **Live** (unlocking the Shell tab), keeps PID 1 alive with `tail -f /dev/null`, and tees a full environment dump to `/data/debug.log` on the persistent disk. Restore `CMD ["/start.sh"]` after diagnosis. + ## Links - [OpenClaw docs](https://docs.openclaw.ai) diff --git a/debug-start.sh b/debug-start.sh new file mode 100644 index 0000000..fac9d71 --- /dev/null +++ b/debug-start.sh @@ -0,0 +1,26 @@ +#!/bin/bash +LOGFILE=/data/debug.log +mkdir -p /data +exec > >(tee -a "$LOGFILE") 2>&1 +set -x + +echo "===== BOOT $(date -u +%FT%TZ) pid=$$ =====" +echo "whoami=$(whoami) pwd=$(pwd)" +echo "PATH=$PATH" +echo "--- /usr/bin/openclaw ---" +ls -la /usr/bin/openclaw 2>&1 || echo "absent" +echo "--- /usr/local/bin/openclaw ---" +ls -la /usr/local/bin/openclaw 2>&1 || echo "absent" +echo "--- /app/node_modules/.bin (claw matches) ---" +# shellcheck disable=SC2010 # diagnostic one-liner; ls|grep is fine here +ls -la /app/node_modules/.bin/ 2>&1 | grep -i claw || echo "no matches" +echo "--- env ---" +env | sort +echo "===== END BOOT INFO =====" + +node -e "require('http').createServer((_,r)=>r.end('ok')).listen(3000,'0.0.0.0',()=>{console.log('LISTENER UP on port 3000');process.stdout.write('');})" & +NODE_PID=$! +echo "node listener bg pid=$NODE_PID" + +# Keep PID 1 alive forever, regardless of node exit +exec tail -f /dev/null diff --git a/failure-server.js b/failure-server.js new file mode 100644 index 0000000..8b76a83 --- /dev/null +++ b/failure-server.js @@ -0,0 +1,54 @@ +// Minimal HTTP server that serves a status page when alphaclaw has failed +// to start. Keeps the Render container Live (port 3000 bound, /health 200) +// so the Shell tab remains accessible for debugging. +// +// IMPORTANT: this server is publicly reachable on the service URL. It must +// NOT expose any logs, env vars, file contents, or other potentially +// sensitive state. Direct the operator to the Render Shell tab to inspect +// /data/start.log there. + +const http = require("http"); + +const html = ` + + + + AlphaClaw — startup failure + + + +

AlphaClaw failed to start

+

The container is up — but the alphaclaw start process exited unexpectedly. This page is the failure-mode fallback so the deploy stays Live and you can debug from the Render Shell tab.

+ +

Debug steps

+
    +
  1. Open the Render Shell tab for this service (now reachable, since the container is healthy).
  2. +
  3. Inspect the boot log: cat /data/start.log
  4. +
  5. Try running alphaclaw manually to see live output: /app/node_modules/.bin/alphaclaw start
  6. +
  7. Check that openclaw resolves on PATH: which openclaw && openclaw --version
  8. +
+ +

No log content is rendered on this page because the boot log can contain environment values. Use the Shell tab to inspect.

+ +`; + +const server = http.createServer((req, res) => { + if (req.url === "/health" || req.url === "/healthz") { + res.writeHead(200, { "content-type": "text/plain" }); + res.end("ok"); + return; + } + res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + res.end(html); +}); + +const PORT = parseInt(process.env.PORT, 10) || 3000; +server.listen(PORT, "0.0.0.0", () => { + console.log(`[failure-server] listening on port ${PORT}`); +}); diff --git a/package.json b/package.json index 9f96ae9..b1b216f 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,12 @@ "dev:stop": "docker compose down", "dev:clean": "docker compose down -v", "dev:logs": "docker compose logs -f", - "dev:shell": "docker compose exec openclaw bash" + "dev:shell": "docker compose exec openclaw bash", + "test": "npm run test:unit && npm run test:contract", + "test:unit": "node --test tests/unit/*.test.mjs", + "test:contract": "bats tests/contract/", + "test:e2e": "bats tests/e2e/", + "test:all": "npm run test && npm run test:e2e" }, "dependencies": { "@chrysb/alphaclaw": "0.9.18" diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..bde5ab2 --- /dev/null +++ b/start.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# Boot script: runs alphaclaw, but if alphaclaw exits/crashes, falls back to +# a tiny HTTP failure-status server so the container stays Live and the +# Render Shell tab remains accessible for debugging. + +LOGFILE=/data/start.log +mkdir -p /data + +# Render's runtime container env can strip PATH down to a narrow set that +# excludes /usr/bin and /app/node_modules/.bin, which breaks alphaclaw's +# spawning of openclaw, curl, etc. Force a sensible PATH explicitly. +export PATH="/app/node_modules/.bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + +# Route temp onto the persistent /data disk instead of ephemeral /tmp. The disk +# is mounted over /data at runtime, so the Dockerfile's build-time mkdir is +# hidden — (re)create /data/tmp here on every boot. Re-export the standard temp +# trio too (same belt-and-suspenders reasoning as PATH above: Render's runtime +# can munge the env even though the Dockerfile sets these via ENV). +export TMPDIR=/data/tmp TEMP=/data/tmp TMP=/data/tmp +mkdir -p "$TMPDIR" && chmod 1777 "$TMPDIR" + +{ + echo "=== boot $(date -u +%FT%TZ) ===" + echo "PATH=$PATH" + echo "TMPDIR=$TMPDIR" + echo "Starting alphaclaw..." +} | tee -a "$LOGFILE" + +set -o pipefail +/app/node_modules/.bin/alphaclaw start 2>&1 | tee -a "$LOGFILE" +CODE=${PIPESTATUS[0]} + +echo "=== alphaclaw exited with code $CODE at $(date -u +%FT%TZ) ===" | tee -a "$LOGFILE" +echo "=== falling back to failure-status server ===" | tee -a "$LOGFILE" + +exec node /failure-server.js diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..e65a215 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,51 @@ +# Tests + +This template is mostly infrastructure (a Dockerfile, a boot script, a fallback +HTTP server, and Render config), so the tests are split by how much they cost to +run. All test files live here and are excluded from the image via `.dockerignore`. + +| Layer | What it checks | Needs | Command | +|--------------|--------------------------------------------------------------------------------|------------------|-----------------------| +| **unit** | `failure-server.js` routing + the "no secret leaks" property | node | `npm run test:unit` | +| **contract** | Static invariants in `start.sh` / `debug-start.sh` / `Dockerfile` / `render.yaml` / `package.json` | bash, `shellcheck`, `bats` | `npm run test:contract` | +| **e2e** | Builds the image and boots it Render-style — both fresh (empty `/data`) and onboarded (gateway must reach ready) | docker, `bats`, curl | `npm run test:e2e` | + +```sh +npm test # unit + contract (fast, no docker) +npm run test:e2e # full image build + run (slow; ~minutes) +npm run test:all # everything +``` + +## Why these layers + +- **Unit** exercises the real `failure-server.js` artifact in a subprocess (no + mocks). The security test plants sentinel secrets in the env and asserts they + never appear in any HTTP response — the failure page is publicly reachable on + the service URL, so it must never render logs or env values. +- **Contract** locks in the load-bearing config that, if removed, restart-loops + the container: the `PATH` prepend (alphaclaw spawns `openclaw` by bare name), + the `TMPDIR=/data/tmp` routing, the sticky-bit `mkdir` on boot, the tini/CMD + wiring, the "never touch bare `/tmp`" rule, and the exact-version alphaclaw + pin (a drifting spec never changes `package.json`, so the Docker npm-install + layer cache silently keeps shipping a stale alphaclaw). +- **e2e** has two suites: + - `docker.bats` mounts a **tmpfs over `/data`** so the dir starts empty at + runtime, reproducing how Render's disk mount shadows the Dockerfile's + build-time `mkdir`. If `/data/tmp` exists with its sticky bit afterwards, + `start.sh` recreated it on boot — load-bearing, since the disk mount hides + anything created at build time. + - `gateway-ready.bats` seeds a realistic **onboarded** `/data` + (`onboarded.json` + `gateway.mode=local`, as `openclaw onboard` writes) and + asserts OpenClaw reaches a **working state**: Render marks a service Live + as soon as `/health` answers — which even the failure server satisfies — so + "Live" alone proves nothing about the gateway. This suite waits for + **`[gateway] ready`** in the logs, checks the usage-tracker plugin actually + initialized, and fails on any invalid-config or gateway-startup error. + +## Local prerequisites + +```sh +brew install bats-core shellcheck # macOS +``` + +Node's built-in test runner (`node --test`) needs no extra packages. diff --git a/tests/contract/dockerfile.bats b/tests/contract/dockerfile.bats new file mode 100644 index 0000000..e69e720 --- /dev/null +++ b/tests/contract/dockerfile.bats @@ -0,0 +1,66 @@ +#!/usr/bin/env bats +# +# Contract tests for the Dockerfile + render.yaml. Static assertions that the +# image-layer invariants documented in CLAUDE.md / AGENTS.md stay in place. + +setup() { + REPO="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" +} + +# --- PATH fix (primary + belt-and-suspenders shims) --------------------------- + +@test "Dockerfile: sets PATH so /app/node_modules/.bin wins" { + grep -Eq 'ENV PATH="/app/node_modules/\.bin:\$PATH"' "$REPO/Dockerfile" +} + +@test "Dockerfile: installs openclaw + alphaclaw shims into /usr/bin" { + grep -q '/usr/bin/openclaw' "$REPO/Dockerfile" + grep -q '/usr/bin/alphaclaw' "$REPO/Dockerfile" +} + +# --- TMPDIR onto the persistent disk ------------------------------------------ + +@test "Dockerfile: sets TMPDIR/TEMP/TMP env to /data/tmp" { + grep -q 'ENV TMPDIR=/data/tmp' "$REPO/Dockerfile" + grep -q 'ENV TEMP=/data/tmp' "$REPO/Dockerfile" + grep -q 'ENV TMP=/data/tmp' "$REPO/Dockerfile" +} + +@test "Dockerfile: creates /data/tmp with the sticky bit" { + grep -Eq 'mkdir -p /data/tmp && chmod 1777 /data/tmp' "$REPO/Dockerfile" +} + +@test "Dockerfile: never redirects bare /tmp (mentions only in comments)" { + # Only /data/tmp is added; bare /tmp must never be a build instruction target. + while IFS= read -r line; do + [[ "$line" =~ ^[0-9]+:[[:space:]]*# ]] || { echo "non-comment /tmp use: $line"; false; } + done < <(grep -nw '/tmp' "$REPO/Dockerfile") +} + +# --- Init + entrypoint -------------------------------------------------------- + +@test "Dockerfile: uses tini as PID 1" { + grep -Eq 'ENTRYPOINT \["/usr/bin/tini", "--"\]' "$REPO/Dockerfile" +} + +@test "Dockerfile: CMD boots via start.sh" { + grep -Eq 'CMD \["/start.sh"\]' "$REPO/Dockerfile" +} + +@test "Dockerfile: exposes port 3000" { + grep -q 'EXPOSE 3000' "$REPO/Dockerfile" +} + +@test "Dockerfile: points ALPHACLAW_ROOT_DIR at the persistent disk" { + grep -q 'ENV ALPHACLAW_ROOT_DIR=/data' "$REPO/Dockerfile" +} + +# --- Render blueprint --------------------------------------------------------- + +@test "render.yaml: health check is /health" { + grep -q 'healthCheckPath: /health' "$REPO/render.yaml" +} + +@test "render.yaml: mounts a persistent disk at /data" { + grep -q 'mountPath: /data' "$REPO/render.yaml" +} diff --git a/tests/contract/package.bats b/tests/contract/package.bats new file mode 100644 index 0000000..805c0ec --- /dev/null +++ b/tests/contract/package.bats @@ -0,0 +1,18 @@ +#!/usr/bin/env bats +# +# Contract tests for package.json's alphaclaw dependency spec. + +setup() { + REPO="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + ALPHACLAW_SPEC="$(node -e 'console.log(require(process.argv[1]).dependencies["@chrysb/alphaclaw"])' "$REPO/package.json")" +} + +@test "package.json: alphaclaw is pinned to an exact version, not a range or tag" { + # The Dockerfile copies only package.json and runs npm install in that layer. + # With a spec that can drift (^range, ~range, latest, a git branch ref), the + # layer's cache key never changes, so Docker — locally and on Render — keeps + # reusing the npm-install layer from whenever the spec was first resolved, + # and alphaclaw updates silently never ship. An exact version makes every + # update an explicit package.json edit that busts the cache. + [[ "$ALPHACLAW_SPEC" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-+].+)?$ ]] +} diff --git a/tests/contract/scripts.bats b/tests/contract/scripts.bats new file mode 100644 index 0000000..9e2b335 --- /dev/null +++ b/tests/contract/scripts.bats @@ -0,0 +1,66 @@ +#!/usr/bin/env bats +# +# Contract tests for the shell scripts. These lock in the load-bearing +# invariants that CLAUDE.md / AGENTS.md call out under "don't remove" and +# "What NOT to do" — the things that, if dropped, restart-loop the container. +# They are static (no container needed) so they run fast in `npm test`. + +setup() { + REPO="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" +} + +@test "start.sh: valid bash syntax" { + bash -n "$REPO/start.sh" +} + +@test "debug-start.sh: valid bash syntax" { + bash -n "$REPO/debug-start.sh" +} + +@test "start.sh: passes shellcheck" { + command -v shellcheck >/dev/null || skip "shellcheck not installed" + shellcheck "$REPO/start.sh" +} + +@test "debug-start.sh: passes shellcheck" { + command -v shellcheck >/dev/null || skip "shellcheck not installed" + shellcheck "$REPO/debug-start.sh" +} + +# --- PATH fix (load-bearing: alphaclaw spawns openclaw by bare name) ---------- + +@test "start.sh: prepends /app/node_modules/.bin to PATH" { + grep -Eq 'export PATH="/app/node_modules/\.bin:' "$REPO/start.sh" +} + +# --- TMPDIR onto the persistent disk ------------------------------------------ + +@test "start.sh: exports the TMPDIR/TEMP/TMP trio to /data/tmp" { + grep -q 'TMPDIR=/data/tmp' "$REPO/start.sh" + grep -q 'TEMP=/data/tmp' "$REPO/start.sh" + grep -q 'TMP=/data/tmp' "$REPO/start.sh" +} + +@test "start.sh: creates /data/tmp with the sticky bit on boot" { + # /data is a runtime-mounted disk, so the dir must be (re)created at boot. + grep -Eq 'mkdir -p "\$TMPDIR"' "$REPO/start.sh" + grep -q 'chmod 1777 "\$TMPDIR"' "$REPO/start.sh" +} + +@test "start.sh: never operates on bare /tmp (mentions only in comments)" { + # CLAUDE.md rule: leave /tmp alone — no symlink, bind-mount, or move. We allow + # /tmp to appear in explanatory comments, but every such line must be a comment. + while IFS= read -r line; do + [[ "$line" =~ ^[0-9]+:[[:space:]]*# ]] || { echo "non-comment /tmp use: $line"; false; } + done < <(grep -nw '/tmp' "$REPO/start.sh") +} + +# --- Resilience: fall back to the failure server, don't crash-loop ------------ + +@test "start.sh: runs 'alphaclaw start' as the primary process" { + grep -q 'alphaclaw start' "$REPO/start.sh" +} + +@test "start.sh: execs the failure server when alphaclaw exits" { + grep -q 'exec node /failure-server.js' "$REPO/start.sh" +} diff --git a/tests/e2e/docker.bats b/tests/e2e/docker.bats new file mode 100644 index 0000000..924dce9 --- /dev/null +++ b/tests/e2e/docker.bats @@ -0,0 +1,99 @@ +#!/usr/bin/env bats +# +# End-to-end test: build the real image, run it the way Render does, and assert +# the container actually stays Live and satisfies every documented invariant. +# +# Key trick: we mount a tmpfs over /data so it starts EMPTY, exactly like +# Render's runtime disk mount — which shadows the Dockerfile's build-time +# `mkdir /data/tmp`. If /data/tmp still exists afterwards, start.sh recreated it +# on boot (the load-bearing behavior). +# +# Slow (builds an image, pulls alphaclaw). Not part of `npm test`; run via +# `npm run test:e2e`. Skips cleanly when docker is unavailable. + +IMAGE="openclaw-render-test:latest" +CONTAINER="openclaw-render-e2e" +HOST_PORT=13000 +SENTINEL="SENTINEL-SECRET-DO-NOT-LEAK-9f3a2b" + +setup_file() { + REPO="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + export REPO IMAGE CONTAINER HOST_PORT SENTINEL + + command -v docker >/dev/null || skip "docker not installed" + docker info >/dev/null 2>&1 || skip "docker daemon not running" + + docker build -t "$IMAGE" "$REPO" >&2 + docker rm -f "$CONTAINER" >/dev/null 2>&1 || true + + # --tmpfs /data => empty /data at runtime, mimicking Render's disk mount. + docker run -d --name "$CONTAINER" \ + --tmpfs /data \ + -p "${HOST_PORT}:3000" \ + -e PORT=3000 \ + -e SETUP_PASSWORD="$SENTINEL" \ + -e OPENCLAW_GATEWAY_TOKEN="$SENTINEL" \ + -e WEBHOOK_TOKEN="$SENTINEL" \ + "$IMAGE" >&2 + + # Wait up to ~120s for /health to answer 200 (alphaclaw OR the failure server). + for _ in $(seq 1 60); do + if curl -fsS "http://127.0.0.1:${HOST_PORT}/health" >/dev/null 2>&1; then + return 0 + fi + sleep 2 + done + + echo "container never became healthy; logs follow:" >&2 + docker logs "$CONTAINER" >&2 || true + return 1 +} + +teardown_file() { + docker rm -f "$CONTAINER" >/dev/null 2>&1 || true +} + +# Helper: run a command inside the running container. Non-login shell on +# purpose — a login shell re-sources /etc/profile and resets PATH, which would +# mask the image's ENV PATH (the very thing alphaclaw inherits and we test). +in_container() { + docker exec "$CONTAINER" sh -c "$1" +} + +@test "container stays Live: /health returns 200" { + run curl -fsS "http://127.0.0.1:${HOST_PORT}/health" + [ "$status" -eq 0 ] +} + +@test "openclaw resolves on PATH and runs" { + in_container 'command -v openclaw && openclaw --version' +} + +@test "alphaclaw resolves on PATH" { + in_container 'command -v alphaclaw' +} + +@test "PATH includes /app/node_modules/.bin" { + in_container 'echo "$PATH"' | grep -q '/app/node_modules/.bin' +} + +@test "TMPDIR env points at /data/tmp" { + run in_container 'printenv TMPDIR' + [ "$status" -eq 0 ] + [ "$output" = "/data/tmp" ] +} + +@test "start.sh recreated /data/tmp (sticky bit) despite empty disk mount" { + in_container 'test -d /data/tmp' + in_container 'test -k /data/tmp' +} + +@test "tini is PID 1" { + in_container 'cat /proc/1/comm' | grep -q tini +} + +@test "SECURITY: failure/landing page does not leak env secrets" { + run curl -fsS "http://127.0.0.1:${HOST_PORT}/" + [ "$status" -eq 0 ] + ! grep -q "$SENTINEL" <<<"$output" +} diff --git a/tests/e2e/gateway-ready.bats b/tests/e2e/gateway-ready.bats new file mode 100644 index 0000000..8b83fb9 --- /dev/null +++ b/tests/e2e/gateway-ready.bats @@ -0,0 +1,99 @@ +#!/usr/bin/env bats +# +# End-to-end test: an ONBOARDED deployment must boot OpenClaw to a genuinely +# working state — not merely bind :3000. Render marks the service Live as soon +# as /health answers, which the Express setup server (or even the failure +# server) satisfies; a deploy can look "Live" while the gateway is dead. This +# suite seeds a realistic onboarded /data, boots the real image Render-style, +# and asserts the gateway itself comes up: "[gateway] ready" in the logs, the +# usage-tracker plugin initialized, and no invalid-config failures. +# +# Slow (builds an image, boots a container). Run via `npm run test:e2e`. +# Skips cleanly when docker is unavailable. + +IMAGE="openclaw-render-test:latest" +CONTAINER="openclaw-render-gateway-e2e" +HOST_PORT=13001 +GATEWAY_READY_MARKER="[gateway] ready" + +setup_file() { + REPO="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + export REPO IMAGE CONTAINER HOST_PORT GATEWAY_READY_MARKER + + command -v docker >/dev/null || skip "docker not installed" + docker info >/dev/null 2>&1 || skip "docker daemon not running" + + docker build -t "$IMAGE" "$REPO" >&2 + docker rm -f "$CONTAINER" >/dev/null 2>&1 || true + + # Seed /data as an onboarded deployment: the marker makes alphaclaw run its + # onboarded boot sequence (which starts the gateway), and gateway.mode=local + # is what `openclaw onboard --mode local` writes — without it the gateway + # refuses to start. + SEED="$(mktemp -d)" + export SEED + mkdir -p "$SEED/.openclaw" + printf '{"onboarded":true}\n' >"$SEED/onboarded.json" + cat >"$SEED/.openclaw/openclaw.json" <<'JSON' +{ + "gateway": { "mode": "local" } +} +JSON + + docker run -d --name "$CONTAINER" \ + -v "$SEED:/data" \ + -p "${HOST_PORT}:3000" \ + -e PORT=3000 \ + -e SETUP_PASSWORD="gateway-e2e-test" \ + -e OPENCLAW_GATEWAY_TOKEN="gateway-e2e-test-token" \ + -e WEBHOOK_TOKEN="gateway-e2e-test" \ + "$IMAGE" >&2 + + # Express /health comes up early; the gateway takes longer. Wait for the + # ready line rather than sleeping a fixed amount. + for _ in $(seq 1 60); do + if docker logs "$CONTAINER" 2>&1 | grep -qF "$GATEWAY_READY_MARKER"; then + return 0 + fi + sleep 2 + done + echo "gateway never became ready; logs follow:" >&2 + docker logs "$CONTAINER" >&2 || true + return 1 +} + +teardown_file() { + docker rm -f "$CONTAINER" >/dev/null 2>&1 || true + [ -n "$SEED" ] && rm -rf "$SEED" || true +} + +@test "container stays Live: /health returns 200" { + run curl -fsS "http://127.0.0.1:${HOST_PORT}/health" + [ "$status" -eq 0 ] +} + +@test "gateway logs '[gateway] ready' — OpenClaw started properly" { + run docker logs "$CONTAINER" + grep -qF "$GATEWAY_READY_MARKER" <<<"$output" +} + +@test "usage-tracker plugin loaded in the gateway" { + # Loading proves alphaclaw's managed plugin config points at a real path: + # the gateway only initializes the plugin after resolving plugins.load.paths. + run docker logs "$CONTAINER" + grep -qE "\[usage-tracker\] initialized|plugin: usage-tracker" <<<"$output" +} + +@test "logs contain no invalid-config or gateway-startup failures" { + run docker logs "$CONTAINER" + ! grep -qF "OpenClaw config is invalid" <<<"$output" + ! grep -qiF "plugin path not found" <<<"$output" + ! grep -qF "Gateway failed to start" <<<"$output" +} + +@test "openclaw config validate accepts the boot-reconciled config" { + run docker exec "$CONTAINER" sh -c "openclaw config validate 2>&1 || true" + echo "validate: $output" >&2 + ! grep -qiF "plugin path not found" <<<"$output" + ! grep -qF "config is invalid" <<<"$output" +} diff --git a/tests/unit/failure-server.test.mjs b/tests/unit/failure-server.test.mjs new file mode 100644 index 0000000..25c6694 --- /dev/null +++ b/tests/unit/failure-server.test.mjs @@ -0,0 +1,100 @@ +// Unit tests for failure-server.js — the fallback HTTP server that keeps the +// Render container Live (port bound, /health 200) after `alphaclaw start` dies. +// +// Strategy: spawn the real artifact as a child process on a throwaway port with +// sentinel secrets in its env, then exercise it over HTTP. This tests the file +// exactly as it ships (no refactor, no mocks). + +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { readFileSync } from "node:fs"; +import path from "node:path"; + +const SERVER = path.resolve(import.meta.dirname, "../../failure-server.js"); +const PORT = 38291; +const BASE = `http://127.0.0.1:${PORT}`; + +// A value we plant in the env that MUST NEVER appear in any HTTP response. +// The failure page is publicly reachable on the service URL (see the header +// comment in failure-server.js + commit "Stop leaking /data/start.log"). +const SENTINEL = "SENTINEL-SECRET-DO-NOT-LEAK-9f3a2b"; + +let child; + +before(async () => { + child = spawn(process.execPath, [SERVER], { + env: { + ...process.env, + PORT: String(PORT), + // Mimic the secret-bearing env the server runs under on Render. + SETUP_PASSWORD: SENTINEL, + OPENCLAW_GATEWAY_TOKEN: SENTINEL, + WEBHOOK_TOKEN: SENTINEL, + }, + stdio: ["ignore", "pipe", "pipe"], + }); + + // Resolve once the server reports it is listening. + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("server did not start in time")), 10000); + child.stdout.on("data", (buf) => { + if (buf.toString().includes("listening on port")) { + clearTimeout(timer); + resolve(); + } + }); + child.on("exit", (code) => reject(new Error(`server exited early (code ${code})`))); + }); +}); + +after(() => { + child?.kill("SIGKILL"); +}); + +test("/health returns 200 ok as text/plain", async () => { + const res = await fetch(`${BASE}/health`); + assert.equal(res.status, 200); + assert.match(res.headers.get("content-type") ?? "", /text\/plain/); + assert.equal((await res.text()).trim(), "ok"); +}); + +test("/healthz is also a 200 ok health alias", async () => { + const res = await fetch(`${BASE}/healthz`); + assert.equal(res.status, 200); + assert.equal((await res.text()).trim(), "ok"); +}); + +test("/ serves the human-readable failure page", async () => { + const res = await fetch(`${BASE}/`); + assert.equal(res.status, 200); + assert.match(res.headers.get("content-type") ?? "", /text\/html/); + const body = await res.text(); + assert.match(body, /AlphaClaw failed to start/); +}); + +test("unknown paths fall through to the failure page (catch-all)", async () => { + const res = await fetch(`${BASE}/some/random/path`); + assert.equal(res.status, 200); + assert.match(await res.text(), /AlphaClaw failed to start/); +}); + +test("SECURITY: no response leaks env secrets", async () => { + for (const route of ["/", "/health", "/healthz", "/anything"]) { + const body = await (await fetch(`${BASE}${route}`)).text(); + assert.ok( + !body.includes(SENTINEL), + `secret from env leaked into the response body for ${route}`, + ); + } +}); + +test("SECURITY: source does no filesystem IO", () => { + // The durable invariant is that the server reads NOTHING from disk, so it + // cannot accidentally render the boot log (which can contain env values). + // Note: naming the path "/data/start.log" in the page as a `cat ...` hint for + // the operator is fine — that's a static string, not file contents. + const src = readFileSync(SERVER, "utf8"); + assert.ok(!/require\(["']fs["']\)|from\s+["']fs["']/.test(src), "failure-server.js must not import fs"); + assert.ok(!/readFile/.test(src), "failure-server.js must not read files"); +});