diff --git a/AGENTS.md b/AGENTS.md index 52bc6c0a57..ddefe527d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -181,18 +181,19 @@ A change is done when all are true: ## 11. Fork-Specific: HenkDz/paperclip -This is a fork of `paperclipai/paperclip` with QoL patches and an **external-only** Hermes adapter story on branch `feat/externalize-hermes-adapter` ([tree](https://github.com/HenkDz/paperclip/tree/feat/externalize-hermes-adapter)). +This is a fork of `paperclipai/paperclip` with QoL patches and a **built-in** Hermes adapter story on branch `feat/externalize-hermes-adapter` ([tree](https://github.com/HenkDz/paperclip/tree/feat/externalize-hermes-adapter)). ### Branch Strategy -- `feat/externalize-hermes-adapter` → core has **no** `hermes-paperclip-adapter` dependency and **no** built-in `hermes_local` registration. Install Hermes via the Adapter Plugin manager (`@henkey/hermes-paperclip-adapter` or a `file:` path). -- Older fork branches may still document built-in Hermes; treat this file as authoritative for the externalize branch. +- `feat/externalize-hermes-adapter` now ships `hermes_local` and `hermes_gateway` as built-in core adapters. +- Older fork branches may still document plugin-only Hermes; treat this file as authoritative for the current branch. -### Hermes (plugin only) +### Hermes (built-in) -- Register through **Board → Adapter manager** (same as Droid). Type remains `hermes_local` once the package is loaded. -- UI uses generic **config-schema** + **ui-parser.js** from the package — no Hermes imports in `server/` or `ui/` source. -- Optional: `file:` entry in `~/.paperclip/adapter-plugins.json` for local dev of the adapter repo. +- `hermes_local` is available without Adapter manager installation and runs the local Hermes CLI. +- `hermes_gateway` is available without Adapter manager installation and calls an already-running Hermes API server. +- Operators may still install external Hermes packages through Adapter manager to override/shadow the built-ins. +- Optional: `file:` entry in `~/.paperclip/adapter-plugins.json` remains useful for local development of override packages. ### Local Dev @@ -217,5 +218,5 @@ PR #2218 (`feat/external-adapter-phase1`) adds external adapter support. See roo - Adapters can be loaded as external plugins via `~/.paperclip/adapter-plugins.json` - The plugin-loader should have ZERO hardcoded adapter imports — pure dynamic loading - `createServerAdapter()` must include ALL optional fields (especially `detectModel`) -- Built-in UI adapters can shadow external plugin parsers — remove built-in when fully externalizing -- Reference external adapters: Hermes (`@henkey/hermes-paperclip-adapter` or `file:`) and Droid (npm) +- Built-in UI adapters can shadow external plugin parsers; external override pause/resume should restore the built-in parser. +- Reference external adapters: Droid (npm); Hermes can also be tested as an override package. diff --git a/Dockerfile b/Dockerfile index cc503d98b6..a6631b71f5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,6 +31,8 @@ COPY packages/adapters/cursor-cloud/package.json packages/adapters/cursor-cloud/ COPY packages/adapters/cursor-local/package.json packages/adapters/cursor-local/ COPY packages/adapters/gemini-local/package.json packages/adapters/gemini-local/ COPY packages/adapters/grok-local/package.json packages/adapters/grok-local/ +COPY packages/adapters/hermes/package.json packages/adapters/hermes/ +COPY packages/adapters/hermes-gateway/package.json packages/adapters/hermes-gateway/ COPY packages/adapters/openclaw-gateway/package.json packages/adapters/openclaw-gateway/ COPY packages/adapters/opencode-local/package.json packages/adapters/opencode-local/ COPY packages/adapters/pi-local/package.json packages/adapters/pi-local/ diff --git a/cli/esbuild.config.mjs b/cli/esbuild.config.mjs index 7976b7c9cd..c92ea4f06f 100644 --- a/cli/esbuild.config.mjs +++ b/cli/esbuild.config.mjs @@ -21,6 +21,8 @@ const workspacePaths = [ "packages/adapter-utils", "packages/adapters/claude-local", "packages/adapters/codex-local", + "packages/adapters/hermes-gateway", + "packages/adapters/hermes", "packages/adapters/openclaw-gateway", ]; diff --git a/cli/package.json b/cli/package.json index ed894750eb..256291c2ef 100644 --- a/cli/package.json +++ b/cli/package.json @@ -51,6 +51,7 @@ "@paperclipai/db": "workspace:*", "@paperclipai/server": "workspace:*", "@paperclipai/shared": "workspace:*", + "@paperclipai/hermes-paperclip-adapter": "workspace:*", "drizzle-orm": "0.45.2", "dotenv": "^17.0.1", "commander": "^13.1.0", diff --git a/cli/src/__tests__/token.test.ts b/cli/src/__tests__/token.test.ts index 02d53fe1e7..0242427d44 100644 --- a/cli/src/__tests__/token.test.ts +++ b/cli/src/__tests__/token.test.ts @@ -70,7 +70,10 @@ describe("token commands", () => { expect(fetchMock.mock.calls[0]?.[0]).toBe(`http://localhost:3100/api/agents/worker?companyId=${COMPANY_ID}`); expect(fetchMock.mock.calls[1]?.[0]).toBe(`http://localhost:3100/api/agents/${AGENT_ID}/keys`); - expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body))).toEqual({ name: "external-worker" }); + expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body))).toEqual({ + name: "external-worker", + scope: { kind: "standard" }, + }); expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toMatchObject({ agentId: AGENT_ID, companyId: COMPANY_ID, diff --git a/cli/src/adapters/registry.ts b/cli/src/adapters/registry.ts index 31dfe0d0af..260a45d6a3 100644 --- a/cli/src/adapters/registry.ts +++ b/cli/src/adapters/registry.ts @@ -6,6 +6,8 @@ import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli"; import { printCursorCloudEvent } from "@paperclipai/adapter-cursor-cloud/cli"; import { printGeminiStreamEvent } from "@paperclipai/adapter-gemini-local/cli"; import { printGrokStreamEvent } from "@paperclipai/adapter-grok-local/cli"; +import { formatStdoutEvent as printHermesGatewayStreamEvent } from "@paperclipai/hermes-paperclip-adapter/gateway/cli"; +import { printHermesStreamEvent } from "@paperclipai/hermes-paperclip-adapter/cli"; import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli"; import { printPiStreamEvent } from "@paperclipai/adapter-pi-local/cli"; import { printOpenClawGatewayStreamEvent } from "@paperclipai/adapter-openclaw-gateway/cli"; @@ -57,6 +59,16 @@ const grokLocalCLIAdapter: CLIAdapterModule = { formatStdoutEvent: printGrokStreamEvent, }; +const hermesGatewayCLIAdapter: CLIAdapterModule = { + type: "hermes_gateway", + formatStdoutEvent: printHermesGatewayStreamEvent, +}; + +const hermesLocalCLIAdapter: CLIAdapterModule = { + type: "hermes_local", + formatStdoutEvent: printHermesStreamEvent, +}; + const openclawGatewayCLIAdapter: CLIAdapterModule = { type: "openclaw_gateway", formatStdoutEvent: printOpenClawGatewayStreamEvent, @@ -73,6 +85,8 @@ const adaptersByType = new Map( cursorCloudCLIAdapter, geminiLocalCLIAdapter, grokLocalCLIAdapter, + hermesGatewayCLIAdapter, + hermesLocalCLIAdapter, openclawGatewayCLIAdapter, processCLIAdapter, httpCLIAdapter, diff --git a/doc/CLI.md b/doc/CLI.md index 2b4bfe6fc5..fd190a0e80 100644 --- a/doc/CLI.md +++ b/doc/CLI.md @@ -688,6 +688,13 @@ pnpm paperclipai llm agent-configuration:adapter pnpm paperclipai llm agent-icons ``` +Hermes gateway uses the generic invite/join commands above rather than +`openclaw invite-prompt`. Create an agent invite, read +`invite onboarding:text`, submit a join request with +`adapterType: "hermes_gateway"` and `agentDefaultsPayload.apiBaseUrl` / +`agentDefaultsPayload.apiKey`, then approve and claim the key with the `join` +commands. See [HERMES_GATEWAY_ONBOARDING.md](./HERMES_GATEWAY_ONBOARDING.md). + ## Adapter, Asset, And Skill Commands ```sh diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index f15c244098..aa47541b0a 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -710,6 +710,13 @@ The board UI generates agent onboarding prompts from the add-agent modal (`+` in - `GET /api/skills/index` lists available skill documents. - `GET /api/skills/paperclip` returns the Paperclip heartbeat skill markdown. +Hermes gateway agents use this same generic agent invite flow with +`adapterType=hermes_gateway` and `agentDefaultsPayload.apiBaseUrl` / +`agentDefaultsPayload.apiKey`. See +[HERMES_GATEWAY_ONBOARDING.md](./HERMES_GATEWAY_ONBOARDING.md) for the full +operator path, including Hermes credentials, invite approval, key claim, and +fresh-state Docker smoke setup. + ## OpenClaw Join Smoke Test Run the end-to-end OpenClaw join smoke harness: diff --git a/doc/HERMES_GATEWAY_ONBOARDING.md b/doc/HERMES_GATEWAY_ONBOARDING.md new file mode 100644 index 0000000000..58b9dfc5b0 --- /dev/null +++ b/doc/HERMES_GATEWAY_ONBOARDING.md @@ -0,0 +1,193 @@ +# Hermes Gateway Onboarding + +Use this guide when a Hermes runtime should join Paperclip as an external +`hermes_gateway` employee. This mirrors the OpenClaw gateway invite path, but +Hermes uses the generic agent invite/onboarding flow instead of the +OpenClaw-specific invite prompt endpoint. + +## Choose The Adapter + +Paperclip ships both Hermes adapters as built-ins: + +- `hermes_local` runs the local `hermes` CLI as a child process on the + Paperclip host. +- `hermes_gateway` calls an already-running Hermes API server over HTTP/SSE. + +No Adapter manager installation is required for normal use. Adapter manager is +only needed when you intentionally install an external +`@paperclipai/hermes-paperclip-adapter` package to override or shadow a built-in +adapter while developing the Hermes package. If the external override is paused +or removed, Paperclip restores the built-in `hermes_local` / `hermes_gateway` +adapter. + +## Required Credentials + +Keep these credentials distinct: + +- Hermes inference provider key: set at least one provider key for Hermes, such + as `OPENROUTER_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, + `GEMINI_API_KEY`, `GOOGLE_API_KEY`, or `MISTRAL_API_KEY`. +- Hermes gateway key: set `API_SERVER_KEY` before starting Hermes. Paperclip + stores the same value as `agentDefaultsPayload.apiKey` so it can call Hermes. +- Paperclip agent key: created after the board approves the join request and + claimed once by the Hermes agent. Hermes uses this key as + `PAPERCLIP_API_KEY` when it calls Paperclip. + +Do not reuse the Hermes gateway key as the Paperclip agent key. The Hermes +gateway key authenticates Paperclip-to-Hermes traffic; the claimed Paperclip key +authenticates Hermes-to-Paperclip traffic. + +## Start Hermes Gateway + +Install and configure Hermes first: + +```sh +pip install hermes-agent +export OPENROUTER_API_KEY='' +export API_SERVER_KEY='' +API_SERVER_ENABLED=true hermes gateway run --replace --accept-hooks +``` + +The default Hermes API server port is `8642`. For local loopback testing, +Paperclip can usually store `http://127.0.0.1:8642` as the gateway URL. For +Docker, LAN, tailnet, or reverse-proxy setups, use a URL reachable by the +Paperclip server process. + +Plain HTTP is accepted for loopback. Non-loopback HTTP is denied by default in +the join flow; use HTTPS for real remote gateways. For private local +development only, the join payload can set +`dangerouslyAllowInsecureRemoteHttp: true`, and the smoke scripts expose the +same escape hatch as `HERMES_GATEWAY_ALLOW_INSECURE_HTTP=1`. + +## Invite From Paperclip + +In the board UI: + +1. Open the target company. +2. Use the add-agent button in the agent sidebar. +3. Generate an agent onboarding prompt/invite. +4. Give the generated onboarding text to the Hermes runtime. + +The UI prompt points Hermes at the same machine-readable onboarding endpoints: + +- `GET /api/invites/:token` +- `GET /api/invites/:token/onboarding` +- `GET /api/invites/:token/onboarding.txt` +- `GET /api/skills/index` +- `GET /api/skills/paperclip` + +For CLI-driven setup, create and inspect the invite directly: + +```sh +pnpm paperclipai invite create --company-id --payload-json '{"requestType":"agent"}' +pnpm paperclipai invite show +pnpm paperclipai invite onboarding:text +``` + +Hermes should submit a join request with `requestType: "agent"` and +`adapterType: "hermes_gateway"`: + +```json +{ + "requestType": "agent", + "agentName": "Hermes Gateway Engineer", + "adapterType": "hermes_gateway", + "capabilities": "Hermes gateway agent with code, browser, web, and file tools.", + "agentDefaultsPayload": { + "apiBaseUrl": "http://127.0.0.1:8642", + "apiKey": "", + "paperclipApiUrl": "http://127.0.0.1:3100", + "sessionKeyStrategy": "issue" + } +} +``` + +Important URL roles: + +- `agentDefaultsPayload.apiBaseUrl` is the Hermes gateway URL that Paperclip + calls. +- `agentDefaultsPayload.paperclipApiUrl` is the Paperclip base URL that Hermes + can call after approval and key claim. +- `PAPERCLIP_API_URL` / `PAPERCLIP_API_KEY` are injected runtime values for + Hermes-originated Paperclip API calls after the agent is approved. + +## Approve And Claim + +After Hermes submits the join request: + +1. In Paperclip, review the pending agent join request. +2. Approve it from the board UI, or use: + + ```sh + pnpm paperclipai join list --company-id --status pending_approval + pnpm paperclipai join approve --company-id + ``` + +3. Hermes claims the one-time agent API key: + + ```sh + pnpm paperclipai join claim-key --claim-secret + ``` + +4. Store the claimed Paperclip key in Hermes runtime state or secrets. The claim + secret and claimed key are sensitive and should not be pasted into issue + comments, logs, or prompt text. + +Once the key is claimed, create an issue assigned to the new Hermes gateway +agent and wake it through the normal Paperclip heartbeat path. + +## Local Fresh-State Smoke + +For a fresh Docker-backed Hermes gateway and end-to-end Paperclip join/run +verification, use: + +```sh +PAPERCLIP_API_URL=http://127.0.0.1:3100 \ +PAPERCLIP_AUTH_HEADER='Bearer ' \ +pnpm smoke:hermes-gateway-e2e +``` + +The E2E smoke: + +- builds a fresh Hermes gateway container +- seeds a minimal non-secret Hermes model config +- passes provider keys from the host environment without printing them +- verifies Hermes `/health`, `/v1/capabilities`, `/v1/runs`, SSE, and stop +- creates and approves a Paperclip agent-only invite +- joins as `hermes_gateway` +- wakes the agent on a smoke issue +- removes Paperclip and Docker test state on success + +If a Hermes gateway is already running and you only need to validate the invite +and stored adapter config, use the join-only helper: + +```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 +``` + +See [HERMES_GATEWAY_SMOKE.md](./HERMES_GATEWAY_SMOKE.md) for Docker Desktop, +Linux, same-network Docker, LAN/private-network, and reverse-proxy/TLS examples. + +## Install Entry Points + +Use these entry points depending on who is driving setup: + +- Board UI: add-agent button in the agent sidebar, then generate the agent + onboarding prompt. +- Invite API: `GET /api/invites/:token/onboarding.txt` for the generated + llm.txt-style setup instructions. +- CLI invite flow: `pnpm paperclipai invite create`, `invite show`, + `invite onboarding:text`, `join approve`, and `join claim-key`. +- Smoke helpers: `pnpm smoke:hermes-gateway-e2e` for fresh-state Docker + verification and `pnpm smoke:hermes-gateway-join` for an already-running + gateway. +- Adapter development override: Adapter manager can install + `@paperclipai/hermes-paperclip-adapter` as an external override, but normal + operators should use the built-in `hermes_local` and `hermes_gateway` + adapters. diff --git a/doc/HERMES_GATEWAY_SMOKE.md b/doc/HERMES_GATEWAY_SMOKE.md new file mode 100644 index 0000000000..a85026f241 --- /dev/null +++ b/doc/HERMES_GATEWAY_SMOKE.md @@ -0,0 +1,168 @@ +# 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. + +For the operator-facing install and invite flow, see +[HERMES_GATEWAY_ONBOARDING.md](./HERMES_GATEWAY_ONBOARDING.md). This smoke guide +focuses on verification commands and network modes. + +## 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. +- Set at least one Hermes inference provider key on the host before running the + Docker E2E smoke. The script passes through set values for + `OPENROUTER_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, + `GOOGLE_API_KEY`, and `MISTRAL_API_KEY`, and logs only the provider env var + names. +- To pin the fresh Hermes container to a known non-secret model config, set + `HERMES_SMOKE_MODEL_PROVIDER`, `HERMES_SMOKE_MODEL_DEFAULT`, and optionally + `HERMES_SMOKE_MODEL_BASE_URL`. For example, OpenRouter GLM: + + ```sh + HERMES_SMOKE_MODEL_PROVIDER=openrouter \ + HERMES_SMOKE_MODEL_DEFAULT=z-ai/glm-5.2 \ + HERMES_SMOKE_MODEL_BASE_URL=https://openrouter.ai/api/v1 \ + pnpm smoke:hermes-gateway-e2e + ``` + + This writes only `model`, `providers: {}`, and + `command_allowlist: [execute_code]` into the temporary Hermes home. Provider + keys still come from environment variables and are redacted from diagnostics. +- The E2E helper always seeds `command_allowlist: [execute_code]` in the fresh + Hermes config so non-interactive gateway/API runs do not wait for a manual + execute-code approval prompt. Do not copy a host `~/.hermes` directory into + the container to solve approval or provider setup. +- 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/doc/PUBLISHING.md b/doc/PUBLISHING.md index c430d3473a..11582e9718 100644 --- a/doc/PUBLISHING.md +++ b/doc/PUBLISHING.md @@ -209,8 +209,12 @@ The helper script: - checks that the package does not already exist on npm - builds the target package unless `--skip-build` is passed -- runs `npm pack --dry-run` in the package directory -- only runs the real `npm publish --access public` when `--publish --otp ` is provided +- runs `pnpm publish --dry-run --no-git-checks --access public` from the repo root +- only runs the real `pnpm publish --no-git-checks --access public` when `--publish --otp ` is provided + +The helper intentionally uses `pnpm publish` instead of `npm publish` so workspace +dependencies and `publishConfig` export fields are normalized before the package +is sent to the registry. For the real `--publish` step, the maintainer machine must already be authenticated to npm. If `npm whoami` returns `401`, first run `npm logout --registry=https://registry.npmjs.org/` to clear any stale local auth, then run `npm login` or `npm adduser` locally as an npm org member, and finally rerun the helper. diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index e3246ed6da..44ff0bb037 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -153,7 +153,7 @@ Invariant: every business record belongs to exactly one company. - `status` enum: `active | paused | idle | running | error | pending_approval | terminated` - `reports_to` uuid fk `agents.id` null - `capabilities` text null -- `adapter_type` text; built-ins include `process`, `http`, `claude_local`, `codex_local`, `gemini_local`, `opencode_local`, `pi_local`, `cursor`, and `openclaw_gateway` +- `adapter_type` text; built-ins include `process`, `http`, `claude_local`, `codex_local`, `gemini_local`, `opencode_local`, `pi_local`, `cursor`, `hermes_local`, `hermes_gateway`, and `openclaw_gateway` - `adapter_config` jsonb not null - `runtime_config` jsonb not null default `{}`; may include Paperclip runtime policy such as `modelProfiles.cheap.adapterConfig` for an optional low-cost model lane that does not change the primary adapter config - `default_environment_id` uuid fk `environments.id` null diff --git a/docker/hermes-gateway-smoke/Dockerfile b/docker/hermes-gateway-smoke/Dockerfile new file mode 100644 index 0000000000..5939c7df6e --- /dev/null +++ b/docker/hermes-gateway-smoke/Dockerfile @@ -0,0 +1,52 @@ +# 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[anthropic]==${HERMES_VERSION}" "aiohttp==3.13.4" \ + && rm -f /usr/local/bin/hermes /usr/local/bin/hermes-agent \ + && 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 0000000000..a1496afa8c --- /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/docs/adapters/external-adapters.md b/docs/adapters/external-adapters.md index 3c814fc9ab..522679f667 100644 --- a/docs/adapters/external-adapters.md +++ b/docs/adapters/external-adapters.md @@ -15,6 +15,22 @@ Paperclip supports external adapter plugins that can be installed from npm packa | Distribution | Ships with Paperclip | Published to npm or linked via `file:` | | Updates | Requires Paperclip release | Independent versioning | +### Built-in Hermes compatibility note + +Hermes is built in with two stable adapter type keys: + +- `hermes_local` starts the local Hermes CLI from + `@paperclipai/hermes-paperclip-adapter`. +- `hermes_gateway` calls an already-running Hermes API server through + `@paperclipai/hermes-paperclip-adapter/gateway`. + +The legacy `@paperclipai/adapter-hermes-gateway` package is a deprecated +compatibility shim for one release. It preserves the old gateway exports while +forwarding to the unified Hermes package. New external override packages should +depend on or link `@paperclipai/hermes-paperclip-adapter` and declare the type +they override (`hermes_local` or `hermes_gateway`); the type keys did not +change. + ## Quick Start ### Minimal Package Structure diff --git a/docs/adapters/overview.md b/docs/adapters/overview.md index dfb4b21fb3..a2f62b54bb 100644 --- a/docs/adapters/overview.md +++ b/docs/adapters/overview.md @@ -24,11 +24,25 @@ When a heartbeat fires, Paperclip: | OpenCode Local | `opencode_local` | Runs OpenCode CLI locally (multi-provider `provider/model`) | | Cursor | `cursor` | Runs Cursor in background mode | | Pi Local | `pi_local` | Runs an embedded Pi agent locally | -| Hermes Local | `hermes_local` | Runs Hermes CLI locally (`hermes-paperclip-adapter`) | +| Hermes Local | `hermes_local` | Runs the local Hermes CLI through `@paperclipai/hermes-paperclip-adapter` | +| Hermes Gateway | `hermes_gateway` | Calls an already-running Hermes API server through `@paperclipai/hermes-paperclip-adapter/gateway` | | OpenClaw Gateway | `openclaw_gateway` | Connects to an OpenClaw gateway endpoint | | [Process](/adapters/process) | `process` | Executes arbitrary shell commands | | [HTTP](/adapters/http) | `http` | Sends webhooks to external agents | +### Hermes local vs gateway + +Use `hermes_local` when Paperclip should start the local `hermes` CLI on the +same host for each heartbeat. Use `hermes_gateway` when Hermes is already +running as an HTTP/SSE API server and Paperclip should call that server instead +of spawning a process. Both type keys are stable built-ins. + +The unified Hermes package owns both built-in adapters. The older +`@paperclipai/adapter-hermes-gateway` package remains only as a deprecated +compatibility shim that re-exports the gateway entrypoints for one release. +New plugin overrides should target `@paperclipai/hermes-paperclip-adapter` and +set the desired type key (`hermes_local` or `hermes_gateway`). + ### External (plugin) adapters These adapters ship as standalone npm packages and are installed via the plugin system: @@ -79,8 +93,9 @@ my-adapter/ ## Choosing an Adapter - **Need a coding agent?** Use `claude_local`, `codex_local`, `opencode_local`, `hermes_local`, or install `droid_local` as an external plugin +- **Need Hermes on another host or already running as a service?** Use `hermes_gateway` - **Need to run a script or command?** Use `process` -- **Need to call an external service?** Use `http` +- **Need to call a custom external service?** Use `http` - **Need something custom?** [Create your own adapter](/adapters/creating-an-adapter) or [build an external adapter plugin](/adapters/external-adapters) ## UI Parser Contract diff --git a/docs/agents-runtime.md b/docs/agents-runtime.md index 81bbda7ca1..252fa3d0a2 100644 --- a/docs/agents-runtime.md +++ b/docs/agents-runtime.md @@ -39,7 +39,8 @@ Built-in adapters: - `opencode_local`: runs your local `opencode` CLI - `cursor`: runs Cursor in background mode - `pi_local`: runs an embedded Pi agent locally -- `hermes_local`: runs your local `hermes` CLI (`hermes-paperclip-adapter`) +- `hermes_local`: starts your local `hermes` CLI through `@paperclipai/hermes-paperclip-adapter` +- `hermes_gateway`: calls an already-running Hermes API server through `@paperclipai/hermes-paperclip-adapter/gateway` - `openclaw_gateway`: connects to an OpenClaw gateway endpoint - `process`: generic shell command adapter - `http`: calls an external HTTP endpoint @@ -48,7 +49,7 @@ External plugin adapters (install via the adapter manager or API): - `droid_local`: runs your local Factory Droid CLI (`@henkey/droid-paperclip-adapter`) -For local CLI adapters (`claude_local`, `codex_local`, `opencode_local`, `hermes_local`, `droid_local`), Paperclip assumes the CLI is already installed and authenticated on the host machine. +For local CLI adapters (`claude_local`, `codex_local`, `opencode_local`, `hermes_local`, `droid_local`), Paperclip assumes the CLI is already installed and authenticated on the host machine. For `hermes_gateway`, Paperclip assumes the Hermes API server is already running, reachable from the Paperclip server, and configured with an API key. The older `@paperclipai/adapter-hermes-gateway` npm package is only a deprecated compatibility shim; the adapter type remains `hermes_gateway`. ## 3.2 Runtime behavior @@ -177,7 +178,7 @@ Start with least privilege where possible, and avoid exposing secrets in broad r ## 10. Minimal setup checklist -1. Choose adapter (e.g. `claude_local`, `codex_local`, `opencode_local`, `hermes_local`, `cursor`, or `openclaw_gateway`). External plugins like `droid_local` are also available via the adapter manager. +1. Choose adapter (e.g. `claude_local`, `codex_local`, `opencode_local`, `hermes_local`, `hermes_gateway`, `cursor`, or `openclaw_gateway`). External plugins like `droid_local` are also available via the adapter manager. 2. Set `cwd` to the target workspace (for local adapters). 3. Optionally add a prompt template (`promptTemplate`) or use the managed instructions bundle. 4. Configure heartbeat policy (timer and/or assignment wakeups). diff --git a/docs/guides/board-operator/managing-agents.md b/docs/guides/board-operator/managing-agents.md index 4850222d0a..1ada4ec87e 100644 --- a/docs/guides/board-operator/managing-agents.md +++ b/docs/guides/board-operator/managing-agents.md @@ -28,10 +28,15 @@ Create agents from the Agents page. Each agent requires: - **Capabilities** — short description of what this agent does Common adapter choices: -- `claude_local` / `codex_local` / `opencode_local` for local coding agents -- `openclaw_gateway` / `http` for webhook-based external agents +- `claude_local` / `codex_local` / `opencode_local` / `hermes_local` for local coding agents +- `hermes_gateway` / `openclaw_gateway` / `http` for webhook-based external agents - `process` for generic local command execution +Use `hermes_local` when Paperclip should start the local Hermes CLI. Use +`hermes_gateway` when Hermes is already running as an API server and Paperclip +should call that server. Both are built-in adapter types from the unified +`@paperclipai/hermes-paperclip-adapter` package. + For `opencode_local`, configure an explicit `adapterConfig.model` (`provider/model`). Paperclip validates the selected model against live `opencode models` output. diff --git a/package.json b/package.json index 501b53a34d..75007b518d 100644 --- a/package.json +++ b/package.json @@ -38,7 +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/packages/adapters/hermes-gateway/README.md b/packages/adapters/hermes-gateway/README.md new file mode 100644 index 0000000000..736930357b --- /dev/null +++ b/packages/adapters/hermes-gateway/README.md @@ -0,0 +1,25 @@ +# Hermes Gateway Adapter Compatibility Shim + +`@paperclipai/adapter-hermes-gateway` is a deprecated compatibility shim. + +Use `@paperclipai/hermes-paperclip-adapter` for new installs and import gateway +entrypoints from `@paperclipai/hermes-paperclip-adapter/gateway`. The adapter +type remains `hermes_gateway`; only package ownership changed. + +`hermes_gateway` is for an already-running Hermes API server. It does not start +the local Hermes CLI. If Paperclip should launch local `hermes chat` as a child +process, use `hermes_local` from `@paperclipai/hermes-paperclip-adapter` +instead. + +The shim preserves the legacy exports for one release: + +- `.` +- `./server` +- `./ui` +- `./cli` +- `./ui-parser` + +These exports forward to the unified Hermes package. Existing +`@paperclipai/adapter-hermes-gateway` plugin installs should continue to load +during the compatibility window, but should migrate to +`@paperclipai/hermes-paperclip-adapter` before the shim is removed. diff --git a/packages/adapters/hermes-gateway/package.json b/packages/adapters/hermes-gateway/package.json new file mode 100644 index 0000000000..f9fb40cc3c --- /dev/null +++ b/packages/adapters/hermes-gateway/package.json @@ -0,0 +1,81 @@ +{ + "name": "@paperclipai/adapter-hermes-gateway", + "version": "0.1.0", + "description": "Deprecated compatibility shim for Hermes Gateway; use @paperclipai/hermes-paperclip-adapter/gateway", + "type": "module", + "license": "MIT", + "author": "Paperclip", + "repository": { + "type": "git", + "url": "https://github.com/paperclipai/paperclip", + "directory": "packages/adapters/hermes-gateway" + }, + "bugs": { + "url": "https://github.com/paperclipai/paperclip/issues" + }, + "homepage": "https://github.com/paperclipai/paperclip/tree/master/packages/adapters/hermes#readme", + "keywords": [ + "paperclip", + "hermes", + "gateway", + "adapter", + "orchestration" + ], + "exports": { + ".": "./src/index.ts", + "./server": "./src/server/index.ts", + "./ui": "./src/ui/index.ts", + "./cli": "./src/cli/index.ts", + "./ui-parser": "./ui-parser.cjs" + }, + "paperclip": { + "adapterUiParser": "1.0.0" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./server": { + "types": "./dist/server/index.d.ts", + "import": "./dist/server/index.js" + }, + "./ui": { + "types": "./dist/ui/index.d.ts", + "import": "./dist/ui/index.js" + }, + "./cli": { + "types": "./dist/cli/index.d.ts", + "import": "./dist/cli/index.js" + }, + "./ui-parser": "./ui-parser.cjs" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "provenance": true + }, + "files": [ + "dist", + "ui-parser.cjs", + "README.md" + ], + "scripts": { + "build": "tsc", + "clean": "rm -rf dist", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@paperclipai/hermes-paperclip-adapter": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.19.21", + "typescript": "^5.7.3", + "vitest": "^4.1.8" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/packages/adapters/hermes-gateway/src/cli/index.ts b/packages/adapters/hermes-gateway/src/cli/index.ts new file mode 100644 index 0000000000..0ecd76a446 --- /dev/null +++ b/packages/adapters/hermes-gateway/src/cli/index.ts @@ -0,0 +1 @@ +export { formatStdoutEvent } from "@paperclipai/hermes-paperclip-adapter/gateway/cli"; diff --git a/packages/adapters/hermes-gateway/src/index.ts b/packages/adapters/hermes-gateway/src/index.ts new file mode 100644 index 0000000000..36e1cdc54f --- /dev/null +++ b/packages/adapters/hermes-gateway/src/index.ts @@ -0,0 +1,7 @@ +export { + agentConfigurationDoc, + createServerAdapter, + label, + models, + type, +} from "@paperclipai/hermes-paperclip-adapter/gateway"; diff --git a/packages/adapters/hermes-gateway/src/server/index.ts b/packages/adapters/hermes-gateway/src/server/index.ts new file mode 100644 index 0000000000..eebc096eb7 --- /dev/null +++ b/packages/adapters/hermes-gateway/src/server/index.ts @@ -0,0 +1,9 @@ +export { + execute, + getConfigSchema, + mapFinalResultForTest, + parseSseFramesForTest, + resolveSessionKey, + sessionCodec, + testEnvironment, +} from "@paperclipai/hermes-paperclip-adapter/gateway/server"; diff --git a/packages/adapters/hermes-gateway/src/ui/index.ts b/packages/adapters/hermes-gateway/src/ui/index.ts new file mode 100644 index 0000000000..1f5105a6e4 --- /dev/null +++ b/packages/adapters/hermes-gateway/src/ui/index.ts @@ -0,0 +1 @@ +export { parseStdoutLine } from "@paperclipai/hermes-paperclip-adapter/gateway/ui"; diff --git a/packages/adapters/hermes-gateway/tsconfig.json b/packages/adapters/hermes-gateway/tsconfig.json new file mode 100644 index 0000000000..8fea361a34 --- /dev/null +++ b/packages/adapters/hermes-gateway/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src"] +} diff --git a/packages/adapters/hermes-gateway/ui-parser.cjs b/packages/adapters/hermes-gateway/ui-parser.cjs new file mode 100644 index 0000000000..9b2e7fa0ce --- /dev/null +++ b/packages/adapters/hermes-gateway/ui-parser.cjs @@ -0,0 +1 @@ +module.exports = require("@paperclipai/hermes-paperclip-adapter/gateway/ui-parser"); diff --git a/packages/adapters/hermes-gateway/vitest.config.ts b/packages/adapters/hermes-gateway/vitest.config.ts new file mode 100644 index 0000000000..ad7d95889a --- /dev/null +++ b/packages/adapters/hermes-gateway/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + exclude: ["dist/**", "node_modules/**"], + }, +}); diff --git a/packages/adapters/hermes/LICENSE b/packages/adapters/hermes/LICENSE new file mode 100644 index 0000000000..5700b99782 --- /dev/null +++ b/packages/adapters/hermes/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Nous Research + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/adapters/hermes/README.md b/packages/adapters/hermes/README.md new file mode 100644 index 0000000000..53c4ca3901 --- /dev/null +++ b/packages/adapters/hermes/README.md @@ -0,0 +1,335 @@ +# Paperclip Adapters for Hermes Agent + +A [Paperclip](https://paperclip.ing) adapter package that lets you run [Hermes Agent](https://github.com/NousResearch/hermes-agent) as a managed employee in a Paperclip company. + +Hermes Agent is a full-featured AI agent by [Nous Research](https://nousresearch.com) with 30+ native tools, persistent memory, session persistence, 80+ skills, MCP support, and multi-provider model access. + +This package owns both built-in Hermes adapter types: + +- `hermes_local` runs the local Hermes CLI as a child process. The package root exports remain compatible with the original local adapter. +- `hermes_gateway` calls an already-running Hermes API server over HTTP/SSE. Gateway entrypoints live under the `./gateway` export namespace. + +Choose `hermes_local` when Paperclip and Hermes run on the same trusted host +and Paperclip should start `hermes chat` for each heartbeat. Choose +`hermes_gateway` when Hermes is already running as an API server, often on +another host, in Docker, or behind a private-network/TLS endpoint. The adapter +type keys did not change during package consolidation. + +## Key Features + +This adapter provides: + +- **8 inference providers** — Anthropic, OpenRouter, OpenAI, Nous, OpenAI Codex, ZAI, Kimi Coding, MiniMax +- **Skills integration** — Scans both Paperclip-managed and Hermes-native skills (`~/.hermes/skills/`), with sync/list/resolve APIs +- **Structured transcript parsing** — Raw Hermes stdout is parsed into typed `TranscriptEntry` objects so Paperclip renders proper tool cards with status icons and expand/collapse +- **Rich post-processing** — Converts Hermes ASCII banners, setext headings, and `+--+` table borders into clean GFM markdown +- **Comment-driven wakes** — Agents wake to respond to issue comments, not just task assignments +- **Auto model detection** — Reads `~/.hermes/config.yaml` to pre-populate the UI with the user's configured model +- **Session codec** — Structured validation and migration of session state across heartbeats +- **Benign stderr reclassification** — MCP init messages and structured logs are reclassified so they don't appear as errors in the UI +- **Session source tagging** — Sessions are tagged as `tool` source so they don't clutter the user's interactive history +- **Filesystem checkpoints** — Optional `--checkpoints` for rollback safety +- **Thinking effort control** — Passes `--reasoning-effort` for thinking/reasoning models + +### Hermes Agent Capabilities + +| Feature | Claude Code | Codex | Hermes Agent | +|---------|------------|-------|-------------| +| Persistent memory | ❌ | ❌ | ✅ Remembers across sessions | +| Native tools | ~5 | ~5 | 30+ (terminal, file, web, browser, vision, git, etc.) | +| Skills system | ❌ | ❌ | ✅ 80+ loadable skills | +| Session search | ❌ | ❌ | ✅ FTS5 search over past conversations | +| Sub-agent delegation | ❌ | ❌ | ✅ Parallel sub-tasks | +| Context compression | ❌ | ❌ | ✅ Auto-compresses long conversations | +| MCP client | ❌ | ❌ | ✅ Connect to any MCP server | +| Multi-provider | Anthropic only | OpenAI only | ✅ 8 providers out of the box | + +## Installation + +This package ships with Paperclip core as the built-in `hermes_local` and +`hermes_gateway` adapters. No Adapter manager installation is required for +normal Paperclip use. + +### Prerequisites + +- [Hermes Agent](https://github.com/NousResearch/hermes-agent) installed (`pip install hermes-agent`) +- Python 3.10+ +- At least one LLM API key (Anthropic, OpenRouter, or OpenAI) + +## Quick Start + +### 1. Optional: override the built-in for adapter development + +For local adapter development, install the package from a local path in Adapter +manager, or add an entry to `~/.paperclip/adapter-plugins.json` and restart +Paperclip. The external package can override either built-in Hermes adapter +while it is enabled: + +```json +[ + { + "packageName": "@paperclipai/hermes-paperclip-adapter", + "localPath": "/absolute/path/to/paperclip/packages/adapters/hermes", + "type": "hermes_local", + "installedAt": "2026-06-23T00:00:00.000Z" + } +] +``` + +Use `"type": "hermes_gateway"` with the same package when testing a gateway +override. + +The package root exports `createServerAdapter()` for the local server adapter, +a declarative config schema for the generic agent form, and `./ui-parser` for +local run transcript parsing. Gateway entrypoints are exported from `./gateway`, +`./gateway/server`, `./gateway/ui`, `./gateway/cli`, and `./gateway/ui-parser`. +Paperclip core imports these same package entrypoints for built-in adapter +registration. + +### 2. Create a local Hermes agent in Paperclip + +In the Paperclip UI or via API, create an agent with adapter type `hermes_local`: + +```json +{ + "name": "Hermes Engineer", + "adapterType": "hermes_local", + "adapterConfig": { + "model": "anthropic/claude-sonnet-4", + "maxIterations": 50, + "timeoutSec": 300, + "persistSession": true, + "enabledToolsets": ["terminal", "file", "web"] + } +} +``` + +This mode shells out to the local `hermes` CLI. Paperclip injects runtime +environment variables and captures stdout/stderr from the child process. + +### 3. Create a Hermes gateway agent in Paperclip + +Start Hermes with its API server enabled first: + +```bash +API_SERVER_ENABLED=true \ +API_SERVER_KEY= \ +hermes gateway run --replace --accept-hooks +``` + +Then create an agent with adapter type `hermes_gateway`: + +```json +{ + "name": "Hermes Gateway Engineer", + "adapterType": "hermes_gateway", + "adapterConfig": { + "apiBaseUrl": "http://127.0.0.1:8642", + "apiKey": "", + "paperclipApiUrl": "http://127.0.0.1:3100", + "sessionKeyStrategy": "issue", + "timeoutSec": 120 + } +} +``` + +This mode does not start Hermes. It creates runs with `POST /v1/runs`, streams +Hermes events with SSE, polls run status as a fallback, and stops timed-out runs +with `POST /v1/runs/{run_id}/stop`. + +### Compatibility with the old gateway package + +`@paperclipai/adapter-hermes-gateway` remains as a deprecated compatibility shim +for one release. It re-exports the gateway entrypoints from +`@paperclipai/hermes-paperclip-adapter/gateway` and preserves the legacy exports +for existing plugin installs. New installs and built-in Paperclip registrations +should use `@paperclipai/hermes-paperclip-adapter`; the adapter type remains +`hermes_gateway`. + +### Runtime API guidance + +Hermes receives Paperclip runtime identity through environment variables: + +- `PAPERCLIP_API_URL` +- `PAPERCLIP_API_KEY` +- `PAPERCLIP_RUN_ID` + +Prompts should reference those variables directly. Command output may redact +secret values, so do not copy printed tokens into comments or config. Use +`Authorization: Bearer $PAPERCLIP_API_KEY` on Paperclip API requests and +`X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID` on mutating issue requests. For +multiline comments or status updates, preserve newlines with a heredoc plus +`jq --arg`. + +### Hermes-originated Paperclip tasks + +The package includes a Hermes skill/helper for the reverse direction: a user +starts in Hermes and asks Hermes to create or update Paperclip work. This is not +the same as Paperclip waking Hermes through `hermes_local` or `hermes_gateway`. + +Configure Paperclip access in Hermes env/profile secrets, not prompt text: + +```bash +PAPERCLIP_API_URL=http://127.0.0.1:3100/api +PAPERCLIP_BRIDGE_API_KEY= +``` + +Optional env values: + +- `PAPERCLIP_COMPANY_ID` +- `PAPERCLIP_AGENT_ID` +- `PAPERCLIP_RUN_ID` + +The bundled `paperclip-task-bridge` skill provides deterministic helper +commands: + +```bash +node ./paperclip-task.mjs list-assigned +node ./paperclip-task.mjs create-task --parent-id "" --title "Investigate checkout failures" --description "Capture failing request and root cause." +node ./paperclip-task.mjs comment --issue PAP-123 --body "Found the failing request path." +node ./paperclip-task.mjs update-status --issue PAP-123 --status in_review --comment "Ready for review." +``` + +The helper reads credentials from environment variables and prints only JSON +summaries. It supports `create-task`, `comment`, `update-status`, and +`list-assigned`. + +Create the bridge key with `scope.kind = "task_bridge"` plus a `parentIssueId` +or `projectId` boundary. Do not use a normal claimed agent API key for +internet-facing Hermes chat/webhook task-bridge operations. + +### 4. Assign work + +Create issues in Paperclip and assign them to your Hermes agent. On each heartbeat, Hermes will: + +1. Receive the task instructions +2. Use its full tool suite to complete the work +3. Report results back to Paperclip +4. Persist session state for continuity + +## Configuration Reference + +### Core + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `model` | string | `anthropic/claude-sonnet-4` | Model in `provider/model` format | +| `provider` | string | *(auto-detected)* | API provider: `auto`, `openrouter`, `nous`, `openai-codex`, `zai`, `kimi-coding`, `minimax`, `minimax-cn` | +| `timeoutSec` | number | `300` | Execution timeout in seconds | +| `graceSec` | number | `10` | Grace period before SIGKILL | + +### Tools + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `toolsets` | string | *(all)* | Comma-separated toolsets to enable (e.g. `"terminal,file,web"`) | + +Available toolsets: `terminal`, `file`, `web`, `browser`, `code_execution`, `vision`, `mcp`, `creative`, `productivity` + +### Session & Workspace + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `persistSession` | boolean | `true` | Resume sessions across heartbeats | +| `worktreeMode` | boolean | `false` | Git worktree isolation | +| `checkpoints` | boolean | `false` | Enable filesystem checkpoints for rollback | + +### Advanced + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `hermesCommand` | string | `hermes` | Custom CLI binary path | +| `verbose` | boolean | `false` | Enable verbose output | +| `quiet` | boolean | `true` | Quiet mode (clean output, no banner/spinner) | +| `extraArgs` | string[] | `[]` | Additional CLI arguments | +| `env` | object | `{}` | Extra environment variables | +| `promptTemplate` | string | *(built-in)* | Custom prompt template | +| `paperclipApiUrl` | string | `http://127.0.0.1:3100/api` | Paperclip API base URL | + +### Prompt Template Variables + +Use `{{variable}}` syntax in `promptTemplate`: + +| Variable | Description | +|----------|-------------| +| `{{agentId}}` | Paperclip agent ID | +| `{{agentName}}` | Agent display name | +| `{{companyId}}` | Company ID | +| `{{companyName}}` | Company name | +| `{{runId}}` | Current heartbeat run ID | +| `{{taskId}}` | Assigned task/issue ID | +| `{{taskTitle}}` | Task title | +| `{{taskBody}}` | Task instructions | +| `{{projectName}}` | Project name | +| `{{paperclipApiUrl}}` | Paperclip API base URL | +| `{{commentId}}` | Comment ID (when woken by a comment) | +| `{{wakeReason}}` | Reason this run was triggered | + +Conditional sections: + +- `{{#taskId}}...{{/taskId}}` — included only when a task is assigned +- `{{#noTask}}...{{/noTask}}` — included only when no task (heartbeat check) +- `{{#commentId}}...{{/commentId}}` — included only when woken by a comment + +## Architecture + +``` +Paperclip Hermes Agent +┌──────────────────┐ ┌──────────────────┐ +│ Heartbeat │ │ │ +│ Scheduler │───execute()──▶│ hermes chat -q │ +│ │ │ │ +│ Issue System │ │ 30+ Tools │ +│ Comment Wakes │◀──results─────│ Memory System │ +│ │ │ Session DB │ +│ Cost Tracking │ │ Skills │ +│ │ │ MCP Client │ +│ Skill Sync │◀──snapshot────│ ~/.hermes/skills│ +│ Org Chart │ │ │ +└──────────────────┘ └──────────────────┘ +``` + +The adapter spawns Hermes Agent's CLI in single-query mode (`-q`). Hermes +processes the task using its full tool suite, then exits. The adapter: + +1. **Captures** stdout/stderr and parses token usage, session IDs, and cost +2. **Parses** raw output into structured `TranscriptEntry` objects (tool cards with status icons) +3. **Post-processes** Hermes ASCII formatting (banners, setext headings, table borders) into clean GFM markdown +4. **Reclassifies** benign stderr (MCP init, structured logs) so they don't show as errors +5. **Tags** sessions as `tool` source to keep them separate from interactive usage +6. **Reports** results back to Paperclip with cost, usage, and session state + +Session persistence works via Hermes's `--resume` flag — each run picks +up where the last one left off, maintaining conversation context, +memories, and tool state across heartbeats. The `sessionCodec` validates +and migrates session state between runs. + +### Skills Integration + +The adapter scans two skill sources and merges them: + +- **Paperclip-managed skills** — bundled with the adapter, togglable from the UI +- **Hermes-native skills** — from `~/.hermes/skills/`, read-only, always loaded + +The `listSkills` / `syncSkills` APIs expose a unified snapshot so the +Paperclip UI can display both managed and native skills in one view. + +## Development + +```bash +git clone https://github.com/paperclipai/paperclip +cd paperclip/packages/adapters/hermes +pnpm install +pnpm build +``` + +## License + +MIT — see [LICENSE](LICENSE) + +## Links + +- [Hermes Agent](https://github.com/NousResearch/hermes-agent) — The AI agent this adapter runs +- [Paperclip](https://github.com/paperclipai/paperclip) — The orchestration platform +- [Nous Research](https://nousresearch.com) — The team behind Hermes +- [Paperclip Docs](https://paperclip.ing/docs) — Paperclip documentation diff --git a/packages/adapters/hermes/gateway-ui-parser.cjs b/packages/adapters/hermes/gateway-ui-parser.cjs new file mode 100644 index 0000000000..1cba319c8d --- /dev/null +++ b/packages/adapters/hermes/gateway-ui-parser.cjs @@ -0,0 +1,49 @@ +"use strict"; + +function safeJsonParse(text) { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function asRecord(value) { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value; +} + +function asString(value) { + return typeof value === "string" ? value : ""; +} + +function parseStdoutLine(line, ts) { + const trimmed = line.trim(); + if (!trimmed) return []; + + const eventMatch = trimmed.match(/^\[hermes-gateway:event\]\s+run=([^\s]+)\s+event=([^\s]+)\s+data=(.*)$/s); + if (eventMatch) { + const eventName = eventMatch[2]; + const data = asRecord(safeJsonParse(eventMatch[3])); + if (eventName === "message.delta") { + const delta = asString(data && data.delta) || asString(data && data.text_delta); + return delta ? [{ kind: "assistant", ts, text: delta, delta: true }] : []; + } + if (eventName === "run.failed" || eventName === "run.error") { + const message = asString(data && data.error) || asString(data && data.message) || "Hermes run failed"; + return [{ kind: "stderr", ts, text: message }]; + } + if (eventName === "reasoning.available") { + return [{ kind: "thinking", ts, text: "Hermes reasoning available" }]; + } + return [{ kind: "system", ts, text: `Hermes event: ${eventName}` }]; + } + + if (trimmed.startsWith("[hermes-gateway]")) { + return [{ kind: "system", ts, text: trimmed.replace(/^\[hermes-gateway\]\s*/, "") }]; + } + + return [{ kind: "stdout", ts, text: line }]; +} + +module.exports = { parseStdoutLine }; diff --git a/packages/adapters/hermes/package.json b/packages/adapters/hermes/package.json new file mode 100644 index 0000000000..356c049d9d --- /dev/null +++ b/packages/adapters/hermes/package.json @@ -0,0 +1,113 @@ +{ + "name": "@paperclipai/hermes-paperclip-adapter", + "version": "0.3.1", + "description": "Paperclip adapters for Hermes Agent local CLI and Hermes Gateway HTTP/SSE runs", + "type": "module", + "license": "MIT", + "author": "Paperclip", + "repository": { + "type": "git", + "url": "https://github.com/paperclipai/paperclip", + "directory": "packages/adapters/hermes" + }, + "bugs": { + "url": "https://github.com/paperclipai/paperclip/issues" + }, + "homepage": "https://github.com/paperclipai/paperclip/tree/master/packages/adapters/hermes#readme", + "keywords": [ + "paperclip", + "hermes", + "hermes-agent", + "ai-agent", + "adapter", + "orchestration" + ], + "exports": { + ".": "./src/index.ts", + "./server": "./src/server/index.ts", + "./ui": "./src/ui/index.ts", + "./cli": "./src/cli/index.ts", + "./ui-parser": "./ui-parser.cjs", + "./gateway": "./src/gateway/index.ts", + "./gateway/server": "./src/gateway/server/index.ts", + "./gateway/ui": "./src/gateway/ui/index.ts", + "./gateway/cli": "./src/gateway/cli/index.ts", + "./gateway/ui-parser": "./gateway-ui-parser.cjs" + }, + "paperclip": { + "adapterUiParser": "1.0.0" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./server": { + "types": "./dist/server/index.d.ts", + "import": "./dist/server/index.js" + }, + "./ui": { + "types": "./dist/ui/index.d.ts", + "import": "./dist/ui/index.js" + }, + "./cli": { + "types": "./dist/cli/index.d.ts", + "import": "./dist/cli/index.js" + }, + "./ui-parser": "./ui-parser.cjs", + "./gateway": { + "types": "./dist/gateway/index.d.ts", + "import": "./dist/gateway/index.js" + }, + "./gateway/server": { + "types": "./dist/gateway/server/index.d.ts", + "import": "./dist/gateway/server/index.js" + }, + "./gateway/ui": { + "types": "./dist/gateway/ui/index.d.ts", + "import": "./dist/gateway/ui/index.js" + }, + "./gateway/cli": { + "types": "./dist/gateway/cli/index.d.ts", + "import": "./dist/gateway/cli/index.js" + }, + "./gateway/ui-parser": "./gateway-ui-parser.cjs" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "provenance": true + }, + "files": [ + "dist", + "skills", + "ui-parser.cjs", + "gateway-ui-parser.cjs", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "lint": "eslint src/", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:command-resolution": "vitest run src/server/command-resolution.test.ts", + "prepack": "node scripts/prepare-publish-package.mjs", + "postpack": "node scripts/restore-dev-package.mjs", + "clean": "rm -rf dist" + }, + "dependencies": { + "@paperclipai/adapter-utils": "workspace:*", + "picocolors": "^1.1.1" + }, + "devDependencies": { + "@types/node": "^22.19.21", + "typescript": "^5.7.3", + "vitest": "^4.1.8" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/packages/adapters/hermes/scripts/prepare-publish-package.mjs b/packages/adapters/hermes/scripts/prepare-publish-package.mjs new file mode 100644 index 0000000000..b004c53f8b --- /dev/null +++ b/packages/adapters/hermes/scripts/prepare-publish-package.mjs @@ -0,0 +1,106 @@ +#!/usr/bin/env node +import { existsSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const packageDir = resolve(scriptDir, ".."); +const packageJsonPath = join(packageDir, "package.json"); +const devPackageJsonPath = join(packageDir, "package.dev.json"); + +function findRepoRoot(startDir) { + let current = startDir; + while (current !== dirname(current)) { + if (existsSync(join(current, "pnpm-workspace.yaml"))) { + return current; + } + current = dirname(current); + } + return null; +} + +function readJson(filePath) { + return JSON.parse(readFileSync(filePath, "utf8")); +} + +function collectWorkspaceVersions(repoRoot) { + const roots = ["packages", "server", "ui", "cli"]; + const versions = new Map(); + + function walk(relDir) { + const absDir = join(repoRoot, relDir); + if (!existsSync(absDir)) return; + + const pkgPath = join(absDir, "package.json"); + if (existsSync(pkgPath)) { + const pkg = readJson(pkgPath); + if (pkg.name && pkg.version) { + versions.set(pkg.name, pkg.version); + } + return; + } + + for (const entry of readdirSync(absDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (entry.name === "node_modules" || entry.name === "dist" || entry.name === ".git") continue; + walk(join(relDir, entry.name)); + } + } + + for (const root of roots) { + walk(root); + } + + return versions; +} + +function rewriteWorkspaceDeps(deps, workspaceVersions) { + if (!deps) return deps; + return Object.fromEntries( + Object.entries(deps).map(([name, version]) => { + if (typeof version !== "string" || !version.startsWith("workspace:")) { + return [name, version]; + } + + const resolvedVersion = workspaceVersions.get(name); + if (!resolvedVersion) { + throw new Error(`Cannot resolve workspace dependency ${name} for publish package`); + } + return [name, resolvedVersion]; + }), + ); +} + +const pkg = readJson(packageJsonPath); +const publishConfig = pkg.publishConfig ?? {}; + +if (existsSync(devPackageJsonPath)) { + throw new Error(`Refusing to overwrite existing ${devPackageJsonPath}`); +} + +if (!publishConfig.exports) { + throw new Error(`${pkg.name} is missing publishConfig.exports`); +} + +const repoRoot = findRepoRoot(packageDir); +const workspaceVersions = repoRoot ? collectWorkspaceVersions(repoRoot) : new Map(); + +renameSync(packageJsonPath, devPackageJsonPath); + +const nextPublishConfig = { ...publishConfig }; +delete nextPublishConfig.exports; +delete nextPublishConfig.main; +delete nextPublishConfig.types; + +const publishPkg = { + ...pkg, + exports: publishConfig.exports, + main: publishConfig.main, + types: publishConfig.types, + publishConfig: nextPublishConfig, + dependencies: rewriteWorkspaceDeps(pkg.dependencies, workspaceVersions), + optionalDependencies: rewriteWorkspaceDeps(pkg.optionalDependencies, workspaceVersions), + peerDependencies: rewriteWorkspaceDeps(pkg.peerDependencies, workspaceVersions), +}; + +writeFileSync(packageJsonPath, `${JSON.stringify(publishPkg, null, 2)}\n`); diff --git a/packages/adapters/hermes/scripts/restore-dev-package.mjs b/packages/adapters/hermes/scripts/restore-dev-package.mjs new file mode 100644 index 0000000000..33e24fc04f --- /dev/null +++ b/packages/adapters/hermes/scripts/restore-dev-package.mjs @@ -0,0 +1,14 @@ +#!/usr/bin/env node +import { existsSync, renameSync, rmSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const packageDir = resolve(scriptDir, ".."); +const packageJsonPath = join(packageDir, "package.json"); +const devPackageJsonPath = join(packageDir, "package.dev.json"); + +if (existsSync(devPackageJsonPath)) { + rmSync(packageJsonPath, { force: true }); + renameSync(devPackageJsonPath, packageJsonPath); +} diff --git a/packages/adapters/hermes/skills/paperclip-task-bridge/SKILL.md b/packages/adapters/hermes/skills/paperclip-task-bridge/SKILL.md new file mode 100644 index 0000000000..b325cec516 --- /dev/null +++ b/packages/adapters/hermes/skills/paperclip-task-bridge/SKILL.md @@ -0,0 +1,77 @@ +--- +name: paperclip-task-bridge +description: Create, comment on, update, and list Paperclip tasks from Hermes using scoped Paperclip API credentials. +--- + +# Paperclip Task Bridge + +Use this skill when a Hermes-originated request needs to create or update Paperclip work directly. This is the Hermes-to-Paperclip direction, separate from Paperclip waking Hermes through the `hermes_local` or `hermes_gateway` adapter. + +## Required Environment + +Configure these in Hermes env/profile secrets, not in prompt text: + +- `PAPERCLIP_API_URL` - Paperclip base URL, with or without `/api`. +- `PAPERCLIP_BRIDGE_API_KEY` - a Paperclip agent API key created with `scope.kind = "task_bridge"`. + +Optional: + +- `PAPERCLIP_API_KEY` - fallback env var for older profiles; it must still contain a `task_bridge` scoped key, never a full agent key. +- `PAPERCLIP_COMPANY_ID` - skips one identity lookup when set. +- `PAPERCLIP_AGENT_ID` - skips one identity lookup when set. +- `PAPERCLIP_RUN_ID` - sent as `X-Paperclip-Run-Id` on mutating requests when Hermes is running inside a Paperclip heartbeat. + +Never print or paste API keys. The helper reads credentials from environment variables and only prints response summaries. Do not put a normal claimed agent API key in an internet-facing Hermes runtime; normal keys can use broad same-company Paperclip routes. + +## Create a Bridge Key + +Create the key from a board-authenticated Paperclip API session and store the returned token once: + +```sh +curl -X POST "$PAPERCLIP_API_URL/api/agents/$HERMES_AGENT_ID/keys" \ + -H "Authorization: Bearer $BOARD_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Hermes task bridge", + "scope": { + "kind": "task_bridge", + "parentIssueId": "00000000-0000-4000-8000-000000000000" + } + }' +``` + +Use `parentIssueId` or `parentIssueIds` when Hermes should only create child tasks under approved work. Use `projectId` or `projectIds` when the approved boundary is a project. A bridge key can create tasks only inside that boundary, can comment/update only bridge-created or assigned issues, and cannot use company-wide issue list/search/read surfaces. + +## Helper + +Run the helper from this skill directory: + +```sh +node ./paperclip-task.mjs --help +``` + +Commands: + +```sh +node ./paperclip-task.mjs list-assigned +node ./paperclip-task.mjs create-task --parent-id "00000000-0000-4000-8000-000000000000" --title "Investigate checkout failures" --description "Capture failing request and root cause." +node ./paperclip-task.mjs comment --issue PAP-123 --body "Found the failing request path." +node ./paperclip-task.mjs update-status --issue PAP-123 --status in_review --comment "Ready for review." +``` + +`create-task` defaults to assigning the task to the authenticated Hermes agent so the work is immediately actionable. Use `--unassigned` to create backlog work instead. Use `--assignee-agent-id ` only when the Paperclip API key has permission to assign work to that agent. + +For multiline bodies, prefer files or stdin: + +```sh +node ./paperclip-task.mjs create-task --title "Write rollout note" --description-file ./task.md +node ./paperclip-task.mjs comment --issue PAP-123 --body-file - +``` + +## Workflow Expectations + +- Keep tasks company-scoped by using the company resolved from the scoped agent key. +- Let Paperclip activity logging come from the normal API endpoints; do not write local logs that include credentials. +- Use comments for durable progress. +- Use `update-status` only when the issue has a real disposition: `done`, `in_review`, `blocked`, `todo`, `in_progress`, `backlog`, or `cancelled`. +- Use `list-assigned` before creating duplicate work when the user asks about current Paperclip assignments. diff --git a/packages/adapters/hermes/skills/paperclip-task-bridge/paperclip-task.mjs b/packages/adapters/hermes/skills/paperclip-task-bridge/paperclip-task.mjs new file mode 100755 index 0000000000..956e953281 --- /dev/null +++ b/packages/adapters/hermes/skills/paperclip-task-bridge/paperclip-task.mjs @@ -0,0 +1,351 @@ +#!/usr/bin/env node + +import fs from "node:fs/promises"; + +const STATUSES = new Set(["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"]); +const PRIORITIES = new Set(["critical", "high", "medium", "low"]); +const WORK_MODES = new Set(["standard", "ask", "planning"]); +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +const HELP = `Paperclip task bridge for Hermes + +Usage: + paperclip-task.mjs list-assigned [--status todo,in_progress,in_review,blocked] [--limit 20] + paperclip-task.mjs create-task --title [--description <text>|--description-file <path|->] [options] + paperclip-task.mjs comment --issue <id|identifier> (--body <text>|--body-file <path|->) [--resume|--reopen] + paperclip-task.mjs update-status --issue <id|identifier> --status <status> [--comment <text>|--comment-file <path|->] + +Environment: + PAPERCLIP_API_URL Paperclip base URL, with or without /api. + PAPERCLIP_BRIDGE_API_KEY + Task-bridge Paperclip API key with kind=task_bridge scope. + PAPERCLIP_API_KEY Fallback bridge key env var. Do not use a full agent key. + PAPERCLIP_COMPANY_ID Optional company id override. + PAPERCLIP_AGENT_ID Optional agent id override. + PAPERCLIP_RUN_ID Optional run id for X-Paperclip-Run-Id on mutations. + +create-task options: + --assignee-agent-id <uuid|self> Assign to an agent. Defaults to self. + --unassigned Create backlog/unassigned work. + --parent-id <uuid> Parent issue id. + --goal-id <uuid> Goal id. + --project-id <uuid> Project id. + --priority <critical|high|medium|low> + --status <backlog|todo|in_progress|in_review|done|blocked|cancelled> + --work-mode <standard|ask|planning> + +Output is JSON and never includes credentials.`; + +class UsageError extends Error { + constructor(message) { + super(message); + this.name = "UsageError"; + } +} + +class ApiError extends Error { + constructor(status, body) { + const message = typeof body?.error === "string" ? body.error : `Paperclip API request failed with status ${status}`; + super(message); + this.name = "ApiError"; + this.status = status; + this.body = body; + } +} + +function parseArgs(argv) { + const out = { _: [] }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (!arg.startsWith("--")) { + out._.push(arg); + continue; + } + const eq = arg.indexOf("="); + if (eq !== -1) { + out[arg.slice(2, eq)] = arg.slice(eq + 1); + continue; + } + const key = arg.slice(2); + const next = argv[i + 1]; + if (!next || next.startsWith("--")) { + out[key] = true; + continue; + } + out[key] = next; + i += 1; + } + return out; +} + +function readStringFlag(args, name) { + const value = args[name]; + if (typeof value !== "string" || value.trim().length === 0) return null; + return value; +} + +function requireStringFlag(args, name) { + const value = readStringFlag(args, name); + if (!value) throw new UsageError(`Missing required --${name}`); + return value; +} + +function boolFlag(args, name) { + return args[name] === true; +} + +function normalizeApiBaseUrl(raw) { + if (!raw || typeof raw !== "string" || raw.trim().length === 0) { + throw new UsageError("PAPERCLIP_API_URL is required"); + } + const trimmed = raw.trim().replace(/\/+$/, ""); + return trimmed.endsWith("/api") ? trimmed : `${trimmed}/api`; +} + +function getConfig() { + const apiKey = process.env.PAPERCLIP_BRIDGE_API_KEY?.trim() || process.env.PAPERCLIP_API_KEY?.trim(); + if (!apiKey) throw new UsageError("PAPERCLIP_BRIDGE_API_KEY is required"); + return { + apiBaseUrl: normalizeApiBaseUrl(process.env.PAPERCLIP_API_URL), + apiKey, + runId: process.env.PAPERCLIP_RUN_ID?.trim() || null, + companyId: process.env.PAPERCLIP_COMPANY_ID?.trim() || null, + agentId: process.env.PAPERCLIP_AGENT_ID?.trim() || null, + }; +} + +async function readBody(args, textFlag, fileFlag) { + const direct = readStringFlag(args, textFlag); + const file = readStringFlag(args, fileFlag); + if (direct && file) throw new UsageError(`Use either --${textFlag} or --${fileFlag}, not both`); + if (direct) return direct; + if (!file) return null; + if (file === "-") { + return await new Promise((resolve, reject) => { + let data = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk) => { + data += chunk; + }); + process.stdin.on("end", () => resolve(data)); + process.stdin.on("error", reject); + }); + } + return fs.readFile(file, "utf8"); +} + +async function apiFetch(config, path, options = {}) { + const headers = { + Authorization: `Bearer ${config.apiKey}`, + Accept: "application/json", + ...(options.body !== undefined ? { "Content-Type": "application/json" } : {}), + ...(options.mutating && config.runId ? { "X-Paperclip-Run-Id": config.runId } : {}), + }; + const response = await fetch(`${config.apiBaseUrl}${path}`, { + method: options.method ?? "GET", + headers, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }); + const text = await response.text(); + let body = null; + if (text) { + try { + body = JSON.parse(text); + } catch { + body = { error: text.slice(0, 1000) }; + } + } + if (!response.ok) throw new ApiError(response.status, body); + return body; +} + +async function resolveIdentity(config) { + if (config.companyId && config.agentId) { + return { companyId: config.companyId, agentId: config.agentId, agent: null }; + } + const agent = await apiFetch(config, "/agents/me"); + const companyId = config.companyId || agent.companyId; + const agentId = config.agentId || agent.id; + if (!companyId || !agentId) throw new ApiError(500, { error: "Paperclip identity response did not include companyId and agent id" }); + return { companyId, agentId, agent }; +} + +function issueSummary(issue) { + if (!issue || typeof issue !== "object") return issue; + return { + id: issue.id ?? null, + identifier: issue.identifier ?? null, + title: issue.title ?? null, + status: issue.status ?? null, + priority: issue.priority ?? null, + assigneeAgentId: issue.assigneeAgentId ?? null, + assigneeUserId: issue.assigneeUserId ?? null, + projectId: issue.projectId ?? null, + goalId: issue.goalId ?? null, + parentId: issue.parentId ?? null, + updatedAt: issue.updatedAt ?? null, + }; +} + +function commentSummary(comment) { + if (!comment || typeof comment !== "object") return comment; + return { + id: comment.id ?? null, + issueId: comment.issueId ?? null, + authorType: comment.authorType ?? null, + authorAgentId: comment.authorAgentId ?? null, + createdAt: comment.createdAt ?? null, + }; +} + +function printJson(value) { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +} + +function validateEnum(value, allowed, label) { + if (!allowed.has(value)) { + throw new UsageError(`Invalid ${label}: ${value}`); + } + return value; +} + +function parseLimit(args) { + const raw = readStringFlag(args, "limit"); + if (!raw) return 20; + const value = Number.parseInt(raw, 10); + if (!Number.isInteger(value) || value <= 0 || value > 100) { + throw new UsageError("--limit must be an integer from 1 to 100"); + } + return value; +} + +async function listAssigned(config, args) { + const identity = await resolveIdentity(config); + const status = readStringFlag(args, "status") || "todo,in_progress,in_review,blocked"; + const limit = parseLimit(args); + const issues = await apiFetch(config, "/agents/me/inbox-lite"); + const allowedStatuses = new Set(status.split(",").map((entry) => entry.trim()).filter(Boolean)); + const filteredIssues = Array.isArray(issues) + ? issues.filter((issue) => !allowedStatuses.size || allowedStatuses.has(issue?.status)).slice(0, limit) + : []; + printJson({ + command: "list-assigned", + companyId: identity.companyId, + agentId: identity.agentId, + count: filteredIssues.length, + issues: filteredIssues.map(issueSummary), + }); +} + +async function createTask(config, args) { + const identity = await resolveIdentity(config); + const title = requireStringFlag(args, "title"); + const description = await readBody(args, "description", "description-file"); + const unassigned = boolFlag(args, "unassigned"); + const assigneeRaw = readStringFlag(args, "assignee-agent-id"); + const assigneeAgentId = unassigned + ? undefined + : !assigneeRaw || assigneeRaw === "self" + ? identity.agentId + : assigneeRaw; + if (assigneeAgentId !== undefined && !UUID_RE.test(assigneeAgentId)) { + throw new UsageError("--assignee-agent-id must be a UUID, self, or omitted"); + } + const priority = readStringFlag(args, "priority") ?? "medium"; + const workMode = readStringFlag(args, "work-mode") ?? "standard"; + validateEnum(priority, PRIORITIES, "priority"); + validateEnum(workMode, WORK_MODES, "work mode"); + const body = { + title, + description, + priority, + workMode, + ...(assigneeAgentId !== undefined ? { assigneeAgentId } : {}), + }; + for (const [flag, field] of [ + ["parent-id", "parentId"], + ["goal-id", "goalId"], + ["project-id", "projectId"], + ]) { + const value = readStringFlag(args, flag); + if (value) body[field] = value; + } + const status = readStringFlag(args, "status"); + if (status) body.status = validateEnum(status, STATUSES, "status"); + + const issue = await apiFetch(config, `/companies/${encodeURIComponent(identity.companyId)}/issues`, { + method: "POST", + mutating: true, + body, + }); + printJson({ command: "create-task", issue: issueSummary(issue) }); +} + +async function comment(config, args) { + const issueRef = requireStringFlag(args, "issue"); + const bodyText = await readBody(args, "body", "body-file"); + if (!bodyText || bodyText.trim().length === 0) throw new UsageError("comment requires --body or --body-file"); + const commentBody = { + body: bodyText, + ...(boolFlag(args, "resume") ? { resume: true } : {}), + ...(boolFlag(args, "reopen") ? { reopen: true } : {}), + }; + const created = await apiFetch(config, `/issues/${encodeURIComponent(issueRef)}/comments`, { + method: "POST", + mutating: true, + body: commentBody, + }); + printJson({ command: "comment", issue: issueRef, comment: commentSummary(created) }); +} + +async function updateStatus(config, args) { + const issueRef = requireStringFlag(args, "issue"); + const status = validateEnum(requireStringFlag(args, "status"), STATUSES, "status"); + const commentText = await readBody(args, "comment", "comment-file"); + const body = { + status, + ...(commentText && commentText.trim().length > 0 ? { comment: commentText } : {}), + ...(boolFlag(args, "resume") ? { resume: true } : {}), + ...(boolFlag(args, "reopen") ? { reopen: true } : {}), + }; + const issue = await apiFetch(config, `/issues/${encodeURIComponent(issueRef)}`, { + method: "PATCH", + mutating: true, + body, + }); + printJson({ command: "update-status", issue: issueSummary(issue) }); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const command = args._[0]; + if (!command || command === "help" || command === "--help" || boolFlag(args, "help")) { + process.stdout.write(`${HELP}\n`); + return; + } + const config = getConfig(); + if (command === "list-assigned") return listAssigned(config, args); + if (command === "create-task") return createTask(config, args); + if (command === "comment") return comment(config, args); + if (command === "update-status") return updateStatus(config, args); + throw new UsageError(`Unknown command: ${command}`); +} + +main().catch((err) => { + if (err instanceof UsageError) { + process.stderr.write(`Usage error: ${err.message}\n\n${HELP}\n`); + process.exitCode = 2; + return; + } + if (err instanceof ApiError) { + printJson({ + error: err.message, + status: err.status, + details: err.body?.details ?? null, + }); + process.exitCode = 1; + return; + } + process.stderr.write(`Unexpected error: ${err instanceof Error ? err.message : String(err)}\n`); + process.exitCode = 1; +}); diff --git a/packages/adapters/hermes/src/cli/format-event.ts b/packages/adapters/hermes/src/cli/format-event.ts new file mode 100644 index 0000000000..5f44bbeb4d --- /dev/null +++ b/packages/adapters/hermes/src/cli/format-event.ts @@ -0,0 +1,61 @@ +/** + * CLI output formatting for Hermes Agent adapter. + * + * Pretty-prints Hermes output lines in the terminal when running + * Paperclip's CLI tools. + */ + +import pc from "picocolors"; + +/** + * Format a Hermes Agent stdout event for terminal display. + * + * @param raw Raw stdout line from Hermes + * @param debug If true, show extra metadata with color coding + */ +export function printHermesStreamEvent(raw: string, debug: boolean): void { + const line = raw.trim(); + if (!line) return; + + if (!debug) { + console.log(line); + return; + } + + // Adapter log lines + if (line.startsWith("[hermes]")) { + console.log(pc.blue(line)); + return; + } + + // Tool output (┊ prefix) + if (line.startsWith("┊")) { + console.log(pc.cyan(line)); + return; + } + + // Thinking + if (line.includes("💭") || line.startsWith("<thinking>")) { + console.log(pc.dim(line)); + return; + } + + // Errors + if ( + line.startsWith("Error:") || + line.startsWith("ERROR:") || + line.startsWith("Traceback") + ) { + console.log(pc.red(line)); + return; + } + + // Session info + if (/session/i.test(line) && /id|saved|resumed/i.test(line)) { + console.log(pc.green(line)); + return; + } + + // Default: gray in debug mode + console.log(pc.gray(line)); +} diff --git a/packages/adapters/hermes/src/cli/index.ts b/packages/adapters/hermes/src/cli/index.ts new file mode 100644 index 0000000000..2177ef6627 --- /dev/null +++ b/packages/adapters/hermes/src/cli/index.ts @@ -0,0 +1,5 @@ +/** + * CLI module exports — used by Paperclip's CLI for terminal formatting. + */ + +export { printHermesStreamEvent } from "./format-event.js"; diff --git a/packages/adapters/hermes/src/gateway/cli/format-event.ts b/packages/adapters/hermes/src/gateway/cli/format-event.ts new file mode 100644 index 0000000000..58705bc40a --- /dev/null +++ b/packages/adapters/hermes/src/gateway/cli/format-event.ts @@ -0,0 +1,13 @@ +export function printHermesGatewayStreamEvent(line: string, debug: boolean): void { + const trimmed = line.trim(); + if (!trimmed) return; + if (trimmed.startsWith("[hermes-gateway:event]")) { + console.log(trimmed); + return; + } + if (trimmed.startsWith("[hermes-gateway]")) { + console.log(trimmed); + return; + } + if (debug) console.log(line); +} diff --git a/packages/adapters/hermes/src/gateway/cli/index.ts b/packages/adapters/hermes/src/gateway/cli/index.ts new file mode 100644 index 0000000000..673d48880d --- /dev/null +++ b/packages/adapters/hermes/src/gateway/cli/index.ts @@ -0,0 +1 @@ +export { printHermesGatewayStreamEvent as formatStdoutEvent } from "./format-event.js"; diff --git a/packages/adapters/hermes/src/gateway/index.ts b/packages/adapters/hermes/src/gateway/index.ts new file mode 100644 index 0000000000..dd89113df8 --- /dev/null +++ b/packages/adapters/hermes/src/gateway/index.ts @@ -0,0 +1,72 @@ +import type { AdapterSessionManagement, ServerAdapterModule } from "@paperclipai/adapter-utils"; +import { ADAPTER_LABEL, ADAPTER_TYPE } from "./shared/constants.js"; +import { execute, getConfigSchema, sessionCodec, testEnvironment } from "./server/index.js"; + +export const type = ADAPTER_TYPE; +export const label = ADAPTER_LABEL; +export const models: { id: string; label: string }[] = []; + +const sessionManagement: AdapterSessionManagement = { + supportsSessionResume: true, + nativeContextManagement: "confirmed", + defaultSessionCompaction: { + enabled: true, + maxSessionRuns: 0, + maxRawInputTokens: 0, + maxSessionAgeHours: 0, + }, +}; + +export const agentConfigurationDoc = `# hermes_gateway agent configuration + +Adapter: hermes_gateway + +Use when: +- Hermes Agent runs on another host or process that exposes the Hermes API server. +- Paperclip should create Hermes runs through POST /v1/runs and observe them through SSE events. +- You need remote Hermes session continuity with X-Hermes-Session-Key. + +Don't use when: +- Hermes should run as a local child process on the Paperclip host; use hermes_local instead. +- The Hermes API server is not enabled or is only reachable over an unsafe public HTTP endpoint. +- You need Hermes-originated Paperclip task creation only; that is a Paperclip skill/API bridge, not this wake adapter. + +Required fields: +- apiBaseUrl (string): Hermes API server base URL, for example http://127.0.0.1:8642. +- apiKey (string): Hermes API_SERVER_KEY. Sent as Authorization: Bearer <apiKey>. + +Optional fields: +- headers (object or JSON string): extra noncritical headers. Authorization, Content-Type, Accept, Idempotency-Key, and X-Hermes-Session-Key are generated by the adapter. +- paperclipApiUrl (string): Paperclip API URL reachable from the Hermes host. This is not a credential. +- sessionKeyStrategy (issue | agent | run | none): defaults to issue. +- timeoutSec (number): defaults to 120. +- eventReconnectMs (number): defaults to 2000. +- instructions (string): stable Hermes instructions sent separately from wake input. + +Runtime mapping: +- Creates runs with POST /v1/runs. +- Sends Idempotency-Key equal to the Paperclip run id for correlation only; Hermes v0.16.0 did not dedupe duplicate creates. +- Streams GET /v1/runs/{run_id}/events and polls GET /v1/runs/{run_id} as fallback. +- Calls POST /v1/runs/{run_id}/stop on timeout. + +Security guidance: +- Prefer HTTPS or a private overlay network for non-loopback hosts. +- Do not put Hermes apiKey or Paperclip bearer tokens in prompts, comments, logs, or result JSON. +- Keep the default issue-scoped session strategy unless shared agent memory is intentional. +`; + +export function createServerAdapter(): ServerAdapterModule { + return { + type, + execute, + testEnvironment, + sessionCodec, + sessionManagement, + models, + supportsLocalAgentJwt: false, + supportsInstructionsBundle: false, + requiresMaterializedRuntimeSkills: false, + agentConfigurationDoc, + getConfigSchema, + }; +} diff --git a/packages/adapters/hermes/src/gateway/server/config-schema.ts b/packages/adapters/hermes/src/gateway/server/config-schema.ts new file mode 100644 index 0000000000..ad27054d23 --- /dev/null +++ b/packages/adapters/hermes/src/gateway/server/config-schema.ts @@ -0,0 +1,76 @@ +import type { AdapterConfigSchema } from "@paperclipai/adapter-utils"; +import { DEFAULT_EVENT_RECONNECT_MS, DEFAULT_TIMEOUT_SEC } from "../shared/constants.js"; +import { INSECURE_REMOTE_HTTP_ESCAPE_HATCH } from "./transport-security.js"; + +export function getConfigSchema(): AdapterConfigSchema { + return { + fields: [ + { + key: "apiBaseUrl", + label: "API base URL", + type: "text", + required: true, + hint: "Hermes API server base URL, such as http://127.0.0.1:8642 or a private HTTPS URL.", + }, + { + key: "apiKey", + label: "API key", + type: "text", + required: true, + hint: "Hermes API_SERVER_KEY. Stored as a Paperclip secret reference.", + meta: { secret: true }, + }, + { + key: INSECURE_REMOTE_HTTP_ESCAPE_HATCH, + label: "Dangerously allow remote HTTP", + type: "toggle", + default: false, + hint: "Unsafe dev-only escape hatch. Remote Hermes gateways should use HTTPS; loopback HTTP remains allowed.", + }, + { + key: "sessionKeyStrategy", + label: "Session key strategy", + type: "select", + default: "issue", + options: [ + { value: "issue", label: "Issue scoped" }, + { value: "agent", label: "Agent scoped" }, + { value: "run", label: "Run scoped" }, + { value: "none", label: "None" }, + ], + hint: "Controls X-Hermes-Session-Key. Issue scoped prevents cross-task memory bleed by default.", + }, + { + key: "timeoutSec", + label: "Timeout seconds", + type: "number", + default: DEFAULT_TIMEOUT_SEC, + }, + { + key: "eventReconnectMs", + label: "Event reconnect ms", + type: "number", + default: DEFAULT_EVENT_RECONNECT_MS, + hint: "Delay before reconnecting the Hermes SSE events stream after a nonterminal disconnect.", + }, + { + key: "paperclipApiUrl", + label: "Paperclip API URL", + type: "text", + hint: "Optional Paperclip API URL reachable by the remote Hermes host. This is not a credential.", + }, + { + key: "headers", + label: "Extra headers", + type: "textarea", + hint: "Optional JSON object of extra nonsecret headers. Security-critical headers are generated by the adapter.", + }, + { + key: "instructions", + label: "Instructions", + type: "textarea", + hint: "Optional stable Hermes instructions sent separately from the wake input.", + }, + ], + }; +} diff --git a/packages/adapters/hermes/src/gateway/server/execute.test.ts b/packages/adapters/hermes/src/gateway/server/execute.test.ts new file mode 100644 index 0000000000..de3c165756 --- /dev/null +++ b/packages/adapters/hermes/src/gateway/server/execute.test.ts @@ -0,0 +1,417 @@ +import { describe, expect, it, vi, afterEach } from "vitest"; +import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; +import { execute, mapFinalResultForTest, parseSseFramesForTest, resolveSessionKey } from "./execute.js"; +import { testEnvironment } from "./test.js"; + +function makeCtx(config: Record<string, unknown>): AdapterExecutionContext { + return { + runId: "pc-run-1", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Hermes", + adapterType: "hermes_gateway", + adapterConfig: config, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config, + context: { + issueId: "issue-1", + wakeReason: "manual", + paperclipWake: { + issue: { identifier: "PAP-1", title: "Do the thing" }, + }, + }, + onLog: vi.fn(async () => undefined), + onMeta: vi.fn(async () => undefined), + }; +} + +function sseStream(text: string): ReadableStream<Uint8Array> { + return new ReadableStream<Uint8Array>({ + start(controller) { + controller.enqueue(new TextEncoder().encode(text)); + controller.close(); + }, + }); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("resolveSessionKey", () => { + it("derives issue-scoped session keys by default", () => { + expect( + resolveSessionKey({ + strategy: "issue", + companyId: "company-1", + agentId: "agent-1", + runId: "run-1", + issueId: "issue-1", + }), + ).toBe("paperclip:company:company-1:agent:agent-1:issue:issue-1"); + }); + + it("omits the session key for none strategy", () => { + expect( + resolveSessionKey({ + strategy: "none", + companyId: "company-1", + agentId: "agent-1", + runId: "run-1", + issueId: "issue-1", + }), + ).toBeNull(); + }); +}); + +describe("parseSseFramesForTest", () => { + it("parses event and data lines while preserving partial frames", () => { + const parsed = parseSseFramesForTest("event: message.delta\ndata: {\"delta\":\"hi\"}\n\n:data\ndata: later"); + expect(parsed.frames).toEqual([{ event: "message.delta", data: "{\"delta\":\"hi\"}" }]); + expect(parsed.rest).toBe(":data\ndata: later"); + }); +}); + +describe("execute", () => { + it("rejects remote plain HTTP unless the unsafe dev escape hatch is enabled", async () => { + const fetchMock = vi.fn(async () => new Response(JSON.stringify({ run_id: "unexpected" }), { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + const result = await execute(makeCtx({ + apiBaseUrl: "http://192.168.1.25:8642", + apiKey: "secret-key", + })); + + expect(result.exitCode).toBe(1); + expect(result.errorCode).toBe("hermes_gateway_plain_http_remote_denied"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("constructs POST /v1/runs with auth, idempotency, and Hermes session headers", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/v1/runs")) { + return new Response(JSON.stringify({ run_id: "run-hermes-1", status: "started" }), { status: 200 }); + } + if (url.endsWith("/events")) { + return new Response( + sseStream( + [ + "event: message.delta", + "data: {\"delta\":\"done\"}", + "", + "event: run.completed", + "data: {\"status\":\"completed\",\"output\":\"done\",\"session_id\":\"session-1\",\"usage\":{\"input_tokens\":3,\"output_tokens\":2},\"model\":\"hermes-agent\"}", + "", + ].join("\n"), + ), + { status: 200, headers: { "content-type": "text/event-stream" } }, + ); + } + return new Response(JSON.stringify({ status: "completed", output: "done" }), { status: 200 }); + }); + vi.stubGlobal("fetch", fetchMock); + + const result = await execute(makeCtx({ + apiBaseUrl: "http://127.0.0.1:8642", + apiKey: "secret-key", + timeoutSec: 5, + })); + + expect(result.exitCode).toBe(0); + expect(result.summary).toBe("done"); + expect(result.usage).toEqual({ inputTokens: 3, outputTokens: 2 }); + + const calls = fetchMock.mock.calls as Array<[RequestInfo | URL, RequestInit?]>; + const createCall = calls.find(([input]) => String(input).endsWith("/v1/runs")); + expect(createCall).toBeTruthy(); + const init = createCall?.[1] as RequestInit; + expect(init.headers).toMatchObject({ + Authorization: "Bearer secret-key", + "Content-Type": "application/json", + "Idempotency-Key": "pc-run-1", + "X-Hermes-Session-Key": "paperclip:company:company-1:agent:agent-1:issue:issue-1", + }); + const body = JSON.parse(String(init.body)); + expect(body.input).toContain("Do the thing"); + expect(body.session_id).toBe("paperclip:company:company-1:agent:agent-1:issue:issue-1"); + }); + + it("redacts echoed auth material from stream logs and summaries", async () => { + const ctx = makeCtx({ + apiBaseUrl: "http://127.0.0.1:8642", + apiKey: "secret-key", + timeoutSec: 5, + }); + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/v1/runs")) { + return new Response(JSON.stringify({ run_id: "run-hermes-1", status: "started" }), { status: 200 }); + } + if (url.endsWith("/events")) { + return new Response( + sseStream( + [ + "event: message.delta", + "data: {\"delta\":\"Authorization: Bearer secret-key\\nX-Hermes-Session-Key: paperclip:company:company-1:agent:agent-1:issue:issue-1\"}", + "", + "event: run.completed", + "data: {\"status\":\"completed\",\"output\":\"Authorization: Bearer secret-key\\nraw key secret-key\\nX-Hermes-Session-Key: paperclip:company:company-1:agent:agent-1:issue:issue-1\"}", + "", + ].join("\n"), + ), + { status: 200, headers: { "content-type": "text/event-stream" } }, + ); + } + return new Response(JSON.stringify({ status: "completed" }), { status: 200 }); + }); + vi.stubGlobal("fetch", fetchMock); + + const result = await execute(ctx); + const logText = (ctx.onLog as ReturnType<typeof vi.fn>).mock.calls.map(([, line]) => String(line)).join("\n"); + + expect(result.exitCode).toBe(0); + expect(result.summary).toContain("Bearer [redacted]"); + expect(result.summary).toContain("raw key [redacted len=10]"); + expect(result.summary).toContain("X-Hermes-Session-Key: [redacted]"); + expect(result.summary).not.toContain("secret-key"); + expect(result.summary).not.toContain("paperclip:company:company-1:agent:agent-1:issue:issue-1"); + expect(result.resultJson?.output).toBe(result.summary); + expect(logText).toContain("Bearer [redacted]"); + expect(logText).toContain("X-Hermes-Session-Key: [redacted]"); + expect(logText).not.toContain("secret-key"); + expect(logText).not.toContain("paperclip:company:company-1:agent:agent-1:issue:issue-1"); + }); + + it("redacts agent-scoped Paperclip session keys from logs and public result metadata", async () => { + const ctx = makeCtx({ + apiBaseUrl: "http://127.0.0.1:8642", + apiKey: "secret-key", + sessionKeyStrategy: "agent", + timeoutSec: 5, + }); + const agentSessionKey = "paperclip:company:company-1:agent:agent-1"; + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/v1/runs")) { + return new Response(JSON.stringify({ run_id: "run-hermes-1", status: "started" }), { status: 200 }); + } + if (url.endsWith("/events")) { + return new Response( + sseStream( + [ + "event: message.delta", + `data: {"delta":"session ${agentSessionKey}"}`, + "", + "event: run.completed", + `data: {"status":"completed","output":"session ${agentSessionKey}","session_id":"${agentSessionKey}"}`, + "", + ].join("\n"), + ), + { status: 200, headers: { "content-type": "text/event-stream" } }, + ); + } + return new Response(JSON.stringify({ status: "completed" }), { status: 200 }); + }); + vi.stubGlobal("fetch", fetchMock); + + const result = await execute(ctx); + const logText = (ctx.onLog as ReturnType<typeof vi.fn>).mock.calls.map(([, line]) => String(line)).join("\n"); + + expect(result.exitCode).toBe(0); + expect(result.summary).toBe("session [redacted-session-key]"); + expect(result.sessionId).toBe("[redacted-session-key]"); + expect(result.sessionDisplayId).toBe("[redacted-session-key]"); + expect(result.resultJson?.session_id).toBe("[redacted-session-key]"); + expect(result.sessionParams).toEqual({ + hermesRunId: "run-hermes-1", + strategy: "agent", + }); + expect(logText).toContain("[redacted-session-key]"); + expect(logText).not.toContain(agentSessionKey); + }); + + it("falls back to polling when SSE is unavailable", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/v1/runs")) { + return new Response(JSON.stringify({ run_id: "run-hermes-1", status: "started" }), { status: 200 }); + } + if (url.endsWith("/events")) { + return new Response("no stream", { status: 503 }); + } + return new Response(JSON.stringify({ + status: "completed", + output: "polled done", + session_id: "session-polled", + }), { status: 200 }); + }); + vi.stubGlobal("fetch", fetchMock); + + const result = await execute(makeCtx({ + apiBaseUrl: "http://127.0.0.1:8642", + apiKey: "secret-key", + timeoutSec: 5, + pollIntervalMs: 250, + })); + + expect(result.exitCode).toBe(0); + expect(result.summary).toBe("polled done"); + expect(fetchMock.mock.calls.some(([input]) => String(input).endsWith("/v1/runs/run-hermes-1"))).toBe(true); + }); + + it("maps HTTP auth failures", async () => { + vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ error: "bad key" }), { status: 401 }))); + const result = await execute(makeCtx({ + apiBaseUrl: "http://127.0.0.1:8642", + apiKey: "secret-key", + })); + expect(result.exitCode).toBe(1); + expect(result.errorCode).toBe("hermes_gateway_auth_failed"); + }); + + it("redacts echoed auth material from HTTP error payloads", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + new Response( + JSON.stringify({ + message: "Authorization rejected: Bearer secret-key raw secret-key", + detail: "X-Hermes-Session-Key: paperclip:company:company-1:agent:agent-1:issue:issue-1", + nested: { + note: "session paperclip:company:company-1:agent:agent-1", + }, + }), + { status: 401 }, + )), + ); + + const result = await execute(makeCtx({ + apiBaseUrl: "http://127.0.0.1:8642", + apiKey: "secret-key", + })); + + expect(result.exitCode).toBe(1); + expect(result.errorCode).toBe("hermes_gateway_auth_failed"); + expect(result.errorMeta?.body).toEqual({ + message: "Authorization rejected: Bearer [redacted] raw [redacted len=10]", + detail: "X-Hermes-Session-Key: [redacted]", + nested: { + note: "session [redacted-session-key]", + }, + }); + expect(result.errorMessage).not.toContain("secret-key"); + expect(result.errorMessage).not.toContain("paperclip:company:company-1:agent:agent-1:issue:issue-1"); + }); + + it("calls stop on timeout", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/v1/runs")) { + return new Response(JSON.stringify({ run_id: "run-slow", status: "started" }), { status: 200 }); + } + if (url.endsWith("/events")) { + return new Promise<Response>(() => {}); + } + if (url.endsWith("/stop")) { + return new Response(JSON.stringify({ status: "stopping" }), { status: 200 }); + } + if (init?.method === "GET") { + return new Response(JSON.stringify({ status: "cancelled", last_event: "run.cancelled" }), { status: 200 }); + } + return new Response(JSON.stringify({ status: "running" }), { status: 200 }); + }); + vi.stubGlobal("fetch", fetchMock); + + const result = await execute(makeCtx({ + apiBaseUrl: "http://127.0.0.1:8642", + apiKey: "secret-key", + timeoutSec: 0.001, + })); + + expect(result.timedOut).toBe(true); + expect(result.errorCode).toBe("hermes_gateway_timeout"); + expect(fetchMock.mock.calls.some(([input]) => String(input).endsWith("/stop"))).toBe(true); + }); +}); + +describe("testEnvironment", () => { + it("fails remote plain HTTP before probing health", async () => { + const fetchMock = vi.fn(async () => new Response("{}", { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "hermes_gateway", + config: { + apiBaseUrl: "http://hermes.example:8642", + apiKey: "secret-key", + }, + }); + + expect(result.status).toBe("fail"); + expect(result.checks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "hermes_gateway_plain_http_remote_denied", + level: "error", + }), + ]), + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("allows remote plain HTTP only with the unsafe dev escape hatch", async () => { + const fetchMock = vi.fn(async () => new Response("{}", { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "hermes_gateway", + config: { + apiBaseUrl: "http://hermes.example:8642", + apiKey: "secret-key", + dangerouslyAllowInsecureRemoteHttp: true, + }, + }); + + expect(result.status).toBe("warn"); + expect(result.checks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "hermes_gateway_plain_http_remote_unsafe_allowed", + level: "warn", + }), + expect.objectContaining({ + code: "hermes_gateway_health_ok", + }), + ]), + ); + expect(fetchMock).toHaveBeenCalled(); + }); +}); + +describe("mapFinalResultForTest", () => { + it("maps failed statuses into adapter errors", () => { + const result = mapFinalResultForTest({ + terminal: { + runId: "run-1", + status: "failed", + payload: { status: "failed", error: "boom" }, + }, + outputChunks: [], + sessionKey: "session-key", + strategy: "issue", + }); + expect(result.exitCode).toBe(1); + expect(result.errorCode).toBe("hermes_gateway_run_failed"); + expect(result.errorMessage).toBe("boom"); + }); +}); diff --git a/packages/adapters/hermes/src/gateway/server/execute.ts b/packages/adapters/hermes/src/gateway/server/execute.ts new file mode 100644 index 0000000000..f28ccea4fe --- /dev/null +++ b/packages/adapters/hermes/src/gateway/server/execute.ts @@ -0,0 +1,914 @@ +import type { + AdapterExecutionContext, + AdapterExecutionResult, + UsageSummary, +} from "@paperclipai/adapter-utils"; +import { + asNumber, + asString, + parseObject, + readPaperclipIssueWorkModeFromContext, + renderPaperclipWakePrompt, + stringifyPaperclipWakePayload, +} from "@paperclipai/adapter-utils/server-utils"; +import { + ADAPTER_TYPE, + DEFAULT_EVENT_RECONNECT_MS, + DEFAULT_POLL_INTERVAL_MS, + DEFAULT_TIMEOUT_SEC, + STOP_GRACE_MS, +} from "../shared/constants.js"; +import { + allowsInsecureRemoteHttp, + isRemotePlainHttp, + remotePlainHttpDeniedMessage, +} from "./transport-security.js"; + +type SessionKeyStrategy = "issue" | "agent" | "run" | "none"; + +type SseFrame = { + event: string | null; + data: string; +}; + +type HermesHttpError = Error & { + status?: number; + code?: string; + retryNotBefore?: string | null; + body?: unknown; +}; + +type TerminalState = { + runId: string; + status: string; + eventName?: string | null; + payload?: Record<string, unknown> | null; + output?: string | null; +}; + +type ExecutionState = { + runId: string; + outputChunks: string[]; + lastEventName: string | null; + terminal: TerminalState | null; + resolveTerminal: (state: TerminalState) => void; + terminalPromise: Promise<TerminalState>; +}; + +type TextRedactor = (value: string) => string; + +const CRITICAL_HEADERS = new Set([ + "authorization", + "content-type", + "accept", + "idempotency-key", + "x-hermes-session-key", +]); + +const SENSITIVE_KEY_PATTERN = + /(^|[_-])(auth|authorization|token|secret|password|api[_-]?key|private[_-]?key)([_-]|$)/i; +const BEARER_TOKEN_PATTERN = /Bearer\s+\S+/gi; +const HERMES_SESSION_KEY_HEADER_PATTERN = /(X-Hermes-Session-Key\s*[:=]\s*)([^\s,;]+)/gi; +const PAPERCLIP_SESSION_KEY_PATTERN = + /\bpaperclip:(?:company:[A-Za-z0-9-]+:agent:[A-Za-z0-9-]+(?::(?:issue|run):[A-Za-z0-9-]+)?|run:[A-Za-z0-9-]+)\b/gi; + +const TERMINAL_STATUSES = new Set([ + "completed", + "failed", + "error", + "cancelled", + "canceled", + "stopped", + "interrupted", +]); + +const FAILURE_STATUSES = new Set(["failed", "error"]); +const CANCELLED_STATUSES = new Set(["cancelled", "canceled", "stopped", "interrupted"]); + +function asRecord(value: unknown): Record<string, unknown> | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record<string, unknown>; +} + +function nonEmpty(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function parseNonNegativeNumber(value: unknown, fallback: number): number { + const parsed = typeof value === "number" + ? value + : typeof value === "string" + ? Number.parseFloat(value) + : Number.NaN; + if (!Number.isFinite(parsed)) return fallback; + return Math.max(0, parsed); +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function normalizeSessionKeyStrategy(value: unknown): SessionKeyStrategy { + const raw = asString(value, "issue").trim().toLowerCase(); + if (raw === "agent" || raw === "run" || raw === "none") return raw; + return "issue"; +} + +function normalizeBaseUrl(value: string): URL | null { + try { + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + url.pathname = url.pathname.replace(/\/+$/, ""); + url.search = ""; + url.hash = ""; + return url; + } catch { + return null; + } +} + +function apiUrl(baseUrl: URL, path: string): string { + const base = baseUrl.toString().replace(/\/+$/, ""); + return `${base}${path}`; +} + +function issueIdFromContext(ctx: AdapterExecutionContext): string | null { + return nonEmpty(ctx.context.taskId) ?? nonEmpty(ctx.context.issueId); +} + +export function resolveSessionKey(input: { + strategy: SessionKeyStrategy; + companyId: string; + agentId: string; + runId: string; + issueId: string | null; +}): string | null { + if (input.strategy === "none") return null; + if (input.strategy === "agent") { + return `paperclip:company:${input.companyId}:agent:${input.agentId}`; + } + if (input.strategy === "run") { + return `paperclip:run:${input.runId}`; + } + const issuePart = input.issueId ? `issue:${input.issueId}` : `run:${input.runId}`; + return `paperclip:company:${input.companyId}:agent:${input.agentId}:${issuePart}`; +} + +function stringifyForLog(value: unknown, maxChars = 4_000): string { + const text = JSON.stringify(value); + return text.length <= maxChars ? text : `${text.slice(0, maxChars)}... [truncated ${text.length - maxChars} chars]`; +} + +function sanitizeSensitiveText(value: string): string { + return value + .replace(BEARER_TOKEN_PATTERN, "Bearer [redacted]") + .replace(HERMES_SESSION_KEY_HEADER_PATTERN, "$1[redacted]") + .replace(PAPERCLIP_SESSION_KEY_PATTERN, "[redacted-session-key]"); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function createTextRedactor(secrets: Array<string | null | undefined>): TextRedactor { + const exactSecrets = [...new Set(secrets.filter((secret): secret is string => typeof secret === "string" && secret.length >= 4))] + .sort((a, b) => b.length - a.length) + .map((secret) => ({ + secret, + regex: new RegExp(escapeRegExp(secret), "g"), + })); + + return (value: string) => { + let result = sanitizeSensitiveText(value); + for (const entry of exactSecrets) { + result = result.replace(entry.regex, `[redacted len=${entry.secret.length}]`); + } + return result; + }; +} + +function redactForLog(value: unknown, keyPath: string[] = [], depth = 0, redactText: TextRedactor = sanitizeSensitiveText): unknown { + const key = keyPath[keyPath.length - 1] ?? ""; + if (typeof value === "string") { + if (SENSITIVE_KEY_PATTERN.test(key)) return `[redacted len=${value.length}]`; + const sanitized = redactText(value); + return sanitized.length > 500 + ? `${sanitized.slice(0, 500)}... [truncated ${sanitized.length - 500} chars]` + : sanitized; + } + if (value == null || typeof value === "number" || typeof value === "boolean") return value; + if (Array.isArray(value)) { + if (depth > 5) return "[array-truncated]"; + return value.slice(0, 40).map((entry, index) => redactForLog(entry, [...keyPath, String(index)], depth + 1, redactText)); + } + if (typeof value === "object") { + if (depth > 5) return "[object-truncated]"; + const out: Record<string, unknown> = {}; + for (const [entryKey, entryValue] of Object.entries(value as Record<string, unknown>).slice(0, 80)) { + out[entryKey] = redactForLog(entryValue, [...keyPath, entryKey], depth + 1, redactText); + } + return out; + } + return redactText(String(value)); +} + +function parseHeaders(value: unknown): Record<string, string> { + const source = + typeof value === "string" && value.trim().length > 0 + ? (() => { + try { + return JSON.parse(value); + } catch { + return {}; + } + })() + : value; + const parsed = parseObject(source); + const headers: Record<string, string> = {}; + for (const [key, entry] of Object.entries(parsed)) { + const normalized = key.trim(); + if (!normalized || CRITICAL_HEADERS.has(normalized.toLowerCase())) continue; + if (typeof entry === "string") headers[normalized] = entry; + } + return headers; +} + +function buildHeaders(input: { + apiKey: string; + sessionKey: string | null; + runId: string; + extraHeaders: Record<string, string>; + accept: string; + contentType?: string; +}): Record<string, string> { + return { + ...input.extraHeaders, + Authorization: `Bearer ${input.apiKey}`, + Accept: input.accept, + ...(input.contentType ? { "Content-Type": input.contentType } : {}), + "Idempotency-Key": input.runId, + ...(input.sessionKey ? { "X-Hermes-Session-Key": input.sessionKey } : {}), + }; +} + +function buildInput(ctx: AdapterExecutionContext, paperclipApiUrl: string | null): string { + const wakePrompt = renderPaperclipWakePrompt(ctx.context.paperclipWake); + const wakePayloadJson = stringifyPaperclipWakePayload(ctx.context.paperclipWake); + const taskMarkdown = nonEmpty(ctx.context.paperclipTaskMarkdown); + const sessionHandoff = nonEmpty(ctx.context.paperclipSessionHandoffMarkdown); + const issueWorkMode = readPaperclipIssueWorkModeFromContext(ctx.context); + const lines = [ + `You are ${ctx.agent.name}, an AI agent employee in a Paperclip-managed company.`, + "", + "Paperclip runtime identity:", + `- Agent ID: ${ctx.agent.id}`, + `- Company ID: ${ctx.agent.companyId}`, + `- Run ID: ${ctx.runId}`, + ...(paperclipApiUrl ? [`- Paperclip API URL: ${paperclipApiUrl}`] : []), + ...(issueWorkMode ? [`- Issue work mode: ${issueWorkMode}`] : []), + "", + "Execution contract:", + "- Take concrete action in this run when the task is actionable.", + "- Do not stop at a plan unless the issue asks for planning only.", + "- Leave durable progress and update the issue to a clear final disposition.", + "- Use X-Paperclip-Run-Id on mutating Paperclip API requests when a Paperclip API key is available.", + "", + wakePrompt, + ...(sessionHandoff ? ["", sessionHandoff] : []), + ...(taskMarkdown ? ["", taskMarkdown] : []), + ...(wakePayloadJson + ? [ + "", + "Structured wake payload JSON:", + "```json", + wakePayloadJson, + "```", + ] + : []), + ]; + return lines.filter((line) => line !== null && line !== undefined).join("\n").trim(); +} + +function buildRunBody(ctx: AdapterExecutionContext, sessionKey: string | null): Record<string, unknown> { + const paperclipApiUrl = nonEmpty(ctx.config.paperclipApiUrl); + const payloadTemplate = parseObject(ctx.config.payloadTemplate); + const input = nonEmpty(payloadTemplate.input) ?? buildInput(ctx, paperclipApiUrl); + const instructions = + nonEmpty(ctx.config.instructions) ?? + nonEmpty(payloadTemplate.instructions) ?? + "Follow the Paperclip wake instructions exactly. Do not expose secrets in logs, comments, or final output."; + return { + ...payloadTemplate, + input, + instructions, + ...(sessionKey ? { session_id: sessionKey } : {}), + }; +} + +async function readResponseJson(response: Response): Promise<unknown> { + const text = await response.text(); + if (!text.trim()) return null; + try { + return JSON.parse(text); + } catch { + return { text }; + } +} + +function classifyHttpError(status: number): { code: string; family: AdapterExecutionResult["errorFamily"] | null } { + if (status === 401 || status === 403) return { code: "hermes_gateway_auth_failed", family: null }; + if (status === 404) return { code: "hermes_gateway_runs_unsupported", family: null }; + if (status === 429) return { code: "hermes_gateway_rate_limited", family: "transient_upstream" }; + if (status >= 500) return { code: "hermes_gateway_upstream_error", family: "transient_upstream" }; + return { code: "hermes_gateway_protocol_error", family: null }; +} + +async function fetchJson(input: RequestInfo | URL, init: RequestInit): Promise<unknown> { + let response: Response; + try { + response = await fetch(input, init); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const fetchErr = new Error(`Hermes gateway request failed: ${message}`) as HermesHttpError; + fetchErr.code = "hermes_gateway_connect_failed"; + throw fetchErr; + } + const body = await readResponseJson(response); + if (!response.ok) { + const classified = classifyHttpError(response.status); + const err = new Error(`Hermes gateway HTTP ${response.status}`) as HermesHttpError; + err.status = response.status; + err.code = classified.code; + err.retryNotBefore = response.headers.get("retry-after"); + err.body = body; + throw err; + } + return body; +} + +function extractRunId(value: unknown): string | null { + const record = asRecord(value); + return nonEmpty(record?.run_id) ?? nonEmpty(record?.runId) ?? nonEmpty(record?.id); +} + +function eventNameFromData(data: unknown, fallback: string | null): string | null { + const record = asRecord(data); + return nonEmpty(record?.event) ?? nonEmpty(record?.type) ?? fallback; +} + +function parseJsonData(data: string): unknown { + try { + return JSON.parse(data); + } catch { + return { text: data }; + } +} + +export function parseSseFramesForTest(buffer: string): { frames: SseFrame[]; rest: string } { + const normalized = buffer.replace(/\r\n/g, "\n"); + const frames: SseFrame[] = []; + let offset = 0; + while (true) { + const idx = normalized.indexOf("\n\n", offset); + if (idx < 0) break; + const rawFrame = normalized.slice(offset, idx); + offset = idx + 2; + let event: string | null = null; + const dataLines: string[] = []; + for (const line of rawFrame.split("\n")) { + if (!line || line.startsWith(":")) continue; + if (line.startsWith("event:")) { + event = line.slice("event:".length).trim(); + } else if (line.startsWith("data:")) { + dataLines.push(line.slice("data:".length).trimStart()); + } + } + if (dataLines.length > 0) frames.push({ event, data: dataLines.join("\n") }); + } + return { frames, rest: normalized.slice(offset) }; +} + +function createExecutionState(runId: string): ExecutionState { + let resolveTerminal!: (state: TerminalState) => void; + const terminalPromise = new Promise<TerminalState>((resolve) => { + resolveTerminal = resolve; + }); + return { + runId, + outputChunks: [], + lastEventName: null, + terminal: null, + resolveTerminal, + terminalPromise, + }; +} + +function markTerminal(state: ExecutionState, terminal: TerminalState): void { + if (state.terminal) return; + state.terminal = terminal; + state.resolveTerminal(terminal); +} + +function extractStatus(value: unknown): string | null { + const record = asRecord(value); + return nonEmpty(record?.status)?.toLowerCase() ?? null; +} + +function extractOutput(value: unknown): string | null { + const record = asRecord(value); + if (!record) return null; + const direct = + nonEmpty(record.output) ?? + nonEmpty(record.result) ?? + nonEmpty(record.text) ?? + nonEmpty(record.summary) ?? + nonEmpty(record.message); + if (direct) return direct; + const nested = asRecord(record.data) ?? asRecord(record.payload); + return nested ? extractOutput(nested) : null; +} + +async function handleEvent( + ctx: AdapterExecutionContext, + state: ExecutionState, + frame: SseFrame, + redactText: TextRedactor = sanitizeSensitiveText, +): Promise<void> { + const parsed = parseJsonData(frame.data); + const record = asRecord(parsed); + const eventName = eventNameFromData(parsed, frame.event); + state.lastEventName = eventName; + await ctx.onLog( + "stdout", + `[hermes-gateway:event] run=${state.runId} event=${eventName ?? "message"} data=${stringifyForLog(redactForLog(parsed, [], 0, redactText), 8_000)}\n`, + ); + + const delta = nonEmpty(record?.delta) ?? nonEmpty(record?.text_delta); + if (eventName === "message.delta" && delta) { + const sanitizedDelta = redactText(delta); + state.outputChunks.push(sanitizedDelta); + await ctx.onLog("stdout", sanitizedDelta); + } + + const status = extractStatus(parsed) ?? (eventName?.startsWith("run.") ? eventName.slice(4) : null); + if (status && TERMINAL_STATUSES.has(status)) { + markTerminal(state, { + runId: state.runId, + status, + eventName, + payload: record, + output: extractOutput(parsed), + }); + } +} + +async function delay(ms: number, signal: AbortSignal): Promise<void> { + if (signal.aborted) return; + await new Promise<void>((resolve) => { + const timer = setTimeout(resolve, ms); + signal.addEventListener( + "abort", + () => { + clearTimeout(timer); + resolve(); + }, + { once: true }, + ); + }); +} + +async function pollStatus(input: { + ctx: AdapterExecutionContext; + baseUrl: URL; + headers: Record<string, string>; + state: ExecutionState; + signal: AbortSignal; + intervalMs: number; + redactText?: TextRedactor; +}): Promise<void> { + while (!input.signal.aborted && !input.state.terminal) { + await delay(input.intervalMs, input.signal); + if (input.signal.aborted || input.state.terminal) break; + try { + const status = await fetchJson(apiUrl(input.baseUrl, `/v1/runs/${encodeURIComponent(input.state.runId)}`), { + method: "GET", + headers: input.headers, + signal: input.signal, + }); + const normalized = extractStatus(status); + if (normalized && TERMINAL_STATUSES.has(normalized)) { + markTerminal(input.state, { + runId: input.state.runId, + status: normalized, + payload: asRecord(status), + output: extractOutput(status), + }); + } + } catch (err) { + if (input.signal.aborted) return; + await input.ctx.onLog("stderr", `[hermes-gateway] status poll failed: ${redactErrorMessage(err, input.redactText)}\n`); + } + } +} + +async function consumeEvents(input: { + ctx: AdapterExecutionContext; + baseUrl: URL; + headers: Record<string, string>; + state: ExecutionState; + signal: AbortSignal; + reconnectMs: number; + redactText?: TextRedactor; +}): Promise<void> { + while (!input.signal.aborted && !input.state.terminal) { + try { + const response = await fetch(apiUrl(input.baseUrl, `/v1/runs/${encodeURIComponent(input.state.runId)}/events`), { + method: "GET", + headers: input.headers, + signal: input.signal, + }); + if (!response.ok) { + await input.ctx.onLog("stderr", `[hermes-gateway] event stream HTTP ${response.status}; falling back to polling\n`); + await delay(input.reconnectMs, input.signal); + continue; + } + if (!response.body) { + await input.ctx.onLog("stderr", "[hermes-gateway] event stream response had no body; falling back to polling\n"); + await delay(input.reconnectMs, input.signal); + continue; + } + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + while (!input.signal.aborted && !input.state.terminal) { + const { value, done } = await reader.read(); + if (done) { + if (buffer.trim().length > 0) { + const parsed = parseSseFramesForTest(`${buffer}\n\n`); + buffer = parsed.rest; + for (const frame of parsed.frames) { + await handleEvent(input.ctx, input.state, frame, input.redactText); + if (input.state.terminal) break; + } + } + break; + } + buffer += decoder.decode(value, { stream: true }); + const parsed = parseSseFramesForTest(buffer); + buffer = parsed.rest; + for (const frame of parsed.frames) { + await handleEvent(input.ctx, input.state, frame, input.redactText); + if (input.state.terminal) break; + } + } + } catch (err) { + if (input.signal.aborted || input.state.terminal) return; + await input.ctx.onLog("stderr", `[hermes-gateway] event stream disconnected: ${redactErrorMessage(err, input.redactText)}\n`); + } + if (!input.state.terminal) await delay(input.reconnectMs, input.signal); + } +} + +function parseUsage(value: unknown): UsageSummary | undefined { + const record = asRecord(value); + if (!record) return undefined; + const source = asRecord(record.usage) ?? record; + const inputTokens = asNumber(source.input_tokens ?? source.inputTokens ?? source.input, 0); + const outputTokens = asNumber(source.output_tokens ?? source.outputTokens ?? source.output, 0); + const cachedInputTokens = asNumber(source.cached_input_tokens ?? source.cachedInputTokens, 0); + if (inputTokens <= 0 && outputTokens <= 0 && cachedInputTokens <= 0) return undefined; + return { + inputTokens, + outputTokens, + ...(cachedInputTokens > 0 ? { cachedInputTokens } : {}), + }; +} + +function parseCostUsd(value: unknown): number | null { + const record = asRecord(value); + const raw = record?.cost_usd ?? record?.costUsd ?? asRecord(record?.usage)?.cost_usd ?? asRecord(record?.usage)?.costUsd; + const parsed = typeof raw === "number" ? raw : typeof raw === "string" ? Number.parseFloat(raw) : Number.NaN; + return Number.isFinite(parsed) ? parsed : null; +} + +function extractSessionId(value: unknown): string | null { + const record = asRecord(value); + return nonEmpty(record?.session_id) ?? nonEmpty(record?.sessionId) ?? nonEmpty(asRecord(record?.data)?.session_id); +} + +function extractModel(value: unknown): string | null { + const record = asRecord(value); + return nonEmpty(record?.model) ?? nonEmpty(asRecord(record?.usage)?.model); +} + +function extractErrorMessage(value: unknown): string | null { + const record = asRecord(value); + return nonEmpty(record?.error) ?? nonEmpty(record?.message) ?? nonEmpty(record?.detail) ?? extractOutput(value); +} + +function terminalResultCode(status: string): { exitCode: number; signal: string | null; errorCode: string | null } { + if (status === "completed") return { exitCode: 0, signal: null, errorCode: null }; + if (FAILURE_STATUSES.has(status)) return { exitCode: 1, signal: null, errorCode: "hermes_gateway_run_failed" }; + if (CANCELLED_STATUSES.has(status)) return { exitCode: 1, signal: "SIGTERM", errorCode: "hermes_gateway_cancelled" }; + return { exitCode: 1, signal: null, errorCode: "hermes_gateway_protocol_error" }; +} + +export function mapFinalResultForTest(input: { + terminal: TerminalState; + outputChunks: string[]; + sessionKey: string | null; + strategy: SessionKeyStrategy; + redactText?: TextRedactor; +}): AdapterExecutionResult { + const redactText = input.redactText ?? sanitizeSensitiveText; + const payload = input.terminal.payload ?? {}; + const output = redactText( + input.terminal.output ?? extractOutput(payload) ?? input.outputChunks.join("").trim(), + ); + const sessionId = extractSessionId(payload) ?? input.sessionKey; + const sessionDisplayId = sessionId ? redactText(sessionId) : null; + const mapped = terminalResultCode(input.terminal.status); + const usage = parseUsage(payload); + const costUsd = parseCostUsd(payload); + const errorMessage = mapped.errorCode + ? redactText(extractErrorMessage(payload) ?? `Hermes run ${input.terminal.status}`) + : null; + return { + exitCode: mapped.exitCode, + signal: mapped.signal, + timedOut: false, + provider: "hermes_gateway", + model: extractModel(payload), + ...(mapped.errorCode ? { errorCode: mapped.errorCode } : {}), + ...(errorMessage ? { errorMessage } : {}), + ...(usage ? { usage } : {}), + ...(costUsd !== null ? { costUsd } : {}), + ...(output ? { summary: output.slice(0, 2_000) } : {}), + sessionId: sessionDisplayId, + sessionParams: { + hermesRunId: input.terminal.runId, + ...(sessionId && sessionDisplayId === sessionId ? { hermesSessionId: sessionId } : {}), + strategy: input.strategy, + }, + sessionDisplayId, + resultJson: { + run_id: input.terminal.runId, + status: input.terminal.status, + session_id: sessionDisplayId, + last_event: input.terminal.eventName ?? null, + output: output ?? "", + usage: usage ?? null, + cost_usd: costUsd, + }, + }; +} + +async function stopRun(input: { + ctx: AdapterExecutionContext; + baseUrl: URL; + headers: Record<string, string>; + runId: string; + redactText?: TextRedactor; +}): Promise<Record<string, unknown> | null> { + try { + const stopped = await fetchJson(apiUrl(input.baseUrl, `/v1/runs/${encodeURIComponent(input.runId)}/stop`), { + method: "POST", + headers: input.headers, + }); + await input.ctx.onLog("stdout", `[hermes-gateway] stop requested for run ${input.runId}\n`); + return asRecord(stopped); + } catch (err) { + await input.ctx.onLog("stderr", `[hermes-gateway] stop request failed: ${redactErrorMessage(err, input.redactText)}\n`); + return null; + } +} + +async function fetchFinalStatus(input: { + baseUrl: URL; + headers: Record<string, string>; + runId: string; + deadlineMs: number; +}): Promise<Record<string, unknown> | null> { + const deadline = Date.now() + input.deadlineMs; + while (Date.now() < deadline) { + try { + const status = await fetchJson(apiUrl(input.baseUrl, `/v1/runs/${encodeURIComponent(input.runId)}`), { + method: "GET", + headers: input.headers, + }); + const record = asRecord(status); + const normalized = extractStatus(status); + if (normalized && TERMINAL_STATUSES.has(normalized)) return record; + } catch { + return null; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + return null; +} + +function redactErrorMessage(err: unknown, redactText: TextRedactor = sanitizeSensitiveText): string { + if (err instanceof Error) return redactText(err.message); + return redactText(String(err)); +} + +function errorResult(err: unknown, redactText: TextRedactor = sanitizeSensitiveText): AdapterExecutionResult { + const hermesError = err as HermesHttpError; + const code = hermesError.code ?? "hermes_gateway_protocol_error"; + const classified = hermesError.status ? classifyHttpError(hermesError.status) : null; + return { + exitCode: 1, + signal: null, + timedOut: false, + errorCode: code, + errorFamily: classified?.family ?? (code === "hermes_gateway_connect_failed" ? "transient_upstream" : null), + retryNotBefore: hermesError.retryNotBefore ?? null, + errorMessage: redactErrorMessage(err, redactText), + errorMeta: { + ...(hermesError.status ? { status: hermesError.status } : {}), + ...(hermesError.body ? { body: redactForLog(hermesError.body, [], 0, redactText) as Record<string, unknown> } : {}), + }, + }; +} + +export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> { + const apiBaseUrlValue = asString(ctx.config.apiBaseUrl ?? ctx.config.url, "").trim(); + if (!apiBaseUrlValue) { + return { + exitCode: 1, + signal: null, + timedOut: false, + errorCode: "hermes_gateway_api_base_url_missing", + errorMessage: "Hermes gateway adapter requires apiBaseUrl.", + }; + } + + const baseUrl = normalizeBaseUrl(apiBaseUrlValue); + if (!baseUrl) { + return { + exitCode: 1, + signal: null, + timedOut: false, + errorCode: "hermes_gateway_api_base_url_invalid", + errorMessage: `Invalid Hermes gateway apiBaseUrl: ${apiBaseUrlValue}`, + }; + } + if (isRemotePlainHttp(baseUrl) && !allowsInsecureRemoteHttp(ctx.config)) { + return { + exitCode: 1, + signal: null, + timedOut: false, + errorCode: "hermes_gateway_plain_http_remote_denied", + errorMessage: remotePlainHttpDeniedMessage(baseUrl.hostname), + }; + } + + const apiKey = nonEmpty(ctx.config.apiKey) ?? nonEmpty(ctx.config.token); + if (!apiKey) { + return { + exitCode: 1, + signal: null, + timedOut: false, + errorCode: "hermes_gateway_api_key_missing", + errorMessage: "Hermes gateway adapter requires apiKey.", + }; + } + + const timeoutSec = parseNonNegativeNumber(ctx.config.timeoutSec, DEFAULT_TIMEOUT_SEC); + const timeoutMs = timeoutSec > 0 ? Math.ceil(timeoutSec * 1000) : 0; + const reconnectMs = Math.floor(clamp(parseNonNegativeNumber(ctx.config.eventReconnectMs, DEFAULT_EVENT_RECONNECT_MS), 250, 30_000)); + const pollIntervalMs = Math.floor(clamp(parseNonNegativeNumber(ctx.config.pollIntervalMs, DEFAULT_POLL_INTERVAL_MS), 250, 10_000)); + const strategy = normalizeSessionKeyStrategy(ctx.config.sessionKeyStrategy); + const sessionKey = resolveSessionKey({ + strategy, + companyId: ctx.agent.companyId, + agentId: ctx.agent.id, + runId: ctx.runId, + issueId: issueIdFromContext(ctx), + }); + const extraHeaders = parseHeaders(ctx.config.headers); + const runHeaders = buildHeaders({ + apiKey, + sessionKey, + runId: ctx.runId, + extraHeaders, + accept: "application/json", + contentType: "application/json", + }); + const eventHeaders = buildHeaders({ + apiKey, + sessionKey, + runId: ctx.runId, + extraHeaders, + accept: "text/event-stream", + }); + const redactText = createTextRedactor([ + apiKey, + sessionKey, + runHeaders.Authorization, + runHeaders["X-Hermes-Session-Key"], + ]); + const body = buildRunBody(ctx, sessionKey); + + await ctx.onMeta?.({ + adapterType: ADAPTER_TYPE, + command: "POST /v1/runs", + commandArgs: [baseUrl.origin, "/v1/runs"], + context: { + runId: ctx.runId, + timeoutSec, + eventReconnectMs: reconnectMs, + sessionKeyStrategy: strategy, + hasSessionKey: Boolean(sessionKey), + }, + }); + await ctx.onLog("stdout", `[hermes-gateway] creating run at ${baseUrl.origin}/v1/runs (timeout=${timeoutSec}s, session=${strategy})\n`); + await ctx.onLog("stdout", `[hermes-gateway] request headers (redacted): ${stringifyForLog(redactForLog(runHeaders, [], 0, redactText), 3_000)}\n`); + + let runId: string | null = null; + try { + const created = await fetchJson(apiUrl(baseUrl, "/v1/runs"), { + method: "POST", + headers: runHeaders, + body: JSON.stringify(body), + }); + runId = extractRunId(created); + if (!runId) { + return { + exitCode: 1, + signal: null, + timedOut: false, + errorCode: "hermes_gateway_protocol_error", + errorMessage: "Hermes /v1/runs response did not include run_id.", + errorMeta: { response: redactForLog(created, [], 0, redactText) as Record<string, unknown> }, + }; + } + } catch (err) { + return errorResult(err, redactText); + } + + await ctx.onLog("stdout", `[hermes-gateway] run created: ${runId}\n`); + + const state = createExecutionState(runId); + const controller = new AbortController(); + void consumeEvents({ + ctx, + baseUrl, + headers: eventHeaders, + state, + signal: controller.signal, + reconnectMs, + redactText, + }).catch(() => undefined); + void pollStatus({ + ctx, + baseUrl, + headers: eventHeaders, + state, + signal: controller.signal, + intervalMs: pollIntervalMs, + redactText, + }).catch(() => undefined); + + let timeoutTimer: ReturnType<typeof setTimeout> | null = null; + const timeoutPromise = new Promise<"timeout">((resolve) => { + if (timeoutMs <= 0) return; + timeoutTimer = setTimeout(() => resolve("timeout"), timeoutMs); + }); + + const outcome = await Promise.race([state.terminalPromise, timeoutPromise]); + if (timeoutTimer) clearTimeout(timeoutTimer); + controller.abort(); + + if (outcome === "timeout") { + await stopRun({ ctx, baseUrl, headers: eventHeaders, runId, redactText }); + const finalStatus = await fetchFinalStatus({ baseUrl, headers: eventHeaders, runId, deadlineMs: STOP_GRACE_MS }); + return { + exitCode: 1, + signal: null, + timedOut: true, + errorCode: "hermes_gateway_timeout", + errorMessage: `Hermes gateway run timed out after ${timeoutSec}s.`, + provider: "hermes_gateway", + resultJson: { + run_id: runId, + status: extractStatus(finalStatus) ?? "timeout", + last_event: state.lastEventName, + final_status: redactForLog(finalStatus, [], 0, redactText), + }, + sessionParams: { + hermesRunId: runId, + strategy, + }, + sessionDisplayId: sessionKey ? redactText(sessionKey) : null, + }; + } + + return mapFinalResultForTest({ + terminal: outcome, + outputChunks: state.outputChunks, + sessionKey, + strategy, + redactText, + }); +} diff --git a/packages/adapters/hermes/src/gateway/server/index.ts b/packages/adapters/hermes/src/gateway/server/index.ts new file mode 100644 index 0000000000..b29873d6ec --- /dev/null +++ b/packages/adapters/hermes/src/gateway/server/index.ts @@ -0,0 +1,45 @@ +import type { AdapterSessionCodec } from "@paperclipai/adapter-utils"; + +export { execute, resolveSessionKey, parseSseFramesForTest, mapFinalResultForTest } from "./execute.js"; +export { testEnvironment } from "./test.js"; +export { getConfigSchema } from "./config-schema.js"; + +function readString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +export const sessionCodec: AdapterSessionCodec = { + deserialize(raw) { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null; + const record = raw as Record<string, unknown>; + const hermesSessionId = readString(record.hermesSessionId) ?? readString(record.sessionId); + const sessionKey = readString(record.sessionKey); + const hermesRunId = readString(record.hermesRunId); + const strategy = readString(record.strategy); + if (!hermesSessionId && !sessionKey && !hermesRunId) return null; + return { + ...(hermesRunId ? { hermesRunId } : {}), + ...(hermesSessionId ? { hermesSessionId } : {}), + ...(sessionKey ? { sessionKey } : {}), + ...(strategy ? { strategy } : {}), + }; + }, + serialize(params) { + if (!params) return null; + const hermesSessionId = readString(params.hermesSessionId) ?? readString(params.sessionId); + const sessionKey = readString(params.sessionKey); + const hermesRunId = readString(params.hermesRunId); + const strategy = readString(params.strategy); + if (!hermesSessionId && !sessionKey && !hermesRunId) return null; + return { + ...(hermesRunId ? { hermesRunId } : {}), + ...(hermesSessionId ? { hermesSessionId } : {}), + ...(sessionKey ? { sessionKey } : {}), + ...(strategy ? { strategy } : {}), + }; + }, + getDisplayId(params) { + if (!params) return null; + return readString(params.hermesSessionId) ?? readString(params.sessionKey) ?? readString(params.hermesRunId); + }, +}; diff --git a/packages/adapters/hermes/src/gateway/server/test.ts b/packages/adapters/hermes/src/gateway/server/test.ts new file mode 100644 index 0000000000..da601a3e81 --- /dev/null +++ b/packages/adapters/hermes/src/gateway/server/test.ts @@ -0,0 +1,127 @@ +import type { + AdapterEnvironmentCheck, + AdapterEnvironmentTestContext, + AdapterEnvironmentTestResult, +} from "@paperclipai/adapter-utils"; +import { asString } from "@paperclipai/adapter-utils/server-utils"; +import { + allowsInsecureRemoteHttp, + isLoopbackHostname, + isRemotePlainHttp, + remotePlainHttpDeniedMessage, +} from "./transport-security.js"; + +function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { + if (checks.some((check) => check.level === "error")) return "fail"; + if (checks.some((check) => check.level === "warn")) return "warn"; + return "pass"; +} + +function normalizeBaseUrl(value: string): URL | null { + try { + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + return url; + } catch { + return null; + } +} + +export async function testEnvironment( + ctx: AdapterEnvironmentTestContext, +): Promise<AdapterEnvironmentTestResult> { + const checks: AdapterEnvironmentCheck[] = []; + const apiBaseUrl = asString(ctx.config.apiBaseUrl ?? ctx.config.url, "").trim(); + const apiKey = asString(ctx.config.apiKey ?? ctx.config.token, "").trim(); + + if (!apiBaseUrl) { + checks.push({ + code: "hermes_gateway_api_base_url_missing", + level: "error", + message: "Hermes Gateway requires apiBaseUrl.", + hint: "Enable Hermes API server and set apiBaseUrl, for example http://127.0.0.1:8642.", + }); + } + + const parsed = apiBaseUrl ? normalizeBaseUrl(apiBaseUrl) : null; + if (apiBaseUrl && !parsed) { + checks.push({ + code: "hermes_gateway_api_base_url_invalid", + level: "error", + message: "apiBaseUrl must be an http:// or https:// URL.", + }); + } + + if (!apiKey) { + checks.push({ + code: "hermes_gateway_api_key_missing", + level: "error", + message: "Hermes Gateway requires apiKey.", + hint: "Set Hermes API_SERVER_KEY and copy the same value into adapterConfig.apiKey.", + }); + } + + if (parsed && isRemotePlainHttp(parsed) && !allowsInsecureRemoteHttp(ctx.config)) { + checks.push({ + code: "hermes_gateway_plain_http_remote_denied", + level: "error", + message: remotePlainHttpDeniedMessage(parsed.hostname), + hint: "Use https:// for remote Hermes gateways. Loopback http://localhost and http://127.0.0.1 remain allowed.", + }); + } else if (parsed && isRemotePlainHttp(parsed)) { + checks.push({ + code: "hermes_gateway_plain_http_remote_unsafe_allowed", + level: "warn", + message: "Unsafe dev escape hatch enabled for non-loopback HTTP Hermes traffic.", + hint: "Remove the escape hatch and use HTTPS before using this gateway for real credentials.", + }); + } else if (parsed?.protocol === "http:" && isLoopbackHostname(parsed.hostname)) { + checks.push({ + code: "hermes_gateway_loopback_http_allowed", + level: "info", + message: "Loopback HTTP Hermes gateway URL is allowed.", + }); + } + + if (checks.some((check) => check.level === "error") || !parsed || !apiKey) { + return { + adapterType: ctx.adapterType, + status: summarizeStatus(checks), + checks, + testedAt: new Date().toISOString(), + }; + } + + try { + const healthUrl = new URL("/health", parsed); + const response = await fetch(healthUrl, { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + }, + signal: AbortSignal.timeout(2_000), + }); + checks.push({ + code: response.ok ? "hermes_gateway_health_ok" : "hermes_gateway_health_failed", + level: response.ok ? "info" : "warn", + message: response.ok + ? "Hermes Gateway health endpoint is reachable." + : `Hermes Gateway health endpoint returned HTTP ${response.status}.`, + }); + } catch (err) { + checks.push({ + code: "hermes_gateway_health_unreachable", + level: "warn", + message: "Could not reach Hermes Gateway health endpoint.", + detail: err instanceof Error ? err.message : String(err), + }); + } + + return { + adapterType: ctx.adapterType, + status: summarizeStatus(checks), + checks, + testedAt: new Date().toISOString(), + }; +} diff --git a/packages/adapters/hermes/src/gateway/server/transport-security.ts b/packages/adapters/hermes/src/gateway/server/transport-security.ts new file mode 100644 index 0000000000..00d7deceff --- /dev/null +++ b/packages/adapters/hermes/src/gateway/server/transport-security.ts @@ -0,0 +1,36 @@ +export const INSECURE_REMOTE_HTTP_ESCAPE_HATCH = "dangerouslyAllowInsecureRemoteHttp"; + +export function parseBooleanLike(value: unknown): boolean | null { + if (typeof value === "boolean") return value; + if (typeof value !== "string") return null; + const normalized = value.trim().toLowerCase(); + if (["1", "true", "yes", "on"].includes(normalized)) return true; + if (["0", "false", "no", "off"].includes(normalized)) return false; + return null; +} + +export function isLoopbackHostname(hostname: string): boolean { + const value = hostname.toLowerCase().replace(/^\[|\]$/g, ""); + return ( + value === "localhost" || + value === "::1" || + value === "0:0:0:0:0:0:0:1" || + value === "127.0.0.1" || + /^127(?:\.\d{1,3}){3}$/.test(value) + ); +} + +export function isRemotePlainHttp(url: URL): boolean { + return url.protocol === "http:" && !isLoopbackHostname(url.hostname); +} + +export function allowsInsecureRemoteHttp(config: Record<string, unknown>): boolean { + return parseBooleanLike(config[INSECURE_REMOTE_HTTP_ESCAPE_HATCH]) === true; +} + +export function remotePlainHttpDeniedMessage(hostname: string): string { + return ( + `Hermes gateway apiBaseUrl uses remote plain HTTP for "${hostname}". ` + + `Use HTTPS or set ${INSECURE_REMOTE_HTTP_ESCAPE_HATCH}=true only for unsafe local development.` + ); +} diff --git a/packages/adapters/hermes/src/gateway/shared/constants.ts b/packages/adapters/hermes/src/gateway/shared/constants.ts new file mode 100644 index 0000000000..de974c47a7 --- /dev/null +++ b/packages/adapters/hermes/src/gateway/shared/constants.ts @@ -0,0 +1,7 @@ +export const ADAPTER_TYPE = "hermes_gateway"; +export const ADAPTER_LABEL = "Hermes Gateway"; + +export const DEFAULT_TIMEOUT_SEC = 120; +export const DEFAULT_EVENT_RECONNECT_MS = 2_000; +export const DEFAULT_POLL_INTERVAL_MS = 1_000; +export const STOP_GRACE_MS = 10_000; diff --git a/packages/adapters/hermes/src/gateway/ui/index.ts b/packages/adapters/hermes/src/gateway/ui/index.ts new file mode 100644 index 0000000000..eae592b1e0 --- /dev/null +++ b/packages/adapters/hermes/src/gateway/ui/index.ts @@ -0,0 +1 @@ +export { parseHermesGatewayStdoutLine as parseStdoutLine } from "./parse-stdout.js"; diff --git a/packages/adapters/hermes/src/gateway/ui/parse-stdout.ts b/packages/adapters/hermes/src/gateway/ui/parse-stdout.ts new file mode 100644 index 0000000000..df9db752dd --- /dev/null +++ b/packages/adapters/hermes/src/gateway/ui/parse-stdout.ts @@ -0,0 +1,47 @@ +import type { TranscriptEntry } from "@paperclipai/adapter-utils"; + +function safeJsonParse(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function asRecord(value: unknown): Record<string, unknown> | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record<string, unknown>; +} + +function asString(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +export function parseHermesGatewayStdoutLine(line: string, ts: string): TranscriptEntry[] { + const trimmed = line.trim(); + if (!trimmed) return []; + + const eventMatch = trimmed.match(/^\[hermes-gateway:event\]\s+run=([^\s]+)\s+event=([^\s]+)\s+data=(.*)$/s); + if (eventMatch) { + const eventName = eventMatch[2]; + const data = asRecord(safeJsonParse(eventMatch[3])); + if (eventName === "message.delta") { + const delta = asString(data?.delta) || asString(data?.text_delta); + return delta ? [{ kind: "assistant", ts, text: delta, delta: true }] : []; + } + if (eventName === "run.failed" || eventName === "run.error") { + const message = asString(data?.error) || asString(data?.message) || "Hermes run failed"; + return [{ kind: "stderr", ts, text: message }]; + } + if (eventName === "reasoning.available") { + return [{ kind: "thinking", ts, text: "Hermes reasoning available" }]; + } + return [{ kind: "system", ts, text: `Hermes event: ${eventName}` }]; + } + + if (trimmed.startsWith("[hermes-gateway]")) { + return [{ kind: "system", ts, text: trimmed.replace(/^\[hermes-gateway\]\s*/, "") }]; + } + + return [{ kind: "stdout", ts, text: line }]; +} diff --git a/packages/adapters/hermes/src/index.test.ts b/packages/adapters/hermes/src/index.test.ts new file mode 100644 index 0000000000..03e8809262 --- /dev/null +++ b/packages/adapters/hermes/src/index.test.ts @@ -0,0 +1,63 @@ +import { expect, test } from "vitest"; + +import { + createHermesGatewayServerAdapter, + createHermesLocalServerAdapter, + createServerAdapter, + hermesGatewayType, +} from "./index.js"; +import { createServerAdapter as createGatewayServerAdapterFromSubpath } from "./gateway/index.js"; + +test("root package export exposes Paperclip external adapter entrypoint", () => { + const adapter = createServerAdapter(); + + expect(adapter.type).toBe("hermes_local"); + expect(typeof adapter.execute).toBe("function"); + expect(typeof adapter.testEnvironment).toBe("function"); + expect(typeof adapter.sessionCodec?.deserialize).toBe("function"); + expect(adapter.sessionManagement?.nativeContextManagement).toBe("confirmed"); + expect(adapter.supportsLocalAgentJwt).toBe(true); + expect(adapter.supportsInstructionsBundle).toBe(true); + expect(adapter.instructionsPathKey).toBe("instructionsFilePath"); + expect(adapter.getRuntimeCommandSpec?.({ command: "hermes-dev" })).toMatchObject({ + command: "hermes-dev", + detectCommand: "hermes-dev", + installCommand: null, + }); + expect(typeof adapter.detectModel).toBe("function"); + expect(typeof adapter.getConfigSchema).toBe("function"); +}); + +test("root package export keeps explicit local and gateway adapter factories", () => { + const localAdapter = createHermesLocalServerAdapter(); + const gatewayAdapter = createHermesGatewayServerAdapter(); + + expect(localAdapter.type).toBe("hermes_local"); + expect(gatewayAdapter.type).toBe("hermes_gateway"); + expect(hermesGatewayType).toBe("hermes_gateway"); + expect(gatewayAdapter.supportsLocalAgentJwt).toBe(false); + expect(gatewayAdapter.supportsInstructionsBundle).toBe(false); +}); + +test("gateway subpath export exposes the Hermes Gateway adapter entrypoint", () => { + const adapter = createGatewayServerAdapterFromSubpath(); + + expect(adapter.type).toBe("hermes_gateway"); + expect(typeof adapter.execute).toBe("function"); + expect(typeof adapter.testEnvironment).toBe("function"); + expect(typeof adapter.sessionCodec?.deserialize).toBe("function"); + expect(adapter.sessionManagement?.nativeContextManagement).toBe("confirmed"); + expect(typeof adapter.getConfigSchema).toBe("function"); +}); + +test("Hermes adapter exposes bundled Paperclip task bridge skill", async () => { + const adapter = createServerAdapter(); + const snapshot = await adapter.listSkills?.({ + adapterType: "hermes_local", + agentId: "11111111-1111-4111-8111-111111111111", + companyId: "22222222-2222-4222-8222-222222222222", + config: {}, + }); + + expect(snapshot?.entries.some((entry) => entry.runtimeName === "paperclip-task-bridge")).toBe(true); +}); diff --git a/packages/adapters/hermes/src/index.ts b/packages/adapters/hermes/src/index.ts new file mode 100644 index 0000000000..3494bf5970 --- /dev/null +++ b/packages/adapters/hermes/src/index.ts @@ -0,0 +1,176 @@ +/** + * Hermes Agent adapter for Paperclip. + * + * Runs Hermes Agent (https://github.com/NousResearch/hermes-agent) + * as a managed employee in a Paperclip company. Hermes Agent is a + * full-featured AI agent with 30+ native tools, persistent memory, + * skills, session persistence, and MCP support. + * + * @packageDocumentation + */ + +import type { + AdapterRuntimeCommandSpec, + AdapterSessionManagement, + ServerAdapterModule, +} from "@paperclipai/adapter-utils"; + +import { ADAPTER_TYPE, ADAPTER_LABEL } from "./shared/constants.js"; +import { + execute, + testEnvironment, + sessionCodec, + listSkills, + syncSkills, + detectModel, + getConfigSchema, +} from "./server/index.js"; +import { resolveHermesCommand } from "./server/execute.js"; + +export const type = ADAPTER_TYPE; +export const label = ADAPTER_LABEL; +export { + createServerAdapter as createHermesGatewayServerAdapter, + agentConfigurationDoc as hermesGatewayAgentConfigurationDoc, + label as hermesGatewayLabel, + models as hermesGatewayModels, + type as hermesGatewayType, +} from "./gateway/index.js"; + +/** + * Models available through Hermes Agent. + * + * Hermes supports any model via any provider. The Paperclip UI should + * prefer detectModel() plus manual entry over curated placeholder models, + * since Hermes availability depends on the user's local configuration. + */ +export const models: { id: string; label: string }[] = []; + +const sessionManagement: AdapterSessionManagement = { + supportsSessionResume: true, + nativeContextManagement: "confirmed", + defaultSessionCompaction: { + enabled: true, + maxSessionRuns: 0, + maxRawInputTokens: 0, + maxSessionAgeHours: 0, + }, +}; + +function getRuntimeCommandSpec(config: Record<string, unknown>): AdapterRuntimeCommandSpec { + const command = resolveHermesCommand(config); + return { + command, + detectCommand: command, + installCommand: null, + }; +} + +/** + * Documentation shown in the Paperclip UI when configuring a Hermes agent. + */ +export const agentConfigurationDoc = `# Hermes Agent Configuration + +Hermes Agent is a full-featured AI agent by Nous Research with 30+ native +tools, persistent memory, session persistence, skills, and MCP support. + +## Prerequisites + +- Python 3.10+ installed +- Hermes Agent installed: \`pip install hermes-agent\` +- At least one LLM API key configured in ~/.hermes/.env + +## Core Configuration + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| model | string | (Hermes configured default) | Optional explicit model in provider/model format. Leave blank to use Hermes's configured default model. | +| provider | string | (auto) | API provider: auto, openrouter, nous, openai-codex, zai, kimi-coding, minimax, minimax-cn. Usually not needed — Hermes auto-detects from model name. | +| timeoutSec | number | 300 | Execution timeout in seconds | +| graceSec | number | 10 | Grace period after SIGTERM before SIGKILL | + +## Tool Configuration + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| toolsets | string | (all) | Comma-separated toolsets to enable (e.g. "terminal,file,web") | + +## Session & Workspace + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| persistSession | boolean | true | Resume sessions across heartbeats | +| worktreeMode | boolean | false | Use git worktree for isolated changes | +| checkpoints | boolean | false | Enable filesystem checkpoints | + +## Advanced + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| hermesCommand | string | hermes | Path to hermes CLI binary | +| verbose | boolean | false | Enable verbose output | +| extraArgs | string[] | [] | Additional CLI arguments | +| env | object | {} | Extra environment variables | +| promptTemplate | string | (default) | Custom prompt template with {{variable}} placeholders | + +## Hermes-Originated Paperclip Tasks + +This adapter package also ships a Hermes-facing Paperclip task bridge skill: +\`paperclip-task-bridge\`. Use it when a user starts in Hermes and asks Hermes +to create, comment on, update, or list Paperclip tasks. + +Configure credentials through Hermes env/profile secrets, never in prompt text: + +- \`PAPERCLIP_API_URL\` - Paperclip base URL, with or without \`/api\` +- \`PAPERCLIP_BRIDGE_API_KEY\` - Paperclip agent API key created with \`scope.kind = "task_bridge"\` +- optional fallback \`PAPERCLIP_API_KEY\` - must still be a task_bridge key, never a normal claimed agent key +- optional \`PAPERCLIP_COMPANY_ID\`, \`PAPERCLIP_AGENT_ID\`, and \`PAPERCLIP_RUN_ID\` + +The bridge is separate from adapter execution: + +- \`hermes_local\` means Paperclip shells out to local \`hermes chat\`. +- \`hermes_gateway\` means Paperclip wakes remote Hermes through Hermes's API server. +- \`paperclip-task-bridge\` means Hermes calls Paperclip's REST API to manage tasks. + +Create task bridge keys with a parent issue or project boundary. Do not expose +normal claimed Paperclip agent API keys to internet-facing Hermes chat/webhook +task-bridge surfaces. + +## Available Template Variables + +- \`{{agentId}}\` — Paperclip agent ID +- \`{{agentName}}\` — Agent display name +- \`{{companyId}}\` — Paperclip company ID +- \`{{companyName}}\` — Company display name +- \`{{runId}}\` — Current heartbeat run ID +- \`{{taskId}}\` — Current task/issue ID (if assigned) +- \`{{taskTitle}}\` — Task title (if assigned) +- \`{{taskBody}}\` — Task description (if assigned) +- \`{{projectName}}\` — Project name (if scoped to a project) +`; + +/** + * External adapter plugin entrypoint expected by Paperclip's adapter manager. + */ +export function createServerAdapter(): ServerAdapterModule { + return { + type, + execute, + testEnvironment, + sessionCodec, + sessionManagement, + listSkills, + syncSkills, + models, + supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "instructionsFilePath", + requiresMaterializedRuntimeSkills: false, + getRuntimeCommandSpec, + agentConfigurationDoc, + detectModel, + getConfigSchema, + }; +} + +export { createServerAdapter as createHermesLocalServerAdapter }; diff --git a/packages/adapters/hermes/src/server/command-resolution.test.ts b/packages/adapters/hermes/src/server/command-resolution.test.ts new file mode 100644 index 0000000000..0462ad5a0c --- /dev/null +++ b/packages/adapters/hermes/src/server/command-resolution.test.ts @@ -0,0 +1,48 @@ +import os from "node:os"; +import path from "node:path"; +import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { expect, test } from "vitest"; + +import { HERMES_CLI } from "../shared/constants.js"; +import { resolveHermesCommand } from "./execute.js"; +import { testEnvironment } from "./test.js"; + +test("resolveHermesCommand prefers hermesCommand over command", () => { + expect(resolveHermesCommand({ hermesCommand: "hermes_maximus", command: "hermes_backup" })) + .toBe("hermes_maximus"); +}); + +test("resolveHermesCommand falls back to command before default hermes binary", () => { + expect(resolveHermesCommand({ command: "hermes_maximus" })).toBe("hermes_maximus"); + expect(resolveHermesCommand({})).toBe(HERMES_CLI); +}); + +test("testEnvironment accepts config.command when hermesCommand is absent", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "hermes-command-resolution-")); + const cliPath = path.join(tempDir, "fake-hermes"); + + try { + await writeFile( + cliPath, + "#!/bin/sh\necho fake-hermes 1.2.3\n", + "utf8", + ); + await chmod(cliPath, 0o755); + + const result = await testEnvironment({ + companyId: "company-test", + adapterType: "hermes_local", + config: { + command: cliPath, + }, + }); + + expect(result.status).not.toBe("fail"); + expect(result.checks.some((check) => check.code === "hermes_cli_not_found")).toBe(false); + expect(result.checks.some( + (check) => check.code === "hermes_version" && check.message.includes("fake-hermes 1.2.3"), + )).toBe(true); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); diff --git a/packages/adapters/hermes/src/server/config-schema.ts b/packages/adapters/hermes/src/server/config-schema.ts new file mode 100644 index 0000000000..25d3a5bd69 --- /dev/null +++ b/packages/adapters/hermes/src/server/config-schema.ts @@ -0,0 +1,108 @@ +import type { AdapterConfigSchema } from "@paperclipai/adapter-utils"; + +import { + DEFAULT_GRACE_SEC, + DEFAULT_TIMEOUT_SEC, + VALID_PROVIDERS, +} from "../shared/constants.js"; + +function providerLabel(provider: string): string { + if (provider === "auto") return "Auto"; + if (provider === "openai-codex") return "OpenAI Codex"; + if (provider === "kimi-coding") return "Kimi Coding"; + if (provider === "minimax-cn") return "MiniMax China"; + return provider + .split("-") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +export function getConfigSchema(): AdapterConfigSchema { + return { + fields: [ + { + key: "provider", + label: "Provider", + type: "select", + default: "auto", + options: VALID_PROVIDERS.map((provider) => ({ + value: provider, + label: providerLabel(provider), + })), + hint: "Usually auto. Set this only when Hermes cannot infer the provider from the model or ~/.hermes/config.yaml.", + }, + { + key: "timeoutSec", + label: "Timeout seconds", + type: "number", + default: DEFAULT_TIMEOUT_SEC, + }, + { + key: "graceSec", + label: "Grace seconds", + type: "number", + default: DEFAULT_GRACE_SEC, + hint: "Seconds to wait after SIGTERM before killing the Hermes process.", + }, + { + key: "maxTurnsPerRun", + label: "Max turns per run", + type: "number", + hint: "Optional Hermes --max-turns limit for tool-calling iterations.", + }, + { + key: "toolsets", + label: "Toolsets", + type: "text", + hint: "Optional comma-separated Hermes toolsets, such as terminal,file,web.", + }, + { + key: "persistSession", + label: "Persist session", + type: "toggle", + default: true, + hint: "Resume Hermes sessions across Paperclip heartbeats.", + }, + { + key: "worktreeMode", + label: "Hermes worktree mode", + type: "toggle", + default: false, + hint: "Pass Hermes --worktree.", + }, + { + key: "checkpoints", + label: "Checkpoints", + type: "toggle", + default: false, + hint: "Pass Hermes --checkpoints.", + }, + { + key: "quiet", + label: "Quiet output", + type: "toggle", + default: true, + hint: "Pass Hermes --quiet for cleaner Paperclip run transcripts.", + }, + { + key: "verbose", + label: "Verbose output", + type: "toggle", + default: false, + hint: "Pass Hermes --verbose.", + }, + { + key: "paperclipApiUrl", + label: "Paperclip API URL", + type: "text", + hint: "Optional API base override. Defaults to PAPERCLIP_API_URL.", + }, + { + key: "promptTemplate", + label: "Prompt template", + type: "textarea", + hint: "Optional custom prompt template with {{variable}} placeholders.", + }, + ], + }; +} diff --git a/packages/adapters/hermes/src/server/detect-model.test.ts b/packages/adapters/hermes/src/server/detect-model.test.ts new file mode 100644 index 0000000000..05ee2d8fc3 --- /dev/null +++ b/packages/adapters/hermes/src/server/detect-model.test.ts @@ -0,0 +1,186 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, test } from "vitest"; + +import { parseModelFromConfig, resolveProvider } from "./detect-model.js"; +import { testEnvironment } from "./test.js"; + +const providerEnvKeys = [ + "ANTHROPIC_API_KEY", + "OPENROUTER_API_KEY", + "OPENAI_API_KEY", + "ZAI_API_KEY", + "KIMI_API_KEY", + "MINIMAX_API_KEY", +]; + +const previousEnv = { + HOME: process.env.HOME, + USERPROFILE: process.env.USERPROFILE, + HOMEDRIVE: process.env.HOMEDRIVE, + HOMEPATH: process.env.HOMEPATH, + ...Object.fromEntries(providerEnvKeys.map((key) => [key, process.env[key]])), +}; + +afterEach(async () => { + for (const [key, value] of Object.entries(previousEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +}); + +test("parseModelFromConfig tracks api_key presence without exposing the raw secret", () => { + const parsed = parseModelFromConfig([ + "model:", + " default: oca/gpt-5.4", + " provider: custom", + " base_url: https://example.invalid/litellm", + " api_key: super-secret-value", + "", + ].join("\n")); + + expect(parsed).toBeTruthy(); + expect(parsed?.hasApiKey).toBe(true); + expect(Object.hasOwn(parsed ?? {}, "apiKey")).toBe(false); +}); + +test("resolveProvider does not fall through to model inference when Hermes config provider is unsupported but matches the requested model", () => { + expect(resolveProvider({ + explicitProvider: undefined, + detectedProvider: "custom", + detectedModel: "oca/gpt-5.4", + detectedBaseUrl: "https://example.invalid/litellm", + detectedHasApiKey: true, + model: "oca/gpt-5.4", + })).toEqual({ + provider: "auto", + resolvedFrom: "hermesConfigUnsupported:custom", + }); +}); + +test("resolveProvider also defers to Hermes runtime when the matching config omits provider but includes runtime signals", () => { + expect(resolveProvider({ + explicitProvider: undefined, + detectedProvider: "", + detectedModel: "oca/gpt-5.4", + detectedBaseUrl: "https://example.invalid/litellm", + detectedHasApiKey: true, + model: "oca/gpt-5.4", + })).toEqual({ + provider: "auto", + resolvedFrom: "hermesConfigRuntime", + }); +}); + +test("resolveProvider still infers from the requested model when Hermes config is for a different model", () => { + expect(resolveProvider({ + explicitProvider: undefined, + detectedProvider: "custom", + detectedModel: "oca/gpt-5.4", + detectedBaseUrl: "https://example.invalid/litellm", + detectedHasApiKey: true, + model: "claude-sonnet-4", + })).toEqual({ + provider: "anthropic", + resolvedFrom: "modelInference", + }); +}); + +async function withHermesHomeConfig( + configLines: string[], + fn: () => Promise<void>, +) { + const tempHome = await mkdtemp(join(tmpdir(), "hermes-paperclip-adapter-")); + const hermesDir = join(tempHome, ".hermes"); + const configPath = join(hermesDir, "config.yaml"); + + await mkdir(hermesDir, { recursive: true }); + await writeFile(configPath, `${configLines.join("\n")}\n`, "utf8"); + process.env.HOME = tempHome; + process.env.USERPROFILE = tempHome; + delete process.env.HOMEDRIVE; + delete process.env.HOMEPATH; + for (const key of providerEnvKeys) { + delete process.env[key]; + } + + try { + await fn(); + } finally { + await rm(tempHome, { recursive: true, force: true }); + } +} + +test("testEnvironment does not warn about missing API keys when Hermes config provides a supported provider api_key", async () => { + await withHermesHomeConfig([ + "model:", + " default: openrouter/gpt-4.1-mini", + " provider: openrouter", + " api_key: test-secret", + ], async () => { + const result = await testEnvironment({ + companyId: "company-test", + adapterType: "hermes_local", + config: { + hermesCommand: "python3", + model: "openrouter/gpt-4.1-mini", + }, + }); + + const codes = result.checks.map((check) => check.code); + + expect(codes.includes("hermes_no_api_keys")).toBe(false); + expect(result.status).toBe("pass"); + }); +}); + +test("testEnvironment describes provider-omitted runtime config without inventing provider auto", async () => { + await withHermesHomeConfig([ + "model:", + " default: oca/gpt-5.4", + " base_url: https://example.invalid/litellm", + " api_key: test-secret", + ], async () => { + const result = await testEnvironment({ + companyId: "company-test", + adapterType: "hermes_local", + config: { + hermesCommand: "python3", + model: "oca/gpt-5.4", + }, + }); + + const apiKeyCheck = result.checks.find((check) => check.code === "hermes_api_key_in_config"); + expect(apiKeyCheck).toBeTruthy(); + expect(apiKeyCheck?.message).toMatch(/without an explicit provider/i); + expect(apiKeyCheck?.message).not.toMatch(/provider "auto"/i); + }); +}); + +test("testEnvironment does not warn about missing API keys when Hermes config provides a custom provider base_url and api_key", async () => { + await withHermesHomeConfig([ + "model:", + " default: oca/gpt-5.4", + " provider: custom", + " base_url: https://example.invalid/litellm", + " api_key: test-secret", + ], async () => { + const result = await testEnvironment({ + companyId: "company-test", + adapterType: "hermes_local", + config: { + hermesCommand: "python3", + model: "oca/gpt-5.4", + }, + }); + + const codes = result.checks.map((check) => check.code); + + expect(codes.includes("hermes_no_api_keys")).toBe(false); + expect(result.status).toBe("pass"); + }); +}); diff --git a/packages/adapters/hermes/src/server/detect-model.ts b/packages/adapters/hermes/src/server/detect-model.ts new file mode 100644 index 0000000000..fc92ca4601 --- /dev/null +++ b/packages/adapters/hermes/src/server/detect-model.ts @@ -0,0 +1,217 @@ +/** + * Detect the current model and provider from the user's Hermes config. + * + * Reads ~/.hermes/config.yaml and extracts the default model, + * provider, base_url, api_key presence, and api_mode settings. + * + * Also provides provider resolution logic that merges explicit config, + * Hermes config detection, and model-name prefix inference. + */ + +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import { MODEL_PREFIX_PROVIDER_HINTS, VALID_PROVIDERS } from "../shared/constants.js"; + +export interface DetectedModel { + /** Model name from config (e.g. "gpt-5.4", "anthropic/claude-sonnet-4") */ + model: string; + /** Provider name from config (e.g. "copilot", "zai"). May be empty. */ + provider: string; + /** Base URL override from config (e.g. "https://api.githubcopilot.com"). May be empty. */ + baseUrl: string; + /** Whether Hermes config includes a non-empty API key. */ + hasApiKey: boolean; + /** API mode from config (e.g. "chat_completions", "codex_responses"). May be empty. */ + apiMode: string; + /** Where the detection came from */ + source: "config"; +} + +/** + * Read the Hermes config file and extract the default model config. + */ +export async function detectModel( + configPath?: string, +): Promise<DetectedModel | null> { + const filePath = configPath ?? join(homedir(), ".hermes", "config.yaml"); + + let content: string; + try { + content = await readFile(filePath, "utf-8"); + } catch { + return null; + } + + return parseModelFromConfig(content); +} + +/** + * Parse model.default, model.provider, model.base_url, model.api_key, and model.api_mode + * from raw YAML content. Uses simple regex parsing to avoid a YAML dependency. + */ +export function parseModelFromConfig(content: string): DetectedModel | null { + const lines = content.split("\n"); + let model = ""; + let provider = ""; + let baseUrl = ""; + let hasApiKey = false; + let apiMode = ""; + let inModelSection = false; + let modelSectionIndent = 0; + + for (const line of lines) { + const trimmed = line.trimEnd(); + const indent = line.length - line.trimStart().length; + + // Track model: section (indent 0) + if (/^model:\s*$/.test(trimmed) && indent === 0) { + inModelSection = true; + modelSectionIndent = 0; + continue; + } + + // We left the model section if indent drops back to the section level or below + if (inModelSection && indent <= modelSectionIndent && trimmed && !trimmed.startsWith("#")) { + inModelSection = false; + } + + if (inModelSection) { + const match = trimmed.match(/^\s*(\w+)\s*:\s*(.+)$/); + if (match) { + const key = match[1]; + const val = match[2].trim().replace(/#.*$/, "").trim().replace(/^['"]|['"]$/g, ""); + if (key === "default") model = val; + if (key === "provider") provider = val; + if (key === "base_url") baseUrl = val; + if (key === "api_key") hasApiKey = val.length > 0; + if (key === "api_mode") apiMode = val; + } + } + } + + if (!model) return null; + + return { model, provider, baseUrl, hasApiKey, apiMode, source: "config" }; +} + +/** + * Infer a provider from the model name using prefix-based hints. + * + * For example: + * "gpt-5.4" → "copilot" + * "claude-sonnet-4" → "anthropic" + * "glm-5-turbo" → "zai" + * + * Returns undefined if no hint matches (caller should fall back to "auto"). + */ +export function inferProviderFromModel(model: string): string | undefined { + const lower = model.toLowerCase(); + + // Strip provider/ prefix if present (e.g. "anthropic/claude-sonnet-4") + const bareName = lower.includes("/") ? lower.split("/").pop()! : lower; + + for (const [prefix, hint] of MODEL_PREFIX_PROVIDER_HINTS) { + if (bareName.startsWith(prefix)) { + return hint; + } + } + + return undefined; +} + +/** + * Resolve the correct provider for a model, using a priority chain: + * + * 1. Explicit provider from adapterConfig (highest priority — user override) + * 2. Provider from Hermes config file — ONLY if the config model matches + * the requested model (otherwise the config provider is for a different model) + * 3. If Hermes config matches the requested model but uses runtime settings that + * the adapter cannot represent directly, return "auto" and let Hermes resolve it itself + * 4. Provider inferred from model name prefix + * 5. "auto" (let Hermes figure it out — lowest priority) + * + * Always returns a valid provider string. + * The `resolvedFrom` field indicates which source was used, useful for logging. + */ +export function resolveProvider(options: { + /** Explicit provider from adapterConfig (user override) */ + explicitProvider?: string | null; + /** Provider detected from Hermes config file */ + detectedProvider?: string; + /** Model name from Hermes config file (to check consistency) */ + detectedModel?: string; + /** Base URL detected from Hermes config file */ + detectedBaseUrl?: string; + /** Whether Hermes config includes a non-empty API key */ + detectedHasApiKey?: boolean; + /** API mode detected from Hermes config file */ + detectedApiMode?: string; + /** Model name to infer from if no explicit/detected provider */ + model?: string; +}): { provider: string; resolvedFrom: string } { + const { + explicitProvider, + detectedProvider, + detectedModel, + detectedBaseUrl, + detectedHasApiKey, + detectedApiMode, + model, + } = options; + + // 1. Explicit provider from adapterConfig — user override, always wins + if (explicitProvider && (VALID_PROVIDERS as readonly string[]).includes(explicitProvider)) { + return { provider: explicitProvider, resolvedFrom: "adapterConfig" }; + } + + const supportedProviders = VALID_PROVIDERS as readonly string[]; + const configMatchesRequestedModel = + !!detectedModel && + !!model && + detectedModel.toLowerCase() === model.toLowerCase(); + + // 2. Provider from Hermes config file — but ONLY if the config model matches + // the requested model. Otherwise the config provider is for a different model + // and would cause exactly the kind of routing bug we're fixing. + if ( + configMatchesRequestedModel && + !!detectedProvider && + supportedProviders.includes(detectedProvider) + ) { + return { provider: detectedProvider, resolvedFrom: "hermesConfig" }; + } + + const hasRuntimeSignals = !!detectedBaseUrl || !!detectedHasApiKey || !!detectedApiMode; + + // 3a. Matching Hermes config with an unsupported provider (for example "custom") + // should not fall through to model-name inference, because that can route to + // the wrong provider entirely. Defer back to Hermes's own runtime resolution. + if (configMatchesRequestedModel && !!detectedProvider && !supportedProviders.includes(detectedProvider)) { + return { + provider: "auto", + resolvedFrom: `hermesConfigUnsupported:${detectedProvider}`, + }; + } + + // 3b. Matching Hermes config may omit provider entirely while still specifying + // enough runtime information (base_url, api_key, api_mode) for Hermes itself. + // In that case, also defer to Hermes instead of doing a wrong prefix inference. + if (configMatchesRequestedModel && !detectedProvider && hasRuntimeSignals) { + return { + provider: "auto", + resolvedFrom: "hermesConfigRuntime", + }; + } + + // 4. Infer from model name prefix + if (model) { + const inferred = inferProviderFromModel(model); + if (inferred) { + return { provider: inferred, resolvedFrom: "modelInference" }; + } + } + + // 5. Let Hermes auto-detect + return { provider: "auto", resolvedFrom: "auto" }; +} diff --git a/packages/adapters/hermes/src/server/execute.ts b/packages/adapters/hermes/src/server/execute.ts new file mode 100644 index 0000000000..ae85c29857 --- /dev/null +++ b/packages/adapters/hermes/src/server/execute.ts @@ -0,0 +1,584 @@ +/** + * Server-side execution logic for the Hermes Agent adapter. + * + * Spawns `hermes chat -q "..." -Q` as a child process, streams output, + * and returns structured results to Paperclip. + * + * Verified CLI flags (hermes chat): + * -q/--query single query (non-interactive) + * -Q/--quiet quiet mode (no banner/spinner, only response + session_id) + * -m/--model model name (e.g. anthropic/claude-sonnet-4) + * -t/--toolsets comma-separated toolsets to enable + * --provider inference provider (auto, openrouter, nous, etc.) + * -r/--resume resume session by ID + * -w/--worktree isolated git worktree + * -v/--verbose verbose output + * --checkpoints filesystem checkpoints + * --yolo bypass dangerous-command approval prompts (agents have no TTY) + * --source session source tag for filtering + */ + +import fs from "node:fs/promises"; +import path from "node:path"; + +import type { + AdapterExecutionContext, + AdapterExecutionResult, + UsageSummary, +} from "@paperclipai/adapter-utils"; + +import { + runChildProcess, + buildPaperclipEnv, + renderTemplate, + ensureAbsoluteDirectory, + DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, + joinPromptSections, + renderPaperclipWakePrompt, + stringifyPaperclipWakePayload, +} from "@paperclipai/adapter-utils/server-utils"; + +import { + HERMES_CLI, + DEFAULT_TIMEOUT_SEC, + DEFAULT_GRACE_SEC, + DEFAULT_MODEL, + VALID_PROVIDERS, +} from "../shared/constants.js"; + +import { + detectModel, + resolveProvider, +} from "./detect-model.js"; + +// --------------------------------------------------------------------------- +// Config helpers +// --------------------------------------------------------------------------- + +function cfgString(v: unknown): string | undefined { + return typeof v === "string" && v.length > 0 ? v : undefined; +} +function cfgNumber(v: unknown): number | undefined { + return typeof v === "number" ? v : undefined; +} +function cfgBoolean(v: unknown): boolean | undefined { + return typeof v === "boolean" ? v : undefined; +} +function cfgStringArray(v: unknown): string[] | undefined { + return Array.isArray(v) && v.every((i) => typeof i === "string") + ? (v as string[]) + : undefined; +} + +export function resolveHermesCommand(config: Record<string, unknown>): string { + return cfgString(config.hermesCommand) || cfgString(config.command) || HERMES_CLI; +} + +// --------------------------------------------------------------------------- +// Wake-up prompt builder +// --------------------------------------------------------------------------- + +const HERMES_DEFAULT_PROMPT_TEMPLATE = [ + 'You are "{{agent.name}}", an AI agent employee in a Paperclip-managed company.', + "", + "Paperclip runtime identity:", + "- Agent ID: {{agent.id}}", + "- Company ID: {{agent.companyId}}", + "- Run ID: {{run.id}}", + "- API base: {{paperclipApiUrl}}", + "", + "Paperclip API guidance:", + "- Use `curl` from the terminal for Paperclip API calls; browser/web extraction tools may not reach localhost.", + "- Use `$PAPERCLIP_API_URL`, `$PAPERCLIP_API_KEY`, and `$PAPERCLIP_RUN_ID`; do not hard-code local ports or copy secrets into comments.", + "- Displayed command logs may redact secrets; rely on environment variables instead of printed token values.", + "- Include `-H \"Authorization: Bearer $PAPERCLIP_API_KEY\"` on API requests.", + "- Include `-H \"X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID\"` on mutating issue requests.", + "- For multiline comments or status updates, preserve newlines with `jq --arg` or a heredoc-fed helper rather than hand-escaping JSON.", + "", + "Safe multiline update pattern:", + "```bash", + "api=\"${PAPERCLIP_API_URL%/}\"", + "case \"$api\" in */api) ;; *) api=\"$api/api\" ;; esac", + "", + "body=$(cat <<'MD'", + "Summary line", + "", + "- Detail one", + "- Detail two", + "MD", + ")", + "jq -n --arg status done --arg comment \"$body\" '{status:$status, comment:$comment}' | \\", + " curl -sS -X PATCH \"$api/issues/{{context.issueId}}\" \\", + " -H \"Authorization: Bearer $PAPERCLIP_API_KEY\" \\", + " -H \"X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID\" \\", + " -H \"Content-Type: application/json\" \\", + " --data-binary @-", + "```", + "", + DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, +].join("\n"); + +function renderConditionalSections(template: string, vars: Record<string, unknown>): string { + const isTruthy = (key: string) => { + if (key === "noTask") return !vars.taskId; + const value = vars[key]; + if (Array.isArray(value)) return value.length > 0; + return Boolean(value); + }; + return template.replace( + /\{\{#([a-zA-Z0-9_.-]+)\}\}([\s\S]*?)\{\{\/\1\}\}/g, + (_match, key: string, body: string) => (isTruthy(key) ? body : ""), + ); +} + +export function buildPrompt( + ctx: AdapterExecutionContext, + config: Record<string, unknown>, + options: { resumedSession?: boolean } = {}, +): string { + const template = cfgString(config.promptTemplate) || HERMES_DEFAULT_PROMPT_TEMPLATE; + + const context = (ctx as any).context || {}; + const taskId = cfgString(context.taskId) || cfgString(context.issueId) || cfgString(ctx.config?.taskId); + const taskTitle = cfgString(context.taskTitle) || cfgString(ctx.config?.taskTitle) || ""; + const taskBody = cfgString(context.taskBody) || cfgString(ctx.config?.taskBody) || ""; + const commentId = cfgString(context.commentId) || cfgString(context.wakeCommentId) || cfgString(ctx.config?.commentId) || ""; + const wakeReason = cfgString(context.wakeReason) || cfgString(ctx.config?.wakeReason) || ""; + const agentName = ctx.agent?.name || "Hermes Agent"; + const companyName = cfgString(context.companyName) || cfgString(ctx.config?.companyName) || ""; + const projectName = cfgString(context.projectName) || cfgString(ctx.config?.projectName) || ""; + + // Build API URL — ensure it has the /api path + let paperclipApiUrl = + cfgString(config.paperclipApiUrl) || + process.env.PAPERCLIP_API_URL || + "http://127.0.0.1:3100/api"; + // Ensure /api suffix + if (!paperclipApiUrl.endsWith("/api")) { + paperclipApiUrl = paperclipApiUrl.replace(/\/+$/, "") + "/api"; + } + + const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { + resumedSession: options.resumedSession === true, + }); + const paperclipTaskMarkdown = cfgString(context.paperclipTaskMarkdown)?.trim() || ""; + const sessionHandoffMarkdown = cfgString(context.paperclipSessionHandoffMarkdown)?.trim() || ""; + const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake) || ""; + + const vars: Record<string, unknown> = { + agentId: ctx.agent?.id || "", + agentName, + companyId: ctx.agent?.companyId || "", + companyName, + runId: ctx.runId || "", + agent: ctx.agent || {}, + company: { id: ctx.agent?.companyId || "", name: companyName }, + run: { id: ctx.runId || "", source: "on_demand" }, + context, + taskId: taskId || "", + taskTitle, + taskBody, + commentId, + wakeReason, + projectName, + paperclipApiUrl, + paperclipWakePrompt: wakePrompt, + paperclipTaskMarkdown, + taskContext: paperclipTaskMarkdown, + paperclipWakeJson: wakePayloadJson, + wakePayloadJson, + paperclipApiKeyEnv: "PAPERCLIP_API_KEY", + paperclipRunIdEnv: "PAPERCLIP_RUN_ID", + }; + + const rendered = renderTemplate(renderConditionalSections(template, vars), vars); + return joinPromptSections([ + wakePrompt, + sessionHandoffMarkdown, + paperclipTaskMarkdown, + rendered, + ]); +} + +// --------------------------------------------------------------------------- +// Output parsing +// --------------------------------------------------------------------------- + +/** Regex to extract session ID from Hermes quiet-mode output: "session_id: <id>" */ +const SESSION_ID_REGEX = /^session_id:\s*(\S+)/m; + +/** Regex for legacy session output format */ +const SESSION_ID_REGEX_LEGACY = /session[_ ](?:id|saved)[:\s]+([a-zA-Z0-9_-]+)/i; + +/** Regex to extract token usage from Hermes output. */ +const TOKEN_USAGE_REGEX = + /tokens?[:\s]+(\d+)\s*(?:input|in)\b.*?(\d+)\s*(?:output|out)\b/i; + +/** Regex to extract cost from Hermes output. */ +const COST_REGEX = /(?:cost|spent)[:\s]*\$?([\d.]+)/i; + +interface ParsedOutput { + sessionId?: string; + response?: string; + usage?: UsageSummary; + costUsd?: number; + errorMessage?: string; +} + +// --------------------------------------------------------------------------- +// Response cleaning +// --------------------------------------------------------------------------- + +/** Strip noise lines from a Hermes response (tool output, system messages, etc.) */ +function cleanResponse(raw: string): string { + return raw + .split("\n") + .filter((line) => { + const t = line.trim(); + if (!t) return true; // keep blank lines for paragraph separation + if (t.startsWith("[tool]") || t.startsWith("[hermes]") || t.startsWith("[paperclip]")) return false; + if (t.startsWith("session_id:")) return false; + if (/^\[\d{4}-\d{2}-\d{2}T/.test(t)) return false; + if (/^\[done\]\s*┊/.test(t)) return false; + if (/^┊\s*[\p{Emoji_Presentation}]/u.test(t) && !/^┊\s*💬/.test(t)) return false; + if (/^\p{Emoji_Presentation}\s*(Completed|Running|Error)?\s*$/u.test(t)) return false; + return true; + }) + .map((line) => { + let t = line.replace(/^[\s]*┊\s*💬\s*/, "").trim(); + t = t.replace(/^\[done\]\s*/, "").trim(); + return t; + }) + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +// --------------------------------------------------------------------------- +// Output parsing +// --------------------------------------------------------------------------- + +function parseHermesOutput(stdout: string, stderr: string): ParsedOutput { + const combined = stdout + "\n" + stderr; + const result: ParsedOutput = {}; + + // In quiet mode, Hermes outputs: + // <response text> + // + // session_id: <id> + const sessionMatch = stdout.match(SESSION_ID_REGEX); + if (sessionMatch?.[1]) { + result.sessionId = sessionMatch?.[1] ?? null; + // The response is everything before the session_id line + const sessionLineIdx = stdout.lastIndexOf("\nsession_id:"); + if (sessionLineIdx > 0) { + result.response = cleanResponse(stdout.slice(0, sessionLineIdx)); + } + } else { + // Legacy format (non-quiet mode) + const legacyMatch = combined.match(SESSION_ID_REGEX_LEGACY); + if (legacyMatch?.[1]) { + result.sessionId = legacyMatch?.[1] ?? null; + } + // In non-quiet mode, extract clean response from stdout by + // filtering out tool lines, system messages, and noise + const cleaned = cleanResponse(stdout); + if (cleaned.length > 0) { + result.response = cleaned; + } + } + + // Extract token usage + const usageMatch = combined.match(TOKEN_USAGE_REGEX); + if (usageMatch) { + result.usage = { + inputTokens: parseInt(usageMatch[1], 10) || 0, + outputTokens: parseInt(usageMatch[2], 10) || 0, + }; + } + + // Extract cost + const costMatch = combined.match(COST_REGEX); + if (costMatch?.[1]) { + result.costUsd = parseFloat(costMatch[1]); + } + + // Check for error patterns in stderr + if (stderr.trim()) { + const errorLines = stderr + .split("\n") + .filter((line) => /error|exception|traceback|failed/i.test(line)) + .filter((line) => !/INFO|DEBUG|warn/i.test(line)); // skip log-level noise + if (errorLines.length > 0) { + result.errorMessage = errorLines.slice(0, 5).join("\n"); + } + } + + return result; +} + +// --------------------------------------------------------------------------- +// Main execute +// --------------------------------------------------------------------------- + +export async function execute( + ctx: AdapterExecutionContext, +): Promise<AdapterExecutionResult> { + const config = (ctx.config ?? ctx.agent?.adapterConfig ?? {}) as Record<string, unknown>; + + // ── Resolve configuration ────────────────────────────────────────────── + const hermesCmd = resolveHermesCommand(config); + const model = cfgString(config.model) || DEFAULT_MODEL; + const timeoutSec = cfgNumber(config.timeoutSec) || DEFAULT_TIMEOUT_SEC; + const graceSec = cfgNumber(config.graceSec) || DEFAULT_GRACE_SEC; + const maxTurns = cfgNumber(config.maxTurnsPerRun); + const toolsets = cfgString(config.toolsets) || cfgStringArray(config.enabledToolsets)?.join(","); + const extraArgs = cfgStringArray(config.extraArgs); + const persistSession = cfgBoolean(config.persistSession) !== false; + const worktreeMode = cfgBoolean(config.worktreeMode) === true; + const checkpoints = cfgBoolean(config.checkpoints) === true; + const prevSessionId = cfgString( + (ctx.runtime?.sessionParams as Record<string, unknown> | null)?.sessionId, + ); + + // ── Resolve provider (defense in depth) ──────────────────────────────── + // Priority chain: + // 1. Explicit provider in adapterConfig (user override) + // 2. Provider from ~/.hermes/config.yaml (detected at runtime) + // 3. Provider inferred from model name prefix + // 4. "auto" (let Hermes decide) + // + // This ensures that even if the agent was created before provider tracking + // was added, or if the model was changed without updating provider, the + // correct provider is still used. + let detectedConfig: Awaited<ReturnType<typeof detectModel>> | null = null; + const explicitProvider = cfgString(config.provider); + + if (!explicitProvider) { + try { + detectedConfig = await detectModel(); + } catch { + // Non-fatal — detection failure shouldn't block execution + } + } + + const { provider: resolvedProvider, resolvedFrom } = resolveProvider({ + explicitProvider, + detectedProvider: detectedConfig?.provider, + detectedModel: detectedConfig?.model, + detectedBaseUrl: detectedConfig?.baseUrl, + detectedHasApiKey: detectedConfig?.hasApiKey, + detectedApiMode: detectedConfig?.apiMode, + model, + }); + + // ── Load agent instructions file (Paperclip instruction bundles) ────── + // Paperclip can materialize managed instructions into instructionsFilePath; + // when present, inject that bundle into the Hermes prompt. + const instructionsFilePath = cfgString(config.instructionsFilePath); + let agentInstructions = ""; + if (instructionsFilePath) { + try { + agentInstructions = await fs.readFile(instructionsFilePath, "utf-8"); + const loadedInstructionsLength = agentInstructions.length; + const instructionsFileDir = path.dirname(instructionsFilePath); + agentInstructions += `\nThe above agent instructions were loaded from ${instructionsFilePath}. Resolve any relative file references from ${instructionsFileDir}/.`; + await ctx.onLog( + "stdout", + `[hermes] Loaded agent instructions from ${instructionsFilePath} (${loadedInstructionsLength} chars)\n`, + ); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + // Non-fatal: log to stdout with an explicit "Warning:" prefix so the + // Paperclip UI doesn't render this as a red error (stderr output is + // surfaced as an error signal even when execution continues). + await ctx.onLog( + "stdout", + `[hermes] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`, + ); + } + } + + // ── Build prompt ─────────────────────────────────────────────────────── + let prompt = buildPrompt(ctx, config, { resumedSession: Boolean(prevSessionId) }); + if (agentInstructions) { + prompt = agentInstructions + "\n\n---\n\n" + prompt; + } + + // ── Build command args ───────────────────────────────────────────────── + // Use -Q (quiet) to get clean output: just response + session_id line + const useQuiet = cfgBoolean(config.quiet) === true; // default false + const args: string[] = ["chat", "-q", prompt]; + if (useQuiet) args.push("-Q"); + + if (model) { + args.push("-m", model); + } + + // Always pass --provider when we have a resolved one (not "auto"). + // "auto" means Hermes will decide on its own — no need to pass it. + if (resolvedProvider !== "auto") { + args.push("--provider", resolvedProvider); + } + + if (toolsets) { + args.push("-t", toolsets); + } + + if (maxTurns && maxTurns > 0) { + args.push("--max-turns", String(maxTurns)); + } + + if (worktreeMode) args.push("-w"); + if (checkpoints) args.push("--checkpoints"); + if (cfgBoolean(config.verbose) === true) args.push("-v"); + + // Tag sessions as "tool" source so they don't clutter the user's session history. + // Requires hermes-agent >= PR #3255 (feat/session-source-tag). + args.push("--source", "tool"); + + // Bypass Hermes dangerous-command approval prompts. + // Paperclip agents run as non-interactive subprocesses with no TTY, + // so approval prompts would always timeout and deny legitimate commands + // (curl, python3 -c, etc.). Agents operate in a sandbox — the approval + // system is designed for human-attended interactive sessions. + args.push("--yolo"); + + if (persistSession && prevSessionId) { + args.push("--resume", prevSessionId); + } + + if (extraArgs?.length) { + args.push(...extraArgs); + } + + // ── Build environment ────────────────────────────────────────────────── + const userEnv = config.env as Record<string, string> | undefined; + const env: Record<string, string> = { + ...(process.env as Record<string, string>), + ...(userEnv && typeof userEnv === "object" ? userEnv : {}), + ...buildPaperclipEnv(ctx.agent), + }; + + if (ctx.runId) env.PAPERCLIP_RUN_ID = ctx.runId; + + // BUG FIX: Inject authToken as PAPERCLIP_API_KEY (matches adapter-claude-local behavior) + if ((ctx as any).authToken) env.PAPERCLIP_API_KEY = (ctx as any).authToken; + + // BUG FIX: Read task context from ctx.context (wake context), not ctx.config (adapter config) + const ctxContext = (ctx as any).context || {}; + const envTaskId = cfgString(ctxContext.taskId) || cfgString(ctxContext.issueId) || cfgString(ctx.config?.taskId); + if (envTaskId) env.PAPERCLIP_TASK_ID = envTaskId; + const envWakeReason = cfgString(ctxContext.wakeReason) || cfgString(ctx.config?.wakeReason); + if (envWakeReason) env.PAPERCLIP_WAKE_REASON = envWakeReason; + const envCommentId = cfgString(ctxContext.commentId) || cfgString(ctxContext.wakeCommentId) || cfgString(ctx.config?.commentId); + if (envCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = envCommentId; + const wakePayloadJson = stringifyPaperclipWakePayload(ctxContext.paperclipWake); + if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; + + // ── Resolve working directory ────────────────────────────────────────── + const cwd = + cfgString(config.cwd) || cfgString(ctx.config?.workspaceDir) || "."; + try { + await ensureAbsoluteDirectory(cwd); + } catch { + // Non-fatal + } + + // ── Log start ────────────────────────────────────────────────────────── + await ctx.onLog( + "stdout", + `[hermes] Starting Hermes Agent (model=${model}, provider=${resolvedProvider} [${resolvedFrom}], timeout=${timeoutSec}s${maxTurns ? `, max_turns=${maxTurns}` : ""})\n`, + ); + if (prevSessionId) { + await ctx.onLog( + "stdout", + `[hermes] Resuming session: ${prevSessionId}\n`, + ); + } + + // ── Execute ──────────────────────────────────────────────────────────── + // Hermes writes non-error noise to stderr (MCP init, INFO logs, etc). + // Paperclip renders all stderr as red/error in the UI. + // Wrap onLog to reclassify benign stderr lines as stdout. + const wrappedOnLog = async (stream: "stdout" | "stderr", chunk: string) => { + if (stream === "stderr") { + const trimmed = chunk.trimEnd(); + // Benign patterns that should NOT appear as errors: + // - Structured log lines: [timestamp] INFO/DEBUG/WARN: ... + // - MCP server registration messages + // - Python import/site noise + const isBenign = /^\[?\d{4}[-/]\d{2}[-/]\d{2}T/.test(trimmed) || // structured timestamps + /^[A-Z]+:\s+(INFO|DEBUG|WARN|WARNING)\b/.test(trimmed) || // log levels + /Successfully registered all tools/.test(trimmed) || + /MCP [Ss]erver/.test(trimmed) || + /tool registered successfully/.test(trimmed) || + /Application initialized/.test(trimmed); + if (isBenign) { + return ctx.onLog("stdout", chunk); + } + } + return ctx.onLog(stream, chunk); + }; + + const result = await runChildProcess(ctx.runId, hermesCmd, args, { + cwd, + env, + timeoutSec, + graceSec, + onLog: wrappedOnLog, + }); + + // ── Parse output ─────────────────────────────────────────────────────── + const parsed = parseHermesOutput(result.stdout || "", result.stderr || ""); + + await ctx.onLog( + "stdout", + `[hermes] Exit code: ${result.exitCode ?? "null"}, timed out: ${result.timedOut}\n`, + ); + if (parsed.sessionId) { + await ctx.onLog("stdout", `[hermes] Session: ${parsed.sessionId}\n`); + } + + // ── Build result ─────────────────────────────────────────────────────── + const executionResult: AdapterExecutionResult = { + exitCode: result.exitCode, + signal: result.signal, + timedOut: result.timedOut, + provider: resolvedProvider, + model, + }; + + if (parsed.errorMessage) { + executionResult.errorMessage = parsed.errorMessage; + } + + if (parsed.usage) { + executionResult.usage = parsed.usage; + } + + if (parsed.costUsd !== undefined) { + executionResult.costUsd = parsed.costUsd; + } + + // Summary from agent response + if (parsed.response) { + executionResult.summary = parsed.response.slice(0, 2000); + } + + // Set resultJson so Paperclip can persist run metadata (used for UI display + auto-comments) + executionResult.resultJson = { + result: parsed.response || "", + session_id: parsed.sessionId || null, + usage: parsed.usage || null, + cost_usd: parsed.costUsd ?? null, + }; + + // Store session ID for next run + if (persistSession && parsed.sessionId) { + executionResult.sessionParams = { sessionId: parsed.sessionId }; + executionResult.sessionDisplayId = parsed.sessionId.slice(0, 16); + } + + return executionResult; +} diff --git a/packages/adapters/hermes/src/server/index.ts b/packages/adapters/hermes/src/server/index.ts new file mode 100644 index 0000000000..2382374d46 --- /dev/null +++ b/packages/adapters/hermes/src/server/index.ts @@ -0,0 +1,49 @@ +/** + * Server-side adapter module exports. + */ + +export { execute } from "./execute.js"; +export { testEnvironment } from "./test.js"; +export { detectModel, parseModelFromConfig, resolveProvider, inferProviderFromModel } from "./detect-model.js"; +export { getConfigSchema } from "./config-schema.js"; +export { + listHermesSkills as listSkills, + syncHermesSkills as syncSkills, + resolveHermesDesiredSkillNames as resolveDesiredSkillNames, +} from "./skills.js"; + +import type { AdapterSessionCodec } from "@paperclipai/adapter-utils"; + +function readNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +/** + * Session codec for structured validation and migration of session parameters. + * + * Hermes Agent uses a single `sessionId` for cross-heartbeat session continuity + * via the `--resume` CLI flag. The codec validates and normalizes this field. + */ +export const sessionCodec: AdapterSessionCodec = { + deserialize(raw: unknown) { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null; + const record = raw as Record<string, unknown>; + const sessionId = + readNonEmptyString(record.sessionId) ?? + readNonEmptyString(record.session_id); + if (!sessionId) return null; + return { sessionId }; + }, + serialize(params: Record<string, unknown> | null) { + if (!params) return null; + const sessionId = + readNonEmptyString(params.sessionId) ?? + readNonEmptyString(params.session_id); + if (!sessionId) return null; + return { sessionId }; + }, + getDisplayId(params: Record<string, unknown> | null) { + if (!params) return null; + return readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id); + }, +}; diff --git a/packages/adapters/hermes/src/server/paperclip-task-bridge.test.ts b/packages/adapters/hermes/src/server/paperclip-task-bridge.test.ts new file mode 100644 index 0000000000..1801407269 --- /dev/null +++ b/packages/adapters/hermes/src/server/paperclip-task-bridge.test.ts @@ -0,0 +1,184 @@ +import { spawn } from "node:child_process"; +import http from "node:http"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const helperPath = path.resolve(__dirname, "../../skills/paperclip-task-bridge/paperclip-task.mjs"); +const apiKey = "pc_test_secret_should_not_print"; + +type RequestRecord = { + method: string; + url: string; + headers: http.IncomingHttpHeaders; + body: unknown; +}; + +function runHelper(args: string[], env: Record<string, string>) { + return new Promise<{ code: number | null; stdout: string; stderr: string }>((resolve, reject) => { + const child = spawn(process.execPath, [helperPath, ...args], { + env: { + ...process.env, + ...env, + }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", reject); + child.on("close", (code) => resolve({ code, stdout, stderr })); + }); +} + +describe("paperclip-task-bridge helper", () => { + let server: http.Server; + let baseUrl: string; + let requests: RequestRecord[]; + + beforeEach(async () => { + requests = []; + server = http.createServer(async (req, res) => { + let raw = ""; + req.setEncoding("utf8"); + for await (const chunk of req) raw += chunk; + const body = raw ? JSON.parse(raw) : null; + requests.push({ + method: req.method ?? "GET", + url: req.url ?? "/", + headers: req.headers, + body, + }); + + res.setHeader("Content-Type", "application/json"); + if (req.headers.authorization !== `Bearer ${apiKey}`) { + res.statusCode = 401; + res.end(JSON.stringify({ error: "bad auth" })); + return; + } + if (req.method === "GET" && req.url === "/api/agents/me") { + res.end(JSON.stringify({ id: "11111111-1111-4111-8111-111111111111", companyId: "22222222-2222-4222-8222-222222222222" })); + return; + } + if (req.method === "GET" && req.url === "/api/agents/me/inbox-lite") { + res.end(JSON.stringify([ + { + id: "33333333-3333-4333-8333-333333333333", + identifier: "PAP-123", + title: "Existing task", + status: "todo", + priority: "medium", + assigneeAgentId: "11111111-1111-4111-8111-111111111111", + updatedAt: "2026-06-26T00:00:00.000Z", + }, + ])); + return; + } + if (req.method === "POST" && req.url === "/api/companies/22222222-2222-4222-8222-222222222222/issues") { + res.statusCode = 201; + res.end(JSON.stringify({ + id: "44444444-4444-4444-8444-444444444444", + identifier: "PAP-124", + title: body.title, + status: body.status ?? "todo", + priority: body.priority, + assigneeAgentId: body.assigneeAgentId ?? null, + })); + return; + } + if (req.method === "POST" && req.url === "/api/issues/PAP-123/comments") { + res.statusCode = 201; + res.end(JSON.stringify({ + id: "55555555-5555-4555-8555-555555555555", + issueId: "33333333-3333-4333-8333-333333333333", + authorType: "agent", + authorAgentId: "11111111-1111-4111-8111-111111111111", + createdAt: "2026-06-26T00:00:00.000Z", + })); + return; + } + if (req.method === "PATCH" && req.url === "/api/issues/PAP-123") { + res.end(JSON.stringify({ + id: "33333333-3333-4333-8333-333333333333", + identifier: "PAP-123", + title: "Existing task", + status: body.status, + priority: "medium", + })); + return; + } + res.statusCode = 404; + res.end(JSON.stringify({ error: "not found" })); + }); + + await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("missing server address"); + baseUrl = `http://127.0.0.1:${address.port}/api`; + }); + + afterEach(async () => { + await new Promise<void>((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); + }); + + function env() { + return { + PAPERCLIP_API_URL: baseUrl, + PAPERCLIP_BRIDGE_API_KEY: apiKey, + PAPERCLIP_COMPANY_ID: "22222222-2222-4222-8222-222222222222", + PAPERCLIP_AGENT_ID: "11111111-1111-4111-8111-111111111111", + PAPERCLIP_RUN_ID: "66666666-6666-4666-8666-666666666666", + }; + } + + it("lists assigned tasks without printing credentials", async () => { + const result = await runHelper(["list-assigned"], env()); + + expect(result.code).toBe(0); + expect(result.stdout).toContain('"command": "list-assigned"'); + expect(result.stdout).toContain('"identifier": "PAP-123"'); + expect(result.stdout).not.toContain(apiKey); + expect(result.stderr).not.toContain(apiKey); + expect(requests.some((request) => request.url === "/api/agents/me/inbox-lite")).toBe(true); + }); + + it("creates tasks assigned to the authenticated agent by default", async () => { + const result = await runHelper(["create-task", "--title", "Bridge task", "--description", "Created from Hermes"], env()); + + expect(result.code).toBe(0); + expect(result.stdout).toContain('"identifier": "PAP-124"'); + const createRequest = requests.find((request) => request.method === "POST" && request.url.includes("/companies/")); + expect(createRequest?.headers["x-paperclip-run-id"]).toBe("66666666-6666-4666-8666-666666666666"); + expect(createRequest?.body).toMatchObject({ + title: "Bridge task", + description: "Created from Hermes", + priority: "medium", + workMode: "standard", + assigneeAgentId: "11111111-1111-4111-8111-111111111111", + }); + expect(result.stdout).not.toContain(apiKey); + }); + + it("comments and updates status through direct issue identifier routes", async () => { + const comment = await runHelper(["comment", "--issue", "PAP-123", "--body", "Progress from Hermes"], env()); + const update = await runHelper(["update-status", "--issue", "PAP-123", "--status", "in_review", "--comment", "Ready"], env()); + + expect(comment.code).toBe(0); + expect(update.code).toBe(0); + const commentRequest = requests.find((request) => request.method === "POST" && request.url.includes("/comments")); + const patchRequest = requests.find((request) => request.method === "PATCH"); + expect(commentRequest?.body).toMatchObject({ body: "Progress from Hermes" }); + expect(patchRequest?.body).toMatchObject({ status: "in_review", comment: "Ready" }); + expect(comment.stdout + update.stdout).not.toContain(apiKey); + }); +}); diff --git a/packages/adapters/hermes/src/server/prompt-rendering.test.ts b/packages/adapters/hermes/src/server/prompt-rendering.test.ts new file mode 100644 index 0000000000..1a5d1c9a8a --- /dev/null +++ b/packages/adapters/hermes/src/server/prompt-rendering.test.ts @@ -0,0 +1,248 @@ +import { expect, test } from "vitest"; + +import { buildPrompt } from "./execute.js"; + +function baseContext(overrides: Record<string, unknown> = {}) { + return { + agent: { + id: "agent-1", + name: "Hermes Engineer", + companyId: "company-1", + }, + runId: "run-1", + config: {}, + context: { + issueId: "issue-1", + paperclipWake: { + reason: "issue_assigned", + issue: { + id: "issue-1", + identifier: "PAP-3404", + title: "Plan the Hermes prompt update", + status: "in_progress", + priority: "medium", + workMode: "planning", + }, + checkedOutByHarness: true, + commentWindow: { requestedCount: 0, includedCount: 0, missingCount: 0 }, + comments: [], + fallbackFetchNeeded: false, + }, + paperclipTaskMarkdown: [ + "Paperclip task context:", + '- Issue: "PAP-3404"', + '- Title: "Plan the Hermes prompt update"', + "", + "Planning mode directive:", + "Make the plan only. Do not write code or perform implementation work.", + "", + "Issue description:", + "```text", + "Use the wake payload as runtime authority.", + "```", + ].join("\n"), + ...overrides, + }, + } as any; +} + +test("renders standard assignment wake with task authority and no backlog discovery guidance", () => { + const prompt = buildPrompt(baseContext({ + paperclipWake: { + reason: "issue_assigned", + issue: { + id: "issue-1", + identifier: "PAP-11750", + title: "Add Hermes prompt rendering regression tests", + status: "in_progress", + priority: "medium", + workMode: "standard", + }, + checkedOutByHarness: true, + commentWindow: { requestedCount: 0, includedCount: 0, missingCount: 0 }, + comments: [], + fallbackFetchNeeded: false, + }, + paperclipTaskMarkdown: [ + "Paperclip task context:", + '- Issue: "PAP-11750"', + '- Title: "Add Hermes prompt rendering regression tests"', + "", + "Issue description:", + "```text", + "Add focused unit tests for assignment wake and custom prompt rendering.", + "```", + ].join("\n"), + }), {}); + + expect(prompt).toContain("## Paperclip Wake Payload"); + expect(prompt).toContain("- reason: issue_assigned"); + expect(prompt).toContain("- issue: PAP-11750 Add Hermes prompt rendering regression tests"); + expect(prompt).toContain("- issue work mode: standard"); + expect(prompt).toContain("Paperclip task context:"); + expect(prompt).toContain("Add focused unit tests for assignment wake and custom prompt rendering."); + expect(prompt).toContain("The harness already checked out this issue for the current run."); + expect(prompt).toContain("clear final disposition"); + expect(prompt).not.toContain("check for unassigned issues"); + expect(prompt).not.toContain("status=backlog"); +}); + +test("renders scoped planning wake authority before the Hermes default workflow", () => { + const prompt = buildPrompt(baseContext(), { + paperclipApiUrl: "http://127.0.0.1:3101/api", + }); + + expect(prompt).toContain("## Paperclip Wake Payload"); + expect(prompt).toContain("- issue: PAP-3404 Plan the Hermes prompt update"); + expect(prompt).toContain("- planning directive: Make the plan only. Do not write code or perform implementation work."); + expect(prompt).toContain("- checkout: already claimed by the harness for this run"); + expect(prompt).toContain("The harness already checked out this issue for the current run."); + expect(prompt).toContain("Issue description:\n```text\nUse the wake payload as runtime authority.\n```"); + expect(prompt).toContain("clear final disposition"); + expect(prompt).toContain("keep `in_progress` only when a live continuation path exists"); + expect(prompt).not.toContain("check for unassigned issues"); + expect(prompt).not.toContain("status=backlog"); +}); + +test("renders resume deltas instead of full scoped-wake boilerplate when continuing a session", () => { + const prompt = buildPrompt(baseContext({ + paperclipWake: { + reason: "issue_commented", + issue: { + id: "issue-1", + identifier: "PAP-11750", + title: "Add Hermes prompt rendering regression tests", + status: "in_progress", + priority: "medium", + workMode: "standard", + }, + latestCommentId: "comment-2", + commentWindow: { requestedCount: 1, includedCount: 1, missingCount: 0 }, + comments: [{ id: "comment-2", body: "Please add the resume-delta case.", createdAt: "2026-06-23T00:00:00.000Z" }], + fallbackFetchNeeded: false, + }, + }), {}, { resumedSession: true }); + + expect(prompt).toContain("## Paperclip Resume Delta"); + expect(prompt).toContain("You are resuming an existing Paperclip session."); + expect(prompt).toContain("Focus on the new wake delta below"); + expect(prompt).toContain("Please add the resume-delta case."); + expect(prompt).toContain("- fallback fetch needed: no"); + expect(prompt).not.toContain("Before generic repo exploration or boilerplate heartbeat updates"); +}); + +test("renders comment wake batch guidance without defaulting to a full-thread refetch", () => { + const prompt = buildPrompt(baseContext({ + wakeCommentId: "comment-1", + paperclipWake: { + reason: "issue_commented", + issue: { + id: "issue-1", + identifier: "PAP-3404", + title: "Plan the Hermes prompt update", + status: "in_progress", + priority: "medium", + workMode: "standard", + }, + latestCommentId: "comment-1", + commentWindow: { requestedCount: 1, includedCount: 1, missingCount: 0 }, + comments: [{ id: "comment-1", body: "Please tighten the prompt.", createdAt: "2026-06-23T00:00:00.000Z" }], + fallbackFetchNeeded: false, + }, + }), {}); + + expect(prompt).toContain("Use this inline wake data first before refetching the issue thread."); + expect(prompt).toContain("Only fetch the API thread when `fallbackFetchNeeded` is true"); + expect(prompt).toContain("New comments in order:"); + expect(prompt).toContain("Please tighten the prompt."); + expect(prompt).toContain("- fallback fetch needed: no"); +}); + +test("renders accepted-plan continuation without authorizing implementation on the planning issue", () => { + const prompt = buildPrompt(baseContext({ + paperclipWake: { + reason: "issue_commented", + issue: { + id: "issue-1", + identifier: "PAP-3404", + title: "Plan the Hermes prompt update", + status: "in_progress", + priority: "medium", + workMode: "planning", + }, + interactionKind: "request_confirmation", + interactionStatus: "accepted", + commentWindow: { requestedCount: 0, includedCount: 0, missingCount: 0 }, + comments: [], + fallbackFetchNeeded: false, + }, + }), {}); + + expect(prompt).toContain("- planning directive: Create child issues from the approved plan only. Do not write code or perform implementation work on the planning issue."); + expect(prompt).toContain("- accepted-plan continuation: you may create child implementation issues from the approved plan"); + expect(prompt).toContain("must not start implementation work on the planning issue itself"); + expect(prompt).not.toContain("- planning directive: Make the plan only."); + expect(prompt).not.toContain("Update the plan only"); +}); + +test("keeps authoritative parent and ancestor context from task markdown", () => { + const prompt = buildPrompt(baseContext({ + paperclipTaskMarkdown: [ + "Paperclip task context:", + '- Issue: "PAP-3404"', + "", + "Authoritative parent / ancestor context:", + "- Parent: PAP-11724 Optimize prompt traces (in_progress) [medium]", + "- Ancestor 2: PAP-11721 Fetch raw traces (done) [medium]", + ].join("\n"), + }), {}); + + expect(prompt).toContain("Authoritative parent / ancestor context:"); + expect(prompt).toContain("- Parent: PAP-11724 Optimize prompt traces (in_progress) [medium]"); + expect(prompt).not.toContain("check the issue body or comments for references"); +}); + +test("renders safe Paperclip API examples from environment variables with multiline update preservation", () => { + const prompt = buildPrompt(baseContext(), { + paperclipApiUrl: "http://paperclip.local/api", + }); + + expect(prompt).toContain("Use `$PAPERCLIP_API_URL`, `$PAPERCLIP_API_KEY`, and `$PAPERCLIP_RUN_ID`"); + expect(prompt).toContain("Displayed command logs may redact secrets"); + expect(prompt).toContain('-H "Authorization: Bearer $PAPERCLIP_API_KEY"'); + expect(prompt).toContain('-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID"'); + expect(prompt).toContain("body=$(cat <<'MD'"); + expect(prompt).toContain("jq -n --arg status done --arg comment \"$body\""); + expect(prompt).toContain("--data-binary @-"); + expect(prompt).not.toContain("Authorization: Bearer <"); +}); + +test("preserves custom prompt templates while exposing runtime and wake variables", () => { + const prompt = buildPrompt(baseContext(), { + paperclipApiUrl: "http://paperclip.local/api", + promptTemplate: [ + "CUSTOM TEMPLATE", + "agent={{agent.name}}", + "api={{paperclipApiUrl}}", + "keyEnv={{paperclipApiKeyEnv}}", + "runEnv={{paperclipRunIdEnv}}", + "wakePrompt={{paperclipWakePrompt}}", + "task={{paperclipTaskMarkdown}}", + "wakeJson={{paperclipWakeJson}}", + "wake={{wakePayloadJson}}", + ].join("\n"), + }); + + expect(prompt).toContain("CUSTOM TEMPLATE"); + expect(prompt).toContain("agent=Hermes Engineer"); + expect(prompt).toContain("api=http://paperclip.local/api"); + expect(prompt).toContain("keyEnv=PAPERCLIP_API_KEY"); + expect(prompt).toContain("runEnv=PAPERCLIP_RUN_ID"); + expect(prompt).toContain("wakePrompt=## Paperclip Wake Payload"); + expect(prompt).toContain("task=Paperclip task context:"); + expect(prompt).toContain("wakeJson={\"reason\":\"issue_assigned\""); + expect(prompt).toContain('"reason":"issue_assigned"'); + expect(prompt).toContain("## Paperclip Wake Payload"); + expect(prompt).toContain("Issue description:\n```text\nUse the wake payload as runtime authority.\n```"); + expect(prompt).not.toContain("Paperclip runtime identity:"); +}); diff --git a/packages/adapters/hermes/src/server/skills.ts b/packages/adapters/hermes/src/server/skills.ts new file mode 100644 index 0000000000..6a9ea3c0d7 --- /dev/null +++ b/packages/adapters/hermes/src/server/skills.ts @@ -0,0 +1,226 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { + AdapterSkillContext, + AdapterSkillEntry, + AdapterSkillSnapshot, +} from "@paperclipai/adapter-utils"; +import { + readPaperclipRuntimeSkillEntries, + resolvePaperclipDesiredSkillNames, +} from "@paperclipai/adapter-utils/server-utils"; +import { fileURLToPath } from "node:url"; + +const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function asString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function resolveHermesHome(config: Record<string, unknown>): string { + const env = + typeof config.env === "object" && config.env !== null && !Array.isArray(config.env) + ? (config.env as Record<string, unknown>) + : {}; + const configuredHome = asString(env.HOME); + return configuredHome ? path.resolve(configuredHome) : os.homedir(); +} + +interface SkillFrontmatter { + name?: string; + description?: string; + version?: string; + category?: string; + metadata?: Record<string, unknown>; +} + +function parseSkillFrontmatter(content: string): SkillFrontmatter { + const match = content.match(/^---\s*\n([\s\S]*?)\n---/); + if (!match) return {}; + const frontmatter: Record<string, unknown> = {}; + for (const line of match[1].split("\n")) { + const idx = line.indexOf(":"); + if (idx === -1) continue; + const key = line.slice(0, idx).trim(); + let val: unknown = line.slice(idx + 1).trim(); + // Strip quotes + if (typeof val === "string" && ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'")))) { + val = val.slice(1, -1); + } + frontmatter[key] = val; + } + return frontmatter as SkillFrontmatter; +} + +async function scanHermesSkills( + skillsHome: string, +): Promise<AdapterSkillEntry[]> { + const entries: AdapterSkillEntry[] = []; + + try { + const categories = await fs.readdir(skillsHome, { withFileTypes: true }); + for (const cat of categories) { + if (!cat.isDirectory()) continue; + const catPath = path.join(skillsHome, cat.name); + + // Check if the category directory itself has a SKILL.md (top-level skill) + const topLevelSkillMd = path.join(catPath, "SKILL.md"); + if (await fs.stat(topLevelSkillMd).catch(() => null)) { + entries.push(await buildSkillEntry(cat.name, topLevelSkillMd, cat.name)); + } + + // Scan for sub-skills + const items = await fs.readdir(catPath, { withFileTypes: true }).catch(() => []); + for (const item of items) { + if (!item.isDirectory()) continue; + const skillMd = path.join(catPath, item.name, "SKILL.md"); + if (await fs.stat(skillMd).catch(() => null)) { + const key = item.name; + entries.push(await buildSkillEntry(key, skillMd, `${cat.name}/${item.name}`)); + } + } + } + } catch { + // ~/.hermes/skills/ doesn't exist — no skills available + } + + return entries.sort((a, b) => a.key.localeCompare(b.key)); +} + +async function buildSkillEntry( + key: string, + skillMdPath: string, + categoryPath: string, +): Promise<AdapterSkillEntry> { + let description: string | null = null; + try { + const content = await fs.readFile(skillMdPath, "utf8"); + const fm = parseSkillFrontmatter(content); + description = fm.description ?? null; + } catch { + // ignore + } + + return { + key, + runtimeName: key, + desired: true, // Hermes loads all available skills + managed: false, + state: "installed", + origin: "user_installed", + originLabel: "Hermes skill", + locationLabel: `~/.hermes/skills/${categoryPath}`, + readOnly: true, // Hermes manages its own skills — Paperclip can't toggle them + sourcePath: skillMdPath, + targetPath: null, + detail: description, + }; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +async function buildHermesSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> { + const home = resolveHermesHome(config); + const hermesSkillsHome = path.join(home, ".hermes", "skills"); + + // 1. Scan Paperclip-managed skills (bundled with the adapter) + const paperclipEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); + const desiredSkills = resolvePaperclipDesiredSkillNames(config, paperclipEntries); + const desiredSet = new Set(desiredSkills); + const availableByKey = new Map(paperclipEntries.map((e) => [e.key, e])); + + // 2. Scan Hermes's own skills from ~/.hermes/skills/ + const hermesSkillEntries = await scanHermesSkills(hermesSkillsHome); + const hermesKeys = new Set(hermesSkillEntries.map((e) => e.key)); + + // 3. Merge: Paperclip skills first (ephemeral), then Hermes skills + const entries: AdapterSkillEntry[] = []; + const warnings: string[] = []; + + // Paperclip-managed skills + for (const entry of paperclipEntries) { + const desired = desiredSet.has(entry.key); + entries.push({ + key: entry.key, + runtimeName: entry.runtimeName, + desired, + managed: true, + state: desired ? "configured" : "available", + origin: "company_managed", + originLabel: "Managed by Paperclip", + readOnly: false, + sourcePath: entry.source, + targetPath: null, + detail: desired + ? "Will be available on the next run via Hermes skill loading." + : null, + }); + } + + // Hermes-installed skills (read-only, always loaded) + for (const entry of hermesSkillEntries) { + // Skip if Paperclip already manages a skill with the same key + if (availableByKey.has(entry.key)) continue; + entries.push(entry); + } + + // Check for desired skills that don't exist + for (const desiredSkill of desiredSkills) { + if (availableByKey.has(desiredSkill) || hermesKeys.has(desiredSkill)) continue; + warnings.push( + `Desired skill "${desiredSkill}" is not available in Paperclip or Hermes skills.`, + ); + entries.push({ + key: desiredSkill, + runtimeName: null, + desired: true, + managed: true, + state: "missing", + origin: "external_unknown", + originLabel: "External or unavailable", + readOnly: false, + sourcePath: null, + targetPath: null, + detail: + "Cannot find this skill in Paperclip or ~/.hermes/skills/.", + }); + } + + return { + adapterType: "hermes_local", + supported: true, + mode: "persistent", + desiredSkills, + entries, + warnings, + }; +} + +export async function listHermesSkills( + ctx: AdapterSkillContext, +): Promise<AdapterSkillSnapshot> { + return buildHermesSkillSnapshot(ctx.config); +} + +export async function syncHermesSkills( + ctx: AdapterSkillContext, + _desiredSkills: string[], +): Promise<AdapterSkillSnapshot> { + // Hermes manages its own skill loading — sync is a no-op. + // Return the current snapshot so the UI stays in sync. + return buildHermesSkillSnapshot(ctx.config); +} + +export function resolveHermesDesiredSkillNames( + config: Record<string, unknown>, + availableEntries: Array<{ key: string; runtimeName?: string | null }>, +): string[] { + return resolvePaperclipDesiredSkillNames(config, availableEntries); +} diff --git a/packages/adapters/hermes/src/server/test.ts b/packages/adapters/hermes/src/server/test.ts new file mode 100644 index 0000000000..b75ce020ca --- /dev/null +++ b/packages/adapters/hermes/src/server/test.ts @@ -0,0 +1,387 @@ +/** + * Environment test for the Hermes Agent adapter. + * + * Verifies that Hermes Agent is installed, accessible, and configured + * before allowing the adapter to be used. + */ + +import type { + AdapterEnvironmentTestContext, + AdapterEnvironmentTestResult, + AdapterEnvironmentCheck, +} from "@paperclipai/adapter-utils"; + +import { execFile } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { promisify } from "node:util"; + +import { HERMES_CLI, DEFAULT_MODEL, ADAPTER_TYPE, VALID_PROVIDERS } from "../shared/constants.js"; +import { detectModel, resolveProvider, inferProviderFromModel } from "./detect-model.js"; +import { resolveHermesCommand } from "./execute.js"; + +const execFileAsync = promisify(execFile); + +function asString(v: unknown): string | undefined { + return typeof v === "string" ? v : undefined; +} + +// --------------------------------------------------------------------------- +// Checks +// --------------------------------------------------------------------------- + +async function checkCliInstalled( + command: string, +): Promise<AdapterEnvironmentCheck | null> { + try { + // Try to run the command to see if it exists + await execFileAsync(command, ["--version"], { timeout: 10_000 }); + return null; // OK — it ran successfully + } catch (err: unknown) { + const e = err as NodeJS.ErrnoException; + if (e.code === "ENOENT") { + return { + level: "error", + message: `Hermes CLI "${command}" not found in PATH`, + hint: "Install Hermes Agent: pip install hermes-agent", + code: "hermes_cli_not_found", + }; + } + // Command exists but --version might have failed for some reason + // Still consider it installed + return null; + } +} + +async function checkCliVersion( + command: string, +): Promise<AdapterEnvironmentCheck | null> { + try { + const { stdout } = await execFileAsync(command, ["--version"], { + timeout: 10_000, + }); + const version = stdout.trim(); + if (version) { + return { + level: "info", + message: `Hermes Agent version: ${version}`, + code: "hermes_version", + }; + } + return { + level: "warn", + message: "Could not determine Hermes Agent version", + code: "hermes_version_unknown", + }; + } catch { + return { + level: "warn", + message: + "Could not determine Hermes Agent version (hermes --version failed)", + hint: "Make sure the hermes CLI is properly installed and functional", + code: "hermes_version_failed", + }; + } +} + +async function checkPython(): Promise<AdapterEnvironmentCheck | null> { + try { + const { stdout } = await execFileAsync("python3", ["--version"], { + timeout: 5_000, + }); + const version = stdout.trim(); + const match = version.match(/(\d+)\.(\d+)/); + if (match) { + const major = parseInt(match[1], 10); + const minor = parseInt(match[2], 10); + if (major < 3 || (major === 3 && minor < 10)) { + return { + level: "error", + message: `Python ${version} found — Hermes requires Python 3.10+`, + hint: "Upgrade Python to 3.10 or later", + code: "hermes_python_old", + }; + } + } + return null; // OK + } catch { + return { + level: "warn", + message: "python3 not found in PATH", + hint: "Hermes Agent requires Python 3.10+. Install it from python.org", + code: "hermes_python_missing", + }; + } +} + +function checkModel( + config: Record<string, unknown>, +): AdapterEnvironmentCheck | null { + const model = asString(config.model); + if (!model) { + return { + level: "info", + message: "No model specified — Hermes will use its configured default model", + hint: "Set a model explicitly in Paperclip only if you want to override your local Hermes configuration.", + code: "hermes_configured_default_model", + }; + } + return { + level: "info", + message: `Model: ${model}`, + code: "hermes_model_configured", + }; +} + +async function checkApiKeys( + config: Record<string, unknown>, + detectedConfig: Awaited<ReturnType<typeof detectModel>> | null, +): Promise<AdapterEnvironmentCheck | null> { + // The server resolves secret refs into config.env before calling testEnvironment, + // so we check config.env first (adapter-configured secrets), then fall back to + // process.env (server/host environment), then ~/.hermes/.env (Hermes local config). + const envConfig = (config.env ?? {}) as Record<string, unknown>; + const resolvedEnv: Record<string, string> = {}; + for (const [key, value] of Object.entries(envConfig)) { + if (typeof value === "string" && value.length > 0) resolvedEnv[key] = value; + } + + // Also read ~/.hermes/.env — Hermes stores API keys there by default and does + // not export them to the parent process, so Paperclip's process.env won't + // contain them. Parsing this file ensures the environment test reports + // accurate results for keys that Hermes already knows about. + const hermesEnvKeys: Record<string, string> = {}; + try { + const homeDir = process.env.HOME || process.env.USERPROFILE || "/root"; + const hermesEnvPath = `${homeDir}/.hermes/.env`; + const content = readFileSync(hermesEnvPath, "utf-8"); + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eqIdx = trimmed.indexOf("="); + if (eqIdx > 0) { + const key = trimmed.substring(0, eqIdx).trim(); + const value = trimmed.substring(eqIdx + 1).trim(); + if (value.length > 0) hermesEnvKeys[key] = value; + } + } + } catch { + // ~/.hermes/.env may not exist — that's fine + } + + const has = (key: string): boolean => + !!(resolvedEnv[key] ?? process.env[key] ?? hermesEnvKeys[key]); + + const hasAnthropic = has("ANTHROPIC_API_KEY"); + const hasOpenRouter = has("OPENROUTER_API_KEY"); + const hasOpenAI = has("OPENAI_API_KEY"); + const hasZai = has("ZAI_API_KEY"); + const hasKimi = has("KIMI_API_KEY"); + const hasMiniMax = has("MINIMAX_API_KEY"); + + const providers: string[] = []; + if (hasAnthropic) providers.push("Anthropic"); + if (hasOpenRouter) providers.push("OpenRouter"); + if (hasOpenAI) providers.push("OpenAI"); + if (hasZai) providers.push("Z.AI"); + if (hasKimi) providers.push("Kimi"); + if (hasMiniMax) providers.push("MiniMax"); + + if (providers.length > 0) { + return { + level: "info", + message: `API keys found: ${providers.join(", ")}`, + code: "hermes_api_keys_found", + }; + } + + const requestedModel = asString(config.model); + + const supportedProviders = VALID_PROVIDERS as readonly string[]; + const modelMatchesRequested = + !!detectedConfig?.model && + (!requestedModel || detectedConfig.model.toLowerCase() === requestedModel.toLowerCase()); + + const matchingHermesConfigApiKey = + !!detectedConfig?.hasApiKey && + modelMatchesRequested; + + if (matchingHermesConfigApiKey && detectedConfig) { + const providerLabel = detectedConfig.provider.trim(); + + if (!providerLabel) { + return { + level: "info", + message: "Hermes config includes an API key for the requested model via ~/.hermes/config.yaml without an explicit provider", + hint: "Skipping the built-in API-key warning because Hermes can use model.api_key from the local Hermes config.", + code: "hermes_api_key_in_config", + }; + } + + if (!supportedProviders.includes(providerLabel)) { + return { + level: "info", + message: `Hermes config includes runtime settings for unsupported adapter provider "${providerLabel}" via ~/.hermes/config.yaml`, + hint: "Skipping the built-in API-key warning because Hermes can resolve this provider at runtime.", + code: "hermes_custom_provider_config", + }; + } + + return { + level: "info", + message: `Hermes config includes an API key for provider "${providerLabel}" via ~/.hermes/config.yaml`, + hint: "Skipping the built-in API-key warning because Hermes can use model.api_key from the local Hermes config.", + code: "hermes_api_key_in_config", + }; + } + + return { + level: "warn", + message: "No LLM API keys found in environment", + hint: "Set API keys in the agent's env secrets or ~/.hermes/.env. Hermes supports: ANTHROPIC_API_KEY, OPENROUTER_API_KEY, OPENAI_API_KEY, ZAI_API_KEY, KIMI_API_KEY, MINIMAX_API_KEY", + code: "hermes_no_api_keys", + }; +} + +/** + * Check provider/model consistency. + * Warns if the configured provider might be wrong for the model. + */ +async function checkProviderConsistency( + config: Record<string, unknown>, + detectedConfig: Awaited<ReturnType<typeof detectModel>> | null, +): Promise<AdapterEnvironmentCheck | null> { + const model = asString(config.model); + if (!model) return null; + + const explicitProvider = asString(config.provider); + + const { provider: resolved, resolvedFrom } = resolveProvider({ + explicitProvider, + detectedProvider: detectedConfig?.provider, + detectedModel: detectedConfig?.model, + detectedBaseUrl: detectedConfig?.baseUrl, + detectedHasApiKey: detectedConfig?.hasApiKey, + detectedApiMode: detectedConfig?.apiMode, + model, + }); + + // If provider was explicitly set but doesn't match what Hermes config says, + // that's worth flagging. + if (explicitProvider && detectedConfig?.provider && explicitProvider !== detectedConfig.provider) { + return { + level: "warn", + message: `Provider mismatch: adapterConfig has "${explicitProvider}" but ~/.hermes/config.yaml has "${detectedConfig.provider}". Using adapterConfig value.`, + hint: `Model "${model}" may not work correctly with provider "${explicitProvider}". Consider aligning with your Hermes config or removing the explicit provider to use auto-detection.`, + code: "hermes_provider_mismatch", + }; + } + + // If Hermes config matches the requested model but uses an adapter-unsupported + // provider such as "custom", do not report a false provider inference. + if (!explicitProvider && resolvedFrom.startsWith("hermesConfigUnsupported:")) { + const unsupportedProvider = resolvedFrom.split(":", 2)[1] || detectedConfig?.provider || "unknown"; + return { + level: "info", + message: `Hermes config uses unsupported adapter provider "${unsupportedProvider}" for model "${model}" — deferring to Hermes auto-detection`, + hint: "Paperclip will avoid model-name provider inference here and let Hermes resolve the provider from ~/.hermes/config.yaml at runtime.", + code: "hermes_provider_unsupported", + }; + } + + // If matching Hermes config provides runtime signals without an explicit provider, + // also defer to Hermes rather than inventing a provider from the model name. + if (!explicitProvider && resolvedFrom === "hermesConfigRuntime") { + return { + level: "info", + message: `Hermes config provides runtime settings for model "${model}" without an explicit adapter provider — deferring to Hermes auto-detection`, + hint: "Paperclip will avoid model-name provider inference here and let Hermes resolve the provider from ~/.hermes/config.yaml at runtime.", + code: "hermes_provider_runtime_config", + }; + } + + // If provider was auto-detected (not explicitly set), log what was resolved + if (!explicitProvider && resolvedFrom !== "auto") { + return { + level: "info", + message: `Provider auto-detected as "${resolved}" (from ${resolvedFrom}) for model "${model}"`, + code: "hermes_provider_detected", + }; + } + + // If we couldn't resolve any provider, warn + if (resolvedFrom === "auto" && !explicitProvider) { + return { + level: "warn", + message: `Could not determine provider for model "${model}" — will use Hermes auto-detection`, + hint: "Set an explicit provider in the agent config or ensure ~/.hermes/config.yaml has a matching provider for this model.", + code: "hermes_provider_unknown", + }; + } + + return null; +} + +// --------------------------------------------------------------------------- +// Main test +// --------------------------------------------------------------------------- + +export async function testEnvironment( + ctx: AdapterEnvironmentTestContext, +): Promise<AdapterEnvironmentTestResult> { + const config = (ctx.config ?? {}) as Record<string, unknown>; + const command = resolveHermesCommand(config); + const checks: AdapterEnvironmentCheck[] = []; + + // 1. CLI installed? + const cliCheck = await checkCliInstalled(command); + if (cliCheck) { + checks.push(cliCheck); + if (cliCheck.level === "error") { + return { + adapterType: ADAPTER_TYPE, + status: "fail", + checks, + testedAt: new Date().toISOString(), + }; + } + } + + // 2. CLI version + const versionCheck = await checkCliVersion(command); + if (versionCheck) checks.push(versionCheck); + + // 3. Python available? + const pythonCheck = await checkPython(); + if (pythonCheck) checks.push(pythonCheck); + + // 4. Model config + const modelCheck = checkModel(config); + if (modelCheck) checks.push(modelCheck); + + // 5. Detect Hermes config once for the remaining checks. + let detectedConfig: Awaited<ReturnType<typeof detectModel>> | null = null; + try { + detectedConfig = await detectModel(); + } catch { + // Non-fatal + } + + // 6. API keys (check config.env — server resolves secrets before calling us) + const apiKeyCheck = await checkApiKeys(config, detectedConfig); + if (apiKeyCheck) checks.push(apiKeyCheck); + + // 7. Provider/model consistency + const providerCheck = await checkProviderConsistency(config, detectedConfig); + if (providerCheck) checks.push(providerCheck); + + // Determine overall status + const hasErrors = checks.some((c) => c.level === "error"); + const hasWarnings = checks.some((c) => c.level === "warn"); + + return { + adapterType: ADAPTER_TYPE, + status: hasErrors ? "fail" : hasWarnings ? "warn" : "pass", + checks, + testedAt: new Date().toISOString(), + }; +} diff --git a/packages/adapters/hermes/src/shared/constants.ts b/packages/adapters/hermes/src/shared/constants.ts new file mode 100644 index 0000000000..25f57cd86e --- /dev/null +++ b/packages/adapters/hermes/src/shared/constants.ts @@ -0,0 +1,104 @@ +/** + * Shared constants for the Hermes Agent adapter. + */ + +/** Adapter type identifier registered with Paperclip. */ +export const ADAPTER_TYPE = "hermes_local"; + +/** Human-readable label shown in the Paperclip UI. */ +export const ADAPTER_LABEL = "Hermes Agent"; + +/** Default CLI binary name. */ +export const HERMES_CLI = "hermes"; + +/** Default timeout for a single execution run (seconds). */ +export const DEFAULT_TIMEOUT_SEC = 1800; + +/** Grace period after SIGTERM before SIGKILL (seconds). */ +export const DEFAULT_GRACE_SEC = 10; + +/** + * Default model to use if none specified. + * + * Use "auto" so that Hermes resolves the model from the user's local + * ~/.hermes/config.yaml — preventing the adapter from overriding a + * user's configured default (e.g. MiniMax, OpenRouter, etc.) with a + * hardcoded Anthropic model during Paperclip onboarding. + */ +export const DEFAULT_MODEL = "auto"; + +/** + * Valid --provider choices for the hermes CLI. + * Must stay in sync with `hermes chat --help`. + */ +export const VALID_PROVIDERS = [ + "auto", + "openrouter", + "nous", + "openai-codex", + "copilot", + "copilot-acp", + "anthropic", + "huggingface", + "zai", + "kimi-coding", + "minimax", + "minimax-cn", + "kilocode", +] as const; + +/** + * Model-name prefix → provider hint mapping. + * Used when no explicit provider is configured and we need to infer + * the correct provider from the model string alone. + * + * Keys are lowercased prefix patterns; values must be valid provider names. + * Longer prefixes are matched first (order matters). + */ +export const MODEL_PREFIX_PROVIDER_HINTS: [string, string][] = [ + // OpenAI-native models + ["gpt-4", "openai-codex"], + ["gpt-5", "copilot"], + ["o1-", "openai-codex"], + ["o3-", "openai-codex"], + ["o4-", "openai-codex"], + // Anthropic models + ["claude", "anthropic"], + // Google models (via openrouter or direct) + ["gemini", "auto"], + // Nous models + ["hermes-", "nous"], + // Z.AI / GLM models + ["glm-", "zai"], + // Kimi / Moonshot + ["moonshot", "kimi-coding"], + ["kimi", "kimi-coding"], + // MiniMax + ["minimax", "minimax"], + // DeepSeek + ["deepseek", "auto"], + // Meta Llama + ["llama", "auto"], + // Qwen + ["qwen", "auto"], + // Mistral + ["mistral", "auto"], + // HuggingFace models (org/model format) + ["huggingface/", "huggingface"], +]; + +/** Regex to extract session ID from Hermes CLI output. */ +export const SESSION_ID_REGEX = /session[_ ](?:id|saved)[:\s]+([a-zA-Z0-9_-]+)/i; + +/** Regex to extract token usage from Hermes output. */ +export const TOKEN_USAGE_REGEX = + /tokens?[:\s]+(\d+)\s*(?:input|in)\b.*?(\d+)\s*(?:output|out)\b/i; + +/** Regex to extract cost from Hermes output. */ +export const COST_REGEX = /(?:cost|spent)[:\s]*\$?([\d.]+)/i; + +/** Prefix used by Hermes for tool output lines. */ +export const TOOL_OUTPUT_PREFIX = "┊"; + +/** Prefix for Hermes thinking blocks. */ +export const THINKING_PREFIX = "💭"; diff --git a/packages/adapters/hermes/src/ui/build-config.ts b/packages/adapters/hermes/src/ui/build-config.ts new file mode 100644 index 0000000000..ef9c5fb67a --- /dev/null +++ b/packages/adapters/hermes/src/ui/build-config.ts @@ -0,0 +1,87 @@ +/** + * Build adapter configuration from UI form values. + * + * Translates Paperclip's CreateConfigValues into the adapterConfig + * object stored in the agent record. + * + * NOTE: Provider resolution happens at runtime in execute.ts, not here. + * The UI may or may not pass a provider field. If it does, we persist it + * as the user's explicit override. If not, execute.ts will detect it from + * ~/.hermes/config.yaml at runtime. + */ + +import type { CreateConfigValues } from "@paperclipai/adapter-utils"; + +import { + DEFAULT_TIMEOUT_SEC, +} from "../shared/constants.js"; + +/** + * Build a Hermes Agent adapter config from the Paperclip UI form values. + */ +export function buildHermesConfig( + v: CreateConfigValues, +): Record<string, unknown> { + const ac: Record<string, unknown> = {}; + + // Model + if (v.model.trim()) { + ac.model = v.model.trim(); + } + + // NOTE: Provider is NOT set here because the Paperclip UI form + // (CreateConfigValues) does not expose a provider field. + // Instead, provider is resolved at runtime in execute.ts using + // a priority chain: + // 1. adapterConfig.provider (if set via API directly) + // 2. ~/.hermes/config.yaml detection + // 3. Model-name prefix inference + // 4. "auto" fallback + // This ensures correct provider routing even for agents created + // before provider tracking existed. + + // Execution limits — let the user configure these from the Paperclip UI. + // timeoutSec: wall-clock kill timeout for the hermes child process. + // maxTurnsPerRun: maps to Hermes's --max-turns (agent tool-calling iterations). + ac.timeoutSec = DEFAULT_TIMEOUT_SEC; + if (v.maxTurnsPerRun > 0) { + ac.maxTurnsPerRun = v.maxTurnsPerRun; + // Scale timeout to match: ~20s per tool turn is generous headroom. + // Never go below the default (1800s / 30 min). + ac.timeoutSec = Math.max(DEFAULT_TIMEOUT_SEC, v.maxTurnsPerRun * 20); + } + + // Session persistence (default: on) + ac.persistSession = true; + + // Working directory + if (v.cwd) { + ac.cwd = v.cwd; + } + + // Custom hermes binary path + if (v.command) { + ac.hermesCommand = v.command; + } + + // Extra CLI arguments + if (v.extraArgs) { + ac.extraArgs = v.extraArgs.split(/\s+/).filter(Boolean); + } + + // Thinking/reasoning effort + if (v.thinkingEffort) { + const existing = (ac.extraArgs as string[]) || []; + existing.push("--reasoning-effort", String(v.thinkingEffort)); + ac.extraArgs = existing; + } + + // Prompt template + if (v.promptTemplate) { + ac.promptTemplate = v.promptTemplate; + } + + // Heartbeat config is handled by Paperclip itself + + return ac; +} diff --git a/packages/adapters/hermes/src/ui/index.ts b/packages/adapters/hermes/src/ui/index.ts new file mode 100644 index 0000000000..3a49f11209 --- /dev/null +++ b/packages/adapters/hermes/src/ui/index.ts @@ -0,0 +1,7 @@ +/** + * UI module exports — used by Paperclip's dashboard for run viewing + * and agent configuration forms. + */ + +export { parseHermesStdoutLine } from "./parse-stdout.js"; +export { buildHermesConfig } from "./build-config.js"; diff --git a/packages/adapters/hermes/src/ui/parse-stdout.ts b/packages/adapters/hermes/src/ui/parse-stdout.ts new file mode 100644 index 0000000000..6e48b40ed4 --- /dev/null +++ b/packages/adapters/hermes/src/ui/parse-stdout.ts @@ -0,0 +1,283 @@ +/** + * Parse Hermes Agent stdout into TranscriptEntry objects for the Paperclip UI. + * + * Hermes CLI quiet-mode output patterns: + * Assistant: " ┊ 💬 {text}" + * Tool (TTY): " ┊ {emoji} {verb:9} {detail} {duration}" + * Tool (pipe): " [done] ┊ {emoji} {verb:9} {detail} {duration} ({total})" + * System: "[hermes] ..." + * + * We emit structured tool_call/tool_result pairs so Paperclip renders proper + * tool cards (with status icons, expand/collapse) instead of raw stdout blocks. + */ + +import type { TranscriptEntry } from "@paperclipai/adapter-utils"; + +import { TOOL_OUTPUT_PREFIX } from "../shared/constants.js"; + +// ── Kaomoji / noise stripping ────────────────────────────────────────────── + +/** + * Strip kawaii faces and decorative emoji from a tool summary line. + * Leaves meaningful emoji (💻 for terminal, 🔍 for search, etc.) intact + * by only stripping parenthesized kaomoji like (。◕‿◕。). + */ +function stripKaomoji(text: string): string { + // Strip parenthesized kaomoji faces: (。◕‿◕。), (★ω★), etc. + return text.replace(/[(][^()]{2,20}[)]\s*/gu, "").trim(); +} + +// ── Line classification ──────────────────────────────────────────────────── + +/** Check if a ┊ line is an assistant message (┊ 💬 ...). */ +function isAssistantToolLine(stripped: string): boolean { + return /^┊\s*💬/.test(stripped); +} + +/** Extract assistant text from a ┊ 💬 line. */ +function extractAssistantText(line: string): string { + return line.replace(/^[\s┊]*💬\s*/, "").trim(); +} + +/** + * Parse a tool completion line into structured data. + * + * Handles both TTY and pipe formats: + * TTY: ┊ 💻 $ curl -s "..." 0.1s + * Pipe: [done] ┊ 💻 $ curl -s "..." 0.1s (0.5s) + */ +function parseToolCompletionLine( + line: string, +): { name: string; detail: string; duration: string; hasError: boolean } | null { + // Strip leading whitespace and [done] prefix + let cleaned = line.trim().replace(/^\[done\]\s*/, ""); + + // Must start with ┊ + if (!cleaned.startsWith(TOOL_OUTPUT_PREFIX)) return null; + + // Remove ┊ prefix and any leading kaomoji face + cleaned = cleaned.slice(TOOL_OUTPUT_PREFIX.length); + cleaned = stripKaomoji(cleaned).trim(); + + // Now format is: "{emoji} {verb:9} {detail} {duration}" or "{emoji} {verb:9} {detail} {duration} ({total})" + // Example: "💻 $ curl -s ..." or "🔍 search pattern 0.1s" + // The verb+detail are separated by whitespace, duration is at the end + + // Match: emoji + verb + detail + duration + // Duration pattern: N.Ns (possibly followed by (N.Ns)) + const durationMatch = cleaned.match(/([\d.]+s)\s*(?:\([\d.]+s\))?\s*$/); + const duration = durationMatch ? durationMatch[1] : ""; + + // Remove duration from the end to get verb + detail + let verbAndDetail = durationMatch + ? cleaned.slice(0, cleaned.lastIndexOf(durationMatch[0])).trim() + : cleaned; + verbAndDetail = verbAndDetail.replace(/^\p{Emoji_Presentation}\s*/u, ""); + + // Check for error suffixes + const hasError = /\[(?:exit \d+|error|full)\]/.test(verbAndDetail) || + /\[error\]\s*$/.test(cleaned); + + // The first token (after emoji) is the verb, rest is detail + // Verbs are always a single word or symbol ($ for terminal) + const parts = verbAndDetail.match(/^(\S+)\s+(.*)/); + if (!parts) { + return { name: "tool", detail: verbAndDetail, duration, hasError }; + } + + const verb = parts[1]; + const detail = parts[2].trim(); + + // Map Hermes verbs to readable tool names + const nameMap: Record<string, string> = { + "$": "shell", + "exec": "shell", + "terminal": "shell", + "search": "search", + "fetch": "fetch", + "crawl": "crawl", + "navigate": "browser", + "snapshot": "browser", + "click": "browser", + "type": "browser", + "scroll": "browser", + "back": "browser", + "press": "browser", + "close": "browser", + "images": "browser", + "vision": "browser", + "read": "read", + "write": "write", + "patch": "patch", + "grep": "search", + "find": "search", + "plan": "plan", + "recall": "recall", + "proc": "process", + "delegate": "delegate", + "todo": "todo", + "memory": "memory", + "clarify": "clarify", + "session_search": "recall", + "code": "execute", + "execute": "execute", + "web_search": "search", + "web_extract": "fetch", + "browser_navigate": "browser", + "browser_click": "browser", + "browser_type": "browser", + "browser_snapshot": "browser", + "browser_vision": "browser", + "browser_scroll": "browser", + "browser_press": "browser", + "browser_back": "browser", + "browser_close": "browser", + "browser_get_images": "browser", + "read_file": "read", + "write_file": "write_file", + "search_files": "search", + "patch_file": "patch", + "execute_code": "execute", + }; + + const name = nameMap[verb.toLowerCase()] || verb; + + return { name, detail, duration, hasError }; +} + +// ── Synthetic tool ID generation ──────────────────────────────────────────── + +let toolCallCounter = 0; + +/** + * Generate a synthetic toolUseId for pairing tool_call with tool_result. + * Paperclip uses this to match them in normalizeTranscript. + */ +function syntheticToolUseId(): string { + return `hermes-tool-${++toolCallCounter}`; +} + +// ── Thinking detection ───────────────────────────────────────────────────── + +function isThinkingLine(line: string): boolean { + return ( + line.includes("💭") || + line.startsWith("<thinking>") || + line.startsWith("</thinking>") || + line.startsWith("Thinking:") + ); +} + +// ── Main parser ──────────────────────────────────────────────────────────── + +/** + * Parse a single line of Hermes stdout into transcript entries. + * + * Emits structured tool_call/tool_result pairs (with synthetic IDs) so + * Paperclip renders proper tool cards with status icons and expand/collapse. + * + * @param line Raw stdout line from Hermes CLI + * @param ts ISO timestamp for the entry + * @returns Array of TranscriptEntry objects (may be empty) + */ +export function parseHermesStdoutLine( + line: string, + ts: string, +): TranscriptEntry[] { + const trimmed = line.trim(); + if (!trimmed) return []; + + // ── System/adapter messages ──────────────────────────────────────────── + if (trimmed.startsWith("[hermes]") || trimmed.startsWith("[paperclip]")) { + return [{ kind: "system", ts, text: trimmed }]; + } + + // ── Non-quiet mode tool start lines: [tool] (kaomoji) emoji verb ... ── + // These are redundant — the tool_call/tool_result pair arrives later from + // the ┊ completion line. Skip them to avoid duplicate entries. + if (trimmed.startsWith("[tool]")) { + return []; + } + + // ── MCP / server init noise reclassified from stderr by wrappedOnLog ── + // Pattern: [2026-03-25T10:40:53.941Z] INFO: ... + // Emit as stderr so Paperclip groups them into the amber accordion. + if (/^\[\d{4}-\d{2}-\d{2}T/.test(trimmed)) { + return [{ kind: "stderr", ts, text: trimmed }]; + } + + // ── Standalone spinner remnants: "💻 Completed", "💻\nCompleted", etc. ─ + // These are non-quiet mode spinner frame leftovers — skip them. + if (/^\p{Emoji_Presentation}\s*(Completed|Running|Error)?\s*$/u.test(trimmed)) { + return []; + } + + // ── Session info line ──────────────────────────────────────────────── + if (trimmed.startsWith("session_id:")) { + return [{ kind: "system", ts, text: trimmed }]; + } + + // ── Quiet-mode tool/message lines (prefixed with ┊) ──────────────────── + if (trimmed.includes(TOOL_OUTPUT_PREFIX)) { + // Assistant message: ┊ 💬 {text} + if (isAssistantToolLine(trimmed)) { + return [{ kind: "assistant", ts, text: extractAssistantText(trimmed) }]; + } + + // Tool completion: ┊ {emoji} {verb} {detail} {duration} + const toolInfo = parseToolCompletionLine(trimmed); + if (toolInfo) { + const id = syntheticToolUseId(); + const detailText = toolInfo.duration + ? `${toolInfo.detail} ${toolInfo.duration}` + : toolInfo.detail; + + return [ + { + kind: "tool_call" as const, + ts, + name: toolInfo.name, + input: { detail: toolInfo.detail }, + toolUseId: id, + }, + { + kind: "tool_result" as const, + ts, + toolUseId: id, + content: detailText, + isError: toolInfo.hasError, + }, + ] as TranscriptEntry[]; + } + + // Fallback: raw ┊ line that doesn't match tool format + const stripped = trimmed + .replace(/^\[done\]\s*/, "") + .replace(new RegExp(`^${TOOL_OUTPUT_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*`), "") + .trim(); + return [{ kind: "stdout", ts, text: stripped }]; + } + + // ── Thinking blocks ──────────────────────────────────────────────────── + if (isThinkingLine(trimmed)) { + return [ + { + kind: "thinking", + ts, + text: trimmed.replace(/^💭\s*/, ""), + }, + ]; + } + + // ── Error output ─────────────────────────────────────────────────────── + if ( + trimmed.startsWith("Error:") || + trimmed.startsWith("ERROR:") || + trimmed.startsWith("Traceback") + ) { + return [{ kind: "stderr", ts, text: trimmed }]; + } + + // ── Regular assistant output ─────────────────────────────────────────── + return [{ kind: "assistant", ts, text: trimmed }]; +} diff --git a/packages/adapters/hermes/tsconfig.json b/packages/adapters/hermes/tsconfig.json new file mode 100644 index 0000000000..8fea361a34 --- /dev/null +++ b/packages/adapters/hermes/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src"] +} diff --git a/packages/adapters/hermes/ui-parser.cjs b/packages/adapters/hermes/ui-parser.cjs new file mode 100644 index 0000000000..618c37606c --- /dev/null +++ b/packages/adapters/hermes/ui-parser.cjs @@ -0,0 +1,193 @@ +"use strict"; + +const TOOL_OUTPUT_PREFIX = "\u250a"; + +function stripKaomoji(text) { + return text.replace(/[(][^()]{2,20}[)]\s*/gu, "").trim(); +} + +function isAssistantToolLine(stripped) { + return /^\u250a\s*\u{1f4ac}/u.test(stripped); +} + +function extractAssistantText(line) { + return line.replace(/^[\s\u250a]*\u{1f4ac}\s*/u, "").trim(); +} + +function parseToolCompletionLine(line) { + let cleaned = line.trim().replace(/^\[done\]\s*/, ""); + if (!cleaned.startsWith(TOOL_OUTPUT_PREFIX)) return null; + + cleaned = cleaned.slice(TOOL_OUTPUT_PREFIX.length); + cleaned = stripKaomoji(cleaned).trim(); + + const durationMatch = cleaned.match(/([\d.]+s)\s*(?:\([\d.]+s\))?\s*$/); + const duration = durationMatch ? durationMatch[1] : ""; + const verbAndDetail = durationMatch + ? cleaned.slice(0, cleaned.lastIndexOf(durationMatch[0])).trim() + : cleaned; + const detailWithoutEmoji = verbAndDetail.replace(/^\p{Emoji_Presentation}\s*/u, ""); + + const hasError = /\[(?:exit \d+|error|full)\]/.test(detailWithoutEmoji) || + /\[error\]\s*$/.test(cleaned); + + const parts = detailWithoutEmoji.match(/^(\S+)\s+(.*)/); + if (!parts) { + return { name: "tool", detail: detailWithoutEmoji, duration, hasError }; + } + + const verb = parts[1]; + const detail = parts[2].trim(); + const nameMap = { + "$": "shell", + exec: "shell", + terminal: "shell", + search: "search", + fetch: "fetch", + crawl: "crawl", + navigate: "browser", + snapshot: "browser", + click: "browser", + type: "browser", + scroll: "browser", + back: "browser", + press: "browser", + close: "browser", + images: "browser", + vision: "browser", + read: "read", + write: "write", + patch: "patch", + grep: "search", + find: "search", + plan: "plan", + recall: "recall", + proc: "process", + delegate: "delegate", + todo: "todo", + memory: "memory", + clarify: "clarify", + session_search: "recall", + code: "execute", + execute: "execute", + web_search: "search", + web_extract: "fetch", + browser_navigate: "browser", + browser_click: "browser", + browser_type: "browser", + browser_snapshot: "browser", + browser_vision: "browser", + browser_scroll: "browser", + browser_press: "browser", + browser_back: "browser", + browser_close: "browser", + browser_get_images: "browser", + read_file: "read", + write_file: "write_file", + search_files: "search", + patch_file: "patch", + execute_code: "execute", + }; + + return { + name: nameMap[verb.toLowerCase()] || verb, + detail, + duration, + hasError, + }; +} + +let toolCallCounter = 0; + +function syntheticToolUseId() { + toolCallCounter += 1; + return `hermes-tool-${toolCallCounter}`; +} + +function isThinkingLine(line) { + return ( + line.includes("\u{1f4ad}") || + line.startsWith("<thinking>") || + line.startsWith("</thinking>") || + line.startsWith("Thinking:") + ); +} + +function parseStdoutLine(line, ts) { + const trimmed = line.trim(); + if (!trimmed) return []; + + if (trimmed.startsWith("[hermes]") || trimmed.startsWith("[paperclip]")) { + return [{ kind: "system", ts, text: trimmed }]; + } + + if (trimmed.startsWith("[tool]")) { + return []; + } + + if (/^\[\d{4}-\d{2}-\d{2}T/.test(trimmed)) { + return [{ kind: "stderr", ts, text: trimmed }]; + } + + if (/^\p{Emoji_Presentation}\s*(Completed|Running|Error)?\s*$/u.test(trimmed)) { + return []; + } + + if (trimmed.startsWith("session_id:")) { + return [{ kind: "system", ts, text: trimmed }]; + } + + if (trimmed.includes(TOOL_OUTPUT_PREFIX)) { + if (isAssistantToolLine(trimmed)) { + return [{ kind: "assistant", ts, text: extractAssistantText(trimmed) }]; + } + + const toolInfo = parseToolCompletionLine(trimmed); + if (toolInfo) { + const id = syntheticToolUseId(); + const detailText = toolInfo.duration + ? `${toolInfo.detail} ${toolInfo.duration}` + : toolInfo.detail; + + return [ + { + kind: "tool_call", + ts, + name: toolInfo.name, + input: { detail: toolInfo.detail }, + toolUseId: id, + }, + { + kind: "tool_result", + ts, + toolUseId: id, + content: detailText, + isError: toolInfo.hasError, + }, + ]; + } + + const escapedPrefix = TOOL_OUTPUT_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const stripped = trimmed + .replace(/^\[done\]\s*/, "") + .replace(new RegExp(`^${escapedPrefix}\\s*`), "") + .trim(); + return [{ kind: "stdout", ts, text: stripped }]; + } + + if (isThinkingLine(trimmed)) { + return [{ kind: "thinking", ts, text: trimmed.replace(/^\u{1f4ad}\s*/u, "") }]; + } + + if ( + trimmed.startsWith("Error:") || + trimmed.startsWith("ERROR:") || + trimmed.startsWith("Traceback") + ) { + return [{ kind: "stderr", ts, text: trimmed }]; + } + + return [{ kind: "assistant", ts, text: trimmed }]; +} + +module.exports = { parseStdoutLine }; diff --git a/packages/adapters/hermes/vitest.config.ts b/packages/adapters/hermes/vitest.config.ts new file mode 100644 index 0000000000..ad7d95889a --- /dev/null +++ b/packages/adapters/hermes/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + exclude: ["dist/**", "node_modules/**"], + }, +}); diff --git a/packages/adapters/opencode-local/src/server/execute.remote.test.ts b/packages/adapters/opencode-local/src/server/execute.remote.test.ts index f13fa4d5e6..dab6280423 100644 --- a/packages/adapters/opencode-local/src/server/execute.remote.test.ts +++ b/packages/adapters/opencode-local/src/server/execute.remote.test.ts @@ -1,7 +1,7 @@ import { mkdir, mkdtemp, rm } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const { runChildProcess, @@ -102,9 +102,19 @@ import { execute } from "./execute.js"; describe("opencode remote execution", () => { const cleanupDirs: string[] = []; + const originalOpenCodeAllowAllModels = process.env.OPENCODE_ALLOW_ALL_MODELS; + + beforeEach(() => { + delete process.env.OPENCODE_ALLOW_ALL_MODELS; + }); afterEach(async () => { vi.clearAllMocks(); + if (originalOpenCodeAllowAllModels === undefined) { + delete process.env.OPENCODE_ALLOW_ALL_MODELS; + } else { + process.env.OPENCODE_ALLOW_ALL_MODELS = originalOpenCodeAllowAllModels; + } while (cleanupDirs.length > 0) { const dir = cleanupDirs.pop(); if (!dir) continue; diff --git a/packages/db/src/migrations/0124_agent_api_key_scope_config.sql b/packages/db/src/migrations/0124_agent_api_key_scope_config.sql new file mode 100644 index 0000000000..52167d2526 --- /dev/null +++ b/packages/db/src/migrations/0124_agent_api_key_scope_config.sql @@ -0,0 +1 @@ +ALTER TABLE "agent_api_keys" ADD COLUMN IF NOT EXISTS "scope_config" jsonb; diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 1956b93218..6c63f8b5d9 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -869,6 +869,13 @@ "when": 1781903700000, "tag": "0123_document_annotation_source_trust", "breakpoints": true + }, + { + "idx": 124, + "version": "7", + "when": 1782440000000, + "tag": "0124_agent_api_key_scope_config", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/agent_api_keys.ts b/packages/db/src/schema/agent_api_keys.ts index 025fab437f..8cc430ae00 100644 --- a/packages/db/src/schema/agent_api_keys.ts +++ b/packages/db/src/schema/agent_api_keys.ts @@ -1,4 +1,5 @@ -import { pgTable, uuid, text, timestamp, index } from "drizzle-orm/pg-core"; +import { pgTable, uuid, text, timestamp, index, jsonb } from "drizzle-orm/pg-core"; +import type { AgentApiKeyScope } from "@paperclipai/shared"; import { agents } from "./agents.js"; import { companies } from "./companies.js"; @@ -10,6 +11,7 @@ export const agentApiKeys = pgTable( companyId: uuid("company_id").notNull().references(() => companies.id), name: text("name").notNull(), keyHash: text("key_hash").notNull(), + scopeConfig: jsonb("scope_config").$type<AgentApiKeyScope | null>(), lastUsedAt: timestamp("last_used_at", { withTimezone: true }), revokedAt: timestamp("revoked_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index f4c9836762..8725947eca 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -35,6 +35,8 @@ export const AGENT_ADAPTER_TYPES = [ "codex_local", "cursor_cloud", "gemini_local", + "hermes_gateway", + "hermes_local", "opencode_local", "pi_local", "cursor", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index d098b39d00..07f4dd4256 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1052,6 +1052,10 @@ export { updateAgentInstructionsBundleSchema, upsertAgentInstructionsFileSchema, updateAgentInstructionsPathSchema, + agentApiKeyScopeSchema, + normalizeAgentApiKeyScope, + standardAgentKeyScopeSchema, + taskBridgeAgentKeyScopeSchema, createAgentKeySchema, agentMineInboxQuerySchema, wakeAgentSchema, @@ -1065,6 +1069,8 @@ export { type UpdateAgentInstructionsBundle, type UpsertAgentInstructionsFile, type UpdateAgentInstructionsPath, + type AgentApiKeyScope, + type TaskBridgeAgentKeyScope, type CreateAgentKey, type AgentMineInboxQuery, type WakeAgent, diff --git a/packages/shared/src/types/agent.ts b/packages/shared/src/types/agent.ts index ad16c7dad8..38df57ba1e 100644 --- a/packages/shared/src/types/agent.ts +++ b/packages/shared/src/types/agent.ts @@ -14,6 +14,7 @@ import type { TrustPreset, } from "../trust-policy.js"; import type { AgentOrgChainHealth } from "../agent-eligibility.js"; +import type { AgentApiKeyScope } from "../validators/agent.js"; export interface AgentPermissions extends Record<string, unknown> { canCreateAgents: boolean; @@ -116,6 +117,7 @@ export type ClearAgentErrorResponse = Agent; export interface AgentKeyCreated { id: string; name: string; + scope: AgentApiKeyScope; token: string; createdAt: Date; } diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index 69f1b5b233..65934b7e6d 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -114,8 +114,45 @@ export const updateAgentInstructionsPathSchema = z.object({ export type UpdateAgentInstructionsPath = z.infer<typeof updateAgentInstructionsPathSchema>; +export const taskBridgeAgentKeyScopeSchema = z.object({ + kind: z.literal("task_bridge"), + projectId: z.string().uuid().optional().nullable(), + projectIds: z.array(z.string().uuid()).max(50).optional(), + parentIssueId: z.string().uuid().optional().nullable(), + parentIssueIds: z.array(z.string().uuid()).max(50).optional(), + allowedAssigneeAgentIds: z.array(z.string().uuid()).max(50).optional(), +}).strict().superRefine((value, ctx) => { + const hasProjectBoundary = Boolean(value.projectId) || Boolean(value.projectIds?.length); + const hasParentBoundary = Boolean(value.parentIssueId) || Boolean(value.parentIssueIds?.length); + if (!hasProjectBoundary && !hasParentBoundary) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "task_bridge keys require at least one project or parent issue boundary", + path: ["projectId"], + }); + } +}); + +export const standardAgentKeyScopeSchema = z.object({ + kind: z.literal("standard"), +}).strict(); + +export const agentApiKeyScopeSchema = z.union([ + standardAgentKeyScopeSchema, + taskBridgeAgentKeyScopeSchema, +]); + +export type AgentApiKeyScope = z.infer<typeof agentApiKeyScopeSchema>; +export type TaskBridgeAgentKeyScope = z.infer<typeof taskBridgeAgentKeyScopeSchema>; + +export function normalizeAgentApiKeyScope(value: unknown): AgentApiKeyScope { + const parsed = agentApiKeyScopeSchema.safeParse(value); + return parsed.success ? parsed.data : { kind: "standard" }; +} + export const createAgentKeySchema = z.object({ name: z.string().min(1).default("default"), + scope: agentApiKeyScopeSchema.optional().default({ kind: "standard" }), }); export type CreateAgentKey = z.infer<typeof createAgentKeySchema>; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 43fcc8ac84..9e3fd62649 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -207,6 +207,10 @@ export { updateAgentInstructionsBundleSchema, upsertAgentInstructionsFileSchema, updateAgentInstructionsPathSchema, + agentApiKeyScopeSchema, + normalizeAgentApiKeyScope, + standardAgentKeyScopeSchema, + taskBridgeAgentKeyScopeSchema, createAgentKeySchema, agentMineInboxQuerySchema, wakeAgentSchema, @@ -220,6 +224,8 @@ export { type UpdateAgentInstructionsBundle, type UpsertAgentInstructionsFile, type UpdateAgentInstructionsPath, + type AgentApiKeyScope, + type TaskBridgeAgentKeyScope, type CreateAgentKey, type AgentMineInboxQuery, type WakeAgent, diff --git a/scripts/bootstrap-npm-package.mjs b/scripts/bootstrap-npm-package.mjs index 9b1e01beb0..b255d59ab6 100644 --- a/scripts/bootstrap-npm-package.mjs +++ b/scripts/bootstrap-npm-package.mjs @@ -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, diff --git a/scripts/bootstrap-npm-package.test.mjs b/scripts/bootstrap-npm-package.test.mjs index 32270f6be4..2f68662a90 100644 --- a/scripts/bootstrap-npm-package.test.mjs +++ b/scripts/bootstrap-npm-package.test.mjs @@ -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", + ]); +}); diff --git a/scripts/generate-npm-package-json.mjs b/scripts/generate-npm-package-json.mjs index f7be8cc04b..72fd63f443 100644 --- a/scripts/generate-npm-package-json.mjs +++ b/scripts/generate-npm-package-json.mjs @@ -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", ]; diff --git a/scripts/release-package-manifest.json b/scripts/release-package-manifest.json index 9f85129782..d0c1e83579 100644 --- a/scripts/release-package-manifest.json +++ b/scripts/release-package-manifest.json @@ -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", diff --git a/scripts/release-package-map.test.mjs b/scripts/release-package-map.test.mjs index b632b92e8b..577186057b 100644 --- a/scripts/release-package-map.test.mjs +++ b/scripts/release-package-map.test.mjs @@ -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()); }); diff --git a/scripts/smoke/hermes-gateway-e2e.sh b/scripts/smoke/hermes-gateway-e2e.sh new file mode 100755 index 0000000000..7aac9c1736 --- /dev/null +++ b/scripts/smoke/hermes-gateway-e2e.sh @@ -0,0 +1,1007 @@ +#!/usr/bin/env bash +set -euo pipefail + +log() { + echo "[hermes-gateway-e2e] $*" +} + +warn() { + echo "[hermes-gateway-e2e] WARN: $*" >&2 +} + +fail() { + echo "[hermes-gateway-e2e] ERROR: $*" >&2 + if [[ -n "${HERMES_SMOKE_DIAG_DIR:-}" ]]; then + mkdir -p "$HERMES_SMOKE_DIAG_DIR" 2>/dev/null || true + printf "%s\n" "$*" > "${HERMES_SMOKE_DIAG_DIR}/failure.txt" 2>/dev/null || true + fi + exit 1 +} + +require_cmd() { + local cmd="$1" + command -v "$cmd" >/dev/null 2>&1 || fail "missing required command: ${cmd}" +} + +PAPERCLIP_API_URL="${PAPERCLIP_API_URL:-http://127.0.0.1:3100}" +API_BASE="${PAPERCLIP_API_URL%/}/api" +COMPANY_ID="${COMPANY_ID:-${PAPERCLIP_COMPANY_ID:-}}" +COMPANY_SELECTOR="${COMPANY_SELECTOR:-}" + +RUN_SUFFIX="${HERMES_SMOKE_RUN_SUFFIX:-$(date +%Y%m%d-%H%M%S)-$$}" +HERMES_IMAGE="${HERMES_IMAGE:-paperclip-hermes-gateway-smoke:local}" +HERMES_VERSION="${HERMES_VERSION:-0.17.0}" +HERMES_BUILD="${HERMES_BUILD:-1}" +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}" +HERMES_ADAPTER_TIMEOUT_SEC="${HERMES_ADAPTER_TIMEOUT_SEC:-180}" +HERMES_DIRECT_RUN_TIMEOUT_SEC="${HERMES_DIRECT_RUN_TIMEOUT_SEC:-180}" +HERMES_DIRECT_RUN_EVENTS_TIMEOUT_SEC="${HERMES_DIRECT_RUN_EVENTS_TIMEOUT_SEC:-20}" +HERMES_STOP_ASSERT="${HERMES_STOP_ASSERT:-auto}" +HERMES_SMOKE_KEEP="${HERMES_SMOKE_KEEP:-0}" +HERMES_SMOKE_NETWORK="${HERMES_SMOKE_NETWORK:-}" +HERMES_DOCKER_ADD_HOST="${HERMES_DOCKER_ADD_HOST:-1}" +HERMES_SMOKE_STATE_DIR="${HERMES_SMOKE_STATE_DIR:-${TMPDIR:-/tmp}/paperclip-hermes-gateway-smoke-${RUN_SUFFIX}}" +HERMES_SMOKE_DIAG_DIR="${HERMES_SMOKE_DIAG_DIR:-${TMPDIR:-/tmp}/paperclip-hermes-gateway-e2e-diag-${RUN_SUFFIX}}" +HERMES_SMOKE_MODEL_PROVIDER="${HERMES_SMOKE_MODEL_PROVIDER:-}" +HERMES_SMOKE_MODEL_DEFAULT="${HERMES_SMOKE_MODEL_DEFAULT:-}" +HERMES_SMOKE_MODEL_BASE_URL="${HERMES_SMOKE_MODEL_BASE_URL:-}" +HERMES_AGENT_NAME="${HERMES_AGENT_NAME:-Hermes Gateway Smoke Agent ${RUN_SUFFIX}}" +PAPERCLIP_API_URL_FOR_HERMES="${PAPERCLIP_API_URL_FOR_HERMES:-http://host.docker.internal:3100}" +RUN_TIMEOUT_SEC="${RUN_TIMEOUT_SEC:-420}" +CASE_TIMEOUT_SEC="${CASE_TIMEOUT_SEC:-420}" +GATEWAY_READY_TIMEOUT_SEC="${GATEWAY_READY_TIMEOUT_SEC:-90}" +STRICT_CASES="${STRICT_CASES:-1}" +HERMES_PROVIDER_ENV_KEYS=( + OPENROUTER_API_KEY + OPENAI_API_KEY + ANTHROPIC_API_KEY + GEMINI_API_KEY + GOOGLE_API_KEY + MISTRAL_API_KEY +) + +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 <board-token>' # or PAPERCLIP_COOKIE + +Common flags: + COMPANY_ID=<uuid> or COMPANY_SELECTOR=<prefix|name|uuid> + 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=<docker-network> + HERMES_DOCKER_ADD_HOST=0|1 + HERMES_SMOKE_KEEP=1 # keep diagnostics/container + HERMES_SMOKE_DIAG_DIR=/tmp/hermes-gateway-diag + HERMES_SMOKE_MODEL_PROVIDER=openrouter + HERMES_SMOKE_MODEL_DEFAULT=z-ai/glm-5.2 + HERMES_SMOKE_MODEL_BASE_URL=https://openrouter.ai/api/v1 + +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 E2E helper seeds a minimal non-secret Hermes config in the fresh container + state, including command_allowlist: execute_code so gateway/API runs do not + pause on an interactive approval prompt. + 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}") +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}") + PAPERCLIP_BROWSER_ORIGIN="${PAPERCLIP_BROWSER_ORIGIN:-${PAPERCLIP_API_URL%/}}" + AUTH_HEADERS+=(-H "Origin: ${PAPERCLIP_BROWSER_ORIGIN}" -H "Referer: ${PAPERCLIP_BROWSER_ORIGIN}/") +fi + +RESPONSE_CODE="" +RESPONSE_BODY="" +AGENT_ID="" +AGENT_API_KEY="" +INVITE_ID="" +JOIN_REQUEST_ID="" +KEY_ID="" +SMOKE_ISSUE_ID="" +SMOKE_ISSUE_IDENTIFIER="" +RUN_ID="" +DIRECT_RUN_ID="" +STOP_RUN_ID="" +JOIN_OUTPUT_FILE="${HERMES_SMOKE_DIAG_DIR}/join-output.json" +KEEP_ON_EXIT="$HERMES_SMOKE_KEEP" + +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() { + if command -v openssl >/dev/null 2>&1; then + openssl rand -hex 32 + elif command -v node >/dev/null 2>&1; then + node -e 'process.stdout.write(require("node:crypto").randomBytes(32).toString("hex"))' + else + fail "missing openssl or node for API_SERVER_KEY generation" + fi +} + +redact_text() { + local text="$1" + local secret + for secret in \ + "${HERMES_GATEWAY_API_KEY:-}" \ + "${AGENT_API_KEY:-}" \ + "${PAPERCLIP_API_KEY:-}" \ + "${PAPERCLIP_AUTH_HEADER:-}" \ + "${PAPERCLIP_COOKIE:-}"; do + if [[ -n "$secret" ]]; then + text="${text//$secret/[redacted len=${#secret}]}" + fi + done + local key + for key in "${HERMES_PROVIDER_ENV_KEYS[@]}"; do + secret="${!key-}" + if [[ -n "$secret" ]]; then + text="${text//$secret/[redacted len=${#secret}]}" + fi + done + 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" + 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 + redact_text "$RESPONSE_BODY" >&2 + echo >&2 + fail "expected HTTP ${expected}, got HTTP ${RESPONSE_CODE}" + fi +} + +gateway_request() { + local method="$1" + local path="$2" + local data="${3-}" + local output_file="${4-}" + local tmp + tmp="$(mktemp)" + + 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 + RESPONSE_CODE="$(curl -s -o "$tmp" -w "%{http_code}" -X "$method" -H "Authorization: Bearer ${HERMES_GATEWAY_API_KEY}" "$url" || true)" + fi + RESPONSE_BODY="$(cat "$tmp")" + if [[ -n "$output_file" ]]; then + redact_text "$RESPONSE_BODY" > "$output_file" + fi + rm -f "$tmp" +} + +wait_http_ready() { + local url="$1" + local timeout_sec="$2" + local started now code + started="$(date +%s)" + while true; do + code="$(curl -sS -o /dev/null -w "%{http_code}" -H "Authorization: Bearer ${HERMES_GATEWAY_API_KEY}" "$url" || true)" + if [[ "$code" == "200" ]]; then + return 0 + fi + now="$(date +%s)" + if (( now - started >= timeout_sec )); then + return 1 + fi + sleep 1 + done +} + +require_board_auth() { + if [[ ${#AUTH_HEADERS[@]} -eq 0 ]]; then + fail "board/operator auth required. Set PAPERCLIP_COOKIE, PAPERCLIP_AUTH_HEADER, or a board-capable PAPERCLIP_API_KEY." + fi + api_request "GET" "/companies" + if [[ "$RESPONSE_CODE" != "200" ]]; then + redact_text "$RESPONSE_BODY" >&2 + echo >&2 + fail "board/operator auth invalid for /api/companies (HTTP ${RESPONSE_CODE})" + fi +} + +resolve_company_id() { + if [[ -n "$COMPANY_ID" ]]; then + log "using company ${COMPANY_ID}" + return + fi + + api_request "GET" "/companies" + 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" + fi + log "resolved company ${COMPANY_ID}" +} + +capture_container_logs() { + mkdir -p "$HERMES_SMOKE_DIAG_DIR" + docker logs --tail=2000 "$HERMES_CONTAINER_NAME" > "${HERMES_SMOKE_DIAG_DIR}/hermes-container.log" 2>&1 || true + if [[ -s "${HERMES_SMOKE_DIAG_DIR}/hermes-container.log" ]]; then + local redacted_tmp + redacted_tmp="$(mktemp)" + redact_text "$(cat "${HERMES_SMOKE_DIAG_DIR}/hermes-container.log")" > "$redacted_tmp" + mv "$redacted_tmp" "${HERMES_SMOKE_DIAG_DIR}/hermes-container.log" + fi +} + +capture_run_diagnostics() { + local run_id="$1" + local label="${2:-run}" + [[ -n "$run_id" ]] || return 0 + mkdir -p "$HERMES_SMOKE_DIAG_DIR" + + api_request "GET" "/heartbeat-runs/${run_id}/events?limit=1000" + if [[ "$RESPONSE_CODE" == "200" ]]; then + redact_text "$RESPONSE_BODY" > "${HERMES_SMOKE_DIAG_DIR}/${label}-${run_id}-events.json" + fi + + api_request "GET" "/heartbeat-runs/${run_id}/log?limitBytes=524288" + if [[ "$RESPONSE_CODE" == "200" ]]; then + redact_text "$RESPONSE_BODY" > "${HERMES_SMOKE_DIAG_DIR}/${label}-${run_id}-log.json" + jq -r '.content // ""' <<<"$RESPONSE_BODY" | while IFS= read -r line; do redact_text "$line"; echo; done > "${HERMES_SMOKE_DIAG_DIR}/${label}-${run_id}-log.txt" 2>/dev/null || true + fi +} + +capture_issue_diagnostics() { + local issue_id="$1" + local label="${2:-issue}" + [[ -n "$issue_id" ]] || return 0 + mkdir -p "$HERMES_SMOKE_DIAG_DIR" + + api_request "GET" "/issues/${issue_id}" + if [[ "$RESPONSE_CODE" == "200" ]]; then + redact_text "$RESPONSE_BODY" > "${HERMES_SMOKE_DIAG_DIR}/${label}-${issue_id}.json" + fi + + api_request "GET" "/issues/${issue_id}/comments" + if [[ "$RESPONSE_CODE" == "200" ]]; then + redact_text "$RESPONSE_BODY" > "${HERMES_SMOKE_DIAG_DIR}/${label}-${issue_id}-comments.json" + fi +} + +capture_diagnostics() { + mkdir -p "$HERMES_SMOKE_DIAG_DIR" + { + echo "runSuffix=${RUN_SUFFIX}" + echo "companyId=${COMPANY_ID:-}" + echo "agentId=${AGENT_ID:-}" + echo "inviteId=${INVITE_ID:-}" + echo "joinRequestId=${JOIN_REQUEST_ID:-}" + 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}" + echo "agentApiKeySha256=$(hash_prefix "${AGENT_API_KEY:-}") len=${#AGENT_API_KEY}" + } > "${HERMES_SMOKE_DIAG_DIR}/summary.env" + + gateway_request "GET" "/health" "" "${HERMES_SMOKE_DIAG_DIR}/gateway-health.json" || true + gateway_request "GET" "/v1/capabilities" "" "${HERMES_SMOKE_DIAG_DIR}/gateway-capabilities.json" || true + capture_container_logs + capture_issue_diagnostics "$SMOKE_ISSUE_ID" "paperclip-smoke" + capture_run_diagnostics "$RUN_ID" "paperclip-smoke" +} + +cleanup_paperclip_state() { + [[ "$KEEP_ON_EXIT" != "1" ]] || return 0 + + if [[ -n "$SMOKE_ISSUE_ID" ]]; then + log "deleting smoke issue ${SMOKE_ISSUE_ID}" + api_request "DELETE" "/issues/${SMOKE_ISSUE_ID}" + if [[ "$RESPONSE_CODE" != "200" && "$RESPONSE_CODE" != "404" && "$RESPONSE_CODE" != "204" ]]; then + warn "delete issue returned HTTP ${RESPONSE_CODE}" + fi + fi + + if [[ -n "$AGENT_ID" ]]; then + log "terminating/deleting smoke agent ${AGENT_ID}" + api_request "POST" "/agents/${AGENT_ID}/terminate" "{}" + if [[ "$RESPONSE_CODE" != "200" && "$RESPONSE_CODE" != "404" ]]; then + warn "terminate agent returned HTTP ${RESPONSE_CODE}" + fi + api_request "DELETE" "/agents/${AGENT_ID}" + if [[ "$RESPONSE_CODE" != "200" && "$RESPONSE_CODE" != "404" && "$RESPONSE_CODE" != "204" ]]; then + warn "delete agent returned HTTP ${RESPONSE_CODE}" + fi + fi + + if [[ -n "$JOIN_REQUEST_ID" && -n "$COMPANY_ID" ]]; then + api_request "POST" "/companies/${COMPANY_ID}/join-requests/${JOIN_REQUEST_ID}/reject" "{}" + if [[ "$RESPONSE_CODE" != "200" && "$RESPONSE_CODE" != "404" && "$RESPONSE_CODE" != "409" ]]; then + warn "reject join request returned HTTP ${RESPONSE_CODE}" + fi + fi +} + +cleanup_local_state() { + [[ "$KEEP_ON_EXIT" != "1" ]] || return 0 + + docker rm -f "$HERMES_CONTAINER_NAME" >/dev/null 2>&1 || true + rm -rf "$HERMES_SMOKE_STATE_DIR" + rm -f "$JOIN_OUTPUT_FILE" +} + +on_exit() { + local status=$? + if [[ "$status" -ne 0 ]]; then + KEEP_ON_EXIT=1 + warn "smoke failed; preserving diagnostics/state" + capture_diagnostics || true + fi + + cleanup_paperclip_state || true + cleanup_local_state || true + + if [[ "$KEEP_ON_EXIT" == "1" ]]; then + warn "retained diagnostics: ${HERMES_SMOKE_DIAG_DIR}" + warn "retained state dir: ${HERMES_SMOKE_STATE_DIR}" + warn "retained container: ${HERMES_CONTAINER_NAME}" + fi + exit "$status" +} +trap on_exit EXIT + +build_image() { + if [[ "$HERMES_BUILD" != "1" ]]; then + log "HERMES_BUILD=${HERMES_BUILD}; reusing image ${HERMES_IMAGE}" + return + fi + log "building Hermes gateway image ${HERMES_IMAGE} (HERMES_VERSION=${HERMES_VERSION})" + docker build --build-arg "HERMES_VERSION=${HERMES_VERSION}" -t "$HERMES_IMAGE" "$HERMES_DOCKER_CONTEXT" +} + +prepare_fresh_state() { + mkdir -p "$HERMES_SMOKE_DIAG_DIR" + if [[ -e "$HERMES_SMOKE_STATE_DIR" && "$HERMES_SMOKE_KEEP" != "1" ]]; then + rm -rf "$HERMES_SMOKE_STATE_DIR" + fi + mkdir -p \ + "${HERMES_SMOKE_STATE_DIR}/hermes-home" \ + "${HERMES_SMOKE_STATE_DIR}/workspace" \ + "${HERMES_SMOKE_STATE_DIR}/fake-host-home/.hermes" + # These host-created bind mounts must be readable and writable by the + # non-root hermes user (uid 10001) inside the container. + chmod 777 "${HERMES_SMOKE_STATE_DIR}/hermes-home" "${HERMES_SMOKE_STATE_DIR}/workspace" || true + echo "host hermes sentinel ${RUN_SUFFIX}" > "${HERMES_SMOKE_STATE_DIR}/fake-host-home/.hermes/host-sentinel.txt" + + if find "${HERMES_SMOKE_STATE_DIR}/hermes-home" -mindepth 1 -print -quit | grep -q .; then + fail "Hermes state dir is not empty: ${HERMES_SMOKE_STATE_DIR}/hermes-home" + fi +} + +yaml_single_quote() { + local value="$1" + value="${value//\'/\'\'}" + printf "'%s'" "$value" +} + +write_hermes_model_config() { + local has_model_config=0 + if [[ -n "$HERMES_SMOKE_MODEL_PROVIDER" || -n "$HERMES_SMOKE_MODEL_DEFAULT" || -n "$HERMES_SMOKE_MODEL_BASE_URL" ]]; then + has_model_config=1 + fi + if [[ "$has_model_config" == "1" && ( -z "$HERMES_SMOKE_MODEL_PROVIDER" || -z "$HERMES_SMOKE_MODEL_DEFAULT" ) ]]; then + fail "HERMES_SMOKE_MODEL_PROVIDER and HERMES_SMOKE_MODEL_DEFAULT must be set together" + fi + + local config_file="${HERMES_SMOKE_STATE_DIR}/hermes-home/config.yaml" + if [[ -e "$config_file" ]]; then + fail "Hermes model config already exists in fresh state: ${config_file}" + fi + + { + if [[ "$has_model_config" == "1" ]]; then + echo "model:" + printf " default: %s\n" "$(yaml_single_quote "$HERMES_SMOKE_MODEL_DEFAULT")" + printf " provider: %s\n" "$(yaml_single_quote "$HERMES_SMOKE_MODEL_PROVIDER")" + if [[ -n "$HERMES_SMOKE_MODEL_BASE_URL" ]]; then + printf " base_url: %s\n" "$(yaml_single_quote "$HERMES_SMOKE_MODEL_BASE_URL")" + fi + echo "providers: {}" + fi + echo "command_allowlist:" + echo "- execute_code" + } > "$config_file" + chmod 644 "$config_file" + if [[ "$has_model_config" == "1" ]]; then + log "seeded Hermes model config provider=${HERMES_SMOKE_MODEL_PROVIDER} model=${HERMES_SMOKE_MODEL_DEFAULT}" + else + log "seeded Hermes smoke config" + fi +} + +start_container() { + docker rm -f "$HERMES_CONTAINER_NAME" >/dev/null 2>&1 || true + + local args=( + run -d + --name "$HERMES_CONTAINER_NAME" + -p "127.0.0.1:${HERMES_GATEWAY_PORT}:8642" + -e API_SERVER_ENABLED=true + -e API_SERVER_KEY="$HERMES_GATEWAY_API_KEY" + -e API_SERVER_HOST=0.0.0.0 + -e API_SERVER_PORT=8642 + -e PAPERCLIP_API_URL="$PAPERCLIP_API_URL_FOR_HERMES" + -e NO_COLOR=1 + -v "${HERMES_SMOKE_STATE_DIR}/hermes-home:/home/hermes/.hermes" + -v "${HERMES_SMOKE_STATE_DIR}/workspace:/home/hermes/workspace" + ) + local provider_key + local provider_keys=() + for provider_key in "${HERMES_PROVIDER_ENV_KEYS[@]}"; do + if [[ -n "${!provider_key-}" ]]; then + args+=(-e "${provider_key}=${!provider_key}") + provider_keys+=("$provider_key") + fi + done + if [[ ${#provider_keys[@]} -gt 0 ]]; then + log "passing Hermes inference provider env keys: ${provider_keys[*]}" + else + warn "no Hermes inference provider env keys set; direct run will fail unless Hermes state config already has a provider" + fi + if [[ -n "$HERMES_SMOKE_NETWORK" ]]; then + args+=(--network "$HERMES_SMOKE_NETWORK") + fi + if [[ "$HERMES_DOCKER_ADD_HOST" == "1" ]]; then + args+=(--add-host=host.docker.internal:host-gateway) + fi + args+=("$HERMES_IMAGE") + + log "starting container ${HERMES_CONTAINER_NAME}" + docker "${args[@]}" >/dev/null +} + +assert_fresh_container_state() { + log "asserting container does not see host Hermes state" + docker exec "$HERMES_CONTAINER_NAME" sh -lc 'test ! -e "$HERMES_HOME/host-sentinel.txt"' + docker exec "$HERMES_CONTAINER_NAME" sh -lc 'env | sort | grep -E "^(HOME|HERMES_HOME|XDG_|API_SERVER_|PAPERCLIP_API_URL)=" || true' > "${HERMES_SMOKE_DIAG_DIR}/container-env.txt" + docker exec "$HERMES_CONTAINER_NAME" sh -lc 'find "$HERMES_HOME" -maxdepth 2 -type f -print | sort' > "${HERMES_SMOKE_DIAG_DIR}/container-hermes-home-files-before.txt" || true + if docker exec "$HERMES_CONTAINER_NAME" sh -lc 'env | grep -q "^PAPERCLIP_API_KEY="'; then + fail "container unexpectedly has PAPERCLIP_API_KEY before join/key claim" + fi +} + +probe_container_to_paperclip() { + log "probing container-to-Paperclip connectivity at ${PAPERCLIP_API_URL_FOR_HERMES}/api/health" + if ! docker exec "$HERMES_CONTAINER_NAME" curl -fsS --max-time 8 "${PAPERCLIP_API_URL_FOR_HERMES%/}/api/health" > "${HERMES_SMOKE_DIAG_DIR}/container-paperclip-health.json"; then + fail "Hermes container cannot reach Paperclip. Set PAPERCLIP_API_URL_FOR_HERMES to a URL reachable from inside Docker, or keep HERMES_DOCKER_ADD_HOST=1 for Linux host.docker.internal." + fi +} + +probe_gateway_readiness() { + 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_PROBE_URL%/}/v1/capabilities" || true)" + if [[ "$wrong_code" == "200" ]]; then + fail "Hermes protected endpoint accepted a wrong API key" + fi +} + +assert_capabilities() { + log "asserting /v1/capabilities" + gateway_request "GET" "/v1/capabilities" "" "${HERMES_SMOKE_DIAG_DIR}/gateway-capabilities.json" + assert_status "200" + if ! jq -e 'type == "object"' <<<"$RESPONSE_BODY" >/dev/null; then + fail "capabilities response is not a JSON object" + fi +} + +poll_gateway_run_terminal() { + local run_id="$1" + local timeout_sec="$2" + local label="$3" + local started now status + started="$(date +%s)" + while true; do + gateway_request "GET" "/v1/runs/${run_id}" "" "${HERMES_SMOKE_DIAG_DIR}/${label}-${run_id}-status.json" + if [[ "$RESPONSE_CODE" == "200" ]]; then + status="$(jq -r '.status // empty' <<<"$RESPONSE_BODY")" + case "$status" in + completed|failed|error|cancelled|canceled|stopped|interrupted) + echo "$status" + return 0 + ;; + esac + fi + now="$(date +%s)" + if (( now - started >= timeout_sec )); then + echo "timeout" + return 0 + fi + sleep 2 + done +} + +assert_direct_gateway_run() { + local marker="HERMES_DIRECT_OK_${RUN_SUFFIX}" + local payload + payload="$(jq -nc \ + --arg marker "$marker" \ + --arg session "paperclip-smoke-direct-${RUN_SUFFIX}" \ + '{input: ("Reply with exactly " + $marker + " and no other text."), instructions: "You are running a Paperclip Hermes gateway smoke direct API assertion.", session_id: $session}')" + + log "asserting POST /v1/runs and SSE events" + gateway_request "POST" "/v1/runs" "$payload" "${HERMES_SMOKE_DIAG_DIR}/direct-run-create.json" + if [[ "$RESPONSE_CODE" != "200" && "$RESPONSE_CODE" != "202" ]]; then + redact_text "$RESPONSE_BODY" >&2 + echo >&2 + fail "expected HTTP 200 or 202, got HTTP ${RESPONSE_CODE}" + fi + DIRECT_RUN_ID="$(jq -r '.run_id // .runId // .id // empty' <<<"$RESPONSE_BODY")" + [[ -n "$DIRECT_RUN_ID" ]] || fail "direct run creation did not return run id" + + local events_file="${HERMES_SMOKE_DIAG_DIR}/direct-run-${DIRECT_RUN_ID}-events.sse" + curl -sS --max-time "$HERMES_DIRECT_RUN_EVENTS_TIMEOUT_SEC" -N \ + -H "Authorization: Bearer ${HERMES_GATEWAY_API_KEY}" \ + "${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" + local events_seen=0 + if grep -Eq '(^event:|^data:)' "$events_file"; then + events_seen=1 + else + warn "SSE stream produced no event/data frames within ${HERMES_DIRECT_RUN_EVENTS_TIMEOUT_SEC}s; polling direct run status" + fi + + local status + status="$(poll_gateway_run_terminal "$DIRECT_RUN_ID" "$HERMES_DIRECT_RUN_TIMEOUT_SEC" "direct-run")" + log "direct Hermes run ${DIRECT_RUN_ID} status=${status}" + [[ "$status" == "completed" ]] || fail "direct Hermes run did not complete successfully (status=${status})" + if [[ "$events_seen" != "1" ]]; then + warn "direct Hermes run completed, but the live SSE probe was quiet" + fi +} + +assert_stop_behavior_if_deterministic() { + [[ "$HERMES_STOP_ASSERT" != "0" ]] || { + log "HERMES_STOP_ASSERT=0; skipping /stop assertion" + return + } + + local payload + payload="$(jq -nc \ + --arg session "paperclip-smoke-stop-${RUN_SUFFIX}" \ + '{input: "Wait until stopped. If you cannot wait, emit a short acknowledgement.", instructions: "This run exists only to verify the Hermes gateway stop endpoint.", session_id: $session}')" + + log "probing /stop behavior (mode=${HERMES_STOP_ASSERT})" + gateway_request "POST" "/v1/runs" "$payload" "${HERMES_SMOKE_DIAG_DIR}/stop-run-create.json" + if [[ "$RESPONSE_CODE" != "200" && "$RESPONSE_CODE" != "202" ]]; then + redact_text "$RESPONSE_BODY" >&2 + echo >&2 + fail "expected HTTP 200 or 202, got HTTP ${RESPONSE_CODE}" + fi + STOP_RUN_ID="$(jq -r '.run_id // .runId // .id // empty' <<<"$RESPONSE_BODY")" + [[ -n "$STOP_RUN_ID" ]] || fail "stop test run creation did not return run id" + + gateway_request "POST" "/v1/runs/${STOP_RUN_ID}/stop" "{}" "${HERMES_SMOKE_DIAG_DIR}/stop-run-stop-response.json" + if [[ "$RESPONSE_CODE" != "200" && "$RESPONSE_CODE" != "202" && "$RESPONSE_CODE" != "204" ]]; then + if [[ "$HERMES_STOP_ASSERT" == "auto" ]]; then + warn "/stop returned HTTP ${RESPONSE_CODE}; treating stop assertion as non-deterministic" + return + fi + fail "/stop returned HTTP ${RESPONSE_CODE}" + fi + + local status + status="$(poll_gateway_run_terminal "$STOP_RUN_ID" 45 "stop-run")" + case "$status" in + cancelled|canceled|stopped|interrupted) + log "stop run ${STOP_RUN_ID} reached ${status}" + ;; + completed) + if [[ "$HERMES_STOP_ASSERT" == "auto" ]]; then + warn "stop run completed before cancellation could be observed; treating stop assertion as non-deterministic" + else + fail "stop run completed instead of stopping" + fi + ;; + *) + if [[ "$HERMES_STOP_ASSERT" == "auto" ]]; then + warn "stop run terminal status ${status}; treating stop assertion as non-deterministic" + else + fail "stop run did not reach a stopped/cancelled terminal status (status=${status})" + fi + ;; + esac +} + +join_hermes_agent() { + log "running join-only smoke helper" + 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" \ + HERMES_GATEWAY_TIMEOUT_SEC="$HERMES_ADAPTER_TIMEOUT_SEC" \ + PAPERCLIP_API_URL="$PAPERCLIP_API_URL" \ + PAPERCLIP_API_URL_FOR_HERMES="$PAPERCLIP_API_URL_FOR_HERMES" \ + PAPERCLIP_AUTH_HEADER="${PAPERCLIP_AUTH_HEADER:-}" \ + PAPERCLIP_API_KEY="${PAPERCLIP_API_KEY:-}" \ + PAPERCLIP_COOKIE="${PAPERCLIP_COOKIE:-}" \ + COMPANY_ID="$COMPANY_ID" \ + COMPANY_SELECTOR="$COMPANY_SELECTOR" \ + HERMES_JOIN_OUTPUT_FILE="$JOIN_OUTPUT_FILE" \ + bash scripts/smoke/hermes-gateway-join.sh > "${join_log}.raw" 2>&1 || { + redact_text "$(cat "${join_log}.raw")" > "$join_log" + rm -f "${join_log}.raw" + fail "join helper failed; see ${join_log}" + } + redact_text "$(cat "${join_log}.raw")" > "$join_log" + rm -f "${join_log}.raw" + + [[ -f "$JOIN_OUTPUT_FILE" ]] || fail "join helper did not write ${JOIN_OUTPUT_FILE}" + AGENT_ID="$(jq -r '.agentId // empty' "$JOIN_OUTPUT_FILE")" + AGENT_API_KEY="$(jq -r '.agentApiKey // empty' "$JOIN_OUTPUT_FILE")" + INVITE_ID="$(jq -r '.inviteId // empty' "$JOIN_OUTPUT_FILE")" + JOIN_REQUEST_ID="$(jq -r '.joinRequestId // empty' "$JOIN_OUTPUT_FILE")" + KEY_ID="$(jq -r '.keyId // empty' "$JOIN_OUTPUT_FILE")" + [[ -n "$AGENT_ID" && -n "$AGENT_API_KEY" ]] || fail "join output missing agent id or API key" + log "joined Hermes gateway agent ${AGENT_ID} keyId=${KEY_ID} agentKeySha256=$(hash_prefix "$AGENT_API_KEY")" +} + +install_claimed_key_in_container() { + log "placing newly claimed Paperclip key in container workspace" + local key_file="${HERMES_SMOKE_STATE_DIR}/workspace/paperclip-claimed-api-key.json" + jq -nc --arg token "$AGENT_API_KEY" '{token:$token,apiKey:$token}' > "$key_file" + # The host-created bind-mounted file must be readable by the non-root hermes + # user inside the container. The state dir is still per-run and deleted on + # success unless HERMES_SMOKE_KEEP=1. + chmod 644 "$key_file" + docker exec "$HERMES_CONTAINER_NAME" sh -lc 'test -f /home/hermes/workspace/paperclip-claimed-api-key.json && test ! -e "$HERMES_HOME/host-sentinel.txt"' +} + +patch_agent_instructions_with_claimed_key() { + log "patching Hermes agent instructions with claimed Paperclip API context" + api_request "GET" "/agents/${AGENT_ID}" + assert_status "200" + + local instructions patch_payload + instructions="For this smoke run only, call Paperclip at ${PAPERCLIP_API_URL_FOR_HERMES}. Read /home/hermes/workspace/paperclip-claimed-api-key.json and use its token as PAPERCLIP_API_KEY for Paperclip API requests. Do not reveal this key. When mutating Paperclip, include X-Paperclip-Run-Id with the current Paperclip run id when available." + patch_payload="$(jq -c --arg instructions "$instructions" ' + {adapterConfig: ((.adapterConfig // {}) + {instructions: $instructions})} + ' <<<"$RESPONSE_BODY")" + api_request "PATCH" "/agents/${AGENT_ID}" "$patch_payload" + assert_status "200" +} + +create_smoke_issue() { + local marker="HERMES_PAPERCLIP_E2E_OK_${RUN_SUFFIX}" + local title="[Hermes Gateway Smoke] ${RUN_SUFFIX}" + local description + description="Hermes gateway full Docker e2e smoke.\n\n1. Read this issue.\n2. Post a Paperclip issue comment containing exactly: ${marker}\n3. Mark this issue done.\n\nUse the Paperclip API URL and key provided in your run instructions. Do not reveal secrets." + + local payload + payload="$(jq -nc \ + --arg title "$title" \ + --arg description "$description" \ + --arg assignee "$AGENT_ID" \ + '{title:$title,description:$description,status:"todo",priority:"high",assigneeAgentId:$assignee}')" + api_request "POST" "/companies/${COMPANY_ID}/issues" "$payload" + assert_status "201" + SMOKE_ISSUE_ID="$(jq -r '.id // empty' <<<"$RESPONSE_BODY")" + SMOKE_ISSUE_IDENTIFIER="$(jq -r '.identifier // empty' <<<"$RESPONSE_BODY")" + [[ -n "$SMOKE_ISSUE_ID" ]] || fail "smoke issue create missing id" + log "created smoke issue ${SMOKE_ISSUE_ID} (${SMOKE_ISSUE_IDENTIFIER})" + echo "$marker" > "${HERMES_SMOKE_DIAG_DIR}/paperclip-marker.txt" +} + +trigger_wakeup() { + local payload + payload="$(jq -nc --arg issueId "$SMOKE_ISSUE_ID" '{source:"on_demand",triggerDetail:"manual",reason:"hermes_gateway_docker_e2e_smoke",payload:{issueId:$issueId,taskId:$issueId}}')" + api_request "POST" "/agents/${AGENT_ID}/wakeup" "$payload" + if [[ "$RESPONSE_CODE" != "202" ]]; then + redact_text "$RESPONSE_BODY" >&2 + echo >&2 + fail "wakeup failed (HTTP ${RESPONSE_CODE})" + fi + RUN_ID="$(jq -r '.id // empty' <<<"$RESPONSE_BODY")" + [[ -n "$RUN_ID" ]] || fail "wakeup response missing run id" + log "triggered Paperclip run ${RUN_ID}" +} + +get_run_status() { + local run_id="$1" + api_request "GET" "/companies/${COMPANY_ID}/heartbeat-runs?agentId=${AGENT_ID}&limit=200" + if [[ "$RESPONSE_CODE" != "200" ]]; then + echo "" + return 0 + fi + jq -r --arg runId "$run_id" '.[] | select(.id == $runId) | .status' <<<"$RESPONSE_BODY" | head -n1 +} + +wait_for_run_terminal() { + local run_id="$1" + local timeout_sec="$2" + local started now status + started="$(date +%s)" + while true; do + status="$(get_run_status "$run_id")" + if [[ "$status" == "succeeded" || "$status" == "failed" || "$status" == "timed_out" || "$status" == "cancelled" ]]; then + echo "$status" + return + fi + now="$(date +%s)" + if (( now - started >= timeout_sec )); then + echo "timeout" + return + fi + sleep 3 + done +} + +get_issue_status() { + local issue_id="$1" + api_request "GET" "/issues/${issue_id}" + if [[ "$RESPONSE_CODE" != "200" ]]; then + echo "" + return 0 + fi + jq -r '.status // empty' <<<"$RESPONSE_BODY" +} + +wait_for_issue_terminal() { + local issue_id="$1" + local timeout_sec="$2" + local started now status + started="$(date +%s)" + while true; do + status="$(get_issue_status "$issue_id")" + if [[ "$status" == "done" || "$status" == "blocked" || "$status" == "cancelled" ]]; then + echo "$status" + return + fi + now="$(date +%s)" + if (( now - started >= timeout_sec )); then + echo "timeout" + return + fi + sleep 3 + done +} + +issue_comments_contain() { + local issue_id="$1" + local marker="$2" + api_request "GET" "/issues/${issue_id}/comments" + if [[ "$RESPONSE_CODE" != "200" ]]; then + echo "false" + return + fi + jq -r --arg marker "$marker" '[.[] | (.body // "") | contains($marker)] | any' <<<"$RESPONSE_BODY" +} + +assert_paperclip_wake_success() { + local marker + marker="$(cat "${HERMES_SMOKE_DIAG_DIR}/paperclip-marker.txt")" + + trigger_wakeup + local run_status issue_status marker_found + run_status="$(wait_for_run_terminal "$RUN_ID" "$RUN_TIMEOUT_SEC")" + log "Paperclip run ${RUN_ID} status=${run_status}" + issue_status="$(wait_for_issue_terminal "$SMOKE_ISSUE_ID" "$CASE_TIMEOUT_SEC")" + marker_found="$(issue_comments_contain "$SMOKE_ISSUE_ID" "$marker")" + log "smoke issue status=${issue_status} marker_found=${marker_found}" + + if [[ "$run_status" != "succeeded" || "$issue_status" != "done" || "$marker_found" != "true" ]]; then + capture_diagnostics + fi + if [[ "$STRICT_CASES" == "1" ]]; then + [[ "$run_status" == "succeeded" ]] || fail "Paperclip Hermes gateway run did not succeed" + [[ "$issue_status" == "done" ]] || fail "smoke issue did not reach done" + [[ "$marker_found" == "true" ]] || fail "smoke marker was not found in issue comments" + fi +} + +scan_diagnostics_for_secret_leaks() { + log "scanning diagnostics for raw secret leaks" + local secrets=() + [[ -n "$HERMES_GATEWAY_API_KEY" ]] && secrets+=("$HERMES_GATEWAY_API_KEY") + [[ -n "$AGENT_API_KEY" ]] && secrets+=("$AGENT_API_KEY") + local key + for key in "${HERMES_PROVIDER_ENV_KEYS[@]}"; do + [[ -n "${!key-}" ]] && secrets+=("${!key}") + done + [[ ${#secrets[@]} -gt 0 ]] || return + local file + while IFS= read -r file; do + [[ "$file" == "$JOIN_OUTPUT_FILE" ]] && continue + local secret + for secret in "${secrets[@]}"; do + if grep -Fq "$secret" "$file"; then + fail "raw secret leaked in diagnostics file ${file}" + fi + done + done < <(find "$HERMES_SMOKE_DIAG_DIR" -type f -print) +} + +main() { + log "starting Hermes gateway Docker E2E smoke" + mkdir -p "$HERMES_SMOKE_DIAG_DIR" + log "diagnostics dir: ${HERMES_SMOKE_DIAG_DIR}" + + require_cmd curl + require_cmd docker + require_cmd jq + + if [[ -z "$HERMES_GATEWAY_API_KEY" ]]; then + 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" + log "Paperclip health deploymentMode=$(jq -r '.deploymentMode // "unknown"' <<<"$RESPONSE_BODY") exposure=$(jq -r '.deploymentExposure // "unknown"' <<<"$RESPONSE_BODY")" + require_board_auth + resolve_company_id + + prepare_fresh_state + write_hermes_model_config + build_image + start_container + assert_fresh_container_state + probe_container_to_paperclip + probe_gateway_readiness + assert_capabilities + assert_direct_gateway_run + assert_stop_behavior_if_deterministic + join_hermes_agent + install_claimed_key_in_container + patch_agent_instructions_with_claimed_key + create_smoke_issue + assert_paperclip_wake_success + capture_diagnostics + scan_diagnostics_for_secret_leaks + + log "success" + log "companyId=${COMPANY_ID}" + log "agentId=${AGENT_ID}" + log "inviteId=${INVITE_ID}" + log "joinRequestId=${JOIN_REQUEST_ID}" + log "issueId=${SMOKE_ISSUE_ID}" + log "issueIdentifier=${SMOKE_ISSUE_IDENTIFIER}" + log "runId=${RUN_ID}" + log "directHermesRunId=${DIRECT_RUN_ID}" + log "diagnostics=${HERMES_SMOKE_DIAG_DIR}" +} + +main "$@" diff --git a/scripts/smoke/hermes-gateway-join.sh b/scripts/smoke/hermes-gateway-join.sh new file mode 100755 index 0000000000..b646d97043 --- /dev/null +++ b/scripts/smoke/hermes-gateway-join.sh @@ -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 diff --git a/scripts/smoke/hermes-gateway-smoke.test.mjs b/scripts/smoke/hermes-gateway-smoke.test.mjs new file mode 100644 index 0000000000..49efa5d682 --- /dev/null +++ b/scripts/smoke/hermes-gateway-smoke.test.mjs @@ -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"); +}); diff --git a/server/package.json b/server/package.json index 1c9d1cb276..a636d6b544 100644 --- a/server/package.json +++ b/server/package.json @@ -59,6 +59,7 @@ "@paperclipai/plugin-sdk": "workspace:*", "@paperclipai/shared": "workspace:*", "@paperclipai/skills-catalog": "workspace:*", + "@paperclipai/hermes-paperclip-adapter": "workspace:*", "ajv": "^8.20.0", "ajv-formats": "^3.0.1", "better-auth": "1.6.20", @@ -69,7 +70,6 @@ "drizzle-orm": "^0.45.2", "embedded-postgres": "^18.1.0-beta.16", "express": "^5.1.0", - "hermes-paperclip-adapter": "^0.3.0", "jsdom": "^28.1.0", "multer": "^2.1.1", "open": "^11.0.0", diff --git a/server/src/__tests__/adapter-registry.test.ts b/server/src/__tests__/adapter-registry.test.ts index 983011fe8e..23455f282e 100644 --- a/server/src/__tests__/adapter-registry.test.ts +++ b/server/src/__tests__/adapter-registry.test.ts @@ -2,28 +2,6 @@ import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; import { buildSandboxNpmInstallCommand } from "@paperclipai/adapter-utils"; import type { ServerAdapterModule } from "../adapters/index.js"; -const hermesExecuteMock = vi.hoisted(() => - vi.fn(async () => ({ - exitCode: 0, - signal: null, - timedOut: false, - })), -); - -vi.mock("hermes-paperclip-adapter/server", () => ({ - execute: hermesExecuteMock, - testEnvironment: async () => ({ - adapterType: "hermes_local", - status: "pass", - checks: [], - testedAt: new Date(0).toISOString(), - }), - sessionCodec: null, - listSkills: async () => [], - syncSkills: async () => ({ entries: [] }), - detectModel: async () => null, -})); - import { detectAdapterModel, findActiveServerAdapter, @@ -59,17 +37,18 @@ const externalAdapter: ServerAdapterModule = { describe("server adapter registry", () => { beforeEach(() => { unregisterServerAdapter("external_test"); + unregisterServerAdapter("hermes_local"); + unregisterServerAdapter("hermes_gateway"); unregisterServerAdapter("claude_local"); setOverridePaused("claude_local", false); - setOverridePaused("hermes_local", true); }); afterEach(() => { unregisterServerAdapter("external_test"); + unregisterServerAdapter("hermes_local"); + unregisterServerAdapter("hermes_gateway"); unregisterServerAdapter("claude_local"); setOverridePaused("claude_local", false); - setOverridePaused("hermes_local", false); - hermesExecuteMock.mockClear(); }); it("registers external adapters and exposes them through lookup helpers", async () => { @@ -151,6 +130,88 @@ describe("server adapter registry", () => { ]); }); + it("ships Hermes adapters as built-ins and still accepts external overrides", () => { + const builtInLocal = findServerAdapter("hermes_local"); + const builtInGateway = findServerAdapter("hermes_gateway"); + + expect(builtInLocal).not.toBeNull(); + expect(builtInLocal?.supportsLocalAgentJwt).toBe(true); + expect(builtInLocal?.supportsInstructionsBundle).toBe(true); + expect(builtInLocal?.requiresMaterializedRuntimeSkills).toBe(false); + expect(builtInLocal?.detectModel).toBeTypeOf("function"); + expect(builtInLocal?.getConfigSchema).toBeTypeOf("function"); + + expect(builtInGateway).not.toBeNull(); + expect(builtInGateway?.supportsLocalAgentJwt).toBe(false); + expect(builtInGateway?.supportsInstructionsBundle).toBe(false); + expect(builtInGateway?.requiresMaterializedRuntimeSkills).toBe(false); + expect(builtInGateway?.getConfigSchema).toBeTypeOf("function"); + + const hermesLocalExternalAdapter: ServerAdapterModule = { + type: "hermes_local", + execute: async () => ({ exitCode: 0, signal: null, timedOut: false }), + testEnvironment: async () => ({ + adapterType: "hermes_local", + status: "pass", + checks: [], + testedAt: new Date(0).toISOString(), + }), + supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "instructionsFilePath", + requiresMaterializedRuntimeSkills: false, + listSkills: async () => ({ + adapterType: "hermes_local", + supported: true, + mode: "ephemeral", + desiredSkills: [], + entries: [], + warnings: [], + }), + getConfigSchema: () => ({ fields: [{ key: "provider", label: "Provider", type: "text" }] }), + detectModel: async () => ({ + model: "hermes-model", + provider: "openrouter", + source: "test", + }), + }; + + const hermesGatewayExternalAdapter: ServerAdapterModule = { + type: "hermes_gateway", + execute: async () => ({ exitCode: 0, signal: null, timedOut: false }), + testEnvironment: async () => ({ + adapterType: "hermes_gateway", + status: "pass", + checks: [], + testedAt: new Date(0).toISOString(), + }), + supportsLocalAgentJwt: false, + supportsInstructionsBundle: false, + requiresMaterializedRuntimeSkills: false, + getConfigSchema: () => ({ + fields: [{ key: "apiBaseUrl", label: "API URL", type: "text" }], + }), + }; + + registerServerAdapter(hermesLocalExternalAdapter); + + expect(requireServerAdapter("hermes_local")).toBe(hermesLocalExternalAdapter); + expect(findActiveServerAdapter("hermes_local")?.supportsLocalAgentJwt).toBe(true); + + unregisterServerAdapter("hermes_local"); + + expect(requireServerAdapter("hermes_local")).toBe(builtInLocal); + + registerServerAdapter(hermesGatewayExternalAdapter); + + expect(requireServerAdapter("hermes_gateway")).toBe(hermesGatewayExternalAdapter); + expect(findActiveServerAdapter("hermes_gateway")?.supportsLocalAgentJwt).toBe(false); + + unregisterServerAdapter("hermes_gateway"); + + expect(requireServerAdapter("hermes_gateway")).toBe(builtInGateway); + }); + it("exposes capability flags from registered adapters", () => { const adapterWithCaps: ServerAdapterModule = { type: "external_test", @@ -308,279 +369,6 @@ describe("server adapter registry", () => { expect(await detectAdapterModel("claude_local")).toBeNull(); expect(detectModel).toHaveBeenCalledTimes(1); }); - - it("injects the local agent JWT and Paperclip API auth guidance into Hermes", async () => { - const adapter = requireServerAdapter("hermes_local"); - - await adapter.execute({ - runId: "run-123", - agent: { - id: "agent-123", - companyId: "company-123", - name: "Hermes Agent", - role: "engineer", - adapterType: "hermes_local", - adapterConfig: { - env: { - OPENAI_API_KEY: "llm-token", - }, - promptTemplate: "Existing prompt", - }, - }, - runtime: {}, - config: {}, - context: {}, - onLog: async () => {}, - onMeta: async () => {}, - onSpawn: async () => {}, - authToken: "agent-run-jwt", - }); - - expect(hermesExecuteMock).toHaveBeenCalledTimes(1); - const [patchedCtx] = hermesExecuteMock.mock.calls[0]; - expect(patchedCtx.agent.adapterConfig).toMatchObject({ - env: { - OPENAI_API_KEY: "llm-token", - PAPERCLIP_API_KEY: "agent-run-jwt", - PAPERCLIP_RUN_ID: "run-123", - }, - }); - expect(patchedCtx.agent.adapterConfig.promptTemplate).toContain( - "Authorization: Bearer $PAPERCLIP_API_KEY", - ); - expect(patchedCtx.agent.adapterConfig.promptTemplate).toContain( - "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID", - ); - expect(patchedCtx.agent.adapterConfig.promptTemplate).toContain("Existing prompt"); - }); - - it("preserves Hermes command normalization while injecting auth", async () => { - const adapter = requireServerAdapter("hermes_local"); - - await adapter.execute({ - runId: "run-123", - agent: { - id: "agent-123", - companyId: "company-123", - name: "Hermes Agent", - role: "engineer", - adapterType: "hermes_local", - adapterConfig: { - command: "agent-hermes", - }, - }, - runtime: {}, - config: { - command: "runtime-hermes", - }, - context: {}, - onLog: async () => {}, - onMeta: async () => {}, - onSpawn: async () => {}, - authToken: "agent-run-jwt", - }); - - expect(hermesExecuteMock).toHaveBeenCalledTimes(1); - const [patchedCtx] = hermesExecuteMock.mock.calls[0]; - expect(patchedCtx.config.hermesCommand).toBe("runtime-hermes"); - expect(patchedCtx.agent.adapterConfig.hermesCommand).toBe("agent-hermes"); - expect(patchedCtx.agent.adapterConfig.env.PAPERCLIP_API_KEY).toBe("agent-run-jwt"); - }); - - it("passes Hermes custom providers through extraArgs while injecting auth", async () => { - const adapter = requireServerAdapter("hermes_local"); - - await adapter.execute({ - runId: "run-123", - agent: { - id: "agent-123", - companyId: "company-123", - name: "Hermes Agent", - role: "engineer", - adapterType: "hermes_local", - adapterConfig: { - provider: "custom:paperclip-openai", - extraArgs: ["--source", "tool"], - }, - }, - runtime: {}, - config: {}, - context: {}, - onLog: async () => {}, - onMeta: async () => {}, - onSpawn: async () => {}, - authToken: "agent-run-jwt", - }); - - const [patchedCtx] = hermesExecuteMock.mock.calls[0]; - expect(patchedCtx.agent.adapterConfig.provider).toBe("custom:paperclip-openai"); - expect(patchedCtx.agent.adapterConfig.extraArgs).toEqual([ - "--source", - "tool", - "--provider", - "custom:paperclip-openai", - ]); - expect(patchedCtx.agent.adapterConfig.env.PAPERCLIP_API_KEY).toBe("agent-run-jwt"); - }); - - it("does not duplicate an explicit Hermes provider extraArg", async () => { - const adapter = requireServerAdapter("hermes_local"); - - await adapter.execute({ - runId: "run-123", - agent: { - id: "agent-123", - companyId: "company-123", - name: "Hermes Agent", - role: "engineer", - adapterType: "hermes_local", - adapterConfig: { - provider: "custom:paperclip-openai", - extraArgs: ["--provider=custom:paperclip-openai"], - }, - }, - runtime: {}, - config: {}, - context: {}, - onLog: async () => {}, - onMeta: async () => {}, - onSpawn: async () => {}, - authToken: "agent-run-jwt", - }); - - const [patchedCtx] = hermesExecuteMock.mock.calls[0]; - expect(patchedCtx.agent.adapterConfig.extraArgs).toEqual([ - "--provider=custom:paperclip-openai", - ]); - }); - - it("does not duplicate spaced Hermes provider extraArgs", async () => { - const adapter = requireServerAdapter("hermes_local"); - - await adapter.execute({ - runId: "run-123", - agent: { - id: "agent-123", - companyId: "company-123", - name: "Hermes Agent", - role: "engineer", - adapterType: "hermes_local", - adapterConfig: { - provider: "custom:paperclip-openai", - extraArgs: ["--provider", "custom:paperclip-openai"], - }, - }, - runtime: {}, - config: {}, - context: {}, - onLog: async () => {}, - onMeta: async () => {}, - onSpawn: async () => {}, - authToken: "agent-run-jwt", - }); - - const [patchedCtx] = hermesExecuteMock.mock.calls[0]; - expect(patchedCtx.agent.adapterConfig.extraArgs).toEqual([ - "--provider", - "custom:paperclip-openai", - ]); - }); - - it("passes the original Hermes context through when authToken is absent", async () => { - const adapter = requireServerAdapter("hermes_local"); - const ctx = { - runId: "run-123", - agent: { - id: "agent-123", - companyId: "company-123", - name: "Hermes Agent", - role: "engineer", - adapterType: "hermes_local", - adapterConfig: { - env: { - PAPERCLIP_API_KEY: "server-level-key", - }, - promptTemplate: "Existing prompt", - }, - }, - runtime: {}, - config: {}, - context: {}, - onLog: async () => {}, - onMeta: async () => {}, - onSpawn: async () => {}, - }; - - await adapter.execute(ctx); - - expect(hermesExecuteMock).toHaveBeenCalledTimes(1); - expect(hermesExecuteMock).toHaveBeenCalledWith(ctx); - }); - - it("preserves an explicit Hermes Paperclip API key and does not set promptTemplate when none was configured", async () => { - const adapter = requireServerAdapter("hermes_local"); - - await adapter.execute({ - runId: "run-123", - agent: { - id: "agent-123", - companyId: "company-123", - name: "Hermes Agent", - role: "engineer", - adapterType: "hermes_local", - adapterConfig: { - env: { - PAPERCLIP_API_KEY: "explicit-agent-key", - PAPERCLIP_RUN_ID: "stale-run-id", - }, - }, - }, - runtime: {}, - config: {}, - context: {}, - onLog: async () => {}, - onMeta: async () => {}, - onSpawn: async () => {}, - authToken: "agent-run-jwt", - }); - - const [patchedCtx] = hermesExecuteMock.mock.calls[0]; - expect(patchedCtx.agent.adapterConfig.env.PAPERCLIP_API_KEY).toBe("explicit-agent-key"); - expect(patchedCtx.agent.adapterConfig.env.PAPERCLIP_RUN_ID).toBe("run-123"); - // No custom promptTemplate was set — Hermes must use its built-in default. - // Setting promptTemplate here would replace the full default with just the auth guard text, - // stripping assigned issue / workflow instructions. - expect(patchedCtx.agent.adapterConfig.promptTemplate).toBeUndefined(); - }); - - it("does not set promptTemplate when no custom template is configured, preserving Hermes default", async () => { - const adapter = requireServerAdapter("hermes_local"); - - await adapter.execute({ - runId: "run-123", - agent: { - id: "agent-123", - companyId: "company-123", - name: "Hermes Agent", - role: "engineer", - adapterType: "hermes_local", - adapterConfig: {}, - }, - runtime: {}, - config: {}, - context: {}, - onLog: async () => {}, - onMeta: async () => {}, - onSpawn: async () => {}, - authToken: "agent-run-jwt", - }); - - const [patchedCtx] = hermesExecuteMock.mock.calls[0]; - // promptTemplate must remain unset so Hermes uses its built-in heartbeat/task prompt. - expect(patchedCtx.agent.adapterConfig.promptTemplate).toBeUndefined(); - // Auth token is still injected. - expect(patchedCtx.agent.adapterConfig.env.PAPERCLIP_API_KEY).toBe("agent-run-jwt"); - }); }); describe("resolveExternalAdapterRegistration", () => { diff --git a/server/src/__tests__/adapter-routes.test.ts b/server/src/__tests__/adapter-routes.test.ts index ccc0edcb5e..13e57a3539 100644 --- a/server/src/__tests__/adapter-routes.test.ts +++ b/server/src/__tests__/adapter-routes.test.ts @@ -115,12 +115,14 @@ describe("adapter routes", () => { adapterRoutes = routes.adapterRoutes; errorHandler = middleware.errorHandler; setOverridePaused("claude_local", false); + unregisterServerAdapter("hermes_local"); unregisterServerAdapter("claude_local"); registerServerAdapter(overridingConfigSchemaAdapter); }); afterEach(() => { setOverridePaused("claude_local", false); + unregisterServerAdapter("hermes_local"); unregisterServerAdapter("claude_local"); }); @@ -185,17 +187,25 @@ describe("adapter routes", () => { requiresMaterializedRuntimeSkills: true, }); - // hermes_local currently supports skills + local JWT, but not the managed - // instructions bundle flow because the bundled adapter does not consume - // instructionsFilePath at runtime. - const hermesAdapter = res.body.find((a: any) => a.type === "hermes_local"); - expect(hermesAdapter).toBeDefined(); - expect(hermesAdapter.capabilities).toMatchObject({ - supportsInstructionsBundle: false, + const hermesLocal = res.body.find((a: any) => a.type === "hermes_local"); + expect(hermesLocal).toBeDefined(); + expect(hermesLocal.source).toBe("builtin"); + expect(hermesLocal.capabilities).toMatchObject({ + supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, }); + + const hermesGateway = res.body.find((a: any) => a.type === "hermes_gateway"); + expect(hermesGateway).toBeDefined(); + expect(hermesGateway.source).toBe("builtin"); + expect(hermesGateway.capabilities).toMatchObject({ + supportsInstructionsBundle: false, + supportsSkills: false, + supportsLocalAgentJwt: false, + requiresMaterializedRuntimeSkills: false, + }); }); it("GET /api/adapters derives supportsSkills from listSkills/syncSkills presence", async () => { @@ -277,6 +287,28 @@ describe("adapter routes", () => { expect(keys).not.toContain("bootstrapPromptTemplate"); }); + it("serves built-in Hermes config schemas", async () => { + const app = createApp(); + + const local = await request(app).get("/api/adapters/hermes_local/config-schema"); + expect(local.status, JSON.stringify(local.body)).toBe(200); + expect(local.body.fields).toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: "provider" }), + expect.objectContaining({ key: "timeoutSec" }), + ]), + ); + + const gateway = await request(app).get("/api/adapters/hermes_gateway/config-schema"); + expect(gateway.status, JSON.stringify(gateway.body)).toBe(200); + expect(gateway.body.fields).toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: "apiBaseUrl", required: true }), + expect.objectContaining({ key: "apiKey", required: true }), + ]), + ); + }); + it("GET /api/adapters includes ACPX model availability", async () => { const app = createApp(); diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index 70eec87767..9aeda84ac5 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -709,7 +709,7 @@ describe.sequential("agent permission routes", () => { model: "gpt-5.3-codex-spark", env: expect.any(Object), }), - { strictMode: false }, + { strictMode: false, adapterType: "codex_local" }, ); expect(mockAgentService.update).toHaveBeenCalledWith( agentId, diff --git a/server/src/__tests__/agents-service-secret-bindings.test.ts b/server/src/__tests__/agents-service-secret-bindings.test.ts index 634086cf5b..d27cc58b5c 100644 --- a/server/src/__tests__/agents-service-secret-bindings.test.ts +++ b/server/src/__tests__/agents-service-secret-bindings.test.ts @@ -115,6 +115,65 @@ describeEmbeddedPostgres("agent service secret binding sync", () => { }); }); + it("converts Hermes gateway apiKey strings into persisted secret refs", async () => { + const companyId = await seedCompany(); + const literalApiKey = `hermes-key-${randomUUID()}`; + + const created = await agentService(db).create(companyId, { + name: "Hermes Gateway", + role: "engineer", + status: "idle", + adapterType: "hermes_gateway", + adapterConfig: { + apiBaseUrl: "https://hermes.example", + apiKey: literalApiKey, + }, + runtimeConfig: {}, + spentMonthlyCents: 0, + lastHeartbeatAt: null, + }); + + const persistedRows = await db + .select() + .from(agents) + .where(eq(agents.id, created.id)); + const persistedConfig = persistedRows[0]?.adapterConfig as Record<string, unknown>; + expect(JSON.stringify(persistedConfig)).not.toContain(literalApiKey); + expect(persistedConfig.apiKey).toMatchObject({ + type: "secret_ref", + version: "latest", + }); + + const secretId = (persistedConfig.apiKey as { secretId: string }).secretId; + const bindings = await db + .select() + .from(companySecretBindings) + .where(and( + eq(companySecretBindings.companyId, companyId), + eq(companySecretBindings.targetType, "agent"), + eq(companySecretBindings.targetId, created.id), + )); + expect(bindings).toHaveLength(1); + expect(bindings[0]).toMatchObject({ + secretId, + configPath: "apiKey", + versionSelector: "latest", + required: true, + }); + + const resolved = await secretService(db).resolveAdapterConfigForRuntime( + companyId, + persistedConfig, + { + consumerType: "agent", + consumerId: created.id, + }, + { adapterType: "hermes_gateway" }, + ); + expect(resolved.config.apiKey).toBe(literalApiKey); + expect(JSON.stringify(persistedConfig)).not.toContain(literalApiKey); + }); + it("replaces agent secret bindings when adapterConfig env changes", async () => { const companyId = await seedCompany(); const secrets = secretService(db); diff --git a/server/src/__tests__/authorization-service.test.ts b/server/src/__tests__/authorization-service.test.ts index 2c1dd29427..aba122f285 100644 --- a/server/src/__tests__/authorization-service.test.ts +++ b/server/src/__tests__/authorization-service.test.ts @@ -73,6 +73,8 @@ async function createIssue( projectId?: string | null; parentId?: string | null; assigneeAgentId?: string | null; + originKind?: string | null; + originId?: string | null; } = {}, ) { return db @@ -86,6 +88,8 @@ async function createIssue( projectId: input.projectId ?? null, parentId: input.parentId ?? null, assigneeAgentId: input.assigneeAgentId ?? null, + originKind: input.originKind ?? "manual", + originId: input.originId ?? null, }) .returning() .then((rows) => rows[0]!); @@ -1187,4 +1191,118 @@ describeEmbeddedPostgres("authorization service", () => { grant: { permissionKey: "tasks:assign" }, }); }); + + it("scopes task bridge keys away from company-wide reads and unrelated issue writes", async () => { + const company = await createCompany(db, "TaskBridge"); + const bridgeAgent = await createAgent(db, company.id); + const targetAgent = await createAgent(db, company.id); + const project = await createProject(db, company.id, "Bridge"); + const parentIssue = await createIssue(db, company.id, { projectId: project.id }); + const assignedIssue = await createIssue(db, company.id, { assigneeAgentId: bridgeAgent.id }); + const keyId = randomUUID(); + const bridgeCreatedIssue = await createIssue(db, company.id, { + originKind: "task_bridge", + originId: keyId, + }); + const unrelatedIssue = await createIssue(db, company.id); + const actor = { + type: "agent" as const, + agentId: bridgeAgent.id, + companyId: company.id, + source: "agent_key" as const, + keyId, + keyScope: { + kind: "task_bridge" as const, + parentIssueId: parentIssue.id, + allowedAssigneeAgentIds: [targetAgent.id], + }, + }; + const authz = authorizationService(db); + + await expect(authz.decide({ + actor, + action: "company_scope:read", + resource: { type: "company", companyId: company.id }, + })).resolves.toMatchObject({ + allowed: false, + reason: "deny_scope", + }); + + await expect(authz.decide({ + actor, + action: "tasks:assign", + resource: { + type: "issue", + companyId: company.id, + parentIssueId: parentIssue.id, + assigneeAgentId: targetAgent.id, + }, + })).resolves.toMatchObject({ + allowed: true, + }); + + await expect(authz.decide({ + actor, + action: "tasks:assign", + resource: { + type: "issue", + companyId: company.id, + projectId: project.id, + assigneeUserId: randomUUID(), + }, + })).resolves.toMatchObject({ + allowed: false, + reason: "deny_scope", + }); + + await expect(authz.decide({ + actor, + action: "issue:comment", + resource: { + type: "issue", + companyId: company.id, + issueId: assignedIssue.id, + }, + })).resolves.toMatchObject({ + allowed: true, + }); + + await expect(authz.decide({ + actor, + action: "issue:mutate", + resource: { + type: "issue", + companyId: company.id, + issueId: bridgeCreatedIssue.id, + }, + })).resolves.toMatchObject({ + allowed: true, + }); + + await expect(authz.decide({ + actor, + action: "issue:mutate", + resource: { + type: "issue", + companyId: company.id, + issueId: unrelatedIssue.id, + }, + })).resolves.toMatchObject({ + allowed: false, + reason: "deny_scope", + }); + + await expect(authz.decide({ + actor, + action: "agent_config:read", + resource: { + type: "agent", + companyId: company.id, + agentId: bridgeAgent.id, + }, + })).resolves.toMatchObject({ + allowed: false, + reason: "deny_scope", + }); + }); }); diff --git a/server/src/__tests__/aws-secrets-manager-provider.test.ts b/server/src/__tests__/aws-secrets-manager-provider.test.ts index 90fec29575..aa006f5e2e 100644 --- a/server/src/__tests__/aws-secrets-manager-provider.test.ts +++ b/server/src/__tests__/aws-secrets-manager-provider.test.ts @@ -12,6 +12,11 @@ describe("awsSecretsManagerProvider", () => { AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN: process.env.AWS_SESSION_TOKEN, + AWS_PROFILE: process.env.AWS_PROFILE, + AWS_DEFAULT_PROFILE: process.env.AWS_DEFAULT_PROFILE, + AWS_CONFIG_FILE: process.env.AWS_CONFIG_FILE, + AWS_SHARED_CREDENTIALS_FILE: process.env.AWS_SHARED_CREDENTIALS_FILE, + AWS_SDK_LOAD_CONFIG: process.env.AWS_SDK_LOAD_CONFIG, }; afterEach(() => { @@ -169,6 +174,11 @@ describe("awsSecretsManagerProvider", () => { }); it("signs AWS Secrets Manager JSON requests with default runtime credentials", async () => { + delete process.env.AWS_PROFILE; + delete process.env.AWS_DEFAULT_PROFILE; + delete process.env.AWS_CONFIG_FILE; + delete process.env.AWS_SHARED_CREDENTIALS_FILE; + delete process.env.AWS_SDK_LOAD_CONFIG; process.env.AWS_ACCESS_KEY_ID = "AKIA_TEST_ACCESS"; process.env.AWS_SECRET_ACCESS_KEY = "test-secret-key"; process.env.AWS_SESSION_TOKEN = "test-session-token"; diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index 8d58ea264f..f4283a0910 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -1537,7 +1537,7 @@ describe("company portability", () => { }, }, }), - { strictMode: false }, + { strictMode: false, adapterType: "codex_local" }, ); expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ adapterConfig: expect.objectContaining({ @@ -3490,7 +3490,7 @@ describe("company portability", () => { expect(secretSvc.normalizeAdapterConfigForPersistence).toHaveBeenCalledWith( "company-imported", expect.anything(), - { strictMode: false }, + { strictMode: false, adapterType: "claude_local" }, ); expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({ adapterType: "claude_local", @@ -3559,7 +3559,7 @@ describe("company portability", () => { model: "gpt-5.4", extraArgs: ["--skip-git-repo-check"], }), - { strictMode: false }, + { strictMode: false, adapterType: "codex_local" }, ); expect(agentSvc.update).toHaveBeenCalledWith("agent-1", expect.objectContaining({ adapterType: "codex_local", diff --git a/server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts b/server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts index fcde58f3e9..0b99064dc1 100644 --- a/server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts +++ b/server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts @@ -54,6 +54,11 @@ vi.mock("../adapters/index.js", () => ({ execute: adapterExecute, supportsLocalAgentJwt: false, }), + findActiveServerAdapter: () => ({ + type: "codex_local", + execute: adapterExecute, + supportsLocalAgentJwt: false, + }), listAdapterModelProfiles: async () => [], runningProcesses: new Map(), })); diff --git a/server/src/__tests__/heartbeat-plugin-environment.test.ts b/server/src/__tests__/heartbeat-plugin-environment.test.ts index deda0e9d61..ee7155bb78 100644 --- a/server/src/__tests__/heartbeat-plugin-environment.test.ts +++ b/server/src/__tests__/heartbeat-plugin-environment.test.ts @@ -38,6 +38,11 @@ vi.mock("../adapters/index.js", () => ({ execute: adapterExecute, supportsLocalAgentJwt: false, }), + findActiveServerAdapter: () => ({ + type: "codex_local", + execute: adapterExecute, + supportsLocalAgentJwt: false, + }), listAdapterModelProfiles: async () => [], runningProcesses: new Map(), })); diff --git a/server/src/__tests__/invite-accept-gateway-defaults.test.ts b/server/src/__tests__/invite-accept-gateway-defaults.test.ts index 3ff239f628..cec0f913b6 100644 --- a/server/src/__tests__/invite-accept-gateway-defaults.test.ts +++ b/server/src/__tests__/invite-accept-gateway-defaults.test.ts @@ -1,8 +1,36 @@ -import { describe, expect, it } from "vitest"; +import { randomUUID } from "node:crypto"; +import { mkdirSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { eq } from "drizzle-orm"; +import { + companies, + companySecretProviderConfigs, + companySecretVersions, + companySecrets, + createDb, + invites, + joinRequests, +} from "@paperclipai/db"; import { buildJoinDefaultsPayloadForAccept, normalizeAgentDefaultsForJoin, + prepareAgentDefaultsPayloadForJoinPersistence, } from "../routes/access.js"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres invite accept gateway defaults tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} describe("buildJoinDefaultsPayloadForAccept (openclaw_gateway)", () => { it("leaves non-gateway payloads unchanged", () => { @@ -117,3 +145,162 @@ describe("normalizeAgentDefaultsForJoin (openclaw_gateway)", () => { expect(normalized.normalized?.devicePrivateKeyPem).toBeUndefined(); }); }); + +describe("normalizeAgentDefaultsForJoin (hermes_gateway)", () => { + it("rejects remote plain HTTP by default", () => { + const normalized = normalizeAgentDefaultsForJoin({ + adapterType: "hermes_gateway", + defaultsPayload: { + apiBaseUrl: "http://192.168.1.25:8642", + apiKey: "hermes-key-1234567890", + }, + deploymentMode: "authenticated", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }); + + expect(normalized.fatalErrors.join("\n")).toContain("remote plain HTTP"); + expect(normalized.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "hermes_gateway_plain_http_remote_denied", + }), + ]), + ); + }); + + it("allows remote plain HTTP only with the explicit unsafe flag", () => { + const normalized = normalizeAgentDefaultsForJoin({ + adapterType: "hermes_gateway", + defaultsPayload: { + apiBaseUrl: "http://192.168.1.25:8642", + apiKey: "hermes-key-1234567890", + dangerouslyAllowInsecureRemoteHttp: true, + }, + deploymentMode: "authenticated", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }); + + expect(normalized.fatalErrors).toEqual([]); + expect(normalized.normalized?.apiBaseUrl).toBe("http://192.168.1.25:8642/"); + expect(normalized.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "hermes_gateway_plain_http_remote_unsafe_allowed", + }), + ]), + ); + }); +}); + +describeEmbeddedPostgres("prepareAgentDefaultsPayloadForJoinPersistence (hermes_gateway)", () => { + let stopDb: (() => Promise<void>) | null = null; + let db!: ReturnType<typeof createDb>; + const previousKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; + const secretsTmpDir = path.join(os.tmpdir(), `paperclip-hermes-join-defaults-${randomUUID()}`); + + beforeAll(async () => { + mkdirSync(secretsTmpDir, { recursive: true }); + process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = path.join(secretsTmpDir, "master.key"); + const started = await startEmbeddedPostgresTestDatabase("hermes-join-defaults"); + stopDb = started.cleanup; + db = createDb(started.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(joinRequests); + await db.delete(invites); + await db.delete(companySecretVersions); + await db.delete(companySecrets); + await db.delete(companySecretProviderConfigs); + await db.delete(companies); + }); + + afterAll(async () => { + await stopDb?.(); + if (previousKeyFile === undefined) { + delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; + } else { + process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = previousKeyFile; + } + rmSync(secretsTmpDir, { recursive: true, force: true }); + }); + + it("stores a secret ref instead of the literal apiKey in join request defaults", async () => { + const companyId = randomUUID(); + const inviteId = randomUUID(); + const joinRequestId = randomUUID(); + const literalApiKey = `hermes-key-${randomUUID()}`; + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(invites).values({ + id: inviteId, + companyId, + inviteType: "company_join", + tokenHash: `invite-token-${randomUUID()}`, + allowedJoinTypes: "agent", + defaultsPayload: null, + expiresAt: new Date("2027-03-10T00:00:00.000Z"), + }); + + const joinDefaults = normalizeAgentDefaultsForJoin({ + adapterType: "hermes_gateway", + defaultsPayload: { + apiBaseUrl: "https://hermes.example", + apiKey: literalApiKey, + paperclipApiUrl: "https://paperclip.example", + }, + deploymentMode: "authenticated", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }); + expect(joinDefaults.fatalErrors).toEqual([]); + + const persistedDefaults = await prepareAgentDefaultsPayloadForJoinPersistence({ + db, + companyId, + adapterType: "hermes_gateway", + normalized: joinDefaults.normalized, + }); + + await db.insert(joinRequests).values({ + id: joinRequestId, + inviteId, + companyId, + requestType: "agent", + status: "pending_approval", + requestIp: "127.0.0.1", + agentName: "Hermes Gateway", + adapterType: "hermes_gateway", + capabilities: "Hermes gateway agent", + agentDefaultsPayload: persistedDefaults, + claimSecretHash: "claim-secret-hash", + claimSecretExpiresAt: new Date("2027-03-11T00:00:00.000Z"), + }); + + const persistedJoinRequest = await db + .select() + .from(joinRequests) + .where(eq(joinRequests.id, joinRequestId)) + .then((rows) => rows[0]); + const storedPayload = persistedJoinRequest?.agentDefaultsPayload as Record<string, unknown>; + expect(JSON.stringify(storedPayload)).not.toContain(literalApiKey); + expect(storedPayload.apiKey).toMatchObject({ + type: "secret_ref", + version: "latest", + }); + + const storedSecrets = await db.select().from(companySecrets); + expect(storedSecrets).toHaveLength(1); + expect((storedPayload.apiKey as { secretId: string }).secretId).toBe(storedSecrets[0]?.id); + }); +}); diff --git a/server/src/__tests__/invite-onboarding-text.test.ts b/server/src/__tests__/invite-onboarding-text.test.ts index 7d8407b608..e08828a907 100644 --- a/server/src/__tests__/invite-onboarding-text.test.ts +++ b/server/src/__tests__/invite-onboarding-text.test.ts @@ -51,6 +51,21 @@ describe("buildInviteOnboardingTextDocument", () => { expect(text).toContain('"adapterType": "openclaw_gateway"'); expect(text).toContain("headers.x-openclaw-token"); expect(text).toContain("Do NOT use /v1/responses or /hooks/*"); + expect(text).toContain('adapterType: "hermes_gateway"'); + expect(text).toContain('"adapterType": "hermes_gateway"'); + expect(text).toContain("API_SERVER_ENABLED=true"); + expect(text).toContain("API_SERVER_KEY"); + expect(text).toContain("hermes gateway run --replace --accept-hooks"); + expect(text).toContain("Default Hermes API server port: 8642"); + expect(text).toContain("agentDefaultsPayload.apiBaseUrl"); + expect(text).toContain("agentDefaultsPayload.paperclipApiUrl"); + expect(text).toContain("hermes_local"); + expect(text).toContain("Hermes-originated Paperclip API usage"); + expect(text).toContain("http://127.0.0.1:8642"); + expect(text).toContain("http://192.168.1.25:8642"); + expect(text).toContain("tailnet-name.ts.net:8642"); + expect(text).toContain("http://host.docker.internal:8642"); + expect(text).toContain("https://hermes-gateway.example"); expect(text).toContain("set the first reachable candidate as agentDefaultsPayload.paperclipApiUrl"); expect(text).toContain("PAPERCLIP_API_KEY"); expect(text).toContain("Use your runtime's normal skill or instruction installation path."); diff --git a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts index f2aabc93e4..e810f3085a 100644 --- a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts +++ b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts @@ -669,6 +669,22 @@ describe("agent issue mutation checkout ownership", () => { mockStorageService.deleteObject.mockResolvedValue(undefined); }); + it("denies company-wide issue list routes for task bridge keys", async () => { + const app = await createApp(peerActor({ + keyId: "99999999-9999-4999-8999-999999999999", + keyScope: { + kind: "task_bridge", + parentIssueId: issueId, + }, + })); + + const res = await request(app).get(`/api/companies/${companyId}/issues`); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("Task bridge keys cannot use company-wide issue list APIs"); + expect(mockIssueService.list).not.toHaveBeenCalled(); + }); + it("uses the company-scope fast path on the issue list route", async () => { mockAccessService.decide.mockImplementation(async (input: { action: string }) => { if (input.action === "company_scope:read") { diff --git a/server/src/__tests__/llms-routes.test.ts b/server/src/__tests__/llms-routes.test.ts index fa530d9292..6078d98893 100644 --- a/server/src/__tests__/llms-routes.test.ts +++ b/server/src/__tests__/llms-routes.test.ts @@ -72,4 +72,39 @@ describe("llm routes", () => { expect(res.text).toContain("Timer heartbeats are opt-in for new hires."); expect(res.text).toContain("Leave runtimeConfig.heartbeat.enabled false"); }); + + it("serves static Hermes Gateway configuration docs before the plugin is installed", async () => { + const app = await createApp({ + type: "board", + userId: "board-user", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: true, + }); + + const indexRes = await request(app).get("/api/llms/agent-configuration.txt"); + expect(indexRes.status).toBe(200); + expect(indexRes.text).toContain( + "- hermes_gateway: /llms/agent-configuration/hermes_gateway.txt", + ); + + const res = await request(app).get("/api/llms/agent-configuration/hermes_gateway.txt"); + + expect(res.status).toBe(200); + expect(res.text).toContain("Adapter: hermes_gateway"); + expect(res.text).toContain('adapterType": "hermes_gateway"'); + expect(res.text).toContain("API_SERVER_ENABLED=true"); + expect(res.text).toContain("API_SERVER_KEY"); + expect(res.text).toContain("hermes gateway run --replace --accept-hooks"); + expect(res.text).toContain("Default Hermes API server port: 8642"); + expect(res.text).toContain("agentDefaultsPayload.apiBaseUrl"); + expect(res.text).toContain("agentDefaultsPayload.paperclipApiUrl"); + expect(res.text).toContain("hermes_local"); + expect(res.text).toContain("Hermes-originated Paperclip API usage"); + expect(res.text).toContain("http://127.0.0.1:8642"); + expect(res.text).toContain("http://192.168.1.25:8642"); + expect(res.text).toContain("tailnet-name.ts.net:8642"); + expect(res.text).toContain("http://host.docker.internal:8642"); + expect(res.text).toContain("https://hermes-gateway.example"); + }); }); diff --git a/server/src/__tests__/secrets-service.test.ts b/server/src/__tests__/secrets-service.test.ts index 3bc48eb908..eda8c2f953 100644 --- a/server/src/__tests__/secrets-service.test.ts +++ b/server/src/__tests__/secrets-service.test.ts @@ -998,6 +998,11 @@ describeEmbeddedPostgres("secretService", () => { version: 1, }, })); + + const persisted = await svc.getByName(companyId, "Create Rollback"); + expect(persisted).toBeNull(); + const versions = await db.select().from(companySecretVersions); + expect(versions).toHaveLength(0); }); it("keeps a local cleanup handle when create rollback cleanup fails", async () => { diff --git a/server/src/adapters/builtin-adapter-types.ts b/server/src/adapters/builtin-adapter-types.ts index bb96eb9962..505162f7ed 100644 --- a/server/src/adapters/builtin-adapter-types.ts +++ b/server/src/adapters/builtin-adapter-types.ts @@ -9,10 +9,11 @@ export const BUILTIN_ADAPTER_TYPES = new Set([ "cursor", "gemini_local", "grok_local", + "hermes_gateway", + "hermes_local", "openclaw_gateway", "opencode_local", "pi_local", - "hermes_local", "process", "http", ]); diff --git a/server/src/adapters/hermes-gateway-doc.ts b/server/src/adapters/hermes-gateway-doc.ts new file mode 100644 index 0000000000..8fc2716806 --- /dev/null +++ b/server/src/adapters/hermes-gateway-doc.ts @@ -0,0 +1,56 @@ +export const hermesGatewayAgentConfigurationDoc = `# hermes_gateway agent configuration + +Adapter: hermes_gateway + +Use when: +- Hermes is already running outside Paperclip and exposes its API server. +- Paperclip should invoke Hermes through the gateway HTTP API instead of spawning Hermes locally. +- The Hermes runtime may live on another host, a private overlay, in Docker, or behind a TLS reverse proxy. + +Don't use when: +- Paperclip should start the Hermes CLI directly on the same host. Use the built-in hermes_local adapter for that flow. +- Hermes is the process calling Paperclip APIs after claiming its key. That is Hermes-originated Paperclip API usage, not the gateway adapter transport. + +Runtime distinction: +- hermes_local: Paperclip starts Hermes on the Paperclip host through the built-in local adapter. +- hermes_gateway: Paperclip calls an already-running Hermes API server using agentDefaultsPayload.apiBaseUrl. +- Hermes-originated Paperclip API usage: Hermes calls Paperclip with PAPERCLIP_API_URL and PAPERCLIP_BRIDGE_API_KEY. Create that Paperclip key with scope.kind = "task_bridge" and an approved parent/project boundary; do not expose a normal claimed agent key to Hermes-facing chat/webhook surfaces. Do not use agentDefaultsPayload.apiBaseUrl for Paperclip API calls. + +Hermes gateway process setup: +- Set API_SERVER_ENABLED=true. +- Set API_SERVER_KEY to a generated secret value. Do not paste a real key into tickets, docs, screenshots, or tests. +- Start Hermes with: hermes gateway run --replace --accept-hooks +- Default Hermes API server port: 8642. + +Join request minimum: +{ + "requestType": "agent", + "agentName": "My Hermes Gateway Agent", + "adapterType": "hermes_gateway", + "capabilities": "Hermes gateway agent", + "agentDefaultsPayload": { + "apiBaseUrl": "http://127.0.0.1:8642", + "apiKey": "<same-value-as-API_SERVER_KEY>", + "paperclipApiUrl": "http://localhost:3100" + } +} + +Core fields: +- agentDefaultsPayload.apiBaseUrl (string, required): Base URL for the Hermes API server as reachable from the Paperclip server. +- agentDefaultsPayload.apiKey (string, required unless the adapter package documents another auth field): Hermes API server key matching API_SERVER_KEY. +- agentDefaultsPayload.paperclipApiUrl (string, strongly recommended): Paperclip base URL as reachable from Hermes for invite, claim, skill bootstrap, and later Paperclip API calls. +- agentDefaultsPayload.timeoutSec or timeoutMs (number, optional): Runtime request timeout when supported by the installed Hermes gateway adapter. + +Network examples: +- Local loopback on one host: agentDefaultsPayload.apiBaseUrl = "http://127.0.0.1:8642"; agentDefaultsPayload.paperclipApiUrl = "http://127.0.0.1:3100". +- LAN/private network: agentDefaultsPayload.apiBaseUrl = "http://192.168.1.25:8642"; agentDefaultsPayload.paperclipApiUrl = "http://192.168.1.10:3100". Use private IPs or hostnames reachable from both machines. +- Private overlay: agentDefaultsPayload.apiBaseUrl = "http://hermes-host.tailnet-name.ts.net:8642"; agentDefaultsPayload.paperclipApiUrl = "http://paperclip-host.tailnet-name.ts.net:3100". Add the Paperclip hostname with pnpm paperclipai allowed-hostname <host> when authenticated/private mode requires it. +- Docker: if Hermes runs on the host and Paperclip runs in Docker, use agentDefaultsPayload.apiBaseUrl = "http://host.docker.internal:8642". If Hermes runs in another container, use the Compose service DNS name such as "http://hermes:8642". +- Reverse proxy/TLS: publish Hermes behind HTTPS and set agentDefaultsPayload.apiBaseUrl = "https://hermes-gateway.example"; set agentDefaultsPayload.paperclipApiUrl = "https://paperclip.example". Keep API_SERVER_KEY required at the origin or proxy. + +Security notes: +- Treat API_SERVER_KEY and PAPERCLIP_BRIDGE_API_KEY as secrets. +- Never use a normal claimed Paperclip agent API key for internet-facing Hermes-originated task bridge calls; task_bridge keys cannot use company-wide issue list/search/read surfaces and can only mutate bridge-created or assigned issues. +- Prefer private network or TLS for non-loopback gateway access. +- Use placeholders such as <same-value-as-API_SERVER_KEY> in docs and tests. +`; diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 315f02dd74..c60d91948a 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -92,6 +92,10 @@ import { agentConfigurationDoc as grokAgentConfigurationDoc, models as grokModels, } from "@paperclipai/adapter-grok-local"; +import { + createHermesGatewayServerAdapter, + createHermesLocalServerAdapter, +} from "@paperclipai/hermes-paperclip-adapter"; import { execute as openCodeExecute, listOpenCodeSkills, @@ -127,18 +131,6 @@ import { agentConfigurationDoc as piAgentConfigurationDoc, modelProfiles as piModelProfiles, } from "@paperclipai/adapter-pi-local"; -import { - execute as hermesExecute, - testEnvironment as hermesTestEnvironment, - sessionCodec as hermesSessionCodec, - listSkills as hermesListSkills, - syncSkills as hermesSyncSkills, - detectModel as detectModelFromHermes, -} from "hermes-paperclip-adapter/server"; -import { - agentConfigurationDoc as hermesAgentConfigurationDoc, - models as hermesModels, -} from "hermes-paperclip-adapter"; import { BUILTIN_ADAPTER_TYPES } from "./builtin-adapter-types.js"; import { buildExternalAdapters } from "./plugin-loader.js"; import { getDisabledAdapterTypes } from "../services/adapter-plugin-store.js"; @@ -184,55 +176,6 @@ function buildCursorRuntimeCommandSpec(config: Record<string, unknown>): Adapter }; } -function normalizeHermesConfig<T extends { config?: unknown; agent?: unknown }>(ctx: T): T { - const config = - ctx && typeof ctx === "object" && "config" in ctx && ctx.config && typeof ctx.config === "object" - ? (ctx.config as Record<string, unknown>) - : null; - const agent = - ctx && typeof ctx === "object" && "agent" in ctx && ctx.agent && typeof ctx.agent === "object" - ? (ctx.agent as Record<string, unknown>) - : null; - const agentAdapterConfig = - agent?.adapterConfig && typeof agent.adapterConfig === "object" - ? (agent.adapterConfig as Record<string, unknown>) - : null; - - const configCommand = - typeof config?.command === "string" && config.command.length > 0 ? config.command : undefined; - const agentCommand = - typeof agentAdapterConfig?.command === "string" && agentAdapterConfig.command.length > 0 - ? agentAdapterConfig.command - : undefined; - - if (config && !config.hermesCommand && configCommand) { - config.hermesCommand = configCommand; - } - if (agentAdapterConfig && !agentAdapterConfig.hermesCommand && agentCommand) { - agentAdapterConfig.hermesCommand = agentCommand; - } - - return ctx; -} - -function passHermesCustomProviderThroughExtraArgs(config: Record<string, unknown>): Record<string, unknown> { - const provider = typeof config.provider === "string" ? config.provider.trim() : ""; - if (!provider.startsWith("custom:")) return config; - - const existingExtraArgs = Array.isArray(config.extraArgs) - ? config.extraArgs.filter((arg): arg is string => typeof arg === "string") - : []; - const alreadyHasProviderArg = existingExtraArgs.some((arg) => - arg === "--provider" || arg.startsWith("--provider=") - ); - if (alreadyHasProviderArg) return config; - - return { - ...config, - extraArgs: [...existingExtraArgs, "--provider", provider], - }; -} - function dedupeAdapterModels(models: AdapterModel[]): AdapterModel[] { const seen = new Set<string>(); const result: AdapterModel[] = []; @@ -403,6 +346,10 @@ const grokLocalAdapter: ServerAdapterModule = { agentConfigurationDoc: grokAgentConfigurationDoc, }; +const hermesGatewayAdapter = createHermesGatewayServerAdapter(); + +const hermesLocalAdapter = createHermesLocalServerAdapter(); + const openclawGatewayAdapter: ServerAdapterModule = { type: "openclaw_gateway", execute: openclawGatewayExecute, @@ -453,77 +400,6 @@ const piLocalAdapter: ServerAdapterModule = { agentConfigurationDoc: piAgentConfigurationDoc, }; -// hermes-paperclip-adapter v0.2.0 predates the authToken field; cast is -// intentional until hermes ships a matching AdapterExecutionContext type. -const executeHermesLocal = hermesExecute as unknown as ServerAdapterModule["execute"]; -// hermes-paperclip-adapter v0.2.0 still depends on the published @paperclipai/adapter-utils -// that ships the "paperclip_required" origin; casts bridge until hermes upgrades. -const listHermesSkills = hermesListSkills as unknown as ServerAdapterModule["listSkills"]; -const syncHermesSkills = hermesSyncSkills as unknown as ServerAdapterModule["syncSkills"]; - -const hermesLocalAdapter: ServerAdapterModule = { - type: "hermes_local", - execute: async (ctx) => { - const normalizedCtx = normalizeHermesConfig(ctx); - if (!normalizedCtx.authToken) return executeHermesLocal(normalizedCtx); - - const existingConfig = (normalizedCtx.agent.adapterConfig ?? {}) as Record<string, unknown>; - const existingEnv = - typeof existingConfig.env === "object" && existingConfig.env !== null && !Array.isArray(existingConfig.env) - ? (existingConfig.env as Record<string, string>) - : {}; - const explicitApiKey = - typeof existingEnv.PAPERCLIP_API_KEY === "string" && existingEnv.PAPERCLIP_API_KEY.trim().length > 0; - const promptTemplate = - typeof existingConfig.promptTemplate === "string" && existingConfig.promptTemplate.trim().length > 0 - ? existingConfig.promptTemplate - : ""; - const authGuardPrompt = [ - "Paperclip API safety rule:", - "Use Authorization: Bearer $PAPERCLIP_API_KEY on every Paperclip API request.", - "Use X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID on every Paperclip API request that writes or mutates data, including comments and issue updates.", - "Never use a board, browser, or local-board session for Paperclip API writes.", - ].join("\n"); - - const patchedConfig: Record<string, unknown> = { - ...existingConfig, - env: { - ...existingEnv, - ...(!explicitApiKey ? { PAPERCLIP_API_KEY: normalizedCtx.authToken } : {}), - PAPERCLIP_RUN_ID: normalizedCtx.runId, - }, - }; - const effectivePatchedConfig = passHermesCustomProviderThroughExtraArgs(patchedConfig); - - // Only inject the auth guard into promptTemplate when a custom template already exists. - // When no custom template is set, Hermes uses its built-in default heartbeat/task prompt — - // overwriting it with only the auth guard text would strip the assigned issue/workflow instructions. - if (promptTemplate) { - effectivePatchedConfig.promptTemplate = `${authGuardPrompt}\n\n${promptTemplate}`; - } - - const patchedCtx = { - ...normalizedCtx, - agent: { - ...normalizedCtx.agent, - adapterConfig: effectivePatchedConfig, - }, - }; - - return executeHermesLocal(patchedCtx); - }, - testEnvironment: (ctx) => hermesTestEnvironment(normalizeHermesConfig(ctx) as never), - sessionCodec: hermesSessionCodec, - listSkills: listHermesSkills, - syncSkills: syncHermesSkills, - models: hermesModels, - supportsLocalAgentJwt: true, - supportsInstructionsBundle: false, - requiresMaterializedRuntimeSkills: false, - agentConfigurationDoc: hermesAgentConfigurationDoc, - detectModel: () => detectModelFromHermes(), -}; - const adaptersByType = new Map<string, ServerAdapterModule>(); // For builtin types that are overridden by an external adapter, we keep the @@ -546,8 +422,9 @@ function registerBuiltInAdapters() { cursorLocalAdapter, geminiLocalAdapter, grokLocalAdapter, - openclawGatewayAdapter, + hermesGatewayAdapter, hermesLocalAdapter, + openclawGatewayAdapter, processAdapter, httpAdapter, ]) { diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index c0cbf954dd..46650a0beb 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -4,7 +4,7 @@ import { and, eq, isNull } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agentApiKeys, agents, authUsers, companies, companyMemberships, instanceUserRoles } from "@paperclipai/db"; import { verifyLocalAgentJwt } from "../agent-auth-jwt.js"; -import type { DeploymentMode } from "@paperclipai/shared"; +import { normalizeAgentApiKeyScope, type DeploymentMode } from "@paperclipai/shared"; import type { BetterAuthSessionResult } from "../auth/better-auth.js"; import { logger } from "./logger.js"; import { boardAuthService } from "../services/board-auth.js"; @@ -192,6 +192,7 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa agentId: key.agentId, companyId: key.companyId, keyId: key.id, + keyScope: normalizeAgentApiKeyScope(key.scopeConfig), runId: runIdHeader || undefined, source: "agent_key", }; diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index a4a2fea15b..0de06e57c7 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -83,6 +83,7 @@ import { } from "../board-claim.js"; import { claimFirstInstanceAdmin } from "../first-admin-claim.js"; import { getStorageService } from "../storage/index.js"; +import { secretService } from "../services/secrets.js"; function hashToken(token: string) { return createHash("sha256").update(token).digest("hex"); @@ -294,8 +295,14 @@ function isPlainObject(value: unknown): value is Record<string, unknown> { } function isLoopbackHost(hostname: string): boolean { - const value = hostname.trim().toLowerCase(); - return value === "localhost" || value === "127.0.0.1" || value === "::1"; + const value = hostname.trim().toLowerCase().replace(/^\[|\]$/g, ""); + return ( + value === "localhost" || + value === "::1" || + value === "0:0:0:0:0:0:0:1" || + value === "127.0.0.1" || + /^127(?:\.\d{1,3}){3}$/.test(value) + ); } function normalizeHostname(value: string | null | undefined): string | null { @@ -474,8 +481,8 @@ function parseBooleanLike(value: unknown): boolean | null { if (typeof value === "boolean") return value; if (typeof value !== "string") return null; const normalized = value.trim().toLowerCase(); - if (normalized === "true" || normalized === "1") return true; - if (normalized === "false" || normalized === "0") return false; + if (["true", "1", "yes", "on"].includes(normalized)) return true; + if (["false", "0", "no", "off"].includes(normalized)) return false; return null; } @@ -676,6 +683,91 @@ export function normalizeAgentDefaultsForJoin(input: { }) { const fatalErrors: string[] = []; const diagnostics: JoinDiagnostic[] = []; + if (input.adapterType === "hermes_gateway") { + if (!isPlainObject(input.defaultsPayload)) { + diagnostics.push({ + code: "hermes_gateway_defaults_missing", + level: "warn", + message: "No Hermes gateway config was provided in agentDefaultsPayload.", + hint: "Include agentDefaultsPayload.apiBaseUrl and agentDefaultsPayload.apiKey for Hermes gateway joins.", + }); + fatalErrors.push("agentDefaultsPayload is required for adapterType=hermes_gateway"); + return { normalized: null as Record<string, unknown> | null, diagnostics, fatalErrors }; + } + + const defaults = input.defaultsPayload as Record<string, unknown>; + const normalized = { ...defaults }; + const rawApiBaseUrl = nonEmptyTrimmedString(defaults.apiBaseUrl ?? defaults.url); + if (!rawApiBaseUrl) { + diagnostics.push({ + code: "hermes_gateway_api_base_url_missing", + level: "warn", + message: "Hermes gateway apiBaseUrl is missing.", + hint: "Set agentDefaultsPayload.apiBaseUrl to the Hermes API server URL.", + }); + fatalErrors.push("agentDefaultsPayload.apiBaseUrl is required"); + } else { + try { + const apiBaseUrl = new URL(rawApiBaseUrl); + if (apiBaseUrl.protocol !== "http:" && apiBaseUrl.protocol !== "https:") { + diagnostics.push({ + code: "hermes_gateway_api_base_url_protocol", + level: "warn", + message: `Hermes gateway apiBaseUrl must use http:// or https:// (got ${apiBaseUrl.protocol}).`, + }); + fatalErrors.push("agentDefaultsPayload.apiBaseUrl must use http:// or https:// for hermes_gateway"); + } else if ( + apiBaseUrl.protocol === "http:" && + !isLoopbackHost(apiBaseUrl.hostname) && + parseBooleanLike(defaults.dangerouslyAllowInsecureRemoteHttp) !== true + ) { + diagnostics.push({ + code: "hermes_gateway_plain_http_remote_denied", + level: "warn", + message: "Remote plain HTTP Hermes gateway traffic is denied by default.", + hint: "Use https:// or set agentDefaultsPayload.dangerouslyAllowInsecureRemoteHttp=true only for unsafe local development.", + }); + fatalErrors.push( + "agentDefaultsPayload.apiBaseUrl uses remote plain HTTP; use HTTPS or set dangerouslyAllowInsecureRemoteHttp=true for unsafe local development", + ); + } else { + normalized.apiBaseUrl = apiBaseUrl.toString(); + if (apiBaseUrl.protocol === "http:" && !isLoopbackHost(apiBaseUrl.hostname)) { + diagnostics.push({ + code: "hermes_gateway_plain_http_remote_unsafe_allowed", + level: "warn", + message: "Unsafe dev escape hatch enabled for non-loopback HTTP Hermes traffic.", + }); + } else { + diagnostics.push({ + code: "hermes_gateway_api_base_url_configured", + level: "info", + message: `Hermes gateway endpoint set to ${apiBaseUrl.toString()}`, + }); + } + } + } catch { + diagnostics.push({ + code: "hermes_gateway_api_base_url_invalid", + level: "warn", + message: `Invalid Hermes gateway apiBaseUrl: ${rawApiBaseUrl}`, + }); + fatalErrors.push("agentDefaultsPayload.apiBaseUrl is not a valid URL"); + } + } + + if (!nonEmptyTrimmedString(defaults.apiKey)) { + diagnostics.push({ + code: "hermes_gateway_api_key_missing", + level: "warn", + message: "Hermes gateway API key is missing.", + hint: "Set agentDefaultsPayload.apiKey to the Hermes API_SERVER_KEY value.", + }); + fatalErrors.push("agentDefaultsPayload.apiKey is required"); + } + + return { normalized, diagnostics, fatalErrors }; + } if (input.adapterType !== "openclaw_gateway") { const normalized = isPlainObject(input.defaultsPayload) ? (input.defaultsPayload as Record<string, unknown>) @@ -919,6 +1011,24 @@ export function normalizeAgentDefaultsForJoin(input: { return { normalized, diagnostics, fatalErrors }; } +export async function prepareAgentDefaultsPayloadForJoinPersistence(input: { + db: Db; + companyId: string; + adapterType: string | null; + normalized: Record<string, unknown> | null; + actor?: { userId?: string | null; agentId?: string | null }; +}): Promise<Record<string, unknown> | null> { + if (input.adapterType !== "hermes_gateway" || !input.normalized) { + return input.normalized; + } + + return secretService(input.db).normalizeAdapterConfigForPersistence( + input.companyId, + input.normalized, + { adapterType: input.adapterType, actor: input.actor }, + ); +} + function toInviteSummaryResponse( req: Request, token: string, @@ -1585,17 +1695,17 @@ function buildInviteOnboardingManifest( ), onboarding: { instructions: - "Join as an external Paperclip agent, save your one-time claim secret, wait for board approval, then claim your API key. Use requestType='agent', include your agentName and capabilities, and set adapterType plus agentDefaultsPayload for your runtime when applicable. OpenClaw Gateway agents must use adapterType='openclaw_gateway', set agentDefaultsPayload.url to a ws:// or wss:// gateway endpoint, and include agentDefaultsPayload.headers.x-openclaw-token.", + "Join as an external Paperclip agent, save your one-time claim secret, wait for board approval, then claim your API key. Use requestType='agent', include your agentName and capabilities, and set adapterType plus agentDefaultsPayload for your runtime when applicable. OpenClaw Gateway agents must use adapterType='openclaw_gateway', set agentDefaultsPayload.url to a ws:// or wss:// gateway endpoint, and include agentDefaultsPayload.headers.x-openclaw-token. Hermes Gateway agents must use adapterType='hermes_gateway', start Hermes with API_SERVER_ENABLED=true, API_SERVER_KEY, and `hermes gateway run --replace --accept-hooks`, then set agentDefaultsPayload.apiBaseUrl and agentDefaultsPayload.paperclipApiUrl.", inviteMessage: extractInviteMessage(invite), recommendedAdapterType: null, requiredFields: { requestType: "agent", agentName: "Display name for this agent", adapterType: - "Adapter type for this runtime. Use 'openclaw_gateway' only for OpenClaw Gateway agents.", + "Adapter type for this runtime. Use 'openclaw_gateway' only for OpenClaw Gateway agents. Use 'hermes_gateway' only for Hermes Gateway agents.", capabilities: "Optional capability summary", agentDefaultsPayload: - "Runtime-specific adapter config. OpenClaw Gateway agents must include url (ws:// or wss://) and headers.x-openclaw-token. Other runtimes should include the config their adapter expects." + "Runtime-specific adapter config. OpenClaw Gateway agents must include url (ws:// or wss://) and headers.x-openclaw-token. Hermes Gateway agents must include apiBaseUrl, API_SERVER_KEY-backed auth, and paperclipApiUrl. Other runtimes should include the config their adapter expects." }, registrationEndpoint: { method: "POST", @@ -1744,6 +1854,37 @@ export function buildInviteOnboardingTextDocument( For OpenClaw Gateway, include agentDefaultsPayload.headers.x-openclaw-token with your gateway token. Legacy x-openclaw-auth is also accepted, but x-openclaw-token is preferred. Do NOT use /v1/responses or /hooks/* in this gateway join flow. + Hermes Gateway setup: + - adapterType: "hermes_gateway" + - Start Hermes with API_SERVER_ENABLED=true and API_SERVER_KEY=<random-gateway-key>. + - Run: hermes gateway run --replace --accept-hooks + - Default Hermes API server port: 8642. + - Set agentDefaultsPayload.apiBaseUrl to the Hermes gateway URL Paperclip can reach. + - Set agentDefaultsPayload.paperclipApiUrl to the Paperclip base URL Hermes can reach. + - Use hermes_local when Paperclip should start Hermes on the Paperclip host. + - Use hermes_gateway when Paperclip should call an already-running Hermes API server. + - Hermes-originated Paperclip API usage means Hermes calls Paperclip with PAPERCLIP_API_URL and PAPERCLIP_API_KEY after approval/key claim. Do not confuse that with agentDefaultsPayload.apiBaseUrl, which points Paperclip to Hermes. + + Hermes Gateway payload example: + { + "requestType": "agent", + "agentName": "My Hermes Gateway Agent", + "adapterType": "hermes_gateway", + "capabilities": "Hermes gateway agent", + "agentDefaultsPayload": { + "apiBaseUrl": "http://127.0.0.1:8642", + "apiKey": "<same-value-as-API_SERVER_KEY>", + "paperclipApiUrl": "http://localhost:3100" + } + } + + Hermes Gateway network examples: + - Local loopback: agentDefaultsPayload.apiBaseUrl = "http://127.0.0.1:8642" and agentDefaultsPayload.paperclipApiUrl = "http://127.0.0.1:3100". + - LAN/private network: use reachable private addresses, for example agentDefaultsPayload.apiBaseUrl = "http://192.168.1.25:8642" and agentDefaultsPayload.paperclipApiUrl = "http://192.168.1.10:3100". + - Private overlay: use overlay DNS names, for example agentDefaultsPayload.apiBaseUrl = "http://hermes-host.tailnet-name.ts.net:8642" and agentDefaultsPayload.paperclipApiUrl = "http://paperclip-host.tailnet-name.ts.net:3100". + - Docker: if Paperclip runs in Docker and Hermes runs on the host, use agentDefaultsPayload.apiBaseUrl = "http://host.docker.internal:8642"; if both run in Compose, use the Hermes service name. + - Reverse proxy/TLS: use HTTPS origins, for example agentDefaultsPayload.apiBaseUrl = "https://hermes-gateway.example" and agentDefaultsPayload.paperclipApiUrl = "https://paperclip.example". + Expected response includes: - request id - one-time claimSecret @@ -3565,6 +3706,20 @@ export function accessRoutes( throw badRequest(joinDefaults.fatalErrors.join("; ")); } + const persistedJoinDefaultsPayload = + requestType === "agent" + ? await prepareAgentDefaultsPayloadForJoinPersistence({ + db, + companyId, + adapterType, + normalized: joinDefaults.normalized, + actor: { + userId: req.actor.userId ?? null, + agentId: req.actor.agentId ?? null + } + }) + : null; + if (requestType === "agent" && adapterType === "openclaw_gateway") { logger.info( { @@ -3658,7 +3813,7 @@ export function accessRoutes( ? req.body.capabilities ?? null : null, agentDefaultsPayload: - requestType === "agent" ? joinDefaults.normalized : null, + requestType === "agent" ? persistedJoinDefaultsPayload : null, claimSecretHash, claimSecretExpiresAt }) @@ -3684,7 +3839,7 @@ export function accessRoutes( : null, adapterType: requestType === "agent" ? adapterType : null, agentDefaultsPayload: - requestType === "agent" ? joinDefaults.normalized : null, + requestType === "agent" ? persistedJoinDefaultsPayload : null, updatedAt: new Date() }) .where(eq(joinRequests.id, replayJoinRequestId as string)) diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 055abfb5c1..57c63ea9ad 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -140,7 +140,6 @@ export function agentRoutes( codex_local: "instructionsFilePath", droid_local: "instructionsFilePath", gemini_local: "instructionsFilePath", - hermes_local: "instructionsFilePath", opencode_local: "instructionsFilePath", cursor: "instructionsFilePath", pi_local: "instructionsFilePath", @@ -1079,7 +1078,10 @@ export function agentRoutes( const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( input.companyId, input.adapterConfig, - { strictMode: strictSecretsMode }, + { + strictMode: strictSecretsMode, + adapterType: input.adapterType ?? null, + }, ); await assertAdapterConfigConstraints( input.adapterType, @@ -1617,11 +1619,13 @@ export function agentRoutes( const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( companyId, inputAdapterConfig, - { strictMode: strictSecretsMode }, + { strictMode: strictSecretsMode, adapterType: type }, ); const { config: runtimeAdapterConfig } = await secretsSvc.resolveAdapterConfigForRuntime( companyId, normalizedAdapterConfig, + undefined, + { adapterType: type }, ); const { executionTarget, environmentName, fallbackChecks, release } = @@ -1943,6 +1947,18 @@ export function agentRoutes( res.json(buildLowTrustSelfView(agent)); return; } + if (req.actor.keyScope?.kind === "task_bridge") { + res.json({ + id: agent.id, + companyId: agent.companyId, + name: agent.name, + role: agent.role, + title: agent.title, + status: agent.status, + keyScope: req.actor.keyScope, + }); + return; + } res.json(await buildAgentDetail(agent)); }); @@ -2572,7 +2588,7 @@ export function agentRoutes( const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( existing.companyId, syncedAdapterConfig, - { strictMode: strictSecretsMode }, + { strictMode: strictSecretsMode, adapterType: existing.adapterType }, ); const actor = getActorInfo(req); const agent = await svc.update( @@ -2643,7 +2659,7 @@ export function agentRoutes( const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( existing.companyId, adapterConfig, - { strictMode: strictSecretsMode }, + { strictMode: strictSecretsMode, adapterType: existing.adapterType }, ); await svc.update( id, @@ -2711,7 +2727,7 @@ export function agentRoutes( const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( existing.companyId, result.adapterConfig, - { strictMode: strictSecretsMode }, + { strictMode: strictSecretsMode, adapterType: existing.adapterType }, ); await svc.update( id, @@ -3184,7 +3200,7 @@ export function agentRoutes( if (!agent) { return; } - const key = await svc.createApiKey(id, req.body.name); + const key = await svc.createApiKey(id, req.body.name, req.body.scope); await logActivity(db, { companyId: agent.companyId, @@ -3193,7 +3209,7 @@ export function agentRoutes( action: "agent.key_created", entityType: "agent", entityId: agent.id, - details: { keyId: key.id, name: key.name }, + details: { keyId: key.id, name: key.name, scope: key.scope }, }); res.status(201).json(key); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 95e0a05cfa..2913a05fc0 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -1889,6 +1889,25 @@ export function issueRoutes( throw forbidden(decision.explanation); } + function isTaskBridgeKeyActor(req: Request) { + return req.actor.type === "agent" && req.actor.source === "agent_key" && req.actor.keyScope?.kind === "task_bridge"; + } + + function taskBridgeOriginForActor(req: Request) { + return isTaskBridgeKeyActor(req) && req.actor.keyId + ? { originKind: "task_bridge", originId: req.actor.keyId } + : null; + } + + async function assertTaskBridgeCreateAllowed( + req: Request, + companyId: string, + assignmentScope: TaskAssignmentAuthorizationScope, + ) { + if (!isTaskBridgeKeyActor(req)) return; + await assertCanAssignTasks(req, companyId, assignmentScope); + } + async function decideIssueAccess( req: Request, issue: { @@ -2966,6 +2985,10 @@ export function issueRoutes( router.get("/companies/:companyId/issues", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); + if (isTaskBridgeKeyActor(req)) { + res.status(403).json({ error: "Task bridge keys cannot use company-wide issue list APIs" }); + return; + } const assigneeUserFilterRaw = req.query.assigneeUserId as string | undefined; const touchedByUserFilterRaw = req.query.touchedByUserId as string | undefined; const inboxArchivedByUserFilterRaw = req.query.inboxArchivedByUserId as string | undefined; @@ -3126,6 +3149,10 @@ export function issueRoutes( router.get("/companies/:companyId/issues/count", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); + if (isTaskBridgeKeyActor(req)) { + res.status(403).json({ error: "Task bridge keys cannot use company-wide issue count APIs" }); + return; + } const attention = req.query.attention as string | undefined; const hasPlanDocument = parseOptionalBooleanQuery(req.query.hasPlanDocument); if (attention !== "blocked") { @@ -5050,7 +5077,7 @@ export function issueRoutes( if (watchdogProductBugFollowUp === false) return; const effectiveParentId = watchdogProductBugFollowUp ? null : rawCreateBody.parentId; let createParent: Awaited<ReturnType<typeof svc.getById>> | null = null; - if (req.actor.type === "agent" && !effectiveParentId && !watchdogProductBugFollowUp) { + if (req.actor.type === "agent" && !effectiveParentId && !watchdogProductBugFollowUp && !isTaskBridgeKeyActor(req)) { const companyScopeDecision = await access.decide({ actor: req.actor, action: "company_scope:read", @@ -5067,7 +5094,7 @@ export function issueRoutes( res.status(404).json({ error: "Parent issue not found" }); return; } - if (!(await assertIssueReadAllowed(req, res, createParent))) return; + if (!isTaskBridgeKeyActor(req) && !(await assertIssueReadAllowed(req, res, createParent))) return; } if ( !watchdogProductBugFollowUp && @@ -5113,17 +5140,19 @@ export function issueRoutes( : {}), }; if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, { companyId }, createBody))) return; + const createAssignmentScope = { + projectId: await resolveAssignmentProjectId({ + companyId, + projectId: createBody.projectId, + parentIssueId: createBody.parentId, + }), + parentIssueId: createBody.parentId ?? null, + assigneeAgentId: createBody.assigneeAgentId ?? null, + assigneeUserId: rawCreateBody.assigneeUserId ?? null, + }; + await assertTaskBridgeCreateAllowed(req, companyId, createAssignmentScope); if (rawCreateBody.assigneeAgentId || rawCreateBody.assigneeUserId) { - await assertCanAssignTasks(req, companyId, { - projectId: await resolveAssignmentProjectId({ - companyId, - projectId: createBody.projectId, - parentIssueId: createBody.parentId, - }), - parentIssueId: createBody.parentId ?? null, - assigneeAgentId: createBody.assigneeAgentId ?? null, - assigneeUserId: rawCreateBody.assigneeUserId ?? null, - }); + await assertCanAssignTasks(req, companyId, createAssignmentScope); } await assertIssueEnvironmentSelection(companyId, createBody.executionWorkspaceSettings?.environmentId); @@ -5141,6 +5170,7 @@ export function issueRoutes( }, actor); const issue = await svc.create(companyId, { ...createBody, + ...(taskBridgeOriginForActor(req) ?? {}), id: issueId, executionPolicy, ...(sourceTrust ? { sourceTrust } : {}), @@ -5258,7 +5288,7 @@ export function issueRoutes( return; } assertCompanyAccess(req, parent.companyId); - if (!(await assertIssueReadAllowed(req, res, parent))) return; + if (!isTaskBridgeKeyActor(req) && !(await assertIssueReadAllowed(req, res, parent))) return; if (!(await assertTaskWatchdogCreateIssueAllowed(req, res, parent.companyId, parent))) return; if (await assertLowTrustControlPlaneDenied(req, res, parent.companyId, parent)) return; assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body)); @@ -5271,13 +5301,15 @@ export function issueRoutes( ...(normalizedAssigneeAgentId !== undefined ? { assigneeAgentId: normalizedAssigneeAgentId } : {}), }; if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, parent, createBody))) return; + const childAssignmentScope = { + projectId: createBody.projectId ?? parent.projectId ?? null, + parentIssueId: parent.id, + assigneeAgentId: createBody.assigneeAgentId ?? null, + assigneeUserId: createBody.assigneeUserId ?? null, + }; + await assertTaskBridgeCreateAllowed(req, parent.companyId, childAssignmentScope); if (req.body.assigneeAgentId || req.body.assigneeUserId) { - await assertCanAssignTasks(req, parent.companyId, { - projectId: createBody.projectId ?? parent.projectId ?? null, - parentIssueId: parent.id, - assigneeAgentId: createBody.assigneeAgentId ?? null, - assigneeUserId: createBody.assigneeUserId ?? null, - }); + await assertCanAssignTasks(req, parent.companyId, childAssignmentScope); } await assertIssueEnvironmentSelection(parent.companyId, createBody.executionWorkspaceSettings?.environmentId); @@ -5300,6 +5332,7 @@ export function issueRoutes( }, actor); const { issue, parentBlockerAdded } = await svc.createChild(parent.id, { ...createBody, + ...(taskBridgeOriginForActor(req) ?? {}), id: issueId, executionPolicy, ...(currentSerializedChild diff --git a/server/src/routes/llms.ts b/server/src/routes/llms.ts index ff5f36dca4..2d0d75eb60 100644 --- a/server/src/routes/llms.ts +++ b/server/src/routes/llms.ts @@ -3,8 +3,13 @@ import type { Db } from "@paperclipai/db"; import { AGENT_ICON_NAMES } from "@paperclipai/shared"; import { forbidden } from "../errors.js"; import { listServerAdapters } from "../adapters/index.js"; +import { hermesGatewayAgentConfigurationDoc } from "../adapters/hermes-gateway-doc.js"; import { agentService } from "../services/agents.js"; +const pluginOnlyAdapterDocs = new Map<string, string>([ + ["hermes_gateway", hermesGatewayAgentConfigurationDoc], +]); + function hasCreatePermission(agent: { role: string; permissions: Record<string, unknown> | null | undefined }) { if (!agent.permissions || typeof agent.permissions !== "object") return false; return Boolean((agent.permissions as Record<string, unknown>).canCreateAgents); @@ -34,6 +39,15 @@ export function llmRoutes(db: Db) { "Installed adapters:", ...adapters.map((adapter) => `- ${adapter.type}: /llms/agent-configuration/${adapter.type}.txt`), "", + "Plugin-only adapter docs:", + ...Array.from(pluginOnlyAdapterDocs.keys()) + .filter( + (adapterType) => !adapters.some((adapter) => adapter.type === adapterType), + ) + .map( + (adapterType) => `- ${adapterType}: /llms/agent-configuration/${adapterType}.txt`, + ), + "", "Related API endpoints:", "- GET /api/companies/:companyId/agent-configurations", "- GET /api/agents/:id/configuration", @@ -72,6 +86,11 @@ export function llmRoutes(db: Db) { const adapterType = req.params.adapterType as string; const adapter = listServerAdapters().find((entry) => entry.type === adapterType); if (!adapter) { + const pluginOnlyDoc = pluginOnlyAdapterDocs.get(adapterType); + if (pluginOnlyDoc) { + res.type("text/plain").send(pluginOnlyDoc); + return; + } res.status(404).type("text/plain").send(`Unknown adapter type: ${adapterType}`); return; } diff --git a/server/src/services/agent-secret-bindings.ts b/server/src/services/agent-secret-bindings.ts index 4afb096b24..9e266f21b6 100644 --- a/server/src/services/agent-secret-bindings.ts +++ b/server/src/services/agent-secret-bindings.ts @@ -1,4 +1,18 @@ +import { envBindingSchema, type SecretVersionSelector } from "@paperclipai/shared"; + interface AgentSecretBindingSyncService { + syncSecretRefsForTarget?: ( + companyId: string, + target: { targetType: "agent"; targetId: string }, + refs: Array<{ + secretId: string; + configPath: string; + versionSelector?: SecretVersionSelector; + required?: boolean; + label?: string | null; + }>, + options?: { replaceAll?: boolean }, + ) => Promise<unknown>; syncEnvBindingsForTarget?: ( companyId: string, target: { targetType: "agent"; targetId: string; pathPrefix?: string }, @@ -11,12 +25,63 @@ function asRecord(value: unknown): Record<string, unknown> | null { return value as Record<string, unknown>; } +function collectSecretRefs(adapterConfig: unknown): Array<{ + secretId: string; + configPath: string; + versionSelector?: SecretVersionSelector; +}> { + const config = asRecord(adapterConfig); + if (!config) return []; + const refs: Array<{ + secretId: string; + configPath: string; + versionSelector?: SecretVersionSelector; + }> = []; + + const envValue = asRecord(config.env); + for (const [key, rawBinding] of Object.entries(envValue ?? {})) { + const parsed = envBindingSchema.safeParse(rawBinding); + if (!parsed.success) continue; + const binding = parsed.data; + if (typeof binding !== "object" || binding === null || binding.type !== "secret_ref") continue; + refs.push({ + secretId: binding.secretId, + configPath: `env.${key}`, + versionSelector: binding.version ?? "latest", + }); + } + + for (const [key, rawBinding] of Object.entries(config)) { + if (key === "env") continue; + const parsed = envBindingSchema.safeParse(rawBinding); + if (!parsed.success) continue; + const binding = parsed.data; + if (typeof binding !== "object" || binding === null || binding.type !== "secret_ref") continue; + refs.push({ + secretId: binding.secretId, + configPath: key, + versionSelector: binding.version ?? "latest", + }); + } + + return refs; +} + export async function syncAgentAdapterEnvBindings(input: { secretsSvc: AgentSecretBindingSyncService; companyId: string; agentId: string; adapterConfig: unknown; }) { + if (input.secretsSvc.syncSecretRefsForTarget) { + await input.secretsSvc.syncSecretRefsForTarget( + input.companyId, + { targetType: "agent", targetId: input.agentId }, + collectSecretRefs(input.adapterConfig), + { replaceAll: true }, + ); + return; + } const envValue = asRecord(asRecord(input.adapterConfig)?.env); await input.secretsSvc.syncEnvBindingsForTarget?.( input.companyId, diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index 2c69558944..9be0140503 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -20,8 +20,10 @@ import { AGENT_DEFAULT_MAX_CONCURRENT_RUNS, getAgentWorkEligibility, isUuidLike, + normalizeAgentApiKeyScope, normalizeAgentUrlKey, type AgentEligibilityAgent, + type AgentApiKeyScope, } from "@paperclipai/shared"; import { conflict, notFound, unprocessable } from "../errors.js"; import { syncAgentAdapterEnvBindings } from "./agent-secret-bindings.js"; @@ -428,6 +430,16 @@ export function agentService(db: Db) { const role = (data.role ?? existing.role) as string; normalizedPatch.permissions = normalizeAgentPermissions(data.permissions, role); } + if ( + Object.prototype.hasOwnProperty.call(normalizedPatch, "adapterConfig") && + isPlainRecord(normalizedPatch.adapterConfig) + ) { + normalizedPatch.adapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( + existing.companyId, + normalizedPatch.adapterConfig, + { adapterType: (normalizedPatch.adapterType ?? existing.adapterType) as string }, + ); + } const shouldRecordRevision = Boolean(options?.recordRevision) && hasConfigPatchFields(normalizedPatch); const beforeConfig = shouldRecordRevision ? buildConfigSnapshot(existing) : null; @@ -503,11 +515,24 @@ export function agentService(db: Db) { const role = data.role ?? "general"; const normalizedPermissions = normalizeAgentPermissions(data.permissions, role); const runtimeConfig = normalizeRuntimeConfigForNewAgent(data.runtimeConfig); + const adapterType = data.adapterType ?? "process"; + const adapterConfig = isPlainRecord(data.adapterConfig) + ? await secretsSvc.normalizeAdapterConfigForPersistence(companyId, data.adapterConfig, { adapterType }) + : {}; return db.transaction(async (tx) => { const txDb = tx as unknown as Db; const created = await tx .insert(agents) - .values({ ...data, name: uniqueName, companyId, role, permissions: normalizedPermissions, runtimeConfig }) + .values({ + ...data, + name: uniqueName, + companyId, + role, + adapterType, + adapterConfig, + permissions: normalizedPermissions, + runtimeConfig, + }) .returning() .then((rows) => rows[0]); await syncAgentSecretBindings(created, txDb); @@ -733,7 +758,7 @@ export function agentService(db: Db) { }); }, - createApiKey: async (id: string, name: string) => { + createApiKey: async (id: string, name: string, scope: AgentApiKeyScope = { kind: "standard" }) => { const existing = await getById(id); if (!existing) throw notFound("Agent not found"); if (existing.status === "pending_approval") { @@ -752,6 +777,7 @@ export function agentService(db: Db) { companyId: existing.companyId, name, keyHash, + scopeConfig: scope.kind === "standard" ? null : scope, }) .returning() .then((rows) => rows[0]); @@ -759,6 +785,7 @@ export function agentService(db: Db) { return { id: created.id, name: created.name, + scope: normalizeAgentApiKeyScope(created.scopeConfig), token, createdAt: created.createdAt, }; @@ -769,11 +796,19 @@ export function agentService(db: Db) { .select({ id: agentApiKeys.id, name: agentApiKeys.name, + scopeConfig: agentApiKeys.scopeConfig, createdAt: agentApiKeys.createdAt, revokedAt: agentApiKeys.revokedAt, }) .from(agentApiKeys) - .where(eq(agentApiKeys.agentId, id)), + .where(eq(agentApiKeys.agentId, id)) + .then((rows) => rows.map((row) => ({ + id: row.id, + name: row.name, + scope: normalizeAgentApiKeyScope(row.scopeConfig), + createdAt: row.createdAt, + revokedAt: row.revokedAt, + }))), getKeyById: async (keyId: string) => db @@ -782,12 +817,21 @@ export function agentService(db: Db) { agentId: agentApiKeys.agentId, companyId: agentApiKeys.companyId, name: agentApiKeys.name, + scopeConfig: agentApiKeys.scopeConfig, createdAt: agentApiKeys.createdAt, revokedAt: agentApiKeys.revokedAt, }) .from(agentApiKeys) .where(eq(agentApiKeys.id, keyId)) - .then((rows) => rows[0] ?? null), + .then((rows) => { + const row = rows[0] ?? null; + return row + ? { + ...row, + scope: normalizeAgentApiKeyScope(row.scopeConfig), + } + : null; + }), revokeKey: async (agentId: string, keyId: string) => { const rows = await db diff --git a/server/src/services/authorization.ts b/server/src/services/authorization.ts index a4c513009c..d3c2a2749c 100644 --- a/server/src/services/authorization.ts +++ b/server/src/services/authorization.ts @@ -10,7 +10,7 @@ import { principalPermissionGrants, projects, } from "@paperclipai/db"; -import type { PermissionKey, PrincipalType } from "@paperclipai/shared"; +import type { AgentApiKeyScope, PermissionKey, PrincipalType, TaskBridgeAgentKeyScope } from "@paperclipai/shared"; import { LOW_TRUST_REVIEW_PRESET, extractAgentMentionIds, type LowTrustBoundary } from "@paperclipai/shared"; import { LOW_TRUST_ISSUE_ANCESTRY_MAX_DEPTH, @@ -29,6 +29,8 @@ export type AuthorizationActor = isInstanceAdmin?: boolean; agentId?: string | null; companyId?: string | null; + keyId?: string | null; + keyScope?: AgentApiKeyScope | null; runId?: string | null; source?: | "local_implicit" @@ -66,6 +68,8 @@ export type AuthorizationResource = parentIssueId?: string | null; assigneeAgentId?: string | null; assigneeUserId?: string | null; + originKind?: string | null; + originId?: string | null; status?: string | null; }; @@ -211,6 +215,8 @@ type IssueAuthorizationRow = { assigneeUserId: string | null; status: string; executionPolicy: unknown; + originKind: string | null; + originId: string | null; }; function evaluateAuthorizationPolicyForAssignment( @@ -550,6 +556,8 @@ export function authorizationService(db: Db) { assigneeUserId: issues.assigneeUserId, status: issues.status, executionPolicy: issues.executionPolicy, + originKind: issues.originKind, + originId: issues.originId, }) .from(issues) .where(eq(issues.id, issueId)) @@ -802,6 +810,130 @@ export function authorizationService(db: Db) { return null; } + function taskBridgeScopeIds( + scope: TaskBridgeAgentKeyScope, + singularKey: "projectId" | "parentIssueId", + pluralKey: "projectIds" | "parentIssueIds", + ) { + return [ + ...(typeof scope[singularKey] === "string" ? [scope[singularKey]] : []), + ...(Array.isArray(scope[pluralKey]) ? scope[pluralKey] : []), + ].filter((value): value is string => typeof value === "string" && value.length > 0); + } + + async function parentIssueMatchesTaskBridgeBoundary( + parentIssueId: string | null | undefined, + companyId: string, + allowedParentIssueIds: string[], + ) { + if (!parentIssueId || allowedParentIssueIds.length === 0) return false; + if (allowedParentIssueIds.includes(parentIssueId)) return true; + for (const rootIssueId of allowedParentIssueIds) { + if (await issueIdIsDescendantOf(parentIssueId, rootIssueId, companyId)) return true; + } + return false; + } + + async function issueMatchesTaskBridgeCreateBoundary( + scope: TaskBridgeAgentKeyScope, + resource: Extract<AuthorizationResource, { type: "issue" }>, + ) { + const allowedProjectIds = taskBridgeScopeIds(scope, "projectId", "projectIds"); + const allowedParentIssueIds = taskBridgeScopeIds(scope, "parentIssueId", "parentIssueIds"); + if (resource.projectId && allowedProjectIds.includes(resource.projectId)) return true; + if (await parentIssueMatchesTaskBridgeBoundary(resource.parentIssueId, resource.companyId, allowedParentIssueIds)) { + return true; + } + if (resource.parentIssueId && allowedProjectIds.length > 0) { + const parent = await loadIssue(resource.parentIssueId); + if (parent?.companyId === resource.companyId && parent.projectId && allowedProjectIds.includes(parent.projectId)) { + return true; + } + } + return false; + } + + async function issueMatchesTaskBridgeWriteBoundary(input: { + actorAgentId: string; + keyId: string; + resource: Extract<AuthorizationResource, { type: "issue" }>; + }) { + const issue = input.resource.issueId ? await loadIssue(input.resource.issueId) : null; + const assigneeAgentId = issue?.assigneeAgentId ?? input.resource.assigneeAgentId ?? null; + if (assigneeAgentId === input.actorAgentId) return true; + const originKind = issue?.originKind ?? input.resource.originKind ?? null; + const originId = issue?.originId ?? input.resource.originId ?? null; + return originKind === "task_bridge" && originId === input.keyId; + } + + async function decideTaskBridgeAccess(input: { + actorAgentId: string; + action: AuthorizationAction; + resource: AuthorizationResource; + scope: TaskBridgeAgentKeyScope; + keyId: string; + }): Promise<AuthorizationDecision | null> { + const denyBridge = (explanation: string) => + deny({ + action: input.action, + reason: "deny_scope", + explanation, + }); + const allowBridge = (explanation: string) => + allow({ + action: input.action, + reason: "allow_explicit_grant", + explanation, + }); + + if ( + input.action === "company_scope:read" || + input.action === "agent:read" || + input.action === "agent:wake" || + input.action === "project:read" || + input.action === "runtime:manage" || + input.action === "secrets:read" + ) { + return denyBridge("Task bridge keys cannot use company-wide, peer-agent, project, runtime, or secret APIs."); + } + + if (input.action === "tasks:assign") { + if (input.resource.type !== "issue") { + return denyBridge("Task bridge assignment requires an issue resource."); + } + if (!(await issueMatchesTaskBridgeCreateBoundary(input.scope, input.resource))) { + return denyBridge("Task bridge key is outside its approved parent or project boundary."); + } + if (input.resource.assigneeUserId) { + return denyBridge("Task bridge keys cannot assign work to board users."); + } + const allowedAssigneeAgentIds = input.scope.allowedAssigneeAgentIds ?? []; + if ( + input.resource.assigneeAgentId && + input.resource.assigneeAgentId !== input.actorAgentId && + !allowedAssigneeAgentIds.includes(input.resource.assigneeAgentId) + ) { + return denyBridge("Task bridge key cannot assign work to that agent."); + } + return allowBridge("Allowed by task bridge create boundary."); + } + + if (input.action === "issue:read" || input.action === "issue:comment" || input.action === "issue:mutate") { + if (input.resource.type !== "issue") { + return denyBridge("Task bridge issue access requires an issue resource."); + } + return await issueMatchesTaskBridgeWriteBoundary({ + actorAgentId: input.actorAgentId, + keyId: input.keyId, + resource: input.resource, + }) + ? allowBridge("Allowed for bridge-created or assigned issue.") + : denyBridge("Task bridge key can only access assigned or bridge-created issues."); + } + + return denyBridge("Task bridge key cannot use this API action."); + } + async function assignmentTargetIsInCompany(resource: AuthorizationResource) { if (resource.type !== "issue") return true; if (resource.assigneeAgentId) { @@ -1183,6 +1315,25 @@ export function authorizationService(db: Db) { }); } + if (input.actor.source === "agent_key" && input.actor.keyScope?.kind === "task_bridge") { + const keyId = input.actor.keyId ?? null; + if (!keyId) { + return deny({ + action: input.action, + reason: "deny_scope", + explanation: "Task bridge key context is missing.", + }); + } + const taskBridgeDecision = await decideTaskBridgeAccess({ + actorAgentId, + action: input.action, + resource: input.resource, + scope: input.actor.keyScope, + keyId, + }); + if (taskBridgeDecision) return taskBridgeDecision; + } + const lowTrustDecision = await decideLowTrustAccess({ actorAgentId, action: input.action, diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 1024a871b7..b1c1c7216c 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -3050,7 +3050,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const normalizedAdapterConfig = await secrets.normalizeAdapterConfigForPersistence( companyId, nextAdapterConfig, - { strictMode: strictSecretsMode }, + { strictMode: strictSecretsMode, adapterType: effectiveAdapterType }, ); await assertImportAdapterConfigConstraints(effectiveAdapterType, normalizedAdapterConfig); return { diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 197b11d8c6..81c4a3af9f 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -412,7 +412,10 @@ const INLINE_BASE64_IMAGE_DATA_RE = /("type":"image","source":\{"type":"base64", type RuntimeConfigSecretResolver = Pick< ReturnType<typeof secretService>, - "resolveAdapterConfigForRuntime" | "resolveEnvBindings" | "collectMissingRuntimeBindings" + | "resolveAdapterConfigForRuntime" + | "resolveEnvBindings" + | "collectMissingRuntimeBindings" + | "collectMissingAdapterConfigRuntimeBindings" >; function formatMissingBindingForOperator(missing: MissingRuntimeBinding): string { @@ -494,6 +497,7 @@ function assertLowTrustEnvConfigAllowed(envValue: unknown, source: string) { export async function resolveExecutionRunAdapterConfig(input: { companyId: string; agentId?: string | null; + adapterType?: string | null; issueId?: string | null; heartbeatRunId?: string | null; environmentId?: string | null; @@ -574,6 +578,16 @@ export async function resolveExecutionRunAdapterConfig(input: { { consumerType: "agent", consumerId: input.agentId }, )), ); + if (typeof input.secretsSvc.collectMissingAdapterConfigRuntimeBindings === "function") { + missingBindings.push( + ...(await input.secretsSvc.collectMissingAdapterConfigRuntimeBindings( + input.companyId, + executionRunConfig, + input.adapterType ?? null, + { consumerType: "agent", consumerId: input.agentId }, + )), + ); + } } if (projectEnv && input.projectId) { missingBindings.push( @@ -666,6 +680,7 @@ export async function resolveExecutionRunAdapterConfig(input: { ...(lowTrustAllowedBindingIds !== undefined ? { allowedBindingIds: lowTrustAllowedBindingIds } : {}), } : undefined, + { adapterType: input.adapterType ?? null }, ); if (Object.keys(environmentEnvResolution.env).length > 0) { resolvedConfig.env = { @@ -3140,6 +3155,13 @@ export function buildPaperclipTaskMarkdown(input: { workMode?: string | null; description?: string | null; } | null; + ancestors?: Array<{ + id: string; + identifier?: string | null; + title?: string | null; + status?: string | null; + priority?: string | null; + }> | null; wakeComment?: { id: string; body: string; @@ -3160,6 +3182,7 @@ export function buildPaperclipTaskMarkdown(input: { return [fence + "text", value, fence].join("\n"); }; const issue = input.issue; + const ancestors = (input.ancestors ?? []).slice(0, 6); const wakeComment = input.wakeComment ?? null; const acceptedPlanContinuation = !wakeComment && @@ -3212,6 +3235,19 @@ export function buildPaperclipTaskMarkdown(input: { lines.push("", "Issue description:", fenceTaskText(description)); } } + if (ancestors.length > 0) { + lines.push("", "Authoritative parent / ancestor context:"); + for (const [index, ancestor] of ancestors.entries()) { + const label = ancestor.identifier || ancestor.id; + const status = ancestor.status ? ` (${ancestor.status})` : ""; + const priority = ancestor.priority ? ` [${ancestor.priority}]` : ""; + const title = ancestor.title ? ` ${ancestor.title}` : ""; + lines.push(`- ${index === 0 ? "Parent" : `Ancestor ${index + 1}`}: ${label}${title}${status}${priority}`); + } + if ((input.ancestors ?? []).length > ancestors.length) { + lines.push(`- [ancestor context truncated after ${ancestors.length} entries]`); + } + } if (wakeComment?.body.trim()) { lines.push("", "Latest wake comment:", fenceTaskText(wakeComment.body.trim())); } @@ -8647,6 +8683,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) wakeCommentContext && !exposeLowTrustRaw ? sanitizeQuarantinedCommentForHigherTrust(wakeCommentContext) : wakeCommentContext; + const issueAncestors = issueRef + ? await issuesSvc.getAncestors(issueRef.id) + : []; if (continuationSummary) { context.paperclipContinuationSummary = { key: safeContinuationSummary!.key, @@ -8692,6 +8731,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) description: issueRef.description, } : null, + ancestors: issueAncestors, wakeComment: safeWakeCommentContext, interaction: { kind: readNonEmptyString(context.interactionKind), @@ -8918,6 +8958,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) const { resolvedConfig, secretKeys, secretManifest } = await resolveExecutionRunAdapterConfig({ companyId: agent.companyId, agentId: agent.id, + adapterType: agent.adapterType, issueId, heartbeatRunId: run.id, environmentId: selectedEnvironmentForConfig?.id ?? null, diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts index 25b1228d8e..69fefbbe3d 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { and, desc, eq, inArray, like, ne, notInArray, or, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { @@ -55,6 +56,7 @@ import type { } from "../secrets/types.js"; import { isSecretProviderClientError } from "../secrets/types.js"; import { authorizationService } from "./authorization.js"; +import { findActiveServerAdapter } from "../adapters/index.js"; const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; const SENSITIVE_ENV_KEY_RE = @@ -64,6 +66,9 @@ const COMING_SOON_SECRET_PROVIDERS: ReadonlySet<SecretProvider> = new Set([ "gcp_secret_manager", "vault", ]); +const FALLBACK_ADAPTER_SCHEMA_SECRET_FIELDS: Readonly<Record<string, readonly string[]>> = { + hermes_gateway: ["apiKey"], +}; type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0]; type SecretBindingDb = Pick<Db | DbTransaction, "select" | "delete" | "insert">; @@ -357,6 +362,11 @@ export function secretService(db: Db) { strictMode?: boolean; fieldPath?: string; }; + type NormalizeAdapterConfigOptions = { + strictMode?: boolean; + adapterType?: string | null; + actor?: { userId?: string | null; agentId?: string | null }; + }; async function getById(id: string, source: Pick<Db | DbTransaction, "select"> = db) { return source @@ -809,16 +819,210 @@ export function secretService(db: Db) { async function normalizeAdapterConfigForPersistenceInternal( companyId: string, adapterConfig: Record<string, unknown>, - opts?: { strictMode?: boolean }, + opts?: NormalizeAdapterConfigOptions, ) { const normalized = { ...adapterConfig }; - if (!Object.prototype.hasOwnProperty.call(adapterConfig, "env")) { - return normalized; + if (Object.prototype.hasOwnProperty.call(adapterConfig, "env")) { + normalized.env = await normalizeEnvConfig(companyId, adapterConfig.env, opts); + } + const secretFieldKeys = await listAdapterSchemaSecretFieldKeys(opts?.adapterType); + for (const key of secretFieldKeys) { + if (!Object.prototype.hasOwnProperty.call(adapterConfig, key)) continue; + const value = await normalizeSchemaSecretFieldForPersistence(companyId, { + adapterType: opts?.adapterType ?? null, + key, + rawValue: adapterConfig[key], + actor: opts?.actor, + }); + if (value === undefined) { + delete normalized[key]; + } else { + normalized[key] = value; + } } - normalized.env = await normalizeEnvConfig(companyId, adapterConfig.env, opts); return normalized; } + async function listAdapterSchemaSecretFieldKeys(adapterType: string | null | undefined): Promise<string[]> { + if (!adapterType) return []; + const adapter = findActiveServerAdapter(adapterType); + const fallback = [...(FALLBACK_ADAPTER_SCHEMA_SECRET_FIELDS[adapterType] ?? [])]; + if (!adapter?.getConfigSchema) return fallback; + try { + const schema = await adapter.getConfigSchema(); + return [...new Set([ + ...fallback, + ...schema.fields + .filter((field) => field.meta?.secret === true) + .map((field) => field.key), + ])]; + } catch (err) { + logger.warn({ err, adapterType }, "adapter config schema unavailable while normalizing secret fields"); + return fallback; + } + } + + async function normalizeSchemaSecretFieldForPersistence( + companyId: string, + input: { + adapterType: string | null; + key: string; + rawValue: unknown; + actor?: { userId?: string | null; agentId?: string | null }; + }, + ): Promise<EnvBinding | undefined> { + if (input.rawValue === null || input.rawValue === undefined) return undefined; + const parsed = envBindingSchema.safeParse(input.rawValue); + if (!parsed.success) { + throw unprocessable(`${input.key} must be a string, plain binding, or secret reference`); + } + const binding = canonicalizeBinding(parsed.data as EnvBinding); + if (binding.type === "secret_ref") { + await assertSecretInCompany(companyId, binding.secretId); + return { + type: "secret_ref", + secretId: binding.secretId, + version: binding.version, + }; + } + const value = binding.value.trim(); + if (!value) return undefined; + if (value === REDACTED_SENTINEL) { + throw unprocessable(`Refusing to persist redacted placeholder for key: ${input.key}`); + } + const id = randomUUID(); + const adapterPart = normalizeSecretKey(input.adapterType ?? "adapter"); + const fieldPart = normalizeSecretKey(input.key); + const secret = await createManagedLocalSecret(companyId, { + name: `${adapterPart}.${fieldPart}.${id}`, + key: `${adapterPart}.${fieldPart}.${id}`, + value, + description: `Adapter config secret for ${input.adapterType ?? "adapter"}.${input.key}`, + }, input.actor); + return { + type: "secret_ref", + secretId: secret.id, + version: "latest", + }; + } + + async function createManagedLocalSecret( + companyId: string, + input: { + name: string; + key: string; + value: string; + description?: string | null; + }, + actor?: { userId?: string | null; agentId?: string | null }, + ) { + const existing = await getByName(companyId, input.name); + if (existing) throw conflict(`Secret already exists: ${input.name}`); + const key = normalizeSecretKey(input.key); + if (!key) throw unprocessable("Secret key is required"); + const duplicateKey = await db + .select() + .from(companySecrets) + .where(and( + eq(companySecrets.companyId, companyId), + eq(companySecrets.key, key), + ne(companySecrets.status, "deleted"), + )) + .then((rows) => rows[0] ?? null); + if (duplicateKey) throw conflict(`Secret key already exists: ${key}`); + + const provider = getSecretProvider("local_encrypted"); + const providerConfig = await getSelectableRuntimeProviderConfig({ + companyId, + provider: "local_encrypted", + providerConfigId: null, + }); + const providerWriteContext = { + companyId, + secretKey: key, + secretName: input.name, + version: 1, + }; + const reservedSecret = await db + .insert(companySecrets) + .values({ + companyId, + key, + name: input.name, + provider: "local_encrypted", + providerConfigId: null, + status: "archived", + managedMode: "paperclip_managed", + externalRef: null, + providerMetadata: null, + latestVersion: 0, + description: input.description ?? null, + createdByAgentId: actor?.agentId ?? null, + createdByUserId: actor?.userId ?? null, + }) + .returning() + .then((rows) => rows[0]); + + let prepared: PreparedSecretVersion | null = null; + try { + prepared = await provider.createSecret({ + value: input.value, + externalRef: null, + providerConfig, + context: providerWriteContext, + }); + const preparedSecret = prepared; + await db.insert(companySecretVersions).values({ + secretId: reservedSecret.id, + version: 1, + material: preparedSecret.material, + valueSha256: preparedSecret.valueSha256, + fingerprintSha256: preparedSecret.fingerprintSha256 ?? preparedSecret.valueSha256, + providerVersionRef: preparedSecret.providerVersionRef ?? null, + status: "disabled", + createdByAgentId: actor?.agentId ?? null, + createdByUserId: actor?.userId ?? null, + }); + return await db.transaction(async (tx) => { + await tx + .update(companySecretVersions) + .set({ status: "current" }) + .where(and( + eq(companySecretVersions.secretId, reservedSecret.id), + eq(companySecretVersions.version, 1), + )); + const secret = await tx + .update(companySecrets) + .set({ + status: "active", + externalRef: preparedSecret.externalRef, + latestVersion: 1, + lastRotatedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(companySecrets.id, reservedSecret.id)) + .returning() + .then((rows) => rows[0]); + if (!secret) throw notFound("Secret not found"); + return secret; + }); + } catch (error) { + if (prepared) { + await cleanupPreparedProviderWrite({ + provider, + prepared, + providerConfig, + context: providerWriteContext, + mode: "delete", + operation: "adapter_config_secret.create_rollback", + }).catch(() => false); + } + await db.delete(companySecretVersions).where(eq(companySecretVersions.secretId, reservedSecret.id)).catch(() => undefined); + await db.delete(companySecrets).where(eq(companySecrets.id, reservedSecret.id)).catch(() => undefined); + throw error; + } + } + function collectTargetIds( bindings: Array<typeof companySecretBindings.$inferSelect>, targetType: SecretBindingTargetType, @@ -1817,9 +2021,11 @@ export function secretService(db: Db) { operation: "create.prepare_rollback", }); if (cleaned) { + await db.delete(companySecretVersions).where(eq(companySecretVersions.secretId, reservedSecret.id)).catch(() => undefined); await db.delete(companySecrets).where(eq(companySecrets.id, reservedSecret.id)).catch(() => undefined); } } else { + await db.delete(companySecretVersions).where(eq(companySecretVersions.secretId, reservedSecret.id)).catch(() => undefined); await db.delete(companySecrets).where(eq(companySecrets.id, reservedSecret.id)).catch(() => undefined); } throw error; @@ -1862,9 +2068,11 @@ export function secretService(db: Db) { operation: "create.rollback", }); if (cleaned) { + await db.delete(companySecretVersions).where(eq(companySecretVersions.secretId, reservedSecret.id)).catch(() => undefined); await db.delete(companySecrets).where(eq(companySecrets.id, reservedSecret.id)).catch(() => undefined); } } else { + await db.delete(companySecretVersions).where(eq(companySecretVersions.secretId, reservedSecret.id)).catch(() => undefined); await db.delete(companySecrets).where(eq(companySecrets.id, reservedSecret.id)).catch(() => undefined); } throw error; @@ -2159,6 +2367,7 @@ export function secretService(db: Db) { required?: boolean; label?: string | null; }>, + options?: { replaceAll?: boolean }, ) => { const normalizedRefs: Array<{ secretId: string; @@ -2181,7 +2390,17 @@ export function secretService(db: Db) { const pathPrefixes = [...new Set(normalizedRefs.map((ref) => ref.configPath.split(".")[0]))]; await db.transaction(async (tx) => { - if (pathPrefixes.length > 0) { + if (options?.replaceAll) { + await tx + .delete(companySecretBindings) + .where( + and( + eq(companySecretBindings.companyId, companyId), + eq(companySecretBindings.targetType, target.targetType), + eq(companySecretBindings.targetId, target.targetId), + ), + ); + } else if (pathPrefixes.length > 0) { for (const pathPrefix of pathPrefixes) { await tx .delete(companySecretBindings) @@ -2351,7 +2570,7 @@ export function secretService(db: Db) { normalizeAdapterConfigForPersistence: async ( companyId: string, adapterConfig: Record<string, unknown>, - opts?: { strictMode?: boolean }, + opts?: NormalizeAdapterConfigOptions, ) => normalizeAdapterConfigForPersistenceInternal(companyId, adapterConfig, opts), normalizeEnvBindingsForPersistence: async ( @@ -2363,7 +2582,7 @@ export function secretService(db: Db) { normalizeHireApprovalPayloadForPersistence: async ( companyId: string, payload: Record<string, unknown>, - opts?: { strictMode?: boolean }, + opts?: NormalizeAdapterConfigOptions, ) => { const normalized = { ...payload }; const adapterConfig = asRecord(payload.adapterConfig); @@ -2473,52 +2692,121 @@ export function secretService(db: Db) { })); }, + collectMissingAdapterConfigRuntimeBindings: async ( + companyId: string, + adapterConfig: Record<string, unknown>, + adapterType: string | null | undefined, + context: Omit<SecretConsumerContext, "configPath">, + ): Promise<MissingRuntimeBinding[]> => { + const secretFieldKeys = await listAdapterSchemaSecretFieldKeys(adapterType); + const secretRefs = secretFieldKeys.flatMap((key) => { + const parsed = envBindingSchema.safeParse(adapterConfig[key]); + if (!parsed.success) return []; + const binding = canonicalizeBinding(parsed.data as EnvBinding); + if (binding.type !== "secret_ref") return []; + return [{ key, configPath: key, secretId: binding.secretId }]; + }); + if (secretRefs.length === 0) return []; + const bindingChecks = await Promise.all(secretRefs.map(async (entry) => ({ + entry, + found: await getBinding({ + companyId, + secretId: entry.secretId, + consumerType: context.consumerType, + consumerId: context.consumerId, + configPath: entry.configPath, + }), + }))); + const missingEntries = bindingChecks + .filter((check) => !check.found) + .map((check) => check.entry); + if (missingEntries.length === 0) return []; + + const secretRows = await Promise.all( + [...new Set(missingEntries.map((entry) => entry.secretId))].map(async (secretId) => [ + secretId, + await getById(secretId).catch(() => null), + ] as const), + ); + const secretsById = new Map(secretRows); + + return missingEntries.map((entry) => ({ + consumerType: context.consumerType, + consumerId: context.consumerId, + configPath: entry.configPath, + envKey: entry.key, + secretId: entry.secretId, + secretName: secretsById.get(entry.secretId)?.name ?? null, + })); + }, + resolveAdapterConfigForRuntime: async ( companyId: string, adapterConfig: Record<string, unknown>, context?: Omit<SecretConsumerContext, "configPath">, + opts?: { adapterType?: string | null }, ): Promise<{ config: Record<string, unknown>; secretKeys: Set<string>; manifest: RuntimeSecretManifestEntry[] }> => { const resolved = { ...adapterConfig }; const secretKeys = new Set<string>(); const manifest: RuntimeSecretManifestEntry[] = []; - if (!Object.prototype.hasOwnProperty.call(adapterConfig, "env")) { - return { config: resolved, secretKeys, manifest }; - } - const record = asRecord(adapterConfig.env); - if (!record) { - resolved.env = {}; - return { config: resolved, secretKeys, manifest }; - } - const env: Record<string, string> = {}; - for (const [key, rawBinding] of Object.entries(record)) { - if (!ENV_KEY_RE.test(key)) { - throw unprocessable(`Invalid environment variable name: ${key}`); - } - const parsed = envBindingSchema.safeParse(rawBinding); - if (!parsed.success) { - throw unprocessable(`Invalid environment binding for key: ${key}`); - } - const binding = canonicalizeBinding(parsed.data as EnvBinding); - if (binding.type === "plain") { - env[key] = binding.value; + if (Object.prototype.hasOwnProperty.call(adapterConfig, "env")) { + const record = asRecord(adapterConfig.env); + if (!record) { + resolved.env = {}; } else { - const secretResolution = await resolveSecretValueInternal( - companyId, - binding.secretId, - binding.version, - context - ? { - bindingContext: { ...context, configPath: `env.${key}` }, - accessContext: { ...context, configPath: `env.${key}` }, - } - : undefined, - ); - env[key] = secretResolution.value; - manifest.push(secretResolution.manifestEntry); - secretKeys.add(key); + const env: Record<string, string> = {}; + for (const [key, rawBinding] of Object.entries(record)) { + if (!ENV_KEY_RE.test(key)) { + throw unprocessable(`Invalid environment variable name: ${key}`); + } + const parsed = envBindingSchema.safeParse(rawBinding); + if (!parsed.success) { + throw unprocessable(`Invalid environment binding for key: ${key}`); + } + const binding = canonicalizeBinding(parsed.data as EnvBinding); + if (binding.type === "plain") { + env[key] = binding.value; + } else { + const secretResolution = await resolveSecretValueInternal( + companyId, + binding.secretId, + binding.version, + context + ? { + bindingContext: { ...context, configPath: `env.${key}` }, + accessContext: { ...context, configPath: `env.${key}` }, + } + : undefined, + ); + env[key] = secretResolution.value; + manifest.push(secretResolution.manifestEntry); + secretKeys.add(key); + } + } + resolved.env = env; } } - resolved.env = env; + const secretFieldKeys = await listAdapterSchemaSecretFieldKeys(opts?.adapterType); + for (const key of secretFieldKeys) { + const parsed = envBindingSchema.safeParse(adapterConfig[key]); + if (!parsed.success) continue; + const binding = canonicalizeBinding(parsed.data as EnvBinding); + if (binding.type === "plain") continue; + const secretResolution = await resolveSecretValueInternal( + companyId, + binding.secretId, + binding.version, + context + ? { + bindingContext: { ...context, configPath: key }, + accessContext: { ...context, configPath: key }, + } + : undefined, + ); + resolved[key] = secretResolution.value; + manifest.push(secretResolution.manifestEntry); + secretKeys.add(key); + } return { config: resolved, secretKeys, manifest }; }, }; diff --git a/server/src/types/express.d.ts b/server/src/types/express.d.ts index 17f94e752e..4d04d1cc48 100644 --- a/server/src/types/express.d.ts +++ b/server/src/types/express.d.ts @@ -1,5 +1,7 @@ export {}; +import type { AgentApiKeyScope } from "@paperclipai/shared"; + declare global { namespace Express { interface Request { @@ -18,6 +20,7 @@ declare global { }>; isInstanceAdmin?: boolean; keyId?: string; + keyScope?: AgentApiKeyScope; runId?: string; source?: "local_implicit" | "session" | "board_key" | "agent_key" | "agent_jwt" | "cloud_tenant" | "none"; }; diff --git a/tsconfig.json b/tsconfig.json index e597c33111..e043a6a490 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,8 @@ { "path": "./packages/adapters/claude-local" }, { "path": "./packages/adapters/codex-local" }, { "path": "./packages/adapters/cursor-local" }, + { "path": "./packages/adapters/hermes-gateway" }, + { "path": "./packages/adapters/hermes" }, { "path": "./packages/adapters/droid-local" }, { "path": "./packages/adapters/openclaw-gateway" }, { "path": "./packages/adapters/opencode-local" }, diff --git a/ui/package.json b/ui/package.json index e20a5751ad..b52f0b6e32 100644 --- a/ui/package.json +++ b/ui/package.json @@ -46,13 +46,13 @@ "@paperclipai/adapter-pi-local": "workspace:*", "@paperclipai/adapter-utils": "workspace:*", "@paperclipai/shared": "workspace:*", + "@paperclipai/hermes-paperclip-adapter": "workspace:*", "@radix-ui/react-slot": "^1.2.4", "@tailwindcss/typography": "^0.5.19", "@tanstack/react-query": "^5.90.21", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "hermes-paperclip-adapter": "^0.3.0", "i18next": "^26.3.1", "lexical": "0.35.0", "lucide-react": "^0.577.0", diff --git a/ui/src/adapters/adapter-display-registry.ts b/ui/src/adapters/adapter-display-registry.ts index e710d07b9c..17a20dca8f 100644 --- a/ui/src/adapters/adapter-display-registry.ts +++ b/ui/src/adapters/adapter-display-registry.ts @@ -16,7 +16,6 @@ import { Cpu, } from "lucide-react"; import { OpenCodeLogoIcon } from "@/components/OpenCodeLogoIcon"; -import { HermesIcon } from "@/components/HermesIcon"; // --------------------------------------------------------------------------- // Type suffix parsing @@ -83,16 +82,22 @@ const adapterDisplayMap: Record<string, AdapterDisplayInfo> = { description: "Local Grok Build agent", icon: Bot, }, + hermes_gateway: { + label: "Hermes", + description: "Remote Hermes API server", + icon: Bot, + hideFromVisualSelection: true, + }, + hermes_local: { + label: "Hermes", + description: "Local Hermes agent", + icon: Bot, + }, opencode_local: { label: "OpenCode", description: "Local multi-provider agent", icon: OpenCodeLogoIcon, }, - hermes_local: { - label: "Hermes Agent", - description: "Local Hermes CLI agent", - icon: HermesIcon, - }, pi_local: { label: "Pi", description: "Local Pi agent", diff --git a/ui/src/adapters/hermes-gateway/index.ts b/ui/src/adapters/hermes-gateway/index.ts new file mode 100644 index 0000000000..3aa46ec063 --- /dev/null +++ b/ui/src/adapters/hermes-gateway/index.ts @@ -0,0 +1,11 @@ +import type { UIAdapterModule } from "../types"; +import { parseStdoutLine as parseHermesGatewayStdoutLine } from "@paperclipai/hermes-paperclip-adapter/gateway/ui"; +import { SchemaConfigFields, buildSchemaAdapterConfig } from "../schema-config-fields"; + +export const hermesGatewayUIAdapter: UIAdapterModule = { + type: "hermes_gateway", + label: "Hermes Gateway", + parseStdoutLine: parseHermesGatewayStdoutLine, + ConfigFields: SchemaConfigFields, + buildAdapterConfig: buildSchemaAdapterConfig, +}; diff --git a/ui/src/adapters/hermes-local/config-fields.tsx b/ui/src/adapters/hermes-local/config-fields.tsx deleted file mode 100644 index 4b80704365..0000000000 --- a/ui/src/adapters/hermes-local/config-fields.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import type { AdapterConfigFieldsProps } from "../types"; -import { - Field, - DraftInput, -} from "../../components/agent-config-primitives"; -import { ChoosePathButton } from "../../components/PathInstructionsModal"; - -const inputClass = - "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"; -const instructionsFileHint = - "Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime."; - -export function HermesLocalConfigFields({ - isCreate, - values, - set, - config, - eff, - mark, - hideInstructionsFile, -}: AdapterConfigFieldsProps) { - if (hideInstructionsFile) return null; - return ( - <Field label="Agent instructions file" hint={instructionsFileHint}> - <div className="flex items-center gap-2"> - <DraftInput - value={ - isCreate - ? values!.instructionsFilePath ?? "" - : eff( - "adapterConfig", - "instructionsFilePath", - String(config.instructionsFilePath ?? ""), - ) - } - onCommit={(v) => - isCreate - ? set!({ instructionsFilePath: v }) - : mark("adapterConfig", "instructionsFilePath", v || undefined) - } - immediate - className={inputClass} - placeholder="/absolute/path/to/AGENTS.md" - /> - <ChoosePathButton /> - </div> - </Field> - ); -} diff --git a/ui/src/adapters/hermes-local/index.ts b/ui/src/adapters/hermes-local/index.ts index a74491748b..6eb325aeb7 100644 --- a/ui/src/adapters/hermes-local/index.ts +++ b/ui/src/adapters/hermes-local/index.ts @@ -1,11 +1,10 @@ import type { UIAdapterModule } from "../types"; -import { parseHermesStdoutLine } from "hermes-paperclip-adapter/ui"; -import { buildHermesConfig } from "hermes-paperclip-adapter/ui"; +import { parseHermesStdoutLine, buildHermesConfig } from "@paperclipai/hermes-paperclip-adapter/ui"; import { SchemaConfigFields } from "../schema-config-fields"; export const hermesLocalUIAdapter: UIAdapterModule = { type: "hermes_local", - label: "Hermes Agent", + label: "Hermes", parseStdoutLine: parseHermesStdoutLine, ConfigFields: SchemaConfigFields, buildAdapterConfig: buildHermesConfig, diff --git a/ui/src/adapters/registry.test.ts b/ui/src/adapters/registry.test.ts index 6d30f6b0a6..2d36af655e 100644 --- a/ui/src/adapters/registry.test.ts +++ b/ui/src/adapters/registry.test.ts @@ -5,6 +5,7 @@ import { getUIAdapter, listUIAdapters, registerUIAdapter, + syncExternalAdapters, unregisterUIAdapter, } from "./registry"; import { processUIAdapter } from "./process"; @@ -21,10 +22,12 @@ const externalUIAdapter: UIAdapterModule = { describe("ui adapter registry", () => { beforeEach(() => { unregisterUIAdapter("external_test"); + syncExternalAdapters([]); }); afterEach(() => { unregisterUIAdapter("external_test"); + syncExternalAdapters([]); }); it("registers adapters for lookup and listing", () => { @@ -48,4 +51,30 @@ describe("ui adapter registry", () => { // But it uses the schema-based config fields for external adapter forms. expect(fallback.ConfigFields).toBe(SchemaConfigFields); }); + + it("restores built-in Hermes adapters when external overrides are paused or removed", () => { + for (const type of ["hermes_local", "hermes_gateway"]) { + const builtin = getUIAdapter(type); + + syncExternalAdapters([{ type, label: "External Hermes" }]); + + const overridden = getUIAdapter(type); + expect(overridden).not.toBe(builtin); + expect(overridden.type).toBe(type); + expect(overridden.label).toBe("External Hermes"); + expect(overridden.ConfigFields).toBe(builtin.ConfigFields); + expect(overridden.buildAdapterConfig).toBe(builtin.buildAdapterConfig); + + syncExternalAdapters([{ type, label: "External Hermes", overrideDisabled: true }]); + + expect(getUIAdapter(type)).toBe(builtin); + + syncExternalAdapters([{ type, label: "External Hermes" }]); + expect(getUIAdapter(type)).not.toBe(builtin); + + syncExternalAdapters([]); + + expect(getUIAdapter(type)).toBe(builtin); + } + }); }); diff --git a/ui/src/adapters/registry.ts b/ui/src/adapters/registry.ts index 9b2476d3c8..d698e86b31 100644 --- a/ui/src/adapters/registry.ts +++ b/ui/src/adapters/registry.ts @@ -6,10 +6,11 @@ import { cursorCloudUIAdapter } from "./cursor-cloud"; import { cursorLocalUIAdapter } from "./cursor"; import { geminiLocalUIAdapter } from "./gemini-local"; import { grokLocalUIAdapter } from "./grok-local"; +import { hermesGatewayUIAdapter } from "./hermes-gateway"; +import { hermesLocalUIAdapter } from "./hermes-local"; import { openCodeLocalUIAdapter } from "./opencode-local"; import { piLocalUIAdapter } from "./pi-local"; import { openClawGatewayUIAdapter } from "./openclaw-gateway"; -import { hermesLocalUIAdapter } from "./hermes-local"; import { processUIAdapter } from "./process"; import { httpUIAdapter } from "./http"; import { loadDynamicParser, invalidateDynamicParser, setDynamicParserResultNotifier } from "./dynamic-loader"; @@ -58,6 +59,7 @@ function registerBuiltInUIAdapters() { cursorCloudUIAdapter, geminiLocalUIAdapter, grokLocalUIAdapter, + hermesGatewayUIAdapter, hermesLocalUIAdapter, openCodeLocalUIAdapter, piLocalUIAdapter, diff --git a/ui/src/adapters/use-adapter-capabilities.ts b/ui/src/adapters/use-adapter-capabilities.ts index 89f2c2b6c4..14e466e67a 100644 --- a/ui/src/adapters/use-adapter-capabilities.ts +++ b/ui/src/adapters/use-adapter-capabilities.ts @@ -24,7 +24,6 @@ const KNOWN_DEFAULTS: Record<string, AdapterCapabilities> = { grok_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: false }, opencode_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true }, pi_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: false }, - hermes_local: { supportsInstructionsBundle: false, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: false }, openclaw_gateway: ALL_FALSE, }; diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index fc29bac98e..45e134c3cd 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -15,6 +15,7 @@ import type { Approval, AgentConfigRevision, ClearAgentErrorResponse, + AgentApiKeyScope, } from "@paperclipai/shared"; import type { AdapterModelProfileDefinition, @@ -26,6 +27,7 @@ import { ApiError, api } from "./client"; export interface AgentKey { id: string; name: string; + scope: AgentApiKeyScope; createdAt: Date; revokedAt: Date | null; } @@ -178,8 +180,8 @@ export const agentsApi = { api.get<AgentSkillSnapshot>(agentPath(id, companyId, "/skills")), syncSkills: (id: string, desiredSkills: Array<string | AgentDesiredSkillEntry>, companyId?: string) => api.post<AgentSkillSnapshot>(agentPath(id, companyId, "/skills/sync"), { desiredSkills }), - createKey: (id: string, name: string, companyId?: string) => - api.post<AgentKeyCreated>(agentPath(id, companyId, "/keys"), { name }), + createKey: (id: string, name: string, companyId?: string, scope?: AgentApiKeyScope) => + api.post<AgentKeyCreated>(agentPath(id, companyId, "/keys"), { name, ...(scope ? { scope } : {}) }), revokeKey: (agentId: string, keyId: string, companyId?: string) => api.delete<{ ok: true }>(agentPath(agentId, companyId, `/keys/${encodeURIComponent(keyId)}`)), runtimeState: (id: string, companyId?: string) => diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 06f12521e9..1dafb6a6dc 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -436,8 +436,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const [refreshModelsError, setRefreshModelsError] = useState<string | null>(null); const [refreshingModels, setRefreshingModels] = useState(false); const rawModels = fetchedModels ?? externalModels ?? []; - const adapterCommandField = - adapterType === "hermes_local" ? "hermesCommand" : "command"; + const adapterCommandField = "command"; const acpxAgent = adapterType === "acpx_local" ? isCreate @@ -529,17 +528,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) { if (adapterConfigPatch) { Object.assign(next, adapterConfigPatch); } - if (adapterType === "hermes_local") { - const hermesCommand = - typeof next.hermesCommand === "string" && next.hermesCommand.length > 0 - ? next.hermesCommand - : typeof next.command === "string" && next.command.length > 0 - ? next.command - : undefined; - if (hermesCommand) { - next.hermesCommand = hermesCommand; - } - } return next; } @@ -1201,9 +1189,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { "adapterConfig", adapterCommandField, String( - (adapterType === "hermes_local" - ? config.hermesCommand ?? config.command - : config.command) ?? "", + config.command ?? "", ), ) } diff --git a/ui/src/components/HermesIcon.tsx b/ui/src/components/HermesIcon.tsx deleted file mode 100644 index fb02623a22..0000000000 --- a/ui/src/components/HermesIcon.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { cn } from "../lib/utils"; - -interface HermesIconProps { - className?: string; -} - -/** - * Hermes caduceus icon — winged staff with two intertwined serpents. - * Replaces the generic Zap icon for the hermes_local adapter type. - * - * ⚕️ inspired but as the proper caduceus (Hermes' symbol): staff + two snakes + wings. - */ -export function HermesIcon({ className }: HermesIconProps) { - return ( - <svg - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - strokeWidth="1.5" - strokeLinecap="round" - strokeLinejoin="round" - className={cn(className)} - > - {/* Central staff */} - <line x1="12" y1="6" x2="12" y2="23" /> - {/* Left serpent curves */} - <path d="M12 8 C10 9 9.5 11 10.5 13 C11.5 15 10 17 12 18" /> - {/* Right serpent curves */} - <path d="M12 8 C14 9 14.5 11 13.5 13 C12.5 15 14 17 12 18" /> - {/* Snake heads facing outward */} - <circle cx="10" cy="8" r="0.8" fill="currentColor" stroke="none" /> - <circle cx="14" cy="8" r="0.8" fill="currentColor" stroke="none" /> - {/* Wings at top of staff */} - <path d="M12 6 L8 3 L6 5 L9 6" strokeWidth="1.2" /> - <path d="M12 6 L16 3 L18 5 L15 6" strokeWidth="1.2" /> - {/* Wing feather details */} - <line x1="7.5" y1="4" x2="7" y2="5.2" strokeWidth="1" /> - <line x1="16.5" y1="4" x2="17" y2="5.2" strokeWidth="1" /> - {/* Staff sphere at top */} - <circle cx="12" cy="6.5" r="1.2" /> - </svg> - ); -} diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index f7c6eaac08..b68a7de9bb 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -271,7 +271,6 @@ export function OnboardingWizard() { adapterType === "claude_local" || adapterType === "codex_local" || adapterType === "gemini_local" || - adapterType === "hermes_local" || adapterType === "opencode_local" || adapterType === "pi_local" || adapterType === "cursor"; @@ -298,7 +297,6 @@ export function OnboardingWizard() { claude_local: "claude", codex_local: "codex", gemini_local: "gemini", - hermes_local: "hermes", pi_local: "pi", cursor: "agent", opencode_local: "opencode", diff --git a/ui/src/lib/agent-onboarding-prompt.test.ts b/ui/src/lib/agent-onboarding-prompt.test.ts new file mode 100644 index 0000000000..1166fce2ad --- /dev/null +++ b/ui/src/lib/agent-onboarding-prompt.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { buildAgentOnboardingPrompt } from "./agent-onboarding-prompt"; + +describe("buildAgentOnboardingPrompt", () => { + it("includes Hermes Gateway setup guidance for external agents", () => { + const prompt = buildAgentOnboardingPrompt({ + onboardingTextUrl: "http://localhost:3100/api/invites/token-123/onboarding.txt", + connectionCandidates: ["http://192.168.1.10:3100"], + }); + + expect(prompt).toContain('adapterType: "hermes_gateway"'); + expect(prompt).toContain("API_SERVER_ENABLED=true"); + expect(prompt).toContain("API_SERVER_KEY"); + expect(prompt).toContain("hermes gateway run --replace --accept-hooks"); + expect(prompt).toContain("default Hermes API server port is `8642`"); + expect(prompt).toContain("agentDefaultsPayload.apiBaseUrl"); + expect(prompt).toContain("agentDefaultsPayload.paperclipApiUrl"); + expect(prompt).toContain("http://127.0.0.1:8642"); + expect(prompt).toContain("http://<private-ip>:8642"); + expect(prompt).toContain("http://<tailnet-host>:8642"); + expect(prompt).toContain("http://host.docker.internal:8642"); + expect(prompt).toContain("https://hermes-gateway.example"); + expect(prompt).toContain("`hermes_local` runs Hermes on the Paperclip host"); + expect(prompt).toContain("Hermes-originated Paperclip API calls"); + }); +}); diff --git a/ui/src/lib/agent-onboarding-prompt.ts b/ui/src/lib/agent-onboarding-prompt.ts index bac83f4600..3da1b65967 100644 --- a/ui/src/lib/agent-onboarding-prompt.ts +++ b/ui/src/lib/agent-onboarding-prompt.ts @@ -55,6 +55,13 @@ Join flow: OpenClaw Gateway note: If you are an OpenClaw Gateway agent, use \`adapterType: "openclaw_gateway"\`, set \`agentDefaultsPayload.url\` to your \`ws://\` or \`wss://\` gateway URL, and include \`agentDefaultsPayload.headers["x-openclaw-token"]\` with your gateway token. Do not use \`/v1/responses\` or \`/hooks/*\` in that join flow. +Hermes Gateway note: +If you are a Hermes Gateway agent, use \`adapterType: "hermes_gateway"\`. +- Start Hermes with \`API_SERVER_ENABLED=true\` and \`API_SERVER_KEY=<random-gateway-key>\`, then run \`hermes gateway run --replace --accept-hooks\`. The default Hermes API server port is \`8642\`. +- Set \`agentDefaultsPayload.apiBaseUrl\` to the Hermes gateway URL Paperclip can reach, \`agentDefaultsPayload.apiKey\` to the same value as \`API_SERVER_KEY\`, and \`agentDefaultsPayload.paperclipApiUrl\` to the Paperclip base URL Hermes can reach. +- \`apiBaseUrl\` examples: local loopback \`http://127.0.0.1:8642\`; LAN/private network \`http://<private-ip>:8642\`; private overlay \`http://<tailnet-host>:8642\`; Docker \`http://host.docker.internal:8642\`; reverse proxy/TLS \`https://hermes-gateway.example\`. +- Three distinct uses to keep apart: \`hermes_local\` runs Hermes on the Paperclip host; \`hermes_gateway\` calls an already-running Hermes API server via \`apiBaseUrl\`; Hermes-originated Paperclip API calls use the claimed \`PAPERCLIP_API_KEY\` and \`PAPERCLIP_API_URL\`, not \`agentDefaultsPayload.apiBaseUrl\`. + After you have connected to Paperclip, review and follow the full onboarding instructions in onboarding.txt. `; }