Fix OpenClaw invite accept config mapping and logging
This commit is contained in:
@@ -22,6 +22,12 @@ export const acceptInviteSchema = z.object({
|
|||||||
adapterType: z.enum(AGENT_ADAPTER_TYPES).optional(),
|
adapterType: z.enum(AGENT_ADAPTER_TYPES).optional(),
|
||||||
capabilities: z.string().max(4000).optional().nullable(),
|
capabilities: z.string().max(4000).optional().nullable(),
|
||||||
agentDefaultsPayload: z.record(z.string(), z.unknown()).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>;
|
export type AcceptInvite = z.infer<typeof acceptInviteSchema>;
|
||||||
|
|||||||
61
server/src/__tests__/invite-accept-openclaw-defaults.test.ts
Normal file
61
server/src/__tests__/invite-accept-openclaw-defaults.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared";
|
import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared";
|
||||||
import { forbidden, conflict, notFound, unauthorized, badRequest } from "../errors.js";
|
import { forbidden, conflict, notFound, unauthorized, badRequest } from "../errors.js";
|
||||||
|
import { logger } from "../middleware/logger.js";
|
||||||
import { validate } from "../middleware/validate.js";
|
import { validate } from "../middleware/validate.js";
|
||||||
import { accessService, agentService, logActivity, notifyHireApproved } from "../services/index.js";
|
import { accessService, agentService, logActivity, notifyHireApproved } from "../services/index.js";
|
||||||
import { assertCompanyAccess } from "./authz.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;
|
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: {
|
function buildJoinConnectivityDiagnostics(input: {
|
||||||
deploymentMode: DeploymentMode;
|
deploymentMode: DeploymentMode;
|
||||||
deploymentExposure: DeploymentExposure;
|
deploymentExposure: DeploymentExposure;
|
||||||
@@ -1196,10 +1299,41 @@ export function accessRoutes(
|
|||||||
throw badRequest("agentName is required for agent join requests");
|
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"
|
const joinDefaults = requestType === "agent"
|
||||||
? normalizeAgentDefaultsForJoin({
|
? normalizeAgentDefaultsForJoin({
|
||||||
adapterType: req.body.adapterType ?? null,
|
adapterType: req.body.adapterType ?? null,
|
||||||
defaultsPayload: req.body.agentDefaultsPayload ?? null,
|
defaultsPayload: openClawDefaultsPayload,
|
||||||
deploymentMode: opts.deploymentMode,
|
deploymentMode: opts.deploymentMode,
|
||||||
deploymentExposure: opts.deploymentExposure,
|
deploymentExposure: opts.deploymentExposure,
|
||||||
bindHost: opts.bindHost,
|
bindHost: opts.bindHost,
|
||||||
@@ -1207,6 +1341,20 @@ export function accessRoutes(
|
|||||||
})
|
})
|
||||||
: { normalized: null as Record<string, unknown> | null, diagnostics: [] as JoinDiagnostic[] };
|
: { 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 claimSecret = requestType === "agent" ? createClaimSecret() : null;
|
||||||
const claimSecretHash = claimSecret ? hashToken(claimSecret) : null;
|
const claimSecretHash = claimSecret ? hashToken(claimSecret) : null;
|
||||||
const claimSecretExpiresAt = claimSecret
|
const claimSecretExpiresAt = claimSecret
|
||||||
|
|||||||
Reference in New Issue
Block a user