From b213eb695be533f4112dd071651c703b4f553b0a Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 6 Mar 2026 08:39:29 -0600 Subject: [PATCH] Add OpenClaw Paperclip API URL override for onboarding --- packages/adapters/openclaw/src/index.ts | 1 + .../adapters/openclaw/src/server/execute.ts | 16 +++++++ .../__tests__/invite-onboarding-text.test.ts | 2 + server/src/__tests__/openclaw-adapter.test.ts | 25 +++++++++++ server/src/routes/access.ts | 42 ++++++++++++++++++- ui/src/adapters/openclaw/config-fields.tsx | 16 +++++++ 6 files changed, 101 insertions(+), 1 deletion(-) diff --git a/packages/adapters/openclaw/src/index.ts b/packages/adapters/openclaw/src/index.ts index 96e70a1d..61da17d6 100644 --- a/packages/adapters/openclaw/src/index.ts +++ b/packages/adapters/openclaw/src/index.ts @@ -22,6 +22,7 @@ Core fields: - headers (object, optional): extra HTTP headers for requests - webhookAuthHeader (string, optional): Authorization header value if your endpoint requires auth - payloadTemplate (object, optional): additional JSON payload fields merged into each wake payload +- paperclipApiUrl (string, optional): absolute http(s) Paperclip base URL to advertise to OpenClaw as \`PAPERCLIP_API_URL\` Session routing fields: - sessionKeyStrategy (string, optional): \`fixed\` (default), \`issue\`, or \`run\` diff --git a/packages/adapters/openclaw/src/server/execute.ts b/packages/adapters/openclaw/src/server/execute.ts index f3f2ee44..763c2ff3 100644 --- a/packages/adapters/openclaw/src/server/execute.ts +++ b/packages/adapters/openclaw/src/server/execute.ts @@ -8,6 +8,18 @@ function nonEmpty(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } +function resolvePaperclipApiUrlOverride(value: unknown): string | null { + const raw = nonEmpty(value); + if (!raw) return null; + try { + const parsed = new URL(raw); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; + return parsed.toString(); + } catch { + return null; + } +} + function normalizeSessionKeyStrategy(value: unknown): SessionKeyStrategy { const normalized = asString(value, "fixed").trim().toLowerCase(); if (normalized === "issue" || normalized === "run") return normalized; @@ -516,10 +528,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise = { ...buildPaperclipEnv(agent), PAPERCLIP_RUN_ID: runId, }; + if (paperclipApiUrlOverride) { + paperclipEnv.PAPERCLIP_API_URL = paperclipApiUrlOverride; + } if (wakePayload.taskId) paperclipEnv.PAPERCLIP_TASK_ID = wakePayload.taskId; if (wakePayload.wakeReason) paperclipEnv.PAPERCLIP_WAKE_REASON = wakePayload.wakeReason; if (wakePayload.wakeCommentId) paperclipEnv.PAPERCLIP_WAKE_COMMENT_ID = wakePayload.wakeCommentId; diff --git a/server/src/__tests__/invite-onboarding-text.test.ts b/server/src/__tests__/invite-onboarding-text.test.ts index 5bef277b..5f1f8db5 100644 --- a/server/src/__tests__/invite-onboarding-text.test.ts +++ b/server/src/__tests__/invite-onboarding-text.test.ts @@ -45,6 +45,8 @@ describe("buildInviteOnboardingTextDocument", () => { 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("set the first reachable candidate as agentDefaultsPayload.paperclipApiUrl"); }); it("includes loopback diagnostics for authenticated/private onboarding", () => { diff --git a/server/src/__tests__/openclaw-adapter.test.ts b/server/src/__tests__/openclaw-adapter.test.ts index d55942f3..9dc6924d 100644 --- a/server/src/__tests__/openclaw-adapter.test.ts +++ b/server/src/__tests__/openclaw-adapter.test.ts @@ -198,6 +198,31 @@ describe("openclaw adapter execute", () => { expect(text).toContain("PAPERCLIP_LINKED_ISSUE_IDS=issue-123"); }); + it("uses paperclipApiUrl override when provided", async () => { + const fetchMock = vi.fn().mockResolvedValue( + sseResponse([ + "event: response.completed\n", + 'data: {"type":"response.completed","status":"completed"}\n\n', + ]), + ); + vi.stubGlobal("fetch", fetchMock); + + const result = await execute( + buildContext({ + url: "https://agent.example/sse", + method: "POST", + paperclipApiUrl: "http://dotta-macbook-pro:3100", + }), + ); + + expect(result.exitCode).toBe(0); + const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; + const paperclip = body.paperclip as Record; + const env = paperclip.env as Record; + expect(env.PAPERCLIP_API_URL).toBe("http://dotta-macbook-pro:3100/"); + expect(String(body.text ?? "")).toContain("PAPERCLIP_API_URL=http://dotta-macbook-pro:3100/"); + }); + it("derives issue session keys when configured", async () => { const fetchMock = vi.fn().mockResolvedValue( sseResponse([ diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index a9691141..8755ebea 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -300,6 +300,44 @@ function normalizeAgentDefaultsForJoin(input: { normalized.payloadTemplate = defaults.payloadTemplate; } + const rawPaperclipApiUrl = typeof defaults.paperclipApiUrl === "string" + ? defaults.paperclipApiUrl.trim() + : ""; + if (rawPaperclipApiUrl) { + try { + const parsedPaperclipApiUrl = new URL(rawPaperclipApiUrl); + if (parsedPaperclipApiUrl.protocol !== "http:" && parsedPaperclipApiUrl.protocol !== "https:") { + diagnostics.push({ + code: "openclaw_paperclip_api_url_protocol", + level: "warn", + message: `paperclipApiUrl must use http:// or https:// (got ${parsedPaperclipApiUrl.protocol}).`, + }); + } else { + normalized.paperclipApiUrl = parsedPaperclipApiUrl.toString(); + diagnostics.push({ + code: "openclaw_paperclip_api_url_configured", + level: "info", + message: `paperclipApiUrl set to ${parsedPaperclipApiUrl.toString()}`, + }); + if (isLoopbackHost(parsedPaperclipApiUrl.hostname)) { + diagnostics.push({ + code: "openclaw_paperclip_api_url_loopback", + level: "warn", + message: + "paperclipApiUrl uses loopback hostname. Remote OpenClaw workers cannot reach localhost on the Paperclip host.", + hint: "Use a reachable hostname/IP and keep it in allowed hostnames for authenticated/private deployments.", + }); + } + } + } catch { + diagnostics.push({ + code: "openclaw_paperclip_api_url_invalid", + level: "warn", + message: `Invalid paperclipApiUrl: ${rawPaperclipApiUrl}`, + }); + } + } + diagnostics.push( ...buildJoinConnectivityDiagnostics({ deploymentMode: input.deploymentMode, @@ -486,7 +524,7 @@ function buildInviteOnboardingManifest( adapterType: "Use 'openclaw' for OpenClaw streaming agents", capabilities: "Optional capability summary", agentDefaultsPayload: - "Optional adapter config such as url/method/headers/webhookAuthHeader for OpenClaw SSE endpoint", + "Optional adapter config such as url/method/headers/webhookAuthHeader and paperclipApiUrl for OpenClaw SSE endpoint", }, registrationEndpoint: { method: "POST", @@ -593,6 +631,7 @@ export function buildInviteOnboardingTextDocument( ' "capabilities": "Optional summary",', ' "agentDefaultsPayload": {', ' "url": "https://your-openclaw-agent.example/v1/responses",', + ' "paperclipApiUrl": "https://paperclip-hostname-your-agent-can-reach:3100",', ' "streamTransport": "sse",', ' "method": "POST",', ' "headers": { "x-openclaw-auth": "replace-me" },', @@ -655,6 +694,7 @@ export function buildInviteOnboardingTextDocument( "", "Test each candidate with:", "- GET /api/health", + "- set the first reachable candidate as agentDefaultsPayload.paperclipApiUrl when submitting your join request", "", "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:", diff --git a/ui/src/adapters/openclaw/config-fields.tsx b/ui/src/adapters/openclaw/config-fields.tsx index b63d0661..85231716 100644 --- a/ui/src/adapters/openclaw/config-fields.tsx +++ b/ui/src/adapters/openclaw/config-fields.tsx @@ -48,6 +48,22 @@ export function OpenClawConfigFields({ {!isCreate && ( <> + + mark("adapterConfig", "paperclipApiUrl", v || undefined)} + immediate + className={inputClass} + placeholder="https://paperclip.example" + /> + +