Files
PaperClipAI/server/src/__tests__/invite-onboarding-text.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

209 lines
7.8 KiB
TypeScript

import os from "node:os";
import { describe, expect, it } from "vitest";
import { vi } from "vitest";
import type { Request } from "express";
import { buildInviteOnboardingTextDocument } from "../routes/access.js";
function buildReq(host: string): Request {
return {
protocol: "http",
header(name: string) {
if (name.toLowerCase() === "host") return host;
return undefined;
},
} as unknown as Request;
}
describe("buildInviteOnboardingTextDocument", () => {
it("renders a plain-text onboarding doc with expected endpoint references", () => {
const req = buildReq("localhost:3100");
const invite = {
id: "invite-1",
companyId: "company-1",
inviteType: "company_join",
allowedJoinTypes: "agent",
tokenHash: "hash",
defaultsPayload: null,
expiresAt: new Date("2026-03-05T00:00:00.000Z"),
invitedByUserId: null,
revokedAt: null,
acceptedAt: null,
createdAt: new Date("2026-03-04T00:00:00.000Z"),
updatedAt: new Date("2026-03-04T00:00:00.000Z"),
} as const;
const text = buildInviteOnboardingTextDocument(req, "token-123", invite as any, {
deploymentMode: "local_trusted",
deploymentExposure: "private",
bindHost: "127.0.0.1",
allowedHostnames: [],
});
expect(text).toContain("Paperclip Agent Onboarding");
expect(text).toContain("/api/invites/token-123/accept");
expect(text).toContain("/api/join-requests/{requestId}/claim-api-key");
expect(text).toContain("/api/invites/token-123/onboarding.txt");
expect(text).toContain("/api/invites/token-123/skills/paperclip");
expect(text).toContain("Suggested Paperclip base URLs to try");
expect(text).toContain("http://localhost:3100");
expect(text).toContain("host.docker.internal");
expect(text).toContain("paperclipApiUrl");
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("Set agentDefaultsPayload.apiKey to the exact same value as 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://127.0.0.1:9119");
expect(text).toContain("http://127.0.0.1:9119/chat");
expect(text).toContain("map to /api");
expect(text).toContain("/chat and the dashboard root are browser UI routes");
expect(text).toContain("/api/v1/runs");
expect(text).toContain("Do not rotate or invent a Paperclip key manually");
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("Store the parsed token field from the raw HTTP JSON response");
expect(text).toContain("A token value containing literal ... or [redacted]");
expect(text).toContain("The response body includes the full token exactly once");
expect(text).toContain("Do not persist displayed previews containing literal ... or [redacted]");
expect(text).toContain("Use your runtime's normal skill or instruction installation path.");
expect(text).toContain("Decide which Paperclip adapter type matches your runtime.");
});
it("includes loopback diagnostics for authenticated/private onboarding", () => {
const req = buildReq("localhost:3100");
const invite = {
id: "invite-2",
companyId: "company-1",
inviteType: "company_join",
allowedJoinTypes: "both",
tokenHash: "hash",
defaultsPayload: null,
expiresAt: new Date("2026-03-05T00:00:00.000Z"),
invitedByUserId: null,
revokedAt: null,
acceptedAt: null,
createdAt: new Date("2026-03-04T00:00:00.000Z"),
updatedAt: new Date("2026-03-04T00:00:00.000Z"),
} as const;
const text = buildInviteOnboardingTextDocument(req, "token-456", invite as any, {
deploymentMode: "authenticated",
deploymentExposure: "private",
bindHost: "127.0.0.1",
allowedHostnames: [],
});
expect(text).toContain("Connectivity diagnostics");
expect(text).toContain("loopback hostname");
expect(text).toContain("If none are reachable");
});
it("includes inviter message in the onboarding text when provided", () => {
const req = buildReq("localhost:3100");
const invite = {
id: "invite-3",
companyId: "company-1",
inviteType: "company_join",
allowedJoinTypes: "agent",
tokenHash: "hash",
defaultsPayload: {
agentMessage: "Please join as our QA lead and prioritize flaky test triage first.",
},
expiresAt: new Date("2026-03-05T00:00:00.000Z"),
invitedByUserId: null,
revokedAt: null,
acceptedAt: null,
createdAt: new Date("2026-03-04T00:00:00.000Z"),
updatedAt: new Date("2026-03-04T00:00:00.000Z"),
} as const;
const text = buildInviteOnboardingTextDocument(req, "token-789", invite as any, {
deploymentMode: "local_trusted",
deploymentExposure: "private",
bindHost: "127.0.0.1",
allowedHostnames: [],
});
expect(text).toContain("Message from inviter");
expect(text).toContain("prioritize flaky test triage first");
});
it("includes LAN candidates when the advertised host is tailnet-only", () => {
const networkSpy = vi.spyOn(os, "networkInterfaces").mockReturnValue({
en0: [
{
address: "fe80::1",
family: "IPv6",
internal: false,
netmask: "ffff:ffff:ffff:ffff::",
cidr: "fe80::1/64",
mac: "00:00:00:00:00:00",
scopeid: 1,
},
{
address: "192.168.6.178",
family: "IPv4",
internal: false,
netmask: "255.255.252.0",
cidr: "192.168.6.178/22",
mac: "00:00:00:00:00:00",
},
],
utun0: [
{
address: "203.0.113.42",
family: "IPv4",
internal: false,
netmask: "255.255.255.255",
cidr: "203.0.113.42/32",
mac: "00:00:00:00:00:00",
},
],
});
try {
const req = buildReq("paperclip.example.test:3103");
const invite = {
id: "invite-4",
companyId: "company-1",
inviteType: "company_join",
allowedJoinTypes: "agent",
tokenHash: "hash",
defaultsPayload: null,
expiresAt: new Date("2026-03-05T00:00:00.000Z"),
invitedByUserId: null,
revokedAt: null,
acceptedAt: null,
createdAt: new Date("2026-03-04T00:00:00.000Z"),
updatedAt: new Date("2026-03-04T00:00:00.000Z"),
} as const;
const text = buildInviteOnboardingTextDocument(req, "token-999", invite as any, {
deploymentMode: "authenticated",
deploymentExposure: "private",
bindHost: "0.0.0.0",
allowedHostnames: ["paperclip.example.test", "203.0.113.42"],
});
expect(text).toContain("http://192.168.6.178:3103");
expect(text).not.toContain("http://[fe80::1]:3103");
} finally {
networkSpy.mockRestore();
}
});
});