Fix OpenClaw invite accept config mapping and logging

This commit is contained in:
Dotta
2026-03-06 09:36:20 -06:00
parent c0c64fe682
commit e6339e911d
3 changed files with 216 additions and 1 deletions

View File

@@ -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<typeof acceptInviteSchema>;

View File

@@ -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<string, unknown>;
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<string, unknown>;
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);
});
});

View File

@@ -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<string, string> | 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<string, string>, 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<string, unknown>) }
: {} as Record<string, unknown>;
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<string, 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
: 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<string, unknown> | 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