From 61b21059c9a2fd1c6aacaa17e48d1c3b6e12773a Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 26 Jun 2026 13:52:21 +0000 Subject: [PATCH] Add Hermes gateway smoke harness --- docker/hermes-gateway-smoke/Dockerfile | 51 ++++ docker/hermes-gateway-smoke/entrypoint.sh | 56 ++++ scripts/smoke/hermes-gateway-join.sh | 355 ++++++++++++++++++++++ 3 files changed, 462 insertions(+) create mode 100644 docker/hermes-gateway-smoke/Dockerfile create mode 100755 docker/hermes-gateway-smoke/entrypoint.sh create mode 100755 scripts/smoke/hermes-gateway-join.sh diff --git a/docker/hermes-gateway-smoke/Dockerfile b/docker/hermes-gateway-smoke/Dockerfile new file mode 100644 index 00000000000..9c3f0eda8f1 --- /dev/null +++ b/docker/hermes-gateway-smoke/Dockerfile @@ -0,0 +1,51 @@ +# syntax=docker/dockerfile:1.6 +ARG HERMES_VERSION=0.17.0 +FROM node:22-bookworm-slim + +ARG HERMES_VERSION + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash \ + ca-certificates \ + curl \ + git \ + jq \ + python3 \ + python3-pip \ + tini \ + && rm -rf /var/lib/apt/lists/* + +# The npm package is a pinned bridge that exposes the `hermes` binary. Install +# the matching Python package globally first so the non-root runtime user can +# import the upstream Hermes modules at container start. +RUN python3 -m pip install --break-system-packages --no-cache-dir "hermes-agent==${HERMES_VERSION}" \ + && npm install -g --ignore-scripts "hermes-agent@${HERMES_VERSION}" \ + && python3 -c "import hermes_cli.main" \ + && command -v hermes >/dev/null + +RUN useradd --create-home --shell /bin/bash --uid 10001 hermes \ + && mkdir -p /home/hermes/.hermes /home/hermes/workspace \ + && chown -R hermes:hermes /home/hermes + +COPY entrypoint.sh /usr/local/bin/hermes-gateway-entrypoint +RUN chmod 0755 /usr/local/bin/hermes-gateway-entrypoint + +USER hermes +WORKDIR /home/hermes/workspace + +ENV HOME=/home/hermes \ + HERMES_HOME=/home/hermes/.hermes \ + XDG_CONFIG_HOME=/home/hermes/.config \ + XDG_CACHE_HOME=/home/hermes/.cache \ + XDG_DATA_HOME=/home/hermes/.local/share \ + API_SERVER_ENABLED=true \ + API_SERVER_HOST=0.0.0.0 \ + API_SERVER_PORT=8642 \ + NO_COLOR=1 + +EXPOSE 8642 + +ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/hermes-gateway-entrypoint"] diff --git a/docker/hermes-gateway-smoke/entrypoint.sh b/docker/hermes-gateway-smoke/entrypoint.sh new file mode 100755 index 00000000000..a1496afa8c0 --- /dev/null +++ b/docker/hermes-gateway-smoke/entrypoint.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +log() { + echo "[hermes-gateway-smoke] $*" +} + +hash_prefix() { + local value="$1" + if command -v sha256sum >/dev/null 2>&1; then + printf "%s" "$value" | sha256sum | awk '{print substr($1,1,12)}' + elif command -v shasum >/dev/null 2>&1; then + printf "%s" "$value" | shasum -a 256 | awk '{print substr($1,1,12)}' + else + printf "unavailable" + fi +} + +generate_key() { + node -e 'process.stdout.write(require("node:crypto").randomBytes(32).toString("hex"))' +} + +export HOME="${HOME:-/home/hermes}" +export HERMES_HOME="${HERMES_HOME:-${HOME}/.hermes}" +export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-${HOME}/.config}" +export XDG_CACHE_HOME="${XDG_CACHE_HOME:-${HOME}/.cache}" +export XDG_DATA_HOME="${XDG_DATA_HOME:-${HOME}/.local/share}" +export API_SERVER_ENABLED="${API_SERVER_ENABLED:-true}" +export API_SERVER_HOST="${API_SERVER_HOST:-0.0.0.0}" +export API_SERVER_PORT="${API_SERVER_PORT:-8642}" +export NO_COLOR="${NO_COLOR:-1}" + +if [[ "${API_SERVER_ENABLED}" != "true" ]]; then + log "forcing API_SERVER_ENABLED=true for gateway smoke" + export API_SERVER_ENABLED=true +fi + +if [[ -z "${API_SERVER_KEY:-}" ]]; then + API_SERVER_KEY="$(generate_key)" + export API_SERVER_KEY + log "generated API_SERVER_KEY sha256=$(hash_prefix "$API_SERVER_KEY") len=${#API_SERVER_KEY}" +else + log "using provided API_SERVER_KEY sha256=$(hash_prefix "$API_SERVER_KEY") len=${#API_SERVER_KEY}" +fi + +mkdir -p "$HERMES_HOME" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" "$XDG_DATA_HOME" "$HOME/workspace" +chmod 0700 "$HERMES_HOME" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" "$XDG_DATA_HOME" || true + +log "HOME=${HOME}" +log "HERMES_HOME=${HERMES_HOME}" +log "workspace=$(pwd)" +log "API_SERVER_HOST=${API_SERVER_HOST}" +log "API_SERVER_PORT=${API_SERVER_PORT}" +log "state listing hash=$(find "$HERMES_HOME" -maxdepth 2 -type f -print 2>/dev/null | sort | sha256sum | awk '{print substr($1,1,12)}')" + +exec hermes gateway run --replace --accept-hooks "$@" diff --git a/scripts/smoke/hermes-gateway-join.sh b/scripts/smoke/hermes-gateway-join.sh new file mode 100755 index 00000000000..df1c5d9e555 --- /dev/null +++ b/scripts/smoke/hermes-gateway-join.sh @@ -0,0 +1,355 @@ +#!/usr/bin/env bash +set -euo pipefail + +log() { + echo "[hermes-gateway-join] $*" +} + +warn() { + echo "[hermes-gateway-join] WARN: $*" >&2 +} + +fail() { + echo "[hermes-gateway-join] ERROR: $*" >&2 + exit 1 +} + +require_cmd() { + local cmd="$1" + command -v "$cmd" >/dev/null 2>&1 || fail "missing required command: ${cmd}" +} + +require_cmd curl +require_cmd jq + +PAPERCLIP_API_URL="${PAPERCLIP_API_URL:-http://localhost:3100}" +API_BASE="${PAPERCLIP_API_URL%/}/api" +COMPANY_ID="${COMPANY_ID:-${PAPERCLIP_COMPANY_ID:-}}" +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_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}" +HERMES_GATEWAY_TIMEOUT_SEC="${HERMES_GATEWAY_TIMEOUT_SEC:-180}" +PAPERCLIP_API_URL_FOR_HERMES="${PAPERCLIP_API_URL_FOR_HERMES:-}" +GATEWAY_PROBE_TIMEOUT_SEC="${GATEWAY_PROBE_TIMEOUT_SEC:-4}" + +AUTH_HEADERS=() +if [[ -n "${PAPERCLIP_AUTH_HEADER:-}" ]]; then + AUTH_HEADERS+=(-H "Authorization: ${PAPERCLIP_AUTH_HEADER}") +elif [[ -n "${PAPERCLIP_API_KEY:-}" ]]; then + AUTH_HEADERS+=(-H "Authorization: Bearer ${PAPERCLIP_API_KEY}") +fi +if [[ -n "${PAPERCLIP_COOKIE:-}" ]]; then + AUTH_HEADERS+=(-H "Cookie: ${PAPERCLIP_COOKIE}") +fi + +RESPONSE_CODE="" +RESPONSE_BODY="" + +hash_prefix() { + local value="$1" + if command -v sha256sum >/dev/null 2>&1; then + printf "%s" "$value" | sha256sum | awk '{print substr($1,1,12)}' + elif command -v shasum >/dev/null 2>&1; then + printf "%s" "$value" | shasum -a 256 | awk '{print substr($1,1,12)}' + else + printf "unavailable" + fi +} + +redact_text() { + local text="$1" + local secret + for secret in "${HERMES_GATEWAY_API_KEY:-}" "${CLAIM_SECRET:-}" "${AGENT_API_KEY:-}" "${PAPERCLIP_AUTH_HEADER:-}" "${PAPERCLIP_COOKIE:-}" "${PAPERCLIP_API_KEY:-}"; do + if [[ -n "$secret" ]]; then + text="${text//$secret/[redacted len=${#secret}]}" + fi + done + printf "%s" "$text" +} + +print_response_error() { + redact_text "$RESPONSE_BODY" >&2 + echo >&2 +} + +api_request() { + local method="$1" + local path="$2" + local data="${3-}" + local tmp + tmp="$(mktemp)" + + local url + if [[ "$path" == http://* || "$path" == https://* ]]; then + url="$path" + elif [[ "$path" == /api/* ]]; then + url="${PAPERCLIP_API_URL%/}${path}" + else + url="${API_BASE}${path}" + fi + + if [[ -n "$data" ]]; then + RESPONSE_CODE="$(curl -sS -o "$tmp" -w "%{http_code}" -X "$method" "${AUTH_HEADERS[@]}" -H "Content-Type: application/json" "$url" --data "$data")" + else + RESPONSE_CODE="$(curl -sS -o "$tmp" -w "%{http_code}" -X "$method" "${AUTH_HEADERS[@]}" "$url")" + fi + RESPONSE_BODY="$(cat "$tmp")" + rm -f "$tmp" +} + +assert_status() { + local expected="$1" + if [[ "$RESPONSE_CODE" != "$expected" ]]; then + print_response_error + fail "expected HTTP ${expected}, got HTTP ${RESPONSE_CODE}" + fi +} + +assert_json_has_string() { + local jq_expr="$1" + local value + value="$(jq -r "$jq_expr // empty" <<<"$RESPONSE_BODY")" + if [[ -z "$value" ]]; then + print_response_error + fail "expected JSON string at ${jq_expr}" + fi + echo "$value" +} + +fail_board_auth_required() { + local operation="$1" + print_response_error + cat >&2 <&2 + fail "onboarding response missing expected text: ${needle}" + fi +} + +probe_hermes_gateway() { + [[ -n "$HERMES_GATEWAY_API_BASE_URL" ]] || fail "HERMES_GATEWAY_API_BASE_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" + log "probing Hermes gateway health at ${health_url} with apiKey sha256=$(hash_prefix "$HERMES_GATEWAY_API_KEY") len=${#HERMES_GATEWAY_API_KEY}" + 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 + fail "Hermes gateway health probe failed before mutating Paperclip state: ${health_url} returned HTTP ${code}. Start Hermes with API_SERVER_ENABLED=true API_SERVER_KEY= hermes gateway run --replace --accept-hooks, or set HERMES_GATEWAY_API_BASE_URL/HERMES_GATEWAY_API_KEY." + fi +} + +log "checking Paperclip health" +api_request "GET" "/health" +assert_status "200" +log "deployment mode=$(jq -r '.deploymentMode // "unknown"' <<<"$RESPONSE_BODY") exposure=$(jq -r '.deploymentExposure // "unknown"' <<<"$RESPONSE_BODY")" + +resolve_company_id +probe_hermes_gateway + +log "creating agent-only invite for company ${COMPANY_ID}" +INVITE_PAYLOAD="$(jq -nc '{allowedJoinTypes:"agent"}')" +api_request "POST" "/companies/${COMPANY_ID}/invites" "$INVITE_PAYLOAD" +if [[ "$RESPONSE_CODE" == "401" || "$RESPONSE_CODE" == "403" ]]; then + fail_board_auth_required "Invite creation" +fi +assert_status "201" +INVITE_TOKEN="$(assert_json_has_string '.token')" +INVITE_ID="$(assert_json_has_string '.id')" +log "created invite ${INVITE_ID}" + +log "verifying onboarding JSON and text endpoints" +api_request "GET" "/invites/${INVITE_TOKEN}/onboarding" +assert_status "200" +ONBOARDING_JSON="$RESPONSE_BODY" +ONBOARDING_TEXT_PATH="$(jq -r '.invite.onboardingTextPath // empty' <<<"$ONBOARDING_JSON")" +[[ -n "$ONBOARDING_TEXT_PATH" ]] || fail "onboarding manifest missing invite.onboardingTextPath" +assert_onboarding_contains "$ONBOARDING_JSON" "hermes_gateway" +assert_onboarding_contains "$ONBOARDING_JSON" "API_SERVER_ENABLED=true" +assert_onboarding_contains "$ONBOARDING_JSON" "API_SERVER_KEY" +assert_onboarding_contains "$ONBOARDING_JSON" "agentDefaultsPayload" + +api_request "GET" "/invites/${INVITE_TOKEN}/onboarding.txt" +assert_status "200" +ONBOARDING_TEXT="$RESPONSE_BODY" +assert_onboarding_contains "$ONBOARDING_TEXT" 'adapterType: "hermes_gateway"' +assert_onboarding_contains "$ONBOARDING_TEXT" "API_SERVER_ENABLED=true" +assert_onboarding_contains "$ONBOARDING_TEXT" "API_SERVER_KEY" +assert_onboarding_contains "$ONBOARDING_TEXT" "hermes gateway run --replace --accept-hooks" +assert_onboarding_contains "$ONBOARDING_TEXT" "agentDefaultsPayload.apiBaseUrl" + +JOIN_PAYLOAD="$(jq -nc \ + --arg name "$HERMES_AGENT_NAME" \ + --arg apiBaseUrl "$HERMES_GATEWAY_API_BASE_URL" \ + --arg apiKey "$HERMES_GATEWAY_API_KEY" \ + --arg paperclipApiUrl "$PAPERCLIP_API_URL_FOR_HERMES" \ + --arg sessionKeyStrategy "$HERMES_GATEWAY_SESSION_KEY_STRATEGY" \ + --argjson timeoutSec "$HERMES_GATEWAY_TIMEOUT_SEC" \ + --argjson allowInsecure "$(if [[ "$HERMES_GATEWAY_ALLOW_INSECURE_HTTP" == "1" ]]; then echo true; else echo false; fi)" \ + '{ + requestType: "agent", + agentName: $name, + adapterType: "hermes_gateway", + capabilities: "Hermes gateway Docker smoke harness", + agentDefaultsPayload: { + apiBaseUrl: $apiBaseUrl, + apiKey: $apiKey, + sessionKeyStrategy: $sessionKeyStrategy, + timeoutSec: $timeoutSec + } + } + | if $paperclipApiUrl != "" then .agentDefaultsPayload.paperclipApiUrl = $paperclipApiUrl else . end + | if $allowInsecure then .agentDefaultsPayload.dangerouslyAllowInsecureRemoteHttp = true else . end')" + +log "submitting Hermes gateway agent join request" +api_request "POST" "/invites/${INVITE_TOKEN}/accept" "$JOIN_PAYLOAD" +if [[ "$RESPONSE_CODE" != "202" ]]; then + print_response_error +fi +assert_status "202" +JOIN_REQUEST_ID="$(assert_json_has_string '.id')" +CLAIM_SECRET="$(assert_json_has_string '.claimSecret')" +CLAIM_API_PATH="$(assert_json_has_string '.claimApiKeyPath')" +DIAGNOSTICS_JSON="$(jq -c '.diagnostics // []' <<<"$RESPONSE_BODY")" +if [[ "$DIAGNOSTICS_JSON" != "[]" ]]; then + log "join diagnostics: $(redact_text "$DIAGNOSTICS_JSON")" +fi + +if is_remote_plain_http "$HERMES_GATEWAY_API_BASE_URL"; then + if ! jq -e '[.diagnostics[]? | select(.code == "hermes_gateway_plain_http_remote_unsafe_allowed")] | length > 0' <<<"$RESPONSE_BODY" >/dev/null; then + fail "expected hermes_gateway_plain_http_remote_unsafe_allowed diagnostic for non-loopback HTTP join" + fi +fi + +log "approving join request ${JOIN_REQUEST_ID}" +api_request "POST" "/companies/${COMPANY_ID}/join-requests/${JOIN_REQUEST_ID}/approve" "{}" +if [[ "$RESPONSE_CODE" == "401" || "$RESPONSE_CODE" == "403" ]]; then + fail_board_auth_required "Join approval" +fi +assert_status "200" +CREATED_AGENT_ID="$(assert_json_has_string '.createdAgentId')" + +log "verifying invalid claim secret is rejected" +api_request "POST" "/join-requests/${JOIN_REQUEST_ID}/claim-api-key" '{"claimSecret":"invalid-smoke-secret-value"}' +if [[ "$RESPONSE_CODE" == "201" ]]; then + fail "invalid claim secret unexpectedly succeeded" +fi + +log "claiming API key with one-time claim secret" +CLAIM_PAYLOAD="$(jq -nc --arg secret "$CLAIM_SECRET" '{claimSecret:$secret}')" +api_request "POST" "$CLAIM_API_PATH" "$CLAIM_PAYLOAD" +assert_status "201" +AGENT_API_KEY="$(assert_json_has_string '.token')" +KEY_ID="$(assert_json_has_string '.keyId')" + +log "verifying replay claim is rejected" +api_request "POST" "$CLAIM_API_PATH" "$CLAIM_PAYLOAD" +if [[ "$RESPONSE_CODE" == "201" ]]; then + fail "claim secret replay unexpectedly succeeded" +fi + +log "verifying stored Hermes gateway agent config" +api_request "GET" "/agents/${CREATED_AGENT_ID}" +assert_status "200" + +AGENT_ADAPTER_TYPE="$(jq -r '.adapterType // empty' <<<"$RESPONSE_BODY")" +[[ "$AGENT_ADAPTER_TYPE" == "hermes_gateway" ]] || fail "expected adapterType=hermes_gateway, got ${AGENT_ADAPTER_TYPE}" + +STORED_API_BASE_URL="$(jq -r '.adapterConfig.apiBaseUrl // empty' <<<"$RESPONSE_BODY")" +[[ -n "$STORED_API_BASE_URL" ]] || fail "stored adapterConfig.apiBaseUrl is missing" +if [[ "$(strip_trailing_slash "$STORED_API_BASE_URL")" != "$(strip_trailing_slash "$HERMES_GATEWAY_API_BASE_URL")" ]]; then + fail "stored apiBaseUrl mismatch: expected $(strip_trailing_slash "$HERMES_GATEWAY_API_BASE_URL"), got $(strip_trailing_slash "$STORED_API_BASE_URL")" +fi + +if jq -e --arg raw "$HERMES_GATEWAY_API_KEY" '.adapterConfig.apiKey == $raw' <<<"$RESPONSE_BODY" >/dev/null; then + fail "stored adapterConfig.apiKey leaked the raw Hermes API key" +fi +if ! jq -e '(.adapterConfig.apiKey.type // "") == "secret_ref"' <<<"$RESPONSE_BODY" >/dev/null; then + warn "stored adapterConfig.apiKey is not a visible secret_ref; response shape may redact it entirely" +fi + +STORED_SESSION_STRATEGY="$(jq -r '.adapterConfig.sessionKeyStrategy // empty' <<<"$RESPONSE_BODY")" +[[ "$STORED_SESSION_STRATEGY" == "$HERMES_GATEWAY_SESSION_KEY_STRATEGY" ]] || fail "stored sessionKeyStrategy mismatch: expected ${HERMES_GATEWAY_SESSION_KEY_STRATEGY}, got ${STORED_SESSION_STRATEGY:-}" + +if [[ -n "$PAPERCLIP_API_URL_FOR_HERMES" ]]; then + STORED_PAPERCLIP_API_URL="$(jq -r '.adapterConfig.paperclipApiUrl // empty' <<<"$RESPONSE_BODY")" + [[ "$STORED_PAPERCLIP_API_URL" == "$PAPERCLIP_API_URL_FOR_HERMES" || "$(strip_trailing_slash "$STORED_PAPERCLIP_API_URL")" == "$(strip_trailing_slash "$PAPERCLIP_API_URL_FOR_HERMES")" ]] \ + || fail "stored paperclipApiUrl mismatch" +fi + +log "success" +log "companyId=${COMPANY_ID}" +log "inviteId=${INVITE_ID}" +log "joinRequestId=${JOIN_REQUEST_ID}" +log "agentId=${CREATED_AGENT_ID}" +log "keyId=${KEY_ID}" +log "hermesGatewayApiKeySha256=$(hash_prefix "$HERMES_GATEWAY_API_KEY") len=${#HERMES_GATEWAY_API_KEY}" +log "agentApiKeySha256=$(hash_prefix "$AGENT_API_KEY") len=${#AGENT_API_KEY}"