mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Paperclip runs agents through adapters, including built-in local and gateway-style adapters. > - Hermes gateway users need to connect Paperclip to an already-running Hermes API server. > - The gateway setup flow was missing clear non-local adapter configuration fields and accepted fewer URL shapes than operators naturally paste from Hermes. > - It also surfaced sparse diagnostics when the gateway was unreachable or when Paperclip generated onboarding prompts for gateway agents. > - This pull request tightens Hermes gateway configuration, URL normalization, diagnostics, and onboarding defaults. > - The benefit is that Hermes gateway setup is easier to complete and easier to debug without affecting unrelated adapters. ## Linked Issues or Issue Description No public issue found for this exact follow-up. Related prior/in-flight Hermes work: - Refs #2363 - Refs #4359 - Refs #6473 Problem statement: - **Type:** Adapter follow-up / setup reliability - **Adapter:** `hermes_gateway` - **Motivation:** Operators configure `hermes_gateway` against a running Hermes API server, but the UI and onboarding flow did not expose enough gateway-specific configuration or diagnostics. - **Expected behavior:** Paperclip should render the gateway fields, normalize common Hermes dashboard/API URL inputs, preserve sensible gateway onboarding defaults, and report reachability failures with actionable detail. - **Deployment mode:** Built-in adapter package in the Paperclip monorepo. ## What Changed - Added UI config fields for non-local Hermes gateway settings, including tests for rendering and field behavior. - Accepted Hermes dashboard URLs by normalizing them to gateway API URLs for execution. - Improved gateway reachability and run URL diagnostics. - Updated Hermes gateway onboarding text/default behavior so join prompts preserve gateway configuration. - Added focused server, UI, and adapter tests for the gateway configuration and onboarding paths. ## Verification - `pnpm install --frozen-lockfile --prefer-offline` - `pnpm --filter @paperclipai/hermes-paperclip-adapter exec vitest run src/gateway/server/execute.test.ts` — 20 passed - `pnpm exec vitest run server/src/__tests__/invite-accept-gateway-defaults.test.ts server/src/__tests__/invite-onboarding-text.test.ts ui/src/adapters/hermes-gateway/config-fields.test.tsx ui/src/components/AgentConfigForm.render.test.tsx ui/src/lib/agent-onboarding-prompt.test.ts` — 23 passed across 5 files - Confirmed the PR diff excludes `pnpm-lock.yaml` and `.github/workflows`. ## Risks Low to moderate risk. The changes are scoped to Hermes gateway configuration/onboarding and generic non-local adapter field rendering. The main risk is rejecting an unusual Hermes URL shape that should be accepted; the normalization tests cover dashboard and API URL variants added here. > 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-based coding agent in a tool-enabled local CLI environment, with shell/GitHub/Paperclip API access. Exact runtime model identifier was not exposed in this session. ## 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] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
363 lines
12 KiB
TypeScript
363 lines
12 KiB
TypeScript
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", () => {
|
|
const defaultsPayload = { command: "echo hello" };
|
|
const result = buildJoinDefaultsPayloadForAccept({
|
|
adapterType: "process",
|
|
defaultsPayload,
|
|
inboundOpenClawAuthHeader: "ignored-token",
|
|
});
|
|
|
|
expect(result).toEqual(defaultsPayload);
|
|
});
|
|
|
|
it("normalizes wrapped x-openclaw-token header", () => {
|
|
const result = buildJoinDefaultsPayloadForAccept({
|
|
adapterType: "openclaw_gateway",
|
|
defaultsPayload: {
|
|
url: "ws://127.0.0.1:18789",
|
|
headers: {
|
|
"x-openclaw-token": {
|
|
value: "gateway-token-1234567890",
|
|
},
|
|
},
|
|
},
|
|
}) as Record<string, unknown>;
|
|
|
|
expect(result).toMatchObject({
|
|
url: "ws://127.0.0.1:18789",
|
|
headers: {
|
|
"x-openclaw-token": "gateway-token-1234567890",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("accepts inbound x-openclaw-token for gateway joins", () => {
|
|
const result = buildJoinDefaultsPayloadForAccept({
|
|
adapterType: "openclaw_gateway",
|
|
defaultsPayload: {
|
|
url: "ws://127.0.0.1:18789",
|
|
},
|
|
inboundOpenClawTokenHeader: "gateway-token-1234567890",
|
|
}) as Record<string, unknown>;
|
|
|
|
expect(result).toMatchObject({
|
|
headers: {
|
|
"x-openclaw-token": "gateway-token-1234567890",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("derives x-openclaw-token from authorization header", () => {
|
|
const result = buildJoinDefaultsPayloadForAccept({
|
|
adapterType: "openclaw_gateway",
|
|
defaultsPayload: {
|
|
url: "ws://127.0.0.1:18789",
|
|
headers: {
|
|
authorization: "Bearer gateway-token-1234567890",
|
|
},
|
|
},
|
|
}) as Record<string, unknown>;
|
|
|
|
expect(result).toMatchObject({
|
|
headers: {
|
|
authorization: "Bearer gateway-token-1234567890",
|
|
"x-openclaw-token": "gateway-token-1234567890",
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("normalizeAgentDefaultsForJoin (openclaw_gateway)", () => {
|
|
it("generates persistent device key when device auth is enabled", () => {
|
|
const normalized = normalizeAgentDefaultsForJoin({
|
|
adapterType: "openclaw_gateway",
|
|
defaultsPayload: {
|
|
url: "ws://127.0.0.1:18789",
|
|
headers: {
|
|
"x-openclaw-token": "gateway-token-1234567890",
|
|
},
|
|
disableDeviceAuth: false,
|
|
},
|
|
deploymentMode: "authenticated",
|
|
deploymentExposure: "private",
|
|
bindHost: "127.0.0.1",
|
|
allowedHostnames: [],
|
|
});
|
|
|
|
expect(normalized.fatalErrors).toEqual([]);
|
|
expect(normalized.normalized?.disableDeviceAuth).toBe(false);
|
|
expect(typeof normalized.normalized?.devicePrivateKeyPem).toBe("string");
|
|
expect((normalized.normalized?.devicePrivateKeyPem as string).length).toBeGreaterThan(64);
|
|
});
|
|
|
|
it("does not generate device key when disableDeviceAuth=true", () => {
|
|
const normalized = normalizeAgentDefaultsForJoin({
|
|
adapterType: "openclaw_gateway",
|
|
defaultsPayload: {
|
|
url: "ws://127.0.0.1:18789",
|
|
headers: {
|
|
"x-openclaw-token": "gateway-token-1234567890",
|
|
},
|
|
disableDeviceAuth: true,
|
|
},
|
|
deploymentMode: "authenticated",
|
|
deploymentExposure: "private",
|
|
bindHost: "127.0.0.1",
|
|
allowedHostnames: [],
|
|
});
|
|
|
|
expect(normalized.fatalErrors).toEqual([]);
|
|
expect(normalized.normalized?.disableDeviceAuth).toBe(true);
|
|
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",
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("maps the default Hermes dashboard root to the API base path", () => {
|
|
const normalized = normalizeAgentDefaultsForJoin({
|
|
adapterType: "hermes_gateway",
|
|
defaultsPayload: {
|
|
apiBaseUrl: "http://127.0.0.1:9119",
|
|
apiKey: "hermes-key-1234567890",
|
|
},
|
|
deploymentMode: "authenticated",
|
|
deploymentExposure: "private",
|
|
bindHost: "127.0.0.1",
|
|
allowedHostnames: [],
|
|
});
|
|
|
|
expect(normalized.fatalErrors).toEqual([]);
|
|
expect(normalized.normalized?.apiBaseUrl).toBe("http://127.0.0.1:9119/api");
|
|
expect(normalized.diagnostics).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
code: "hermes_gateway_dashboard_root_mapped",
|
|
level: "info",
|
|
hint: expect.stringContaining("/api/v1/runs"),
|
|
}),
|
|
expect.objectContaining({
|
|
code: "hermes_gateway_api_base_url_configured",
|
|
message: "Hermes gateway endpoint set to http://127.0.0.1:9119/api",
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("maps the default Hermes dashboard chat URL to the API base path", () => {
|
|
const normalized = normalizeAgentDefaultsForJoin({
|
|
adapterType: "hermes_gateway",
|
|
defaultsPayload: {
|
|
apiBaseUrl: "http://127.0.0.1:9119/chat",
|
|
apiKey: "hermes-key-1234567890",
|
|
},
|
|
deploymentMode: "authenticated",
|
|
deploymentExposure: "private",
|
|
bindHost: "127.0.0.1",
|
|
allowedHostnames: [],
|
|
});
|
|
|
|
expect(normalized.fatalErrors).toEqual([]);
|
|
expect(normalized.normalized?.apiBaseUrl).toBe("http://127.0.0.1:9119/api");
|
|
expect(normalized.diagnostics).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
code: "hermes_gateway_dashboard_root_mapped",
|
|
level: "info",
|
|
hint: expect.stringContaining("/api/v1/runs"),
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|