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 <noreply@paperclip.ing>
Co-authored-by: Dotta <bippadotta@protonmail.com>
This commit is contained in:
Robin van Duiven
2026-04-21 14:18:11 +02:00
committed by GitHub
parent 1266954a4e
commit 8d0c3d2fe6
2 changed files with 250 additions and 1 deletions

View File

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

View File

@@ -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<string, unknown>;
const existingEnv =
typeof existingConfig.env === "object" && existingConfig.env !== null && !Array.isArray(existingConfig.env)
? (existingConfig.env as Record<string, string>)
: {};
const explicitApiKey =
typeof existingEnv.PAPERCLIP_API_KEY === "string" && existingEnv.PAPERCLIP_API_KEY.trim().length > 0;
const promptTemplate =
typeof existingConfig.promptTemplate === "string" && existingConfig.promptTemplate.trim().length > 0
? existingConfig.promptTemplate
: "";
const authGuardPrompt = [
"Paperclip API safety rule:",
"Use Authorization: Bearer $PAPERCLIP_API_KEY on every Paperclip API request.",
"Use X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID on every Paperclip API request that writes or mutates data, including comments and issue updates.",
"Never use a board, browser, or local-board session for Paperclip API writes.",
].join("\n");
const patchedConfig: Record<string, unknown> = {
...existingConfig,
env: {
...existingEnv,
...(!explicitApiKey ? { PAPERCLIP_API_KEY: normalizedCtx.authToken } : {}),
PAPERCLIP_RUN_ID: normalizedCtx.runId,
},
};
// 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,