From a05aa99c7e2d00c735a4fe388736387c23626b33 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 5 Mar 2026 13:43:37 -0600 Subject: [PATCH] fix(openclaw): support /hooks/wake compatibility payload --- packages/adapters/openclaw/src/index.ts | 2 + .../adapters/openclaw/src/server/execute.ts | 148 ++++++++++++++++-- packages/adapters/openclaw/src/server/test.ts | 14 ++ server/src/__tests__/openclaw-adapter.test.ts | 137 ++++++++++++++++ 4 files changed, 291 insertions(+), 10 deletions(-) create mode 100644 server/src/__tests__/openclaw-adapter.test.ts diff --git a/packages/adapters/openclaw/src/index.ts b/packages/adapters/openclaw/src/index.ts index d7399505..8ddc4cf4 100644 --- a/packages/adapters/openclaw/src/index.ts +++ b/packages/adapters/openclaw/src/index.ts @@ -17,6 +17,8 @@ Don't use when: Core fields: - url (string, required): OpenClaw webhook endpoint URL +- If the URL path is \`/hooks/wake\`, Paperclip uses OpenClaw compatibility payload (\`{ text, mode }\`). +- For full structured Paperclip context payloads, use a mapped endpoint (for example \`/hooks/paperclip\`). - method (string, optional): HTTP method, default POST - headers (object, optional): extra HTTP headers for webhook calls - webhookAuthHeader (string, optional): Authorization header value if your endpoint requires auth diff --git a/packages/adapters/openclaw/src/server/execute.ts b/packages/adapters/openclaw/src/server/execute.ts index c0de9f4e..d3e67aa1 100644 --- a/packages/adapters/openclaw/src/server/execute.ts +++ b/packages/adapters/openclaw/src/server/execute.ts @@ -6,6 +6,82 @@ function nonEmpty(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } +function shouldUseWakeTextPayload(url: string): boolean { + try { + const parsed = new URL(url); + const path = parsed.pathname.toLowerCase(); + return path === "/hooks/wake" || path.endsWith("/hooks/wake"); + } catch { + return false; + } +} + +function buildWakeText(payload: { + runId: string; + agentId: string; + companyId: string; + taskId: string | null; + issueId: string | null; + wakeReason: string | null; + wakeCommentId: string | null; + approvalId: string | null; + approvalStatus: string | null; + issueIds: string[]; +}): string { + const lines = [ + "Paperclip wake event.", + "", + `runId: ${payload.runId}`, + `agentId: ${payload.agentId}`, + `companyId: ${payload.companyId}`, + ]; + + if (payload.taskId) lines.push(`taskId: ${payload.taskId}`); + if (payload.issueId) lines.push(`issueId: ${payload.issueId}`); + if (payload.wakeReason) lines.push(`wakeReason: ${payload.wakeReason}`); + if (payload.wakeCommentId) lines.push(`wakeCommentId: ${payload.wakeCommentId}`); + if (payload.approvalId) lines.push(`approvalId: ${payload.approvalId}`); + if (payload.approvalStatus) lines.push(`approvalStatus: ${payload.approvalStatus}`); + if (payload.issueIds.length > 0) lines.push(`issueIds: ${payload.issueIds.join(",")}`); + + lines.push("", "Run your Paperclip heartbeat procedure now."); + return lines.join("\n"); +} + +function isTextRequiredResponse(responseText: string): boolean { + const parsed = parseOpenClawResponse(responseText); + const parsedError = parsed && typeof parsed.error === "string" ? parsed.error : null; + if (parsedError && parsedError.toLowerCase().includes("text required")) { + return true; + } + return responseText.toLowerCase().includes("text required"); +} + +async function sendWebhookRequest(params: { + url: string; + method: string; + headers: Record; + payload: Record; + onLog: AdapterExecutionContext["onLog"]; + signal: AbortSignal; +}): Promise<{ response: Response; responseText: string }> { + const response = await fetch(params.url, { + method: params.method, + headers: params.headers, + body: JSON.stringify(params.payload), + signal: params.signal, + }); + + const responseText = await response.text(); + if (responseText.trim().length > 0) { + await params.onLog("stdout", `[openclaw] response (${response.status}) ${responseText.slice(0, 2000)}\n`); + } else { + await params.onLog("stdout", `[openclaw] response (${response.status}) \n`); + } + + return { response, responseText }; +} + export async function execute(ctx: AdapterExecutionContext): Promise { const { config, runId, agent, context, onLog, onMeta } = ctx; const url = asString(config.url, "").trim(); @@ -52,13 +128,17 @@ export async function execute(ctx: AdapterExecutionContext): Promise controller.abort(), timeoutSec * 1000); try { - const response = await fetch(url, { + const preferWakeTextPayload = shouldUseWakeTextPayload(url); + if (preferWakeTextPayload) { + await onLog("stdout", "[openclaw] using wake text payload for /hooks/wake compatibility\n"); + } + + const initialPayload = preferWakeTextPayload ? wakeTextBody : paperclipBody; + + const { response, responseText } = await sendWebhookRequest({ + url, method, headers, - body: JSON.stringify(body), + payload: initialPayload, + onLog, signal: controller.signal, }); - const responseText = await response.text(); - if (responseText.trim().length > 0) { - await onLog("stdout", `[openclaw] response (${response.status}) ${responseText.slice(0, 2000)}\n`); - } else { - await onLog("stdout", `[openclaw] response (${response.status}) \n`); - } - if (!response.ok) { + const canRetryWithWakeText = !preferWakeTextPayload && isTextRequiredResponse(responseText); + + if (canRetryWithWakeText) { + await onLog("stdout", "[openclaw] endpoint requires text payload; retrying with wake compatibility format\n"); + + const retry = await sendWebhookRequest({ + url, + method, + headers, + payload: wakeTextBody, + onLog, + signal: controller.signal, + }); + + if (retry.response.ok) { + return { + exitCode: 0, + signal: null, + timedOut: false, + provider: "openclaw", + model: null, + summary: `OpenClaw webhook ${method} ${url} (wake compatibility)`, + resultJson: { + status: retry.response.status, + statusText: retry.response.statusText, + compatibilityMode: "wake_text", + response: parseOpenClawResponse(retry.responseText) ?? retry.responseText, + }, + }; + } + + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage: `OpenClaw webhook failed with status ${retry.response.status}`, + errorCode: "openclaw_http_error", + resultJson: { + status: retry.response.status, + statusText: retry.response.statusText, + compatibilityMode: "wake_text", + response: parseOpenClawResponse(retry.responseText) ?? retry.responseText, + }, + }; + } + return { exitCode: 1, signal: null, diff --git a/packages/adapters/openclaw/src/server/test.ts b/packages/adapters/openclaw/src/server/test.ts index ecc1e43c..a6093dab 100644 --- a/packages/adapters/openclaw/src/server/test.ts +++ b/packages/adapters/openclaw/src/server/test.ts @@ -29,6 +29,11 @@ function normalizeHostname(value: string | null | undefined): string | null { return trimmed.toLowerCase(); } +function isWakePath(pathname: string): boolean { + const value = pathname.trim().toLowerCase(); + return value === "/hooks/wake" || value.endsWith("/hooks/wake"); +} + function pushDeploymentDiagnostics( checks: AdapterEnvironmentCheck[], ctx: AdapterEnvironmentTestContext, @@ -148,6 +153,15 @@ export async function testEnvironment( hint: "Use a reachable hostname/IP (for example Tailscale/private hostname or public domain).", }); } + + if (isWakePath(url.pathname)) { + checks.push({ + code: "openclaw_wake_endpoint_compat_mode", + level: "info", + message: "Endpoint targets /hooks/wake; adapter will use OpenClaw wake compatibility payload (text/mode).", + hint: "For structured Paperclip JSON payloads, use a mapped webhook endpoint such as /hooks/paperclip.", + }); + } } pushDeploymentDiagnostics(checks, ctx, url); diff --git a/server/src/__tests__/openclaw-adapter.test.ts b/server/src/__tests__/openclaw-adapter.test.ts new file mode 100644 index 00000000..06366dab --- /dev/null +++ b/server/src/__tests__/openclaw-adapter.test.ts @@ -0,0 +1,137 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { execute, testEnvironment } from "@paperclipai/adapter-openclaw/server"; +import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; + +function buildContext(config: Record): AdapterExecutionContext { + return { + runId: "run-123", + agent: { + id: "agent-123", + companyId: "company-123", + name: "OpenClaw Agent", + adapterType: "openclaw", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config, + context: { + taskId: "task-123", + issueId: "issue-123", + wakeReason: "issue_assigned", + issueIds: ["issue-123"], + }, + onLog: async () => {}, + }; +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("openclaw adapter execute", () => { + it("sends structured paperclip payload to mapped endpoints", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { status: 200, statusText: "OK" }), + ); + vi.stubGlobal("fetch", fetchMock); + + const result = await execute( + buildContext({ + url: "https://agent.example/hooks/paperclip", + method: "POST", + payloadTemplate: { foo: "bar" }, + }), + ); + + expect(result.exitCode).toBe(0); + expect(fetchMock).toHaveBeenCalledTimes(1); + const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; + expect(body.foo).toBe("bar"); + expect(body.paperclip).toBeTypeOf("object"); + expect((body.paperclip as Record).runId).toBe("run-123"); + }); + + it("uses wake text payload for /hooks/wake endpoints", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { status: 200, statusText: "OK" }), + ); + vi.stubGlobal("fetch", fetchMock); + + const result = await execute( + buildContext({ + url: "https://agent.example/hooks/wake", + method: "POST", + }), + ); + + expect(result.exitCode).toBe(0); + expect(fetchMock).toHaveBeenCalledTimes(1); + const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; + expect(body.mode).toBe("now"); + expect(typeof body.text).toBe("string"); + expect(body.paperclip).toBeUndefined(); + }); + + it("retries with wake text payload when endpoint reports text required", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ ok: false, error: "text required" }), { + status: 400, + statusText: "Bad Request", + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true }), { status: 200, statusText: "OK" }), + ); + vi.stubGlobal("fetch", fetchMock); + + const result = await execute( + buildContext({ + url: "https://agent.example/hooks/paperclip", + method: "POST", + }), + ); + + expect(result.exitCode).toBe(0); + expect(fetchMock).toHaveBeenCalledTimes(2); + + const firstBody = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; + expect(firstBody.paperclip).toBeTypeOf("object"); + + const secondBody = JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body ?? "{}")) as Record; + expect(secondBody.mode).toBe("now"); + expect(typeof secondBody.text).toBe("string"); + expect(result.resultJson?.compatibilityMode).toBe("wake_text"); + }); +}); + +describe("openclaw adapter environment checks", () => { + it("reports compatibility mode info for /hooks/wake endpoints", async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 405, statusText: "Method Not Allowed" })); + vi.stubGlobal("fetch", fetchMock); + + const result = await testEnvironment({ + companyId: "company-123", + adapterType: "openclaw", + config: { + url: "https://agent.example/hooks/wake", + }, + deployment: { + mode: "authenticated", + exposure: "private", + bindHost: "paperclip.internal", + allowedHostnames: ["paperclip.internal"], + }, + }); + + const compatibilityCheck = result.checks.find((check) => check.code === "openclaw_wake_endpoint_compat_mode"); + expect(compatibilityCheck?.level).toBe("info"); + }); +});