From d7f68ec1c9187009c0bf30eae7ec49b65b2b7a45 Mon Sep 17 00:00:00 2001 From: Logesh Date: Sat, 7 Mar 2026 02:40:53 +0530 Subject: [PATCH 1/8] fix(issues): skip agent wakeup when issue status is backlog Agents were being woken immediately when an issue was created or reassigned with status "backlog", defeating the purpose of backlog as "not ready to work on yet" (issue #96). Added `&& issue.status !== "backlog"` guard to both the CREATE and PATCH wakeup paths, mirroring the existing done/cancelled suppression pattern already present in the file. Co-Authored-By: Claude Sonnet 4.6 --- server/src/routes/issues.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 88070293..28807857 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -432,7 +432,7 @@ export function issueRoutes(db: Db, storage: StorageService) { details: { title: issue.title, identifier: issue.identifier }, }); - if (issue.assigneeAgentId) { + if (issue.assigneeAgentId && issue.status !== "backlog") { void heartbeat .wakeup(issue.assigneeAgentId, { source: "assignment", @@ -566,7 +566,7 @@ export function issueRoutes(db: Db, storage: StorageService) { void (async () => { const wakeups = new Map[1]>(); - if (assigneeChanged && issue.assigneeAgentId) { + if (assigneeChanged && issue.assigneeAgentId && issue.status !== "backlog") { wakeups.set(issue.assigneeAgentId, { source: "assignment", triggerDetail: "system", From 36013c35d9c7f95d2659b33bee55b78fc3c46440 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 6 Mar 2026 15:48:35 -0600 Subject: [PATCH 2/8] dev: make pnpm dev watch workspace package changes --- README.md | 3 ++- doc/DEVELOPING.md | 2 ++ package.json | 3 ++- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e38cb1ba..c3d9fc8e 100644 --- a/README.md +++ b/README.md @@ -218,7 +218,8 @@ By default, agents run on scheduled heartbeats and event-based triggers (task as ## Development ```bash -pnpm dev # Full dev (API + UI) +pnpm dev # Full dev (API + UI, watch mode) +pnpm dev:once # Full dev without file watching pnpm dev:server # Server only pnpm build # Build all pnpm typecheck # Type checking diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 4659bb4b..bb287623 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -29,6 +29,8 @@ This starts: - API server: `http://localhost:3100` - UI: served by the API server in dev middleware mode (same origin as API) +`pnpm dev` runs the server in watch mode and restarts on changes from workspace packages (including adapter packages). Use `pnpm dev:once` to run without file watching. + Tailscale/private-auth dev mode: ```sh diff --git a/package.json b/package.json index ad6d692f..45c02b8b 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,9 @@ "private": true, "type": "module", "scripts": { - "dev": "node scripts/dev-runner.mjs dev", + "dev": "node scripts/dev-runner.mjs watch", "dev:watch": "PAPERCLIP_MIGRATION_PROMPT=never node scripts/dev-runner.mjs watch", + "dev:once": "node scripts/dev-runner.mjs dev", "dev:server": "pnpm --filter @paperclipai/server dev", "dev:ui": "pnpm --filter @paperclipai/ui dev", "build": "pnpm -r build", From 5ab1c185306c697e94328dde1281ca64c1c4f109 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 6 Mar 2026 15:50:08 -0600 Subject: [PATCH 3/8] fix openclaw webhook payload for /v1/responses --- .../openclaw/src/server/execute-common.ts | 35 ++++++++ .../openclaw/src/server/execute-webhook.ts | 44 +++++++++- server/src/__tests__/openclaw-adapter.test.ts | 82 ++++++++++++++++++- 3 files changed, 157 insertions(+), 4 deletions(-) diff --git a/packages/adapters/openclaw/src/server/execute-common.ts b/packages/adapters/openclaw/src/server/execute-common.ts index f56a880c..fd0d6484 100644 --- a/packages/adapters/openclaw/src/server/execute-common.ts +++ b/packages/adapters/openclaw/src/server/execute-common.ts @@ -306,6 +306,41 @@ export function isTextRequiredResponse(responseText: string): boolean { return responseText.toLowerCase().includes("text required"); } +function extractResponseErrorMessage(responseText: string): string { + const parsed = parseOpenClawResponse(responseText); + if (!parsed) return responseText; + + const directError = parsed.error; + if (typeof directError === "string") return directError; + if (directError && typeof directError === "object") { + const nestedMessage = (directError as Record).message; + if (typeof nestedMessage === "string") return nestedMessage; + } + + const directMessage = parsed.message; + if (typeof directMessage === "string") return directMessage; + + return responseText; +} + +export function isWakeCompatibilityRetryableResponse(responseText: string): boolean { + if (isTextRequiredResponse(responseText)) return true; + + const normalized = extractResponseErrorMessage(responseText).toLowerCase(); + const expectsStringInput = + normalized.includes("invalid input") && + normalized.includes("expected string") && + normalized.includes("undefined"); + if (expectsStringInput) return true; + + const missingInputField = + normalized.includes("input") && + (normalized.includes("required") || normalized.includes("missing")); + if (missingInputField) return true; + + return false; +} + export async function sendJsonRequest(params: { url: string; method: string; diff --git a/packages/adapters/openclaw/src/server/execute-webhook.ts b/packages/adapters/openclaw/src/server/execute-webhook.ts index 483eb3c0..f76d6729 100644 --- a/packages/adapters/openclaw/src/server/execute-webhook.ts +++ b/packages/adapters/openclaw/src/server/execute-webhook.ts @@ -1,14 +1,18 @@ import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; import { appendWakeText, + appendWakeTextToOpenResponsesInput, buildExecutionState, buildWakeCompatibilityPayload, + isOpenResponsesEndpoint, isTextRequiredResponse, + isWakeCompatibilityRetryableResponse, isWakeCompatibilityEndpoint, readAndLogResponseText, redactForLog, sendJsonRequest, stringifyForLog, + toStringRecord, type OpenClawExecutionState, } from "./execute-common.js"; import { parseOpenClawResponse } from "./parse.js"; @@ -18,12 +22,37 @@ function nonEmpty(value: unknown): string | null { } function buildWebhookBody(input: { + url: string; state: OpenClawExecutionState; context: AdapterExecutionContext["context"]; + configModel: unknown; }): Record { - const { state, context } = input; + const { url, state, context, configModel } = input; const templateText = nonEmpty(state.payloadTemplate.text); const payloadText = templateText ? appendWakeText(templateText, state.wakeText) : state.wakeText; + const isOpenResponses = isOpenResponsesEndpoint(url); + + if (isOpenResponses) { + const openResponsesInput = Object.prototype.hasOwnProperty.call(state.payloadTemplate, "input") + ? appendWakeTextToOpenResponsesInput(state.payloadTemplate.input, state.wakeText) + : payloadText; + + return { + ...state.payloadTemplate, + stream: false, + model: + nonEmpty(state.payloadTemplate.model) ?? + nonEmpty(configModel) ?? + "openclaw", + input: openResponsesInput, + metadata: { + ...toStringRecord(state.payloadTemplate.metadata), + ...state.paperclipEnv, + paperclip_session_key: state.sessionKey, + paperclip_stream_transport: "webhook", + }, + }; + } return { ...state.payloadTemplate, @@ -74,7 +103,16 @@ export async function executeWebhook(ctx: AdapterExecutionContext, url: string): } const headers = { ...state.headers }; - const webhookBody = buildWebhookBody({ state, context }); + if (isOpenResponsesEndpoint(url) && !headers["x-openclaw-session-key"] && !headers["X-OpenClaw-Session-Key"]) { + headers["x-openclaw-session-key"] = state.sessionKey; + } + + const webhookBody = buildWebhookBody({ + url, + state, + context, + configModel: ctx.config.model, + }); const wakeCompatibilityBody = buildWakeCompatibilityPayload(state.wakeText); const preferWakeCompatibilityBody = isWakeCompatibilityEndpoint(url); const initialBody = preferWakeCompatibilityBody ? wakeCompatibilityBody : webhookBody; @@ -110,7 +148,7 @@ export async function executeWebhook(ctx: AdapterExecutionContext, url: string): if (!initialResponse.response.ok) { const canRetryWithWakeCompatibility = - !preferWakeCompatibilityBody && isTextRequiredResponse(initialResponse.responseText); + !preferWakeCompatibilityBody && isWakeCompatibilityRetryableResponse(initialResponse.responseText); if (canRetryWithWakeCompatibility) { await onLog( diff --git a/server/src/__tests__/openclaw-adapter.test.ts b/server/src/__tests__/openclaw-adapter.test.ts index aa5d4999..e13319ba 100644 --- a/server/src/__tests__/openclaw-adapter.test.ts +++ b/server/src/__tests__/openclaw-adapter.test.ts @@ -564,6 +564,40 @@ describe("openclaw adapter execute", () => { expect((body.paperclip as Record).streamTransport).toBe("webhook"); }); + it("uses OpenResponses payload shape for webhook transport against /v1/responses", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + status: 200, + statusText: "OK", + headers: { + "content-type": "application/json", + }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const result = await execute( + buildContext({ + url: "https://agent.example/v1/responses", + streamTransport: "webhook", + 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.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(); + }); + it("uses wake compatibility payloads for /hooks/wake when transport=webhook", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ ok: true }), { @@ -624,7 +658,53 @@ 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.paperclip).toBeTypeOf("object"); + expect(firstBody.model).toBe("openclaw"); + expect(String(firstBody.input ?? "")).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 () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: { + message: "model: Invalid input: expected string, received undefined", + type: "invalid_request_error", + }, + }), + { + status: 400, + statusText: "Bad Request", + headers: { + "content-type": "application/json", + }, + }, + ), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true }), { + status: 200, + statusText: "OK", + headers: { + "content-type": "application/json", + }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const result = await execute( + buildContext({ + url: "https://agent.example/v1/responses", + streamTransport: "webhook", + }), + ); + + expect(result.exitCode).toBe(0); + expect(fetchMock).toHaveBeenCalledTimes(2); + const secondBody = JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body ?? "{}")) as Record; expect(secondBody.mode).toBe("now"); expect(String(secondBody.text ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); }); From aa7e06904415944cbd93bcd9187ea33ea9e3bc91 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 6 Mar 2026 16:11:11 -0600 Subject: [PATCH 4/8] openclaw: force webhook transport to use hooks/wake --- .../openclaw/src/server/execute-common.ts | 22 +++++++++ .../openclaw/src/server/execute-webhook.ts | 31 ++++++++---- packages/adapters/openclaw/src/server/test.ts | 15 ++++++ server/src/__tests__/openclaw-adapter.test.ts | 47 +++++++++++++------ 4 files changed, 91 insertions(+), 24 deletions(-) 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", () => { From b53946231938b14eca9c9fc185182a1aa76374d1 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 6 Mar 2026 16:18:26 -0600 Subject: [PATCH 5/8] Revert "openclaw: force webhook transport to use hooks/wake" This reverts commit aa7e06904415944cbd93bcd9187ea33ea9e3bc91. --- .../openclaw/src/server/execute-common.ts | 22 --------- .../openclaw/src/server/execute-webhook.ts | 31 ++++-------- packages/adapters/openclaw/src/server/test.ts | 15 ------ server/src/__tests__/openclaw-adapter.test.ts | 47 ++++++------------- 4 files changed, 24 insertions(+), 91 deletions(-) diff --git a/packages/adapters/openclaw/src/server/execute-common.ts b/packages/adapters/openclaw/src/server/execute-common.ts index ab2a0693..fd0d6484 100644 --- a/packages/adapters/openclaw/src/server/execute-common.ts +++ b/packages/adapters/openclaw/src/server/execute-common.ts @@ -93,28 +93,6 @@ 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 8d687b11..f76d6729 100644 --- a/packages/adapters/openclaw/src/server/execute-webhook.ts +++ b/packages/adapters/openclaw/src/server/execute-webhook.ts @@ -8,7 +8,6 @@ import { isTextRequiredResponse, isWakeCompatibilityRetryableResponse, isWakeCompatibilityEndpoint, - normalizeWebhookInvocationUrl, readAndLogResponseText, redactForLog, sendJsonRequest, @@ -93,35 +92,29 @@ 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, webhookUrl], + commandArgs: [state.method, url], context, }); } const headers = { ...state.headers }; - if ( - isOpenResponsesEndpoint(webhookUrl) && - !headers["x-openclaw-session-key"] && - !headers["X-OpenClaw-Session-Key"] - ) { + if (isOpenResponsesEndpoint(url) && !headers["x-openclaw-session-key"] && !headers["X-OpenClaw-Session-Key"]) { headers["x-openclaw-session-key"] = state.sessionKey; } const webhookBody = buildWebhookBody({ - url: webhookUrl, + url, state, context, configModel: ctx.config.model, }); const wakeCompatibilityBody = buildWakeCompatibilityPayload(state.wakeText); - const preferWakeCompatibilityBody = isWakeCompatibilityEndpoint(webhookUrl); + const preferWakeCompatibilityBody = isWakeCompatibilityEndpoint(url); const initialBody = preferWakeCompatibilityBody ? wakeCompatibilityBody : webhookBody; const outboundHeaderKeys = Object.keys(headers).sort(); @@ -134,13 +127,7 @@ 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`); - 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`); + await onLog("stdout", `[openclaw] invoking ${state.method} ${url} (transport=webhook)\n`); if (preferWakeCompatibilityBody) { await onLog("stdout", "[openclaw] using wake text payload for /hooks/wake compatibility\n"); @@ -151,7 +138,7 @@ export async function executeWebhook(ctx: AdapterExecutionContext, url: string): try { const initialResponse = await sendWebhookRequest({ - url: webhookUrl, + url, method: state.method, headers, payload: initialBody, @@ -170,7 +157,7 @@ export async function executeWebhook(ctx: AdapterExecutionContext, url: string): ); const retryResponse = await sendWebhookRequest({ - url: webhookUrl, + url, method: state.method, headers, payload: wakeCompatibilityBody, @@ -185,7 +172,7 @@ export async function executeWebhook(ctx: AdapterExecutionContext, url: string): timedOut: false, provider: "openclaw", model: null, - summary: `OpenClaw webhook ${state.method} ${webhookUrl} (wake compatibility)`, + summary: `OpenClaw webhook ${state.method} ${url} (wake compatibility)`, resultJson: { status: retryResponse.response.status, statusText: retryResponse.response.statusText, @@ -240,7 +227,7 @@ export async function executeWebhook(ctx: AdapterExecutionContext, url: string): timedOut: false, provider: "openclaw", model: null, - summary: `OpenClaw webhook ${state.method} ${webhookUrl}`, + summary: `OpenClaw webhook ${state.method} ${url}`, 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 6a5f4daa..00e252ad 100644 --- a/packages/adapters/openclaw/src/server/test.ts +++ b/packages/adapters/openclaw/src/server/test.ts @@ -34,11 +34,6 @@ 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"; @@ -176,16 +171,6 @@ 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 6f5b0667..e13319ba 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("normalizes /v1/responses to /hooks/wake for webhook transport", async () => { + it("uses OpenResponses payload shape for webhook transport against /v1/responses", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ ok: true }), { status: 200, @@ -586,12 +586,16 @@ 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.mode).toBe("now"); - expect(String(body.text ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); - expect(body.model).toBeUndefined(); - expect(body.input).toBeUndefined(); + 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(); }); it("uses wake compatibility payloads for /hooks/wake when transport=webhook", async () => { @@ -645,7 +649,7 @@ describe("openclaw adapter execute", () => { const result = await execute( buildContext({ - url: "https://agent.example/webhook", + url: "https://agent.example/v1/responses", streamTransport: "webhook", }), ); @@ -654,13 +658,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.paperclip).toBeTruthy(); - expect(String(firstBody.text ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); + expect(firstBody.model).toBe("openclaw"); + expect(String(firstBody.input ?? "")).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 endpoint reports missing string input", async () => { + it("retries webhook payloads when /v1/responses reports missing string input", async () => { const fetchMock = vi .fn() .mockResolvedValueOnce( @@ -693,7 +697,7 @@ describe("openclaw adapter execute", () => { const result = await execute( buildContext({ - url: "https://agent.example/webhook", + url: "https://agent.example/v1/responses", streamTransport: "webhook", }), ); @@ -803,27 +807,6 @@ 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", () => { From 514dc43923cbe54f86d7f4a9eb58471bb5eb55f4 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 6 Mar 2026 16:50:15 -0600 Subject: [PATCH 6/8] feat(openclaw): support /hooks/agent endpoint and multi-endpoint detection Add OpenClawEndpointKind type to distinguish between /hooks/wake, /hooks/agent, open_responses, and generic endpoints. Build appropriate payloads per endpoint kind with optional sessionKey inclusion. Refactor webhook execution to use endpoint-aware payload construction. Co-Authored-By: Claude Opus 4.6 --- packages/adapters/openclaw/README.md | 139 ++++++++ packages/adapters/openclaw/src/index.ts | 3 +- .../openclaw/src/server/execute-common.ts | 84 ++++- .../openclaw/src/server/execute-webhook.ts | 314 ++++++++++++++---- .../adapters/openclaw/src/server/execute.ts | 6 +- packages/adapters/openclaw/src/server/test.ts | 16 +- server/src/__tests__/openclaw-adapter.test.ts | 204 +++++++++++- 7 files changed, 683 insertions(+), 83 deletions(-) create mode 100644 packages/adapters/openclaw/README.md diff --git a/packages/adapters/openclaw/README.md b/packages/adapters/openclaw/README.md new file mode 100644 index 00000000..10b7d4c1 --- /dev/null +++ b/packages/adapters/openclaw/README.md @@ -0,0 +1,139 @@ +# OpenClaw Adapter Modes + +This document describes how `@paperclipai/adapter-openclaw` selects request shape and endpoint behavior. + +## Transport Modes + +The adapter has two transport modes: + +- `sse` (default) +- `webhook` + +Configured via `adapterConfig.streamTransport` (or legacy `adapterConfig.transport`). + +## Mode Matrix + +| streamTransport | configured URL path | behavior | +| --- | --- | --- | +| `sse` | `/v1/responses` | Sends OpenResponses request with `stream: true`, expects `text/event-stream` response until terminal event. | +| `sse` | `/hooks/*` | Rejected (`openclaw_sse_incompatible_endpoint`). Hooks are not stream-capable. | +| `sse` | other endpoint | Sends generic streaming payload (`stream: true`, `text`, `paperclip`) and expects SSE response. | +| `webhook` | `/hooks/wake` | Sends wake payload `{ text, mode }`. | +| `webhook` | `/hooks/agent` | Sends agent payload `{ message, ...hook fields }`. | +| `webhook` | `/v1/responses` | Compatibility flow: tries `/hooks/agent` first, then falls back to original `/v1/responses` if hook endpoint returns `404`. | +| `webhook` | other endpoint | Sends legacy generic webhook payload (`stream: false`, `text`, `paperclip`). | + +## Webhook Payload Shapes + +### 1) Hook Wake (`/hooks/wake`) + +Payload: + +```json +{ + "text": "Paperclip wake event ...", + "mode": "now" +} +``` + +### 2) Hook Agent (`/hooks/agent`) + +Payload: + +```json +{ + "message": "Paperclip wake event ...", + "name": "Optional hook name", + "agentId": "Optional OpenClaw agent id", + "wakeMode": "now", + "deliver": true, + "channel": "last", + "to": "Optional channel recipient", + "model": "Optional model override", + "thinking": "Optional thinking override", + "timeoutSeconds": 120 +} +``` + +Notes: + +- `message` is always used (not `text`) for `/hooks/agent`. +- `sessionKey` is **not** sent by default for `/hooks/agent`. +- To include derived session keys in `/hooks/agent`, set: + - `hookIncludeSessionKey: true` + +### 3) OpenResponses (`/v1/responses`) + +When used directly (SSE mode or webhook fallback), payload uses OpenResponses shape: + +```json +{ + "stream": false, + "model": "openclaw", + "input": "...", + "metadata": { + "paperclip_session_key": "paperclip" + } +} +``` + +## Auth Header Behavior + +You can provide auth either explicitly or via token headers: + +- Explicit auth header: + - `webhookAuthHeader: "Bearer ..."` +- Token headers (adapter derives `Authorization` automatically when missing): + - `headers["x-openclaw-token"]` (preferred) + - `headers["x-openclaw-auth"]` (legacy compatibility) + +## Session Key Behavior + +Session keys are resolved from: + +- `sessionKeyStrategy`: `fixed` (default), `issue`, `run` +- `sessionKey`: used when strategy is `fixed` (default value `paperclip`) + +Where session keys are applied: + +- `/v1/responses`: sent via `x-openclaw-session-key` header + metadata. +- `/hooks/wake`: not sent as a dedicated field. +- `/hooks/agent`: only sent if `hookIncludeSessionKey=true`. +- Generic webhook fallback: sent as `sessionKey` field. + +## Recommended Config Examples + +### SSE (streaming endpoint) + +```json +{ + "url": "http://127.0.0.1:18789/v1/responses", + "streamTransport": "sse", + "method": "POST", + "headers": { + "x-openclaw-token": "replace-me" + } +} +``` + +### Webhook (hooks endpoint) + +```json +{ + "url": "http://127.0.0.1:18789/hooks/agent", + "streamTransport": "webhook", + "method": "POST", + "headers": { + "x-openclaw-token": "replace-me" + } +} +``` + +### Webhook with legacy URL retained + +If URL is still `/v1/responses` and `streamTransport=webhook`, the adapter will: + +1. try `.../hooks/agent` +2. fallback to original `.../v1/responses` when hook endpoint returns `404` + +This lets older OpenClaw setups continue working while migrating to hooks. diff --git a/packages/adapters/openclaw/src/index.ts b/packages/adapters/openclaw/src/index.ts index 4ae9cdba..2d939d92 100644 --- a/packages/adapters/openclaw/src/index.ts +++ b/packages/adapters/openclaw/src/index.ts @@ -11,7 +11,7 @@ Use when: - You run an OpenClaw agent remotely and wake it over HTTP. - You want selectable transport: - \`sse\` for streaming execution in one Paperclip run. - - \`webhook\` for wake-style callbacks (including /hooks/wake compatibility). + - \`webhook\` for wake-style callbacks (\`/hooks/wake\`, \`/hooks/agent\`, or compatibility webhooks). Don't use when: - You need local CLI execution inside Paperclip (use claude_local/codex_local/opencode_local/process). @@ -25,6 +25,7 @@ Core fields: - 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\` +- hookIncludeSessionKey (boolean, optional): when true, include derived \`sessionKey\` in \`/hooks/agent\` webhook payloads (default false) Session routing fields: - sessionKeyStrategy (string, optional): \`fixed\` (default), \`issue\`, or \`run\` diff --git a/packages/adapters/openclaw/src/server/execute-common.ts b/packages/adapters/openclaw/src/server/execute-common.ts index fd0d6484..d2c71583 100644 --- a/packages/adapters/openclaw/src/server/execute-common.ts +++ b/packages/adapters/openclaw/src/server/execute-common.ts @@ -5,6 +5,7 @@ import { parseOpenClawResponse } from "./parse.js"; export type OpenClawTransport = "sse" | "webhook"; export type SessionKeyStrategy = "fixed" | "issue" | "run"; +export type OpenClawEndpointKind = "open_responses" | "hook_wake" | "hook_agent" | "generic"; export type WakePayload = { runId: string; @@ -31,7 +32,7 @@ export type OpenClawExecutionState = { }; const SENSITIVE_LOG_KEY_PATTERN = - /(^|[_-])(auth|authorization|token|secret|password|api[_-]?key|private[_-]?key)([_-]|$)|^x-openclaw-auth$/i; + /(^|[_-])(auth|authorization|token|secret|password|api[_-]?key|private[_-]?key)([_-]|$)|^x-openclaw-(auth|token)$/i; export function nonEmpty(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; @@ -73,11 +74,54 @@ export function resolveSessionKey(input: { return fallback; } +function normalizeUrlPath(pathname: string): string { + const trimmed = pathname.trim().toLowerCase(); + if (!trimmed) return "/"; + return trimmed.endsWith("/") && trimmed !== "/" ? trimmed.slice(0, -1) : trimmed; +} + +function isWakePath(pathname: string): boolean { + const normalized = normalizeUrlPath(pathname); + return normalized === "/hooks/wake" || normalized.endsWith("/hooks/wake"); +} + +function isHookAgentPath(pathname: string): boolean { + const normalized = normalizeUrlPath(pathname); + return normalized === "/hooks/agent" || normalized.endsWith("/hooks/agent"); +} + +function isHookPath(pathname: string): boolean { + const normalized = normalizeUrlPath(pathname); + return ( + normalized === "/hooks" || + normalized.startsWith("/hooks/") || + normalized.endsWith("/hooks") || + normalized.includes("/hooks/") + ); +} + +export function isHookEndpoint(url: string): boolean { + try { + const parsed = new URL(url); + return isHookPath(parsed.pathname); + } catch { + return false; + } +} + export function isWakeCompatibilityEndpoint(url: string): boolean { try { const parsed = new URL(url); - const path = parsed.pathname.toLowerCase(); - return path === "/hooks/wake" || path.endsWith("/hooks/wake"); + return isWakePath(parsed.pathname); + } catch { + return false; + } +} + +export function isHookAgentEndpoint(url: string): boolean { + try { + const parsed = new URL(url); + return isHookAgentPath(parsed.pathname); } catch { return false; } @@ -86,13 +130,38 @@ export function isWakeCompatibilityEndpoint(url: string): boolean { export function isOpenResponsesEndpoint(url: string): boolean { try { const parsed = new URL(url); - const path = parsed.pathname.toLowerCase(); + const path = normalizeUrlPath(parsed.pathname); return path === "/v1/responses" || path.endsWith("/v1/responses"); } catch { return false; } } +export function resolveEndpointKind(url: string): OpenClawEndpointKind { + if (isOpenResponsesEndpoint(url)) return "open_responses"; + if (isWakeCompatibilityEndpoint(url)) return "hook_wake"; + if (isHookAgentEndpoint(url)) return "hook_agent"; + return "generic"; +} + +export function deriveHookAgentUrlFromResponses(url: string): string | null { + try { + const parsed = new URL(url); + const path = normalizeUrlPath(parsed.pathname); + if (path === "/v1/responses") { + parsed.pathname = "/hooks/agent"; + return parsed.toString(); + } + if (path.endsWith("/v1/responses")) { + parsed.pathname = `${path.slice(0, -"/v1/responses".length)}/hooks/agent`; + return parsed.toString(); + } + return null; + } catch { + return null; + } +} + export function toStringRecord(value: unknown): Record { const parsed = parseObject(value); const out: Record = {}; @@ -390,7 +459,12 @@ export function buildExecutionState(ctx: AdapterExecutionContext): OpenClawExecu } } - const openClawAuthHeader = nonEmpty(headers["x-openclaw-auth"] ?? headers["X-OpenClaw-Auth"]); + const openClawAuthHeader = nonEmpty( + headers["x-openclaw-token"] ?? + headers["X-OpenClaw-Token"] ?? + headers["x-openclaw-auth"] ?? + headers["X-OpenClaw-Auth"], + ); if (openClawAuthHeader && !headers.authorization && !headers.Authorization) { headers.authorization = toAuthorizationHeaderValue(openClawAuthHeader); } diff --git a/packages/adapters/openclaw/src/server/execute-webhook.ts b/packages/adapters/openclaw/src/server/execute-webhook.ts index f76d6729..a4f55989 100644 --- a/packages/adapters/openclaw/src/server/execute-webhook.ts +++ b/packages/adapters/openclaw/src/server/execute-webhook.ts @@ -4,15 +4,16 @@ import { appendWakeTextToOpenResponsesInput, buildExecutionState, buildWakeCompatibilityPayload, - isOpenResponsesEndpoint, + deriveHookAgentUrlFromResponses, isTextRequiredResponse, isWakeCompatibilityRetryableResponse, - isWakeCompatibilityEndpoint, readAndLogResponseText, redactForLog, + resolveEndpointKind, sendJsonRequest, stringifyForLog, toStringRecord, + type OpenClawEndpointKind, type OpenClawExecutionState, } from "./execute-common.js"; import { parseOpenClawResponse } from "./parse.js"; @@ -21,39 +22,132 @@ function nonEmpty(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } -function buildWebhookBody(input: { - url: string; +function asBooleanFlag(value: unknown, fallback = false): boolean { + if (typeof value === "boolean") return value; + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (normalized === "true" || normalized === "1") return true; + if (normalized === "false" || normalized === "0") return false; + } + return fallback; +} + +function normalizeWakeMode(value: unknown): "now" | "next-heartbeat" | null { + if (typeof value !== "string") return null; + const normalized = value.trim().toLowerCase(); + if (normalized === "now" || normalized === "next-heartbeat") return normalized; + return null; +} + +function parseOptionalPositiveInteger(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) { + const normalized = Math.max(1, Math.floor(value)); + return Number.isFinite(normalized) ? normalized : null; + } + if (typeof value === "string" && value.trim().length > 0) { + const parsed = Number.parseInt(value.trim(), 10); + if (Number.isFinite(parsed)) { + const normalized = Math.max(1, Math.floor(parsed)); + return Number.isFinite(normalized) ? normalized : null; + } + } + return null; +} + +function buildOpenResponsesWebhookBody(input: { state: OpenClawExecutionState; - context: AdapterExecutionContext["context"]; configModel: unknown; }): Record { - const { url, state, context, configModel } = input; + const { state, configModel } = input; const templateText = nonEmpty(state.payloadTemplate.text); const payloadText = templateText ? appendWakeText(templateText, state.wakeText) : state.wakeText; - const isOpenResponses = isOpenResponsesEndpoint(url); + const openResponsesInput = Object.prototype.hasOwnProperty.call(state.payloadTemplate, "input") + ? appendWakeTextToOpenResponsesInput(state.payloadTemplate.input, state.wakeText) + : payloadText; - if (isOpenResponses) { - const openResponsesInput = Object.prototype.hasOwnProperty.call(state.payloadTemplate, "input") - ? appendWakeTextToOpenResponsesInput(state.payloadTemplate.input, state.wakeText) - : payloadText; + return { + ...state.payloadTemplate, + stream: false, + model: + nonEmpty(state.payloadTemplate.model) ?? + nonEmpty(configModel) ?? + "openclaw", + input: openResponsesInput, + metadata: { + ...toStringRecord(state.payloadTemplate.metadata), + ...state.paperclipEnv, + paperclip_session_key: state.sessionKey, + paperclip_stream_transport: "webhook", + }, + }; +} - return { - ...state.payloadTemplate, - stream: false, - model: - nonEmpty(state.payloadTemplate.model) ?? - nonEmpty(configModel) ?? - "openclaw", - input: openResponsesInput, - metadata: { - ...toStringRecord(state.payloadTemplate.metadata), - ...state.paperclipEnv, - paperclip_session_key: state.sessionKey, - paperclip_stream_transport: "webhook", - }, - }; +function buildHookWakeBody(state: OpenClawExecutionState): Record { + const templateText = nonEmpty(state.payloadTemplate.text) ?? nonEmpty(state.payloadTemplate.message); + const payloadText = templateText ? appendWakeText(templateText, state.wakeText) : state.wakeText; + const wakeMode = normalizeWakeMode(state.payloadTemplate.mode ?? state.payloadTemplate.wakeMode) ?? "now"; + + return { + text: payloadText, + mode: wakeMode, + }; +} + +function buildHookAgentBody(input: { + state: OpenClawExecutionState; + includeSessionKey: boolean; +}): Record { + const { state, includeSessionKey } = input; + const templateMessage = nonEmpty(state.payloadTemplate.message) ?? nonEmpty(state.payloadTemplate.text); + const message = templateMessage ? appendWakeText(templateMessage, state.wakeText) : state.wakeText; + const payload: Record = { + message, + }; + + const name = nonEmpty(state.payloadTemplate.name); + if (name) payload.name = name; + + const agentId = nonEmpty(state.payloadTemplate.agentId); + if (agentId) payload.agentId = agentId; + + const wakeMode = normalizeWakeMode(state.payloadTemplate.wakeMode ?? state.payloadTemplate.mode); + if (wakeMode) payload.wakeMode = wakeMode; + + const deliver = state.payloadTemplate.deliver; + if (typeof deliver === "boolean") payload.deliver = deliver; + + const channel = nonEmpty(state.payloadTemplate.channel); + if (channel) payload.channel = channel; + + const to = nonEmpty(state.payloadTemplate.to); + if (to) payload.to = to; + + const model = nonEmpty(state.payloadTemplate.model); + if (model) payload.model = model; + + const thinking = nonEmpty(state.payloadTemplate.thinking); + if (thinking) payload.thinking = thinking; + + const timeoutSeconds = parseOptionalPositiveInteger(state.payloadTemplate.timeoutSeconds); + if (timeoutSeconds != null) payload.timeoutSeconds = timeoutSeconds; + + const explicitSessionKey = nonEmpty(state.payloadTemplate.sessionKey); + if (explicitSessionKey) { + payload.sessionKey = explicitSessionKey; + } else if (includeSessionKey) { + payload.sessionKey = state.sessionKey; } + return payload; +} + +function buildLegacyWebhookBody(input: { + state: OpenClawExecutionState; + context: AdapterExecutionContext["context"]; +}): Record { + const { state, context } = input; + const templateText = nonEmpty(state.payloadTemplate.text); + const payloadText = templateText ? appendWakeText(templateText, state.wakeText) : state.wakeText; return { ...state.payloadTemplate, stream: false, @@ -69,6 +163,27 @@ function buildWebhookBody(input: { }; } +function buildWebhookBody(input: { + endpointKind: OpenClawEndpointKind; + state: OpenClawExecutionState; + context: AdapterExecutionContext["context"]; + configModel: unknown; + includeHookSessionKey: boolean; +}): Record { + const { endpointKind, state, context, configModel, includeHookSessionKey } = input; + if (endpointKind === "open_responses") { + return buildOpenResponsesWebhookBody({ state, configModel }); + } + if (endpointKind === "hook_wake") { + return buildHookWakeBody(state); + } + if (endpointKind === "hook_agent") { + return buildHookAgentBody({ state, includeSessionKey: includeHookSessionKey }); + } + + return buildLegacyWebhookBody({ state, context }); +} + async function sendWebhookRequest(params: { url: string; method: string; @@ -92,30 +207,50 @@ async function sendWebhookRequest(params: { export async function executeWebhook(ctx: AdapterExecutionContext, url: string): Promise { const { onLog, onMeta, context } = ctx; const state = buildExecutionState(ctx); + const originalUrl = url; + const originalEndpointKind = resolveEndpointKind(originalUrl); + let targetUrl = originalUrl; + let endpointKind = resolveEndpointKind(targetUrl); + const remappedFromResponses = originalEndpointKind === "open_responses"; + + // In webhook mode, /v1/responses is legacy wiring. Prefer hooks/agent. + if (remappedFromResponses) { + const rewritten = deriveHookAgentUrlFromResponses(targetUrl); + if (rewritten) { + await onLog( + "stdout", + `[openclaw] webhook transport selected; remapping ${targetUrl} -> ${rewritten}\n`, + ); + targetUrl = rewritten; + endpointKind = resolveEndpointKind(targetUrl); + } + } + + const headers = { ...state.headers }; + if (endpointKind === "open_responses" && !headers["x-openclaw-session-key"] && !headers["X-OpenClaw-Session-Key"]) { + headers["x-openclaw-session-key"] = state.sessionKey; + } if (onMeta) { await onMeta({ adapterType: "openclaw", command: "webhook", - commandArgs: [state.method, url], + commandArgs: [state.method, targetUrl], context, }); } - const headers = { ...state.headers }; - if (isOpenResponsesEndpoint(url) && !headers["x-openclaw-session-key"] && !headers["X-OpenClaw-Session-Key"]) { - headers["x-openclaw-session-key"] = state.sessionKey; - } - + const includeHookSessionKey = asBooleanFlag(ctx.config.hookIncludeSessionKey, false); const webhookBody = buildWebhookBody({ - url, + endpointKind, state, context, configModel: ctx.config.model, + includeHookSessionKey, }); const wakeCompatibilityBody = buildWakeCompatibilityPayload(state.wakeText); - const preferWakeCompatibilityBody = isWakeCompatibilityEndpoint(url); - const initialBody = preferWakeCompatibilityBody ? wakeCompatibilityBody : webhookBody; + const preferWakeCompatibilityBody = endpointKind === "hook_wake"; + const initialBody = webhookBody; const outboundHeaderKeys = Object.keys(headers).sort(); await onLog( @@ -127,10 +262,10 @@ 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`); + await onLog("stdout", `[openclaw] invoking ${state.method} ${targetUrl} (transport=webhook kind=${endpointKind})\n`); if (preferWakeCompatibilityBody) { - await onLog("stdout", "[openclaw] using wake text payload for /hooks/wake compatibility\n"); + await onLog("stdout", "[openclaw] using webhook wake payload for /hooks/wake\n"); } const controller = new AbortController(); @@ -138,7 +273,7 @@ export async function executeWebhook(ctx: AdapterExecutionContext, url: string): try { const initialResponse = await sendWebhookRequest({ - url, + url: targetUrl, method: state.method, headers, payload: initialBody, @@ -146,9 +281,70 @@ export async function executeWebhook(ctx: AdapterExecutionContext, url: string): signal: controller.signal, }); - if (!initialResponse.response.ok) { + let activeResponse = initialResponse; + let activeEndpointKind = endpointKind; + let activeUrl = targetUrl; + let activeHeaders = headers; + let usedLegacyResponsesFallback = false; + + if ( + remappedFromResponses && + targetUrl !== originalUrl && + initialResponse.response.status === 404 + ) { + await onLog( + "stdout", + `[openclaw] remapped hook endpoint returned 404; retrying legacy endpoint ${originalUrl}\n`, + ); + + activeEndpointKind = originalEndpointKind; + activeUrl = originalUrl; + usedLegacyResponsesFallback = true; + const fallbackHeaders = { ...state.headers }; + if ( + activeEndpointKind === "open_responses" && + !fallbackHeaders["x-openclaw-session-key"] && + !fallbackHeaders["X-OpenClaw-Session-Key"] + ) { + fallbackHeaders["x-openclaw-session-key"] = state.sessionKey; + } + + const fallbackBody = buildWebhookBody({ + endpointKind: activeEndpointKind, + state, + context, + configModel: ctx.config.model, + includeHookSessionKey, + }); + + await onLog( + "stdout", + `[openclaw] fallback headers (redacted): ${stringifyForLog(redactForLog(fallbackHeaders), 4_000)}\n`, + ); + await onLog( + "stdout", + `[openclaw] fallback payload (redacted): ${stringifyForLog(redactForLog(fallbackBody), 12_000)}\n`, + ); + await onLog( + "stdout", + `[openclaw] invoking fallback ${state.method} ${activeUrl} (transport=webhook kind=${activeEndpointKind})\n`, + ); + + activeResponse = await sendWebhookRequest({ + url: activeUrl, + method: state.method, + headers: fallbackHeaders, + payload: fallbackBody, + onLog, + signal: controller.signal, + }); + activeHeaders = fallbackHeaders; + } + + if (!activeResponse.response.ok) { const canRetryWithWakeCompatibility = - !preferWakeCompatibilityBody && isWakeCompatibilityRetryableResponse(initialResponse.responseText); + (activeEndpointKind === "open_responses" || activeEndpointKind === "generic") && + isWakeCompatibilityRetryableResponse(activeResponse.responseText); if (canRetryWithWakeCompatibility) { await onLog( @@ -157,9 +353,9 @@ export async function executeWebhook(ctx: AdapterExecutionContext, url: string): ); const retryResponse = await sendWebhookRequest({ - url, + url: activeUrl, method: state.method, - headers, + headers: activeHeaders, payload: wakeCompatibilityBody, onLog, signal: controller.signal, @@ -172,11 +368,12 @@ 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} ${activeUrl} (wake compatibility)`, resultJson: { status: retryResponse.response.status, statusText: retryResponse.response.statusText, compatibilityMode: "wake_text", + usedLegacyResponsesFallback, response: parseOpenClawResponse(retryResponse.responseText) ?? retryResponse.responseText, }, }; @@ -203,20 +400,20 @@ export async function executeWebhook(ctx: AdapterExecutionContext, url: string): } return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: - isTextRequiredResponse(initialResponse.responseText) - ? "OpenClaw endpoint rejected the payload as text-required." - : `OpenClaw webhook failed with status ${initialResponse.response.status}`, - errorCode: isTextRequiredResponse(initialResponse.responseText) + exitCode: 1, + signal: null, + timedOut: false, + errorMessage: + isTextRequiredResponse(activeResponse.responseText) + ? "OpenClaw endpoint rejected the payload as text-required." + : `OpenClaw webhook failed with status ${activeResponse.response.status}`, + errorCode: isTextRequiredResponse(activeResponse.responseText) ? "openclaw_text_required" : "openclaw_http_error", resultJson: { - status: initialResponse.response.status, - statusText: initialResponse.response.statusText, - response: parseOpenClawResponse(initialResponse.responseText) ?? initialResponse.responseText, + status: activeResponse.response.status, + statusText: activeResponse.response.statusText, + response: parseOpenClawResponse(activeResponse.responseText) ?? activeResponse.responseText, }, }; } @@ -227,11 +424,12 @@ 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} ${activeUrl}`, resultJson: { - status: initialResponse.response.status, - statusText: initialResponse.response.statusText, - response: parseOpenClawResponse(initialResponse.responseText) ?? initialResponse.responseText, + status: activeResponse.response.status, + statusText: activeResponse.response.statusText, + usedLegacyResponsesFallback, + response: parseOpenClawResponse(activeResponse.responseText) ?? activeResponse.responseText, }, }; } catch (err) { diff --git a/packages/adapters/openclaw/src/server/execute.ts b/packages/adapters/openclaw/src/server/execute.ts index 68b651ea..c560a067 100644 --- a/packages/adapters/openclaw/src/server/execute.ts +++ b/packages/adapters/openclaw/src/server/execute.ts @@ -1,6 +1,6 @@ import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; import { asString } from "@paperclipai/adapter-utils/server-utils"; -import { isWakeCompatibilityEndpoint } from "./execute-common.js"; +import { isHookEndpoint } from "./execute-common.js"; import { executeSse } from "./execute-sse.js"; import { executeWebhook } from "./execute-webhook.js"; @@ -35,12 +35,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise { expect(headers.authorization).toBe("Bearer gateway-token"); }); + it("derives Authorization header from x-openclaw-token when webhookAuthHeader is unset", 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", + headers: { + "x-openclaw-token": "gateway-token", + }, + }), + ); + + expect(result.exitCode).toBe(0); + const headers = (fetchMock.mock.calls[0]?.[1]?.headers ?? {}) as Record; + expect(headers["x-openclaw-token"]).toBe("gateway-token"); + expect(headers.authorization).toBe("Bearer gateway-token"); + }); + it("derives issue session keys when configured", async () => { const fetchMock = vi.fn().mockResolvedValue( sseResponse([ @@ -564,7 +589,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("remaps legacy /v1/responses URLs to /hooks/agent in webhook transport", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ ok: true }), { status: 200, @@ -586,16 +611,69 @@ describe("openclaw adapter execute", () => { expect(result.exitCode).toBe(0); expect(fetchMock).toHaveBeenCalledTimes(1); + expect(String(fetchMock.mock.calls[0]?.[0] ?? "")).toBe("https://agent.example/hooks/agent"); 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(typeof body.message).toBe("string"); + expect(String(body.message ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); + expect(body.stream).toBeUndefined(); + expect(body.input).toBeUndefined(); + expect(body.metadata).toBeUndefined(); expect(body.paperclip).toBeUndefined(); + const headers = (fetchMock.mock.calls[0]?.[1]?.headers ?? {}) as Record; + expect(headers["x-openclaw-session-key"]).toBeUndefined(); + }); + + it("falls back to legacy /v1/responses when remapped /hooks/agent returns 404", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response("Not Found", { + status: 404, + statusText: "Not Found", + headers: { + "content-type": "text/plain", + }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true }), { + status: 200, + statusText: "OK", + headers: { + "content-type": "application/json", + }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const result = await execute( + buildContext({ + url: "https://agent.example/v1/responses", + streamTransport: "webhook", + }), + ); + + expect(result.exitCode).toBe(0); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(String(fetchMock.mock.calls[0]?.[0] ?? "")).toBe("https://agent.example/hooks/agent"); + expect(String(fetchMock.mock.calls[1]?.[0] ?? "")).toBe("https://agent.example/v1/responses"); + + const firstBody = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; + expect(typeof firstBody.message).toBe("string"); + expect(String(firstBody.message ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); + + const secondBody = JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body ?? "{}")) as Record; + expect(secondBody.stream).toBe(false); + expect(typeof secondBody.input).toBe("string"); + expect(String(secondBody.input ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); + + const secondHeaders = (fetchMock.mock.calls[1]?.[1]?.headers ?? {}) as Record; + expect(secondHeaders["x-openclaw-session-key"]).toBe("paperclip"); + expect(result.resultJson).toEqual( + expect.objectContaining({ + usedLegacyResponsesFallback: true, + }), + ); }); it("uses wake compatibility payloads for /hooks/wake when transport=webhook", async () => { @@ -624,6 +702,73 @@ describe("openclaw adapter execute", () => { expect(body.paperclip).toBeUndefined(); }); + it("uses /hooks/agent payloads for webhook transport and omits sessionKey by default", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + status: 200, + statusText: "OK", + headers: { + "content-type": "application/json", + }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const result = await execute( + buildContext({ + url: "https://agent.example/hooks/agent", + streamTransport: "webhook", + payloadTemplate: { + name: "Paperclip Hook", + wakeMode: "next-heartbeat", + deliver: true, + channel: "last", + model: "openai/gpt-5.2-mini", + }, + }), + ); + + expect(result.exitCode).toBe(0); + expect(fetchMock).toHaveBeenCalledTimes(1); + const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; + expect(typeof body.message).toBe("string"); + expect(String(body.message)).toContain("PAPERCLIP_RUN_ID=run-123"); + expect(body.name).toBe("Paperclip Hook"); + expect(body.wakeMode).toBe("next-heartbeat"); + expect(body.deliver).toBe(true); + expect(body.channel).toBe("last"); + expect(body.model).toBe("openai/gpt-5.2-mini"); + expect(body.sessionKey).toBeUndefined(); + expect(body.text).toBeUndefined(); + expect(body.paperclip).toBeUndefined(); + }); + + it("includes sessionKey for /hooks/agent payloads only when hookIncludeSessionKey=true", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + status: 200, + statusText: "OK", + headers: { + "content-type": "application/json", + }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const result = await execute( + buildContext({ + url: "https://agent.example/hooks/agent", + streamTransport: "webhook", + hookIncludeSessionKey: true, + }), + ); + + expect(result.exitCode).toBe(0); + expect(fetchMock).toHaveBeenCalledTimes(1); + const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; + expect(body.sessionKey).toBe("paperclip"); + }); + it("retries webhook payloads with wake compatibility format on text-required errors", async () => { const fetchMock = vi .fn() @@ -649,7 +794,7 @@ describe("openclaw adapter execute", () => { const result = await execute( buildContext({ - url: "https://agent.example/v1/responses", + url: "https://agent.example/webhook", streamTransport: "webhook", }), ); @@ -658,8 +803,8 @@ 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(String(firstBody.text ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); + expect(firstBody.paperclip).toBeTypeOf("object"); expect(secondBody.mode).toBe("now"); expect(String(secondBody.text ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); }); @@ -697,7 +842,7 @@ describe("openclaw adapter execute", () => { const result = await execute( buildContext({ - url: "https://agent.example/v1/responses", + url: "https://agent.example/webhook", streamTransport: "webhook", }), ); @@ -739,6 +884,21 @@ describe("openclaw adapter execute", () => { expect(result.errorCode).toBe("openclaw_sse_incompatible_endpoint"); expect(fetchMock).not.toHaveBeenCalled(); }); + + it("rejects /hooks/agent endpoints in SSE mode", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const result = await execute( + buildContext({ + url: "https://agent.example/hooks/agent", + }), + ); + + expect(result.exitCode).toBe(1); + expect(result.errorCode).toBe("openclaw_sse_incompatible_endpoint"); + expect(fetchMock).not.toHaveBeenCalled(); + }); }); describe("openclaw adapter environment checks", () => { @@ -766,6 +926,24 @@ describe("openclaw adapter environment checks", () => { expect(check?.level).toBe("error"); }); + it("reports /hooks/agent endpoints as incompatible for SSE mode", 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/agent", + }, + }); + + const check = result.checks.find((entry) => entry.code === "openclaw_wake_endpoint_incompatible"); + expect(check?.level).toBe("error"); + }); + it("reports unsupported streamTransport settings", async () => { const fetchMock = vi .fn() From e4928f3a10c45eda48d3243c21923fbf8c9544b9 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 6 Mar 2026 16:50:20 -0600 Subject: [PATCH 7/8] feat(openclaw): support x-openclaw-token header alongside legacy x-openclaw-auth Accept x-openclaw-token as the preferred auth header for OpenClaw invite/join flows, falling back to x-openclaw-auth for backwards compatibility. Update diagnostics messages accordingly. Co-Authored-By: Claude Opus 4.6 --- .../invite-accept-openclaw-defaults.test.ts | 35 +++++++++++++++++ server/src/routes/access.ts | 38 ++++++++++++++----- 2 files changed, 64 insertions(+), 9 deletions(-) diff --git a/server/src/__tests__/invite-accept-openclaw-defaults.test.ts b/server/src/__tests__/invite-accept-openclaw-defaults.test.ts index b886e210..b94dd55d 100644 --- a/server/src/__tests__/invite-accept-openclaw-defaults.test.ts +++ b/server/src/__tests__/invite-accept-openclaw-defaults.test.ts @@ -90,6 +90,41 @@ describe("buildJoinDefaultsPayloadForAccept", () => { }); }); + it("accepts auth from agentDefaultsPayload.headers.x-openclaw-token", () => { + const result = buildJoinDefaultsPayloadForAccept({ + adapterType: "openclaw", + defaultsPayload: { + url: "http://127.0.0.1:18789/hooks/agent", + method: "POST", + headers: { + "x-openclaw-token": "gateway-token", + }, + }, + }) as Record; + + expect(result).toMatchObject({ + headers: { + "x-openclaw-token": "gateway-token", + }, + webhookAuthHeader: "Bearer gateway-token", + }); + }); + + it("accepts inbound x-openclaw-token compatibility header", () => { + const result = buildJoinDefaultsPayloadForAccept({ + adapterType: "openclaw", + defaultsPayload: null, + inboundOpenClawTokenHeader: "gateway-token", + }) as Record; + + expect(result).toMatchObject({ + headers: { + "x-openclaw-token": "gateway-token", + }, + webhookAuthHeader: "Bearer gateway-token", + }); + }); + it("accepts wrapped auth values in headers for compatibility", () => { const result = buildJoinDefaultsPayloadForAccept({ adapterType: "openclaw", diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 9f115299..186b8515 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -320,6 +320,7 @@ export function buildJoinDefaultsPayloadForAccept(input: { paperclipApiUrl?: unknown; webhookAuthHeader?: unknown; inboundOpenClawAuthHeader?: string | null; + inboundOpenClawTokenHeader?: string | null; }): unknown { if (input.adapterType !== "openclaw") { return input.defaultsPayload; @@ -367,6 +368,15 @@ export function buildJoinDefaultsPayloadForAccept(input: { const inboundOpenClawAuthHeader = nonEmptyTrimmedString( input.inboundOpenClawAuthHeader ); + const inboundOpenClawTokenHeader = nonEmptyTrimmedString( + input.inboundOpenClawTokenHeader + ); + if ( + inboundOpenClawTokenHeader && + !headerMapHasKeyIgnoreCase(mergedHeaders, "x-openclaw-token") + ) { + mergedHeaders["x-openclaw-token"] = inboundOpenClawTokenHeader; + } if ( inboundOpenClawAuthHeader && !headerMapHasKeyIgnoreCase(mergedHeaders, "x-openclaw-auth") @@ -388,7 +398,9 @@ export function buildJoinDefaultsPayloadForAccept(input: { nonEmptyTrimmedString(merged.webhookAuthHeader) ); if (!hasAuthorizationHeader && !hasWebhookAuthHeader) { - const openClawAuthToken = headerMapGetIgnoreCase( + const openClawAuthToken = + headerMapGetIgnoreCase(mergedHeaders, "x-openclaw-token") ?? + headerMapGetIgnoreCase( mergedHeaders, "x-openclaw-auth" ); @@ -484,9 +496,8 @@ function summarizeOpenClawDefaultsForLog(defaultsPayload: unknown) { : null; const headers = defaults ? normalizeHeaderMap(defaults.headers) : undefined; const openClawAuthHeaderValue = headers - ? Object.entries(headers).find( - ([key]) => key.trim().toLowerCase() === "x-openclaw-auth" - )?.[1] ?? null + ? headerMapGetIgnoreCase(headers, "x-openclaw-token") ?? + headerMapGetIgnoreCase(headers, "x-openclaw-auth") : null; return { @@ -703,20 +714,23 @@ function normalizeAgentDefaultsForJoin(input: { } const openClawAuthHeader = headers - ? headerMapGetIgnoreCase(headers, "x-openclaw-auth") + ? headerMapGetIgnoreCase(headers, "x-openclaw-token") ?? + headerMapGetIgnoreCase(headers, "x-openclaw-auth") : null; if (openClawAuthHeader) { diagnostics.push({ code: "openclaw_auth_header_configured", level: "info", - message: "Gateway auth token received via headers.x-openclaw-auth." + message: + "Gateway auth token received via headers.x-openclaw-token (or legacy x-openclaw-auth)." }); } else { diagnostics.push({ code: "openclaw_auth_header_missing", level: "warn", message: "Gateway auth token is missing from agent defaults.", - hint: "Set agentDefaultsPayload.headers.x-openclaw-auth to the token your OpenClaw endpoint requires." + hint: + "Set agentDefaultsPayload.headers.x-openclaw-token (or legacy x-openclaw-auth) to the token your OpenClaw endpoint requires." }); } @@ -1894,7 +1908,8 @@ export function accessRoutes( responsesWebhookHeaders: req.body.responsesWebhookHeaders ?? null, paperclipApiUrl: req.body.paperclipApiUrl ?? null, webhookAuthHeader: req.body.webhookAuthHeader ?? null, - inboundOpenClawAuthHeader: req.header("x-openclaw-auth") ?? null + inboundOpenClawAuthHeader: req.header("x-openclaw-auth") ?? null, + inboundOpenClawTokenHeader: req.header("x-openclaw-token") ?? null }) : null; @@ -1917,6 +1932,9 @@ export function accessRoutes( inboundOpenClawAuthHeader: summarizeSecretForLog( req.header("x-openclaw-auth") ?? null ), + inboundOpenClawTokenHeader: summarizeSecretForLog( + req.header("x-openclaw-token") ?? null + ), rawAgentDefaults: summarizeOpenClawDefaultsForLog( req.body.agentDefaultsPayload ?? null ), @@ -2107,7 +2125,9 @@ export function accessRoutes( expectedDefaults.openClawAuthHeader && !persistedDefaults.openClawAuthHeader ) { - missingPersistedFields.push("headers.x-openclaw-auth"); + missingPersistedFields.push( + "headers.x-openclaw-token|headers.x-openclaw-auth" + ); } if ( expectedDefaults.headerKeys.length > 0 && From e693e3d4669a6ad1d8e052b044bf4ac79848f850 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 6 Mar 2026 16:50:25 -0600 Subject: [PATCH 8/8] feat(release): auto-create GitHub Release on publish Add create_github_release helper to release script that creates a GitHub Release via gh CLI after tagging, using release notes from releases/vX.Y.Z.md if available or auto-generated notes. Co-Authored-By: Claude Opus 4.6 --- scripts/release.sh | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/scripts/release.sh b/scripts/release.sh index 520d1966..769b5f47 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -28,6 +28,36 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" CLI_DIR="$REPO_ROOT/cli" +# ── Helper: create GitHub Release ──────────────────────────────────────────── +create_github_release() { + local version="$1" + local is_dry_run="$2" + local release_notes="$REPO_ROOT/releases/v${version}.md" + + if [ "$is_dry_run" = true ]; then + echo " [dry-run] gh release create v$version" + return + fi + + if ! command -v gh &>/dev/null; then + echo " ⚠ gh CLI not found — skipping GitHub Release" + return + fi + + local gh_args=(gh release create "v$version" --title "v$version") + if [ -f "$release_notes" ]; then + gh_args+=(--notes-file "$release_notes") + else + gh_args+=(--generate-notes) + fi + + if "${gh_args[@]}"; then + echo " ✓ Created GitHub Release v$version" + else + echo " ⚠ GitHub Release creation failed (non-fatal)" + fi +} + # ── Parse args ──────────────────────────────────────────────────────────────── dry_run=false @@ -141,11 +171,14 @@ console.log(names.join('\n')); echo " ✓ Committed and tagged v$NEW_VERSION" fi + create_github_release "$NEW_VERSION" "$dry_run" + echo "" if [ "$dry_run" = true ]; then echo "Dry run complete for promote v$NEW_VERSION." echo " - Would promote all packages to @latest" echo " - Would commit and tag v$NEW_VERSION" + echo " - Would create GitHub Release" else echo "Promoted all packages to @latest at v$NEW_VERSION" echo "" @@ -346,6 +379,10 @@ if [ "$canary" = false ]; then echo " ✓ Committed and tagged v$NEW_VERSION" fi +if [ "$canary" = false ]; then + create_github_release "$NEW_VERSION" "$dry_run" +fi + # ── Done ────────────────────────────────────────────────────────────────────── echo "" @@ -371,6 +408,7 @@ elif [ "$dry_run" = true ]; then echo " - Versions bumped, built, and previewed" echo " - Dev package.json restored" echo " - Commit and tag created (locally)" + echo " - Would create GitHub Release" echo "" echo "To actually publish, run:" echo " ./scripts/release.sh $bump_type" @@ -379,4 +417,6 @@ else echo "" echo "To push:" echo " git push && git push origin v$NEW_VERSION" + echo "" + echo "GitHub Release: https://github.com/cryppadotta/paperclip/releases/tag/v$NEW_VERSION" fi