Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/adapters/hermes/src/gateway/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,9 @@ export function createServerAdapter(): ServerAdapterModule {
sessionCodec,
sessionManagement,
models,
supportsLocalAgentJwt: false,
// The gateway forwards the run-scoped token through Hermes' trusted
// runtime environment, never through the model-visible prompt.
supportsLocalAgentJwt: true,
supportsInstructionsBundle: false,
requiresMaterializedRuntimeSkills: false,
agentConfigurationDoc,
Expand Down
51 changes: 51 additions & 0 deletions packages/adapters/hermes/src/gateway/server/execute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,57 @@ describe("execute", () => {
expect(fetchMock).not.toHaveBeenCalled();
});

it("injects the scoped Paperclip run credential into the remote Hermes runtime", async () => {
const scopedToken = "paperclip-run-jwt";
let createBody: Record<string, unknown> | null = null;
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith("/v1/runs")) {
createBody = JSON.parse(String(init?.body));
return new Response(JSON.stringify({ run_id: "run-hermes-auth", status: "started" }), { status: 200 });
}
if (url.endsWith("/events")) {
return new Response(
sseStream(
[
"event: run.completed",
`data: ${JSON.stringify({ status: "completed", output: scopedToken })}`,
"",
].join("\n"),
),
{ status: 200, headers: { "content-type": "text/event-stream" } },
);
}
return new Response(JSON.stringify({ status: "completed", output: "done" }), { status: 200 });
});
vi.stubGlobal("fetch", fetchMock);

const ctx = makeCtx({
apiBaseUrl: "http://127.0.0.1:8642",
apiKey: "gateway-key",
paperclipApiUrl: "https://paperclip.example/api",
timeoutSec: 5,
});
ctx.authToken = scopedToken;

const result = await execute(ctx);

expect(createBody).toMatchObject({
runtime_env: {
PAPERCLIP_API_KEY: scopedToken,
PAPERCLIP_RUN_ID: "pc-run-1",
PAPERCLIP_AGENT_ID: "agent-1",
PAPERCLIP_COMPANY_ID: "company-1",
PAPERCLIP_API_URL: "https://paperclip.example/api",
PAPERCLIP_TASK_ID: "issue-1",
},
});
const submittedBody = createBody as unknown as Record<string, unknown>;
expect(String(submittedBody.input)).not.toContain(scopedToken);
expect(String(submittedBody.instructions)).not.toContain(scopedToken);
expect(result.summary).not.toContain(scopedToken);
});

it("constructs POST /v1/runs with auth, idempotency, and Hermes session headers", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
Expand Down
10 changes: 10 additions & 0 deletions packages/adapters/hermes/src/gateway/server/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,10 +327,19 @@ function buildRunBody(ctx: AdapterExecutionContext, sessionKey: string | null):
nonEmpty(ctx.config.instructions) ??
nonEmpty(payloadTemplate.instructions) ??
"Follow the Paperclip wake instructions exactly. Do not expose secrets in logs, comments, or final output.";
const runtimeEnv = {
...(nonEmpty(ctx.authToken) ? { PAPERCLIP_API_KEY: ctx.authToken } : {}),
PAPERCLIP_RUN_ID: ctx.runId,
PAPERCLIP_AGENT_ID: ctx.agent.id,
PAPERCLIP_COMPANY_ID: ctx.agent.companyId,
...(paperclipApiUrl ? { PAPERCLIP_API_URL: paperclipApiUrl } : {}),
...(issueIdFromContext(ctx) ? { PAPERCLIP_TASK_ID: issueIdFromContext(ctx) } : {}),
};
return {
...payloadTemplate,
input,
instructions,
runtime_env: runtimeEnv,
...(sessionKey ? { session_id: sessionKey } : {}),
};
}
Expand Down Expand Up @@ -848,6 +857,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
});
const redactText = createTextRedactor([
apiKey,
ctx.authToken,
sessionKey,
runHeaders.Authorization,
runHeaders["X-Hermes-Session-Key"],
Expand Down
2 changes: 1 addition & 1 deletion packages/adapters/hermes/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ test("root package export keeps explicit local and gateway adapter factories", (
expect(localAdapter.type).toBe("hermes_local");
expect(gatewayAdapter.type).toBe("hermes_gateway");
expect(hermesGatewayType).toBe("hermes_gateway");
expect(gatewayAdapter.supportsLocalAgentJwt).toBe(false);
expect(gatewayAdapter.supportsLocalAgentJwt).toBe(true);
expect(gatewayAdapter.supportsInstructionsBundle).toBe(false);
});

Expand Down
2 changes: 1 addition & 1 deletion server/src/__tests__/adapter-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ describe("server adapter registry", () => {
expect(builtInLocal?.getConfigSchema).toBeTypeOf("function");

expect(builtInGateway).not.toBeNull();
expect(builtInGateway?.supportsLocalAgentJwt).toBe(false);
expect(builtInGateway?.supportsLocalAgentJwt).toBe(true);
expect(builtInGateway?.supportsInstructionsBundle).toBe(false);
expect(builtInGateway?.requiresMaterializedRuntimeSkills).toBe(false);
expect(builtInGateway?.getConfigSchema).toBeTypeOf("function");
Expand Down
2 changes: 1 addition & 1 deletion server/src/__tests__/adapter-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ describe("adapter routes", () => {
expect(hermesGateway.capabilities).toMatchObject({
supportsInstructionsBundle: false,
supportsSkills: false,
supportsLocalAgentJwt: false,
supportsLocalAgentJwt: true,
requiresMaterializedRuntimeSkills: false,
supportsAcp: false,
});
Expand Down
Loading