Add OpenClaw onboarding text endpoint and join smoke harness

This commit is contained in:
Dotta
2026-03-04 16:29:14 -06:00
parent 5bbfddf70d
commit be50daba42
10 changed files with 720 additions and 1 deletions

View File

@@ -0,0 +1,73 @@
import { describe, expect, it } 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 OpenClaw 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");
});
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");
});
});

View File

@@ -293,6 +293,7 @@ function normalizeAgentDefaultsForJoin(input: {
function toInviteSummaryResponse(req: Request, token: string, invite: typeof invites.$inferSelect) {
const baseUrl = requestBaseUrl(req);
const onboardingPath = `/api/invites/${token}/onboarding`;
const onboardingTextPath = `/api/invites/${token}/onboarding.txt`;
return {
id: invite.id,
companyId: invite.companyId,
@@ -301,11 +302,79 @@ function toInviteSummaryResponse(req: Request, token: string, invite: typeof inv
expiresAt: invite.expiresAt,
onboardingPath,
onboardingUrl: baseUrl ? `${baseUrl}${onboardingPath}` : onboardingPath,
onboardingTextPath,
onboardingTextUrl: baseUrl ? `${baseUrl}${onboardingTextPath}` : onboardingTextPath,
skillIndexPath: "/api/skills/index",
skillIndexUrl: baseUrl ? `${baseUrl}/api/skills/index` : "/api/skills/index",
};
}
function buildOnboardingDiscoveryDiagnostics(input: {
apiBaseUrl: string;
deploymentMode: DeploymentMode;
deploymentExposure: DeploymentExposure;
bindHost: string;
allowedHostnames: string[];
}): JoinDiagnostic[] {
const diagnostics: JoinDiagnostic[] = [];
let apiHost: string | null = null;
if (input.apiBaseUrl) {
try {
apiHost = normalizeHostname(new URL(input.apiBaseUrl).hostname);
} catch {
apiHost = null;
}
}
const bindHost = normalizeHostname(input.bindHost);
const allowSet = new Set(
input.allowedHostnames
.map((entry) => normalizeHostname(entry))
.filter((entry): entry is string => Boolean(entry)),
);
if (apiHost && isLoopbackHost(apiHost)) {
diagnostics.push({
code: "openclaw_onboarding_api_loopback",
level: "warn",
message:
"Onboarding URL resolves to loopback hostname. Remote OpenClaw agents cannot reach localhost on your Paperclip host.",
hint: "Use a reachable hostname/IP (for example Tailscale hostname, Docker host alias, or public domain).",
});
}
if (
input.deploymentMode === "authenticated" &&
input.deploymentExposure === "private" &&
(!bindHost || isLoopbackHost(bindHost))
) {
diagnostics.push({
code: "openclaw_onboarding_private_loopback_bind",
level: "warn",
message: "Paperclip is bound to loopback in authenticated/private mode.",
hint: "Run with a reachable bind host or use pnpm dev --tailscale-auth for private-network onboarding.",
});
}
if (
input.deploymentMode === "authenticated" &&
input.deploymentExposure === "private" &&
apiHost &&
!isLoopbackHost(apiHost) &&
allowSet.size > 0 &&
!allowSet.has(apiHost)
) {
diagnostics.push({
code: "openclaw_onboarding_private_host_not_allowed",
level: "warn",
message: `Onboarding host "${apiHost}" is not in allowed hostnames for authenticated/private mode.`,
hint: `Run pnpm paperclipai allowed-hostname ${apiHost}`,
});
}
return diagnostics;
}
function buildInviteOnboardingManifest(
req: Request,
token: string,
@@ -322,6 +391,15 @@ function buildInviteOnboardingManifest(
const skillUrl = baseUrl ? `${baseUrl}${skillPath}` : skillPath;
const registrationEndpointPath = `/api/invites/${token}/accept`;
const registrationEndpointUrl = baseUrl ? `${baseUrl}${registrationEndpointPath}` : registrationEndpointPath;
const onboardingTextPath = `/api/invites/${token}/onboarding.txt`;
const onboardingTextUrl = baseUrl ? `${baseUrl}${onboardingTextPath}` : onboardingTextPath;
const discoveryDiagnostics = buildOnboardingDiscoveryDiagnostics({
apiBaseUrl: baseUrl,
deploymentMode: opts.deploymentMode,
deploymentExposure: opts.deploymentExposure,
bindHost: opts.bindHost,
allowedHostnames: opts.allowedHostnames,
});
return {
invite: toInviteSummaryResponse(req, token, invite),
@@ -354,11 +432,17 @@ function buildInviteOnboardingManifest(
deploymentExposure: opts.deploymentExposure,
bindHost: opts.bindHost,
allowedHostnames: opts.allowedHostnames,
diagnostics: discoveryDiagnostics,
guidance:
opts.deploymentMode === "authenticated" && opts.deploymentExposure === "private"
? "If OpenClaw runs on another machine, ensure the Paperclip hostname is reachable and allowed via `pnpm paperclipai allowed-hostname <host>`."
: "Ensure OpenClaw can reach this Paperclip API base URL for callbacks and claims.",
},
textInstructions: {
path: onboardingTextPath,
url: onboardingTextUrl,
contentType: "text/plain",
},
skill: {
name: "paperclip",
path: skillPath,
@@ -369,6 +453,108 @@ function buildInviteOnboardingManifest(
};
}
export function buildInviteOnboardingTextDocument(
req: Request,
token: string,
invite: typeof invites.$inferSelect,
opts: {
deploymentMode: DeploymentMode;
deploymentExposure: DeploymentExposure;
bindHost: string;
allowedHostnames: string[];
},
) {
const manifest = buildInviteOnboardingManifest(req, token, invite, opts);
const onboarding = manifest.onboarding as {
registrationEndpoint: { method: string; path: string; url: string };
claimEndpointTemplate: { method: string; path: string };
textInstructions: { path: string; url: string };
skill: { path: string; url: string; installPath: string };
connectivity: { diagnostics?: JoinDiagnostic[]; guidance?: string };
};
const diagnostics = Array.isArray(onboarding.connectivity?.diagnostics)
? onboarding.connectivity.diagnostics
: [];
const lines = [
"# Paperclip OpenClaw Onboarding",
"",
"This document is meant to be readable by both humans and agents.",
"",
"## Invite",
`- inviteType: ${invite.inviteType}`,
`- allowedJoinTypes: ${invite.allowedJoinTypes}`,
`- expiresAt: ${invite.expiresAt.toISOString()}`,
"",
"## Step 1: Submit agent join request",
`${onboarding.registrationEndpoint.method} ${onboarding.registrationEndpoint.url}`,
"",
"Body (JSON):",
"{",
' "requestType": "agent",',
' "agentName": "My OpenClaw Agent",',
' "adapterType": "openclaw",',
' "capabilities": "Optional summary",',
' "agentDefaultsPayload": {',
' "url": "https://your-openclaw-webhook.example/webhook",',
' "method": "POST",',
' "headers": { "x-openclaw-auth": "replace-me" },',
' "timeoutSec": 30',
" }",
"}",
"",
"Expected response includes:",
"- request id",
"- one-time claimSecret",
"- claimApiKeyPath",
"",
"## Step 2: Wait for board approval",
"The board approves the join request in Paperclip before key claim is allowed.",
"",
"## Step 3: Claim API key (one-time)",
`${onboarding.claimEndpointTemplate.method} /api/join-requests/{requestId}/claim-api-key`,
"",
"Body (JSON):",
"{",
' "claimSecret": "<one-time-claim-secret>"',
"}",
"",
"Important:",
"- claim secrets expire",
"- claim secrets are single-use",
"- claim fails before board approval",
"",
"## Step 4: Install Paperclip skill in OpenClaw",
`GET ${onboarding.skill.url}`,
`Install path: ${onboarding.skill.installPath}`,
"",
"## Text onboarding URL",
`${onboarding.textInstructions.url}`,
"",
"## Connectivity guidance",
onboarding.connectivity?.guidance ?? "Ensure Paperclip is reachable from your OpenClaw runtime.",
];
if (diagnostics.length > 0) {
lines.push("", "## Connectivity diagnostics");
for (const diag of diagnostics) {
lines.push(`- [${diag.level}] ${diag.message}`);
if (diag.hint) lines.push(` hint: ${diag.hint}`);
}
}
lines.push(
"",
"## Helpful endpoints",
`${onboarding.registrationEndpoint.path}`,
`${onboarding.claimEndpointTemplate.path}`,
`${onboarding.skill.path}`,
manifest.invite.onboardingPath,
);
return `${lines.join("\n")}\n`;
}
function requestIp(req: Request) {
const forwarded = req.header("x-forwarded-for");
if (forwarded) {
@@ -586,6 +772,21 @@ export function accessRoutes(
res.json(buildInviteOnboardingManifest(req, token, invite, opts));
});
router.get("/invites/:token/onboarding.txt", async (req, res) => {
const token = (req.params.token as string).trim();
if (!token) throw notFound("Invite not found");
const invite = await db
.select()
.from(invites)
.where(eq(invites.tokenHash, hashToken(token)))
.then((rows) => rows[0] ?? null);
if (!invite || invite.revokedAt || inviteExpired(invite)) {
throw notFound("Invite not found");
}
res.type("text/plain; charset=utf-8").send(buildInviteOnboardingTextDocument(req, token, invite, opts));
});
router.post("/invites/:token/accept", validate(acceptInviteSchema), async (req, res) => {
const token = (req.params.token as string).trim();
if (!token) throw notFound("Invite not found");