From 2ec2dcf9c676e39bb52540dd0bad1fd030bec8a0 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 6 Mar 2026 10:14:57 -0600 Subject: [PATCH] Fix OpenClaw auth propagation and debug visibility --- .../adapters/openclaw/src/server/execute.ts | 12 ++++ .../invite-accept-openclaw-defaults.test.ts | 25 ++++++- server/src/__tests__/openclaw-adapter.test.ts | 58 +++++++++++++++ server/src/routes/access.ts | 71 +++++++++++++++++++ ui/src/adapters/openclaw/config-fields.tsx | 31 ++++++++ 5 files changed, 196 insertions(+), 1 deletion(-) diff --git a/packages/adapters/openclaw/src/server/execute.ts b/packages/adapters/openclaw/src/server/execute.ts index 763c2ff3..ac131ef9 100644 --- a/packages/adapters/openclaw/src/server/execute.ts +++ b/packages/adapters/openclaw/src/server/execute.ts @@ -8,6 +8,12 @@ function nonEmpty(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } +function toAuthorizationHeaderValue(rawToken: string): string { + const trimmed = rawToken.trim(); + if (!trimmed) return trimmed; + return /^bearer\s+/i.test(trimmed) ? trimmed : `Bearer ${trimmed}`; +} + function resolvePaperclipApiUrlOverride(value: unknown): string | null { const raw = nonEmpty(value); if (!raw) return null; @@ -499,6 +505,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise { expect(result).toMatchObject({ url: "http://localhost:18789/v1/responses", paperclipApiUrl: "http://host.docker.internal:3100", + webhookAuthHeader: "Bearer gateway-token", headers: { "x-openclaw-auth": "gateway-token", }, }); }); - it("does not overwrite explicit OpenClaw defaults when already provided", () => { + it("does not overwrite explicit OpenClaw endpoint defaults when already provided", () => { const result = buildJoinDefaultsPayloadForAccept({ adapterType: "openclaw", defaultsPayload: { @@ -41,6 +42,28 @@ describe("buildJoinDefaultsPayloadForAccept", () => { url: "https://example.com/v1/responses", method: "POST", paperclipApiUrl: "https://paperclip.example.com", + webhookAuthHeader: "Bearer existing-token", + headers: { + "x-openclaw-auth": "existing-token", + }, + }); + }); + + it("preserves explicit webhookAuthHeader when configured", () => { + const result = buildJoinDefaultsPayloadForAccept({ + adapterType: "openclaw", + defaultsPayload: { + url: "https://example.com/v1/responses", + webhookAuthHeader: "Bearer explicit-token", + headers: { + "x-openclaw-auth": "existing-token", + }, + }, + inboundOpenClawAuthHeader: "legacy-token", + }) as Record; + + expect(result).toMatchObject({ + webhookAuthHeader: "Bearer explicit-token", headers: { "x-openclaw-auth": "existing-token", }, diff --git a/server/src/__tests__/openclaw-adapter.test.ts b/server/src/__tests__/openclaw-adapter.test.ts index 9dc6924d..1ea72f95 100644 --- a/server/src/__tests__/openclaw-adapter.test.ts +++ b/server/src/__tests__/openclaw-adapter.test.ts @@ -223,6 +223,64 @@ describe("openclaw adapter execute", () => { expect(String(body.text ?? "")).toContain("PAPERCLIP_API_URL=http://dotta-macbook-pro:3100/"); }); + it("logs outbound header keys for auth debugging", async () => { + const fetchMock = vi.fn().mockResolvedValue( + sseResponse([ + "event: response.completed\n", + 'data: {"type":"response.completed","status":"completed"}\n\n', + ]), + ); + vi.stubGlobal("fetch", fetchMock); + + const logs: string[] = []; + const result = await execute( + buildContext( + { + url: "https://agent.example/sse", + method: "POST", + headers: { + "x-openclaw-auth": "gateway-token", + }, + }, + { + onLog: async (_stream, chunk) => { + logs.push(chunk); + }, + }, + ), + ); + + expect(result.exitCode).toBe(0); + expect( + logs.some((line) => line.includes("[openclaw] outbound header keys:") && line.includes("x-openclaw-auth")), + ).toBe(true); + }); + + it("derives Authorization header from x-openclaw-auth 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-auth": "gateway-token", + }, + }), + ); + + expect(result.exitCode).toBe(0); + const headers = (fetchMock.mock.calls[0]?.[1]?.headers ?? {}) as Record; + expect(headers["x-openclaw-auth"]).toBe("gateway-token"); + expect(headers.authorization).toBe("Bearer gateway-token"); + }); + it("derives issue session keys when configured", async () => { const fetchMock = vi.fn().mockResolvedValue( sseResponse([ diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 1d16bb96..0db3afbc 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -152,6 +152,20 @@ function headerMapHasKeyIgnoreCase(headers: Record, targetKey: s return Object.keys(headers).some((key) => key.trim().toLowerCase() === normalizedTarget); } +function headerMapGetIgnoreCase(headers: Record, targetKey: string): string | null { + const normalizedTarget = targetKey.trim().toLowerCase(); + const key = Object.keys(headers).find((candidate) => candidate.trim().toLowerCase() === normalizedTarget); + if (!key) return null; + const value = headers[key]; + return typeof value === "string" ? value : null; +} + +function toAuthorizationHeaderValue(rawToken: string): string { + const trimmed = rawToken.trim(); + if (!trimmed) return trimmed; + return /^bearer\s+/i.test(trimmed) ? trimmed : `Bearer ${trimmed}`; +} + export function buildJoinDefaultsPayloadForAccept(input: { adapterType: string | null; defaultsPayload: unknown; @@ -211,6 +225,15 @@ export function buildJoinDefaultsPayloadForAccept(input: { delete merged.headers; } + const hasAuthorizationHeader = headerMapHasKeyIgnoreCase(mergedHeaders, "authorization"); + const hasWebhookAuthHeader = Boolean(nonEmptyTrimmedString(merged.webhookAuthHeader)); + if (!hasAuthorizationHeader && !hasWebhookAuthHeader) { + const openClawAuthToken = headerMapGetIgnoreCase(mergedHeaders, "x-openclaw-auth"); + if (openClawAuthToken) { + merged.webhookAuthHeader = toAuthorizationHeaderValue(openClawAuthToken); + } + } + return Object.keys(merged).length > 0 ? merged : null; } @@ -1395,6 +1418,54 @@ export function accessRoutes( return row; }); + if (requestType === "agent" && (req.body.adapterType ?? null) === "openclaw") { + const expectedDefaults = summarizeOpenClawDefaultsForLog(joinDefaults.normalized); + const persistedDefaults = summarizeOpenClawDefaultsForLog(created.agentDefaultsPayload); + const missingPersistedFields: string[] = []; + + if (expectedDefaults.url && !persistedDefaults.url) missingPersistedFields.push("url"); + if (expectedDefaults.paperclipApiUrl && !persistedDefaults.paperclipApiUrl) { + missingPersistedFields.push("paperclipApiUrl"); + } + if (expectedDefaults.webhookAuthHeader && !persistedDefaults.webhookAuthHeader) { + missingPersistedFields.push("webhookAuthHeader"); + } + if (expectedDefaults.openClawAuthHeader && !persistedDefaults.openClawAuthHeader) { + missingPersistedFields.push("headers.x-openclaw-auth"); + } + if (expectedDefaults.headerKeys.length > 0 && persistedDefaults.headerKeys.length === 0) { + missingPersistedFields.push("headers"); + } + + logger.info( + { + inviteId: invite.id, + joinRequestId: created.id, + joinRequestStatus: created.status, + expectedDefaults, + persistedDefaults, + diagnostics: joinDefaults.diagnostics.map((diag) => ({ + code: diag.code, + level: diag.level, + message: diag.message, + hint: diag.hint ?? null, + })), + }, + "invite accept persisted OpenClaw join request", + ); + + if (missingPersistedFields.length > 0) { + logger.warn( + { + inviteId: invite.id, + joinRequestId: created.id, + missingPersistedFields, + }, + "invite accept detected missing persisted OpenClaw defaults", + ); + } + } + await logActivity(db, { companyId, actorType: req.actor.type === "agent" ? "agent" : "user", diff --git a/ui/src/adapters/openclaw/config-fields.tsx b/ui/src/adapters/openclaw/config-fields.tsx index 85231716..5200256a 100644 --- a/ui/src/adapters/openclaw/config-fields.tsx +++ b/ui/src/adapters/openclaw/config-fields.tsx @@ -16,6 +16,27 @@ export function OpenClawConfigFields({ eff, mark, }: AdapterConfigFieldsProps) { + const configuredHeaders = + config.headers && typeof config.headers === "object" && !Array.isArray(config.headers) + ? (config.headers as Record) + : {}; + const effectiveHeaders = + (eff("adapterConfig", "headers", configuredHeaders) as Record) ?? {}; + const effectiveGatewayAuthHeader = typeof effectiveHeaders["x-openclaw-auth"] === "string" + ? String(effectiveHeaders["x-openclaw-auth"]) + : ""; + + const commitGatewayAuthHeader = (rawValue: string) => { + const nextValue = rawValue.trim(); + const nextHeaders: Record = { ...effectiveHeaders }; + if (nextValue) { + nextHeaders["x-openclaw-auth"] = nextValue; + } else { + delete nextHeaders["x-openclaw-auth"]; + } + mark("adapterConfig", "headers", Object.keys(nextHeaders).length > 0 ? nextHeaders : undefined); + }; + const transport = eff( "adapterConfig", "streamTransport", @@ -110,6 +131,16 @@ export function OpenClawConfigFields({ placeholder="Bearer " /> + + + + )}