mirror of
https://github.com/paperclipai/paperclip
synced 2026-04-25 17:25:15 +02:00
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:
@@ -1,5 +1,28 @@
|
|||||||
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
|
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
|
||||||
import type { ServerAdapterModule } from "../adapters/index.js";
|
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 {
|
import {
|
||||||
detectAdapterModel,
|
detectAdapterModel,
|
||||||
findActiveServerAdapter,
|
findActiveServerAdapter,
|
||||||
@@ -39,6 +62,7 @@ describe("server adapter registry", () => {
|
|||||||
unregisterServerAdapter("external_test");
|
unregisterServerAdapter("external_test");
|
||||||
unregisterServerAdapter("claude_local");
|
unregisterServerAdapter("claude_local");
|
||||||
setOverridePaused("claude_local", false);
|
setOverridePaused("claude_local", false);
|
||||||
|
hermesExecuteMock.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("registers external adapters and exposes them through lookup helpers", async () => {
|
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(await detectAdapterModel("claude_local")).toBeNull();
|
||||||
expect(detectModel).toHaveBeenCalledTimes(1);
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -231,9 +231,60 @@ const piLocalAdapter: ServerAdapterModule = {
|
|||||||
agentConfigurationDoc: piAgentConfigurationDoc,
|
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 = {
|
const hermesLocalAdapter: ServerAdapterModule = {
|
||||||
type: "hermes_local",
|
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),
|
testEnvironment: (ctx) => hermesTestEnvironment(normalizeHermesConfig(ctx) as never),
|
||||||
sessionCodec: hermesSessionCodec,
|
sessionCodec: hermesSessionCodec,
|
||||||
listSkills: hermesListSkills,
|
listSkills: hermesListSkills,
|
||||||
|
|||||||
Reference in New Issue
Block a user