Skip to content
Open
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
31 changes: 31 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
30 changes: 28 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
26 changes: 26 additions & 0 deletions debug-start.sh
Original file line number Diff line number Diff line change
@@ -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
54 changes: 54 additions & 0 deletions failure-server.js
Original file line number Diff line number Diff line change
@@ -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 = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>AlphaClaw — startup failure</title>
<style>
body { font-family: ui-monospace, monospace; padding: 2em; max-width: 50em; line-height: 1.6; color: #222; }
h1 { color: #c00; }
code { background: #f5f5f5; padding: 0.1em 0.4em; border-radius: 3px; }
.ok { color: #060; }
ol li { margin-bottom: 0.5em; }
</style>
</head>
<body>
<h1>AlphaClaw failed to start</h1>
<p><strong class="ok">The container is up</strong> — but the <code>alphaclaw start</code> process exited unexpectedly. This page is the failure-mode fallback so the deploy stays Live and you can debug from the Render Shell tab.</p>

<h2>Debug steps</h2>
<ol>
<li>Open the Render Shell tab for this service (now reachable, since the container is healthy).</li>
<li>Inspect the boot log: <code>cat /data/start.log</code></li>
<li>Try running alphaclaw manually to see live output: <code>/app/node_modules/.bin/alphaclaw start</code></li>
<li>Check that <code>openclaw</code> resolves on PATH: <code>which openclaw &amp;&amp; openclaw --version</code></li>
</ol>

<p style="margin-top: 3em; color: #888; font-size: 0.9em;">No log content is rendered on this page because the boot log can contain environment values. Use the Shell tab to inspect.</p>
</body>
</html>`;

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}`);
});
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
36 changes: 36 additions & 0 deletions start.sh
Original file line number Diff line number Diff line change
@@ -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
51 changes: 51 additions & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
@@ -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.
66 changes: 66 additions & 0 deletions tests/contract/dockerfile.bats
Original file line number Diff line number Diff line change
@@ -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"
}
18 changes: 18 additions & 0 deletions tests/contract/package.bats
Original file line number Diff line number Diff line change
@@ -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]+([-+].+)?$ ]]
}
Loading