diff --git a/docs/install/docker.md b/docs/install/docker.md index 5aa643dae39f..6bc80b9087fb 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -287,7 +287,7 @@ The image's built-in `HEALTHCHECK` pings `/healthz`; repeated failures mark the Authenticated deep health snapshot: ```bash -docker compose exec openclaw-gateway node dist/index.js health --token "$OPENCLAW_GATEWAY_TOKEN" +docker compose exec openclaw-gateway sh -lc 'node dist/index.js gateway health --token "$OPENCLAW_GATEWAY_TOKEN"' ``` ### LAN vs loopback diff --git a/scripts/docker/setup.sh b/scripts/docker/setup.sh index 809d1967bf3b..42bc4d1d301c 100755 --- a/scripts/docker/setup.sh +++ b/scripts/docker/setup.sh @@ -985,4 +985,4 @@ echo "Token: stored in Docker environment/config (not printed)." echo "" echo "Commands:" echo " ${COMPOSE_HINT} logs -f openclaw-gateway" -echo " ${COMPOSE_HINT} exec openclaw-gateway sh -lc 'node dist/index.js health --token \"\$OPENCLAW_GATEWAY_TOKEN\"'" +echo " ${COMPOSE_HINT} exec openclaw-gateway sh -lc 'node dist/index.js gateway health --token \"\$OPENCLAW_GATEWAY_TOKEN\"'" diff --git a/scripts/e2e/compose-setup.sh b/scripts/e2e/compose-setup.sh index 04ef24e25648..94a9dba268c5 100755 --- a/scripts/e2e/compose-setup.sh +++ b/scripts/e2e/compose-setup.sh @@ -17,10 +17,35 @@ cleanup() { docker_e2e_docker_cmd rm -f "$CLI_NAME" >/dev/null 2>&1 || true "${COMPOSE[@]}" down --remove-orphans --volumes >/dev/null 2>&1 || true docker_e2e_cleanup_package_tgz "$PACKAGE_TGZ" + docker_e2e_docker_cmd run --rm --user 0:0 \ + -v "$PROJECT_DIR:/target" \ + "$IMAGE_NAME" \ + sh -c 'rm -rf /target/* /target/.[!.]* /target/..?*' >/dev/null 2>&1 || true rm -rf "$PROJECT_DIR" } trap cleanup EXIT +assert_gateway_health_json() { + local label="$1" + local health_path="$2" + node - "$label" "$health_path" <<'NODE' +const fs = require("node:fs"); +const label = process.argv[2]; +const healthPath = process.argv[3]; +const health = JSON.parse(fs.readFileSync(healthPath, "utf8")); +if ( + health?.ok !== true || + !Number.isFinite(health.ts) || + !Number.isFinite(health.durationMs) || + !health.channels || + typeof health.channels !== "object" || + Array.isArray(health.channels) +) { + throw new Error(`${label} gateway health JSON is incomplete`); +} +NODE +} + mkdir -p "$PROJECT_DIR/config/workspace" "$PROJECT_DIR/auth-profile" chmod -R 0777 "$PROJECT_DIR/config" "$PROJECT_DIR/auth-profile" cat >"$PROJECT_DIR/config/openclaw.json" <"$PROJECT_DIR/gateway-health.json" +assert_gateway_health_json "gateway service" "$PROJECT_DIR/gateway-health.json" +"${COMPOSE[@]}" run -T --no-deps --name "$CLI_NAME" openclaw-cli gateway health --token "$TOKEN" --json >"$PROJECT_DIR/cli-health.json" +assert_gateway_health_json "CLI sidecar" "$PROJECT_DIR/cli-health.json" GATEWAY_VERSION="$("${COMPOSE[@]}" exec -T openclaw-gateway node -p "require('./package.json').version")" node --import tsx "$ROOT_DIR/scripts/e2e/lib/docker-artifact-proof/write-identities.ts" \ @@ -84,6 +112,8 @@ node --import tsx "$ROOT_DIR/scripts/e2e/lib/docker-artifact-proof/write-identit --container "cli=$CLI_NAME" \ --detail "gateway:openclawVersion=$GATEWAY_VERSION" \ --detail "gateway:health=healthy" \ - --detail "cli:healthCommand=passed" + --detail "gateway:documentedHealthCommand=passed" \ + --detail "gateway:healthJsonEnvelope=passed" \ + --detail "cli:healthJsonEnvelope=passed" echo "Docker Compose setup proof passed." diff --git a/src/docker-setup.e2e.test.ts b/src/docker-setup.e2e.test.ts index 0212b1e9a59f..cc065ba248ce 100644 --- a/src/docker-setup.e2e.test.ts +++ b/src/docker-setup.e2e.test.ts @@ -155,6 +155,10 @@ describe("scripts/docker/setup.sh", () => { expect(result.stdout).toContain("Access from tailnet devices via the host's tailnet IP."); expect(result.stdout).toContain("Commands:"); expect(result.stdout).toContain("logs -f openclaw-gateway"); + expect(result.stdout).toContain( + `exec openclaw-gateway sh -lc 'node dist/index.js gateway health --token "$OPENCLAW_GATEWAY_TOKEN"'`, + ); + expect(result.stdout).not.toContain("node dist/index.js health --token"); expect(result.stdout).not.toContain("test-token"); expect(result.stdout).not.toContain("#token="); expect(log).toContain( diff --git a/src/dockerfile.test.ts b/src/dockerfile.test.ts index 2b27e144e351..89d6d761172b 100644 --- a/src/dockerfile.test.ts +++ b/src/dockerfile.test.ts @@ -10,6 +10,7 @@ const repoRoot = resolve(fileURLToPath(new URL(".", import.meta.url)), ".."); const dockerfilePath = join(repoRoot, "Dockerfile"); const dockerComposePath = join(repoRoot, "docker-compose.yml"); const dockerInstallDocsPath = join(repoRoot, "docs/install/docker.md"); +const composeSetupScriptPath = join(repoRoot, "scripts/e2e/compose-setup.sh"); const dockerReleaseWorkflowPath = join(repoRoot, ".github/workflows/docker-release.yml"); const fullReleaseValidationWorkflowPath = join( repoRoot, @@ -46,6 +47,28 @@ describe("Dockerfile", () => { expect(compose).not.toContain("127.0.0.1:18789/healthz"); }); + it("executes the documented Compose health command and validates JSON envelopes", async () => { + const docs = await readFile(dockerInstallDocsPath, "utf8"); + const composeSetup = await readFile(composeSetupScriptPath, "utf8"); + const gatewayHealthCommand = + 'node dist/index.js gateway health --token "$OPENCLAW_GATEWAY_TOKEN"'; + + expect(docs).toContain(`docker compose exec openclaw-gateway sh -lc '${gatewayHealthCommand}'`); + expect(docs).not.toContain('node dist/index.js health --token "$OPENCLAW_GATEWAY_TOKEN"'); + expect(composeSetup).toContain( + `"\${COMPOSE[@]}" exec -T openclaw-gateway sh -lc '${gatewayHealthCommand}'`, + ); + expect(composeSetup.match(/gateway health --token "\$TOKEN" --json/g)).toHaveLength(2); + expect(composeSetup).toContain('assert_gateway_health_json "gateway service"'); + expect(composeSetup).toContain('assert_gateway_health_json "CLI sidecar"'); + expect(composeSetup).toContain('--detail "gateway:documentedHealthCommand=passed"'); + expect(composeSetup).toContain('--detail "gateway:healthJsonEnvelope=passed"'); + expect(composeSetup).toContain('--detail "cli:healthJsonEnvelope=passed"'); + expect(composeSetup).not.toContain('dist/index.js health --token "$TOKEN"'); + expect(composeSetup).toContain('-v "$PROJECT_DIR:/target"'); + expect(composeSetup).toContain("rm -rf /target/* /target/.[!.]* /target/..?*"); + }); + it("does not force an external Dockerfile frontend pull", async () => { for (const path of dockerSetupDockerfilePaths) { const dockerfile = await readFile(join(repoRoot, path), "utf8");