diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index ffbb7fa3..4659bb4b 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -246,7 +246,7 @@ Agent-oriented invite onboarding now exposes machine-readable API docs: - `GET /api/invites/:token` returns invite summary plus onboarding and skills index links. - `GET /api/invites/:token/onboarding` returns onboarding manifest details (registration endpoint, claim endpoint template, skill install hints). -- `GET /api/invites/:token/onboarding.txt` returns a plain-text onboarding doc intended for both human operators and agents (llm.txt-style handoff). +- `GET /api/invites/:token/onboarding.txt` returns a plain-text onboarding doc intended for both human operators and agents (llm.txt-style handoff), including optional inviter message and suggested network host candidates. - `GET /api/skills/index` lists available skill documents. - `GET /api/skills/paperclip` returns the Paperclip heartbeat skill markdown. diff --git a/server/src/__tests__/invite-onboarding-text.test.ts b/server/src/__tests__/invite-onboarding-text.test.ts index f72bb570..0b807c55 100644 --- a/server/src/__tests__/invite-onboarding-text.test.ts +++ b/server/src/__tests__/invite-onboarding-text.test.ts @@ -41,6 +41,9 @@ describe("buildInviteOnboardingTextDocument", () => { 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("Suggested Paperclip base URLs to try"); + expect(text).toContain("http://localhost:3100"); + expect(text).toContain("host.docker.internal"); }); it("includes loopback diagnostics for authenticated/private onboarding", () => { @@ -69,6 +72,7 @@ describe("buildInviteOnboardingTextDocument", () => { 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", () => { diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 340c7d70..59fd2add 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -377,6 +377,46 @@ function buildOnboardingDiscoveryDiagnostics(input: { return diagnostics; } +function buildOnboardingConnectionCandidates(input: { + apiBaseUrl: string; + bindHost: string; + allowedHostnames: string[]; +}): string[] { + let base: URL | null = null; + try { + if (input.apiBaseUrl) { + base = new URL(input.apiBaseUrl); + } + } catch { + base = null; + } + + const protocol = base?.protocol ?? "http:"; + const port = base?.port ? `:${base.port}` : ""; + const candidates = new Set(); + + if (base) { + candidates.add(base.origin); + } + + const bindHost = normalizeHostname(input.bindHost); + if (bindHost && !isLoopbackHost(bindHost)) { + candidates.add(`${protocol}//${bindHost}${port}`); + } + + for (const rawHost of input.allowedHostnames) { + const host = normalizeHostname(rawHost); + if (!host) continue; + candidates.add(`${protocol}//${host}${port}`); + } + + if (base && isLoopbackHost(base.hostname)) { + candidates.add(`${protocol}//host.docker.internal${port}`); + } + + return Array.from(candidates); +} + function buildInviteOnboardingManifest( req: Request, token: string, @@ -402,6 +442,11 @@ function buildInviteOnboardingManifest( bindHost: opts.bindHost, allowedHostnames: opts.allowedHostnames, }); + const connectionCandidates = buildOnboardingConnectionCandidates({ + apiBaseUrl: baseUrl, + bindHost: opts.bindHost, + allowedHostnames: opts.allowedHostnames, + }); return { invite: toInviteSummaryResponse(req, token, invite), @@ -435,6 +480,7 @@ function buildInviteOnboardingManifest( deploymentExposure: opts.deploymentExposure, bindHost: opts.bindHost, allowedHostnames: opts.allowedHostnames, + connectionCandidates, diagnostics: discoveryDiagnostics, guidance: opts.deploymentMode === "authenticated" && opts.deploymentExposure === "private" @@ -474,7 +520,7 @@ export function buildInviteOnboardingTextDocument( claimEndpointTemplate: { method: string; path: string }; textInstructions: { path: string; url: string }; skill: { path: string; url: string; installPath: string }; - connectivity: { diagnostics?: JoinDiagnostic[]; guidance?: string }; + connectivity: { diagnostics?: JoinDiagnostic[]; guidance?: string; connectionCandidates?: string[] }; }; const diagnostics = Array.isArray(onboarding.connectivity?.diagnostics) ? onboarding.connectivity.diagnostics @@ -546,6 +592,27 @@ export function buildInviteOnboardingTextDocument( onboarding.connectivity?.guidance ?? "Ensure Paperclip is reachable from your OpenClaw runtime.", ); + const connectionCandidates = Array.isArray(onboarding.connectivity?.connectionCandidates) + ? onboarding.connectivity.connectionCandidates.filter((entry): entry is string => Boolean(entry)) + : []; + + if (connectionCandidates.length > 0) { + lines.push("", "## Suggested Paperclip base URLs to try"); + for (const candidate of connectionCandidates) { + lines.push(`- ${candidate}`); + } + lines.push( + "", + "Test each candidate with:", + "- GET /api/health", + "", + "If none are reachable: ask your human operator for a reachable hostname/address and help them update network configuration.", + "For authenticated/private mode, they may need:", + "- pnpm paperclipai allowed-hostname ", + "- then restart Paperclip and retry onboarding.", + ); + } + if (diagnostics.length > 0) { lines.push("", "## Connectivity diagnostics"); for (const diag of diagnostics) {