8d0c3d2fe6
## 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>
387 lines
12 KiB
TypeScript
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");
|
|
});
|
|
});
|