From 8d0c3d2fe6dfda630e4c6f94a2eea2867cbbbc4b Mon Sep 17 00:00:00 2001 From: Robin van Duiven Date: Tue, 21 Apr 2026 14:18:11 +0200 Subject: [PATCH] fix(hermes): inject agent JWT into Hermes adapter env to fix identity attribution (#3608) ## Thinking Path > - Paperclip orchestrates AI agents and records their actions through auditable issue comments and API writes. > - The local adapter registry is responsible for adapting each agent runtime to Paperclip's server-side execution context. > - The Hermes local adapter delegated directly to `hermes-paperclip-adapter`, whose current execution context type predates the server `authToken` field. > - Without explicitly passing the run-scoped agent token and run id into Hermes, Hermes could inherit a server or board-user `PAPERCLIP_API_KEY` and lack a usable `PAPERCLIP_RUN_ID` for mutating API calls. > - That made Paperclip writes from Hermes agents risk appearing under the wrong identity or without the correct run-scoped attribution. > - This pull request wraps the Hermes execution call so Hermes receives the agent run JWT as `PAPERCLIP_API_KEY` and the current execution id as `PAPERCLIP_RUN_ID` while preserving explicit adapter configuration where appropriate. > - Follow-up review fixes preserve Hermes' built-in prompt when no custom prompt template exists and document the intentional type cast. > - The benefit is reliable agent attribution for the covered local Hermes path without clobbering Hermes' default heartbeat/task instructions. ## What Changed - Wrapped `hermesLocalAdapter.execute` so `ctx.authToken` is injected into `adapterConfig.env.PAPERCLIP_API_KEY` when no explicit Paperclip API key is already configured. - Injected `ctx.runId` into `adapterConfig.env.PAPERCLIP_RUN_ID` so the auth guard's `X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID` instruction resolves to the current run id. - Added a Paperclip API auth guard to existing custom Hermes `promptTemplate` values without creating a replacement prompt when no custom template exists. - Documented the intentional `as unknown as` cast needed until `hermes-paperclip-adapter` ships an `AdapterExecutionContext` type that includes `authToken`. - Added registry tests for JWT injection, run-id injection, explicit key preservation, default prompt preservation, and the no-`authToken` early-return path. ## Verification - [x] `pnpm --filter "./server" exec vitest run adapter-registry` - 8 tests passed. - [x] `pnpm --filter "./server" typecheck` - passed. - [x] Trigger a Hermes agent heartbeat and verify Paperclip writes appear under the agent identity rather than a shared board-user identity, with the correct run id on mutating requests. ## Risks - Low migration risk: this changes only the Hermes local adapter wrapper and tests. - Existing explicit `adapterConfig.env.PAPERCLIP_API_KEY` values are preserved to avoid breaking intentionally configured agents. - `PAPERCLIP_RUN_ID` is set from `ctx.runId` for each execution so mutating API calls use the current run id instead of a stale or literal placeholder value. - Prompt behavior is intentionally conservative: the auth guard is only prepended when a custom prompt template already exists, so Hermes' built-in default prompt remains intact for unconfigured agents. - Remaining operational risk: the identity and run-id behavior should still be verified with a live Hermes heartbeat before relying on it in production. ## Model Used - OpenAI Codex, GPT-5 family coding agent, tool use enabled for local shell, GitHub CLI, and test execution. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots (not applicable: backend-only change) - [x] I have updated relevant documentation to reflect my changes (not applicable: no product docs changed; PR description updated) - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip Co-authored-by: Dotta --- server/src/__tests__/adapter-registry.test.ts | 198 ++++++++++++++++++ server/src/adapters/registry.ts | 53 ++++- 2 files changed, 250 insertions(+), 1 deletion(-) diff --git a/server/src/__tests__/adapter-registry.test.ts b/server/src/__tests__/adapter-registry.test.ts index 4e473df5b1..ec5c198871 100644 --- a/server/src/__tests__/adapter-registry.test.ts +++ b/server/src/__tests__/adapter-registry.test.ts @@ -1,5 +1,28 @@ import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; 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, @@ -39,6 +62,7 @@ describe("server adapter registry", () => { unregisterServerAdapter("external_test"); unregisterServerAdapter("claude_local"); setOverridePaused("claude_local", false); + hermesExecuteMock.mockClear(); }); it("registers external adapters and exposes them through lookup helpers", async () => { @@ -185,4 +209,178 @@ 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 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"); + }); }); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 59f0128920..9ee10d136e 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -231,9 +231,60 @@ const piLocalAdapter: ServerAdapterModule = { agentConfigurationDoc: piAgentConfigurationDoc, }; +// hermes-paperclip-adapter v0.2.0 predates the authToken field; cast is +// intentional until hermes ships a matching AdapterExecutionContext type. +const executeHermesLocal = hermesExecute as unknown as ServerAdapterModule["execute"]; + const hermesLocalAdapter: ServerAdapterModule = { type: "hermes_local", - execute: (ctx) => hermesExecute(normalizeHermesConfig(ctx) as never), + execute: async (ctx) => { + const normalizedCtx = normalizeHermesConfig(ctx); + if (!normalizedCtx.authToken) return executeHermesLocal(normalizedCtx); + + const existingConfig = (normalizedCtx.agent.adapterConfig ?? {}) as Record; + const existingEnv = + typeof existingConfig.env === "object" && existingConfig.env !== null && !Array.isArray(existingConfig.env) + ? (existingConfig.env as Record) + : {}; + const explicitApiKey = + typeof existingEnv.PAPERCLIP_API_KEY === "string" && existingEnv.PAPERCLIP_API_KEY.trim().length > 0; + const promptTemplate = + typeof existingConfig.promptTemplate === "string" && existingConfig.promptTemplate.trim().length > 0 + ? existingConfig.promptTemplate + : ""; + const authGuardPrompt = [ + "Paperclip API safety rule:", + "Use Authorization: Bearer $PAPERCLIP_API_KEY on every Paperclip API request.", + "Use X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID on every Paperclip API request that writes or mutates data, including comments and issue updates.", + "Never use a board, browser, or local-board session for Paperclip API writes.", + ].join("\n"); + + const patchedConfig: Record = { + ...existingConfig, + env: { + ...existingEnv, + ...(!explicitApiKey ? { PAPERCLIP_API_KEY: normalizedCtx.authToken } : {}), + PAPERCLIP_RUN_ID: normalizedCtx.runId, + }, + }; + + // Only inject the auth guard into promptTemplate when a custom template already exists. + // When no custom template is set, Hermes uses its built-in default heartbeat/task prompt — + // overwriting it with only the auth guard text would strip the assigned issue/workflow instructions. + if (promptTemplate) { + patchedConfig.promptTemplate = `${authGuardPrompt}\n\n${promptTemplate}`; + } + + const patchedCtx = { + ...normalizedCtx, + agent: { + ...normalizedCtx.agent, + adapterConfig: patchedConfig, + }, + }; + + return executeHermesLocal(patchedCtx); + }, testEnvironment: (ctx) => hermesTestEnvironment(normalizeHermesConfig(ctx) as never), sessionCodec: hermesSessionCodec, listSkills: hermesListSkills,