Files
PaperClipAI/server/src/__tests__/invite-accept-gateway-defaults.test.ts
T
DottaandPaperclip d77fab6aae fix(adapters/hermes-gateway): improve onboarding configuration (#8678)
## 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>
2026-06-26 22:11:16 -05:00

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);
});
});