diff --git a/doc/HERMES_GATEWAY_SMOKE.md b/doc/HERMES_GATEWAY_SMOKE.md new file mode 100644 index 00000000000..3bf9a103224 --- /dev/null +++ b/doc/HERMES_GATEWAY_SMOKE.md @@ -0,0 +1,141 @@ +# Hermes Gateway Smoke + +This smoke validates the built-in `hermes_gateway` adapter against a fresh +Hermes gateway. Keep real Hermes execution manual/local: the CI-safe checks only +lint shell syntax and focused helper behavior. + +## CI-safe validation + +Run these from the repo root: + +```sh +bash -n scripts/smoke/hermes-gateway-join.sh scripts/smoke/hermes-gateway-e2e.sh +pnpm test:hermes-gateway-smoke +``` + +`pnpm test:hermes-gateway-smoke` does not start Docker, Hermes, or Paperclip. It +checks script help output, shell syntax, redaction helpers, URL slash handling, +and the non-loopback HTTP guard. + +## Secrets and cleanup + +- Set the Hermes gateway key with `HERMES_GATEWAY_API_KEY` or `API_SERVER_KEY`. + The scripts print only `sha256=` and length for secret identifiers. +- Board/operator auth is required through `PAPERCLIP_AUTH_HEADER`, + `PAPERCLIP_COOKIE`, or a board-capable `PAPERCLIP_API_KEY`. +- Diagnostic files are redacted before they are written, except the join output + file intentionally contains the claimed Paperclip agent key and is written + `chmod 600`. +- Successful runs remove the smoke issue, smoke agent, join request, Docker + container, and per-run local state. +- Set `HERMES_SMOKE_KEEP=1` to preserve diagnostics, state, and the container. + Failed runs automatically preserve them and print the retained paths. + +## URL model + +The smoke has three URLs because different processes need different routes: + +- `PAPERCLIP_API_URL`: Paperclip URL used by the operator shell. +- `PAPERCLIP_API_URL_FOR_HERMES`: Paperclip URL used from inside the Hermes + container or remote Hermes host. +- `HERMES_GATEWAY_API_BASE_URL`: Hermes gateway URL stored on the Paperclip + adapter, reachable by the Paperclip server. +- `HERMES_GATEWAY_PROBE_URL`: Hermes gateway URL used by the operator shell for + direct `/health`, `/v1/capabilities`, `/v1/runs`, and SSE checks. + +Loopback HTTP gateway URLs are allowed. Non-loopback HTTP gateway URLs require +`HERMES_GATEWAY_ALLOW_INSECURE_HTTP=1` and should only be used on local/private +development networks. Use HTTPS for real remote gateways. + +## Docker Desktop or Linux host Paperclip + +Use this when Paperclip runs on the host at `127.0.0.1:3100` and Docker can +reach the host through `host.docker.internal`. + +```sh +PAPERCLIP_API_URL=http://127.0.0.1:3100 \ +PAPERCLIP_AUTH_HEADER='Bearer ' \ +pnpm smoke:hermes-gateway-e2e +``` + +Linux uses `--add-host=host.docker.internal:host-gateway` by default through +`HERMES_DOCKER_ADD_HOST=1`. If your Docker setup already provides +`host.docker.internal`, the same command works. + +## Same Docker network as Paperclip + +Use this when Paperclip is a container on a Docker network and the Hermes smoke +container should be reachable by container DNS. The operator shell still probes +the host-published loopback port. + +```sh +PAPERCLIP_API_URL=http://127.0.0.1:3100 \ +PAPERCLIP_AUTH_HEADER='Bearer ' \ +HERMES_CONTAINER_NAME=paperclip-hermes-gateway-smoke \ +HERMES_SMOKE_NETWORK=paperclip_default \ +HERMES_DOCKER_ADD_HOST=0 \ +HERMES_GATEWAY_API_BASE_URL=http://paperclip-hermes-gateway-smoke:8642 \ +HERMES_GATEWAY_PROBE_URL=http://127.0.0.1:8642 \ +PAPERCLIP_API_URL_FOR_HERMES=http://paperclip:3100 \ +HERMES_GATEWAY_ALLOW_INSECURE_HTTP=1 \ +pnpm smoke:hermes-gateway-e2e +``` + +Change `paperclip_default` and `paperclip` to your Compose network and service +name. The unsafe HTTP flag is required because Paperclip stores a non-loopback +`http://` gateway URL for private Docker DNS. + +## LAN or private-network Paperclip + +Use this when Paperclip is exposed on a private IP or tailnet address and the +Hermes container can reach that address. + +```sh +PAPERCLIP_API_URL=http://192.168.1.20:3100 \ +PAPERCLIP_AUTH_HEADER='Bearer ' \ +PAPERCLIP_API_URL_FOR_HERMES=http://192.168.1.20:3100 \ +HERMES_GATEWAY_API_BASE_URL=http://192.168.1.20:8642 \ +HERMES_GATEWAY_PROBE_URL=http://127.0.0.1:8642 \ +HERMES_GATEWAY_ALLOW_INSECURE_HTTP=1 \ +pnpm smoke:hermes-gateway-e2e +``` + +Only use this on a trusted private network. For anything beyond local/private +development, put the Hermes gateway behind TLS and use the reverse-proxy mode. + +## Reverse proxy / TLS + +Use this when Paperclip should talk to Hermes through a TLS hostname. The smoke +container still publishes a local port, and your reverse proxy forwards the TLS +hostname to that port. + +```sh +PAPERCLIP_API_URL=https://paperclip.example.com \ +PAPERCLIP_AUTH_HEADER='Bearer ' \ +PAPERCLIP_API_URL_FOR_HERMES=https://paperclip.example.com \ +HERMES_GATEWAY_API_BASE_URL=https://hermes-gateway.example.com \ +HERMES_GATEWAY_PROBE_URL=http://127.0.0.1:8642 \ +pnpm smoke:hermes-gateway-e2e +``` + +No unsafe HTTP escape hatch is needed because the adapter URL is HTTPS. + +## Join-only validation + +If a Hermes gateway is already running, use the join helper without building or +starting a Docker container: + +```sh +API_SERVER_ENABLED=true API_SERVER_KEY='' hermes gateway run --replace --accept-hooks + +PAPERCLIP_API_URL=http://127.0.0.1:3100 \ +PAPERCLIP_AUTH_HEADER='Bearer ' \ +HERMES_GATEWAY_API_BASE_URL=http://127.0.0.1:8642 \ +HERMES_GATEWAY_API_KEY='' \ +pnpm smoke:hermes-gateway-join +``` + +For non-loopback private HTTP join-only runs, set +`HERMES_GATEWAY_ALLOW_INSECURE_HTTP=1`. For Docker DNS or reverse-proxy setups, +set `HERMES_GATEWAY_PROBE_URL` to the URL reachable from the operator shell and +`HERMES_GATEWAY_API_BASE_URL` to the URL Paperclip should store on the adapter. diff --git a/package.json b/package.json index af788767985..71503daeaf5 100644 --- a/package.json +++ b/package.json @@ -38,8 +38,10 @@ "check:tokens": "node scripts/check-forbidden-tokens.mjs", "check:no-git-push": "node scripts/check-no-git-push.mjs", "test:check-no-git-push": "node --test scripts/check-no-git-push.test.mjs", + "test:hermes-gateway-smoke": "node --test scripts/smoke/hermes-gateway-smoke.test.mjs", "docs:dev": "cd docs && npx mintlify dev", "smoke:hermes-gateway-join": "./scripts/smoke/hermes-gateway-join.sh", + "smoke:hermes-gateway-e2e": "./scripts/smoke/hermes-gateway-e2e.sh", "smoke:openclaw-join": "./scripts/smoke/openclaw-join.sh", "smoke:openclaw-docker-ui": "./scripts/smoke/openclaw-docker-ui.sh", "smoke:openclaw-sse-standalone": "./scripts/smoke/openclaw-sse-standalone.sh", diff --git a/scripts/smoke/hermes-gateway-e2e.sh b/scripts/smoke/hermes-gateway-e2e.sh index a569434d479..da832f8e6f6 100755 --- a/scripts/smoke/hermes-gateway-e2e.sh +++ b/scripts/smoke/hermes-gateway-e2e.sh @@ -36,6 +36,7 @@ HERMES_DOCKER_CONTEXT="${HERMES_DOCKER_CONTEXT:-docker/hermes-gateway-smoke}" HERMES_CONTAINER_NAME="${HERMES_CONTAINER_NAME:-paperclip-hermes-gateway-smoke-${RUN_SUFFIX}}" HERMES_GATEWAY_PORT="${HERMES_GATEWAY_PORT:-8642}" HERMES_GATEWAY_API_BASE_URL="${HERMES_GATEWAY_API_BASE_URL:-http://127.0.0.1:${HERMES_GATEWAY_PORT}}" +HERMES_GATEWAY_PROBE_URL="${HERMES_GATEWAY_PROBE_URL:-http://127.0.0.1:${HERMES_GATEWAY_PORT}}" HERMES_GATEWAY_API_KEY="${HERMES_GATEWAY_API_KEY:-${API_SERVER_KEY:-}}" HERMES_GATEWAY_ALLOW_INSECURE_HTTP="${HERMES_GATEWAY_ALLOW_INSECURE_HTTP:-0}" HERMES_GATEWAY_SESSION_KEY_STRATEGY="${HERMES_GATEWAY_SESSION_KEY_STRATEGY:-issue}" @@ -54,6 +55,55 @@ CASE_TIMEOUT_SEC="${CASE_TIMEOUT_SEC:-420}" GATEWAY_READY_TIMEOUT_SEC="${GATEWAY_READY_TIMEOUT_SEC:-90}" STRICT_CASES="${STRICT_CASES:-1}" +print_usage() { + cat <<'EOF' +Hermes gateway Docker E2E smoke + +Builds a fresh Hermes gateway container, verifies the gateway API directly, +joins it to Paperclip as a hermes_gateway agent, wakes that agent on a smoke +issue, verifies the issue result, captures redacted diagnostics, and cleans up +Paperclip and Docker state unless HERMES_SMOKE_KEEP=1. + +Required: + PAPERCLIP_API_URL=http://127.0.0.1:3100 + PAPERCLIP_AUTH_HEADER='Bearer ' # or PAPERCLIP_COOKIE + +Common flags: + COMPANY_ID= or COMPANY_SELECTOR= + HERMES_VERSION=0.17.0 + HERMES_IMAGE=paperclip-hermes-gateway-smoke:local + HERMES_GATEWAY_PORT=8642 + HERMES_GATEWAY_API_BASE_URL=http://127.0.0.1:8642 + HERMES_GATEWAY_PROBE_URL=http://127.0.0.1:8642 + PAPERCLIP_API_URL_FOR_HERMES=http://host.docker.internal:3100 + HERMES_GATEWAY_ALLOW_INSECURE_HTTP=1 # dev-only non-loopback HTTP + HERMES_SMOKE_NETWORK= + HERMES_DOCKER_ADD_HOST=0|1 + HERMES_SMOKE_KEEP=1 # keep diagnostics/container + HERMES_SMOKE_DIAG_DIR=/tmp/hermes-gateway-diag + +Mode notes: + HERMES_GATEWAY_API_BASE_URL is the URL stored on the Paperclip adapter and + must be reachable by the Paperclip server. HERMES_GATEWAY_PROBE_URL is the URL + this operator shell uses for direct gateway checks. They can differ for Docker + network and reverse-proxy smoke runs. + + Raw Hermes and Paperclip API keys are redacted from logs and diagnostic files. + The generated/claimed key material is kept only in the per-run state directory, + which is deleted on success unless HERMES_SMOKE_KEEP=1. + +See doc/HERMES_GATEWAY_SMOKE.md for Docker Desktop, Linux, same-network, +LAN/private-network, and reverse-proxy/TLS examples. +EOF +} + +case "${1:-}" in + -h|--help) + print_usage + exit 0 + ;; +esac + AUTH_HEADERS=() if [[ -n "${PAPERCLIP_AUTH_HEADER:-}" ]]; then AUTH_HEADERS+=(-H "Authorization: ${PAPERCLIP_AUTH_HEADER}") @@ -118,6 +168,42 @@ redact_text() { printf "%s" "$text" } +url_host() { + local url="$1" + local rest host_port host + rest="${url#http://}" + rest="${rest#https://}" + if [[ "$rest" == \[*\]* ]]; then + host="${rest#\[}" + host="${host%%\]*}" + else + host_port="${rest%%/*}" + host="${host_port%%:*}" + fi + printf "%s" "$host" +} + +is_loopback_http_host() { + local host + host="$(printf "%s" "$1" | tr '[:upper:]' '[:lower:]')" + case "$host" in + localhost|0.0.0.0|::1|0:0:0:0:0:0:0:1) return 0 ;; + esac + [[ "$host" =~ ^127\.([0-9]{1,3}\.){2}[0-9]{1,3}$ ]] +} + +is_remote_plain_http() { + local url="$1" + [[ "$url" == http://* ]] || return 1 + ! is_loopback_http_host "$(url_host "$url")" +} + +assert_gateway_api_base_url_allowed() { + if is_remote_plain_http "$HERMES_GATEWAY_API_BASE_URL" && [[ "$HERMES_GATEWAY_ALLOW_INSECURE_HTTP" != "1" ]]; then + fail "HERMES_GATEWAY_API_BASE_URL uses non-loopback http. Set HERMES_GATEWAY_ALLOW_INSECURE_HTTP=1 for local-only unsafe HTTP, or use HTTPS." + fi +} + api_request() { local method="$1" local path="$2" @@ -160,7 +246,7 @@ gateway_request() { local tmp tmp="$(mktemp)" - local url="${HERMES_GATEWAY_API_BASE_URL%/}${path}" + local url="${HERMES_GATEWAY_PROBE_URL%/}${path}" if [[ -n "$data" ]]; then RESPONSE_CODE="$(curl -s -o "$tmp" -w "%{http_code}" -X "$method" -H "Authorization: Bearer ${HERMES_GATEWAY_API_KEY}" -H "Content-Type: application/json" "$url" --data "$data" || true)" else @@ -285,6 +371,7 @@ capture_diagnostics() { echo "container=${HERMES_CONTAINER_NAME}" echo "image=${HERMES_IMAGE}" echo "gateway=${HERMES_GATEWAY_API_BASE_URL}" + echo "gatewayProbe=${HERMES_GATEWAY_PROBE_URL}" echo "paperclipApiUrl=${PAPERCLIP_API_URL}" echo "paperclipApiUrlForHermes=${PAPERCLIP_API_URL_FOR_HERMES}" echo "apiServerKeySha256=$(hash_prefix "${HERMES_GATEWAY_API_KEY:-}") len=${#HERMES_GATEWAY_API_KEY}" @@ -429,14 +516,17 @@ probe_container_to_paperclip() { } probe_gateway_readiness() { - log "waiting for Hermes gateway health at ${HERMES_GATEWAY_API_BASE_URL%/}/health" - wait_http_ready "${HERMES_GATEWAY_API_BASE_URL%/}/health" "$GATEWAY_READY_TIMEOUT_SEC" || fail "Hermes gateway health did not become ready" + log "waiting for Hermes gateway health at ${HERMES_GATEWAY_PROBE_URL%/}/health" + if [[ "$HERMES_GATEWAY_PROBE_URL" != "$HERMES_GATEWAY_API_BASE_URL" ]]; then + log "Paperclip will store Hermes gateway URL ${HERMES_GATEWAY_API_BASE_URL}" + fi + wait_http_ready "${HERMES_GATEWAY_PROBE_URL%/}/health" "$GATEWAY_READY_TIMEOUT_SEC" || fail "Hermes gateway health did not become ready" gateway_request "GET" "/health" "" "${HERMES_SMOKE_DIAG_DIR}/gateway-health.json" assert_status "200" local wrong_code - wrong_code="$(curl -sS -o /dev/null -w "%{http_code}" -H "Authorization: Bearer wrong-smoke-key" "${HERMES_GATEWAY_API_BASE_URL%/}/v1/capabilities" || true)" + wrong_code="$(curl -sS -o /dev/null -w "%{http_code}" -H "Authorization: Bearer wrong-smoke-key" "${HERMES_GATEWAY_PROBE_URL%/}/v1/capabilities" || true)" if [[ "$wrong_code" == "200" ]]; then fail "Hermes protected endpoint accepted a wrong API key" fi @@ -494,7 +584,7 @@ assert_direct_gateway_run() { local events_file="${HERMES_SMOKE_DIAG_DIR}/direct-run-${DIRECT_RUN_ID}-events.sse" curl -sS --max-time 20 -N \ -H "Authorization: Bearer ${HERMES_GATEWAY_API_KEY}" \ - "${HERMES_GATEWAY_API_BASE_URL%/}/v1/runs/${DIRECT_RUN_ID}/events" \ + "${HERMES_GATEWAY_PROBE_URL%/}/v1/runs/${DIRECT_RUN_ID}/events" \ > "${events_file}.raw" || true redact_text "$(cat "${events_file}.raw")" > "$events_file" rm -f "${events_file}.raw" @@ -562,6 +652,7 @@ join_hermes_agent() { local join_log="${HERMES_SMOKE_DIAG_DIR}/hermes-gateway-join.log" HERMES_AGENT_NAME="$HERMES_AGENT_NAME" \ HERMES_GATEWAY_API_BASE_URL="$HERMES_GATEWAY_API_BASE_URL" \ + HERMES_GATEWAY_PROBE_URL="$HERMES_GATEWAY_PROBE_URL" \ HERMES_GATEWAY_API_KEY="$HERMES_GATEWAY_API_KEY" \ HERMES_GATEWAY_ALLOW_INSECURE_HTTP="$HERMES_GATEWAY_ALLOW_INSECURE_HTTP" \ HERMES_GATEWAY_SESSION_KEY_STRATEGY="$HERMES_GATEWAY_SESSION_KEY_STRATEGY" \ @@ -773,6 +864,7 @@ main() { HERMES_GATEWAY_API_KEY="$(generate_key)" fi log "Hermes API key sha256=$(hash_prefix "$HERMES_GATEWAY_API_KEY") len=${#HERMES_GATEWAY_API_KEY}" + assert_gateway_api_base_url_allowed api_request "GET" "/health" assert_status "200" diff --git a/scripts/smoke/hermes-gateway-join.sh b/scripts/smoke/hermes-gateway-join.sh index aa6d51128c3..b646d970431 100755 --- a/scripts/smoke/hermes-gateway-join.sh +++ b/scripts/smoke/hermes-gateway-join.sh @@ -29,6 +29,7 @@ COMPANY_SELECTOR="${COMPANY_SELECTOR:-}" HERMES_AGENT_NAME="${HERMES_AGENT_NAME:-Hermes Gateway Smoke Agent}" HERMES_GATEWAY_API_BASE_URL="${HERMES_GATEWAY_API_BASE_URL:-http://127.0.0.1:${HERMES_GATEWAY_PORT:-8642}}" +HERMES_GATEWAY_PROBE_URL="${HERMES_GATEWAY_PROBE_URL:-$HERMES_GATEWAY_API_BASE_URL}" HERMES_GATEWAY_API_KEY="${HERMES_GATEWAY_API_KEY:-${API_SERVER_KEY:-}}" HERMES_GATEWAY_ALLOW_INSECURE_HTTP="${HERMES_GATEWAY_ALLOW_INSECURE_HTTP:-0}" HERMES_GATEWAY_SESSION_KEY_STRATEGY="${HERMES_GATEWAY_SESSION_KEY_STRATEGY:-issue}" @@ -37,6 +38,49 @@ PAPERCLIP_API_URL_FOR_HERMES="${PAPERCLIP_API_URL_FOR_HERMES:-}" GATEWAY_PROBE_TIMEOUT_SEC="${GATEWAY_PROBE_TIMEOUT_SEC:-4}" HERMES_JOIN_OUTPUT_FILE="${HERMES_JOIN_OUTPUT_FILE:-}" +print_usage() { + cat <<'EOF' +Hermes gateway join smoke + +Creates a Hermes gateway agent from an agent-only Paperclip invite, approves the +join request, claims the one-time Paperclip API key, and verifies the stored +adapter config without printing raw secrets. + +Required: + PAPERCLIP_API_URL=http://127.0.0.1:3100 + PAPERCLIP_AUTH_HEADER='Bearer ' # or PAPERCLIP_COOKIE + HERMES_GATEWAY_API_KEY= + +Common flags: + COMPANY_ID= or COMPANY_SELECTOR= + HERMES_GATEWAY_API_BASE_URL=http://127.0.0.1:8642 + HERMES_GATEWAY_PROBE_URL=http://127.0.0.1:8642 + PAPERCLIP_API_URL_FOR_HERMES=http://host.docker.internal:3100 + HERMES_GATEWAY_ALLOW_INSECURE_HTTP=1 # dev-only non-loopback HTTP + HERMES_GATEWAY_SESSION_KEY_STRATEGY=issue|agent|run|none + HERMES_JOIN_OUTPUT_FILE=/secure/path/join-output.json + +Notes: + HERMES_GATEWAY_API_BASE_URL is stored on the Paperclip adapter and must be + reachable by the Paperclip server. HERMES_GATEWAY_PROBE_URL is only used by + this operator shell to preflight /health, which is useful when Paperclip talks + to the gateway over a Docker network name but the operator probes localhost. + + Raw API keys are redacted from logs. HERMES_JOIN_OUTPUT_FILE contains the + claimed Paperclip agent API key and is written chmod 600. + +See doc/HERMES_GATEWAY_SMOKE.md for Docker Desktop, Linux, same-network, +LAN/private-network, and reverse-proxy/TLS examples. +EOF +} + +case "${1:-}" in + -h|--help) + print_usage + exit 0 + ;; +esac + AUTH_HEADERS=() if [[ -n "${PAPERCLIP_AUTH_HEADER:-}" ]]; then AUTH_HEADERS+=(-H "Authorization: ${PAPERCLIP_AUTH_HEADER}") @@ -141,12 +185,31 @@ EOF is_remote_plain_http() { local url="$1" [[ "$url" == http://* ]] || return 1 - case "$url" in - http://localhost|http://localhost/*|http://localhost:*) return 1 ;; - http://127.*|http://0.0.0.0|http://0.0.0.0/*|http://0.0.0.0:*) return 1 ;; - http://\[::1\]|http://\[::1\]/*|http://\[::1\]:*) return 1 ;; + ! is_loopback_http_host "$(url_host "$url")" +} + +url_host() { + local url="$1" + local rest host_port host + rest="${url#http://}" + rest="${rest#https://}" + if [[ "$rest" == \[*\]* ]]; then + host="${rest#\[}" + host="${host%%\]*}" + else + host_port="${rest%%/*}" + host="${host_port%%:*}" + fi + printf "%s" "$host" +} + +is_loopback_http_host() { + local host + host="$(printf "%s" "$1" | tr '[:upper:]' '[:lower:]')" + case "$host" in + localhost|0.0.0.0|::1|0:0:0:0:0:0:0:1) return 0 ;; esac - return 0 + [[ "$host" =~ ^127\.([0-9]{1,3}\.){2}[0-9]{1,3}$ ]] } strip_trailing_slash() { @@ -195,14 +258,18 @@ assert_onboarding_contains() { probe_hermes_gateway() { [[ -n "$HERMES_GATEWAY_API_BASE_URL" ]] || fail "HERMES_GATEWAY_API_BASE_URL is required" + [[ -n "$HERMES_GATEWAY_PROBE_URL" ]] || fail "HERMES_GATEWAY_PROBE_URL is required" [[ -n "$HERMES_GATEWAY_API_KEY" ]] || fail "HERMES_GATEWAY_API_KEY or API_SERVER_KEY is required before any Paperclip state is mutated" if is_remote_plain_http "$HERMES_GATEWAY_API_BASE_URL" && [[ "$HERMES_GATEWAY_ALLOW_INSECURE_HTTP" != "1" ]]; then fail "HERMES_GATEWAY_API_BASE_URL uses non-loopback http. Set HERMES_GATEWAY_ALLOW_INSECURE_HTTP=1 for local-only unsafe HTTP, or use HTTPS." fi - local health_url="${HERMES_GATEWAY_API_BASE_URL%/}/health" + local health_url="${HERMES_GATEWAY_PROBE_URL%/}/health" log "probing Hermes gateway health at ${health_url} with apiKey sha256=$(hash_prefix "$HERMES_GATEWAY_API_KEY") len=${#HERMES_GATEWAY_API_KEY}" + if [[ "$HERMES_GATEWAY_PROBE_URL" != "$HERMES_GATEWAY_API_BASE_URL" ]]; then + log "Paperclip will store Hermes gateway URL ${HERMES_GATEWAY_API_BASE_URL}" + fi local code code="$(curl -sS -o /dev/null -w "%{http_code}" --max-time "$GATEWAY_PROBE_TIMEOUT_SEC" -H "Authorization: Bearer ${HERMES_GATEWAY_API_KEY}" "$health_url" || true)" if [[ "$code" != "200" ]]; then diff --git a/scripts/smoke/hermes-gateway-smoke.test.mjs b/scripts/smoke/hermes-gateway-smoke.test.mjs new file mode 100644 index 00000000000..04cc44144e8 --- /dev/null +++ b/scripts/smoke/hermes-gateway-smoke.test.mjs @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); +const joinScript = path.join(repoRoot, "scripts", "smoke", "hermes-gateway-join.sh"); +const e2eScript = path.join(repoRoot, "scripts", "smoke", "hermes-gateway-e2e.sh"); +const entrypointScript = path.join(repoRoot, "docker", "hermes-gateway-smoke", "entrypoint.sh"); + +function run(command, args, options = {}) { + return spawnSync(command, args, { + cwd: repoRoot, + encoding: "utf8", + ...options, + }); +} + +function assertSuccess(result, label) { + assert.equal( + result.status, + 0, + `${label} failed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ); +} + +function extractFunction(scriptText, name) { + const lines = scriptText.split("\n"); + const start = lines.findIndex((line) => line.trim() === `${name}() {`); + assert.notEqual(start, -1, `missing function ${name}`); + + const collected = []; + for (let index = start; index < lines.length; index += 1) { + collected.push(lines[index]); + if (index > start && lines[index].trim() === "}") { + return collected.join("\n"); + } + } + assert.fail(`unterminated function ${name}`); +} + +function runBashFunctions(scriptPath, functionNames, body) { + const scriptText = fs.readFileSync(scriptPath, "utf8"); + const functions = functionNames.map((name) => extractFunction(scriptText, name)).join("\n\n"); + return run("bash", ["-c", `set -euo pipefail\n${functions}\n${body}`]); +} + +test("Hermes gateway smoke shell scripts pass bash syntax validation", () => { + const result = run("bash", ["-n", joinScript, e2eScript, entrypointScript]); + assertSuccess(result, "bash -n"); +}); + +test("Hermes gateway smoke help documents operator safety flags", () => { + for (const script of [joinScript, e2eScript]) { + const result = run("bash", [script, "--help"]); + assertSuccess(result, `${path.basename(script)} --help`); + assert.match(result.stdout, /HERMES_GATEWAY_API_BASE_URL/); + assert.match(result.stdout, /HERMES_GATEWAY_PROBE_URL/); + assert.match(result.stdout, /HERMES_GATEWAY_ALLOW_INSECURE_HTTP/); + assert.match(result.stdout, /redact|redacted|Raw .*keys are redacted/i); + } + + const e2eHelp = run("bash", [e2eScript, "--help"]).stdout; + assert.match(e2eHelp, /HERMES_SMOKE_KEEP/); + assert.match(e2eHelp, /HERMES_SMOKE_NETWORK/); + assert.match(e2eHelp, /Docker/); +}); + +test("join helper redacts known secrets without exposing raw key material", () => { + const result = runBashFunctions( + joinScript, + ["redact_text"], + ` +HERMES_GATEWAY_API_KEY="gateway-secret" +CLAIM_SECRET="claim-secret" +AGENT_API_KEY="agent-secret" +PAPERCLIP_API_KEY="paperclip-secret" +PAPERCLIP_AUTH_HEADER="Bearer board-secret" +PAPERCLIP_COOKIE="session=board-cookie" +output="$(redact_text "gateway-secret claim-secret agent-secret paperclip-secret Bearer board-secret session=board-cookie")" +[[ "$output" != *"gateway-secret"* ]] +[[ "$output" != *"claim-secret"* ]] +[[ "$output" != *"agent-secret"* ]] +[[ "$output" != *"paperclip-secret"* ]] +[[ "$output" != *"board-secret"* ]] +[[ "$output" != *"board-cookie"* ]] +[[ "$output" == *"[redacted len=14]"* ]] +`, + ); + assertSuccess(result, "redact_text"); +}); + +test("URL helpers distinguish loopback HTTP from unsafe remote HTTP", () => { + for (const script of [joinScript, e2eScript]) { + const result = runBashFunctions( + script, + ["url_host", "is_loopback_http_host", "is_remote_plain_http"], + ` +is_remote_plain_http "http://192.168.1.20:8642" +is_remote_plain_http "http://hermes-gateway.local:8642" +is_remote_plain_http "http://127.example.com:8642" +is_remote_plain_http "http://localhost.evil:8642" +! is_remote_plain_http "https://192.168.1.20:8642" +! is_remote_plain_http "http://127.0.0.1:8642" +! is_remote_plain_http "http://127.44.55.66:8642" +! is_remote_plain_http "http://localhost:8642" +! is_remote_plain_http "http://[::1]:8642" +[[ "$(url_host "http://[::1]:8642/health")" == "::1" ]] +[[ "$(url_host "http://127.example.com:8642/health")" == "127.example.com" ]] +`, + ); + assertSuccess(result, `${path.basename(script)} URL helpers`); + } +}); + +test("join helper normalizes trailing slashes for URL comparisons", () => { + const result = runBashFunctions( + joinScript, + ["strip_trailing_slash"], + ` +[[ "$(strip_trailing_slash "http://127.0.0.1:8642///")" == "http://127.0.0.1:8642" ]] +[[ "$(strip_trailing_slash "https://gateway.example.com/")" == "https://gateway.example.com" ]] +[[ "$(strip_trailing_slash "https://gateway.example.com/path/")" == "https://gateway.example.com/path" ]] +`, + ); + assertSuccess(result, "strip_trailing_slash"); +});