fix(openclaw): support /hooks/wake compatibility payload

This commit is contained in:
Dotta
2026-03-05 13:43:37 -06:00
parent 690149d555
commit a05aa99c7e
4 changed files with 291 additions and 10 deletions

View File

@@ -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

View File

@@ -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<string, string>;
payload: Record<string, unknown>;
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}) <empty>\n`);
}
return { response, responseText };
}
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
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<AdapterExec
: [],
};
const body = {
const paperclipBody = {
...payloadTemplate,
paperclip: {
...wakePayload,
context,
},
};
const wakeTextBody = {
text: buildWakeText(wakePayload),
mode: "now",
};
if (onMeta) {
await onMeta({
@@ -75,21 +155,69 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const timeout = setTimeout(() => 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}) <empty>\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,

View File

@@ -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);

View File

@@ -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<string, unknown>): 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<string, unknown>;
expect(body.foo).toBe("bar");
expect(body.paperclip).toBeTypeOf("object");
expect((body.paperclip as Record<string, unknown>).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<string, unknown>;
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<string, unknown>;
expect(firstBody.paperclip).toBeTypeOf("object");
const secondBody = JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body ?? "{}")) as Record<string, unknown>;
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");
});
});