diff --git a/packages/adapters/openclaw/src/server/execute-common.ts b/packages/adapters/openclaw/src/server/execute-common.ts index fd0d6484..ab2a0693 100644 --- a/packages/adapters/openclaw/src/server/execute-common.ts +++ b/packages/adapters/openclaw/src/server/execute-common.ts @@ -93,6 +93,28 @@ export function isOpenResponsesEndpoint(url: string): boolean { } } +export function normalizeWebhookInvocationUrl(url: string): { + url: string; + normalizedFromOpenResponses: boolean; +} { + try { + const parsed = new URL(url); + const path = parsed.pathname; + const normalizedPath = path.toLowerCase(); + const suffix = "/v1/responses"; + + if (normalizedPath !== suffix && !normalizedPath.endsWith(suffix)) { + return { url: parsed.toString(), normalizedFromOpenResponses: false }; + } + + const prefix = path.slice(0, path.length - suffix.length); + parsed.pathname = `${prefix}/hooks/wake`; + return { url: parsed.toString(), normalizedFromOpenResponses: true }; + } catch { + return { url, normalizedFromOpenResponses: false }; + } +} + export function toStringRecord(value: unknown): Record { const parsed = parseObject(value); const out: Record = {}; diff --git a/packages/adapters/openclaw/src/server/execute-webhook.ts b/packages/adapters/openclaw/src/server/execute-webhook.ts index f76d6729..8d687b11 100644 --- a/packages/adapters/openclaw/src/server/execute-webhook.ts +++ b/packages/adapters/openclaw/src/server/execute-webhook.ts @@ -8,6 +8,7 @@ import { isTextRequiredResponse, isWakeCompatibilityRetryableResponse, isWakeCompatibilityEndpoint, + normalizeWebhookInvocationUrl, readAndLogResponseText, redactForLog, sendJsonRequest, @@ -92,29 +93,35 @@ async function sendWebhookRequest(params: { export async function executeWebhook(ctx: AdapterExecutionContext, url: string): Promise { const { onLog, onMeta, context } = ctx; const state = buildExecutionState(ctx); + const webhookTarget = normalizeWebhookInvocationUrl(url); + const webhookUrl = webhookTarget.url; if (onMeta) { await onMeta({ adapterType: "openclaw", command: "webhook", - commandArgs: [state.method, url], + commandArgs: [state.method, webhookUrl], context, }); } const headers = { ...state.headers }; - if (isOpenResponsesEndpoint(url) && !headers["x-openclaw-session-key"] && !headers["X-OpenClaw-Session-Key"]) { + if ( + isOpenResponsesEndpoint(webhookUrl) && + !headers["x-openclaw-session-key"] && + !headers["X-OpenClaw-Session-Key"] + ) { headers["x-openclaw-session-key"] = state.sessionKey; } const webhookBody = buildWebhookBody({ - url, + url: webhookUrl, state, context, configModel: ctx.config.model, }); const wakeCompatibilityBody = buildWakeCompatibilityPayload(state.wakeText); - const preferWakeCompatibilityBody = isWakeCompatibilityEndpoint(url); + const preferWakeCompatibilityBody = isWakeCompatibilityEndpoint(webhookUrl); const initialBody = preferWakeCompatibilityBody ? wakeCompatibilityBody : webhookBody; const outboundHeaderKeys = Object.keys(headers).sort(); @@ -127,7 +134,13 @@ export async function executeWebhook(ctx: AdapterExecutionContext, url: string): `[openclaw] outbound payload (redacted): ${stringifyForLog(redactForLog(initialBody), 12_000)}\n`, ); await onLog("stdout", `[openclaw] outbound header keys: ${outboundHeaderKeys.join(", ")}\n`); - await onLog("stdout", `[openclaw] invoking ${state.method} ${url} (transport=webhook)\n`); + if (webhookTarget.normalizedFromOpenResponses) { + await onLog( + "stdout", + `[openclaw] webhook transport normalized /v1/responses endpoint to ${webhookUrl}\n`, + ); + } + await onLog("stdout", `[openclaw] invoking ${state.method} ${webhookUrl} (transport=webhook)\n`); if (preferWakeCompatibilityBody) { await onLog("stdout", "[openclaw] using wake text payload for /hooks/wake compatibility\n"); @@ -138,7 +151,7 @@ export async function executeWebhook(ctx: AdapterExecutionContext, url: string): try { const initialResponse = await sendWebhookRequest({ - url, + url: webhookUrl, method: state.method, headers, payload: initialBody, @@ -157,7 +170,7 @@ export async function executeWebhook(ctx: AdapterExecutionContext, url: string): ); const retryResponse = await sendWebhookRequest({ - url, + url: webhookUrl, method: state.method, headers, payload: wakeCompatibilityBody, @@ -172,7 +185,7 @@ export async function executeWebhook(ctx: AdapterExecutionContext, url: string): timedOut: false, provider: "openclaw", model: null, - summary: `OpenClaw webhook ${state.method} ${url} (wake compatibility)`, + summary: `OpenClaw webhook ${state.method} ${webhookUrl} (wake compatibility)`, resultJson: { status: retryResponse.response.status, statusText: retryResponse.response.statusText, @@ -227,7 +240,7 @@ export async function executeWebhook(ctx: AdapterExecutionContext, url: string): timedOut: false, provider: "openclaw", model: null, - summary: `OpenClaw webhook ${state.method} ${url}`, + summary: `OpenClaw webhook ${state.method} ${webhookUrl}`, resultJson: { status: initialResponse.response.status, statusText: initialResponse.response.statusText, diff --git a/packages/adapters/openclaw/src/server/test.ts b/packages/adapters/openclaw/src/server/test.ts index 00e252ad..6a5f4daa 100644 --- a/packages/adapters/openclaw/src/server/test.ts +++ b/packages/adapters/openclaw/src/server/test.ts @@ -34,6 +34,11 @@ function isWakePath(pathname: string): boolean { return value === "/hooks/wake" || value.endsWith("/hooks/wake"); } +function isOpenResponsesPath(pathname: string): boolean { + const value = pathname.trim().toLowerCase(); + return value === "/v1/responses" || value.endsWith("/v1/responses"); +} + function normalizeTransport(value: unknown): "sse" | "webhook" | null { const normalized = asString(value, "sse").trim().toLowerCase(); if (!normalized || normalized === "sse") return "sse"; @@ -171,6 +176,16 @@ export async function testEnvironment( hint: "Use an endpoint that returns text/event-stream for the full run duration.", }); } + + if (streamTransport === "webhook" && isOpenResponsesPath(url.pathname)) { + checks.push({ + code: "openclaw_webhook_endpoint_normalized", + level: "warn", + message: + "Webhook transport is configured with a /v1/responses endpoint. Runtime will normalize this to /hooks/wake.", + hint: "Set endpoint path to /hooks/wake to avoid ambiguous transport behavior.", + }); + } } if (!streamTransport) { diff --git a/server/src/__tests__/openclaw-adapter.test.ts b/server/src/__tests__/openclaw-adapter.test.ts index e13319ba..6f5b0667 100644 --- a/server/src/__tests__/openclaw-adapter.test.ts +++ b/server/src/__tests__/openclaw-adapter.test.ts @@ -564,7 +564,7 @@ describe("openclaw adapter execute", () => { expect((body.paperclip as Record).streamTransport).toBe("webhook"); }); - it("uses OpenResponses payload shape for webhook transport against /v1/responses", async () => { + it("normalizes /v1/responses to /hooks/wake for webhook transport", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ ok: true }), { status: 200, @@ -586,16 +586,12 @@ describe("openclaw adapter execute", () => { expect(result.exitCode).toBe(0); expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]?.[0]).toBe("https://agent.example/hooks/wake"); const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; - expect(body.foo).toBe("bar"); - expect(body.stream).toBe(false); - expect(body.model).toBe("openclaw"); - expect(String(body.input ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); - const metadata = body.metadata as Record; - expect(metadata.PAPERCLIP_RUN_ID).toBe("run-123"); - expect(metadata.paperclip_session_key).toBe("paperclip"); - expect(metadata.paperclip_stream_transport).toBe("webhook"); - expect(body.paperclip).toBeUndefined(); + expect(body.mode).toBe("now"); + expect(String(body.text ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); + expect(body.model).toBeUndefined(); + expect(body.input).toBeUndefined(); }); it("uses wake compatibility payloads for /hooks/wake when transport=webhook", async () => { @@ -649,7 +645,7 @@ describe("openclaw adapter execute", () => { const result = await execute( buildContext({ - url: "https://agent.example/v1/responses", + url: "https://agent.example/webhook", streamTransport: "webhook", }), ); @@ -658,13 +654,13 @@ describe("openclaw adapter execute", () => { expect(fetchMock).toHaveBeenCalledTimes(2); const firstBody = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; const secondBody = JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body ?? "{}")) as Record; - expect(firstBody.model).toBe("openclaw"); - expect(String(firstBody.input ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); + expect(firstBody.paperclip).toBeTruthy(); + expect(String(firstBody.text ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); expect(secondBody.mode).toBe("now"); expect(String(secondBody.text ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); }); - it("retries webhook payloads when /v1/responses reports missing string input", async () => { + it("retries webhook payloads when endpoint reports missing string input", async () => { const fetchMock = vi .fn() .mockResolvedValueOnce( @@ -697,7 +693,7 @@ describe("openclaw adapter execute", () => { const result = await execute( buildContext({ - url: "https://agent.example/v1/responses", + url: "https://agent.example/webhook", streamTransport: "webhook", }), ); @@ -807,6 +803,27 @@ describe("openclaw adapter environment checks", () => { expect(configured?.level).toBe("info"); expect(wakeIncompatible).toBeUndefined(); }); + + it("warns when webhook transport is configured with a /v1/responses endpoint", 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/v1/responses", + streamTransport: "webhook", + }, + }); + + const normalizedWarning = result.checks.find( + (entry) => entry.code === "openclaw_webhook_endpoint_normalized", + ); + expect(normalizedWarning?.level).toBe("warn"); + }); }); describe("onHireApproved", () => {