[codex] Add built-in Hermes adapters (#8543)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agent adapters are the boundary between the control plane and the
runtimes that actually do work.
> - Hermes support needs to be available as first-class local and
gateway adapters while still preserving the adapter-manager override
path for external packages.
> - The adapter work touches runtime execution, UI adapter metadata,
onboarding prompts, scoped credentials, release packaging, and smoke
coverage, so the handoff needs concrete verification rather than only
unit tests.
> - This pull request adds built-in Hermes local and Hermes gateway
support, keeps external adapter overrides compatible, and
documents/tests the gateway flow end to end.
> - The benefit is that operators can hire Hermes-backed agents without
a manual plugin install, while self-hosted installs can still
override/shadow the built-ins through Adapter manager packages.

## Linked Issues or Issue Description

No public GitHub issue exists for this exact Hermes built-in adapter,
gateway onboarding, and release-source work.

Problem description:
- Hermes local and gateway adapters need a public, reviewable source
path in the monorepo so package artifacts and built-in adapter behavior
match the application source.
- Operators need built-in `hermes_local` and `hermes_gateway` adapter
choices without losing the ability to install external Hermes packages
as overrides.
- Gateway onboarding needs secure defaults for API server URLs, API
keys, and generated agent setup text.
- Hermes-originated task bridge credentials need narrower API-key scope
configuration.
- Related public PRs found during duplicate search include #3027, #2363,
#7544, #7950, #8095, and #8543.

## What Changed

- Added the unified Hermes adapter package with local and gateway
server/UI/CLI exports, config schemas, transcript parsing, model
detection, and package metadata.
- Registered `hermes_local` and `hermes_gateway` as built-in adapters
across shared constants, server registries, CLI packaging, and UI
adapter registries.
- Kept the external adapter override path compatible so installed Hermes
packages can shadow built-ins and restore the built-in parser when
disabled.
- Added Hermes gateway onboarding docs, board-operator docs, Docker
smoke assets, and shell smoke harnesses for join/e2e validation.
- Added scoped task-bridge API-key support, authorization checks,
issue-origin handling, and tests for Hermes-created Paperclip tasks.
- Hardened gateway transport and redaction behavior for API keys,
headers, session data, and smoke diagnostics.
- Updated release packaging/bootstrap checks for the Hermes packages
while leaving `pnpm-lock.yaml` out of the PR per repository policy.

## Verification

Targeted local verification recorded before PR handoff:
- `pnpm --filter @paperclipai/hermes-paperclip-adapter exec vitest run
src/gateway/server/execute.test.ts` — 14/14 passed.
- `pnpm test:hermes-gateway-smoke` — 6/6 passed.
- Hermes package typecheck/build checks passed.
- Focused server/UI adapter tests passed — 31/31.
- Release helper Node tests passed — 18/18.
- `git diff --check origin/master..HEAD` passed.

Fresh Docker E2E smoke evidence:
- Ran `pnpm smoke:hermes-gateway-e2e` on 2026-06-26 with a fresh state
directory and fresh Docker container against a live Paperclip dev
server.
- Hermes direct execution reached `completed`.
- Hermes stop/cancel path reached `cancelled`.
- Hermes gateway created a Paperclip task, Paperclip ran the Hermes
agent, and the task reached `done` with the expected marker response.
- Temporary board auth keys, token files, smoke state, and Docker
containers were cleaned up after the run.

PR checks on head `b5eae40ce`:
- GitHub Actions passed: `policy`, `review`, `Typecheck + Release
Registry`, all general test shards, all serialized server shards,
`Build`, `Canary Dry Run`, `e2e`, and aggregate `verify`.
- External checks passed: Snyk and Socket Project Report.
- External Socket Pull Request Alerts remained pending after the
first-party CI matrix completed.

## Risks

- Medium risk: this spans adapter registration, package publishing,
gateway execution, onboarding docs, API-key scoping, and UI adapter
metadata.
- Migration risk is low: the scope-config migration adds a nullable
column and does not rewrite existing keys.
- Gateway execution depends on operator-provided Hermes API
configuration; the smoke covers the Docker gateway path but real
deployments may differ by network/auth setup.
- Direct Greptile review on the latest expanded diff is file-count
limited, although the commitperclip review gate passed.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex, GPT-5 coding agent, tool use enabled in a local repository
workspace. Context window size is not exposed in this environment.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Commitperclip review gate is green; direct Greptile review is
file-count limited on the latest expanded diff
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-06-26 16:04:58 -05:00 committed by GitHub
parent ef1422c23e
commit fd2f82ac5b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
131 changed files with 10179 additions and 686 deletions

View File

@ -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.

View File

@ -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/

View File

@ -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",
];

View File

@ -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",

View File

@ -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,

View File

@ -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<string, CLIAdapterModule>(
cursorCloudCLIAdapter,
geminiLocalCLIAdapter,
grokLocalCLIAdapter,
hermesGatewayCLIAdapter,
hermesLocalCLIAdapter,
openclawGatewayCLIAdapter,
processCLIAdapter,
httpCLIAdapter,

View File

@ -688,6 +688,13 @@ pnpm paperclipai llm agent-configuration:adapter <adapter-type>
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

View File

@ -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:

View File

@ -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='<provider-key>'
export API_SERVER_KEY='<random-gateway-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 <company-id> --payload-json '{"requestType":"agent"}'
pnpm paperclipai invite show <token>
pnpm paperclipai invite onboarding:text <token>
```
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": "<same-value-as-API_SERVER_KEY>",
"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 <company-id> --status pending_approval
pnpm paperclipai join approve <request-id> --company-id <company-id>
```
3. Hermes claims the one-time agent API key:
```sh
pnpm paperclipai join claim-key <request-id> --claim-secret <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 <board-token>' \
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='<gateway-key>' hermes gateway run --replace --accept-hooks
PAPERCLIP_API_URL=http://127.0.0.1:3100 \
PAPERCLIP_AUTH_HEADER='Bearer <board-token>' \
HERMES_GATEWAY_API_BASE_URL=http://127.0.0.1:8642 \
HERMES_GATEWAY_API_KEY='<gateway-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.

168
doc/HERMES_GATEWAY_SMOKE.md Normal file
View File

@ -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=<prefix>` 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 <board-token>' \
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 <board-token>' \
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 <board-token>' \
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 <board-token>' \
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='<gateway-key>' hermes gateway run --replace --accept-hooks
PAPERCLIP_API_URL=http://127.0.0.1:3100 \
PAPERCLIP_AUTH_HEADER='Bearer <board-token>' \
HERMES_GATEWAY_API_BASE_URL=http://127.0.0.1:8642 \
HERMES_GATEWAY_API_KEY='<gateway-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.

View File

@ -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 <code>` is provided
- runs `pnpm publish <package-dir> --dry-run --no-git-checks --access public` from the repo root
- only runs the real `pnpm publish <package-dir> --no-git-checks --access public` when `--publish --otp <code>` 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.

View File

@ -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

View File

@ -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"]

View File

@ -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 "$@"

View File

@ -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

View File

@ -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

View File

@ -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).

View File

@ -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.

View File

@ -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",

View File

@ -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.

View File

@ -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"
}
}

View File

@ -0,0 +1 @@
export { formatStdoutEvent } from "@paperclipai/hermes-paperclip-adapter/gateway/cli";

View File

@ -0,0 +1,7 @@
export {
agentConfigurationDoc,
createServerAdapter,
label,
models,
type,
} from "@paperclipai/hermes-paperclip-adapter/gateway";

View File

@ -0,0 +1,9 @@
export {
execute,
getConfigSchema,
mapFinalResultForTest,
parseSseFramesForTest,
resolveSessionKey,
sessionCodec,
testEnvironment,
} from "@paperclipai/hermes-paperclip-adapter/gateway/server";

View File

@ -0,0 +1 @@
export { parseStdoutLine } from "@paperclipai/hermes-paperclip-adapter/gateway/ui";

View File

@ -0,0 +1,9 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src"]
}

View File

@ -0,0 +1 @@
module.exports = require("@paperclipai/hermes-paperclip-adapter/gateway/ui-parser");

View File

@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
exclude: ["dist/**", "node_modules/**"],
},
});

View File

@ -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.

View File

@ -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=<generated-secret> \
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": "<same-value-as-API_SERVER_KEY>",
"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=<task-bridge-scoped-agent-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 "<approved-parent-issue-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

View File

@ -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 };

View File

@ -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"
}
}

View File

@ -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`);

View File

@ -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);
}

View File

@ -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 <uuid>` 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.

View File

@ -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 <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;
});

View File

@ -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));
}

View File

@ -0,0 +1,5 @@
/**
* CLI module exports used by Paperclip's CLI for terminal formatting.
*/
export { printHermesStreamEvent } from "./format-event.js";

View File

@ -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);
}

View File

@ -0,0 +1 @@
export { printHermesGatewayStreamEvent as formatStdoutEvent } from "./format-event.js";

View File

@ -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,
};
}

View File

@ -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.",
},
],
};
}

View File

@ -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");
});
});

View File

@ -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,
});
}

View File

@ -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);
},
};

View File

@ -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(),
};
}

View File

@ -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.`
);
}

View File

@ -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;

View File

@ -0,0 +1 @@
export { parseHermesGatewayStdoutLine as parseStdoutLine } from "./parse-stdout.js";

View File

@ -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 }];
}

View File

@ -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);
});

View File

@ -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 };

View File

@ -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 });
}
});

View File

@ -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.",
},
],
};
}

View File

@ -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");
});
});

View File

@ -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" };
}

View File

@ -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;
}

View File

@ -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);
},
};

View File

@ -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);
});
});

View File

@ -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:");
});

View File

@ -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);
}

View File

@ -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(),
};
}

View File

@ -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 = "💭";

View File

@ -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;
}

View File

@ -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";

View File

@ -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 }];
}

View File

@ -0,0 +1,9 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src"]
}

View File

@ -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 };

View File

@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
exclude: ["dist/**", "node_modules/**"],
},
});

View File

@ -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;

View File

@ -0,0 +1 @@
ALTER TABLE "agent_api_keys" ADD COLUMN IF NOT EXISTS "scope_config" jsonb;

View File

@ -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
}
]
}

View File

@ -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(),

View File

@ -35,6 +35,8 @@ export const AGENT_ADAPTER_TYPES = [
"codex_local",
"cursor_cloud",
"gemini_local",
"hermes_gateway",
"hermes_local",
"opencode_local",
"pi_local",
"cursor",

View File

@ -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,

View File

@ -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;
}

View File

@ -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>;

View File

@ -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,

View File

@ -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,

View File

@ -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",
]);
});

View File

@ -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",
];

View File

@ -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",

View File

@ -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());
});

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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");
});

View File

@ -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",

View File

@ -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", () => {

View File

@ -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();

View File

@ -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,

View File

@ -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);

View File

@ -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",
});
});
});

View File

@ -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";

View File

@ -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",

View File

@ -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(),
}));

View File

@ -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(),
}));

View File

@ -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);
});
});

View File

@ -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.");

View File

@ -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") {

View File

@ -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");
});
});

Some files were not shown because too many files have changed in this diff Show More