fix(openclaw-gateway): enforce join token validation and add smoke preflight gates

This commit is contained in:
Dotta
2026-03-07 16:01:19 -06:00
parent 271a632f1c
commit 83488b4ed0
6 changed files with 456 additions and 30 deletions

View File

@@ -208,4 +208,41 @@ describe("buildJoinDefaultsPayloadForAccept", () => {
expect(result).toEqual(defaultsPayload);
});
it("normalizes wrapped gateway token headers for openclaw_gateway", () => {
const result = buildJoinDefaultsPayloadForAccept({
adapterType: "openclaw_gateway",
defaultsPayload: {
url: "ws://127.0.0.1:18789",
headers: {
"x-openclaw-token": {
value: "gateway-token-1234567890",
},
},
},
}) as Record<string, unknown>;
expect(result).toMatchObject({
url: "ws://127.0.0.1:18789",
headers: {
"x-openclaw-token": "gateway-token-1234567890",
},
});
});
it("accepts inbound x-openclaw-token for openclaw_gateway", () => {
const result = buildJoinDefaultsPayloadForAccept({
adapterType: "openclaw_gateway",
defaultsPayload: {
url: "ws://127.0.0.1:18789",
},
inboundOpenClawTokenHeader: "gateway-token-1234567890",
}) as Record<string, unknown>;
expect(result).toMatchObject({
headers: {
"x-openclaw-token": "gateway-token-1234567890",
},
});
});
});

View File

@@ -52,6 +52,7 @@ describe("buildInviteOnboardingTextDocument", () => {
expect(text).toContain("~/.openclaw/workspace/paperclip-claimed-api-key.json");
expect(text).toContain("PAPERCLIP_API_KEY");
expect(text).toContain("saved token field");
expect(text).toContain("Gateway token unexpectedly short");
});
it("includes loopback diagnostics for authenticated/private onboarding", () => {

View File

@@ -311,6 +311,25 @@ function toAuthorizationHeaderValue(rawToken: string): string {
return /^bearer\s+/i.test(trimmed) ? trimmed : `Bearer ${trimmed}`;
}
function tokenFromAuthorizationHeader(rawHeader: string | null): string | null {
const trimmed = nonEmptyTrimmedString(rawHeader);
if (!trimmed) return null;
const bearerMatch = trimmed.match(/^bearer\s+(.+)$/i);
if (bearerMatch?.[1]) {
return nonEmptyTrimmedString(bearerMatch[1]);
}
return trimmed;
}
function parseBooleanLike(value: unknown): boolean | null {
if (typeof value === "boolean") return value;
if (typeof value !== "string") return null;
const normalized = value.trim().toLowerCase();
if (normalized === "true" || normalized === "1") return true;
if (normalized === "false" || normalized === "0") return false;
return null;
}
export function buildJoinDefaultsPayloadForAccept(input: {
adapterType: string | null;
defaultsPayload: unknown;
@@ -322,6 +341,59 @@ export function buildJoinDefaultsPayloadForAccept(input: {
inboundOpenClawAuthHeader?: string | null;
inboundOpenClawTokenHeader?: string | null;
}): unknown {
if (input.adapterType === "openclaw_gateway") {
const merged = isPlainObject(input.defaultsPayload)
? { ...(input.defaultsPayload as Record<string, unknown>) }
: ({} as Record<string, unknown>);
if (!nonEmptyTrimmedString(merged.paperclipApiUrl)) {
const legacyPaperclipApiUrl = nonEmptyTrimmedString(input.paperclipApiUrl);
if (legacyPaperclipApiUrl) merged.paperclipApiUrl = legacyPaperclipApiUrl;
}
const mergedHeaders = normalizeHeaderMap(merged.headers) ?? {};
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")
) {
mergedHeaders["x-openclaw-auth"] = inboundOpenClawAuthHeader;
}
const discoveredToken =
headerMapGetIgnoreCase(mergedHeaders, "x-openclaw-token") ??
headerMapGetIgnoreCase(mergedHeaders, "x-openclaw-auth") ??
tokenFromAuthorizationHeader(
headerMapGetIgnoreCase(mergedHeaders, "authorization")
);
if (
discoveredToken &&
!headerMapHasKeyIgnoreCase(mergedHeaders, "x-openclaw-token")
) {
mergedHeaders["x-openclaw-token"] = discoveredToken;
}
if (Object.keys(mergedHeaders).length > 0) {
merged.headers = mergedHeaders;
} else {
delete merged.headers;
}
return Object.keys(merged).length > 0 ? merged : null;
}
if (input.adapterType !== "openclaw") {
return input.defaultsPayload;
}
@@ -516,6 +588,37 @@ function summarizeOpenClawDefaultsForLog(defaultsPayload: unknown) {
};
}
function summarizeOpenClawGatewayDefaultsForLog(defaultsPayload: unknown) {
const defaults = isPlainObject(defaultsPayload)
? (defaultsPayload as Record<string, unknown>)
: null;
const headers = defaults ? normalizeHeaderMap(defaults.headers) : undefined;
const gatewayTokenValue = headers
? headerMapGetIgnoreCase(headers, "x-openclaw-token") ??
headerMapGetIgnoreCase(headers, "x-openclaw-auth") ??
tokenFromAuthorizationHeader(
headerMapGetIgnoreCase(headers, "authorization")
)
: null;
return {
present: Boolean(defaults),
keys: defaults ? Object.keys(defaults).sort() : [],
url: defaults ? nonEmptyTrimmedString(defaults.url) : null,
paperclipApiUrl: defaults
? nonEmptyTrimmedString(defaults.paperclipApiUrl)
: null,
headerKeys: headers ? Object.keys(headers).sort() : [],
sessionKeyStrategy: defaults
? nonEmptyTrimmedString(defaults.sessionKeyStrategy)
: null,
waitTimeoutMs:
defaults && typeof defaults.waitTimeoutMs === "number"
? defaults.waitTimeoutMs
: null,
gatewayToken: summarizeSecretForLog(gatewayTokenValue)
};
}
function buildJoinConnectivityDiagnostics(input: {
deploymentMode: DeploymentMode;
deploymentExposure: DeploymentExposure;
@@ -597,12 +700,222 @@ function normalizeAgentDefaultsForJoin(input: {
bindHost: string;
allowedHostnames: string[];
}) {
const fatalErrors: string[] = [];
const diagnostics: JoinDiagnostic[] = [];
if (input.adapterType !== "openclaw") {
if (
input.adapterType !== "openclaw" &&
input.adapterType !== "openclaw_gateway"
) {
const normalized = isPlainObject(input.defaultsPayload)
? (input.defaultsPayload as Record<string, unknown>)
: null;
return { normalized, diagnostics };
return { normalized, diagnostics, fatalErrors };
}
if (input.adapterType === "openclaw_gateway") {
if (!isPlainObject(input.defaultsPayload)) {
diagnostics.push({
code: "openclaw_gateway_defaults_missing",
level: "warn",
message:
"No OpenClaw gateway config was provided in agentDefaultsPayload.",
hint:
"Include agentDefaultsPayload.url and headers.x-openclaw-token for OpenClaw gateway joins."
});
fatalErrors.push(
"agentDefaultsPayload is required for adapterType=openclaw_gateway"
);
return {
normalized: null as Record<string, unknown> | null,
diagnostics,
fatalErrors
};
}
const defaults = input.defaultsPayload as Record<string, unknown>;
const normalized: Record<string, unknown> = {};
let gatewayUrl: URL | null = null;
const rawGatewayUrl = nonEmptyTrimmedString(defaults.url);
if (!rawGatewayUrl) {
diagnostics.push({
code: "openclaw_gateway_url_missing",
level: "warn",
message: "OpenClaw gateway URL is missing.",
hint: "Set agentDefaultsPayload.url to ws:// or wss:// gateway URL."
});
fatalErrors.push("agentDefaultsPayload.url is required");
} else {
try {
gatewayUrl = new URL(rawGatewayUrl);
if (
gatewayUrl.protocol !== "ws:" &&
gatewayUrl.protocol !== "wss:"
) {
diagnostics.push({
code: "openclaw_gateway_url_protocol",
level: "warn",
message: `OpenClaw gateway URL must use ws:// or wss:// (got ${gatewayUrl.protocol}).`
});
fatalErrors.push(
"agentDefaultsPayload.url must use ws:// or wss:// for openclaw_gateway"
);
} else {
normalized.url = gatewayUrl.toString();
diagnostics.push({
code: "openclaw_gateway_url_configured",
level: "info",
message: `Gateway endpoint set to ${gatewayUrl.toString()}`
});
}
} catch {
diagnostics.push({
code: "openclaw_gateway_url_invalid",
level: "warn",
message: `Invalid OpenClaw gateway URL: ${rawGatewayUrl}`
});
fatalErrors.push("agentDefaultsPayload.url is not a valid URL");
}
}
const headers = normalizeHeaderMap(defaults.headers) ?? {};
const gatewayToken =
headerMapGetIgnoreCase(headers, "x-openclaw-token") ??
headerMapGetIgnoreCase(headers, "x-openclaw-auth") ??
tokenFromAuthorizationHeader(headerMapGetIgnoreCase(headers, "authorization"));
if (
gatewayToken &&
!headerMapHasKeyIgnoreCase(headers, "x-openclaw-token")
) {
headers["x-openclaw-token"] = gatewayToken;
}
if (Object.keys(headers).length > 0) {
normalized.headers = headers;
}
if (!gatewayToken) {
diagnostics.push({
code: "openclaw_gateway_auth_header_missing",
level: "warn",
message: "Gateway auth token is missing from agent defaults.",
hint:
"Set agentDefaultsPayload.headers.x-openclaw-token (or legacy x-openclaw-auth)."
});
fatalErrors.push(
"agentDefaultsPayload.headers.x-openclaw-token (or x-openclaw-auth) is required"
);
} else if (gatewayToken.trim().length < 16) {
diagnostics.push({
code: "openclaw_gateway_auth_header_too_short",
level: "warn",
message: `Gateway auth token appears too short (${gatewayToken.trim().length} chars).`,
hint:
"Use the full gateway auth token from ~/.openclaw/openclaw.json (typically long random string)."
});
fatalErrors.push(
"agentDefaultsPayload.headers.x-openclaw-token is too short; expected a full gateway token"
);
} else {
diagnostics.push({
code: "openclaw_gateway_auth_header_configured",
level: "info",
message: "Gateway auth token configured."
});
}
if (isPlainObject(defaults.payloadTemplate)) {
normalized.payloadTemplate = defaults.payloadTemplate;
}
const parsedDisableDeviceAuth = parseBooleanLike(defaults.disableDeviceAuth);
if (parsedDisableDeviceAuth !== null) {
normalized.disableDeviceAuth = parsedDisableDeviceAuth;
}
const waitTimeoutMs =
typeof defaults.waitTimeoutMs === "number" &&
Number.isFinite(defaults.waitTimeoutMs)
? Math.floor(defaults.waitTimeoutMs)
: typeof defaults.waitTimeoutMs === "string"
? Number.parseInt(defaults.waitTimeoutMs.trim(), 10)
: NaN;
if (Number.isFinite(waitTimeoutMs) && waitTimeoutMs > 0) {
normalized.waitTimeoutMs = waitTimeoutMs;
}
const timeoutSec =
typeof defaults.timeoutSec === "number" && Number.isFinite(defaults.timeoutSec)
? Math.floor(defaults.timeoutSec)
: typeof defaults.timeoutSec === "string"
? Number.parseInt(defaults.timeoutSec.trim(), 10)
: NaN;
if (Number.isFinite(timeoutSec) && timeoutSec > 0) {
normalized.timeoutSec = timeoutSec;
}
const sessionKeyStrategy = nonEmptyTrimmedString(defaults.sessionKeyStrategy);
if (
sessionKeyStrategy === "fixed" ||
sessionKeyStrategy === "issue" ||
sessionKeyStrategy === "run"
) {
normalized.sessionKeyStrategy = sessionKeyStrategy;
}
const sessionKey = nonEmptyTrimmedString(defaults.sessionKey);
if (sessionKey) {
normalized.sessionKey = sessionKey;
}
const role = nonEmptyTrimmedString(defaults.role);
if (role) {
normalized.role = role;
}
if (Array.isArray(defaults.scopes)) {
const scopes = defaults.scopes
.filter((entry): entry is string => typeof entry === "string")
.map((entry) => entry.trim())
.filter(Boolean);
if (scopes.length > 0) {
normalized.scopes = scopes;
}
}
const rawPaperclipApiUrl =
typeof defaults.paperclipApiUrl === "string"
? defaults.paperclipApiUrl.trim()
: "";
if (rawPaperclipApiUrl) {
try {
const parsedPaperclipApiUrl = new URL(rawPaperclipApiUrl);
if (
parsedPaperclipApiUrl.protocol !== "http:" &&
parsedPaperclipApiUrl.protocol !== "https:"
) {
diagnostics.push({
code: "openclaw_gateway_paperclip_api_url_protocol",
level: "warn",
message: `paperclipApiUrl must use http:// or https:// (got ${parsedPaperclipApiUrl.protocol}).`
});
} else {
normalized.paperclipApiUrl = parsedPaperclipApiUrl.toString();
diagnostics.push({
code: "openclaw_gateway_paperclip_api_url_configured",
level: "info",
message: `paperclipApiUrl set to ${parsedPaperclipApiUrl.toString()}`
});
}
} catch {
diagnostics.push({
code: "openclaw_gateway_paperclip_api_url_invalid",
level: "warn",
message: `Invalid paperclipApiUrl: ${rawPaperclipApiUrl}`
});
}
}
return { normalized, diagnostics, fatalErrors };
}
if (!isPlainObject(input.defaultsPayload)) {
@@ -613,7 +926,11 @@ function normalizeAgentDefaultsForJoin(input: {
"No OpenClaw callback config was provided in agentDefaultsPayload.",
hint: "Include agentDefaultsPayload.url so Paperclip can invoke the OpenClaw endpoint immediately after approval."
});
return { normalized: null as Record<string, unknown> | null, diagnostics };
return {
normalized: null as Record<string, unknown> | null,
diagnostics,
fatalErrors
};
}
const defaults = input.defaultsPayload as Record<string, unknown>;
@@ -790,7 +1107,7 @@ function normalizeAgentDefaultsForJoin(input: {
})
);
return { normalized, diagnostics };
return { normalized, diagnostics, fatalErrors };
}
function toInviteSummaryResponse(
@@ -1091,6 +1408,7 @@ export function buildInviteOnboardingTextDocument(
TOKEN="$(node -p 'require(process.env.HOME+\"/.openclaw/openclaw.json\").gateway.auth.token')"
test -n "$TOKEN" || (echo "Missing TOKEN" && exit 1)
test "\${#TOKEN}" -ge 16 || (echo "Gateway token unexpectedly short (\${#TOKEN})" && exit 1)
3) IMPORTANT: Don't accidentally drop the token when generating JSON
If you build JSON with Node, pass the token explicitly (argv), don't rely on an un-exported env var.
@@ -1931,9 +2249,14 @@ export function accessRoutes(
})
: {
normalized: null as Record<string, unknown> | null,
diagnostics: [] as JoinDiagnostic[]
diagnostics: [] as JoinDiagnostic[],
fatalErrors: [] as string[]
};
if (requestType === "agent" && joinDefaults.fatalErrors.length > 0) {
throw badRequest(joinDefaults.fatalErrors.join("; "));
}
if (requestType === "agent" && adapterType === "openclaw") {
logger.info(
{
@@ -1950,6 +2273,22 @@ export function accessRoutes(
);
}
if (requestType === "agent" && adapterType === "openclaw_gateway") {
logger.info(
{
inviteId: invite.id,
joinRequestDiagnostics: joinDefaults.diagnostics.map((diag) => ({
code: diag.code,
level: diag.level
})),
normalizedAgentDefaults: summarizeOpenClawGatewayDefaultsForLog(
joinDefaults.normalized
)
},
"invite accept normalized OpenClaw gateway defaults"
);
}
const claimSecret =
requestType === "agent" && !inviteAlreadyAccepted
? createClaimSecret()