mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 13:24:55 +01:00
[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:
1 parent
ef1422c23e
commit
fd2f82ac5b
131 files changed
+10179
-686
No files matched your search
@@ -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", () => {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user