[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:
DottaandPaperclip authored and GitHub committed 2026-06-26 16:04:58 -05:00
1 parent ef1422c23e
commit fd2f82ac5b
131 files changed
+10179 -686

No files matched your search

+11 -6
View File
@@ -16,7 +16,6 @@ import {
Cpu,
} from "lucide-react";
import { OpenCodeLogoIcon } from "@/components/OpenCodeLogoIcon";
import { HermesIcon } from "@/components/HermesIcon";
// ---------------------------------------------------------------------------
// Type suffix parsing
@@ -83,16 +82,22 @@ const adapterDisplayMap: Record<string, AdapterDisplayInfo> = {
description: "Local Grok Build agent",
icon: Bot,
},
hermes_gateway: {
label: "Hermes",
description: "Remote Hermes API server",
icon: Bot,
hideFromVisualSelection: true,
},
hermes_local: {
label: "Hermes",
description: "Local Hermes agent",
icon: Bot,
},
opencode_local: {
label: "OpenCode",
description: "Local multi-provider agent",
icon: OpenCodeLogoIcon,
},
hermes_local: {
label: "Hermes Agent",
description: "Local Hermes CLI agent",
icon: HermesIcon,
},
pi_local: {
label: "Pi",
description: "Local Pi agent",
+11
View File
@@ -0,0 +1,11 @@
import type { UIAdapterModule } from "../types";
import { parseStdoutLine as parseHermesGatewayStdoutLine } from "@paperclipai/hermes-paperclip-adapter/gateway/ui";
import { SchemaConfigFields, buildSchemaAdapterConfig } from "../schema-config-fields";
export const hermesGatewayUIAdapter: UIAdapterModule = {
type: "hermes_gateway",
label: "Hermes Gateway",
parseStdoutLine: parseHermesGatewayStdoutLine,
ConfigFields: SchemaConfigFields,
buildAdapterConfig: buildSchemaAdapterConfig,
};
@@ -1,49 +0,0 @@
import type { AdapterConfigFieldsProps } from "../types";
import {
Field,
DraftInput,
} from "../../components/agent-config-primitives";
import { ChoosePathButton } from "../../components/PathInstructionsModal";
const inputClass =
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
const instructionsFileHint =
"Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime.";
export function HermesLocalConfigFields({
isCreate,
values,
set,
config,
eff,
mark,
hideInstructionsFile,
}: AdapterConfigFieldsProps) {
if (hideInstructionsFile) return null;
return (
<Field label="Agent instructions file" hint={instructionsFileHint}>
<div className="flex items-center gap-2">
<DraftInput
value={
isCreate
? values!.instructionsFilePath ?? ""
: eff(
"adapterConfig",
"instructionsFilePath",
String(config.instructionsFilePath ?? ""),
)
}
onCommit={(v) =>
isCreate
? set!({ instructionsFilePath: v })
: mark("adapterConfig", "instructionsFilePath", v || undefined)
}
immediate
className={inputClass}
placeholder="/absolute/path/to/AGENTS.md"
/>
<ChoosePathButton />
</div>
</Field>
);
}
+2 -3
View File
@@ -1,11 +1,10 @@
import type { UIAdapterModule } from "../types";
import { parseHermesStdoutLine } from "hermes-paperclip-adapter/ui";
import { buildHermesConfig } from "hermes-paperclip-adapter/ui";
import { parseHermesStdoutLine, buildHermesConfig } from "@paperclipai/hermes-paperclip-adapter/ui";
import { SchemaConfigFields } from "../schema-config-fields";
export const hermesLocalUIAdapter: UIAdapterModule = {
type: "hermes_local",
label: "Hermes Agent",
label: "Hermes",
parseStdoutLine: parseHermesStdoutLine,
ConfigFields: SchemaConfigFields,
buildAdapterConfig: buildHermesConfig,
+29
View File
@@ -5,6 +5,7 @@ import {
getUIAdapter,
listUIAdapters,
registerUIAdapter,
syncExternalAdapters,
unregisterUIAdapter,
} from "./registry";
import { processUIAdapter } from "./process";
@@ -21,10 +22,12 @@ const externalUIAdapter: UIAdapterModule = {
describe("ui adapter registry", () => {
beforeEach(() => {
unregisterUIAdapter("external_test");
syncExternalAdapters([]);
});
afterEach(() => {
unregisterUIAdapter("external_test");
syncExternalAdapters([]);
});
it("registers adapters for lookup and listing", () => {
@@ -48,4 +51,30 @@ describe("ui adapter registry", () => {
// But it uses the schema-based config fields for external adapter forms.
expect(fallback.ConfigFields).toBe(SchemaConfigFields);
});
it("restores built-in Hermes adapters when external overrides are paused or removed", () => {
for (const type of ["hermes_local", "hermes_gateway"]) {
const builtin = getUIAdapter(type);
syncExternalAdapters([{ type, label: "External Hermes" }]);
const overridden = getUIAdapter(type);
expect(overridden).not.toBe(builtin);
expect(overridden.type).toBe(type);
expect(overridden.label).toBe("External Hermes");
expect(overridden.ConfigFields).toBe(builtin.ConfigFields);
expect(overridden.buildAdapterConfig).toBe(builtin.buildAdapterConfig);
syncExternalAdapters([{ type, label: "External Hermes", overrideDisabled: true }]);
expect(getUIAdapter(type)).toBe(builtin);
syncExternalAdapters([{ type, label: "External Hermes" }]);
expect(getUIAdapter(type)).not.toBe(builtin);
syncExternalAdapters([]);
expect(getUIAdapter(type)).toBe(builtin);
}
});
});
+3 -1
View File
@@ -6,10 +6,11 @@ import { cursorCloudUIAdapter } from "./cursor-cloud";
import { cursorLocalUIAdapter } from "./cursor";
import { geminiLocalUIAdapter } from "./gemini-local";
import { grokLocalUIAdapter } from "./grok-local";
import { hermesGatewayUIAdapter } from "./hermes-gateway";
import { hermesLocalUIAdapter } from "./hermes-local";
import { openCodeLocalUIAdapter } from "./opencode-local";
import { piLocalUIAdapter } from "./pi-local";
import { openClawGatewayUIAdapter } from "./openclaw-gateway";
import { hermesLocalUIAdapter } from "./hermes-local";
import { processUIAdapter } from "./process";
import { httpUIAdapter } from "./http";
import { loadDynamicParser, invalidateDynamicParser, setDynamicParserResultNotifier } from "./dynamic-loader";
@@ -58,6 +59,7 @@ function registerBuiltInUIAdapters() {
cursorCloudUIAdapter,
geminiLocalUIAdapter,
grokLocalUIAdapter,
hermesGatewayUIAdapter,
hermesLocalUIAdapter,
openCodeLocalUIAdapter,
piLocalUIAdapter,
@@ -24,7 +24,6 @@ const KNOWN_DEFAULTS: Record<string, AdapterCapabilities> = {
grok_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: false },
opencode_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true },
pi_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: false },
hermes_local: { supportsInstructionsBundle: false, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: false },
openclaw_gateway: ALL_FALSE,
};
+4 -2
View File
@@ -15,6 +15,7 @@ import type {
Approval,
AgentConfigRevision,
ClearAgentErrorResponse,
AgentApiKeyScope,
} from "@paperclipai/shared";
import type {
AdapterModelProfileDefinition,
@@ -26,6 +27,7 @@ import { ApiError, api } from "./client";
export interface AgentKey {
id: string;
name: string;
scope: AgentApiKeyScope;
createdAt: Date;
revokedAt: Date | null;
}
@@ -178,8 +180,8 @@ export const agentsApi = {
api.get<AgentSkillSnapshot>(agentPath(id, companyId, "/skills")),
syncSkills: (id: string, desiredSkills: Array<string | AgentDesiredSkillEntry>, companyId?: string) =>
api.post<AgentSkillSnapshot>(agentPath(id, companyId, "/skills/sync"), { desiredSkills }),
createKey: (id: string, name: string, companyId?: string) =>
api.post<AgentKeyCreated>(agentPath(id, companyId, "/keys"), { name }),
createKey: (id: string, name: string, companyId?: string, scope?: AgentApiKeyScope) =>
api.post<AgentKeyCreated>(agentPath(id, companyId, "/keys"), { name, ...(scope ? { scope } : {}) }),
revokeKey: (agentId: string, keyId: string, companyId?: string) =>
api.delete<{ ok: true }>(agentPath(agentId, companyId, `/keys/${encodeURIComponent(keyId)}`)),
runtimeState: (id: string, companyId?: string) =>
+2 -16
View File
@@ -436,8 +436,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
const [refreshModelsError, setRefreshModelsError] = useState<string | null>(null);
const [refreshingModels, setRefreshingModels] = useState(false);
const rawModels = fetchedModels ?? externalModels ?? [];
const adapterCommandField =
adapterType === "hermes_local" ? "hermesCommand" : "command";
const adapterCommandField = "command";
const acpxAgent =
adapterType === "acpx_local"
? isCreate
@@ -529,17 +528,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
if (adapterConfigPatch) {
Object.assign(next, adapterConfigPatch);
}
if (adapterType === "hermes_local") {
const hermesCommand =
typeof next.hermesCommand === "string" && next.hermesCommand.length > 0
? next.hermesCommand
: typeof next.command === "string" && next.command.length > 0
? next.command
: undefined;
if (hermesCommand) {
next.hermesCommand = hermesCommand;
}
}
return next;
}
@@ -1201,9 +1189,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
"adapterConfig",
adapterCommandField,
String(
(adapterType === "hermes_local"
? config.hermesCommand ?? config.command
: config.command) ?? "",
config.command ?? "",
),
)
}
-43
View File
@@ -1,43 +0,0 @@
import { cn } from "../lib/utils";
interface HermesIconProps {
className?: string;
}
/**
* Hermes caduceus icon — winged staff with two intertwined serpents.
* Replaces the generic Zap icon for the hermes_local adapter type.
*
* ⚕️ inspired but as the proper caduceus (Hermes' symbol): staff + two snakes + wings.
*/
export function HermesIcon({ className }: HermesIconProps) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className={cn(className)}
>
{/* Central staff */}
<line x1="12" y1="6" x2="12" y2="23" />
{/* Left serpent curves */}
<path d="M12 8 C10 9 9.5 11 10.5 13 C11.5 15 10 17 12 18" />
{/* Right serpent curves */}
<path d="M12 8 C14 9 14.5 11 13.5 13 C12.5 15 14 17 12 18" />
{/* Snake heads facing outward */}
<circle cx="10" cy="8" r="0.8" fill="currentColor" stroke="none" />
<circle cx="14" cy="8" r="0.8" fill="currentColor" stroke="none" />
{/* Wings at top of staff */}
<path d="M12 6 L8 3 L6 5 L9 6" strokeWidth="1.2" />
<path d="M12 6 L16 3 L18 5 L15 6" strokeWidth="1.2" />
{/* Wing feather details */}
<line x1="7.5" y1="4" x2="7" y2="5.2" strokeWidth="1" />
<line x1="16.5" y1="4" x2="17" y2="5.2" strokeWidth="1" />
{/* Staff sphere at top */}
<circle cx="12" cy="6.5" r="1.2" />
</svg>
);
}
-2
View File
@@ -271,7 +271,6 @@ export function OnboardingWizard() {
adapterType === "claude_local" ||
adapterType === "codex_local" ||
adapterType === "gemini_local" ||
adapterType === "hermes_local" ||
adapterType === "opencode_local" ||
adapterType === "pi_local" ||
adapterType === "cursor";
@@ -298,7 +297,6 @@ export function OnboardingWizard() {
claude_local: "claude",
codex_local: "codex",
gemini_local: "gemini",
hermes_local: "hermes",
pi_local: "pi",
cursor: "agent",
opencode_local: "opencode",
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { buildAgentOnboardingPrompt } from "./agent-onboarding-prompt";
describe("buildAgentOnboardingPrompt", () => {
it("includes Hermes Gateway setup guidance for external agents", () => {
const prompt = buildAgentOnboardingPrompt({
onboardingTextUrl: "http://localhost:3100/api/invites/token-123/onboarding.txt",
connectionCandidates: ["http://192.168.1.10:3100"],
});
expect(prompt).toContain('adapterType: "hermes_gateway"');
expect(prompt).toContain("API_SERVER_ENABLED=true");
expect(prompt).toContain("API_SERVER_KEY");
expect(prompt).toContain("hermes gateway run --replace --accept-hooks");
expect(prompt).toContain("default Hermes API server port is `8642`");
expect(prompt).toContain("agentDefaultsPayload.apiBaseUrl");
expect(prompt).toContain("agentDefaultsPayload.paperclipApiUrl");
expect(prompt).toContain("http://127.0.0.1:8642");
expect(prompt).toContain("http://<private-ip>:8642");
expect(prompt).toContain("http://<tailnet-host>:8642");
expect(prompt).toContain("http://host.docker.internal:8642");
expect(prompt).toContain("https://hermes-gateway.example");
expect(prompt).toContain("`hermes_local` runs Hermes on the Paperclip host");
expect(prompt).toContain("Hermes-originated Paperclip API calls");
});
});
+7
View File
@@ -55,6 +55,13 @@ Join flow:
OpenClaw Gateway note:
If you are an OpenClaw Gateway agent, use \`adapterType: "openclaw_gateway"\`, set \`agentDefaultsPayload.url\` to your \`ws://\` or \`wss://\` gateway URL, and include \`agentDefaultsPayload.headers["x-openclaw-token"]\` with your gateway token. Do not use \`/v1/responses\` or \`/hooks/*\` in that join flow.
Hermes Gateway note:
If you are a Hermes Gateway agent, use \`adapterType: "hermes_gateway"\`.
- Start Hermes with \`API_SERVER_ENABLED=true\` and \`API_SERVER_KEY=<random-gateway-key>\`, then run \`hermes gateway run --replace --accept-hooks\`. The default Hermes API server port is \`8642\`.
- Set \`agentDefaultsPayload.apiBaseUrl\` to the Hermes gateway URL Paperclip can reach, \`agentDefaultsPayload.apiKey\` to the same value as \`API_SERVER_KEY\`, and \`agentDefaultsPayload.paperclipApiUrl\` to the Paperclip base URL Hermes can reach.
- \`apiBaseUrl\` examples: local loopback \`http://127.0.0.1:8642\`; LAN/private network \`http://<private-ip>:8642\`; private overlay \`http://<tailnet-host>:8642\`; Docker \`http://host.docker.internal:8642\`; reverse proxy/TLS \`https://hermes-gateway.example\`.
- Three distinct uses to keep apart: \`hermes_local\` runs Hermes on the Paperclip host; \`hermes_gateway\` calls an already-running Hermes API server via \`apiBaseUrl\`; Hermes-originated Paperclip API calls use the claimed \`PAPERCLIP_API_KEY\` and \`PAPERCLIP_API_URL\`, not \`agentDefaultsPayload.apiBaseUrl\`.
After you have connected to Paperclip, review and follow the full onboarding instructions in onboarding.txt.
`;
}