fix(openclaw-gateway): enforce join token validation and add smoke preflight gates
This commit is contained in:
@@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user