openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
307 lines (306 loc) • 14.9 kB
JavaScript
import { c as isRecord } from "./record-coerce-DItp3I4t.js";
import { l as normalizeOptionalString, o as normalizeLowercaseStringOrEmpty } from "./string-coerce-CIXf7egm.js";
import { _ as resolveGatewayPort } from "./paths-D2sRr1a_.js";
import { h as normalizeSecretInputString, v as resolveSecretInputRef } from "./types.secrets-kC0nOetj.js";
import { _ as parseCanonicalIpAddress, a as isCarrierGradeNatIpv4Address, c as isIpv4Address, f as isLoopbackIpAddress, l as isIpv6Address, m as isRfc1918Ipv4Address } from "./ip-BkT2Is0E.js";
import { t as assertExplicitGatewayAuthModeWhenBothConfigured } from "./auth-mode-policy-CgmWtSyI.js";
import { i as safeNetworkInterfaces, n as pickMatchingExternalInterfaceAddress } from "./network-interfaces-S5y8vKUw.js";
import { S as normalizeWebSocketProtocol } from "./net-DbNPs6Xm.js";
import { i as resolveTailscalePublishedHost, r as resolveTailnetHostWithRunner } from "./tailscale-status-D3cAZH54.js";
import { r as normalizeTlsFingerprint } from "./client-address-utils-DUCasR_v.js";
import { t as resolveAdvertisedLanHostCore } from "./advertised-lan-host-Dp_V4Fun.js";
import { G as resolvePairingSetupAccess, I as deviceBootstrapProfilesEqual, M as FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE, P as PAIRING_SETUP_BOOTSTRAP_PROFILE, c as issueDevicePairSetupBootstrapToken } from "./device-bootstrap-DRFMRCcr.js";
import { t as resolveGatewayBindUrl } from "./gateway-bind-url-CO8cGU-C.js";
import { r as materializeGatewayAuthSecretRefs } from "./auth-config-utils-mqnk38xp.js";
import os from "node:os";
//#region src/pairing/setup-code.ts
const PAIRING_SETUP_MAX_URLS = 8;
function resolveConfiguredPairingPublicUrl(config) {
const value = config.plugins?.entries?.["device-pair"]?.config?.["publicUrl"];
return typeof value === "string" && value.trim() ? value.trim() : void 0;
}
function describeSecureMobilePairingFix(source) {
return "Tailscale and public mobile pairing require a secure gateway URL (wss://) or Tailscale Serve/Funnel." + (source ? ` Resolved source: ${source}.` : "") + " Fix: use a private LAN address, prefer gateway.tailscale.mode=serve, or set gateway.remote.url / plugins.entries.device-pair.config.publicUrl to a wss:// URL. ws:// is only valid for localhost, private LAN addresses, .local hosts, or the Android emulator.";
}
function normalizeMobilePairingHost(host) {
let normalized = normalizeLowercaseStringOrEmpty(host);
if (normalized.startsWith("[") && normalized.endsWith("]")) normalized = normalized.slice(1, -1);
if (normalized.endsWith(".")) normalized = normalized.slice(0, -1);
const zoneIndex = normalized.indexOf("%");
if (zoneIndex >= 0) normalized = normalized.slice(0, zoneIndex);
return normalized;
}
function isPrivateLanHost(host) {
const normalized = normalizeMobilePairingHost(host);
if (normalized.endsWith(".local")) return true;
if (isRfc1918Ipv4Address(normalized)) return true;
const parsed = parseCanonicalIpAddress(normalized);
if (!parsed) return false;
if (isIpv4Address(parsed)) {
const normalizedIp = parsed.toString();
return normalizedIp.startsWith("169.254.") && !isCarrierGradeNatIpv4Address(normalizedIp);
}
if (!isIpv6Address(parsed)) return false;
const normalizedIp = normalizeLowercaseStringOrEmpty(parsed.toString());
return normalizedIp.startsWith("fe80:") || normalizedIp.startsWith("fc") || normalizedIp.startsWith("fd");
}
function isMobilePairingCleartextAllowedHost(host) {
const normalized = normalizeMobilePairingHost(host);
return normalized === "localhost" || isLoopbackIpAddress(normalized) || normalized === "10.0.2.2" || isPrivateLanHost(normalized);
}
function isFullAccessMobilePairingUrl(url) {
try {
const parsed = new URL(url);
if (parsed.protocol === "wss:") return true;
const host = normalizeMobilePairingHost(parsed.hostname);
return parsed.protocol === "ws:" && (host === "localhost" || isLoopbackIpAddress(host));
} catch {
return false;
}
}
function validateMobilePairingUrl(url, source) {
let parsed;
try {
parsed = new URL(url);
} catch {
return "Resolved mobile pairing URL is invalid.";
}
const protocol = normalizeWebSocketProtocol(parsed.protocol);
if (protocol === "wss:") return null;
if (protocol !== "ws:" || isMobilePairingCleartextAllowedHost(parsed.hostname)) return null;
return describeSecureMobilePairingFix(source);
}
const GATEWAY_SCHEME_WITHOUT_AUTHORITY_RE = /^(?:https?|wss?):(?!\/\/)/i;
const SCHEME_LIKE_PATH_RE = /^[A-Za-z][A-Za-z0-9+.-]*:\//;
function normalizeUrl(raw, schemeFallback) {
const trimmed = raw.trim();
if (!trimmed) return null;
if (GATEWAY_SCHEME_WITHOUT_AUTHORITY_RE.test(trimmed)) return null;
const parsedUrl = parseNormalizedGatewayUrl(trimmed);
if (parsedUrl) return parsedUrl;
if (trimmed.includes("://") || SCHEME_LIKE_PATH_RE.test(trimmed)) return null;
const withoutPath = normalizeOptionalString(trimmed.split("/", 1)[0]) ?? "";
return withoutPath ? parseNormalizedGatewayUrl(`${schemeFallback}://${withoutPath}`) : null;
}
function parseNormalizedGatewayUrl(raw) {
try {
const parsed = new URL(raw);
if (parsed.username || parsed.password) return null;
const protocol = normalizeWebSocketProtocol(parsed.protocol);
if (!protocol) return null;
const resolvedScheme = protocol.replace(":", "");
if (resolvedScheme !== "ws" && resolvedScheme !== "wss") return null;
const host = parsed.hostname;
if (!host) return null;
return `${resolvedScheme}://${host}${parsed.port ? `:${parsed.port}` : ""}${parsed.pathname === "/" ? "" : parsed.pathname}`;
} catch {
return null;
}
}
function resolveScheme(cfg, opts) {
if (opts?.forceSecure) return "wss";
return cfg.gateway?.tls?.enabled === true ? "wss" : "ws";
}
function isTailnetIPv4(address) {
return isCarrierGradeNatIpv4Address(address);
}
function pickIPv4Matching(networkInterfaces, matches) {
return pickMatchingExternalInterfaceAddress(safeNetworkInterfaces(networkInterfaces), {
family: "IPv4",
matches
}) ?? null;
}
function pickTailnetIPv4(networkInterfaces) {
return pickIPv4Matching(networkInterfaces, isTailnetIPv4);
}
function resolvePairingSetupAuthLabel(cfg, env) {
const mode = cfg.gateway?.auth?.mode;
const defaults = cfg.secrets?.defaults;
const tokenRef = resolveSecretInputRef({
value: cfg.gateway?.auth?.token,
defaults
}).ref;
const passwordRef = resolveSecretInputRef({
value: cfg.gateway?.auth?.password,
defaults
}).ref;
const envToken = normalizeOptionalString(env.OPENCLAW_GATEWAY_TOKEN);
const envPassword = normalizeOptionalString(env.OPENCLAW_GATEWAY_PASSWORD);
const token = envToken || (tokenRef ? void 0 : normalizeSecretInputString(cfg.gateway?.auth?.token));
const password = envPassword || (passwordRef ? void 0 : normalizeSecretInputString(cfg.gateway?.auth?.password));
if (mode === "password") {
if (!password) return { error: "Gateway auth is set to password, but no password is configured." };
return { label: "password" };
}
if (mode === "token") {
if (!token) return { error: "Gateway auth is set to token, but no token is configured." };
return { label: "token" };
}
if (token) return { label: "token" };
if (password) return { label: "password" };
return { error: "Gateway auth is not configured (no token or password)." };
}
async function resolvePairingGatewayUrl(cfg, opts) {
const scheme = resolveScheme(cfg, { forceSecure: opts.forceSecure });
const port = resolveGatewayPort(cfg, opts.env);
if (typeof opts.publicUrl === "string" && opts.publicUrl.trim()) {
const url = normalizeUrl(opts.publicUrl, scheme);
if (url) return {
url,
source: "plugins.entries.device-pair.config.publicUrl"
};
return { error: "Configured publicUrl is invalid." };
}
const remoteUrlRaw = cfg.gateway?.remote?.url;
const hasRemoteUrl = typeof remoteUrlRaw === "string" && remoteUrlRaw.trim();
const remoteUrl = hasRemoteUrl ? normalizeUrl(remoteUrlRaw, scheme) : null;
if (hasRemoteUrl && !remoteUrl) return { error: "Configured gateway.remote.url is invalid." };
if (opts.preferRemoteUrl && remoteUrl) return {
url: remoteUrl,
source: "gateway.remote.url"
};
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
if (tailscaleMode === "serve" || tailscaleMode === "funnel") {
const host = await resolveTailnetHostWithRunner(opts.runCommandWithTimeout);
if (!host) return { error: "Tailscale Serve is enabled, but MagicDNS could not be resolved." };
return {
url: `wss://${resolveTailscalePublishedHost({
tailscaleMode,
tailnetHost: host
})}`,
source: `gateway.tailscale.mode=${tailscaleMode}`
};
}
if (remoteUrl) return {
url: remoteUrl,
source: "gateway.remote.url"
};
const advertisedLanHost = cfg.gateway?.bind === "lan" ? await resolveAdvertisedLanHostCore({
networkInterfaces: opts.networkInterfaces,
runCommandWithTimeout: opts.runCommandWithTimeout
}) : null;
const bindResult = resolveGatewayBindUrl({
bind: cfg.gateway?.bind,
customBindHost: cfg.gateway?.customBindHost,
scheme,
port,
pickTailnetHost: () => pickTailnetIPv4(opts.networkInterfaces),
pickLanHost: () => advertisedLanHost
});
if (bindResult) return bindResult;
return { error: "Gateway is only bound to loopback. Set gateway.bind=lan, enable tailscale serve, or configure plugins.entries.device-pair.config.publicUrl." };
}
function encodePairingSetupCode(payload) {
const json = JSON.stringify(payload);
return Buffer.from(json, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
const PAIRING_SETUP_URL_PREFIX = "oc-pair://";
const PAIRING_SETUP_CODE_RE = /^[A-Za-z0-9_-]+$/u;
/** Decode the current setup payload plus additive fields emitted by older pairing surfaces. */
function decodePairingSetupCode(input, options = {}) {
const trimmed = input.trim();
const setupCode = trimmed.toLowerCase().startsWith(PAIRING_SETUP_URL_PREFIX) ? trimmed.slice(10) : trimmed;
if (!setupCode || !PAIRING_SETUP_CODE_RE.test(setupCode)) throw new Error("Invalid pairing setup code or URL.");
let decoded;
try {
decoded = JSON.parse(Buffer.from(setupCode, "base64url").toString("utf8"));
} catch {
throw new Error("Invalid pairing setup code or URL.");
}
if (!isRecord(decoded)) throw new Error("Invalid pairing setup payload.");
const url = normalizeOptionalString(decoded.url);
const bootstrapToken = normalizeOptionalString(decoded.bootstrapToken);
if (!url || !bootstrapToken || normalizeUrl(url, "ws") !== url) throw new Error("Invalid pairing setup payload.");
let urls;
if (decoded.urls !== void 0) {
if (!Array.isArray(decoded.urls) || decoded.urls.length === 0 || decoded.urls.length > PAIRING_SETUP_MAX_URLS || decoded.urls.some((candidate) => typeof candidate !== "string" || normalizeUrl(candidate, "ws") !== candidate)) throw new Error("Invalid pairing setup payload.");
urls = decoded.urls;
}
let expiresAtMs;
if (decoded.expiresAtMs !== void 0) {
const candidate = decoded.expiresAtMs;
if (typeof candidate !== "number" || !Number.isSafeInteger(candidate) || candidate < 0) throw new Error("Invalid pairing setup payload.");
expiresAtMs = candidate;
if (candidate <= (options.nowMs ?? Date.now())) throw new Error("Pairing setup code has expired.");
}
const tlsFingerprint = typeof decoded.tlsFingerprint === "string" ? normalizeTlsFingerprint(decoded.tlsFingerprint) : void 0;
if (decoded.tlsFingerprint !== void 0 && !tlsFingerprint) throw new Error("Invalid pairing setup payload.");
return {
url,
...urls ? { urls } : {},
bootstrapToken,
...expiresAtMs !== void 0 ? { expiresAtMs } : {},
...tlsFingerprint ? { tlsFingerprint } : {}
};
}
async function resolvePairingSetupFromConfig(cfg, options = {}) {
assertExplicitGatewayAuthModeWhenBothConfigured(cfg);
const env = options.env ?? process.env;
const cfgForAuth = await materializeGatewayAuthSecretRefs({
cfg,
env,
mode: cfg.gateway?.auth?.mode,
hasTokenOverride: false,
hasPasswordOverride: false,
hasTokenFallback: Boolean(normalizeOptionalString(env.OPENCLAW_GATEWAY_TOKEN)),
hasPasswordFallback: Boolean(normalizeOptionalString(env.OPENCLAW_GATEWAY_PASSWORD))
});
const authLabel = resolvePairingSetupAuthLabel(cfgForAuth, env);
if (authLabel.error) return {
ok: false,
error: authLabel.error
};
const urlResult = await resolvePairingGatewayUrl(cfgForAuth, {
env,
publicUrl: options.publicUrl,
preferRemoteUrl: options.preferRemoteUrl,
forceSecure: options.forceSecure,
runCommandWithTimeout: options.runCommandWithTimeout,
networkInterfaces: options.networkInterfaces ?? os.networkInterfaces
});
if (!urlResult.url) return {
ok: false,
error: urlResult.error ?? "Gateway URL unavailable."
};
const mobilePairingUrlError = validateMobilePairingUrl(urlResult.url, urlResult.source);
if (mobilePairingUrlError) return {
ok: false,
error: mobilePairingUrlError
};
if (!authLabel.label) return {
ok: false,
error: "Gateway auth is not configured (no token or password)."
};
const uniqueUrls = [urlResult.url];
const requestedBootstrapProfile = options.bootstrapProfile ?? FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE;
const accessDowngraded = deviceBootstrapProfilesEqual(requestedBootstrapProfile, FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE) && uniqueUrls.some((url) => !isFullAccessMobilePairingUrl(url));
const issuedBootstrapProfile = accessDowngraded ? PAIRING_SETUP_BOOTSTRAP_PROFILE : requestedBootstrapProfile;
const directGatewayTlsFingerprintRaw = urlResult.url.startsWith("wss://") && urlResult.source?.startsWith("gateway.bind=") ? options.localTlsFingerprint ?? await options.loadLocalTlsFingerprint?.() : urlResult.url.startsWith("wss://") && urlResult.source === "gateway.remote.url" ? cfgForAuth.gateway?.remote?.tlsFingerprint : void 0;
const directGatewayTlsFingerprint = directGatewayTlsFingerprintRaw ? normalizeTlsFingerprint(directGatewayTlsFingerprintRaw) : void 0;
if (directGatewayTlsFingerprintRaw !== void 0 && !directGatewayTlsFingerprint) return {
ok: false,
error: "Gateway TLS fingerprint is invalid."
};
const issued = options.issuedBootstrap ?? await issueDevicePairSetupBootstrapToken({
baseDir: options.pairingBaseDir,
profile: issuedBootstrapProfile
});
return {
ok: true,
payload: {
url: urlResult.url,
...uniqueUrls.length > 1 ? { urls: uniqueUrls } : {},
bootstrapToken: issued.token,
expiresAtMs: issued.expiresAtMs,
...directGatewayTlsFingerprint ? { tlsFingerprint: directGatewayTlsFingerprint } : {}
},
authLabel: authLabel.label,
urlSource: urlResult.source ?? "unknown",
access: resolvePairingSetupAccess(issuedBootstrapProfile),
accessDowngraded,
setupId: issued.setupId,
expiresAtMs: issued.expiresAtMs
};
}
//#endregion
export { resolvePairingSetupFromConfig as a, resolvePairingGatewayUrl as i, encodePairingSetupCode as n, resolveConfiguredPairingPublicUrl as r, decodePairingSetupCode as t };