Files
paperclip/server/src/__tests__/adapter-registry.test.ts
T
Robin van Duiven 8d0c3d2fe6 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>
2026-04-21 07:18:11 -05:00

387 lines
12 KiB
TypeScript

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,
findServerAdapter,
listAdapterModels,
registerServerAdapter,
requireServerAdapter,
unregisterServerAdapter,
} from "../adapters/index.js";
import { setOverridePaused } from "../adapters/registry.js";
const externalAdapter: ServerAdapterModule = {
type: "external_test",
execute: async () => ({
exitCode: 0,
signal: null,
timedOut: false,
}),
testEnvironment: async () => ({
adapterType: "external_test",
status: "pass",
checks: [],
testedAt: new Date(0).toISOString(),
}),
models: [{ id: "external-model", label: "External Model" }],
supportsLocalAgentJwt: false,
};
describe("server adapter registry", () => {
beforeEach(() => {
unregisterServerAdapter("external_test");
unregisterServerAdapter("claude_local");
setOverridePaused("claude_local", false);
});
afterEach(() => {
unregisterServerAdapter("external_test");
unregisterServerAdapter("claude_local");
setOverridePaused("claude_local", false);
hermesExecuteMock.mockClear();
});
it("registers external adapters and exposes them through lookup helpers", async () => {
expect(findServerAdapter("external_test")).toBeNull();
registerServerAdapter(externalAdapter);
expect(requireServerAdapter("external_test")).toBe(externalAdapter);
expect(await listAdapterModels("external_test")).toEqual([
{ id: "external-model", label: "External Model" },
]);
});
it("removes external adapters when unregistered", () => {
registerServerAdapter(externalAdapter);
unregisterServerAdapter("external_test");
expect(findServerAdapter("external_test")).toBeNull();
expect(() => requireServerAdapter("external_test")).toThrow(
"Unknown adapter type: external_test",
);
});
it("allows external plugin to override a built-in adapter type", () => {
// claude_local is always built-in
const builtIn = findServerAdapter("claude_local");
expect(builtIn).not.toBeNull();
const plugin: ServerAdapterModule = {
type: "claude_local",
execute: async () => ({
exitCode: 0,
signal: null,
timedOut: false,
}),
testEnvironment: async () => ({
adapterType: "claude_local",
status: "pass",
checks: [],
testedAt: new Date(0).toISOString(),
}),
models: [{ id: "plugin-model", label: "Plugin Override" }],
supportsLocalAgentJwt: false,
};
registerServerAdapter(plugin);
// Plugin wins
const resolved = requireServerAdapter("claude_local");
expect(resolved).toBe(plugin);
expect(resolved.models).toEqual([
{ id: "plugin-model", label: "Plugin Override" },
]);
});
it("exposes capability flags from registered adapters", () => {
const adapterWithCaps: ServerAdapterModule = {
type: "external_test",
execute: async () => ({ exitCode: 0, signal: null, timedOut: false }),
testEnvironment: async () => ({
adapterType: "external_test",
status: "pass" as const,
checks: [],
testedAt: new Date(0).toISOString(),
}),
supportsLocalAgentJwt: true,
supportsInstructionsBundle: true,
instructionsPathKey: "customPathKey",
requiresMaterializedRuntimeSkills: true,
};
registerServerAdapter(adapterWithCaps);
const resolved = findActiveServerAdapter("external_test");
expect(resolved).not.toBeNull();
expect(resolved!.supportsInstructionsBundle).toBe(true);
expect(resolved!.instructionsPathKey).toBe("customPathKey");
expect(resolved!.requiresMaterializedRuntimeSkills).toBe(true);
expect(resolved!.supportsLocalAgentJwt).toBe(true);
});
it("returns undefined for capability flags on adapters that do not set them", () => {
registerServerAdapter(externalAdapter);
const resolved = findActiveServerAdapter("external_test");
expect(resolved).not.toBeNull();
expect(resolved!.supportsInstructionsBundle).toBeUndefined();
expect(resolved!.instructionsPathKey).toBeUndefined();
expect(resolved!.requiresMaterializedRuntimeSkills).toBeUndefined();
});
it("built-in claude_local adapter declares capability flags", () => {
const adapter = findActiveServerAdapter("claude_local");
expect(adapter).not.toBeNull();
expect(adapter!.supportsInstructionsBundle).toBe(true);
expect(adapter!.instructionsPathKey).toBe("instructionsFilePath");
expect(adapter!.requiresMaterializedRuntimeSkills).toBe(false);
expect(adapter!.supportsLocalAgentJwt).toBe(true);
});
it("switches active adapter behavior back to the builtin when an override is paused", async () => {
const builtIn = findServerAdapter("claude_local");
expect(builtIn).not.toBeNull();
const detectModel = vi.fn(async () => ({
model: "plugin-model",
provider: "plugin-provider",
source: "plugin-source",
}));
const plugin: ServerAdapterModule = {
type: "claude_local",
execute: async () => ({
exitCode: 0,
signal: null,
timedOut: false,
}),
testEnvironment: async () => ({
adapterType: "claude_local",
status: "pass",
checks: [],
testedAt: new Date(0).toISOString(),
}),
models: [{ id: "plugin-model", label: "Plugin Override" }],
detectModel,
supportsLocalAgentJwt: false,
};
registerServerAdapter(plugin);
expect(findActiveServerAdapter("claude_local")).toBe(plugin);
expect(await listAdapterModels("claude_local")).toEqual([
{ id: "plugin-model", label: "Plugin Override" },
]);
expect(await detectAdapterModel("claude_local")).toMatchObject({
model: "plugin-model",
provider: "plugin-provider",
});
expect(setOverridePaused("claude_local", true)).toBe(true);
expect(findActiveServerAdapter("claude_local")).not.toBe(plugin);
expect(await listAdapterModels("claude_local")).toEqual(builtIn?.models ?? []);
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");
});
});