[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:
parent
ef1422c23e
commit
fd2f82ac5b
19
AGENTS.md
19
AGENTS.md
|
|
@ -181,18 +181,19 @@ A change is done when all are true:
|
|||
|
||||
## 11. Fork-Specific: HenkDz/paperclip
|
||||
|
||||
This is a fork of `paperclipai/paperclip` with QoL patches and an **external-only** Hermes adapter story on branch `feat/externalize-hermes-adapter` ([tree](https://github.com/HenkDz/paperclip/tree/feat/externalize-hermes-adapter)).
|
||||
This is a fork of `paperclipai/paperclip` with QoL patches and a **built-in** Hermes adapter story on branch `feat/externalize-hermes-adapter` ([tree](https://github.com/HenkDz/paperclip/tree/feat/externalize-hermes-adapter)).
|
||||
|
||||
### Branch Strategy
|
||||
|
||||
- `feat/externalize-hermes-adapter` → core has **no** `hermes-paperclip-adapter` dependency and **no** built-in `hermes_local` registration. Install Hermes via the Adapter Plugin manager (`@henkey/hermes-paperclip-adapter` or a `file:` path).
|
||||
- Older fork branches may still document built-in Hermes; treat this file as authoritative for the externalize branch.
|
||||
- `feat/externalize-hermes-adapter` now ships `hermes_local` and `hermes_gateway` as built-in core adapters.
|
||||
- Older fork branches may still document plugin-only Hermes; treat this file as authoritative for the current branch.
|
||||
|
||||
### Hermes (plugin only)
|
||||
### Hermes (built-in)
|
||||
|
||||
- Register through **Board → Adapter manager** (same as Droid). Type remains `hermes_local` once the package is loaded.
|
||||
- UI uses generic **config-schema** + **ui-parser.js** from the package — no Hermes imports in `server/` or `ui/` source.
|
||||
- Optional: `file:` entry in `~/.paperclip/adapter-plugins.json` for local dev of the adapter repo.
|
||||
- `hermes_local` is available without Adapter manager installation and runs the local Hermes CLI.
|
||||
- `hermes_gateway` is available without Adapter manager installation and calls an already-running Hermes API server.
|
||||
- Operators may still install external Hermes packages through Adapter manager to override/shadow the built-ins.
|
||||
- Optional: `file:` entry in `~/.paperclip/adapter-plugins.json` remains useful for local development of override packages.
|
||||
|
||||
### Local Dev
|
||||
|
||||
|
|
@ -217,5 +218,5 @@ PR #2218 (`feat/external-adapter-phase1`) adds external adapter support. See roo
|
|||
- Adapters can be loaded as external plugins via `~/.paperclip/adapter-plugins.json`
|
||||
- The plugin-loader should have ZERO hardcoded adapter imports — pure dynamic loading
|
||||
- `createServerAdapter()` must include ALL optional fields (especially `detectModel`)
|
||||
- Built-in UI adapters can shadow external plugin parsers — remove built-in when fully externalizing
|
||||
- Reference external adapters: Hermes (`@henkey/hermes-paperclip-adapter` or `file:`) and Droid (npm)
|
||||
- Built-in UI adapters can shadow external plugin parsers; external override pause/resume should restore the built-in parser.
|
||||
- Reference external adapters: Droid (npm); Hermes can also be tested as an override package.
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -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 "$@"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { formatStdoutEvent } from "@paperclipai/hermes-paperclip-adapter/gateway/cli";
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
export {
|
||||
agentConfigurationDoc,
|
||||
createServerAdapter,
|
||||
label,
|
||||
models,
|
||||
type,
|
||||
} from "@paperclipai/hermes-paperclip-adapter/gateway";
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
export {
|
||||
execute,
|
||||
getConfigSchema,
|
||||
mapFinalResultForTest,
|
||||
parseSseFramesForTest,
|
||||
resolveSessionKey,
|
||||
sessionCodec,
|
||||
testEnvironment,
|
||||
} from "@paperclipai/hermes-paperclip-adapter/gateway/server";
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { parseStdoutLine } from "@paperclipai/hermes-paperclip-adapter/gateway/ui";
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
module.exports = require("@paperclipai/hermes-paperclip-adapter/gateway/ui-parser");
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
},
|
||||
});
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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`);
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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;
|
||||
});
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
/**
|
||||
* CLI module exports — used by Paperclip's CLI for terminal formatting.
|
||||
*/
|
||||
|
||||
export { printHermesStreamEvent } from "./format-event.js";
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { printHermesGatewayStreamEvent as formatStdoutEvent } from "./format-event.js";
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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.",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
|
|
@ -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.`
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { parseHermesGatewayStdoutLine as parseStdoutLine } from "./parse-stdout.js";
|
||||
|
|
@ -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 }];
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
|
|
@ -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.",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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" };
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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:");
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
|
|
@ -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 = "💭";
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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 }];
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
},
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "agent_api_keys" ADD COLUMN IF NOT EXISTS "scope_config" jsonb;
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ export const AGENT_ADAPTER_TYPES = [
|
|||
"codex_local",
|
||||
"cursor_cloud",
|
||||
"gemini_local",
|
||||
"hermes_gateway",
|
||||
"hermes_local",
|
||||
"opencode_local",
|
||||
"pi_local",
|
||||
"cursor",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
import { buildReleasePackagePlan } from "./release-package-map.mjs";
|
||||
|
||||
|
|
@ -187,13 +187,24 @@ function printNextSteps(pkg) {
|
|||
);
|
||||
}
|
||||
|
||||
function publishPackage(pkg, otp) {
|
||||
const publishArgs = ["publish", "--access", "public"];
|
||||
if (otp) {
|
||||
publishArgs.push("--otp", otp);
|
||||
function buildPublishArgs(pkg, { dryRun = false, otp = null } = {}) {
|
||||
const args = ["publish", pkg.dir, "--no-git-checks", "--access", "public"];
|
||||
|
||||
if (dryRun) {
|
||||
args.push("--dry-run");
|
||||
}
|
||||
|
||||
const result = runCommand("npm", publishArgs, { cwd: join(repoRoot, pkg.dir) });
|
||||
if (otp) {
|
||||
args.push("--otp", otp);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
function publishPackage(pkg, otp) {
|
||||
const publishArgs = buildPublishArgs(pkg, { otp });
|
||||
|
||||
const result = runCommand("pnpm", publishArgs);
|
||||
const stdout = result.stdout ?? "";
|
||||
const stderr = result.stderr ?? "";
|
||||
const output = `${stdout}\n${stderr}`.trim();
|
||||
|
|
@ -214,7 +225,7 @@ function publishPackage(pkg, otp) {
|
|||
);
|
||||
}
|
||||
|
||||
throw new Error(`${formatCommand("npm", publishArgs)} failed with status ${result.status ?? "unknown"}`);
|
||||
throw new Error(`${formatCommand("pnpm", publishArgs)} failed with status ${result.status ?? "unknown"}`);
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
|
|
@ -255,7 +266,7 @@ function main(argv) {
|
|||
}
|
||||
|
||||
process.stdout.write(`Previewing publish payload for ${pkg.name}...\n`);
|
||||
runChecked("npm", ["pack", "--dry-run"], { cwd: join(repoRoot, pkg.dir) });
|
||||
runChecked("pnpm", buildPublishArgs(pkg, { dryRun: true }));
|
||||
|
||||
if (!publish) {
|
||||
process.stdout.write(
|
||||
|
|
@ -286,6 +297,7 @@ if (isDirectRun) {
|
|||
}
|
||||
|
||||
export {
|
||||
buildPublishArgs,
|
||||
ensureNpmAuth,
|
||||
inspectNpmPackage,
|
||||
parseArgs,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { parseArgs, resolveTargetPackage } from "./bootstrap-npm-package.mjs";
|
||||
import { buildPublishArgs, parseArgs, resolveTargetPackage } from "./bootstrap-npm-package.mjs";
|
||||
|
||||
test("parseArgs recognizes publish and skip-build flags", () => {
|
||||
assert.deepEqual(parseArgs(["@paperclipai/adapter-acpx-local", "--publish", "--skip-build"]), {
|
||||
|
|
@ -58,3 +58,30 @@ test("resolveTargetPackage includes the workspace diff plugin bootstrap package"
|
|||
|
||||
assert.equal(pkg.dir, "packages/plugins/plugin-workspace-diff");
|
||||
});
|
||||
|
||||
test("buildPublishArgs publishes from the repo root through pnpm", () => {
|
||||
const pkg = { dir: "packages/adapters/hermes", name: "@paperclipai/hermes-paperclip-adapter" };
|
||||
|
||||
assert.deepEqual(buildPublishArgs(pkg), [
|
||||
"publish",
|
||||
"packages/adapters/hermes",
|
||||
"--no-git-checks",
|
||||
"--access",
|
||||
"public",
|
||||
]);
|
||||
});
|
||||
|
||||
test("buildPublishArgs includes dry-run and otp flags when requested", () => {
|
||||
const pkg = { dir: "packages/adapters/hermes", name: "@paperclipai/hermes-paperclip-adapter" };
|
||||
|
||||
assert.deepEqual(buildPublishArgs(pkg, { dryRun: true, otp: "123456" }), [
|
||||
"publish",
|
||||
"packages/adapters/hermes",
|
||||
"--no-git-checks",
|
||||
"--access",
|
||||
"public",
|
||||
"--dry-run",
|
||||
"--otp",
|
||||
"123456",
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ const workspacePaths = [
|
|||
"packages/adapter-utils",
|
||||
"packages/adapters/claude-local",
|
||||
"packages/adapters/codex-local",
|
||||
"packages/adapters/hermes-gateway",
|
||||
"packages/adapters/hermes",
|
||||
"packages/adapters/opencode-local",
|
||||
"packages/adapters/openclaw-gateway",
|
||||
];
|
||||
|
|
|
|||
|
|
@ -39,6 +39,16 @@
|
|||
"name": "@paperclipai/adapter-grok-local",
|
||||
"publishFromCi": true
|
||||
},
|
||||
{
|
||||
"dir": "packages/adapters/hermes",
|
||||
"name": "@paperclipai/hermes-paperclip-adapter",
|
||||
"publishFromCi": true
|
||||
},
|
||||
{
|
||||
"dir": "packages/adapters/hermes-gateway",
|
||||
"name": "@paperclipai/adapter-hermes-gateway",
|
||||
"publishFromCi": false
|
||||
},
|
||||
{
|
||||
"dir": "packages/adapters/opencode-local",
|
||||
"name": "@paperclipai/adapter-opencode-local",
|
||||
|
|
|
|||
|
|
@ -24,6 +24,17 @@ test("release package list only contains CI-enrolled packages", () => {
|
|||
assert.ok(enabledPackages.every((pkg) => pkg.publishFromCi === true));
|
||||
});
|
||||
|
||||
test("Hermes release surface publishes the unified built-in package and keeps gateway as a shim", () => {
|
||||
const packages = buildReleasePackagePlan();
|
||||
const hermes = packages.find((pkg) => pkg.name === "@paperclipai/hermes-paperclip-adapter");
|
||||
const gatewayShim = packages.find((pkg) => pkg.name === "@paperclipai/adapter-hermes-gateway");
|
||||
|
||||
assert.equal(hermes?.dir, "packages/adapters/hermes");
|
||||
assert.equal(hermes?.publishFromCi, true);
|
||||
assert.equal(gatewayShim?.dir, "packages/adapters/hermes-gateway");
|
||||
assert.equal(gatewayShim?.publishFromCi, false);
|
||||
});
|
||||
|
||||
test("release package configuration validates successfully", () => {
|
||||
assert.doesNotThrow(() => checkConfiguration());
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,450 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
log() {
|
||||
echo "[hermes-gateway-join] $*"
|
||||
}
|
||||
|
||||
warn() {
|
||||
echo "[hermes-gateway-join] WARN: $*" >&2
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "[hermes-gateway-join] ERROR: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
require_cmd() {
|
||||
local cmd="$1"
|
||||
command -v "$cmd" >/dev/null 2>&1 || fail "missing required command: ${cmd}"
|
||||
}
|
||||
|
||||
require_cmd curl
|
||||
require_cmd jq
|
||||
|
||||
PAPERCLIP_API_URL="${PAPERCLIP_API_URL:-http://localhost:3100}"
|
||||
API_BASE="${PAPERCLIP_API_URL%/}/api"
|
||||
COMPANY_ID="${COMPANY_ID:-${PAPERCLIP_COMPANY_ID:-}}"
|
||||
COMPANY_SELECTOR="${COMPANY_SELECTOR:-}"
|
||||
|
||||
HERMES_AGENT_NAME="${HERMES_AGENT_NAME:-Hermes Gateway Smoke Agent}"
|
||||
HERMES_GATEWAY_API_BASE_URL="${HERMES_GATEWAY_API_BASE_URL:-http://127.0.0.1:${HERMES_GATEWAY_PORT:-8642}}"
|
||||
HERMES_GATEWAY_PROBE_URL="${HERMES_GATEWAY_PROBE_URL:-$HERMES_GATEWAY_API_BASE_URL}"
|
||||
HERMES_GATEWAY_API_KEY="${HERMES_GATEWAY_API_KEY:-${API_SERVER_KEY:-}}"
|
||||
HERMES_GATEWAY_ALLOW_INSECURE_HTTP="${HERMES_GATEWAY_ALLOW_INSECURE_HTTP:-0}"
|
||||
HERMES_GATEWAY_SESSION_KEY_STRATEGY="${HERMES_GATEWAY_SESSION_KEY_STRATEGY:-issue}"
|
||||
HERMES_GATEWAY_TIMEOUT_SEC="${HERMES_GATEWAY_TIMEOUT_SEC:-180}"
|
||||
PAPERCLIP_API_URL_FOR_HERMES="${PAPERCLIP_API_URL_FOR_HERMES:-}"
|
||||
GATEWAY_PROBE_TIMEOUT_SEC="${GATEWAY_PROBE_TIMEOUT_SEC:-4}"
|
||||
HERMES_JOIN_OUTPUT_FILE="${HERMES_JOIN_OUTPUT_FILE:-}"
|
||||
|
||||
print_usage() {
|
||||
cat <<'EOF'
|
||||
Hermes gateway join smoke
|
||||
|
||||
Creates a Hermes gateway agent from an agent-only Paperclip invite, approves the
|
||||
join request, claims the one-time Paperclip API key, and verifies the stored
|
||||
adapter config without printing raw secrets.
|
||||
|
||||
Required:
|
||||
PAPERCLIP_API_URL=http://127.0.0.1:3100
|
||||
PAPERCLIP_AUTH_HEADER='Bearer <board-token>' # or PAPERCLIP_COOKIE
|
||||
HERMES_GATEWAY_API_KEY=<API_SERVER_KEY>
|
||||
|
||||
Common flags:
|
||||
COMPANY_ID=<uuid> or COMPANY_SELECTOR=<prefix|name|uuid>
|
||||
HERMES_GATEWAY_API_BASE_URL=http://127.0.0.1:8642
|
||||
HERMES_GATEWAY_PROBE_URL=http://127.0.0.1:8642
|
||||
PAPERCLIP_API_URL_FOR_HERMES=http://host.docker.internal:3100
|
||||
HERMES_GATEWAY_ALLOW_INSECURE_HTTP=1 # dev-only non-loopback HTTP
|
||||
HERMES_GATEWAY_SESSION_KEY_STRATEGY=issue|agent|run|none
|
||||
HERMES_JOIN_OUTPUT_FILE=/secure/path/join-output.json
|
||||
|
||||
Notes:
|
||||
HERMES_GATEWAY_API_BASE_URL is stored on the Paperclip adapter and must be
|
||||
reachable by the Paperclip server. HERMES_GATEWAY_PROBE_URL is only used by
|
||||
this operator shell to preflight /health, which is useful when Paperclip talks
|
||||
to the gateway over a Docker network name but the operator probes localhost.
|
||||
|
||||
Raw API keys are redacted from logs. HERMES_JOIN_OUTPUT_FILE contains the
|
||||
claimed Paperclip agent API key and is written chmod 600.
|
||||
|
||||
See doc/HERMES_GATEWAY_SMOKE.md for Docker Desktop, Linux, same-network,
|
||||
LAN/private-network, and reverse-proxy/TLS examples.
|
||||
EOF
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
-h|--help)
|
||||
print_usage
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
AUTH_HEADERS=()
|
||||
if [[ -n "${PAPERCLIP_AUTH_HEADER:-}" ]]; then
|
||||
AUTH_HEADERS+=(-H "Authorization: ${PAPERCLIP_AUTH_HEADER}")
|
||||
elif [[ -n "${PAPERCLIP_API_KEY:-}" ]]; then
|
||||
AUTH_HEADERS+=(-H "Authorization: Bearer ${PAPERCLIP_API_KEY}")
|
||||
fi
|
||||
if [[ -n "${PAPERCLIP_COOKIE:-}" ]]; then
|
||||
AUTH_HEADERS+=(-H "Cookie: ${PAPERCLIP_COOKIE}")
|
||||
fi
|
||||
|
||||
RESPONSE_CODE=""
|
||||
RESPONSE_BODY=""
|
||||
CLAIM_SECRET=""
|
||||
AGENT_API_KEY=""
|
||||
|
||||
hash_prefix() {
|
||||
local value="$1"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
printf "%s" "$value" | sha256sum | awk '{print substr($1,1,12)}'
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
printf "%s" "$value" | shasum -a 256 | awk '{print substr($1,1,12)}'
|
||||
else
|
||||
printf "unavailable"
|
||||
fi
|
||||
}
|
||||
|
||||
redact_text() {
|
||||
local text="$1"
|
||||
local secret
|
||||
for secret in "${HERMES_GATEWAY_API_KEY:-}" "${CLAIM_SECRET:-}" "${AGENT_API_KEY:-}" "${PAPERCLIP_AUTH_HEADER:-}" "${PAPERCLIP_COOKIE:-}" "${PAPERCLIP_API_KEY:-}"; do
|
||||
if [[ -n "$secret" ]]; then
|
||||
text="${text//$secret/[redacted len=${#secret}]}"
|
||||
fi
|
||||
done
|
||||
printf "%s" "$text"
|
||||
}
|
||||
|
||||
print_response_error() {
|
||||
redact_text "$RESPONSE_BODY" >&2
|
||||
echo >&2
|
||||
}
|
||||
|
||||
api_request() {
|
||||
local method="$1"
|
||||
local path="$2"
|
||||
local data="${3-}"
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
|
||||
local url
|
||||
if [[ "$path" == http://* || "$path" == https://* ]]; then
|
||||
url="$path"
|
||||
elif [[ "$path" == /api/* ]]; then
|
||||
url="${PAPERCLIP_API_URL%/}${path}"
|
||||
else
|
||||
url="${API_BASE}${path}"
|
||||
fi
|
||||
|
||||
if [[ -n "$data" ]]; then
|
||||
RESPONSE_CODE="$(curl -sS -o "$tmp" -w "%{http_code}" -X "$method" "${AUTH_HEADERS[@]}" -H "Content-Type: application/json" "$url" --data "$data")"
|
||||
else
|
||||
RESPONSE_CODE="$(curl -sS -o "$tmp" -w "%{http_code}" -X "$method" "${AUTH_HEADERS[@]}" "$url")"
|
||||
fi
|
||||
RESPONSE_BODY="$(cat "$tmp")"
|
||||
rm -f "$tmp"
|
||||
}
|
||||
|
||||
assert_status() {
|
||||
local expected="$1"
|
||||
if [[ "$RESPONSE_CODE" != "$expected" ]]; then
|
||||
print_response_error
|
||||
fail "expected HTTP ${expected}, got HTTP ${RESPONSE_CODE}"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_json_has_string() {
|
||||
local jq_expr="$1"
|
||||
local value
|
||||
value="$(jq -r "$jq_expr // empty" <<<"$RESPONSE_BODY")"
|
||||
if [[ -z "$value" ]]; then
|
||||
print_response_error
|
||||
fail "expected JSON string at ${jq_expr}"
|
||||
fi
|
||||
echo "$value"
|
||||
}
|
||||
|
||||
fail_board_auth_required() {
|
||||
local operation="$1"
|
||||
print_response_error
|
||||
cat >&2 <<EOF
|
||||
[hermes-gateway-join] ERROR: ${operation} requires board/operator auth.
|
||||
|
||||
Provide one of:
|
||||
PAPERCLIP_AUTH_HEADER="Bearer <board-token>"
|
||||
PAPERCLIP_COOKIE="<board-session-cookie>"
|
||||
|
||||
Current auth context appears insufficient (HTTP ${RESPONSE_CODE}).
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
is_remote_plain_http() {
|
||||
local url="$1"
|
||||
[[ "$url" == http://* ]] || return 1
|
||||
! is_loopback_http_host "$(url_host "$url")"
|
||||
}
|
||||
|
||||
url_host() {
|
||||
local url="$1"
|
||||
local rest host_port host
|
||||
rest="${url#http://}"
|
||||
rest="${rest#https://}"
|
||||
if [[ "$rest" == \[*\]* ]]; then
|
||||
host="${rest#\[}"
|
||||
host="${host%%\]*}"
|
||||
else
|
||||
host_port="${rest%%/*}"
|
||||
host="${host_port%%:*}"
|
||||
fi
|
||||
printf "%s" "$host"
|
||||
}
|
||||
|
||||
is_loopback_http_host() {
|
||||
local host
|
||||
host="$(printf "%s" "$1" | tr '[:upper:]' '[:lower:]')"
|
||||
case "$host" in
|
||||
localhost|0.0.0.0|::1|0:0:0:0:0:0:0:1) return 0 ;;
|
||||
esac
|
||||
[[ "$host" =~ ^127\.([0-9]{1,3}\.){2}[0-9]{1,3}$ ]]
|
||||
}
|
||||
|
||||
strip_trailing_slash() {
|
||||
local value="$1"
|
||||
while [[ "$value" == */ && "$value" != "http://" && "$value" != "https://" ]]; do
|
||||
value="${value%/}"
|
||||
done
|
||||
printf "%s" "$value"
|
||||
}
|
||||
|
||||
resolve_company_id() {
|
||||
if [[ -n "$COMPANY_ID" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
log "resolving company id"
|
||||
api_request "GET" "/companies"
|
||||
if [[ "$RESPONSE_CODE" == "401" || "$RESPONSE_CODE" == "403" ]]; then
|
||||
fail_board_auth_required "Company resolution"
|
||||
fi
|
||||
assert_status "200"
|
||||
|
||||
if [[ -n "$COMPANY_SELECTOR" ]]; then
|
||||
COMPANY_ID="$(jq -r --arg selector "$COMPANY_SELECTOR" '
|
||||
map(select(
|
||||
(.id == $selector)
|
||||
or ((.issuePrefix // "") == $selector)
|
||||
or ((.name // "") == $selector)
|
||||
)) | .[0].id // empty
|
||||
' <<<"$RESPONSE_BODY")"
|
||||
[[ -n "$COMPANY_ID" ]] || fail "no company matched COMPANY_SELECTOR=${COMPANY_SELECTOR}"
|
||||
else
|
||||
COMPANY_ID="$(jq -r '.[0].id // empty' <<<"$RESPONSE_BODY")"
|
||||
[[ -n "$COMPANY_ID" ]] || fail "no companies found; create one before running smoke test"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_onboarding_contains() {
|
||||
local body="$1"
|
||||
local needle="$2"
|
||||
if ! grep -Fq "$needle" <<<"$body"; then
|
||||
echo "$body" >&2
|
||||
fail "onboarding response missing expected text: ${needle}"
|
||||
fi
|
||||
}
|
||||
|
||||
probe_hermes_gateway() {
|
||||
[[ -n "$HERMES_GATEWAY_API_BASE_URL" ]] || fail "HERMES_GATEWAY_API_BASE_URL is required"
|
||||
[[ -n "$HERMES_GATEWAY_PROBE_URL" ]] || fail "HERMES_GATEWAY_PROBE_URL is required"
|
||||
[[ -n "$HERMES_GATEWAY_API_KEY" ]] || fail "HERMES_GATEWAY_API_KEY or API_SERVER_KEY is required before any Paperclip state is mutated"
|
||||
|
||||
if is_remote_plain_http "$HERMES_GATEWAY_API_BASE_URL" && [[ "$HERMES_GATEWAY_ALLOW_INSECURE_HTTP" != "1" ]]; then
|
||||
fail "HERMES_GATEWAY_API_BASE_URL uses non-loopback http. Set HERMES_GATEWAY_ALLOW_INSECURE_HTTP=1 for local-only unsafe HTTP, or use HTTPS."
|
||||
fi
|
||||
|
||||
local health_url="${HERMES_GATEWAY_PROBE_URL%/}/health"
|
||||
log "probing Hermes gateway health at ${health_url} with apiKey sha256=$(hash_prefix "$HERMES_GATEWAY_API_KEY") len=${#HERMES_GATEWAY_API_KEY}"
|
||||
if [[ "$HERMES_GATEWAY_PROBE_URL" != "$HERMES_GATEWAY_API_BASE_URL" ]]; then
|
||||
log "Paperclip will store Hermes gateway URL ${HERMES_GATEWAY_API_BASE_URL}"
|
||||
fi
|
||||
local code
|
||||
code="$(curl -sS -o /dev/null -w "%{http_code}" --max-time "$GATEWAY_PROBE_TIMEOUT_SEC" -H "Authorization: Bearer ${HERMES_GATEWAY_API_KEY}" "$health_url" || true)"
|
||||
if [[ "$code" != "200" ]]; then
|
||||
fail "Hermes gateway health probe failed before mutating Paperclip state: ${health_url} returned HTTP ${code}. Start Hermes with API_SERVER_ENABLED=true API_SERVER_KEY=<key> hermes gateway run --replace --accept-hooks, or set HERMES_GATEWAY_API_BASE_URL/HERMES_GATEWAY_API_KEY."
|
||||
fi
|
||||
}
|
||||
|
||||
log "checking Paperclip health"
|
||||
api_request "GET" "/health"
|
||||
assert_status "200"
|
||||
log "deployment mode=$(jq -r '.deploymentMode // "unknown"' <<<"$RESPONSE_BODY") exposure=$(jq -r '.deploymentExposure // "unknown"' <<<"$RESPONSE_BODY")"
|
||||
|
||||
resolve_company_id
|
||||
probe_hermes_gateway
|
||||
|
||||
log "creating agent-only invite for company ${COMPANY_ID}"
|
||||
INVITE_PAYLOAD="$(jq -nc '{allowedJoinTypes:"agent"}')"
|
||||
api_request "POST" "/companies/${COMPANY_ID}/invites" "$INVITE_PAYLOAD"
|
||||
if [[ "$RESPONSE_CODE" == "401" || "$RESPONSE_CODE" == "403" ]]; then
|
||||
fail_board_auth_required "Invite creation"
|
||||
fi
|
||||
assert_status "201"
|
||||
INVITE_TOKEN="$(assert_json_has_string '.token')"
|
||||
INVITE_ID="$(assert_json_has_string '.id')"
|
||||
log "created invite ${INVITE_ID}"
|
||||
|
||||
log "verifying onboarding JSON and text endpoints"
|
||||
api_request "GET" "/invites/${INVITE_TOKEN}/onboarding"
|
||||
assert_status "200"
|
||||
ONBOARDING_JSON="$RESPONSE_BODY"
|
||||
ONBOARDING_TEXT_PATH="$(jq -r '.invite.onboardingTextPath // empty' <<<"$ONBOARDING_JSON")"
|
||||
[[ -n "$ONBOARDING_TEXT_PATH" ]] || fail "onboarding manifest missing invite.onboardingTextPath"
|
||||
assert_onboarding_contains "$ONBOARDING_JSON" "hermes_gateway"
|
||||
assert_onboarding_contains "$ONBOARDING_JSON" "API_SERVER_ENABLED=true"
|
||||
assert_onboarding_contains "$ONBOARDING_JSON" "API_SERVER_KEY"
|
||||
assert_onboarding_contains "$ONBOARDING_JSON" "agentDefaultsPayload"
|
||||
|
||||
api_request "GET" "/invites/${INVITE_TOKEN}/onboarding.txt"
|
||||
assert_status "200"
|
||||
ONBOARDING_TEXT="$RESPONSE_BODY"
|
||||
assert_onboarding_contains "$ONBOARDING_TEXT" 'adapterType: "hermes_gateway"'
|
||||
assert_onboarding_contains "$ONBOARDING_TEXT" "API_SERVER_ENABLED=true"
|
||||
assert_onboarding_contains "$ONBOARDING_TEXT" "API_SERVER_KEY"
|
||||
assert_onboarding_contains "$ONBOARDING_TEXT" "hermes gateway run --replace --accept-hooks"
|
||||
assert_onboarding_contains "$ONBOARDING_TEXT" "agentDefaultsPayload.apiBaseUrl"
|
||||
|
||||
JOIN_PAYLOAD="$(jq -nc \
|
||||
--arg name "$HERMES_AGENT_NAME" \
|
||||
--arg apiBaseUrl "$HERMES_GATEWAY_API_BASE_URL" \
|
||||
--arg apiKey "$HERMES_GATEWAY_API_KEY" \
|
||||
--arg paperclipApiUrl "$PAPERCLIP_API_URL_FOR_HERMES" \
|
||||
--arg sessionKeyStrategy "$HERMES_GATEWAY_SESSION_KEY_STRATEGY" \
|
||||
--argjson timeoutSec "$HERMES_GATEWAY_TIMEOUT_SEC" \
|
||||
--argjson allowInsecure "$(if [[ "$HERMES_GATEWAY_ALLOW_INSECURE_HTTP" == "1" ]]; then echo true; else echo false; fi)" \
|
||||
'{
|
||||
requestType: "agent",
|
||||
agentName: $name,
|
||||
adapterType: "hermes_gateway",
|
||||
capabilities: "Hermes gateway Docker smoke harness",
|
||||
agentDefaultsPayload: {
|
||||
apiBaseUrl: $apiBaseUrl,
|
||||
apiKey: $apiKey,
|
||||
sessionKeyStrategy: $sessionKeyStrategy,
|
||||
timeoutSec: $timeoutSec
|
||||
}
|
||||
}
|
||||
| if $paperclipApiUrl != "" then .agentDefaultsPayload.paperclipApiUrl = $paperclipApiUrl else . end
|
||||
| if $allowInsecure then .agentDefaultsPayload.dangerouslyAllowInsecureRemoteHttp = true else . end')"
|
||||
|
||||
log "submitting Hermes gateway agent join request"
|
||||
api_request "POST" "/invites/${INVITE_TOKEN}/accept" "$JOIN_PAYLOAD"
|
||||
if [[ "$RESPONSE_CODE" != "202" ]]; then
|
||||
print_response_error
|
||||
fi
|
||||
assert_status "202"
|
||||
JOIN_REQUEST_ID="$(assert_json_has_string '.id')"
|
||||
CLAIM_SECRET="$(assert_json_has_string '.claimSecret')"
|
||||
CLAIM_API_PATH="$(assert_json_has_string '.claimApiKeyPath')"
|
||||
DIAGNOSTICS_JSON="$(jq -c '.diagnostics // []' <<<"$RESPONSE_BODY")"
|
||||
if [[ "$DIAGNOSTICS_JSON" != "[]" ]]; then
|
||||
log "join diagnostics: $(redact_text "$DIAGNOSTICS_JSON")"
|
||||
fi
|
||||
|
||||
if is_remote_plain_http "$HERMES_GATEWAY_API_BASE_URL"; then
|
||||
if ! jq -e '[.diagnostics[]? | select(.code == "hermes_gateway_plain_http_remote_unsafe_allowed")] | length > 0' <<<"$RESPONSE_BODY" >/dev/null; then
|
||||
fail "expected hermes_gateway_plain_http_remote_unsafe_allowed diagnostic for non-loopback HTTP join"
|
||||
fi
|
||||
fi
|
||||
|
||||
log "approving join request ${JOIN_REQUEST_ID}"
|
||||
api_request "POST" "/companies/${COMPANY_ID}/join-requests/${JOIN_REQUEST_ID}/approve" "{}"
|
||||
if [[ "$RESPONSE_CODE" == "401" || "$RESPONSE_CODE" == "403" ]]; then
|
||||
fail_board_auth_required "Join approval"
|
||||
fi
|
||||
assert_status "200"
|
||||
CREATED_AGENT_ID="$(assert_json_has_string '.createdAgentId')"
|
||||
|
||||
log "verifying invalid claim secret is rejected"
|
||||
api_request "POST" "/join-requests/${JOIN_REQUEST_ID}/claim-api-key" '{"claimSecret":"invalid-smoke-secret-value"}'
|
||||
if [[ "$RESPONSE_CODE" == "201" ]]; then
|
||||
fail "invalid claim secret unexpectedly succeeded"
|
||||
fi
|
||||
|
||||
log "claiming API key with one-time claim secret"
|
||||
CLAIM_PAYLOAD="$(jq -nc --arg secret "$CLAIM_SECRET" '{claimSecret:$secret}')"
|
||||
api_request "POST" "$CLAIM_API_PATH" "$CLAIM_PAYLOAD"
|
||||
assert_status "201"
|
||||
AGENT_API_KEY="$(assert_json_has_string '.token')"
|
||||
KEY_ID="$(assert_json_has_string '.keyId')"
|
||||
|
||||
log "verifying replay claim is rejected"
|
||||
api_request "POST" "$CLAIM_API_PATH" "$CLAIM_PAYLOAD"
|
||||
if [[ "$RESPONSE_CODE" == "201" ]]; then
|
||||
fail "claim secret replay unexpectedly succeeded"
|
||||
fi
|
||||
|
||||
log "verifying stored Hermes gateway agent config"
|
||||
api_request "GET" "/agents/${CREATED_AGENT_ID}"
|
||||
assert_status "200"
|
||||
|
||||
AGENT_ADAPTER_TYPE="$(jq -r '.adapterType // empty' <<<"$RESPONSE_BODY")"
|
||||
[[ "$AGENT_ADAPTER_TYPE" == "hermes_gateway" ]] || fail "expected adapterType=hermes_gateway, got ${AGENT_ADAPTER_TYPE}"
|
||||
|
||||
STORED_API_BASE_URL="$(jq -r '.adapterConfig.apiBaseUrl // empty' <<<"$RESPONSE_BODY")"
|
||||
[[ -n "$STORED_API_BASE_URL" ]] || fail "stored adapterConfig.apiBaseUrl is missing"
|
||||
if [[ "$(strip_trailing_slash "$STORED_API_BASE_URL")" != "$(strip_trailing_slash "$HERMES_GATEWAY_API_BASE_URL")" ]]; then
|
||||
fail "stored apiBaseUrl mismatch: expected $(strip_trailing_slash "$HERMES_GATEWAY_API_BASE_URL"), got $(strip_trailing_slash "$STORED_API_BASE_URL")"
|
||||
fi
|
||||
|
||||
if jq -e --arg raw "$HERMES_GATEWAY_API_KEY" '.adapterConfig.apiKey == $raw' <<<"$RESPONSE_BODY" >/dev/null; then
|
||||
fail "stored adapterConfig.apiKey leaked the raw Hermes API key"
|
||||
fi
|
||||
if ! jq -e '(.adapterConfig.apiKey.type // "") == "secret_ref"' <<<"$RESPONSE_BODY" >/dev/null; then
|
||||
warn "stored adapterConfig.apiKey is not a visible secret_ref; response shape may redact it entirely"
|
||||
fi
|
||||
|
||||
STORED_SESSION_STRATEGY="$(jq -r '.adapterConfig.sessionKeyStrategy // empty' <<<"$RESPONSE_BODY")"
|
||||
[[ "$STORED_SESSION_STRATEGY" == "$HERMES_GATEWAY_SESSION_KEY_STRATEGY" ]] || fail "stored sessionKeyStrategy mismatch: expected ${HERMES_GATEWAY_SESSION_KEY_STRATEGY}, got ${STORED_SESSION_STRATEGY:-<empty>}"
|
||||
|
||||
if [[ -n "$PAPERCLIP_API_URL_FOR_HERMES" ]]; then
|
||||
STORED_PAPERCLIP_API_URL="$(jq -r '.adapterConfig.paperclipApiUrl // empty' <<<"$RESPONSE_BODY")"
|
||||
[[ "$STORED_PAPERCLIP_API_URL" == "$PAPERCLIP_API_URL_FOR_HERMES" || "$(strip_trailing_slash "$STORED_PAPERCLIP_API_URL")" == "$(strip_trailing_slash "$PAPERCLIP_API_URL_FOR_HERMES")" ]] \
|
||||
|| fail "stored paperclipApiUrl mismatch"
|
||||
fi
|
||||
|
||||
log "success"
|
||||
log "companyId=${COMPANY_ID}"
|
||||
log "inviteId=${INVITE_ID}"
|
||||
log "joinRequestId=${JOIN_REQUEST_ID}"
|
||||
log "agentId=${CREATED_AGENT_ID}"
|
||||
log "keyId=${KEY_ID}"
|
||||
log "hermesGatewayApiKeySha256=$(hash_prefix "$HERMES_GATEWAY_API_KEY") len=${#HERMES_GATEWAY_API_KEY}"
|
||||
log "agentApiKeySha256=$(hash_prefix "$AGENT_API_KEY") len=${#AGENT_API_KEY}"
|
||||
|
||||
if [[ -n "$HERMES_JOIN_OUTPUT_FILE" ]]; then
|
||||
mkdir -p "$(dirname "$HERMES_JOIN_OUTPUT_FILE")"
|
||||
jq -nc \
|
||||
--arg companyId "$COMPANY_ID" \
|
||||
--arg inviteId "$INVITE_ID" \
|
||||
--arg joinRequestId "$JOIN_REQUEST_ID" \
|
||||
--arg agentId "$CREATED_AGENT_ID" \
|
||||
--arg keyId "$KEY_ID" \
|
||||
--arg agentApiKey "$AGENT_API_KEY" \
|
||||
--arg hermesGatewayApiKeySha256 "$(hash_prefix "$HERMES_GATEWAY_API_KEY")" \
|
||||
--arg agentApiKeySha256 "$(hash_prefix "$AGENT_API_KEY")" \
|
||||
'{
|
||||
companyId: $companyId,
|
||||
inviteId: $inviteId,
|
||||
joinRequestId: $joinRequestId,
|
||||
agentId: $agentId,
|
||||
keyId: $keyId,
|
||||
agentApiKey: $agentApiKey,
|
||||
hermesGatewayApiKeySha256: $hermesGatewayApiKeySha256,
|
||||
agentApiKeySha256: $agentApiKeySha256
|
||||
}' > "$HERMES_JOIN_OUTPUT_FILE"
|
||||
chmod 600 "$HERMES_JOIN_OUTPUT_FILE"
|
||||
log "wrote join metadata to ${HERMES_JOIN_OUTPUT_FILE} (contains secret material; chmod 600)"
|
||||
fi
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
const joinScript = path.join(repoRoot, "scripts", "smoke", "hermes-gateway-join.sh");
|
||||
const e2eScript = path.join(repoRoot, "scripts", "smoke", "hermes-gateway-e2e.sh");
|
||||
const entrypointScript = path.join(repoRoot, "docker", "hermes-gateway-smoke", "entrypoint.sh");
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
return spawnSync(command, args, {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
function assertSuccess(result, label) {
|
||||
assert.equal(
|
||||
result.status,
|
||||
0,
|
||||
`${label} failed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
|
||||
);
|
||||
}
|
||||
|
||||
function extractFunction(scriptText, name) {
|
||||
const lines = scriptText.split("\n");
|
||||
const start = lines.findIndex((line) => line.trim() === `${name}() {`);
|
||||
assert.notEqual(start, -1, `missing function ${name}`);
|
||||
|
||||
const collected = [];
|
||||
for (let index = start; index < lines.length; index += 1) {
|
||||
collected.push(lines[index]);
|
||||
if (index > start && lines[index].trim() === "}") {
|
||||
return collected.join("\n");
|
||||
}
|
||||
}
|
||||
assert.fail(`unterminated function ${name}`);
|
||||
}
|
||||
|
||||
function runBashFunctions(scriptPath, functionNames, body) {
|
||||
const scriptText = fs.readFileSync(scriptPath, "utf8");
|
||||
const functions = functionNames.map((name) => extractFunction(scriptText, name)).join("\n\n");
|
||||
return run("bash", ["-c", `set -euo pipefail\n${functions}\n${body}`]);
|
||||
}
|
||||
|
||||
test("Hermes gateway smoke shell scripts pass bash syntax validation", () => {
|
||||
const result = run("bash", ["-n", joinScript, e2eScript, entrypointScript]);
|
||||
assertSuccess(result, "bash -n");
|
||||
});
|
||||
|
||||
test("Hermes gateway smoke help documents operator safety flags", () => {
|
||||
for (const script of [joinScript, e2eScript]) {
|
||||
const result = run("bash", [script, "--help"]);
|
||||
assertSuccess(result, `${path.basename(script)} --help`);
|
||||
assert.match(result.stdout, /HERMES_GATEWAY_API_BASE_URL/);
|
||||
assert.match(result.stdout, /HERMES_GATEWAY_PROBE_URL/);
|
||||
assert.match(result.stdout, /HERMES_GATEWAY_ALLOW_INSECURE_HTTP/);
|
||||
assert.match(result.stdout, /redact|redacted|Raw .*keys are redacted/i);
|
||||
}
|
||||
|
||||
const e2eHelp = run("bash", [e2eScript, "--help"]).stdout;
|
||||
assert.match(e2eHelp, /HERMES_SMOKE_KEEP/);
|
||||
assert.match(e2eHelp, /HERMES_SMOKE_NETWORK/);
|
||||
assert.match(e2eHelp, /HERMES_SMOKE_MODEL_DEFAULT/);
|
||||
assert.match(e2eHelp, /Docker/);
|
||||
});
|
||||
|
||||
test("E2E helper can seed a minimal Hermes model config without secrets", () => {
|
||||
const result = runBashFunctions(
|
||||
e2eScript,
|
||||
["log", "fail", "yaml_single_quote", "write_hermes_model_config"],
|
||||
`
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
HERMES_SMOKE_STATE_DIR="$tmp"
|
||||
HERMES_SMOKE_MODEL_PROVIDER="openrouter"
|
||||
HERMES_SMOKE_MODEL_DEFAULT="z-ai/glm-5.2"
|
||||
HERMES_SMOKE_MODEL_BASE_URL="https://openrouter.ai/api/v1"
|
||||
mkdir -p "$HERMES_SMOKE_STATE_DIR/hermes-home"
|
||||
write_hermes_model_config
|
||||
config="$HERMES_SMOKE_STATE_DIR/hermes-home/config.yaml"
|
||||
grep -Fq "default: 'z-ai/glm-5.2'" "$config"
|
||||
grep -Fq "provider: 'openrouter'" "$config"
|
||||
grep -Fq "base_url: 'https://openrouter.ai/api/v1'" "$config"
|
||||
grep -Fq "command_allowlist:" "$config"
|
||||
grep -Fq -- "- execute_code" "$config"
|
||||
! grep -Eiq "api[_-]?key|token|secret" "$config"
|
||||
`,
|
||||
);
|
||||
assertSuccess(result, "write_hermes_model_config");
|
||||
});
|
||||
|
||||
test("join helper redacts known secrets without exposing raw key material", () => {
|
||||
const result = runBashFunctions(
|
||||
joinScript,
|
||||
["redact_text"],
|
||||
`
|
||||
HERMES_GATEWAY_API_KEY="gateway-secret"
|
||||
CLAIM_SECRET="claim-secret"
|
||||
AGENT_API_KEY="agent-secret"
|
||||
PAPERCLIP_API_KEY="paperclip-secret"
|
||||
PAPERCLIP_AUTH_HEADER="Bearer board-secret"
|
||||
PAPERCLIP_COOKIE="session=board-cookie"
|
||||
output="$(redact_text "gateway-secret claim-secret agent-secret paperclip-secret Bearer board-secret session=board-cookie")"
|
||||
[[ "$output" != *"gateway-secret"* ]]
|
||||
[[ "$output" != *"claim-secret"* ]]
|
||||
[[ "$output" != *"agent-secret"* ]]
|
||||
[[ "$output" != *"paperclip-secret"* ]]
|
||||
[[ "$output" != *"board-secret"* ]]
|
||||
[[ "$output" != *"board-cookie"* ]]
|
||||
[[ "$output" == *"[redacted len=14]"* ]]
|
||||
`,
|
||||
);
|
||||
assertSuccess(result, "redact_text");
|
||||
});
|
||||
|
||||
test("URL helpers distinguish loopback HTTP from unsafe remote HTTP", () => {
|
||||
for (const script of [joinScript, e2eScript]) {
|
||||
const result = runBashFunctions(
|
||||
script,
|
||||
["url_host", "is_loopback_http_host", "is_remote_plain_http"],
|
||||
`
|
||||
is_remote_plain_http "http://192.168.1.20:8642"
|
||||
is_remote_plain_http "http://hermes-gateway.local:8642"
|
||||
is_remote_plain_http "http://127.example.com:8642"
|
||||
is_remote_plain_http "http://localhost.evil:8642"
|
||||
! is_remote_plain_http "https://192.168.1.20:8642"
|
||||
! is_remote_plain_http "http://127.0.0.1:8642"
|
||||
! is_remote_plain_http "http://127.44.55.66:8642"
|
||||
! is_remote_plain_http "http://localhost:8642"
|
||||
! is_remote_plain_http "http://[::1]:8642"
|
||||
[[ "$(url_host "http://[::1]:8642/health")" == "::1" ]]
|
||||
[[ "$(url_host "http://127.example.com:8642/health")" == "127.example.com" ]]
|
||||
`,
|
||||
);
|
||||
assertSuccess(result, `${path.basename(script)} URL helpers`);
|
||||
}
|
||||
});
|
||||
|
||||
test("join helper normalizes trailing slashes for URL comparisons", () => {
|
||||
const result = runBashFunctions(
|
||||
joinScript,
|
||||
["strip_trailing_slash"],
|
||||
`
|
||||
[[ "$(strip_trailing_slash "http://127.0.0.1:8642///")" == "http://127.0.0.1:8642" ]]
|
||||
[[ "$(strip_trailing_slash "https://gateway.example.com/")" == "https://gateway.example.com" ]]
|
||||
[[ "$(strip_trailing_slash "https://gateway.example.com/path/")" == "https://gateway.example.com/path" ]]
|
||||
`,
|
||||
);
|
||||
assertSuccess(result, "strip_trailing_slash");
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue