mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 21:49:18 +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>
209 lines
7.8 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|