openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
379 lines (378 loc) • 17.5 kB
JavaScript
import { a as normalizeLowercaseStringOrEmpty, c as normalizeOptionalString } from "./string-coerce-mnp54Vah.js";
import { d as resolveGatewayPort } from "./paths-mvMm5bYV.js";
import { t as formatDocsLink } from "./links-CsLBrRff.js";
import { r as theme } from "./theme-vjDs9tao.js";
import { d as normalizeSecretInputString, m as resolveSecretInputRef, s as hasConfiguredSecretInput } from "./types.secrets-_0JOMGE5.js";
import { n as defaultRuntime } from "./runtime-B4lgFmsS.js";
import { n as assertExplicitGatewayAuthModeWhenBothConfigured } from "./auth-mode-policy-Byq0myX3.js";
import { a as trimToUndefined } from "./credential-planner-wESmL_7M.js";
import "./credentials-irvgw8Le.js";
import { r as resolveRequiredConfiguredSecretRefInputString } from "./resolve-configured-secret-input-string-9uwQlTwC.js";
import { r as runCommandWithTimeout } from "./exec-DubsSJS2.js";
import { a as isCarrierGradeNatIpv4Address, c as isIpv4Address, f as isLoopbackIpAddress, g as parseCanonicalIpAddress, l as isIpv6Address, m as isRfc1918Ipv4Address } from "./ip-0oQXo6_w.js";
import { i as pickMatchingExternalInterfaceAddress, o as safeNetworkInterfaces } from "./tailnet-C1gjmZpy.js";
import { i as getRuntimeConfig } from "./io-Gi7-pyU-.js";
import "./config-C9RxTsn1.js";
import { t as resolveCommandSecretRefsViaGateway } from "./command-secret-gateway-CsdFbI5A.js";
import { u as getQrRemoteCommandSecretTargetIds } from "./command-secret-targets-DY8QOUoE.js";
import { t as renderQrTerminal } from "./qr-terminal-zFHurnm4.js";
import { t as resolveGatewayBindUrl } from "./gateway-bind-url-BGaXe9QQ.js";
import { n as resolveTailscalePublishedHost, t as resolveTailnetHostWithRunner } from "./tailscale-status-B3whvNX4.js";
import { r as materializeGatewayAuthSecretRefs } from "./auth-config-utils-Cgy-Dcqz.js";
import { i as issueDeviceBootstrapToken, u as PAIRING_SETUP_BOOTSTRAP_PROFILE } from "./device-bootstrap-DESSpKoY.js";
import os from "node:os";
//#region src/pairing/setup-code.ts
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 validateMobilePairingUrl(url, source) {
let parsed;
try {
parsed = new URL(url);
} catch {
return "Resolved mobile pairing URL is invalid.";
}
const protocol = parsed.protocol === "https:" ? "wss:" : parsed.protocol === "http:" ? "ws:" : 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 scheme = parsed.protocol.replace(":", "");
if (!scheme) return null;
const resolvedScheme = scheme === "http" ? "ws" : scheme === "https" ? "wss" : scheme;
if (resolvedScheme !== "ws" && resolvedScheme !== "wss") return null;
const host = parsed.hostname;
if (!host) return null;
return `${resolvedScheme}://${host}${parsed.port ? `:${parsed.port}` : ""}`;
} catch {
return null;
}
}
function resolveScheme(cfg, opts) {
if (opts?.forceSecure) return "wss";
return cfg.gateway?.tls?.enabled === true ? "wss" : "ws";
}
function isPrivateIPv4(address) {
return isRfc1918Ipv4Address(address);
}
function isTailnetIPv4(address) {
return isCarrierGradeNatIpv4Address(address);
}
function pickIPv4Matching(networkInterfaces, matches) {
return pickMatchingExternalInterfaceAddress(safeNetworkInterfaces(networkInterfaces), {
family: "IPv4",
matches
}) ?? null;
}
function pickLanIPv4(networkInterfaces) {
return pickIPv4Matching(networkInterfaces, isPrivateIPv4);
}
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 resolveGatewayUrl(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." };
const publishedHost = resolveTailscalePublishedHost({
tailscaleMode,
tailnetHost: host,
serviceName: cfg.gateway?.tailscale?.serviceName
});
if (!publishedHost) return { error: "Tailscale Serve serviceName is configured, but Service MagicDNS could not be derived." };
return {
url: `wss://${publishedHost}`,
source: `gateway.tailscale.mode=${tailscaleMode}`
};
}
if (remoteUrl) return {
url: remoteUrl,
source: "gateway.remote.url"
};
const bindResult = resolveGatewayBindUrl({
bind: cfg.gateway?.bind,
customBindHost: cfg.gateway?.customBindHost,
scheme,
port,
pickTailnetHost: () => pickTailnetIPv4(opts.networkInterfaces),
pickLanHost: () => pickLanIPv4(opts.networkInterfaces)
});
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, "");
}
async function resolvePairingSetupFromConfig(cfg, options = {}) {
assertExplicitGatewayAuthModeWhenBothConfigured(cfg);
const env = options.env ?? process.env;
const cfgForAuth = await materializeGatewayAuthSecretRefs({
cfg,
env,
mode: cfg.gateway?.auth?.mode,
hasTokenCandidate: Boolean(normalizeOptionalString(env.OPENCLAW_GATEWAY_TOKEN)),
hasPasswordCandidate: Boolean(normalizeOptionalString(env.OPENCLAW_GATEWAY_PASSWORD))
});
const authLabel = resolvePairingSetupAuthLabel(cfgForAuth, env);
if (authLabel.error) return {
ok: false,
error: authLabel.error
};
const urlResult = await resolveGatewayUrl(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)."
};
return {
ok: true,
payload: {
url: urlResult.url,
bootstrapToken: (await issueDeviceBootstrapToken({
baseDir: options.pairingBaseDir,
profile: PAIRING_SETUP_BOOTSTRAP_PROFILE
})).token
},
authLabel: authLabel.label,
urlSource: urlResult.source ?? "unknown"
};
}
//#endregion
//#region src/cli/qr-cli.ts
function renderQrAscii(data) {
return renderQrTerminal(data);
}
function readDevicePairPublicUrlFromConfig(cfg) {
const value = cfg.plugins?.entries?.["device-pair"]?.config?.["publicUrl"];
if (typeof value !== "string") return;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : void 0;
}
function shouldResolveLocalGatewayPasswordSecret(cfg, env) {
if (trimToUndefined(env.OPENCLAW_GATEWAY_PASSWORD)) return false;
const authMode = cfg.gateway?.auth?.mode;
if (authMode === "password") return true;
if (authMode === "token" || authMode === "none" || authMode === "trusted-proxy") return false;
const envToken = trimToUndefined(env.OPENCLAW_GATEWAY_TOKEN);
const configTokenConfigured = hasConfiguredSecretInput(cfg.gateway?.auth?.token, cfg.secrets?.defaults);
return !envToken && !configTokenConfigured;
}
async function resolveLocalGatewayPasswordSecretIfNeeded(cfg) {
const resolvedPassword = await resolveRequiredConfiguredSecretRefInputString({
config: cfg,
env: process.env,
value: cfg.gateway?.auth?.password,
path: "gateway.auth.password"
});
if (!resolvedPassword) return;
if (!cfg.gateway?.auth) return;
cfg.gateway.auth.password = resolvedPassword;
}
function emitQrSecretResolveDiagnostics(diagnostics, opts) {
if (diagnostics.length === 0) return;
const toStderr = opts.json === true || opts.setupCodeOnly === true;
for (const entry of diagnostics) {
const message = theme.warn(`[secrets] ${entry}`);
if (toStderr) defaultRuntime.error(message);
else defaultRuntime.log(message);
}
}
function registerQrCli(program) {
program.command("qr").description("Generate a mobile pairing QR code and setup code").addHelpText("after", () => `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/qr", "docs.openclaw.ai/cli/qr")}\n`).option("--remote", "Use gateway.remote.url and gateway.remote token/password (ignores device-pair publicUrl)", false).option("--url <url>", "Override gateway URL used in the setup payload").option("--public-url <url>", "Override gateway public URL used in the setup payload").option("--token <token>", "Override gateway token for setup payload").option("--password <password>", "Override gateway password for setup payload").option("--setup-code-only", "Print only the setup code", false).option("--no-ascii", "Skip ASCII QR rendering").option("--json", "Output JSON", false).action(async (opts) => {
try {
if (opts.token && opts.password) throw new Error("Use either --token or --password, not both.");
const token = trimToUndefined(opts.token) ?? "";
const password = trimToUndefined(opts.password) ?? "";
const wantsRemote = opts.remote === true;
const loadedRaw = getRuntimeConfig();
if (wantsRemote && !opts.url && !opts.publicUrl) {
const tailscaleMode = loadedRaw.gateway?.tailscale?.mode ?? "off";
const remoteUrl = loadedRaw.gateway?.remote?.url;
if (!Boolean(trimToUndefined(remoteUrl)) && !(tailscaleMode === "serve" || tailscaleMode === "funnel")) throw new Error("qr --remote requires gateway.remote.url (or gateway.tailscale.mode=serve/funnel).");
}
let loaded = loadedRaw;
let remoteDiagnostics = [];
if (wantsRemote && !token && !password) {
const resolvedRemote = await resolveCommandSecretRefsViaGateway({
config: loadedRaw,
commandName: "qr --remote",
targetIds: getQrRemoteCommandSecretTargetIds()
});
loaded = resolvedRemote.resolvedConfig;
remoteDiagnostics = resolvedRemote.diagnostics;
}
const cfg = {
...loaded,
gateway: {
...loaded.gateway,
auth: { ...loaded.gateway?.auth }
}
};
emitQrSecretResolveDiagnostics(remoteDiagnostics, opts);
if (token) {
cfg.gateway.auth.mode = "token";
cfg.gateway.auth.token = token;
cfg.gateway.auth.password = void 0;
}
if (password) {
cfg.gateway.auth.mode = "password";
cfg.gateway.auth.password = password;
cfg.gateway.auth.token = void 0;
}
if (wantsRemote && !token && !password) {
const remoteToken = trimToUndefined(cfg.gateway?.remote?.token) ?? "";
const remotePassword = trimToUndefined(cfg.gateway?.remote?.password) ?? "";
if (remoteToken) {
cfg.gateway.auth.mode = "token";
cfg.gateway.auth.token = remoteToken;
cfg.gateway.auth.password = void 0;
} else if (remotePassword) {
cfg.gateway.auth.mode = "password";
cfg.gateway.auth.password = remotePassword;
cfg.gateway.auth.token = void 0;
}
}
if (!wantsRemote && !password && !token && shouldResolveLocalGatewayPasswordSecret(cfg, process.env)) await resolveLocalGatewayPasswordSecretIfNeeded(cfg);
const resolved = await resolvePairingSetupFromConfig(cfg, {
publicUrl: (typeof opts.url === "string" && opts.url.trim() ? opts.url.trim() : typeof opts.publicUrl === "string" && opts.publicUrl.trim() ? opts.publicUrl.trim() : void 0) ?? (wantsRemote ? void 0 : readDevicePairPublicUrlFromConfig(cfg)),
preferRemoteUrl: wantsRemote,
runCommandWithTimeout: async (argv, runOpts) => await runCommandWithTimeout(argv, { timeoutMs: runOpts.timeoutMs })
});
if (!resolved.ok) throw new Error(resolved.error);
const setupCode = encodePairingSetupCode(resolved.payload);
if (opts.setupCodeOnly) {
defaultRuntime.log(setupCode);
return;
}
if (opts.json) {
defaultRuntime.writeJson({
setupCode,
gatewayUrl: resolved.payload.url,
auth: resolved.authLabel,
urlSource: resolved.urlSource
});
return;
}
const lines = [
theme.heading("Pairing QR"),
"Scan this with the OpenClaw mobile app (Onboarding -> Scan QR).",
""
];
if (opts.ascii !== false) {
const qrAscii = await renderQrAscii(setupCode);
lines.push(qrAscii.trimEnd(), "");
}
lines.push(`${theme.muted("Setup code:")} ${setupCode}`, `${theme.muted("Gateway:")} ${resolved.payload.url}`, `${theme.muted("Auth:")} ${resolved.authLabel}`, `${theme.muted("Source:")} ${resolved.urlSource}`, "", "Approve after scan with:", ` ${theme.command("openclaw devices list")}`, ` ${theme.command("openclaw devices approve <requestId>")}`);
defaultRuntime.log(lines.join("\n"));
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
}
//#endregion
export { registerQrCli as t };