#!/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_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}" 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}" 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}") 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="" CLAIM_SECRET="" AGENT_API_KEY="" 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_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_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 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}" if [[ -n "$HERMES_JOIN_OUTPUT_FILE" ]]; then mkdir -p "$(dirname "$HERMES_JOIN_OUTPUT_FILE")" jq -nc \ --arg companyId "$COMPANY_ID" \ --arg inviteId "$INVITE_ID" \ --arg joinRequestId "$JOIN_REQUEST_ID" \ --arg agentId "$CREATED_AGENT_ID" \ --arg keyId "$KEY_ID" \ --arg agentApiKey "$AGENT_API_KEY" \ --arg hermesGatewayApiKeySha256 "$(hash_prefix "$HERMES_GATEWAY_API_KEY")" \ --arg agentApiKeySha256 "$(hash_prefix "$AGENT_API_KEY")" \ '{ companyId: $companyId, inviteId: $inviteId, joinRequestId: $joinRequestId, agentId: $agentId, keyId: $keyId, agentApiKey: $agentApiKey, hermesGatewayApiKeySha256: $hermesGatewayApiKeySha256, agentApiKeySha256: $agentApiKeySha256 }' > "$HERMES_JOIN_OUTPUT_FILE" chmod 600 "$HERMES_JOIN_OUTPUT_FILE" log "wrote join metadata to ${HERMES_JOIN_OUTPUT_FILE} (contains secret material; chmod 600)" fi