openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
1,899 lines • 90 kB
JavaScript
import { a as normalizeLowercaseStringOrEmpty, c as normalizeOptionalString } from "./string-coerce-mnp54Vah.js";
import { D as resolveIntegerOption } from "./number-coercion-CJQ8TR--.js";
import { y as resolveStateDir } from "./paths-mvMm5bYV.js";
import { _ as uniqueStrings } from "./string-normalization-WNUDCpXX.js";
import { s as resolveRuntimeServiceVersion } from "./version-Crcn9X9T.js";
import { D as runWithDiagnosticTraceContext, v as createDiagnosticTraceContext } from "./diagnostic-events-Chmz2PSy.js";
import { a as isPrivateOrLoopbackAddress, c as isTrustedProxyAddress, f as resolveClientIp, h as resolveHostName, i as isLoopbackHost, n as isLocalishHost, o as isPrivateOrLoopbackHost, r as isLoopbackAddress } from "./net-DTe7AQiu.js";
import { a as AUTH_RATE_LIMIT_SCOPE_NODE_PAIRING, o as AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET, r as AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN, t as AUTH_RATE_LIMIT_SCOPE_BOOTSTRAP_TOKEN } from "./auth-rate-limit-Bwr29t2Y.js";
import { a as hasForwardedRequestHeaders, c as checkBrowserOrigin, i as authorizeWsControlUiGatewayConnect, o as isLocalDirectRequest, r as authorizeHttpGatewayConnect, s as withSerializedRateLimitAttempt } from "./auth-DXSWhsnk.js";
import { i as getRuntimeConfig } from "./io-Gi7-pyU-.js";
import { n as GATEWAY_CLIENT_IDS, r as GATEWAY_CLIENT_MODES } from "./client-info-CcqJJIan.js";
import "./version-51ymduTn.js";
import { c as buildDeviceAuthPayloadV3, l as normalizeDeviceMetadataForAuth, s as buildDeviceAuthPayload } from "./client-C2g2lFC5.js";
import { i as normalizeDevicePublicKeyBase64Url, s as verifyDeviceSignature, t as deriveDeviceIdFromPublicKey } from "./device-identity-CEPJolq9.js";
import { i as buildPairingConnectErrorMessage, m as resolveDeviceAuthConnectErrorDetailCode, n as buildPairingConnectCloseReason, p as resolveAuthConnectErrorDetailCode, r as buildPairingConnectErrorDetails, t as ConnectErrorDetailCodes } from "./connect-error-details-BXqba0zp.js";
import { i as gatewayStartupUnavailableDetails, n as GATEWAY_STARTUP_CLOSE_REASON, r as GATEWAY_STARTUP_PENDING_CLOSE_CAUSE, t as GATEWAY_STARTUP_CLOSE_CODE } from "./startup-unavailable-CRTM-3cy.js";
import { t as ADMIN_SCOPE } from "./operator-scopes-CS3xdS-V.js";
import "./method-scopes-BtdN3y7s.js";
import { a as isOperatorUiClient, n as isGatewayCliClient, o as isWebchatClient, t as isBrowserOperatorUiClient } from "./message-channel-BiOeMu0l.js";
import { n as isOperatorApprovalRuntimeToken } from "./operator-approval-runtime-token-BPk6svmE.js";
import { in as errorShape, rn as ErrorCodes } from "./schema-BwaBORnA.js";
import { F as validateConnectParams, Nt as validateRequestFrame, t as formatValidationErrors } from "./src-oj0IwW6K.js";
import { t as rawDataToString } from "./ws-C3qhmaFC.js";
import { l as roleScopesAllow } from "./pairing-token-BEUQW6ez.js";
import { c as updatePairedNodeMetadata, d as sameNodeApprovalSurfaceSet, f as sameNodePermissionSurface, n as getPairedNode, s as requestNodePairing, u as normalizeNodeApprovalSurfaceList } from "./node-pairing-BjnR-f-1.js";
import { i as recordRemoteNodeInfo, o as refreshRemoteNodeBins } from "./remote-DoOmoEi1.js";
import { n as logRejectedLargePayload } from "./diagnostic-payload-DELJ7QXt.js";
import { a as MAX_PAYLOAD_BYTES, i as MAX_BUFFERED_BYTES, o as MAX_PREAUTH_PAYLOAD_BYTES, s as TICK_INTERVAL_MS } from "./server-constants-BGwLM6XN.js";
import { a as indexPluginNodeCapabilitySurfaces, d as setClientPluginNodeCapability, l as resolvePluginNodeCapabilityExpiresAtMs, o as mintPluginNodeCapabilityToken, r as buildPluginNodeCapabilityScopedHostUrl } from "./plugin-node-capability-CQtFV9Fn.js";
import { a as normalizeDeclaredNodeCommands, o as resolveNodeCommandAllowlist, s as resolveNodePairingCommandAllowlist } from "./node-command-policy-DYPdfWjH.js";
import { n as logWs, t as formatForLog } from "./ws-log-DlzuStZb.js";
import { a as redeemDeviceBootstrapTokenProfile, d as isPairingSetupBootstrapProfile, l as verifyDeviceBootstrapToken, m as resolveBootstrapProfileScopesForRoles, n as getBoundDeviceBootstrapProfile, o as restoreDeviceBootstrapToken, p as resolveBootstrapProfileScopesForRole, r as getDeviceBootstrapTokenProfile, s as revokeDeviceBootstrapToken } from "./device-bootstrap-DESSpKoY.js";
import { _ as updatePairedDeviceMetadata, a as getPairedDevice, c as listApprovedPairedDeviceRoles, l as listDevicePairing, n as approveDevicePairing, p as requestDevicePairing, r as ensureDeviceToken, s as hasEffectivePairedDeviceRole, t as approveBootstrapDevicePairing, u as listEffectivePairedDeviceRoles, v as verifyDeviceToken } from "./device-pairing-Bcmp5CuV.js";
import { t as resolveSharedGatewaySessionGeneration } from "./ws-shared-generation-Bp5l7wzu.js";
import { r as loadVoiceWakeConfig, t as formatError } from "./server-utils-Co-s4JxP.js";
import { r as upsertPresence } from "./system-presence-DylNVezb.js";
import { a as incrementPresenceVersion, n as getHealthCache, r as getHealthVersion, t as buildGatewaySnapshot } from "./health-state-Cha0Tz7F.js";
import { n as parseGatewayRole, r as roleCanSkipDeviceIdentity } from "./role-policy-TJzWk9R_.js";
import { t as loadVoiceWakeRoutingConfig } from "./voicewake-routing-BF5EBU0h.js";
import { n as buildHandshakeAuthLogKey, r as shouldLimitMissingCredentialAuthLog, t as HandshakeAuthLogLimiter } from "./handshake-auth-log-limiter-C9RTAsnH.js";
import { t as truncateCloseReason } from "./close-reason-4zH9gzER.js";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
//#region src/gateway/node-connect-reconcile.ts
function resolveApprovedReconnectCommands(params) {
return normalizeDeclaredNodeCommands({
declaredCommands: Array.isArray(params.pairedCommands) ? params.pairedCommands : [],
allowlist: params.allowlist
});
}
function normalizePermissionMap(value) {
if (!value) return;
const entries = Object.entries(value).toSorted(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey));
return entries.length > 0 ? Object.fromEntries(entries) : void 0;
}
function intersectApprovalSurfaceList(params) {
const approved = new Set(normalizeNodeApprovalSurfaceList(params.approved));
return normalizeNodeApprovalSurfaceList(params.declared).filter((entry) => approved.has(entry));
}
function intersectPermissionSurface(params) {
const entries = [];
for (const [key, declaredValue] of Object.entries(params.declared ?? {})) {
const approvedValue = params.approved?.[key];
if (!declaredValue) {
entries.push([key, false]);
continue;
}
if (approvedValue === true) {
entries.push([key, true]);
continue;
}
if (approvedValue === false) entries.push([key, false]);
}
return entries.length > 0 ? Object.fromEntries(entries) : void 0;
}
function buildNodePairingRequestInput(params) {
return {
nodeId: params.nodeId,
displayName: params.connectParams.client.displayName,
platform: params.connectParams.client.platform,
version: params.connectParams.client.version,
deviceFamily: params.connectParams.client.deviceFamily,
modelIdentifier: params.connectParams.client.modelIdentifier,
caps: params.caps,
commands: params.commands,
permissions: params.permissions,
remoteIp: params.remoteIp
};
}
/** Reconciles a connecting node against stored approval and requests pairing when needed. */
async function reconcileNodePairingOnConnect(params) {
const nodeId = params.connectParams.device?.id ?? params.connectParams.client.id;
const policyNode = {
platform: params.connectParams.client.platform,
deviceFamily: params.connectParams.client.deviceFamily,
caps: params.connectParams.caps,
commands: params.connectParams.commands
};
const pairingAllowlist = resolveNodePairingCommandAllowlist(params.cfg, policyNode);
const declared = normalizeDeclaredNodeCommands({
declaredCommands: Array.isArray(params.connectParams.commands) ? params.connectParams.commands : [],
allowlist: pairingAllowlist
});
const declaredCaps = normalizeNodeApprovalSurfaceList(params.connectParams.caps);
const declaredPermissions = normalizePermissionMap(params.connectParams.permissions);
if (!params.pairedNode) {
const pendingPairing = await params.requestPairing(buildNodePairingRequestInput({
nodeId,
connectParams: params.connectParams,
caps: declaredCaps,
commands: declared,
permissions: declaredPermissions,
remoteIp: params.reportedClientIp
}));
if (!pendingPairing) throw new Error("node pairing request required");
return {
nodeId,
declaredCaps,
effectiveCaps: [],
declaredCommands: declared,
effectiveCommands: [],
declaredPermissions,
effectivePermissions: void 0,
pendingPairing
};
}
const runtimeAllowlist = resolveNodeCommandAllowlist(params.cfg, {
...policyNode,
approvedCommands: params.pairedNode.commands
});
const approvedCommands = resolveApprovedReconnectCommands({
pairedCommands: params.pairedNode.commands,
allowlist: runtimeAllowlist
});
const approvedCaps = normalizeNodeApprovalSurfaceList(params.pairedNode.caps);
const approvedPermissions = normalizePermissionMap(params.pairedNode.permissions);
const hasCommandUpgrade = declared.some((command) => !approvedCommands.includes(command));
const hasCapabilityChange = !sameNodeApprovalSurfaceSet(params.pairedNode.caps, declaredCaps);
const hasPermissionChange = !sameNodePermissionSurface(params.pairedNode.permissions, declaredPermissions);
const effectiveApprovedDeclaredCaps = intersectApprovalSurfaceList({
approved: approvedCaps,
declared: declaredCaps
});
const effectiveApprovedDeclaredCommands = intersectApprovalSurfaceList({
approved: approvedCommands,
declared
});
const effectiveApprovedDeclaredPermissions = intersectPermissionSurface({
approved: approvedPermissions,
declared: declaredPermissions
});
if (hasCommandUpgrade || hasCapabilityChange || hasPermissionChange) {
const pendingPairing = await params.requestPairing(buildNodePairingRequestInput({
nodeId,
connectParams: params.connectParams,
caps: declaredCaps,
commands: declared,
permissions: declaredPermissions ?? (hasPermissionChange ? {} : void 0),
remoteIp: params.reportedClientIp
}));
return {
nodeId,
declaredCaps,
effectiveCaps: effectiveApprovedDeclaredCaps,
declaredCommands: declared,
effectiveCommands: effectiveApprovedDeclaredCommands,
declaredPermissions,
effectivePermissions: effectiveApprovedDeclaredPermissions,
...pendingPairing ? { pendingPairing } : {}
};
}
return {
nodeId,
declaredCaps,
effectiveCaps: declaredCaps,
declaredCommands: declared,
effectiveCommands: declared,
declaredPermissions,
effectivePermissions: declaredPermissions
};
}
//#endregion
//#region src/gateway/node-pairing-auto-approve.ts
/** Classifies how the gateway learned the client IP for node auto-approval. */
function resolveNodePairingClientIpSource(params) {
if (!params.reportedClientIp) return "none";
if (!params.hasProxyHeaders || !params.remoteIsTrustedProxy) return "direct";
return params.remoteIsLoopback ? "loopback-trusted-proxy" : "trusted-proxy";
}
/** Returns true when a node pairing request can be auto-approved by trusted CIDR policy. */
function shouldAutoApproveNodePairingFromTrustedCidrs(params) {
if (params.existingPairedDevice) return false;
if (params.role !== "node") return false;
if (params.reason !== "not-paired") return false;
if (params.scopes.length > 0) return false;
if (params.hasBrowserOriginHeader || params.isControlUi || params.isWebchat) return false;
if (params.reportedClientIpSource === "none" || params.reportedClientIpSource === "loopback-trusted-proxy") return false;
if (!params.reportedClientIp) return false;
const autoApproveCidrs = params.autoApproveCidrs?.map((entry) => entry.trim()).filter((entry) => entry.length > 0);
if (!autoApproveCidrs || autoApproveCidrs.length === 0) return false;
return isTrustedProxyAddress(params.reportedClientIp, autoApproveCidrs);
}
//#endregion
//#region src/gateway/server/ws-connection/auth-context.ts
function mapDeviceTokenAuthFailureReason(params) {
if (params.tokenCheckReason === "scope-mismatch" || params.tokenCheckReason === "scope_mismatch") return "scope_mismatch";
if (params.candidateSource === "explicit-device-token") return "device_token_mismatch";
return params.fallbackReason ?? "device_token_mismatch";
}
function resolveSharedConnectAuth(connectAuth) {
const token = normalizeOptionalString(connectAuth?.token);
const password = normalizeOptionalString(connectAuth?.password);
if (!token && !password) return;
return {
token,
password
};
}
function resolveDeviceTokenCandidate(connectAuth) {
const explicitDeviceToken = normalizeOptionalString(connectAuth?.deviceToken);
if (explicitDeviceToken) return {
token: explicitDeviceToken,
source: "explicit-device-token"
};
const fallbackToken = normalizeOptionalString(connectAuth?.token);
if (!fallbackToken) return {};
return {
token: fallbackToken,
source: "shared-token-fallback"
};
}
async function resolveConnectAuthState(params) {
const sharedConnectAuth = resolveSharedConnectAuth(params.connectAuth);
const sharedAuthProvided = Boolean(sharedConnectAuth);
const bootstrapTokenCandidate = params.hasDeviceIdentity ? normalizeOptionalString(params.connectAuth?.bootstrapToken) : void 0;
const { token: deviceTokenCandidate, source: deviceTokenCandidateSource } = params.hasDeviceIdentity ? resolveDeviceTokenCandidate(params.connectAuth) : {};
const authResult = await authorizeWsControlUiGatewayConnect({
auth: params.resolvedAuth,
connectAuth: sharedConnectAuth,
req: params.req,
trustedProxies: params.trustedProxies,
allowRealIpFallback: params.allowRealIpFallback,
rateLimiter: sharedAuthProvided ? params.rateLimiter : void 0,
clientIp: params.clientIp,
rateLimitScope: AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET
});
const sharedAuthResult = sharedConnectAuth && await authorizeHttpGatewayConnect({
auth: {
...params.resolvedAuth,
allowTailscale: false
},
connectAuth: sharedConnectAuth,
req: params.req,
trustedProxies: params.trustedProxies,
allowRealIpFallback: params.allowRealIpFallback,
rateLimitScope: "shared-secret"
});
const sharedAuthOk = sharedAuthResult?.ok === true && (sharedAuthResult.method === "token" || sharedAuthResult.method === "password") || authResult.ok && authResult.method === "trusted-proxy";
return {
authResult,
authOk: authResult.ok,
authMethod: authResult.method ?? (params.resolvedAuth.mode === "password" ? "password" : "token"),
sharedAuthOk,
sharedAuthProvided,
bootstrapTokenCandidate,
deviceTokenCandidate,
deviceTokenCandidateSource
};
}
async function resolveConnectAuthDecision(params) {
if (!Boolean(params.rateLimiter && params.hasDeviceIdentity && params.deviceId && params.publicKey && params.state.bootstrapTokenCandidate)) return await resolveConnectAuthDecisionCore(params);
return await withSerializedRateLimitAttempt({
ip: params.clientIp,
scope: AUTH_RATE_LIMIT_SCOPE_BOOTSTRAP_TOKEN,
run: async () => await resolveConnectAuthDecisionCore(params)
});
}
async function resolveConnectAuthDecisionCore(params) {
let authResult = params.state.authResult;
let authOk = params.state.authOk;
let authMethod = params.state.authMethod;
let deviceTokenSharedGatewaySessionGeneration;
let pendingBootstrapFailure = false;
function finish() {
if (pendingBootstrapFailure && !authOk) params.rateLimiter?.recordFailure(params.clientIp, AUTH_RATE_LIMIT_SCOPE_BOOTSTRAP_TOKEN);
return {
authResult,
authOk,
authMethod,
deviceTokenSharedGatewaySessionGeneration
};
}
const bootstrapTokenCandidate = params.state.bootstrapTokenCandidate;
if (params.hasDeviceIdentity && params.deviceId && params.publicKey && bootstrapTokenCandidate) {
let bootstrapRateLimited = false;
if (params.rateLimiter) {
const bootstrapRateCheck = params.rateLimiter.check(params.clientIp, AUTH_RATE_LIMIT_SCOPE_BOOTSTRAP_TOKEN);
if (!bootstrapRateCheck.allowed) {
bootstrapRateLimited = true;
if (!authOk) authResult = {
ok: false,
reason: "rate_limited",
rateLimited: true,
retryAfterMs: bootstrapRateCheck.retryAfterMs
};
}
}
if (!bootstrapRateLimited) {
const tokenCheck = await params.verifyBootstrapToken({
deviceId: params.deviceId,
publicKey: params.publicKey,
token: bootstrapTokenCandidate,
role: params.role,
scopes: params.scopes
});
if (tokenCheck.ok) {
authOk = true;
authMethod = "bootstrap-token";
params.rateLimiter?.reset(params.clientIp, AUTH_RATE_LIMIT_SCOPE_BOOTSTRAP_TOKEN);
} else {
pendingBootstrapFailure = true;
if (!authOk) authResult = {
ok: false,
reason: tokenCheck.reason ?? "bootstrap_token_invalid"
};
}
}
}
const deviceTokenCandidate = params.state.deviceTokenCandidate;
if (!params.hasDeviceIdentity || !params.deviceId || authOk || !deviceTokenCandidate) return finish();
let deviceTokenRateLimited = false;
if (params.rateLimiter) {
const deviceRateCheck = params.rateLimiter.check(params.clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN);
if (!deviceRateCheck.allowed) {
deviceTokenRateLimited = true;
authResult = {
ok: false,
reason: "rate_limited",
rateLimited: true,
retryAfterMs: deviceRateCheck.retryAfterMs
};
}
}
if (!deviceTokenRateLimited) {
const tokenCheck = await params.verifyDeviceToken({
deviceId: params.deviceId,
token: deviceTokenCandidate,
role: params.role,
scopes: params.scopes
});
if (tokenCheck.ok) {
authOk = true;
authMethod = "device-token";
if (tokenCheck.issuer?.kind === "shared-gateway-auth") deviceTokenSharedGatewaySessionGeneration = tokenCheck.issuer.generation;
params.rateLimiter?.reset(params.clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN);
if (params.state.sharedAuthProvided) params.rateLimiter?.reset(params.clientIp, AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET);
} else {
authResult = {
ok: false,
reason: mapDeviceTokenAuthFailureReason({
tokenCheckReason: tokenCheck.reason,
candidateSource: params.state.deviceTokenCandidateSource,
fallbackReason: authResult.reason
})
};
params.rateLimiter?.recordFailure(params.clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN);
}
}
return finish();
}
//#endregion
//#region src/gateway/server/ws-connection/auth-messages.ts
/** Formats a client-specific auth failure message without exposing secret values. */
function formatGatewayAuthFailureMessage(params) {
const { authMode, authProvided, reason, client } = params;
const isCli = isGatewayCliClient(client);
const isControlUi = isOperatorUiClient(client);
const isWebchat = isWebchatClient(client);
const tokenHint = isCli ? "set gateway.remote.token to match gateway.auth.token" : isControlUi || isWebchat ? "open the dashboard URL and paste the token in Control UI settings" : "provide gateway auth token";
const passwordHint = isCli ? "set gateway.remote.password to match gateway.auth.password" : isControlUi || isWebchat ? "enter the password in Control UI settings" : "provide gateway auth password";
switch (reason) {
case "token_missing": return `unauthorized: gateway token missing (${tokenHint})`;
case "token_mismatch": return `unauthorized: gateway token mismatch (${tokenHint})`;
case "token_missing_config": return "unauthorized: gateway token not configured on gateway (set gateway.auth.token)";
case "password_missing": return `unauthorized: gateway password missing (${passwordHint})`;
case "password_mismatch": return `unauthorized: gateway password mismatch (${passwordHint})`;
case "password_missing_config": return "unauthorized: gateway password not configured on gateway (set gateway.auth.password)";
case "bootstrap_token_invalid": return "unauthorized: bootstrap token invalid or expired (scan a fresh setup code)";
case "tailscale_user_missing": return "unauthorized: tailscale identity missing (use Tailscale Serve auth or gateway token/password)";
case "tailscale_proxy_missing": return "unauthorized: tailscale proxy headers missing (use Tailscale Serve or gateway token/password)";
case "tailscale_whois_failed": return "unauthorized: tailscale identity check failed (use Tailscale Serve auth or gateway token/password)";
case "tailscale_user_mismatch": return "unauthorized: tailscale identity mismatch (use Tailscale Serve auth or gateway token/password)";
case "rate_limited": return "unauthorized: too many failed authentication attempts (retry later)";
case "device_token_mismatch": return "unauthorized: device token mismatch (rotate/reissue device token)";
case "scope_mismatch": return "unauthorized: device token scope mismatch (re-pair or approve scope upgrade)";
default: break;
}
if (authMode === "token" && authProvided === "none") return `unauthorized: gateway token missing (${tokenHint})`;
if (authMode === "token" && authProvided === "device-token") return "unauthorized: device token rejected (pair/repair this device, or provide gateway token)";
if (authProvided === "bootstrap-token") return "unauthorized: bootstrap token invalid or expired (scan a fresh setup code)";
if (authMode === "password" && authProvided === "none") return `unauthorized: gateway password missing (${passwordHint})`;
return "unauthorized";
}
//#endregion
//#region src/gateway/server/ws-connection/connect-policy.ts
function resolveControlUiAuthPolicy(params) {
const allowInsecureAuthConfigured = params.isControlUi && params.controlUiConfig?.allowInsecureAuth === true;
const dangerouslyDisableDeviceAuth = params.isControlUi && params.controlUiConfig?.dangerouslyDisableDeviceAuth === true;
return {
isControlUi: params.isControlUi,
allowInsecureAuthConfigured,
dangerouslyDisableDeviceAuth,
allowBypass: dangerouslyDisableDeviceAuth,
device: dangerouslyDisableDeviceAuth ? null : params.deviceRaw
};
}
function shouldSkipControlUiPairing(policy, role, _trustedProxyAuthOk = false, authMode, authMethod) {
if (policy.isControlUi && role === "operator" && authMethod === "tailscale" && policy.device) return true;
if (policy.isControlUi && role === "operator" && authMode === "none") return true;
return role === "operator" && policy.allowBypass;
}
function isTrustedProxyControlUiOperatorAuth(params) {
return params.isControlUi && params.role === "operator" && params.authMode === "trusted-proxy" && params.authOk && params.authMethod === "trusted-proxy";
}
function shouldClearUnboundScopesForMissingDeviceIdentity(params) {
return params.decision.kind !== "allow" || !params.controlUiAuthPolicy.allowBypass && !params.preserveInsecureLocalControlUiScopes && (params.authMethod === "token" || params.authMethod === "password" || params.authMethod === "trusted-proxy");
}
function evaluateMissingDeviceIdentity(params) {
if (params.hasDeviceIdentity) return { kind: "allow" };
if (params.isControlUi && params.trustedProxyAuthOk) return { kind: "allow" };
if (params.isControlUi && params.controlUiAuthPolicy.allowBypass && params.role === "operator") return { kind: "allow" };
if (params.localBackendSelfPairingOk && params.role === "operator") return { kind: "allow" };
if (params.isControlUi && !params.controlUiAuthPolicy.allowBypass) {
if (!params.controlUiAuthPolicy.allowInsecureAuthConfigured || !params.isLocalClient) return { kind: "reject-control-ui-insecure-auth" };
}
if (roleCanSkipDeviceIdentity(params.role, params.sharedAuthOk)) return { kind: "allow" };
if (!params.authOk && params.hasSharedAuth) return { kind: "reject-unauthorized" };
return { kind: "reject-device-required" };
}
//#endregion
//#region src/gateway/server/ws-connection/handshake-auth-helpers.ts
const BROWSER_ORIGIN_LOOPBACK_RATE_LIMIT_IP = "198.18.0.1";
const BROWSER_ORIGIN_RATE_LIMIT_KEY_PREFIX = "browser-origin:";
function resolveBrowserOriginRateLimitKey(requestOrigin) {
const trimmedOrigin = requestOrigin?.trim();
if (!trimmedOrigin) return BROWSER_ORIGIN_LOOPBACK_RATE_LIMIT_IP;
try {
return `${BROWSER_ORIGIN_RATE_LIMIT_KEY_PREFIX}${normalizeLowercaseStringOrEmpty(new URL(trimmedOrigin).origin)}`;
} catch {
return BROWSER_ORIGIN_LOOPBACK_RATE_LIMIT_IP;
}
}
function resolveHandshakeBrowserSecurityContext(params) {
const hasBrowserOriginHeader = Boolean(params.requestOrigin && params.requestOrigin.trim() !== "");
return {
hasBrowserOriginHeader,
enforceOriginCheckForAnyClient: hasBrowserOriginHeader,
rateLimitClientIp: hasBrowserOriginHeader && isLoopbackAddress(params.clientIp) ? resolveBrowserOriginRateLimitKey(params.requestOrigin) : params.clientIp,
authRateLimiter: hasBrowserOriginHeader && params.browserRateLimiter ? params.browserRateLimiter : params.rateLimiter
};
}
function shouldAllowSilentLocalPairing(params) {
if (params.locality === "remote") return false;
if (params.hasBrowserOriginHeader && !params.isControlUi && !params.isWebchat) return false;
if (params.reason === "not-paired" || params.reason === "scope-upgrade" || params.reason === "role-upgrade") return true;
if (params.reason === "metadata-upgrade" && !params.hasBrowserOriginHeader && !params.isControlUi && !params.isWebchat && (params.locality === "direct_local" && params.isNativeAppUi === true || params.locality === "cli_container_local" || params.locality === "shared_secret_loopback_local")) return true;
return false;
}
function isCliContainerLocalEquivalent(params) {
const isCliClient = params.connectParams.client.id === GATEWAY_CLIENT_IDS.CLI && params.connectParams.client.mode === GATEWAY_CLIENT_MODES.CLI;
const usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password";
return isCliClient && params.sharedAuthOk && usesSharedSecretAuth && !params.hasProxyHeaders && !params.hasBrowserOriginHeader && isLoopbackAddress(params.remoteAddress) && isPrivateOrLoopbackHost(resolveHostName(params.requestHost));
}
function isSharedSecretLoopbackLocalEquivalent(params) {
const usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password";
return params.sharedAuthOk && usesSharedSecretAuth && !params.hasProxyHeaders && !params.hasBrowserOriginHeader && isLoopbackAddress(params.remoteAddress) && isPrivateOrLoopbackHost(resolveHostName(params.requestHost));
}
function resolveOriginHost(origin) {
const trimmed = origin?.trim();
if (!trimmed) return "";
try {
return new URL(trimmed).hostname;
} catch {
return "";
}
}
function isControlUiBrowserContainerLocalEquivalent(params) {
const isControlUiBrowser = params.connectParams.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI && params.connectParams.client.mode === GATEWAY_CLIENT_MODES.WEBCHAT;
const usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password";
return isControlUiBrowser && params.sharedAuthOk && usesSharedSecretAuth && !params.hasProxyHeaders && params.hasBrowserOriginHeader && isPrivateOrLoopbackAddress(params.remoteAddress) && isLoopbackHost(resolveHostName(params.requestHost)) && isLoopbackHost(resolveOriginHost(params.requestOrigin));
}
function resolvePairingLocality(params) {
if (params.isLocalClient) return "direct_local";
if (isControlUiBrowserContainerLocalEquivalent({
connectParams: params.connectParams,
requestHost: params.requestHost,
requestOrigin: params.requestOrigin,
remoteAddress: params.remoteAddress,
hasProxyHeaders: params.hasProxyHeaders,
hasBrowserOriginHeader: params.hasBrowserOriginHeader,
sharedAuthOk: params.sharedAuthOk,
authMethod: params.authMethod
})) return "browser_container_local";
if (isCliContainerLocalEquivalent({
connectParams: params.connectParams,
requestHost: params.requestHost,
remoteAddress: params.remoteAddress,
hasProxyHeaders: params.hasProxyHeaders,
hasBrowserOriginHeader: params.hasBrowserOriginHeader,
sharedAuthOk: params.sharedAuthOk,
authMethod: params.authMethod
})) return "cli_container_local";
if (isSharedSecretLoopbackLocalEquivalent({
requestHost: params.requestHost,
remoteAddress: params.remoteAddress,
hasProxyHeaders: params.hasProxyHeaders,
hasBrowserOriginHeader: params.hasBrowserOriginHeader,
sharedAuthOk: params.sharedAuthOk,
authMethod: params.authMethod
})) return "shared_secret_loopback_local";
return "remote";
}
function shouldSkipLocalBackendSelfPairing(params) {
if (!(params.connectParams.client.id === GATEWAY_CLIENT_IDS.GATEWAY_CLIENT && params.connectParams.client.mode === GATEWAY_CLIENT_MODES.BACKEND)) return false;
if (!(params.locality === "direct_local" || params.locality === "shared_secret_loopback_local") || params.hasBrowserOriginHeader) return false;
if (params.authMethod === "none") return true;
const usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password";
const usesDeviceTokenAuth = params.authMethod === "device-token";
return params.sharedAuthOk && usesSharedSecretAuth || usesDeviceTokenAuth;
}
function resolveSignatureToken(connectParams) {
return connectParams.auth?.token ?? connectParams.auth?.deviceToken ?? connectParams.auth?.bootstrapToken ?? null;
}
function buildUnauthorizedHandshakeContext(params) {
return {
authProvided: params.authProvided,
canRetryWithDeviceToken: params.canRetryWithDeviceToken,
recommendedNextStep: params.recommendedNextStep
};
}
function resolveDeviceSignaturePayloadVersion(params) {
const signatureToken = resolveSignatureToken(params.connectParams);
const basePayload = {
deviceId: params.device.id,
clientId: params.connectParams.client.id,
clientMode: params.connectParams.client.mode,
role: params.role,
scopes: params.scopes,
signedAtMs: params.signedAtMs,
token: signatureToken,
nonce: params.nonce
};
const payloadV3 = buildDeviceAuthPayloadV3({
...basePayload,
platform: params.connectParams.client.platform,
deviceFamily: params.connectParams.client.deviceFamily
});
if (verifyDeviceSignature(params.device.publicKey, payloadV3, params.device.signature)) return "v3";
const payloadV2 = buildDeviceAuthPayload(basePayload);
if (verifyDeviceSignature(params.device.publicKey, payloadV2, params.device.signature)) return "v2";
return null;
}
function resolveAuthProvidedKind(connectAuth) {
return connectAuth?.password ? "password" : connectAuth?.token ? "token" : connectAuth?.bootstrapToken ? "bootstrap-token" : connectAuth?.deviceToken ? "device-token" : "none";
}
function resolveUnauthorizedHandshakeContext(params) {
const authProvided = resolveAuthProvidedKind(params.connectAuth);
const canRetryWithDeviceToken = params.failedAuth.reason === "token_mismatch" && params.hasDeviceIdentity && authProvided === "token" && !params.connectAuth?.deviceToken;
if (canRetryWithDeviceToken) return buildUnauthorizedHandshakeContext({
authProvided,
canRetryWithDeviceToken,
recommendedNextStep: "retry_with_device_token"
});
switch (params.failedAuth.reason) {
case "token_missing":
case "token_missing_config":
case "password_missing":
case "password_missing_config": return buildUnauthorizedHandshakeContext({
authProvided,
canRetryWithDeviceToken,
recommendedNextStep: "update_auth_configuration"
});
case "token_mismatch":
case "password_mismatch":
case "device_token_mismatch": return buildUnauthorizedHandshakeContext({
authProvided,
canRetryWithDeviceToken,
recommendedNextStep: "update_auth_credentials"
});
case "scope_mismatch": return buildUnauthorizedHandshakeContext({
authProvided,
canRetryWithDeviceToken,
recommendedNextStep: "review_auth_configuration"
});
case "rate_limited": return buildUnauthorizedHandshakeContext({
authProvided,
canRetryWithDeviceToken,
recommendedNextStep: "wait_then_retry"
});
default: return buildUnauthorizedHandshakeContext({
authProvided,
canRetryWithDeviceToken,
recommendedNextStep: "review_auth_configuration"
});
}
}
//#endregion
//#region src/gateway/server/ws-connection/unauthorized-flood-guard.ts
const DEFAULT_CLOSE_AFTER = 10;
const DEFAULT_LOG_EVERY = 100;
/** Counts unauthorized failures and decides when to log or close the socket. */
var UnauthorizedFloodGuard = class {
constructor(options) {
this.count = 0;
this.suppressedSinceLastLog = 0;
this.closeAfter = resolveIntegerOption(options?.closeAfter, DEFAULT_CLOSE_AFTER, { min: 1 });
this.logEvery = resolveIntegerOption(options?.logEvery, DEFAULT_LOG_EVERY, { min: 1 });
}
registerUnauthorized() {
this.count += 1;
const shouldClose = this.count > this.closeAfter;
if (!(this.count === 1 || this.count % this.logEvery === 0 || shouldClose)) {
this.suppressedSinceLastLog += 1;
return {
shouldClose,
shouldLog: false,
count: this.count,
suppressedSinceLastLog: 0
};
}
const suppressedSinceLastLog = this.suppressedSinceLastLog;
this.suppressedSinceLastLog = 0;
return {
shouldClose,
shouldLog: true,
count: this.count,
suppressedSinceLastLog
};
}
reset() {
this.count = 0;
this.suppressedSinceLastLog = 0;
}
};
/** Identifies role-auth failures that should feed the flood guard. */
function isUnauthorizedRoleError(error) {
if (!error) return false;
return error.code === ErrorCodes.INVALID_REQUEST && typeof error.message === "string" && error.message.startsWith("unauthorized role:");
}
//#endregion
//#region src/gateway/server/ws-connection/message-handler.ts
const DEVICE_SIGNATURE_SKEW_MS = 120 * 1e3;
const DEVICE_CREDENTIAL_INVALIDATING_METHODS = new Set([
"device.pair.remove",
"device.token.rotate",
"device.token.revoke"
]);
const unauthorizedHandshakeLogLimiter = new HandshakeAuthLogLimiter();
var NodePairingRateLimitError = class extends Error {
constructor(retryAfterMs) {
super("node pairing rate limited");
this.retryAfterMs = retryAfterMs;
}
};
/** Match production release versions (YYYY.M.PATCH or YYYY.M.PATCH-beta.N). */
const RELEASED_VERSION_RE = /^\d{4}\.\d+\.\d+/;
function isReleasedVersion(version) {
return RELEASED_VERSION_RE.test(version);
}
/**
* Lazily resolve the local node host's nodeId from ~/.openclaw/node.json.
* Process-stable: only changes on `openclaw node install`, which requires restart.
*/
let cachedLocalNodeId;
function resolveLocalNodeId() {
if (cachedLocalNodeId !== void 0) return cachedLocalNodeId;
try {
const raw = fs.readFileSync(path.join(resolveStateDir(), "node.json"), "utf8");
const parsed = JSON.parse(raw);
cachedLocalNodeId = typeof parsed.nodeId === "string" ? parsed.nodeId.trim() || null : null;
} catch {
cachedLocalNodeId = null;
}
return cachedLocalNodeId;
}
async function requestNodePairingFromConnect(params) {
if (!params.rateLimiter) return await requestNodePairing(params.input);
return await withSerializedRateLimitAttempt({
ip: params.clientIp,
scope: AUTH_RATE_LIMIT_SCOPE_NODE_PAIRING,
run: async () => {
const rateCheck = params.rateLimiter?.check(params.clientIp, AUTH_RATE_LIMIT_SCOPE_NODE_PAIRING);
if (rateCheck && !rateCheck.allowed) throw new NodePairingRateLimitError(rateCheck.retryAfterMs);
const result = await requestNodePairing(params.input);
params.rateLimiter?.recordFailure(params.clientIp, AUTH_RATE_LIMIT_SCOPE_NODE_PAIRING);
return result;
}
});
}
function firstHeaderValue(value) {
return Array.isArray(value) ? value[0] : value;
}
function resolveTrustedProxyControlUiScopes(params) {
const rawHeader = firstHeaderValue(params.upgradeReq.headers["x-openclaw-scopes"]);
if (rawHeader === void 0) return params.requestedScopes;
const declaredScopes = new Set(rawHeader.split(",").map((scope) => scope.trim()).filter((scope) => scope.length > 0));
if (declaredScopes.size === 0) return [];
return params.requestedScopes.filter((scope) => declaredScopes.has(scope));
}
function resolvePinnedClientMetadata(params) {
function normalizeLegacyNodeHostPlatformPin(value) {
switch (value) {
case "darwin":
case "macos": return "macos";
case "win32":
case "windows": return "windows";
default: return value;
}
}
function normalizeMobileAppPlatformPin(clientId, value) {
if (clientId === GATEWAY_CLIENT_IDS.IOS_APP && /^(?:ios|ipados)(?:\s|$)/.test(value)) return "ios-family";
if (clientId === GATEWAY_CLIENT_IDS.ANDROID_APP && /^android(?:\s|$)/.test(value)) return "android";
return value;
}
const claimedPlatform = normalizeDeviceMetadataForAuth(params.claimedPlatform);
const claimedDeviceFamily = normalizeDeviceMetadataForAuth(params.claimedDeviceFamily);
const pairedPlatform = normalizeDeviceMetadataForAuth(params.pairedPlatform);
const pairedDeviceFamily = normalizeDeviceMetadataForAuth(params.pairedDeviceFamily);
const hasPinnedPlatform = pairedPlatform !== "";
const hasPinnedDeviceFamily = pairedDeviceFamily !== "";
const isLegacyNodeHostPlatformPin = params.clientId === GATEWAY_CLIENT_IDS.NODE_HOST && params.clientMode === GATEWAY_CLIENT_MODES.NODE && hasPinnedPlatform && claimedPlatform !== "" && normalizeLegacyNodeHostPlatformPin(claimedPlatform) === normalizeLegacyNodeHostPlatformPin(pairedPlatform);
const isMobileAppPlatformVersionRefresh = hasPinnedPlatform && claimedPlatform !== "" && claimedPlatform !== pairedPlatform && normalizeMobileAppPlatformPin(params.clientId, claimedPlatform) === normalizeMobileAppPlatformPin(params.clientId, pairedPlatform);
const platformMismatch = hasPinnedPlatform && claimedPlatform !== pairedPlatform && !isLegacyNodeHostPlatformPin && !isMobileAppPlatformVersionRefresh;
const deviceFamilyMismatch = hasPinnedDeviceFamily && claimedDeviceFamily !== pairedDeviceFamily;
const pinnedPlatform = claimedPlatform === pairedPlatform ? params.pairedPlatform : isLegacyNodeHostPlatformPin ? normalizeLegacyNodeHostPlatformPin(pairedPlatform) : isMobileAppPlatformVersionRefresh ? params.claimedPlatform : void 0;
return {
platformMismatch,
deviceFamilyMismatch,
pinnedPlatform: hasPinnedPlatform ? pinnedPlatform : void 0,
pinnedDeviceFamily: hasPinnedDeviceFamily ? params.pairedDeviceFamily : void 0,
...isMobileAppPlatformVersionRefresh ? { refreshPairedPlatform: params.claimedPlatform } : {}
};
}
function attachGatewayWsMessageHandler(params) {
const { socket, upgradeReq, connId, remoteAddr, remotePort, localAddr, localPort, endpoint, forwardedFor, realIp, requestHost, requestOrigin, requestUserAgent, pluginSurfaceBaseUrl, pluginNodeCapabilities = [], connectNonce, getResolvedAuth, getRequiredSharedGatewaySessionGeneration, rateLimiter, browserRateLimiter, isStartupPending, gatewayMethods, events, extraHandlers, getMethodRegistry, buildRequestContext, refreshHealthSnapshot, send, close, isClosed, clearHandshakeTimer, getClient, setClient, setHandshakeState, setCloseCause, setLastFrameMeta, originCheckMetrics, logGateway, logHealth, logWsControl } = params;
const sendFrame = async (obj) => await new Promise((resolve, reject) => {
socket.send(JSON.stringify(obj), (err) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
const configSnapshot = getRuntimeConfig();
const trustedProxies = configSnapshot.gateway?.trustedProxies ?? [];
const allowRealIpFallback = configSnapshot.gateway?.allowRealIpFallback === true;
const clientIp = resolveClientIp({
remoteAddr,
forwardedFor,
realIp,
trustedProxies,
allowRealIpFallback
});
const peerLabel = endpoint ?? remoteAddr ?? "n/a";
const hasProxyHeaders = hasForwardedRequestHeaders(upgradeReq);
const remoteIsTrustedProxy = isTrustedProxyAddress(remoteAddr, trustedProxies);
const hasUntrustedProxyHeaders = hasProxyHeaders && !remoteIsTrustedProxy;
const hostIsLocalish = isLocalishHost(requestHost);
const isLocalClient = isLocalDirectRequest(upgradeReq, trustedProxies, allowRealIpFallback);
const reportedClientIp = isLocalClient || hasUntrustedProxyHeaders ? void 0 : clientIp && !isLoopbackAddress(clientIp) ? clientIp : void 0;
const reportedClientIpSource = resolveNodePairingClientIpSource({
reportedClientIp,
hasProxyHeaders,
remoteIsTrustedProxy,
remoteIsLoopback: isLoopbackAddress(remoteAddr)
});
if (hasUntrustedProxyHeaders) logWsControl.warn("Proxy headers detected from untrusted address. Connection will not be treated as local. Configure gateway.trustedProxies to restore local client detection behind your proxy.");
if (!hostIsLocalish && isLoopbackAddress(remoteAddr) && !hasProxyHeaders) logWsControl.warn("Loopback connection with non-local Host header. Treating it as remote. If you're behind a reverse proxy, set gateway.trustedProxies and forward X-Forwarded-For/X-Real-IP.");
const isWebchatConnect = (p) => isWebchatClient(p?.client);
const unauthorizedFloodGuard = new UnauthorizedFloodGuard();
let deviceCredentialMutationBarrier;
const { hasBrowserOriginHeader, enforceOriginCheckForAnyClient, rateLimitClientIp: browserRateLimitClientIp, authRateLimiter } = resolveHandshakeBrowserSecurityContext({
requestOrigin,
clientIp,
rateLimiter,
browserRateLimiter
});
const closeInvalidatedClient = (client, method) => {
if (!client.invalidated) return false;
const reason = client.invalidatedReason ?? "invalidated";
setCloseCause("client-invalidated", {
reason,
method
});
close(4001, `client invalidated: ${reason}`);
return true;
};
const handleMessage = async (data) => {
if (isClosed()) return;
const preauthPayloadBytes = !getClient() ? getRawDataByteLength(data) : void 0;
if (preauthPayloadBytes !== void 0 && preauthPayloadBytes > 65536) {
logRejectedLargePayload({
surface: "gateway.ws.preauth",
bytes: preauthPayloadBytes,
limitBytes: MAX_PREAUTH_PAYLOAD_BYTES,
reason: "preauth_frame_limit"
});
setHandshakeState("failed");
setCloseCause("preauth-payload-too-large", {
payloadBytes: preauthPayloadBytes,
limitBytes: MAX_PREAUTH_PAYLOAD_BYTES
});
close(1009, "preauth payload too large");
return;
}
const text = rawDataToString(data);
try {
const parsed = JSON.parse(text);
const frameType = parsed && typeof parsed === "object" && "type" in parsed ? typeof parsed.type === "string" ? String(parsed.type) : void 0 : void 0;
const frameMethod = parsed && typeof parsed === "object" && "method" in parsed ? typeof parsed.method === "string" ? String(parsed.method) : void 0 : void 0;
const frameId = parsed && typeof parsed === "object" && "id" in parsed ? typeof parsed.id === "string" ? String(parsed.id) : void 0 : void 0;
if (frameType || frameMethod || frameId) setLastFrameMeta({
type: frameType,
method: frameMethod,
id: frameId
});
const client = getClient();
if (!client) {
const isRequestFrame = validateRequestFrame(parsed);
if (!isRequestFrame || parsed.method !== "connect" || !validateConnectParams(parsed.params)) {
const handshakeError = isRequestFrame ? parsed.method === "connect" ? `invalid connect params: ${formatValidationErrors(validateConnectParams.errors)}` : "invalid handshake: first request must be connect" : "invalid request frame";
setHandshakeState("failed");
setCloseCause("invalid-handshake", {
frameType,
frameMethod,
frameId,
handshakeError
});
if (isRequestFrame) send({
type: "res",
id: parsed.id,
ok: false,
error: errorShape(ErrorCodes.INVALID_REQUEST, handshakeError)
});
else logWsControl.warn(`invalid handshake conn=${connId} peer=${formatForLog(peerLabel)} remote=${remoteAddr ?? "?"} fwd=${formatForLog(forwardedFor ?? "n/a")} origin=${formatForLog(requestOrigin ?? "n/a")} host=${formatForLog(requestHost ?? "n/a")} ua=${formatForLog(requestUserAgent ?? "n/a")}`);
const closeReason = truncateCloseReason(handshakeError || "invalid handshake");
if (isRequestFrame) queueMicrotask(() => close(1008, closeReason));
else close(1008, closeReason);
return;
}
const frame = parsed;
const connectParams = frame.params;
const resolvedAuth = getResolvedAuth();
const clientLabel = connectParams.client.displayName ?? connectParams.client.id;
const clientMeta = {
client: connectParams.client.id,
clientDisplayName: connectParams.client.displayName,
mode: connectParams.client.mode,
version: connectParams.client.version,
platform: connectParams.client.platform,
deviceFamily: connectParams.client.deviceFamily,
modelIdentifier: connectParams.client.modelIdentifier,
instanceId: connectParams.client.instanceId
};
const markHandshakeFailure = (cause, meta) => {
setHandshakeState("failed");
setCloseCause(cause, {
...meta,
...clientMeta
});
};
const sendHandshakeErrorResponse = (code, message, options) => {
send({
type: "res",
id: frame.id,
ok: false,
error: errorShape(code, message, options)
});
};
if (isStartupPending?.()) {
markHandshakeFailure(GATEWAY_STARTUP_PENDING_CLOSE_CAUSE);
await sendFrame({
type: "res",
id: frame.id,
ok: false,
error: errorShape(ErrorCodes.UNAVAILABLE, "gateway starting; retry shortly", {
retryable: true,
retryAfterMs: 500,
details: gatewayStartupUnavailableDetails()
})
}).catch(() => {});
queueMicrotask(() => close(GATEWAY_STARTUP_CLOSE_CODE, GATEWAY_STARTUP_CLOSE_REASON));
return;
}
const { minProtocol, maxProtocol } = connectParams;
const supportsCurrentProtocol = maxProtocol >= 4 && minProtocol <= 4;
const supportsProbeRestartProtocol = connectParams.client.mode === GATEWAY_CLIENT_MODES.PROBE && maxProtocol >= 4 && minProtocol <= 4;
if (!supportsCurrentProtocol && !supportsProbeRestartProtocol) {
markHandshakeFailure("protocol-mismatch", {
minProtocol,
maxProtocol,
expectedProtocol: 4,
minimumProbeProtocol: 4
});
logWsControl.warn(`protocol mismatch conn=${connId} peer=${formatForLog(peerLabel)} remote=${remoteAddr ?? "?"} remotePort=${remotePort ?? "?"} client=${formatForLog(clientLabel)} ${connectParams.client.mode} v${formatForLog(connectParams.client.version)} min=${minProtocol} max=${maxProtocol} expected=4 probeMin=4 instance=${formatForLog(connectParams.client.instanceId ?? "n/a")}`);
sendHandshakeErrorResponse(ErrorCodes.INVALID_REQUEST, "protocol mismatch", { details: {
code: ConnectErrorDetailCodes.PROTOCOL_MISMATCH,
clientMinProtocol: minProtocol,
clientMaxProtocol: maxProtocol,
expectedProtocol: 4,
minimumProbeProtocol: 4
} });
close(1002, "protocol mismatch");
return;
}
const roleRaw = connectParams.role ?? "operator";
const role = parseGatewayRole(roleRaw);
if (!role) {
markHandshakeFailure("invalid-role", { role: roleRaw });
sendHandshakeErrorResponse(ErrorCodes.INVALID_REQUEST, "invalid role");
close(1008, "invalid role");
return;
}
let scopes = Array.isArray(connectParams.scopes) ? connectParams.scopes : [];
connectParams.role = role;
connectParams.scopes = scopes;
const isControlUi = isOperatorUiClient(connectParams.client);
const isBrowserOperatorUi = isBrowserOperatorUiClient(connectParams.client);
const isWebchat = isWebchatConnect(connectParams);
const isNativeAppUi = connectParams.client.mode === GATEWAY_CLIENT_MODES.UI && (connectParams.client.id === GATEWAY_CLIENT_IDS.MACOS_APP || connectParams.client.id === GATEWAY_CLIENT_IDS.IOS_APP || connectParams.client.id === GATEWAY_CLIENT_IDS.ANDROID_APP);
if (enforceOriginCheckForAnyClient || isBrowserOperatorUi || isWebchat) {
const hostHeaderOriginFallbackEnabled = configSnapshot.gateway?.controlUi?.dangerouslyAllowHostHeaderOriginFallback === true;
const originCheck = checkBrowserOrigin({
requestHost,
origin: requestOrigin,
allowedOrigins: configSnapshot.gateway?.controlUi?.allowedOrigins,
allowHostHeaderOriginFallback: hostHeaderOriginFallbackEnabled,
isLocalClient
});
if (!originCheck.ok) {
const errorMessage = "origin not allowed (open the Control UI from the gateway host or allow it in gateway.controlUi.allowedOrigins)";
markHandshakeFailure("origin-mismatch", {
origin: requestOrigin ?? "n/a",
host: requestHost ?? "n/a",
reason: originCheck.reason
});
sendHandshakeErrorResponse(ErrorCodes.INVALID_REQUEST, errorMessage, { details: {
code: ConnectErrorDetailCodes.CONTROL_UI_ORIGIN_NOT_ALLOWED,
reason: originCheck.reason
} });
close(1008, truncateCloseReason(errorMessage));
return;
}
if (originCheck.matchedBy === "host-header-fallback") {
originCheckMetrics.hostHeaderFallbackAccepted += 1;
logWsControl.warn(`security warning: websocket origin accepted via Host-header fallback conn=${connId} count=${originCheckMetrics.hostHeaderFallbackAccepted} host=${requestHost ?? "n/a"} origin=${requestOrigin ?? "n/a"}`);
if (hostHeaderOriginFallbackEnabled) logGateway.warn("security metric: gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback accepted a websocket connect request");
}
}
const deviceRaw = connectParams.device;
let devicePublicKey = null;
let deviceAuthPayloadVersion = null;
const hasTokenAuth = Boolean(connectParams.auth?.token);
const hasPasswordAuth = Boolean(connectParams.auth?.password);
const hasSharedAuth = hasTokenAuth || hasPasswordAuth;
const controlUiAuthPolicy = resolveControlUiAuthPolicy({
isControlUi,
controlUiConfig: configSnapshot.gateway?.controlUi,
deviceRaw
});
const device = controlUiAuthPolicy.device;
const connectAuthState = await resolveConnectAuthState({
resolvedAuth,
connectAuth: connectParams.auth,
hasDeviceIdentity: Boolean(device),
req: upgradeReq,
trustedProxies,
allowRealIpFallback,
rateLimiter: authRateLimiter,
clientIp: browserRateLimitClientIp
});
const { sharedAuthOk, bootstrapTokenCandidate, deviceTokenCandidate, deviceTokenCandidateSource } = connectAuthState;
let { authResult, authOk, authMethod } = connectAuthState;
const rejectUnauthorized = (failedAuth) => {
const { authProvided, canRetryWithDeviceToken, recommendedNextStep } = resolveUnauthorizedHandshakeContext({
connectAuth: connectParams.auth,
failedAuth,
hasDeviceIdentity: Boolean(device)
});
markHandshakeFailure("unauthorized", {
authMode: resolvedAuth.mode,
authProvided,
authReason: failedAuth.reason,
allowTailscale: resolvedAuth.allowTailscale,
peer: peerLabel,
remoteAddr,
remotePort,
localAddr,
localPort,
role,
scopeCount: scopes.length,
hasDeviceIdentity: Boolean(device)
});
const authLogDecision = shouldLimitMissingCredentialAuthLog({
reason: failedAuth.reason,
authProvided
}) ? unauthorizedHandshakeLogLimiter.register(buildHandshakeAuthLogKey({
reason: failedAuth.reason,
remoteAddr,
client: clientLabel,
mode: connectParams.client.mode,
authProvided
})) : {
shouldLog: true,
suppressedSinceLastLog: 0
};
if (authLogDecision.shouldLog) {
const suppressedText = authLogDecision.suppressedSinceLastLog > 0 ? ` suppressed=${authLogDecision.suppressedSinceLastLog}` : "";
logWsControl.warn(`unauthorized conn=${connId} peer=${formatForLog(peerLabel)} remote=${remoteAddr ?? "?"} client=${formatForLog(clientLabel)} ${connectParams.client.mode} v${formatForLog(connectParams.client.version)} role=${role} scopes=${scopes.length} auth=${authProvided} device=${device ? "yes" : "no"} platform=${formatForLog(connectParams.client.platform)} instance=${formatForLog(connectParams.client.instanceId ?? "n/a")} host=${formatForLog(requestHost ?? "n/a")} origin=${formatForLog(requestOrigin ?? "n/a")} ua=${formatForLog(requestUserAgent ?? "n/a")} reason=${failedAuth.reason ?? "unknown"}${suppressedText}`);
}
const authMessage = formatGatewayAuthFailureMessage({
authMode: resolvedAuth.mode,
authProvided,
reason: failedAuth.reason,
client: connectParams.client
});
sendHandshakeErrorResponse(ErrorCodes.INVALID_REQUEST, authMessage, { details: {
code: resolveAuthConnectErrorDetailCode(failedAuth.reason),
authReason: failedAuth.reason,
canRetryWithDeviceToken,
recommendedNextStep
} });
close(1008, truncateCloseReason(authMessage));
};
const clearUnboundScopes = () => {
if (scopes.length > 0) {
scopes = [];
connectParams.scopes = scopes;
}
};
let pairingLocality = resolvePairingLocality({
connectParams,
isLocalClient,
requestHost,
requestOrigin,
remoteAddress: remoteAddr,
hasProxyHeaders,
hasBrowserOriginHeader,
sharedAuthOk,
authMethod
});
let skipLocalBackendSelfPairing = shouldSkipLocalBackendSelfPairing({
connectParams,
locality: pairingLocality,
hasBrowserOriginHeader,
sharedAuthOk,
authMethod
});
const handleMissingDeviceIdentity = () => {
const trustedProxyAuthOk = isTrustedProxyControlUiOperatorAuth({
isControlUi,
role,
authMode: resolvedAuth.mode,
authOk,
authMethod
});
const preserveInsecureLocalControlUiScopes = isControlUi && controlUiAuthPolicy.allowInsecureAuthConfigured && isLocalClient && (authMethod === "token" || authMethod === "password");
const decision = evaluateMissingDeviceIdentity({
hasDeviceIdentity: Boolean(device),
role,
isControlUi,
controlUiAuthPolicy,
trustedProxyAuthOk,
localBackendSelfPairingOk: skipLocalBackendSelfPairing,
sharedAuthOk,
authOk,
hasSharedAuth,
isLocalClient
});
if (!device && !skipLocalBackendSelfPairing && shouldClearUnboundScopesForMissingDeviceIdentity({
decision,
controlUiAuthPolicy,
preserveInsecureLocalControlUiScopes,
authMethod,
trustedProxyAuthOk
})) clearUnboundScopes();
if (decision.kind === "allow") return true;
if (decision.kind === "reject-control-ui-insecure-auth") {
const errorMessage = "control ui requires device identity (use HTTPS or localhost secure context)";
markHandshakeFailure("control-ui-insecure-auth", { insecureAuthConfigured: controlUiAuthPolicy.allowInsecureAuthConfigured });
sendHandshakeErrorResponse(ErrorCodes.INVALID_REQUEST, errorMessage, { details: { code: ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED } });
close(1008, errorMessage);
return false;
}
if (decision.kind === "reject-unauthorized") {
rejectUnauthorized(authResult);
return false;
}
markHandshakeFailure("device-required");
sendHandshakeErrorResponse(ErrorCodes.NOT_PAIRED, "device identity required", { details: { code: ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED } });
close(1008, "device identity required");
return false;
};
if (!handleMissingDeviceIdentity()) return;
if (device) {
const rejectDeviceAuthInvalid = (reason, message) => {
setHandshakeState("failed");
setCloseCause("device-auth-invalid", {
reason,
client: connectParams.client.id,
deviceId: device.id
});
send({
type: "res",
id: frame.id,
ok: false,
error: errorShape(ErrorCodes.INVALID_REQUEST, message, { details: {
code: resolveDeviceAuthConnectErrorDetailCode(reason),
reason
} })
});
close(1008, message);
};
const derivedId = deriveDeviceIdFromPublicKey(device.publicKey);
if (!derivedId || derivedId !== device.id) {
rejectDeviceAuthInvalid("device-id-mismatch", "device identity mismatch");
return;
}
const signedAt = device.signedAt;
if (typeof signedAt !== "number" || Math.abs(Date.now() - signedAt) > DEVICE_SIGNATURE_SKEW_MS) {
rejectDeviceAuthInvalid("device-signature-stale", "device signature expired");
return;
}
const providedNonce = typeof device.nonce === "string" ? device.nonce.trim() : "";
if (!providedNonce) {
rejectDeviceAuthInvalid("device-nonce-missing", "device nonce required");
return;
}
if (providedNonce !== connectNonce) {
rejectDeviceAuthInvalid("device-nonce-mismatch", "device nonce mismatch");
return;
}
const rejectDeviceSignatureInvalid = () => rejectDeviceAuthInvalid("device-signature", "device signature invalid");
const payloadVersion = resolveDeviceSignaturePayloadVersion({
device,
connectParams,
role,
scopes,
signedAtMs: signedAt,
nonce: providedNonce
});
if (!payloadVersion) {
rejectDeviceSignatureInvalid();
return;
}
deviceAuthPayloadVersion = payloadVersion;
devicePublicKey = normalizeDevicePublicKeyBase64Url(device.publicKey);
if (!devicePublicKey) {
rejectDeviceAuthInvalid("device-public-key", "device public key invalid");
return;
}
}
const authDecision = await resolveConnectAuthDecision({
state: {
authResult,
authOk,
authMethod,
sharedAuthOk,
sharedAuthProvided: hasSharedAuth,
bootstrapTokenCandidate,
deviceTokenCandidate,
deviceTokenCandidateSource
},
hasDeviceIdentity: Boolean(device),
deviceId: device?.id,
publicKey: device?.publicKey,
role,
scopes,
rateLimiter: authRateLimiter,
clientIp: browserRateLimitClientIp,
verifyBootstrapToken: async ({ deviceId, publicKey, token, role: roleLocal, scopes: scopesLocal }) => await verifyDeviceBootstrapToken({
deviceId,
publicKey,
token,
role: roleLocal,
scopes: scopesLocal
}),
verifyDeviceToken: async (paramsLocal) => await verifyDeviceToken({
...paramsLocal,
requiredSharedGatewaySessionGeneration: getRequiredSharedGatewaySessionGeneration?.()
})
});
({authResult, authOk, authMethod} = authDecision);
const deviceTokenSharedGatewaySessionGeneration = authDecision.deviceTokenSharedGatewaySessionGeneration;
pairingLocality = resolvePairingLocality({
connectParams,
isLocalClient,
requestHost,
requestOrigin,
remoteAddress: remoteAddr,
hasProxyHeaders,
hasBrowserOriginHeader,
sharedAuthOk,
authMethod
});
skipLocalBackendSelfPairing = shouldSkipLocalBackendSelfPairing({
connectParams,
locality: pairingLocality,
hasBrowserOriginHeader,
sharedAuthOk,
authMethod
});
if (!authOk) {
rejectUnauthorized(authResult);
return;
}
const usesSharedGatewayAuth = authMethod === "token" || authMethod === "password" || authMethod === "trusted-proxy";
const sharedGatewaySessionGeneration = usesSharedGatewayAuth ? resolveSharedGatewaySessionGeneration(resolvedAuth, trustedProxies) : void 0;
const sessionUsesSharedGatewayAuth = usesSharedGatewayAuth || deviceTokenSharedGatewaySessionGeneration !== void 0;
const sessionSharedGatewaySessionGeneration = sharedGatewaySessionGeneration ?? deviceTokenSharedGatewaySessionGeneration;
if (sessionUsesSharedGatewayAuth) {
const requiredSharedGatewaySessionGeneration = getRequiredSharedGatewaySessionGeneration?.();
if (requiredSharedGatewaySessionGeneration !== void 0 && sessionSharedGatewaySessionGeneration !== requiredSharedGatewaySessionGeneration) {
setCloseCause("gateway-auth-rotated", { authGenerationStale: true });
close(4001, "gateway auth changed");
return;
}
}
const issuedBootstrapProfile = authMethod === "bootstrap-token" && bootstrapTokenCandidate ? await getDeviceBootstrapTokenProfile({ token: bootstrapTokenCandidate }) : null;
let handoffBootstrapProfile = null;
const trustedProxyAuthOk = isTrustedProxyControlUiOperatorAuth({
isControlUi,
role,
authMode: resolvedAuth.mode,
authOk,
authMethod
});
if (trustedProxyAuthOk) {
scopes = resolveTrustedProxyControlUiScopes({
requestedScopes: scopes,
upgradeReq
});
connectParams.scopes = scopes;
}
const skipControlUiPairingForDevice = shouldSkipControlUiPairing(controlUiAuthPolicy, role, trustedProxyAuthOk, resolvedAuth.mode, authMethod);
let hasServerApprovedDeviceTokenBaseline = false;
if (device && devicePublicKey) {
const formatAuditList = (items) => {
if (!items || items.length === 0) return "<none>";
const out = /* @__PURE__ */ new Set();
for (const item of items) {
const trimmed = item.trim();
if (trimmed) out.add(trimmed);
}
if (out.size === 0) return "<none>";
return [...out].toSorted().join(",");
};
const logUpgradeAudit = (reason, currentRoles, currentScopes) => {
logGateway.warn(`security audit: device access upgrade requested reason=${reason} device=${device.id} ip=${reportedClientIp ?? "unknown-ip"} auth=${authMethod} roleFrom=${formatAuditList(currentRoles)} roleTo=${role} scopesFrom=${formatAuditList(currentScopes)} scopesTo=${formatAuditList(scopes)} client=${connectParams.client.id} conn=${connId}`);
};
const clientPairingMetadata = {
displayName: connectParams.client.displayName,
platform: connectParams.client.platform,
deviceFamily: connectParams.client.deviceFamily,
clientId: connectParams.client.id,
clientMode: connectParams.client.mode,
role,
scopes,
remoteIp: reportedClientIp
};
const clientAccessMetadata = {
displayName: connectParams.client.displayName,
remoteIp: reportedClientIp,
lastSeenAtMs: Date.now(),
lastSeenReason: "connect"
};
const requirePairing = async (reason, existingPairedDevice = null) => {
const pairingStateAllowsRequestedAccess = (pairedCandidate) => {
if (!pairedCandidate || pairedCandidate.publicKey !== devicePublicKey) return false;
if (!hasEffectivePairedDeviceRole(pairedCandidate, role)) return false;
if (scopes.length === 0) return true;
const pairedScopes = Array.isArray(pairedCandidate.approvedScopes) ? pairedCandidate.approvedScopes : Array.isArray(pairedCandidate.scopes) ? pairedCandidate.scopes : [];
if (pairedScopes.length === 0) return false;
return roleScopesAllow({
role,
requestedScopes: scopes,
allowedScopes: pairedScopes
});
};
const allowSilentLocalPairing = !(existingPairedDevice && role !== "operator") && shouldAllowSilentLocalPairing({
locality: pairingLocality,
hasBrowserOriginHeader,
isControlUi,
isWebchat,
isNativeAppUi,
reason
});
const allowSilentTrustedCidrsNodePairing = shouldAutoApproveNodePairingFromTrustedCidrs({
existingPairedDevice: Boolean(existingPairedDevice),
role,
reason,
scopes,
hasBrowserOriginHeader,
isControlUi,
isWebchat,
reportedClientIpSource,
reportedClientIp,
autoApproveCidrs: configSnapshot.gateway?.nodes?.pairing?.autoApproveCidrs
});
const boundBootstrapProfile = authMethod === "bootstrap-token" && bootstrapTokenCandidate && reason === "not-paired" && role === "node" && scopes.length === 0 && !existingPairedDevice && !isControlUi && !isBrowserOperatorUi && !isWebchat && connectParams.client.mode === GATEWAY_CLIENT_MODES.NODE ? await getBoundDeviceBootstrapProfile({
token: bootstrapTokenCandidate,
deviceId: device.id,
publicKey: devicePublicKey
}) : null;
const allowSilentBootstrapPairing = boundBootstrapProfile !== null && isPairingSetupBootstrapProfile(boundBootstrapProfile);
const bootstrapPairingRoles = allowSilentBootstrapPairing ? uniqueStrings([role, ...boundBootstrapProfile.roles]) : void 0;
const bootstrapPairingScopes = allowSilentBootstrapPairing && bootstrapPairingRoles ? resolveBootstrapProfileScopesForRoles(bootstrapPairingRoles, boundBootstrapProfile.scopes) : void 0;
const pairing = await requestDevicePairing({
deviceId: device.id,
publicKey: devicePublicKey,
...clientPairingMetadata,
...bootstrapPairingRoles ? {
roles: bootstrapPairingRoles,
scopes: bootstrapPairingScopes ?? []
} : {},
silent: reason === "scope-upgrade" ? false : allowSilentLocalPairing || allowSilentTrustedCidrsNodePairing || allowSilentBootstrapPairing
});
const context = buildRequestContext();
let approved;
let resolvedByConcurrentApproval = false;
let recoveryRequestId;
const resolveLivePendingRequestId = async () => {
const pendingList = await listDevicePairing();
const exactPending = pendingList.pending.find((pending) => pending.requestId === pairing.request.requestId);
if (exactPending) return exactPending.requestId;
return pendingList.pending.find((pending) => pending.deviceId === device.id && pending.publicKey === devicePublicKey)?.requestId;
};
if (pairing.request.silent === true) {
approved = allowSilentBootstrapPairing && boundBootstrapProfile ? await approveBootstrapDevicePairing(pairing.request.requestId, boundBootstrapProfile, { accessMetadata: clientAccessMetadata }) : await approveDevicePairing(pairing.request.requestId, {
callerScopes: scopes,
accessMetadata: clientAccessMetadata
});
if (approved?.status === "approved") {
if (allowSilentBootstrapPairing && boundBootstrapProfile) handoffBootstrapProfile = boundBootstrapProfile;
logGateway.info(`device pairing auto-approved device=${approved.device.deviceId} role=${approved.device.role ?? "unknown"}`);
context.broadcast("device.pair.resolved", {
requestId: pairing.request.requestId,
deviceId: approved.device.deviceId,
decision: "approved",
ts: Date.now()
}, { dropIfSlow: true });
} else {
resolvedByConcurrentApproval = pairingStateAllowsRequestedAccess(await getPairedDevice(device.id));
let requestStillPending = false;
if (!resolvedByConcurrentApproval) {
recoveryRequestId = await resolveLivePendingRequestId();
requestStillPending = recoveryRequestId === pairing.request.requestId;
}
if (requestStillPending) context.broadcast("device.pair.requested", pairing.request, { dropIfSlow: true });
}
} else if (pairing.created) context.broadcast("device.pair.requested", pairing.request, { dropIfSlow: true });
recoveryRequestId = await resolveLivePendingRequestId();
if (!(pairing.request.silent === true && (approved?.status === "approved" || resolvedByConcurrentApproval))) {
const exposeApprovedAccess = existingPairedDevice?.publicKey === devicePublicKey;
const approvedRoles = exposeApprovedAccess ? listApprovedPairedDeviceRoles(existingPairedDevice) : [];
const approvedScopes = exposeApprovedAccess ? Array.isArray(existingPairedDevice.approvedScopes) ? existingPairedDevice.approvedScopes : Array.isArray(existingPairedDevice.scopes) ? existingPairedDevice.scopes : [] : [];
const retryAfterBootstrapPairingApproval = authMethod === "bootstrap-token" && reason === "not-paired" && role === "node" && scopes.length === 0 && !existingPairedDevice;
const pairingErrorDetails = buildPairingConnectErrorDetails({
reason,
requestId: recoveryRequestId,
...retryAfterBootstrapPairingApproval ? {
recommendedNextStep: "wait_then_retry",
retryable: true,
pauseReconnect: false
} : {},
deviceId: device.id,
requestedRole: role,
requestedScopes: scopes,
...approvedRoles.length > 0 ? { approvedRoles } : {},
...approvedScopes.length > 0 ? { approvedScopes } : {}
});
const pairingErrorMessage = buildPairingConnectErrorMessage(reason);
setHandshakeState("failed");
setCloseCause("pairing-required", {
deviceId: device.id,
...recoveryRequestId ? { requestId: recoveryRequestId } : {},
reason
});
send({
type: "res",
id: frame.id,
ok: false,
error: errorShape(ErrorCodes.NOT_PAIRED, pairingErrorMessage, { details: pairingErrorDetails })
});
close(1008, truncateCloseReason(buildPairingConnectCloseReason({
reason,
requestId: recoveryRequestId
})));
return false;
}
return true;
};
const paired = await getPairedDevice(device.id);
if (!(paired?.publicKey === devicePublicKey)) {
if (!(skipLocalBackendSelfPairing || skipControlUiPairingForDevice)) {
if (!await requirePairing("not-paired", paired)) return;
hasServerApprovedDeviceTokenBaseline = true;
} else if (skipControlUiPairingForDevice || skipLocalBackendSelfPairing && authMethod !== "device-token") hasServerApprovedDeviceTokenBaseline = true;
} else {
hasServerApprovedDeviceTokenBaseline = true;
const claimedPlatform = connectParams.client.platform;
const pairedPlatform = paired.platform;
const claimedDeviceFamily = connectParams.client.deviceFamily;
const pairedDeviceFamily = paired.deviceFamily;
const metadataPinning = resolvePinnedClientMetadata({
clientId: connectParams.client.id,
clientMode: connectParams.client.mode,
claimedPlatform,
claimedDeviceFamily,
pairedPlatform,
pairedDeviceFamily
});
const { platformMismatch, deviceFamilyMismatch } = metadataPinning;
if (platformMismatch || deviceFamilyMismatch) {
if (!shouldAllowSilentLocalPairing({
locality: pairingLocality,
hasBrowserOriginHeader,
isControlUi,
isWebchat,
isNativeAppUi,
reason: "metadata-upgrade"
})) logGateway.warn(`security audit: device metadata upgrade requested reason=metadata-upgrade device=${device.id} ip=${reportedClientIp ?? "unknown-ip"} auth=${authMethod} payload=${deviceAuthPayloadVersion ?? "unknown"} claimedPlatform=${claimedPlatform ?? "<none>"} pinnedPlatform=${pairedPlatform ?? "<none>"} claimedDeviceFamily=${claimedDeviceFamily ?? "<none>"} pinnedDeviceFamily=${pairedDeviceFamily ?? "<none>"} client=${connectParams.client.id} conn=${connId}`);
if (!await requirePairing("metadata-upgrade", paired)) return;
} else {
if (metadataPinning.pinnedPlatform) connectParams.client.platform = metadataPinning.pinnedPlatform;
if (metadataPinning.pinnedDeviceFamily) connectParams.client.deviceFamily = metadataPinning.pinnedDeviceFamily;
}
const pairedRoles = listEffectivePairedDeviceRoles(paired);
const pairedScopes = Array.isArray(paired.approvedScopes) ? paired.approvedScopes : Array.isArray(paired.scopes) ? paired.scopes : [];
const allowedRoles = new Set(pairedRoles);
if (allowedRoles.size === 0) {
logUpgradeAudit("role-upgrade", pairedRoles, pairedScopes);
if (!await requirePairing("role-upgrade", paired)) return;
} else if (!allowedRoles.has(role)) {
logUpgradeAudit("role-upgrade", pairedRoles, pairedScopes);
if (!await requirePairing("role-upgrade", paired)) return;
}
if (scopes.length > 0) {
if (pairedScopes.length === 0) {
logUpgradeAudit("scope-upgrade", pairedRoles, pairedScopes);
if (!await requirePairing("scope-upgrade", paired)) return;
} else if (!roleScopesAllow({
role,
requestedScopes: scopes,
allowedScopes: pairedScopes
})) {
logUpgradeAudit("scope-upgrade", pairedRoles, pairedScopes);
if (!await requirePairing("scope-upgrade", paired)) return;
}
}
const retryBootstrapHandoffProfile = authMethod === "bootstrap-token" && bootstrapTokenCandidate && role === "node" && scopes.length === 0 && !isControlUi && !isBrowserOperatorUi && !isWebchat && connectParams.client.mode === GATEWAY_CLIENT_MODES.NODE && pairedRoles.includes("operator") ? await getBoundDeviceBootstrapProfile({
token: bootstrapTokenCandidate,
deviceId: device.id,
publicKey: devicePublicKey
}) : null;
if (retryBootstrapHandoffProfile) {
const retryBootstrapOperatorScopes = resolveBootstrapProfileScopesForRole("operator", retryBootstrapHandoffProfile.scopes);
if (isPairingSetupBootstrapProfile(retryBootstrapHandoffProfile) && roleScopesAllow({
role: "operator",
requestedScopes: retryBootstrapOperatorScopes,
allowedScopes: pairedScopes
})) handoffBootstrapProfile = retryBootstrapHandoffProfile;
}
await updatePairedDeviceMetadata(device.id, {
...clientAccessMetadata,
...metadataPinning.refreshPairedPlatform ? { platform: metadataPinning.refreshPairedPlatform } : {}
});
}
}
const shouldIssueDeviceToken = !trustedProxyAuthOk;
const sharedGatewayAuthIssuer = sessionSharedGatewaySessionGeneration && (deviceTokenSharedGatewaySessionGeneration !== void 0 || usesSharedGatewayAuth && (isBrowserOperatorUi || isWebchat)) ? {
kind: "shared-gateway-auth",
generation: sessionSharedGatewaySessionGeneration
} : void 0;
const deviceToken = shouldIssueDeviceToken && device && hasServerApprovedDeviceTokenBaseline ? await ensureDeviceToken({
deviceId: device.id,
role,
scopes,
issuer: sharedGatewayAuthIssuer
}) : null;
const bootstrapDeviceTokens = [];
if (deviceToken) bootstrapDeviceTokens.push({
deviceToken: deviceToken.token,
role: deviceToken.role,
scopes: deviceToken.scopes,
issuedAtMs: deviceToken.rotatedAtMs ?? deviceToken.createdAtMs
});
const approvedHandoffBootstrapProfile = handoffBootstrapProfile;
if (device && approvedHandoffBootstrapProfile) for (const bootstrapRole of approvedHandoffBootstrapProfile.roles) {
if (bootstrapDeviceTokens.some((entry) => entry.role === bootstrapRole)) continue;
const bootstrapRoleScopes = bootstrapRole === "operator" ? resolveBootstrapProfileScopesForRole(bootstrapRole, approvedHandoffBootstrapProfile.scopes) : [];
const extraToken = await ensureDeviceToken({
deviceId: device.id,
role: bootstrapRole,
scopes: bootstrapRoleScopes
});
if (!extraToken) continue;
bootstrapDeviceTokens.push({
deviceToken: extraToken.token,
role: extraToken.role,
scopes: extraToken.scopes,
issuedAtMs: extraToken.rotatedAtMs ?? extraToken.createdAtMs
});
}
if (role === "node") {
let reconciliation;
const pairedNode = await getPairedNode(connectParams.device?.id ?? connectParams.client.id);
try {
reconciliation = await reconcileNodePairingOnConnect({
cfg: getRuntimeConfig(),
connectParams,
pairedNode,
reportedClientIp,
requestPairing: async (input) => {
try {
return await requestNodePairingFromConnect({
input,
rateLimiter: authRateLimiter,
clientIp: browserRateLimitClientIp
});
} catch (error) {
if (error instanceof NodePairingRateLimitError && pairedNode) return null;
throw error;
}
}
});
} catch (error) {
if (error instanceof NodePairingRateLimitError) {
rejectUnauthorized({
ok: false,
reason: "rate_limited",
rateLimited: true,
retryAfterMs: error.retryAfterMs
});
return;
}
throw error;
}
if (reconciliation.pendingPairing?.created) {
const requestContext = buildRequestContext();
const resolvedAt = Date.now();
for (const superseded of reconciliation.pendingPairing.superseded ?? []) requestContext.broadcast("node.pair.resolved", {
requestId: superseded.requestId,
nodeId: superseded.nodeId,
decision: "rejected",
ts: resolvedAt
}, { dropIfSlow: true });
requestContext.broadcast("node.pair.requested", reconciliation.pendingPairing.request, { dropIfSlow: true });
}
const nodeConnectParams = connectParams;
nodeConnectParams.declaredCaps = reconciliation.declaredCaps;
nodeConnectParams.declaredCommands = reconciliation.declaredCommands;
nodeConnectParams.declaredPermissions = reconciliation.declaredPermissions;
connectParams.caps = reconciliation.effectiveCaps;
connectParams.commands = reconciliation.effectiveCommands;
connectParams.permissions = reconciliation.effectivePermissions;
}
const shouldTrackPresence = !isGatewayCliClient(connectParams.client);
const clientId = connectParams.client.id;
const instanceId = connectParams.client.instanceId;
const presenceKey = shouldTrackPresence ? device?.id ?? instanceId ?? connId : void 0;
if (isClosed()) {
setCloseCause("connect-aborted-before-register", {
...clientMeta,
auth: authMethod
});
return;
}
const pluginSurfaceUrls = {};
const pluginNodeCapabilitySurfaces = indexPluginNodeCapabilitySurfaces(pluginNodeCapabilities);
const pendingPluginNodeCapabilities = [];
if (pluginSurfaceBaseUrl) for (const pluginCapabilitySurface of Object.values(pluginNodeCapabilitySurfaces)) {
const capability = mintPluginNodeCapabilityToken();
const expiresAtMs = resolvePluginNodeCapabilityExpiresAtMs(pluginCapabilitySurface);
if (expiresAtMs === void 0) continue;
const scopedUrl = buildPluginNodeCapabilityScopedHostUrl(pluginSurfaceBaseUrl, capability) ?? pluginSurfaceBaseUrl;
pluginSurfaceUrls[pluginCapabilitySurface.surface] = scopedUrl;
pendingPluginNodeCapabilities.push({
surface: pluginCapabilitySurface,
capability,
expiresAtMs
});
}
const isTrustedApprovalRuntime = pairingLocality !== "remote" && scopes.includes("operator.approvals") && connectParams.client.id === GATEWAY_CLIENT_IDS.GATEWAY_CLIENT && connectParams.client.mode === GATEWAY_CLIENT_MODES.BACKEND && isOperatorApprovalRuntimeToken(connectParams.auth?.approvalRuntimeToken);
clearHandshakeTimer();
const nextClient = {
socket,
connect: connectParams,
connId,
isDeviceTokenAuth: authMethod === "device-token",
usesSharedGatewayAuth: sessionUsesSharedGatewayAuth,
sharedGatewaySessionGeneration: sessionSharedGatewaySessionGeneration,
presenceKey,
clientIp: reportedClientIp,
...isTrustedApprovalRuntime ? { internal: { approvalRuntime: true } } : {},
...Object.keys(pluginSurfaceUrls).length > 0 ? { pluginSurfaceUrls } : {},
...Object.keys(pluginNodeCapabilitySurfaces).length > 0 ? { pluginNodeCapabilitySurfaces } : {}
};
for (const entry of pendingPluginNodeCapabilities) setClientPluginNodeCapability({
client: nextClient,
surface: entry.surface,
capability: entry.capability,
expiresAtMs: entry.expiresAtMs
});
setSocketMaxPayload(socket, MAX_PAYLOAD_BYTES);
if (role === "node" && isLocalClient) {
const localNodeId = resolveLocalNodeId();
const clientInstanceId = connectParams.client.instanceId?.trim();
if (localNodeId && clientInstanceId && clientInstanceId === localNodeId) {
const gatewayVersion = resolveRuntimeServiceVersion(process.env);
const clientVersion = connectParams.client.version;
if (clientVersion && gatewayVersion && clientVersion !== gatewayVersion && isReleasedVersion(gatewayVersion) && isReleasedVersion(clientVersion)) {
logWsControl.info(`node version mismatch conn=${connId} client=${formatForLog(clientLabel)} clientVersion=${formatForLog(clientVersion)} gatewayVersion=${gatewayVersion}; closing for supervisor restart`);
sendHandshakeErrorResponse(ErrorCodes.INVALID_REQUEST, "client version mismatch", { details: {
code: ConnectErrorDetailCodes.CLIENT_VERSION_MISMATCH,
clientVersion,
gatewayVersion
} });
close(1008, "client version mismatch");
return;
}
}
}
if (!setClient(nextClient)) {
setCloseCause("connect-aborted-before-register", {
...clientMeta,
auth: authMethod
});
return;
}
setHandshakeState("connected");
logWs("in", "connect", {
connId,
client: connectParams.client.id,
clientDisplayName: connectParams.client.displayName,
version: connectParams.client.version,
mode: connectParams.client.mode,
clientId,
platform: connectParams.client.platform,
auth: authMethod
});
if (isWebchatConnect(connectParams)) logWsControl.info(`webchat connected conn=${connId} remote=${remoteAddr ?? "?"} client=${clientLabel} ${connectParams.client.mode} v${connectParams.client.version}`);
if (presenceKey) {
upsertPresence(presenceKey, {
host: connectParams.client.displayName ?? connectParams.client.id ?? os.hostname(),
ip: isLocalClient ? void 0 : reportedClientIp,
version: connectParams.client.version,
platform: connectParams.client.platform,
deviceFamily: connectParams.client.deviceFamily,
modelIdentifier: connectParams.client.modelIdentifier,
mode: connectParams.client.mode,
deviceId: device?.id,
roles: [role],
scopes,
instanceId: device?.id ?? instanceId,
reason: "connect"
});
incrementPresenceVersion();
}
if (role === "node") {
const context = buildRequestContext();
const nodeSession = context.nodeRegistry.register(nextClient, { remoteIp: reportedClientIp });
const instanceIdRaw = connectParams.client.instanceId;
const instanceIdLocal = typeof instanceIdRaw === "string" ? instanceIdRaw.trim() : "";
const nodeIdsForPairing = new Set([nodeSession.nodeId]);
if (instanceIdLocal) nodeIdsForPairing.add(instanceIdLocal);
for (const nodeId of nodeIdsForPairing) updatePairedNodeMetadata(nodeId, { lastConnectedAtMs: nodeSession.connectedAtMs }).catch((err) => logGateway.warn(`failed to record last connect for ${nodeId}: ${formatForLog(err)}`));
recordRemoteNodeInfo({
nodeId: nodeSession.nodeId,
displayName: nodeSession.displayName,
platform: nodeSession.platform,
deviceFamily: nodeSession.deviceFamily,
commands: nodeSession.commands,
remoteIp: nodeSession.remoteIp
});
refreshRemoteNodeBins({
nodeId: nodeSession.nodeId,
platform: nodeSession.platform,
deviceFamily: nodeSession.deviceFamily,
commands: nodeSession.commands,
cfg: getRuntimeConfig()
}).catch((err) => logGateway.warn(`remote bin probe failed for ${nodeSession.nodeId}: ${formatForLog(err)}`));
loadVoiceWakeConfig().then((cfg) => {
context.nodeRegistry.sendEvent(nodeSession.nodeId, "voicewake.changed", { triggers: cfg.triggers });
}).catch((err) => logGateway.warn(`voicewake snapshot failed for ${nodeSession.nodeId}: ${formatForLog(err)}`));
loadVoiceWakeRoutingConfig().then((routing) => {
context.nodeRegistry.sendEvent(nodeSession.nodeId, "voicewake.routing.changed", { config: routing });
}).catch((err) => logGateway.warn(`voicewake routing snapshot failed for ${nodeSession.nodeId}: ${formatForLog(err)}`));
}
const snapshot = buildGatewaySnapshot({ includeSensitive: scopes.includes(ADMIN_SCOPE) });
const cachedHealth = getHealthCache();
if (cachedHealth) {
snapshot.health = cachedHealth;
snapshot.stateVersion.health = getHealthVersion();
}
const helloOkAuthScopes = deviceToken ? deviceToken.scopes : scopes;
const helloOk = {
type: "hello-ok",
protocol: 4,
server: {
version: resolveRuntimeServiceVersion(process.env),
connId
},
features: {
methods: gatewayMethods,
events
},
snapshot,
...Object.keys(pluginSurfaceUrls).length > 0 ? { pluginSurfaceUrls } : {},
auth: {
role,
scopes: helloOkAuthScopes,
...deviceToken ? {
deviceToken: deviceToken.token,
issuedAtMs: deviceToken.rotatedAtMs ?? deviceToken.createdAtMs,
...bootstrapDeviceTokens.length > 1 ? { deviceTokens: bootstrapDeviceTokens.slice(1) } : {}
} : {}
},
policy: {
maxPayload: MAX_PAYLOAD_BYTES,
maxBufferedBytes: MAX_BUFFERED_BYTES,
tickIntervalMs: TICK_INTERVAL_MS
}
};
let revokedBootstrapTokenRecord;
if (authMethod === "bootstrap-token" && bootstrapTokenCandidate && device) try {
if (handoffBootstrapProfile || issuedBootstrapProfile) {
const redemption = await redeemDeviceBootstrapTokenProfile({
token: bootstrapTokenCandidate,
role,
scopes
});
if (handoffBootstrapProfile || redemption.fullyRedeemed) {
const revoked = await revokeDeviceBootstrapToken({ token: bootstrapTokenCandidate });
if (!revoked.removed) logGateway.warn(`bootstrap token revoke skipped after profile redemption device=${device.id}`);
else revokedBootstrapTokenRecord = revoked.record;
}
}
} catch (err) {
logGateway.warn(`bootstrap token post-connect bookkeeping failed device=${device.id}: ${formatForLog(err)}`);
}
try {
await sendFrame({
type: "res",
id: frame.id,
ok: true,
payload: helloOk
});
} catch (err) {
if (revokedBootstrapTokenRecord) try {
await restoreDeviceBootstrapToken({ record: revokedBootstrapTokenRecord });
} catch (restoreErr) {
logGateway.warn(`bootstrap token restore after hello-send failure failed device=${device?.id ?? "unknown"}: ${formatForLog(restoreErr)}`);
}
setCloseCause("hello-send-failed", { error: formatForLog(err) });
close();
return;
}
logWs("out", "hello-ok", {
connId,
methods: gatewayMethods.length,
events: events.length,
presence: snapshot.presence.length,
stateVersion: snapshot.stateVersion.presence
});
refreshHealthSnapshot({ probe: false }).catch((err) => logHealth.error(`post-connect health refresh failed: ${formatError(err)}`));
return;
}
if (!validateRequestFrame(parsed)) {
send({
type: "res",
id: parsed?.id ?? "invalid",
ok: false,
error: errorShape(ErrorCodes.INVALID_REQUEST, `invalid request frame: ${formatValidationErrors(validateRequestFrame.errors)}`)
});
return;
}
const req = parsed;
logWs("in", "req", {
connId,
id: req.id,
method: req.method
});
for (;;) {
const barrier = deviceCredentialMutationBarrier;
if (!barrier) break;
await barrier.catch(() => void 0);
if (isClosed()) return;
}
if (closeInvalidatedClient(client, req.method)) return;
if (client.usesSharedGatewayAuth) {
const requiredSharedGatewaySessionGeneration = getRequiredSharedGatewaySessionGeneration?.();
if (requiredSharedGatewaySessionGeneration !== void 0 && client.sharedGatewaySessionGeneration !== requiredSharedGatewaySessionGeneration) {
setCloseCause("gateway-auth-rotated", {
authGenerationStale: true,
method: req.method
});
close(4001, "gateway auth changed");
return;
}
}
const respond = (ok, payload, error, meta) => {
send({
type: "res",
id: req.id,
ok,
payload,
error
});
const unauthorizedRoleError = isUnauthorizedRoleError(error);
let logMeta = meta;
if (unauthorizedRoleError) {
const unauthorizedDecision = unauthorizedFloodGuard.registerUnauthorized();
if (unauthorizedDecision.suppressedSinceLastLog > 0) logMeta = {
...logMeta,
suppressedUnauthorizedResponses: unauthorizedDecision.suppressedSinceLastLog
};
if (!unauthorizedDecision.shouldLog) return;
if (unauthorizedDecision.shouldClose) {
setCloseCause("repeated-unauthorized-requests", {
unauthorizedCount: unauthorizedDecision.count,
method: req.method
});
queueMicrotask(() => close(1008, "repeated unauthorized calls"));
}
logMeta = {
...logMeta,
unauthorizedCount: unauthorizedDecision.count
};
} else unauthorizedFloodGuard.reset();
logWs("out", "res", {
connId,
id: req.id,
ok,
method: req.method,
errorCode: error?.code,
errorMessage: error?.message,
...logMeta
});
};
const dispatch = (async () => {
const { handleGatewayRequest } = await import("./server-methods-xd1wrn22.js");
await handleGatewayRequest({
req,
respond,
client,
isWebchatConnect,
extraHandlers,
methodRegistry: getMethodRegistry?.(),
context: buildRequestContext()
});
})().catch((err) => {
logGateway.error(`request handler failed: ${formatForLog(err)}`);
respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)));
});
if (DEVICE_CREDENTIAL_INVALIDATING_METHODS.has(req.method)) {
const barrier = dispatch.finally(() => {
if (deviceCredentialMutationBarrier === barrier) deviceCredentialMutationBarrier = void 0;
});
deviceCredentialMutationBarrier = barrier;
}
} catch (err) {
logGateway.error(`parse/handle error: ${String(err)}`);
logWs("out", "parse-error", {
connId,
error: formatForLog(err)
});
if (!getClient()) close();
}
};
socket.on("message", (data) => {
runWithDiagnosticTraceContext(createDiagnosticTraceContext(), () => handleMessage(data));
});
}
function getRawDataByteLength(data) {
if (Buffer.isBuffer(data)) return data.byteLength;
if (Array.isArray(data)) return data.reduce((total, chunk) => total + chunk.byteLength, 0);
if (data instanceof ArrayBuffer) return data.byteLength;
return Buffer.byteLength(String(data));
}
function setSocketMaxPayload(socket, maxPayload) {
const receiver = socket["_receiver"];
if (receiver) receiver["_maxPayload"] = maxPayload;
}
//#endregion
export { attachGatewayWsMessageHandler };