mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 10:48:12 +02:00
[codex] Add built-in Hermes adapters (#8543)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agent adapters are the boundary between the control plane and the runtimes that actually do work. > - Hermes support needs to be available as first-class local and gateway adapters while still preserving the adapter-manager override path for external packages. > - The adapter work touches runtime execution, UI adapter metadata, onboarding prompts, scoped credentials, release packaging, and smoke coverage, so the handoff needs concrete verification rather than only unit tests. > - This pull request adds built-in Hermes local and Hermes gateway support, keeps external adapter overrides compatible, and documents/tests the gateway flow end to end. > - The benefit is that operators can hire Hermes-backed agents without a manual plugin install, while self-hosted installs can still override/shadow the built-ins through Adapter manager packages. ## Linked Issues or Issue Description No public GitHub issue exists for this exact Hermes built-in adapter, gateway onboarding, and release-source work. Problem description: - Hermes local and gateway adapters need a public, reviewable source path in the monorepo so package artifacts and built-in adapter behavior match the application source. - Operators need built-in `hermes_local` and `hermes_gateway` adapter choices without losing the ability to install external Hermes packages as overrides. - Gateway onboarding needs secure defaults for API server URLs, API keys, and generated agent setup text. - Hermes-originated task bridge credentials need narrower API-key scope configuration. - Related public PRs found during duplicate search include #3027, #2363, #7544, #7950, #8095, and #8543. ## What Changed - Added the unified Hermes adapter package with local and gateway server/UI/CLI exports, config schemas, transcript parsing, model detection, and package metadata. - Registered `hermes_local` and `hermes_gateway` as built-in adapters across shared constants, server registries, CLI packaging, and UI adapter registries. - Kept the external adapter override path compatible so installed Hermes packages can shadow built-ins and restore the built-in parser when disabled. - Added Hermes gateway onboarding docs, board-operator docs, Docker smoke assets, and shell smoke harnesses for join/e2e validation. - Added scoped task-bridge API-key support, authorization checks, issue-origin handling, and tests for Hermes-created Paperclip tasks. - Hardened gateway transport and redaction behavior for API keys, headers, session data, and smoke diagnostics. - Updated release packaging/bootstrap checks for the Hermes packages while leaving `pnpm-lock.yaml` out of the PR per repository policy. ## Verification Targeted local verification recorded before PR handoff: - `pnpm --filter @paperclipai/hermes-paperclip-adapter exec vitest run src/gateway/server/execute.test.ts` — 14/14 passed. - `pnpm test:hermes-gateway-smoke` — 6/6 passed. - Hermes package typecheck/build checks passed. - Focused server/UI adapter tests passed — 31/31. - Release helper Node tests passed — 18/18. - `git diff --check origin/master..HEAD` passed. Fresh Docker E2E smoke evidence: - Ran `pnpm smoke:hermes-gateway-e2e` on 2026-06-26 with a fresh state directory and fresh Docker container against a live Paperclip dev server. - Hermes direct execution reached `completed`. - Hermes stop/cancel path reached `cancelled`. - Hermes gateway created a Paperclip task, Paperclip ran the Hermes agent, and the task reached `done` with the expected marker response. - Temporary board auth keys, token files, smoke state, and Docker containers were cleaned up after the run. PR checks on head `b5eae40ce`: - GitHub Actions passed: `policy`, `review`, `Typecheck + Release Registry`, all general test shards, all serialized server shards, `Build`, `Canary Dry Run`, `e2e`, and aggregate `verify`. - External checks passed: Snyk and Socket Project Report. - External Socket Pull Request Alerts remained pending after the first-party CI matrix completed. ## Risks - Medium risk: this spans adapter registration, package publishing, gateway execution, onboarding docs, API-key scoping, and UI adapter metadata. - Migration risk is low: the scope-config migration adds a nullable column and does not rewrite existing keys. - Gateway execution depends on operator-provided Hermes API configuration; the smoke covers the Docker gateway path but real deployments may differ by network/auth setup. - Direct Greptile review on the latest expanded diff is file-count limited, although the commitperclip review gate passed. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, GPT-5 coding agent, tool use enabled in a local repository workspace. Context window size is not exposed in this environment. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Commitperclip review gate is green; direct Greptile review is file-count limited on the latest expanded diff - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
ef1422c23e
commit
fd2f82ac5b
131 files changed
+10179
-686
No files matched your search
@@ -2,7 +2,7 @@
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
import { buildReleasePackagePlan } from "./release-package-map.mjs";
|
||||
|
||||
@@ -187,13 +187,24 @@ function printNextSteps(pkg) {
|
||||
);
|
||||
}
|
||||
|
||||
function publishPackage(pkg, otp) {
|
||||
const publishArgs = ["publish", "--access", "public"];
|
||||
if (otp) {
|
||||
publishArgs.push("--otp", otp);
|
||||
function buildPublishArgs(pkg, { dryRun = false, otp = null } = {}) {
|
||||
const args = ["publish", pkg.dir, "--no-git-checks", "--access", "public"];
|
||||
|
||||
if (dryRun) {
|
||||
args.push("--dry-run");
|
||||
}
|
||||
|
||||
const result = runCommand("npm", publishArgs, { cwd: join(repoRoot, pkg.dir) });
|
||||
if (otp) {
|
||||
args.push("--otp", otp);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
function publishPackage(pkg, otp) {
|
||||
const publishArgs = buildPublishArgs(pkg, { otp });
|
||||
|
||||
const result = runCommand("pnpm", publishArgs);
|
||||
const stdout = result.stdout ?? "";
|
||||
const stderr = result.stderr ?? "";
|
||||
const output = `${stdout}\n${stderr}`.trim();
|
||||
@@ -214,7 +225,7 @@ function publishPackage(pkg, otp) {
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`${formatCommand("npm", publishArgs)} failed with status ${result.status ?? "unknown"}`);
|
||||
throw new Error(`${formatCommand("pnpm", publishArgs)} failed with status ${result.status ?? "unknown"}`);
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
@@ -255,7 +266,7 @@ function main(argv) {
|
||||
}
|
||||
|
||||
process.stdout.write(`Previewing publish payload for ${pkg.name}...\n`);
|
||||
runChecked("npm", ["pack", "--dry-run"], { cwd: join(repoRoot, pkg.dir) });
|
||||
runChecked("pnpm", buildPublishArgs(pkg, { dryRun: true }));
|
||||
|
||||
if (!publish) {
|
||||
process.stdout.write(
|
||||
@@ -286,6 +297,7 @@ if (isDirectRun) {
|
||||
}
|
||||
|
||||
export {
|
||||
buildPublishArgs,
|
||||
ensureNpmAuth,
|
||||
inspectNpmPackage,
|
||||
parseArgs,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { parseArgs, resolveTargetPackage } from "./bootstrap-npm-package.mjs";
|
||||
import { buildPublishArgs, parseArgs, resolveTargetPackage } from "./bootstrap-npm-package.mjs";
|
||||
|
||||
test("parseArgs recognizes publish and skip-build flags", () => {
|
||||
assert.deepEqual(parseArgs(["@paperclipai/adapter-acpx-local", "--publish", "--skip-build"]), {
|
||||
@@ -58,3 +58,30 @@ test("resolveTargetPackage includes the workspace diff plugin bootstrap package"
|
||||
|
||||
assert.equal(pkg.dir, "packages/plugins/plugin-workspace-diff");
|
||||
});
|
||||
|
||||
test("buildPublishArgs publishes from the repo root through pnpm", () => {
|
||||
const pkg = { dir: "packages/adapters/hermes", name: "@paperclipai/hermes-paperclip-adapter" };
|
||||
|
||||
assert.deepEqual(buildPublishArgs(pkg), [
|
||||
"publish",
|
||||
"packages/adapters/hermes",
|
||||
"--no-git-checks",
|
||||
"--access",
|
||||
"public",
|
||||
]);
|
||||
});
|
||||
|
||||
test("buildPublishArgs includes dry-run and otp flags when requested", () => {
|
||||
const pkg = { dir: "packages/adapters/hermes", name: "@paperclipai/hermes-paperclip-adapter" };
|
||||
|
||||
assert.deepEqual(buildPublishArgs(pkg, { dryRun: true, otp: "123456" }), [
|
||||
"publish",
|
||||
"packages/adapters/hermes",
|
||||
"--no-git-checks",
|
||||
"--access",
|
||||
"public",
|
||||
"--dry-run",
|
||||
"--otp",
|
||||
"123456",
|
||||
]);
|
||||
});
|
||||
@@ -32,6 +32,8 @@ const workspacePaths = [
|
||||
"packages/adapter-utils",
|
||||
"packages/adapters/claude-local",
|
||||
"packages/adapters/codex-local",
|
||||
"packages/adapters/hermes-gateway",
|
||||
"packages/adapters/hermes",
|
||||
"packages/adapters/opencode-local",
|
||||
"packages/adapters/openclaw-gateway",
|
||||
];
|
||||
|
||||
@@ -39,6 +39,16 @@
|
||||
"name": "@paperclipai/adapter-grok-local",
|
||||
"publishFromCi": true
|
||||
},
|
||||
{
|
||||
"dir": "packages/adapters/hermes",
|
||||
"name": "@paperclipai/hermes-paperclip-adapter",
|
||||
"publishFromCi": true
|
||||
},
|
||||
{
|
||||
"dir": "packages/adapters/hermes-gateway",
|
||||
"name": "@paperclipai/adapter-hermes-gateway",
|
||||
"publishFromCi": false
|
||||
},
|
||||
{
|
||||
"dir": "packages/adapters/opencode-local",
|
||||
"name": "@paperclipai/adapter-opencode-local",
|
||||
|
||||
@@ -24,6 +24,17 @@ test("release package list only contains CI-enrolled packages", () => {
|
||||
assert.ok(enabledPackages.every((pkg) => pkg.publishFromCi === true));
|
||||
});
|
||||
|
||||
test("Hermes release surface publishes the unified built-in package and keeps gateway as a shim", () => {
|
||||
const packages = buildReleasePackagePlan();
|
||||
const hermes = packages.find((pkg) => pkg.name === "@paperclipai/hermes-paperclip-adapter");
|
||||
const gatewayShim = packages.find((pkg) => pkg.name === "@paperclipai/adapter-hermes-gateway");
|
||||
|
||||
assert.equal(hermes?.dir, "packages/adapters/hermes");
|
||||
assert.equal(hermes?.publishFromCi, true);
|
||||
assert.equal(gatewayShim?.dir, "packages/adapters/hermes-gateway");
|
||||
assert.equal(gatewayShim?.publishFromCi, false);
|
||||
});
|
||||
|
||||
test("release package configuration validates successfully", () => {
|
||||
assert.doesNotThrow(() => checkConfiguration());
|
||||
});
|
||||
|
||||
Executable
+1007
File diff suppressed because it is too large.
Load diff
Executable
+450
@@ -0,0 +1,450 @@
|
||||
#!/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 <board-token>' # or PAPERCLIP_COOKIE
|
||||
HERMES_GATEWAY_API_KEY=<API_SERVER_KEY>
|
||||
|
||||
Common flags:
|
||||
COMPANY_ID=<uuid> or COMPANY_SELECTOR=<prefix|name|uuid>
|
||||
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 <<EOF
|
||||
[hermes-gateway-join] ERROR: ${operation} requires board/operator auth.
|
||||
|
||||
Provide one of:
|
||||
PAPERCLIP_AUTH_HEADER="Bearer <board-token>"
|
||||
PAPERCLIP_COOKIE="<board-session-cookie>"
|
||||
|
||||
Current auth context appears insufficient (HTTP ${RESPONSE_CODE}).
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
is_remote_plain_http() {
|
||||
local url="$1"
|
||||
[[ "$url" == http://* ]] || 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
|
||||
[[ "$host" =~ ^127\.([0-9]{1,3}\.){2}[0-9]{1,3}$ ]]
|
||||
}
|
||||
|
||||
strip_trailing_slash() {
|
||||
local value="$1"
|
||||
while [[ "$value" == */ && "$value" != "http://" && "$value" != "https://" ]]; do
|
||||
value="${value%/}"
|
||||
done
|
||||
printf "%s" "$value"
|
||||
}
|
||||
|
||||
resolve_company_id() {
|
||||
if [[ -n "$COMPANY_ID" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
log "resolving company id"
|
||||
api_request "GET" "/companies"
|
||||
if [[ "$RESPONSE_CODE" == "401" || "$RESPONSE_CODE" == "403" ]]; then
|
||||
fail_board_auth_required "Company resolution"
|
||||
fi
|
||||
assert_status "200"
|
||||
|
||||
if [[ -n "$COMPANY_SELECTOR" ]]; then
|
||||
COMPANY_ID="$(jq -r --arg selector "$COMPANY_SELECTOR" '
|
||||
map(select(
|
||||
(.id == $selector)
|
||||
or ((.issuePrefix // "") == $selector)
|
||||
or ((.name // "") == $selector)
|
||||
)) | .[0].id // empty
|
||||
' <<<"$RESPONSE_BODY")"
|
||||
[[ -n "$COMPANY_ID" ]] || fail "no company matched COMPANY_SELECTOR=${COMPANY_SELECTOR}"
|
||||
else
|
||||
COMPANY_ID="$(jq -r '.[0].id // empty' <<<"$RESPONSE_BODY")"
|
||||
[[ -n "$COMPANY_ID" ]] || fail "no companies found; create one before running smoke test"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_onboarding_contains() {
|
||||
local body="$1"
|
||||
local needle="$2"
|
||||
if ! grep -Fq "$needle" <<<"$body"; then
|
||||
echo "$body" >&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=<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:-<empty>}"
|
||||
|
||||
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
|
||||
@@ -0,0 +1,155 @@
|
||||
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, /HERMES_SMOKE_MODEL_DEFAULT/);
|
||||
assert.match(e2eHelp, /Docker/);
|
||||
});
|
||||
|
||||
test("E2E helper can seed a minimal Hermes model config without secrets", () => {
|
||||
const result = runBashFunctions(
|
||||
e2eScript,
|
||||
["log", "fail", "yaml_single_quote", "write_hermes_model_config"],
|
||||
`
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
HERMES_SMOKE_STATE_DIR="$tmp"
|
||||
HERMES_SMOKE_MODEL_PROVIDER="openrouter"
|
||||
HERMES_SMOKE_MODEL_DEFAULT="z-ai/glm-5.2"
|
||||
HERMES_SMOKE_MODEL_BASE_URL="https://openrouter.ai/api/v1"
|
||||
mkdir -p "$HERMES_SMOKE_STATE_DIR/hermes-home"
|
||||
write_hermes_model_config
|
||||
config="$HERMES_SMOKE_STATE_DIR/hermes-home/config.yaml"
|
||||
grep -Fq "default: 'z-ai/glm-5.2'" "$config"
|
||||
grep -Fq "provider: 'openrouter'" "$config"
|
||||
grep -Fq "base_url: 'https://openrouter.ai/api/v1'" "$config"
|
||||
grep -Fq "command_allowlist:" "$config"
|
||||
grep -Fq -- "- execute_code" "$config"
|
||||
! grep -Eiq "api[_-]?key|token|secret" "$config"
|
||||
`,
|
||||
);
|
||||
assertSuccess(result, "write_hermes_model_config");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
Reference in new issue
Block a user