diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 6b25e6eb..320c2f4c 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -216,5 +216,26 @@ 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/skills/index` lists available skill documents. - `GET /api/skills/paperclip` returns the Paperclip heartbeat skill markdown. + +## OpenClaw Join Smoke Test + +Run the end-to-end OpenClaw join smoke harness: + +```sh +pnpm smoke:openclaw-join +``` + +What it validates: + +- invite creation for agent-only join +- agent join request using `adapterType=openclaw` +- board approval + one-time API key claim semantics +- callback delivery on wakeup to a dockerized OpenClaw-style webhook receiver + +Optional auth flags (for authenticated mode): + +- `PAPERCLIP_AUTH_HEADER` (for example `Bearer ...`) +- `PAPERCLIP_COOKIE` (session cookie header value) diff --git a/docker/openclaw-smoke/Dockerfile b/docker/openclaw-smoke/Dockerfile new file mode 100644 index 00000000..abdbaf14 --- /dev/null +++ b/docker/openclaw-smoke/Dockerfile @@ -0,0 +1,8 @@ +FROM node:22-alpine + +WORKDIR /app +COPY server.mjs /app/server.mjs + +EXPOSE 8787 + +CMD ["node", "/app/server.mjs"] diff --git a/docker/openclaw-smoke/server.mjs b/docker/openclaw-smoke/server.mjs new file mode 100644 index 00000000..d80f7391 --- /dev/null +++ b/docker/openclaw-smoke/server.mjs @@ -0,0 +1,103 @@ +import http from "node:http"; + +const port = Number.parseInt(process.env.PORT ?? "8787", 10); +const webhookPath = process.env.OPENCLAW_SMOKE_PATH?.trim() || "/webhook"; +const expectedAuthHeader = process.env.OPENCLAW_SMOKE_AUTH?.trim() || ""; +const maxBodyBytes = 1_000_000; +const maxEvents = 200; + +const events = []; +let nextId = 1; + +function writeJson(res, status, payload) { + res.statusCode = status; + res.setHeader("content-type", "application/json; charset=utf-8"); + res.end(JSON.stringify(payload)); +} + +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + let total = 0; + req.on("data", (chunk) => { + total += chunk.length; + if (total > maxBodyBytes) { + reject(new Error("payload_too_large")); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on("end", () => { + resolve(Buffer.concat(chunks).toString("utf8")); + }); + req.on("error", reject); + }); +} + +function trimEvents() { + if (events.length <= maxEvents) return; + events.splice(0, events.length - maxEvents); +} + +const server = http.createServer(async (req, res) => { + const method = req.method ?? "GET"; + const url = req.url ?? "/"; + + if (method === "GET" && url === "/health") { + writeJson(res, 200, { ok: true, webhookPath, events: events.length }); + return; + } + + if (method === "GET" && url === "/events") { + writeJson(res, 200, { count: events.length, events }); + return; + } + + if (method === "POST" && url === "/reset") { + events.length = 0; + writeJson(res, 200, { ok: true }); + return; + } + + if (method === "POST" && url === webhookPath) { + const authorization = req.headers.authorization ?? ""; + if (expectedAuthHeader && authorization !== expectedAuthHeader) { + writeJson(res, 401, { error: "unauthorized" }); + return; + } + + try { + const raw = await readBody(req); + let body = null; + try { + body = raw.length > 0 ? JSON.parse(raw) : null; + } catch { + body = { raw }; + } + + const event = { + id: `evt-${nextId++}`, + receivedAt: new Date().toISOString(), + method, + path: url, + authorizationPresent: Boolean(authorization), + body, + }; + events.push(event); + trimEvents(); + writeJson(res, 200, { ok: true, received: true, eventId: event.id, count: events.length }); + } catch (err) { + const code = err instanceof Error && err.message === "payload_too_large" ? 413 : 500; + writeJson(res, code, { error: err instanceof Error ? err.message : "unknown_error" }); + } + return; + } + + writeJson(res, 404, { error: "not_found" }); +}); + +server.listen(port, "0.0.0.0", () => { + // eslint-disable-next-line no-console + console.log(`[openclaw-smoke] listening on :${port} path=${webhookPath}`); +}); diff --git a/docs/guides/openclaw-docker-setup.md b/docs/guides/openclaw-docker-setup.md index 1c1539c3..c816bd66 100644 --- a/docs/guides/openclaw-docker-setup.md +++ b/docs/guides/openclaw-docker-setup.md @@ -2,6 +2,44 @@ How to get OpenClaw running in a Docker container for local development and testing the Paperclip OpenClaw adapter integration. +## Automated Join Smoke Test (Recommended First) + +Paperclip includes an end-to-end join smoke harness: + +```bash +pnpm smoke:openclaw-join +``` + +The harness automates: + +- invite creation (`allowedJoinTypes=agent`) +- OpenClaw agent join request (`adapterType=openclaw`) +- board approval +- one-time API key claim (including invalid/replay claim checks) +- wakeup callback delivery to a dockerized OpenClaw-style webhook receiver + +By default, this uses a preconfigured Docker receiver image (`docker/openclaw-smoke`) so the run is deterministic and requires no manual OpenClaw config edits. + +### Authenticated mode + +If your Paperclip deployment is `authenticated`, provide auth context: + +```bash +PAPERCLIP_AUTH_HEADER="Bearer " pnpm smoke:openclaw-join +# or +PAPERCLIP_COOKIE="your_session_cookie=..." pnpm smoke:openclaw-join +``` + +### Network topology tips + +- Local same-host smoke: default callback uses `http://127.0.0.1:/webhook`. +- Docker/remote OpenClaw: prefer a reachable hostname (Docker host alias, Tailscale hostname, or public domain). +- Authenticated/private mode: ensure hostnames are in the allowed list when required: + +```bash +pnpm paperclipai allowed-hostname +``` + ## Prerequisites - **Docker Desktop v29+** (with Docker Sandbox support) diff --git a/package.json b/package.json index 550ff5df..f4c1ab98 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "changeset": "changeset", "version-packages": "changeset version", "check:tokens": "node scripts/check-forbidden-tokens.mjs", - "docs:dev": "cd docs && npx mintlify dev" + "docs:dev": "cd docs && npx mintlify dev", + "smoke:openclaw-join": "./scripts/smoke/openclaw-join.sh" }, "devDependencies": { "@changesets/cli": "^2.30.0", diff --git a/scripts/smoke/openclaw-join.sh b/scripts/smoke/openclaw-join.sh new file mode 100755 index 00000000..b073f2c3 --- /dev/null +++ b/scripts/smoke/openclaw-join.sh @@ -0,0 +1,263 @@ +#!/usr/bin/env bash +set -euo pipefail + +if ! command -v curl >/dev/null 2>&1; then + echo "curl is required" >&2 + exit 1 +fi +if ! command -v jq >/dev/null 2>&1; then + echo "jq is required" >&2 + exit 1 +fi + +PAPERCLIP_API_URL="${PAPERCLIP_API_URL:-http://localhost:3100}" +API_BASE="${PAPERCLIP_API_URL%/}/api" +COMPANY_ID="${COMPANY_ID:-${PAPERCLIP_COMPANY_ID:-}}" +OPENCLAW_AGENT_NAME="${OPENCLAW_AGENT_NAME:-OpenClaw Smoke Agent}" +OPENCLAW_WEBHOOK_URL="${OPENCLAW_WEBHOOK_URL:-}" +OPENCLAW_WEBHOOK_AUTH="${OPENCLAW_WEBHOOK_AUTH:-Bearer openclaw-smoke-secret}" +USE_DOCKER_RECEIVER="${USE_DOCKER_RECEIVER:-1}" +SMOKE_IMAGE="${SMOKE_IMAGE:-paperclip-openclaw-smoke:local}" +SMOKE_CONTAINER_NAME="${SMOKE_CONTAINER_NAME:-paperclip-openclaw-smoke}" +SMOKE_PORT="${SMOKE_PORT:-19091}" +SMOKE_TIMEOUT_SEC="${SMOKE_TIMEOUT_SEC:-45}" + +AUTH_HEADERS=() +if [[ -n "${PAPERCLIP_AUTH_HEADER:-}" ]]; then + AUTH_HEADERS+=(-H "Authorization: ${PAPERCLIP_AUTH_HEADER}") +fi +if [[ -n "${PAPERCLIP_COOKIE:-}" ]]; then + AUTH_HEADERS+=(-H "Cookie: ${PAPERCLIP_COOKIE}") +fi + +STARTED_CONTAINER=0 +RESPONSE_CODE="" +RESPONSE_BODY="" + +log() { + echo "[openclaw-smoke] $*" +} + +fail() { + echo "[openclaw-smoke] ERROR: $*" >&2 + exit 1 +} + +cleanup() { + if [[ "$STARTED_CONTAINER" == "1" ]]; then + docker rm -f "$SMOKE_CONTAINER_NAME" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +api_request() { + local method="$1" + local path="$2" + local data="${3-}" + local tmp + tmp="$(mktemp)" + local url + if [[ "$path" == http://* || "$path" == https://* ]]; then + url="$path" + elif [[ "$path" == /api/* ]]; then + url="${PAPERCLIP_API_URL%/}${path}" + else + url="${API_BASE}${path}" + fi + + if [[ -n "$data" ]]; then + RESPONSE_CODE="$(curl -sS -o "$tmp" -w "%{http_code}" -X "$method" "${AUTH_HEADERS[@]}" -H "Content-Type: application/json" "$url" --data "$data")" + else + RESPONSE_CODE="$(curl -sS -o "$tmp" -w "%{http_code}" -X "$method" "${AUTH_HEADERS[@]}" "$url")" + fi + RESPONSE_BODY="$(cat "$tmp")" + rm -f "$tmp" +} + +assert_status() { + local expected="$1" + if [[ "$RESPONSE_CODE" != "$expected" ]]; then + echo "$RESPONSE_BODY" >&2 + fail "expected HTTP $expected, got HTTP $RESPONSE_CODE" + fi +} + +assert_json_has_string() { + local jq_expr="$1" + local value + value="$(jq -r "$jq_expr // empty" <<<"$RESPONSE_BODY")" + if [[ -z "$value" ]]; then + echo "$RESPONSE_BODY" >&2 + fail "expected JSON string at: $jq_expr" + fi + echo "$value" +} + +if [[ "$USE_DOCKER_RECEIVER" == "1" && -z "$OPENCLAW_WEBHOOK_URL" ]]; then + if ! command -v docker >/dev/null 2>&1; then + fail "docker is required when USE_DOCKER_RECEIVER=1" + fi + log "building dockerized OpenClaw webhook receiver image" + docker build -t "$SMOKE_IMAGE" -f docker/openclaw-smoke/Dockerfile docker/openclaw-smoke >/dev/null + docker rm -f "$SMOKE_CONTAINER_NAME" >/dev/null 2>&1 || true + + log "starting dockerized OpenClaw webhook receiver" + docker run -d \ + --name "$SMOKE_CONTAINER_NAME" \ + -p "${SMOKE_PORT}:8787" \ + -e "OPENCLAW_SMOKE_AUTH=${OPENCLAW_WEBHOOK_AUTH}" \ + "$SMOKE_IMAGE" >/dev/null + STARTED_CONTAINER=1 + OPENCLAW_WEBHOOK_URL="http://127.0.0.1:${SMOKE_PORT}/webhook" + + for _ in $(seq 1 30); do + code="$(curl -sS -o /dev/null -w "%{http_code}" "http://127.0.0.1:${SMOKE_PORT}/health" || true)" + if [[ "$code" == "200" ]]; then + break + fi + sleep 1 + done + code="$(curl -sS -o /dev/null -w "%{http_code}" "http://127.0.0.1:${SMOKE_PORT}/health" || true)" + if [[ "$code" != "200" ]]; then + fail "webhook receiver failed health check on port ${SMOKE_PORT}" + fi +fi + +if [[ -z "$OPENCLAW_WEBHOOK_URL" ]]; then + fail "OPENCLAW_WEBHOOK_URL must be set when USE_DOCKER_RECEIVER=0" +fi + +log "checking Paperclip health" +api_request "GET" "/health" +assert_status "200" +DEPLOYMENT_MODE="$(jq -r '.deploymentMode // "unknown"' <<<"$RESPONSE_BODY")" +DEPLOYMENT_EXPOSURE="$(jq -r '.deploymentExposure // "unknown"' <<<"$RESPONSE_BODY")" +log "deployment mode=${DEPLOYMENT_MODE} exposure=${DEPLOYMENT_EXPOSURE}" + +if [[ -z "$COMPANY_ID" ]]; then + log "resolving company id" + api_request "GET" "/companies" + assert_status "200" + COMPANY_ID="$(jq -r '.[0].id // empty' <<<"$RESPONSE_BODY")" + if [[ -z "$COMPANY_ID" ]]; then + fail "no companies found; create one before running smoke test" + fi +fi + +log "creating agent-only invite for company ${COMPANY_ID}" +INVITE_PAYLOAD="$(jq -nc '{allowedJoinTypes:"agent",expiresInHours:24}')" +api_request "POST" "/companies/${COMPANY_ID}/invites" "$INVITE_PAYLOAD" +assert_status "201" +INVITE_TOKEN="$(assert_json_has_string '.token')" +INVITE_ID="$(assert_json_has_string '.id')" +log "created invite ${INVITE_ID}" + +log "verifying onboarding JSON and text endpoints" +api_request "GET" "/invites/${INVITE_TOKEN}/onboarding" +assert_status "200" +ONBOARDING_TEXT_PATH="$(jq -r '.invite.onboardingTextPath // empty' <<<"$RESPONSE_BODY")" +if [[ -z "$ONBOARDING_TEXT_PATH" ]]; then + fail "onboarding manifest missing invite.onboardingTextPath" +fi +api_request "GET" "/invites/${INVITE_TOKEN}/onboarding.txt" +assert_status "200" +if ! grep -q "Paperclip OpenClaw Onboarding" <<<"$RESPONSE_BODY"; then + fail "onboarding.txt response missing expected header" +fi + +log "submitting OpenClaw agent join request" +JOIN_PAYLOAD="$(jq -nc \ + --arg name "$OPENCLAW_AGENT_NAME" \ + --arg url "$OPENCLAW_WEBHOOK_URL" \ + --arg auth "$OPENCLAW_WEBHOOK_AUTH" \ + '{ + requestType: "agent", + agentName: $name, + adapterType: "openclaw", + capabilities: "Automated OpenClaw smoke harness", + agentDefaultsPayload: ( + { url: $url, method: "POST", timeoutSec: 30 } + + (if ($auth | length) > 0 then { webhookAuthHeader: $auth } else {} end) + ) + }')" +api_request "POST" "/invites/${INVITE_TOKEN}/accept" "$JOIN_PAYLOAD" +assert_status "202" +JOIN_REQUEST_ID="$(assert_json_has_string '.id')" +CLAIM_SECRET="$(assert_json_has_string '.claimSecret')" +CLAIM_API_PATH="$(assert_json_has_string '.claimApiKeyPath')" +DIAGNOSTICS_JSON="$(jq -c '.diagnostics // []' <<<"$RESPONSE_BODY")" +if [[ "$DIAGNOSTICS_JSON" != "[]" ]]; then + log "join diagnostics: ${DIAGNOSTICS_JSON}" +fi + +log "approving join request ${JOIN_REQUEST_ID}" +api_request "POST" "/companies/${COMPANY_ID}/join-requests/${JOIN_REQUEST_ID}/approve" "{}" +assert_status "200" +CREATED_AGENT_ID="$(assert_json_has_string '.createdAgentId')" + +log "verifying invalid claim secret is rejected" +api_request "POST" "/join-requests/${JOIN_REQUEST_ID}/claim-api-key" '{"claimSecret":"invalid-smoke-secret-value"}' +if [[ "$RESPONSE_CODE" == "201" ]]; then + fail "invalid claim secret unexpectedly succeeded" +fi + +log "claiming API key with one-time claim secret" +CLAIM_PAYLOAD="$(jq -nc --arg secret "$CLAIM_SECRET" '{claimSecret:$secret}')" +api_request "POST" "$CLAIM_API_PATH" "$CLAIM_PAYLOAD" +assert_status "201" +AGENT_API_KEY="$(assert_json_has_string '.token')" +KEY_ID="$(assert_json_has_string '.keyId')" + +log "verifying replay claim is rejected" +api_request "POST" "$CLAIM_API_PATH" "$CLAIM_PAYLOAD" +if [[ "$RESPONSE_CODE" == "201" ]]; then + fail "claim secret replay unexpectedly succeeded" +fi + +if [[ "$USE_DOCKER_RECEIVER" == "1" && "$STARTED_CONTAINER" == "1" ]]; then + curl -sS -X POST "http://127.0.0.1:${SMOKE_PORT}/reset" >/dev/null +fi + +log "triggering wakeup for newly created OpenClaw agent" +WAKE_PAYLOAD='{"source":"on_demand","triggerDetail":"manual","reason":"openclaw_smoke"}' +api_request "POST" "/agents/${CREATED_AGENT_ID}/wakeup" "$WAKE_PAYLOAD" +assert_status "202" +RUN_ID="$(jq -r '.id // empty' <<<"$RESPONSE_BODY")" +if [[ -z "$RUN_ID" ]]; then + log "wakeup response: ${RESPONSE_BODY}" +fi + +log "waiting for webhook callback" +FOUND_EVENT="0" +LAST_EVENTS='{"count":0,"events":[]}' +for _ in $(seq 1 "$SMOKE_TIMEOUT_SEC"); do + if [[ "$USE_DOCKER_RECEIVER" == "1" && "$STARTED_CONTAINER" == "1" ]]; then + LAST_EVENTS="$(curl -sS "http://127.0.0.1:${SMOKE_PORT}/events")" + else + break + fi + MATCH_COUNT="$(jq -r --arg agentId "$CREATED_AGENT_ID" '[.events[] | select(((.body.paperclip.agentId // "") == $agentId))] | length' <<<"$LAST_EVENTS")" + if [[ "$MATCH_COUNT" -gt 0 ]]; then + FOUND_EVENT="1" + break + fi + sleep 1 +done + +if [[ "$USE_DOCKER_RECEIVER" == "1" && "$STARTED_CONTAINER" == "1" && "$FOUND_EVENT" != "1" ]]; then + echo "$LAST_EVENTS" | jq '.' >&2 + fail "did not observe OpenClaw webhook callback within ${SMOKE_TIMEOUT_SEC}s" +fi + +log "success" +log "companyId=${COMPANY_ID}" +log "inviteId=${INVITE_ID}" +log "joinRequestId=${JOIN_REQUEST_ID}" +log "agentId=${CREATED_AGENT_ID}" +log "keyId=${KEY_ID}" +if [[ -n "$RUN_ID" ]]; then + log "runId=${RUN_ID}" +fi +if [[ -n "$AGENT_API_KEY" ]]; then + log "agentApiKeyPrefix=${AGENT_API_KEY:0:12}..." +fi diff --git a/server/src/__tests__/invite-onboarding-text.test.ts b/server/src/__tests__/invite-onboarding-text.test.ts new file mode 100644 index 00000000..10ed81e7 --- /dev/null +++ b/server/src/__tests__/invite-onboarding-text.test.ts @@ -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"); + }); +}); diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 158dd2df..27e659e4 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -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 `." : "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": ""', + "}", + "", + "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"); diff --git a/ui/src/api/access.ts b/ui/src/api/access.ts index 5bc9d614..6b66a0f1 100644 --- a/ui/src/api/access.ts +++ b/ui/src/api/access.ts @@ -9,6 +9,8 @@ type InviteSummary = { expiresAt: string; onboardingPath?: string; onboardingUrl?: string; + onboardingTextPath?: string; + onboardingTextUrl?: string; skillIndexPath?: string; skillIndexUrl?: string; }; diff --git a/ui/src/pages/InviteLanding.tsx b/ui/src/pages/InviteLanding.tsx index 07df8fe5..10be6787 100644 --- a/ui/src/pages/InviteLanding.tsx +++ b/ui/src/pages/InviteLanding.tsx @@ -169,6 +169,8 @@ export function InviteLandingPage() { const onboardingSkillUrl = readNestedString(payload.onboarding, ["skill", "url"]); const onboardingSkillPath = readNestedString(payload.onboarding, ["skill", "path"]); const onboardingInstallPath = readNestedString(payload.onboarding, ["skill", "installPath"]); + const onboardingTextUrl = readNestedString(payload.onboarding, ["textInstructions", "url"]); + const onboardingTextPath = readNestedString(payload.onboarding, ["textInstructions", "path"]); const diagnostics = Array.isArray(payload.diagnostics) ? payload.diagnostics : []; return (
@@ -195,6 +197,13 @@ export function InviteLandingPage() { {onboardingInstallPath &&

Install to {onboardingInstallPath}

}
)} + {(onboardingTextUrl || onboardingTextPath) && ( +
+

Agent-readable onboarding text

+ {onboardingTextUrl &&

GET {onboardingTextUrl}

} + {!onboardingTextUrl && onboardingTextPath &&

GET {onboardingTextPath}

} +
+ )} {diagnostics.length > 0 && (

Connectivity diagnostics