From e6339e911db30afdc96f61443e260981a4372e8b Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 6 Mar 2026 09:36:20 -0600 Subject: [PATCH] Fix OpenClaw invite accept config mapping and logging --- packages/shared/src/validators/access.ts | 6 + .../invite-accept-openclaw-defaults.test.ts | 61 +++++++ server/src/routes/access.ts | 150 +++++++++++++++++- 3 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 server/src/__tests__/invite-accept-openclaw-defaults.test.ts diff --git a/packages/shared/src/validators/access.ts b/packages/shared/src/validators/access.ts index 12593d60..6a72149c 100644 --- a/packages/shared/src/validators/access.ts +++ b/packages/shared/src/validators/access.ts @@ -22,6 +22,12 @@ export const acceptInviteSchema = z.object({ adapterType: z.enum(AGENT_ADAPTER_TYPES).optional(), capabilities: z.string().max(4000).optional().nullable(), agentDefaultsPayload: z.record(z.string(), z.unknown()).optional().nullable(), + // OpenClaw join compatibility fields accepted at top level. + responsesWebhookUrl: z.string().max(4000).optional().nullable(), + responsesWebhookMethod: z.string().max(32).optional().nullable(), + responsesWebhookHeaders: z.record(z.string(), z.unknown()).optional().nullable(), + paperclipApiUrl: z.string().max(4000).optional().nullable(), + webhookAuthHeader: z.string().max(4000).optional().nullable(), }); export type AcceptInvite = z.infer; diff --git a/server/src/__tests__/invite-accept-openclaw-defaults.test.ts b/server/src/__tests__/invite-accept-openclaw-defaults.test.ts new file mode 100644 index 00000000..55d5f8b6 --- /dev/null +++ b/server/src/__tests__/invite-accept-openclaw-defaults.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { buildJoinDefaultsPayloadForAccept } from "../routes/access.js"; + +describe("buildJoinDefaultsPayloadForAccept", () => { + it("maps OpenClaw compatibility fields into agent defaults", () => { + const result = buildJoinDefaultsPayloadForAccept({ + adapterType: "openclaw", + defaultsPayload: null, + responsesWebhookUrl: "http://localhost:18789/v1/responses", + paperclipApiUrl: "http://host.docker.internal:3100", + inboundOpenClawAuthHeader: "gateway-token", + }) as Record; + + expect(result).toMatchObject({ + url: "http://localhost:18789/v1/responses", + paperclipApiUrl: "http://host.docker.internal:3100", + headers: { + "x-openclaw-auth": "gateway-token", + }, + }); + }); + + it("does not overwrite explicit OpenClaw defaults when already provided", () => { + const result = buildJoinDefaultsPayloadForAccept({ + adapterType: "openclaw", + defaultsPayload: { + url: "https://example.com/v1/responses", + method: "POST", + headers: { + "x-openclaw-auth": "existing-token", + }, + paperclipApiUrl: "https://paperclip.example.com", + }, + responsesWebhookUrl: "https://legacy.example.com/v1/responses", + responsesWebhookMethod: "PUT", + paperclipApiUrl: "https://legacy-paperclip.example.com", + inboundOpenClawAuthHeader: "legacy-token", + }) as Record; + + expect(result).toMatchObject({ + url: "https://example.com/v1/responses", + method: "POST", + paperclipApiUrl: "https://paperclip.example.com", + headers: { + "x-openclaw-auth": "existing-token", + }, + }); + }); + + it("leaves non-openclaw payloads unchanged", () => { + const defaultsPayload = { command: "echo hello" }; + const result = buildJoinDefaultsPayloadForAccept({ + adapterType: "process", + defaultsPayload, + responsesWebhookUrl: "https://ignored.example.com", + inboundOpenClawAuthHeader: "ignored-token", + }); + + expect(result).toEqual(defaultsPayload); + }); +}); diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 8755ebea..71e34d2b 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -23,6 +23,7 @@ import { } from "@paperclipai/shared"; import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared"; import { forbidden, conflict, notFound, unauthorized, badRequest } from "../errors.js"; +import { logger } from "../middleware/logger.js"; import { validate } from "../middleware/validate.js"; import { accessService, agentService, logActivity, notifyHireApproved } from "../services/index.js"; import { assertCompanyAccess } from "./authz.js"; @@ -135,6 +136,108 @@ function normalizeHeaderMap(input: unknown): Record | undefined return Object.keys(out).length > 0 ? out : undefined; } +function nonEmptyTrimmedString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function headerMapHasKeyIgnoreCase(headers: Record, targetKey: string): boolean { + const normalizedTarget = targetKey.trim().toLowerCase(); + return Object.keys(headers).some((key) => key.trim().toLowerCase() === normalizedTarget); +} + +export function buildJoinDefaultsPayloadForAccept(input: { + adapterType: string | null; + defaultsPayload: unknown; + responsesWebhookUrl?: unknown; + responsesWebhookMethod?: unknown; + responsesWebhookHeaders?: unknown; + paperclipApiUrl?: unknown; + webhookAuthHeader?: unknown; + inboundOpenClawAuthHeader?: string | null; +}): unknown { + if (input.adapterType !== "openclaw") { + return input.defaultsPayload; + } + + const merged = isPlainObject(input.defaultsPayload) + ? { ...(input.defaultsPayload as Record) } + : {} as Record; + + if (!nonEmptyTrimmedString(merged.url)) { + const legacyUrl = nonEmptyTrimmedString(input.responsesWebhookUrl); + if (legacyUrl) merged.url = legacyUrl; + } + + if (!nonEmptyTrimmedString(merged.method)) { + const legacyMethod = nonEmptyTrimmedString(input.responsesWebhookMethod); + if (legacyMethod) merged.method = legacyMethod.toUpperCase(); + } + + if (!nonEmptyTrimmedString(merged.paperclipApiUrl)) { + const legacyPaperclipApiUrl = nonEmptyTrimmedString(input.paperclipApiUrl); + if (legacyPaperclipApiUrl) merged.paperclipApiUrl = legacyPaperclipApiUrl; + } + + if (!nonEmptyTrimmedString(merged.webhookAuthHeader)) { + const providedWebhookAuthHeader = nonEmptyTrimmedString(input.webhookAuthHeader); + if (providedWebhookAuthHeader) merged.webhookAuthHeader = providedWebhookAuthHeader; + } + + const mergedHeaders = normalizeHeaderMap(merged.headers) ?? {}; + const compatibilityHeaders = normalizeHeaderMap(input.responsesWebhookHeaders); + if (compatibilityHeaders) { + for (const [key, value] of Object.entries(compatibilityHeaders)) { + if (!headerMapHasKeyIgnoreCase(mergedHeaders, key)) { + mergedHeaders[key] = value; + } + } + } + + const inboundOpenClawAuthHeader = nonEmptyTrimmedString(input.inboundOpenClawAuthHeader); + if (inboundOpenClawAuthHeader && !headerMapHasKeyIgnoreCase(mergedHeaders, "x-openclaw-auth")) { + mergedHeaders["x-openclaw-auth"] = inboundOpenClawAuthHeader; + } + + if (Object.keys(mergedHeaders).length > 0) { + merged.headers = mergedHeaders; + } else { + delete merged.headers; + } + + return Object.keys(merged).length > 0 ? merged : null; +} + +function summarizeSecretForLog(value: unknown): { present: true; length: number; sha256Prefix: string } | null { + const trimmed = nonEmptyTrimmedString(value); + if (!trimmed) return null; + return { + present: true, + length: trimmed.length, + sha256Prefix: hashToken(trimmed).slice(0, 12), + }; +} + +function summarizeOpenClawDefaultsForLog(defaultsPayload: unknown) { + const defaults = isPlainObject(defaultsPayload) ? (defaultsPayload as Record) : null; + const headers = defaults ? normalizeHeaderMap(defaults.headers) : undefined; + const openClawAuthHeaderValue = headers + ? Object.entries(headers).find(([key]) => key.trim().toLowerCase() === "x-openclaw-auth")?.[1] ?? null + : null; + + return { + present: Boolean(defaults), + keys: defaults ? Object.keys(defaults).sort() : [], + url: defaults ? nonEmptyTrimmedString(defaults.url) : null, + method: defaults ? nonEmptyTrimmedString(defaults.method) : null, + paperclipApiUrl: defaults ? nonEmptyTrimmedString(defaults.paperclipApiUrl) : null, + headerKeys: headers ? Object.keys(headers).sort() : [], + webhookAuthHeader: defaults ? summarizeSecretForLog(defaults.webhookAuthHeader) : null, + openClawAuthHeader: summarizeSecretForLog(openClawAuthHeaderValue), + }; +} + function buildJoinConnectivityDiagnostics(input: { deploymentMode: DeploymentMode; deploymentExposure: DeploymentExposure; @@ -1196,10 +1299,41 @@ export function accessRoutes( throw badRequest("agentName is required for agent join requests"); } + const openClawDefaultsPayload = requestType === "agent" + ? buildJoinDefaultsPayloadForAccept({ + adapterType: req.body.adapterType ?? null, + defaultsPayload: req.body.agentDefaultsPayload ?? null, + responsesWebhookUrl: req.body.responsesWebhookUrl ?? null, + responsesWebhookMethod: req.body.responsesWebhookMethod ?? null, + responsesWebhookHeaders: req.body.responsesWebhookHeaders ?? null, + paperclipApiUrl: req.body.paperclipApiUrl ?? null, + webhookAuthHeader: req.body.webhookAuthHeader ?? null, + inboundOpenClawAuthHeader: req.header("x-openclaw-auth") ?? null, + }) + : null; + + if (requestType === "agent" && (req.body.adapterType ?? null) === "openclaw") { + logger.info( + { + inviteId: invite.id, + requestType, + adapterType: req.body.adapterType ?? null, + bodyKeys: isPlainObject(req.body) ? Object.keys(req.body).sort() : [], + responsesWebhookUrl: nonEmptyTrimmedString(req.body.responsesWebhookUrl), + paperclipApiUrl: nonEmptyTrimmedString(req.body.paperclipApiUrl), + webhookAuthHeader: summarizeSecretForLog(req.body.webhookAuthHeader), + inboundOpenClawAuthHeader: summarizeSecretForLog(req.header("x-openclaw-auth") ?? null), + rawAgentDefaults: summarizeOpenClawDefaultsForLog(req.body.agentDefaultsPayload ?? null), + mergedAgentDefaults: summarizeOpenClawDefaultsForLog(openClawDefaultsPayload), + }, + "invite accept received OpenClaw join payload", + ); + } + const joinDefaults = requestType === "agent" ? normalizeAgentDefaultsForJoin({ adapterType: req.body.adapterType ?? null, - defaultsPayload: req.body.agentDefaultsPayload ?? null, + defaultsPayload: openClawDefaultsPayload, deploymentMode: opts.deploymentMode, deploymentExposure: opts.deploymentExposure, bindHost: opts.bindHost, @@ -1207,6 +1341,20 @@ export function accessRoutes( }) : { normalized: null as Record | null, diagnostics: [] as JoinDiagnostic[] }; + if (requestType === "agent" && (req.body.adapterType ?? null) === "openclaw") { + logger.info( + { + inviteId: invite.id, + joinRequestDiagnostics: joinDefaults.diagnostics.map((diag) => ({ + code: diag.code, + level: diag.level, + })), + normalizedAgentDefaults: summarizeOpenClawDefaultsForLog(joinDefaults.normalized), + }, + "invite accept normalized OpenClaw defaults", + ); + } + const claimSecret = requestType === "agent" ? createClaimSecret() : null; const claimSecretHash = claimSecret ? hashToken(claimSecret) : null; const claimSecretExpiresAt = claimSecret