[codex] Add built-in Hermes adapters (#8543)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agent adapters are the boundary between the control plane and the
runtimes that actually do work.
> - Hermes support needs to be available as first-class local and
gateway adapters while still preserving the adapter-manager override
path for external packages.
> - The adapter work touches runtime execution, UI adapter metadata,
onboarding prompts, scoped credentials, release packaging, and smoke
coverage, so the handoff needs concrete verification rather than only
unit tests.
> - This pull request adds built-in Hermes local and Hermes gateway
support, keeps external adapter overrides compatible, and
documents/tests the gateway flow end to end.
> - The benefit is that operators can hire Hermes-backed agents without
a manual plugin install, while self-hosted installs can still
override/shadow the built-ins through Adapter manager packages.

## Linked Issues or Issue Description

No public GitHub issue exists for this exact Hermes built-in adapter,
gateway onboarding, and release-source work.

Problem description:
- Hermes local and gateway adapters need a public, reviewable source
path in the monorepo so package artifacts and built-in adapter behavior
match the application source.
- Operators need built-in `hermes_local` and `hermes_gateway` adapter
choices without losing the ability to install external Hermes packages
as overrides.
- Gateway onboarding needs secure defaults for API server URLs, API
keys, and generated agent setup text.
- Hermes-originated task bridge credentials need narrower API-key scope
configuration.
- Related public PRs found during duplicate search include #3027, #2363,
#7544, #7950, #8095, and #8543.

## What Changed

- Added the unified Hermes adapter package with local and gateway
server/UI/CLI exports, config schemas, transcript parsing, model
detection, and package metadata.
- Registered `hermes_local` and `hermes_gateway` as built-in adapters
across shared constants, server registries, CLI packaging, and UI
adapter registries.
- Kept the external adapter override path compatible so installed Hermes
packages can shadow built-ins and restore the built-in parser when
disabled.
- Added Hermes gateway onboarding docs, board-operator docs, Docker
smoke assets, and shell smoke harnesses for join/e2e validation.
- Added scoped task-bridge API-key support, authorization checks,
issue-origin handling, and tests for Hermes-created Paperclip tasks.
- Hardened gateway transport and redaction behavior for API keys,
headers, session data, and smoke diagnostics.
- Updated release packaging/bootstrap checks for the Hermes packages
while leaving `pnpm-lock.yaml` out of the PR per repository policy.

## Verification

Targeted local verification recorded before PR handoff:
- `pnpm --filter @paperclipai/hermes-paperclip-adapter exec vitest run
src/gateway/server/execute.test.ts` — 14/14 passed.
- `pnpm test:hermes-gateway-smoke` — 6/6 passed.
- Hermes package typecheck/build checks passed.
- Focused server/UI adapter tests passed — 31/31.
- Release helper Node tests passed — 18/18.
- `git diff --check origin/master..HEAD` passed.

Fresh Docker E2E smoke evidence:
- Ran `pnpm smoke:hermes-gateway-e2e` on 2026-06-26 with a fresh state
directory and fresh Docker container against a live Paperclip dev
server.
- Hermes direct execution reached `completed`.
- Hermes stop/cancel path reached `cancelled`.
- Hermes gateway created a Paperclip task, Paperclip ran the Hermes
agent, and the task reached `done` with the expected marker response.
- Temporary board auth keys, token files, smoke state, and Docker
containers were cleaned up after the run.

PR checks on head `b5eae40ce`:
- GitHub Actions passed: `policy`, `review`, `Typecheck + Release
Registry`, all general test shards, all serialized server shards,
`Build`, `Canary Dry Run`, `e2e`, and aggregate `verify`.
- External checks passed: Snyk and Socket Project Report.
- External Socket Pull Request Alerts remained pending after the
first-party CI matrix completed.

## Risks

- Medium risk: this spans adapter registration, package publishing,
gateway execution, onboarding docs, API-key scoping, and UI adapter
metadata.
- Migration risk is low: the scope-config migration adds a nullable
column and does not rewrite existing keys.
- Gateway execution depends on operator-provided Hermes API
configuration; the smoke covers the Docker gateway path but real
deployments may differ by network/auth setup.
- Direct Greptile review on the latest expanded diff is file-count
limited, although the commitperclip review gate passed.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex, GPT-5 coding agent, tool use enabled in a local repository
workspace. Context window size is not exposed in this environment.

## 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 checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Commitperclip review gate is green; direct Greptile review is
file-count limited on the latest expanded diff
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-06-26 16:04:58 -05:00
1 parent ef1422c23e
commit fd2f82ac5b
131 files changed
+10179 -686

No files matched your search

+86 -298
View File
@@ -2,28 +2,6 @@ import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
import { buildSandboxNpmInstallCommand } from "@paperclipai/adapter-utils";
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,
@@ -59,17 +37,18 @@ const externalAdapter: ServerAdapterModule = {
describe("server adapter registry", () => {
beforeEach(() => {
unregisterServerAdapter("external_test");
unregisterServerAdapter("hermes_local");
unregisterServerAdapter("hermes_gateway");
unregisterServerAdapter("claude_local");
setOverridePaused("claude_local", false);
setOverridePaused("hermes_local", true);
});
afterEach(() => {
unregisterServerAdapter("external_test");
unregisterServerAdapter("hermes_local");
unregisterServerAdapter("hermes_gateway");
unregisterServerAdapter("claude_local");
setOverridePaused("claude_local", false);
setOverridePaused("hermes_local", false);
hermesExecuteMock.mockClear();
});
it("registers external adapters and exposes them through lookup helpers", async () => {
@@ -151,6 +130,88 @@ describe("server adapter registry", () => {
]);
});
it("ships Hermes adapters as built-ins and still accepts external overrides", () => {
const builtInLocal = findServerAdapter("hermes_local");
const builtInGateway = findServerAdapter("hermes_gateway");
expect(builtInLocal).not.toBeNull();
expect(builtInLocal?.supportsLocalAgentJwt).toBe(true);
expect(builtInLocal?.supportsInstructionsBundle).toBe(true);
expect(builtInLocal?.requiresMaterializedRuntimeSkills).toBe(false);
expect(builtInLocal?.detectModel).toBeTypeOf("function");
expect(builtInLocal?.getConfigSchema).toBeTypeOf("function");
expect(builtInGateway).not.toBeNull();
expect(builtInGateway?.supportsLocalAgentJwt).toBe(false);
expect(builtInGateway?.supportsInstructionsBundle).toBe(false);
expect(builtInGateway?.requiresMaterializedRuntimeSkills).toBe(false);
expect(builtInGateway?.getConfigSchema).toBeTypeOf("function");
const hermesLocalExternalAdapter: ServerAdapterModule = {
type: "hermes_local",
execute: async () => ({ exitCode: 0, signal: null, timedOut: false }),
testEnvironment: async () => ({
adapterType: "hermes_local",
status: "pass",
checks: [],
testedAt: new Date(0).toISOString(),
}),
supportsLocalAgentJwt: true,
supportsInstructionsBundle: true,
instructionsPathKey: "instructionsFilePath",
requiresMaterializedRuntimeSkills: false,
listSkills: async () => ({
adapterType: "hermes_local",
supported: true,
mode: "ephemeral",
desiredSkills: [],
entries: [],
warnings: [],
}),
getConfigSchema: () => ({ fields: [{ key: "provider", label: "Provider", type: "text" }] }),
detectModel: async () => ({
model: "hermes-model",
provider: "openrouter",
source: "test",
}),
};
const hermesGatewayExternalAdapter: ServerAdapterModule = {
type: "hermes_gateway",
execute: async () => ({ exitCode: 0, signal: null, timedOut: false }),
testEnvironment: async () => ({
adapterType: "hermes_gateway",
status: "pass",
checks: [],
testedAt: new Date(0).toISOString(),
}),
supportsLocalAgentJwt: false,
supportsInstructionsBundle: false,
requiresMaterializedRuntimeSkills: false,
getConfigSchema: () => ({
fields: [{ key: "apiBaseUrl", label: "API URL", type: "text" }],
}),
};
registerServerAdapter(hermesLocalExternalAdapter);
expect(requireServerAdapter("hermes_local")).toBe(hermesLocalExternalAdapter);
expect(findActiveServerAdapter("hermes_local")?.supportsLocalAgentJwt).toBe(true);
unregisterServerAdapter("hermes_local");
expect(requireServerAdapter("hermes_local")).toBe(builtInLocal);
registerServerAdapter(hermesGatewayExternalAdapter);
expect(requireServerAdapter("hermes_gateway")).toBe(hermesGatewayExternalAdapter);
expect(findActiveServerAdapter("hermes_gateway")?.supportsLocalAgentJwt).toBe(false);
unregisterServerAdapter("hermes_gateway");
expect(requireServerAdapter("hermes_gateway")).toBe(builtInGateway);
});
it("exposes capability flags from registered adapters", () => {
const adapterWithCaps: ServerAdapterModule = {
type: "external_test",
@@ -308,279 +369,6 @@ 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 Hermes custom providers through extraArgs 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: {
provider: "custom:paperclip-openai",
extraArgs: ["--source", "tool"],
},
},
runtime: {},
config: {},
context: {},
onLog: async () => {},
onMeta: async () => {},
onSpawn: async () => {},
authToken: "agent-run-jwt",
});
const [patchedCtx] = hermesExecuteMock.mock.calls[0];
expect(patchedCtx.agent.adapterConfig.provider).toBe("custom:paperclip-openai");
expect(patchedCtx.agent.adapterConfig.extraArgs).toEqual([
"--source",
"tool",
"--provider",
"custom:paperclip-openai",
]);
expect(patchedCtx.agent.adapterConfig.env.PAPERCLIP_API_KEY).toBe("agent-run-jwt");
});
it("does not duplicate an explicit Hermes provider extraArg", 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: {
provider: "custom:paperclip-openai",
extraArgs: ["--provider=custom:paperclip-openai"],
},
},
runtime: {},
config: {},
context: {},
onLog: async () => {},
onMeta: async () => {},
onSpawn: async () => {},
authToken: "agent-run-jwt",
});
const [patchedCtx] = hermesExecuteMock.mock.calls[0];
expect(patchedCtx.agent.adapterConfig.extraArgs).toEqual([
"--provider=custom:paperclip-openai",
]);
});
it("does not duplicate spaced Hermes provider extraArgs", 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: {
provider: "custom:paperclip-openai",
extraArgs: ["--provider", "custom:paperclip-openai"],
},
},
runtime: {},
config: {},
context: {},
onLog: async () => {},
onMeta: async () => {},
onSpawn: async () => {},
authToken: "agent-run-jwt",
});
const [patchedCtx] = hermesExecuteMock.mock.calls[0];
expect(patchedCtx.agent.adapterConfig.extraArgs).toEqual([
"--provider",
"custom:paperclip-openai",
]);
});
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");
});
});
describe("resolveExternalAdapterRegistration", () => {
+39 -7
View File
@@ -115,12 +115,14 @@ describe("adapter routes", () => {
adapterRoutes = routes.adapterRoutes;
errorHandler = middleware.errorHandler;
setOverridePaused("claude_local", false);
unregisterServerAdapter("hermes_local");
unregisterServerAdapter("claude_local");
registerServerAdapter(overridingConfigSchemaAdapter);
});
afterEach(() => {
setOverridePaused("claude_local", false);
unregisterServerAdapter("hermes_local");
unregisterServerAdapter("claude_local");
});
@@ -185,17 +187,25 @@ describe("adapter routes", () => {
requiresMaterializedRuntimeSkills: true,
});
// hermes_local currently supports skills + local JWT, but not the managed
// instructions bundle flow because the bundled adapter does not consume
// instructionsFilePath at runtime.
const hermesAdapter = res.body.find((a: any) => a.type === "hermes_local");
expect(hermesAdapter).toBeDefined();
expect(hermesAdapter.capabilities).toMatchObject({
supportsInstructionsBundle: false,
const hermesLocal = res.body.find((a: any) => a.type === "hermes_local");
expect(hermesLocal).toBeDefined();
expect(hermesLocal.source).toBe("builtin");
expect(hermesLocal.capabilities).toMatchObject({
supportsInstructionsBundle: true,
supportsSkills: true,
supportsLocalAgentJwt: true,
requiresMaterializedRuntimeSkills: false,
});
const hermesGateway = res.body.find((a: any) => a.type === "hermes_gateway");
expect(hermesGateway).toBeDefined();
expect(hermesGateway.source).toBe("builtin");
expect(hermesGateway.capabilities).toMatchObject({
supportsInstructionsBundle: false,
supportsSkills: false,
supportsLocalAgentJwt: false,
requiresMaterializedRuntimeSkills: false,
});
});
it("GET /api/adapters derives supportsSkills from listSkills/syncSkills presence", async () => {
@@ -277,6 +287,28 @@ describe("adapter routes", () => {
expect(keys).not.toContain("bootstrapPromptTemplate");
});
it("serves built-in Hermes config schemas", async () => {
const app = createApp();
const local = await request(app).get("/api/adapters/hermes_local/config-schema");
expect(local.status, JSON.stringify(local.body)).toBe(200);
expect(local.body.fields).toEqual(
expect.arrayContaining([
expect.objectContaining({ key: "provider" }),
expect.objectContaining({ key: "timeoutSec" }),
]),
);
const gateway = await request(app).get("/api/adapters/hermes_gateway/config-schema");
expect(gateway.status, JSON.stringify(gateway.body)).toBe(200);
expect(gateway.body.fields).toEqual(
expect.arrayContaining([
expect.objectContaining({ key: "apiBaseUrl", required: true }),
expect.objectContaining({ key: "apiKey", required: true }),
]),
);
});
it("GET /api/adapters includes ACPX model availability", async () => {
const app = createApp();
@@ -709,7 +709,7 @@ describe.sequential("agent permission routes", () => {
model: "gpt-5.3-codex-spark",
env: expect.any(Object),
}),
{ strictMode: false },
{ strictMode: false, adapterType: "codex_local" },
);
expect(mockAgentService.update).toHaveBeenCalledWith(
agentId,
@@ -115,6 +115,65 @@ describeEmbeddedPostgres("agent service secret binding sync", () => {
});
});
it("converts Hermes gateway apiKey strings into persisted secret refs", async () => {
const companyId = await seedCompany();
const literalApiKey = `hermes-key-${randomUUID()}`;
const created = await agentService(db).create(companyId, {
name: "Hermes Gateway",
role: "engineer",
status: "idle",
adapterType: "hermes_gateway",
adapterConfig: {
apiBaseUrl: "https://hermes.example",
apiKey: literalApiKey,
},
runtimeConfig: {},
spentMonthlyCents: 0,
lastHeartbeatAt: null,
});
const persistedRows = await db
.select()
.from(agents)
.where(eq(agents.id, created.id));
const persistedConfig = persistedRows[0]?.adapterConfig as Record<string, unknown>;
expect(JSON.stringify(persistedConfig)).not.toContain(literalApiKey);
expect(persistedConfig.apiKey).toMatchObject({
type: "secret_ref",
version: "latest",
});
const secretId = (persistedConfig.apiKey as { secretId: string }).secretId;
const bindings = await db
.select()
.from(companySecretBindings)
.where(and(
eq(companySecretBindings.companyId, companyId),
eq(companySecretBindings.targetType, "agent"),
eq(companySecretBindings.targetId, created.id),
));
expect(bindings).toHaveLength(1);
expect(bindings[0]).toMatchObject({
secretId,
configPath: "apiKey",
versionSelector: "latest",
required: true,
});
const resolved = await secretService(db).resolveAdapterConfigForRuntime(
companyId,
persistedConfig,
{
consumerType: "agent",
consumerId: created.id,
},
{ adapterType: "hermes_gateway" },
);
expect(resolved.config.apiKey).toBe(literalApiKey);
expect(JSON.stringify(persistedConfig)).not.toContain(literalApiKey);
});
it("replaces agent secret bindings when adapterConfig env changes", async () => {
const companyId = await seedCompany();
const secrets = secretService(db);
@@ -73,6 +73,8 @@ async function createIssue(
projectId?: string | null;
parentId?: string | null;
assigneeAgentId?: string | null;
originKind?: string | null;
originId?: string | null;
} = {},
) {
return db
@@ -86,6 +88,8 @@ async function createIssue(
projectId: input.projectId ?? null,
parentId: input.parentId ?? null,
assigneeAgentId: input.assigneeAgentId ?? null,
originKind: input.originKind ?? "manual",
originId: input.originId ?? null,
})
.returning()
.then((rows) => rows[0]!);
@@ -1187,4 +1191,118 @@ describeEmbeddedPostgres("authorization service", () => {
grant: { permissionKey: "tasks:assign" },
});
});
it("scopes task bridge keys away from company-wide reads and unrelated issue writes", async () => {
const company = await createCompany(db, "TaskBridge");
const bridgeAgent = await createAgent(db, company.id);
const targetAgent = await createAgent(db, company.id);
const project = await createProject(db, company.id, "Bridge");
const parentIssue = await createIssue(db, company.id, { projectId: project.id });
const assignedIssue = await createIssue(db, company.id, { assigneeAgentId: bridgeAgent.id });
const keyId = randomUUID();
const bridgeCreatedIssue = await createIssue(db, company.id, {
originKind: "task_bridge",
originId: keyId,
});
const unrelatedIssue = await createIssue(db, company.id);
const actor = {
type: "agent" as const,
agentId: bridgeAgent.id,
companyId: company.id,
source: "agent_key" as const,
keyId,
keyScope: {
kind: "task_bridge" as const,
parentIssueId: parentIssue.id,
allowedAssigneeAgentIds: [targetAgent.id],
},
};
const authz = authorizationService(db);
await expect(authz.decide({
actor,
action: "company_scope:read",
resource: { type: "company", companyId: company.id },
})).resolves.toMatchObject({
allowed: false,
reason: "deny_scope",
});
await expect(authz.decide({
actor,
action: "tasks:assign",
resource: {
type: "issue",
companyId: company.id,
parentIssueId: parentIssue.id,
assigneeAgentId: targetAgent.id,
},
})).resolves.toMatchObject({
allowed: true,
});
await expect(authz.decide({
actor,
action: "tasks:assign",
resource: {
type: "issue",
companyId: company.id,
projectId: project.id,
assigneeUserId: randomUUID(),
},
})).resolves.toMatchObject({
allowed: false,
reason: "deny_scope",
});
await expect(authz.decide({
actor,
action: "issue:comment",
resource: {
type: "issue",
companyId: company.id,
issueId: assignedIssue.id,
},
})).resolves.toMatchObject({
allowed: true,
});
await expect(authz.decide({
actor,
action: "issue:mutate",
resource: {
type: "issue",
companyId: company.id,
issueId: bridgeCreatedIssue.id,
},
})).resolves.toMatchObject({
allowed: true,
});
await expect(authz.decide({
actor,
action: "issue:mutate",
resource: {
type: "issue",
companyId: company.id,
issueId: unrelatedIssue.id,
},
})).resolves.toMatchObject({
allowed: false,
reason: "deny_scope",
});
await expect(authz.decide({
actor,
action: "agent_config:read",
resource: {
type: "agent",
companyId: company.id,
agentId: bridgeAgent.id,
},
})).resolves.toMatchObject({
allowed: false,
reason: "deny_scope",
});
});
});
@@ -12,6 +12,11 @@ describe("awsSecretsManagerProvider", () => {
AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY,
AWS_SESSION_TOKEN: process.env.AWS_SESSION_TOKEN,
AWS_PROFILE: process.env.AWS_PROFILE,
AWS_DEFAULT_PROFILE: process.env.AWS_DEFAULT_PROFILE,
AWS_CONFIG_FILE: process.env.AWS_CONFIG_FILE,
AWS_SHARED_CREDENTIALS_FILE: process.env.AWS_SHARED_CREDENTIALS_FILE,
AWS_SDK_LOAD_CONFIG: process.env.AWS_SDK_LOAD_CONFIG,
};
afterEach(() => {
@@ -169,6 +174,11 @@ describe("awsSecretsManagerProvider", () => {
});
it("signs AWS Secrets Manager JSON requests with default runtime credentials", async () => {
delete process.env.AWS_PROFILE;
delete process.env.AWS_DEFAULT_PROFILE;
delete process.env.AWS_CONFIG_FILE;
delete process.env.AWS_SHARED_CREDENTIALS_FILE;
delete process.env.AWS_SDK_LOAD_CONFIG;
process.env.AWS_ACCESS_KEY_ID = "AKIA_TEST_ACCESS";
process.env.AWS_SECRET_ACCESS_KEY = "test-secret-key";
process.env.AWS_SESSION_TOKEN = "test-session-token";
@@ -1537,7 +1537,7 @@ describe("company portability", () => {
},
},
}),
{ strictMode: false },
{ strictMode: false, adapterType: "codex_local" },
);
expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({
adapterConfig: expect.objectContaining({
@@ -3490,7 +3490,7 @@ describe("company portability", () => {
expect(secretSvc.normalizeAdapterConfigForPersistence).toHaveBeenCalledWith(
"company-imported",
expect.anything(),
{ strictMode: false },
{ strictMode: false, adapterType: "claude_local" },
);
expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
adapterType: "claude_local",
@@ -3559,7 +3559,7 @@ describe("company portability", () => {
model: "gpt-5.4",
extraArgs: ["--skip-git-repo-check"],
}),
{ strictMode: false },
{ strictMode: false, adapterType: "codex_local" },
);
expect(agentSvc.update).toHaveBeenCalledWith("agent-1", expect.objectContaining({
adapterType: "codex_local",
@@ -54,6 +54,11 @@ vi.mock("../adapters/index.js", () => ({
execute: adapterExecute,
supportsLocalAgentJwt: false,
}),
findActiveServerAdapter: () => ({
type: "codex_local",
execute: adapterExecute,
supportsLocalAgentJwt: false,
}),
listAdapterModelProfiles: async () => [],
runningProcesses: new Map(),
}));
@@ -38,6 +38,11 @@ vi.mock("../adapters/index.js", () => ({
execute: adapterExecute,
supportsLocalAgentJwt: false,
}),
findActiveServerAdapter: () => ({
type: "codex_local",
execute: adapterExecute,
supportsLocalAgentJwt: false,
}),
listAdapterModelProfiles: async () => [],
runningProcesses: new Map(),
}));
@@ -1,8 +1,36 @@
import { describe, expect, it } from "vitest";
import { randomUUID } from "node:crypto";
import { mkdirSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { eq } from "drizzle-orm";
import {
companies,
companySecretProviderConfigs,
companySecretVersions,
companySecrets,
createDb,
invites,
joinRequests,
} from "@paperclipai/db";
import {
buildJoinDefaultsPayloadForAccept,
normalizeAgentDefaultsForJoin,
prepareAgentDefaultsPayloadForJoinPersistence,
} from "../routes/access.js";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres invite accept gateway defaults tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describe("buildJoinDefaultsPayloadForAccept (openclaw_gateway)", () => {
it("leaves non-gateway payloads unchanged", () => {
@@ -117,3 +145,162 @@ describe("normalizeAgentDefaultsForJoin (openclaw_gateway)", () => {
expect(normalized.normalized?.devicePrivateKeyPem).toBeUndefined();
});
});
describe("normalizeAgentDefaultsForJoin (hermes_gateway)", () => {
it("rejects remote plain HTTP by default", () => {
const normalized = normalizeAgentDefaultsForJoin({
adapterType: "hermes_gateway",
defaultsPayload: {
apiBaseUrl: "http://192.168.1.25:8642",
apiKey: "hermes-key-1234567890",
},
deploymentMode: "authenticated",
deploymentExposure: "private",
bindHost: "127.0.0.1",
allowedHostnames: [],
});
expect(normalized.fatalErrors.join("\n")).toContain("remote plain HTTP");
expect(normalized.diagnostics).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: "hermes_gateway_plain_http_remote_denied",
}),
]),
);
});
it("allows remote plain HTTP only with the explicit unsafe flag", () => {
const normalized = normalizeAgentDefaultsForJoin({
adapterType: "hermes_gateway",
defaultsPayload: {
apiBaseUrl: "http://192.168.1.25:8642",
apiKey: "hermes-key-1234567890",
dangerouslyAllowInsecureRemoteHttp: true,
},
deploymentMode: "authenticated",
deploymentExposure: "private",
bindHost: "127.0.0.1",
allowedHostnames: [],
});
expect(normalized.fatalErrors).toEqual([]);
expect(normalized.normalized?.apiBaseUrl).toBe("http://192.168.1.25:8642/");
expect(normalized.diagnostics).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: "hermes_gateway_plain_http_remote_unsafe_allowed",
}),
]),
);
});
});
describeEmbeddedPostgres("prepareAgentDefaultsPayloadForJoinPersistence (hermes_gateway)", () => {
let stopDb: (() => Promise<void>) | null = null;
let db!: ReturnType<typeof createDb>;
const previousKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
const secretsTmpDir = path.join(os.tmpdir(), `paperclip-hermes-join-defaults-${randomUUID()}`);
beforeAll(async () => {
mkdirSync(secretsTmpDir, { recursive: true });
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = path.join(secretsTmpDir, "master.key");
const started = await startEmbeddedPostgresTestDatabase("hermes-join-defaults");
stopDb = started.cleanup;
db = createDb(started.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(joinRequests);
await db.delete(invites);
await db.delete(companySecretVersions);
await db.delete(companySecrets);
await db.delete(companySecretProviderConfigs);
await db.delete(companies);
});
afterAll(async () => {
await stopDb?.();
if (previousKeyFile === undefined) {
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
} else {
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = previousKeyFile;
}
rmSync(secretsTmpDir, { recursive: true, force: true });
});
it("stores a secret ref instead of the literal apiKey in join request defaults", async () => {
const companyId = randomUUID();
const inviteId = randomUUID();
const joinRequestId = randomUUID();
const literalApiKey = `hermes-key-${randomUUID()}`;
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(invites).values({
id: inviteId,
companyId,
inviteType: "company_join",
tokenHash: `invite-token-${randomUUID()}`,
allowedJoinTypes: "agent",
defaultsPayload: null,
expiresAt: new Date("2027-03-10T00:00:00.000Z"),
});
const joinDefaults = normalizeAgentDefaultsForJoin({
adapterType: "hermes_gateway",
defaultsPayload: {
apiBaseUrl: "https://hermes.example",
apiKey: literalApiKey,
paperclipApiUrl: "https://paperclip.example",
},
deploymentMode: "authenticated",
deploymentExposure: "private",
bindHost: "127.0.0.1",
allowedHostnames: [],
});
expect(joinDefaults.fatalErrors).toEqual([]);
const persistedDefaults = await prepareAgentDefaultsPayloadForJoinPersistence({
db,
companyId,
adapterType: "hermes_gateway",
normalized: joinDefaults.normalized,
});
await db.insert(joinRequests).values({
id: joinRequestId,
inviteId,
companyId,
requestType: "agent",
status: "pending_approval",
requestIp: "127.0.0.1",
agentName: "Hermes Gateway",
adapterType: "hermes_gateway",
capabilities: "Hermes gateway agent",
agentDefaultsPayload: persistedDefaults,
claimSecretHash: "claim-secret-hash",
claimSecretExpiresAt: new Date("2027-03-11T00:00:00.000Z"),
});
const persistedJoinRequest = await db
.select()
.from(joinRequests)
.where(eq(joinRequests.id, joinRequestId))
.then((rows) => rows[0]);
const storedPayload = persistedJoinRequest?.agentDefaultsPayload as Record<string, unknown>;
expect(JSON.stringify(storedPayload)).not.toContain(literalApiKey);
expect(storedPayload.apiKey).toMatchObject({
type: "secret_ref",
version: "latest",
});
const storedSecrets = await db.select().from(companySecrets);
expect(storedSecrets).toHaveLength(1);
expect((storedPayload.apiKey as { secretId: string }).secretId).toBe(storedSecrets[0]?.id);
});
});
@@ -51,6 +51,21 @@ describe("buildInviteOnboardingTextDocument", () => {
expect(text).toContain('"adapterType": "openclaw_gateway"');
expect(text).toContain("headers.x-openclaw-token");
expect(text).toContain("Do NOT use /v1/responses or /hooks/*");
expect(text).toContain('adapterType: "hermes_gateway"');
expect(text).toContain('"adapterType": "hermes_gateway"');
expect(text).toContain("API_SERVER_ENABLED=true");
expect(text).toContain("API_SERVER_KEY");
expect(text).toContain("hermes gateway run --replace --accept-hooks");
expect(text).toContain("Default Hermes API server port: 8642");
expect(text).toContain("agentDefaultsPayload.apiBaseUrl");
expect(text).toContain("agentDefaultsPayload.paperclipApiUrl");
expect(text).toContain("hermes_local");
expect(text).toContain("Hermes-originated Paperclip API usage");
expect(text).toContain("http://127.0.0.1:8642");
expect(text).toContain("http://192.168.1.25:8642");
expect(text).toContain("tailnet-name.ts.net:8642");
expect(text).toContain("http://host.docker.internal:8642");
expect(text).toContain("https://hermes-gateway.example");
expect(text).toContain("set the first reachable candidate as agentDefaultsPayload.paperclipApiUrl");
expect(text).toContain("PAPERCLIP_API_KEY");
expect(text).toContain("Use your runtime's normal skill or instruction installation path.");
@@ -669,6 +669,22 @@ describe("agent issue mutation checkout ownership", () => {
mockStorageService.deleteObject.mockResolvedValue(undefined);
});
it("denies company-wide issue list routes for task bridge keys", async () => {
const app = await createApp(peerActor({
keyId: "99999999-9999-4999-8999-999999999999",
keyScope: {
kind: "task_bridge",
parentIssueId: issueId,
},
}));
const res = await request(app).get(`/api/companies/${companyId}/issues`);
expect(res.status).toBe(403);
expect(res.body.error).toContain("Task bridge keys cannot use company-wide issue list APIs");
expect(mockIssueService.list).not.toHaveBeenCalled();
});
it("uses the company-scope fast path on the issue list route", async () => {
mockAccessService.decide.mockImplementation(async (input: { action: string }) => {
if (input.action === "company_scope:read") {
+35
View File
@@ -72,4 +72,39 @@ describe("llm routes", () => {
expect(res.text).toContain("Timer heartbeats are opt-in for new hires.");
expect(res.text).toContain("Leave runtimeConfig.heartbeat.enabled false");
});
it("serves static Hermes Gateway configuration docs before the plugin is installed", async () => {
const app = await createApp({
type: "board",
userId: "board-user",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: true,
});
const indexRes = await request(app).get("/api/llms/agent-configuration.txt");
expect(indexRes.status).toBe(200);
expect(indexRes.text).toContain(
"- hermes_gateway: /llms/agent-configuration/hermes_gateway.txt",
);
const res = await request(app).get("/api/llms/agent-configuration/hermes_gateway.txt");
expect(res.status).toBe(200);
expect(res.text).toContain("Adapter: hermes_gateway");
expect(res.text).toContain('adapterType": "hermes_gateway"');
expect(res.text).toContain("API_SERVER_ENABLED=true");
expect(res.text).toContain("API_SERVER_KEY");
expect(res.text).toContain("hermes gateway run --replace --accept-hooks");
expect(res.text).toContain("Default Hermes API server port: 8642");
expect(res.text).toContain("agentDefaultsPayload.apiBaseUrl");
expect(res.text).toContain("agentDefaultsPayload.paperclipApiUrl");
expect(res.text).toContain("hermes_local");
expect(res.text).toContain("Hermes-originated Paperclip API usage");
expect(res.text).toContain("http://127.0.0.1:8642");
expect(res.text).toContain("http://192.168.1.25:8642");
expect(res.text).toContain("tailnet-name.ts.net:8642");
expect(res.text).toContain("http://host.docker.internal:8642");
expect(res.text).toContain("https://hermes-gateway.example");
});
});
@@ -998,6 +998,11 @@ describeEmbeddedPostgres("secretService", () => {
version: 1,
},
}));
const persisted = await svc.getByName(companyId, "Create Rollback");
expect(persisted).toBeNull();
const versions = await db.select().from(companySecretVersions);
expect(versions).toHaveLength(0);
});
it("keeps a local cleanup handle when create rollback cleanup fails", async () => {