UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

1,270 lines (1,269 loc) 48.1 kB
import { c as normalizeOptionalString, p as readStringValue } from "./string-coerce-mnp54Vah.js"; import { d as resolveGatewayPort, t as CONFIG_PATH } from "./paths-mvMm5bYV.js"; import { i as formatErrorMessage } from "./errors-BXgSefBE.js"; import { t as formatCliCommand } from "./command-format-CKGmlpAQ.js"; import { t as createLazyImportLoader } from "./lazy-promise-BONnzNfb.js"; import { l as normalizeStringEntries } from "./string-normalization-WNUDCpXX.js"; import { b as isPlainObject, g as shortenHomePath, p as resolveUserPath } from "./utils-CCC-BEJH.js"; import { c as isSecretRef, l as isValidEnvSecretRefId } from "./types.secrets-_0JOMGE5.js"; import { n as buildGatewayInstallPlan, r as gatewayInstallErrorHint } from "./auth-install-policy-O7nc7i9I.js"; import { r as isNonFatalSystemdInstallProbeError } from "./systemd-BXTM6gEb.js"; import { n as defaultRuntime } from "./runtime-B4lgFmsS.js"; import { t as isBlockedObjectKey } from "./prototype-keys-D2nJOZIy.js"; import { s as resolveDefaultSecretProviderAlias } from "./ref-contract-BAa3qHwr.js"; import { n as resolveDefaultAgentWorkspaceDir } from "./workspace-default-B3AjhIK3.js"; import { s as resolvePluginContributionOwners } from "./plugin-registry-ChcJPHWl.js"; import { n as GATEWAY_DAEMON_RUNTIME_OPTIONS, t as DEFAULT_GATEWAY_DAEMON_RUNTIME } from "./daemon-runtime-C76za6vm.js"; import { a as findTailscaleBinary } from "./tailscale-BOFw04AY.js"; import { t as sanitizeTerminalText } from "./safe-text-BkQYdJi1.js"; import { i as getRuntimeConfig, u as readConfigFileSnapshot } from "./io-Gi7-pyU-.js"; import { t as ConfigMutationConflictError } from "./config-C9RxTsn1.js"; import { t as resolveGatewayInstallToken } from "./gateway-install-token-jVqLCOms.js"; import { t as randomToken } from "./random-token-B1woZa_H.js"; import { i as resolveGatewayService, t as describeGatewayServiceRestart } from "./service-MPqQWJ3s.js"; import { o as formatPortRangeHint } from "./error-format-COoNML-C.js"; import { t as parsePort } from "./parse-port-CbiRuE9n.js"; import { t as resolveControlUiLinks } from "./control-ui-links-HaNDPHaQ.js"; import { n as ensureAuthProfileStore } from "./store-C8spD0DG.js"; import { n as listChatChannels } from "./chat-meta-KtaM-Sfw.js"; import "./auth-profiles-84rzaGag.js"; import "./workspace-B0bmoo98.js"; import { t as describeCodexNativeWebSearch } from "./codex-native-web-search.shared-xyjcKUq2.js"; import { r as withProgress } from "./progress-CN3xUiCO.js"; import { t as note } from "./note-BRSJp0UF.js"; import { t as ensureControlUiAssetsBuilt } from "./control-ui-assets-OWiFO3R_.js"; import { t as WizardCancelledError } from "./prompts-E0FayNjJ.js"; import { t as createClackPrompter } from "./clack-prompter-fc9v8HJv.js"; import { r as logConfigUpdated } from "./logging-Cr7I5jF_.js"; import { t as commitConfigWithPendingPluginInstalls } from "./plugins-install-record-commit--Zbbdxlm.js"; import { a as guardCancel, c as normalizeGatewayTokenInput, f as summarizeExistingConfig, m as waitForGatewayReachable, n as applyWizardMetadata, p as validateGatewayPasswordInput, r as ensureWorkspaceAndSessions, t as DEFAULT_WORKSPACE, u as probeGatewayReachable } from "./onboard-helpers-DXyUiHQ-.js"; import { a as outro, c as text, i as intro, n as CONFIGURE_WIZARD_SECTIONS, o as parseConfigureWizardSections, r as confirm, s as select, t as CONFIGURE_SECTION_OPTIONS } from "./configure.shared-tjL8qheG.js"; import { t as promptAuthChoiceGrouped } from "./auth-choice-prompt-BelGczGT.js"; import { n as applyAuthChoice } from "./auth-choice-Bb5NCx3i.js"; import { t as resolvePreferredProviderForAuthChoice } from "./provider-auth-choice-preference-BD8UmMfD.js"; import { i as setupChannels } from "./onboard-channels-CR34I3LC.js"; import { t as resolveSetupSecretInputString } from "./setup.secret-input-BOdMUWYa.js"; import { t as ensureSystemdUserLingerInteractive } from "./systemd-linger-BYD8LXaq.js"; import { t as loadStaticManifestCatalogRowsForList } from "./list.manifest-catalog-CBhmbJoM.js"; import { i as promptModelAllowlist, n as applyModelFallbacksFromSelection, r as promptDefaultModel, t as applyModelAllowlist } from "./model-picker-C68ZMSoi.js"; import { t as applyPrimaryModel } from "./provider-model-primary-CjrsffUO.js"; import { t as promptCustomApiConfig } from "./onboard-custom-CWwis0KM.js"; import { a as validateIPv4AddressInput, i as maybeAddTailnetOriginToControlUiAllowedOrigins, n as TAILSCALE_EXPOSURE_OPTIONS, r as TAILSCALE_MISSING_BIN_NOTE_LINES, t as TAILSCALE_DOCS_LINES } from "./gateway-config-prompts.shared-Bx9Y06Mk.js"; import { n as formatHealthCheckFailure } from "./health-format-ebE8IYQV.js"; import { i as healthCommand } from "./health-DeCbGguB.js"; import { t as promptRemoteGatewayConfig } from "./onboard-remote-DTZBaYs7.js"; import { t as setupSkills } from "./onboard-skills-BoUgPd-Y.js"; import path from "node:path"; import fs from "node:fs/promises"; import { isDeepStrictEqual } from "node:util"; //#region src/commands/configure.channels.ts const RESERVED_CHANNEL_CONFIG_KEYS = new Set(["defaults", "modelByChannel"]); const DONE_VALUE = { kind: "done" }; function listConfiguredChannelRemovalChoices(cfg) { const channels = cfg.channels; if (!channels) return []; const labelsById = new Map(listChatChannels().map((meta) => [meta.id, formatChannelRemovalLabel(meta.label, meta.id)])); return Object.keys(channels).filter((id) => !RESERVED_CHANNEL_CONFIG_KEYS.has(id)).filter((id) => !isBlockedObjectKey(id)).map((id) => ({ id, label: labelsById.get(id) ?? formatUnknownChannelRemovalLabel(id) })).toSorted(compareChannelRemovalChoices); } function formatChannelRemovalLabel(label, fallback) { return sanitizeTerminalText(label) || formatUnknownChannelRemovalLabel(fallback); } function formatUnknownChannelRemovalLabel(id) { return sanitizeTerminalText(id) || "<invalid channel key>"; } function compareChannelRemovalChoices(left, right) { return left.label.localeCompare(right.label, void 0, { numeric: true, sensitivity: "base" }) || left.id.localeCompare(right.id, void 0, { numeric: true, sensitivity: "base" }); } /** Prompt for configured channel sections to remove from openclaw.json. */ async function removeChannelConfigWizard(cfg, runtime) { const next = { ...cfg }; while (true) { const configured = listConfiguredChannelRemovalChoices(next); if (configured.length === 0) { note(["No channel config found in openclaw.json.", `Tip: \`${formatCliCommand("openclaw channels status")}\` shows what is configured and enabled.`].join("\n"), "Remove channel"); return next; } const channelOptions = configured.map((meta) => ({ value: { kind: "channel", id: meta.id }, label: meta.label, hint: "Deletes tokens + settings from config (credentials stay on disk)" })); const doneOption = { value: DONE_VALUE, label: "Done" }; const choice = guardCancel(await select({ message: "Remove which channel config?", options: [...channelOptions, doneOption] }), runtime); if (choice.kind === "done") return next; const channel = choice.id; const label = configured.find((entry) => entry.id === channel)?.label ?? channel; if (!guardCancel(await confirm({ message: `Delete ${label} configuration from ${shortenHomePath(CONFIG_PATH)}?`, initialValue: false }), runtime)) continue; const nextChannels = { ...next.channels }; delete nextChannels[channel]; if (Object.keys(nextChannels).length) next.channels = nextChannels; else delete next.channels; note([`${label} removed from config.`, "Note: credentials/sessions on disk are unchanged."].join("\n"), "Channel removed"); } } //#endregion //#region src/commands/configure.daemon.ts /** Prompt to install, reinstall, restart, or skip the local Gateway service. */ async function maybeInstallDaemon(params) { const service = resolveGatewayService(); let loaded; try { loaded = await service.isLoaded({ env: process.env }); } catch (error) { if (!isNonFatalSystemdInstallProbeError(error)) throw error; loaded = false; } let shouldCheckLinger = false; let shouldInstall = true; let daemonRuntime = params.daemonRuntime ?? "node"; if (loaded) { const action = guardCancel(await select({ message: "Gateway service already installed", options: [ { value: "restart", label: "Restart" }, { value: "reinstall", label: "Reinstall" }, { value: "skip", label: "Skip" } ] }), params.runtime); if (action === "restart") { await withProgress({ label: "Gateway service", indeterminate: true, delayMs: 0 }, async (progress) => { progress.setLabel("Restarting Gateway service…"); const restartResult = await service.restart({ env: process.env, stdout: process.stdout }); progress.setLabel(describeGatewayServiceRestart("Gateway", restartResult).progressMessage); }); shouldCheckLinger = true; shouldInstall = false; } if (action === "skip") return; if (action === "reinstall") await withProgress({ label: "Gateway service", indeterminate: true, delayMs: 0 }, async (progress) => { progress.setLabel("Uninstalling Gateway service…"); await service.uninstall({ env: process.env, stdout: process.stdout }); progress.setLabel("Gateway service uninstalled."); }); } if (shouldInstall) { let installError = null; if (!params.daemonRuntime) if (GATEWAY_DAEMON_RUNTIME_OPTIONS.length === 1) daemonRuntime = GATEWAY_DAEMON_RUNTIME_OPTIONS[0]?.value ?? "node"; else daemonRuntime = guardCancel(await select({ message: "Gateway service runtime", options: GATEWAY_DAEMON_RUNTIME_OPTIONS, initialValue: DEFAULT_GATEWAY_DAEMON_RUNTIME }), params.runtime); await withProgress({ label: "Gateway service", indeterminate: true, delayMs: 0 }, async (progress) => { progress.setLabel("Preparing Gateway service…"); const cfg = getRuntimeConfig(); const tokenResolution = await resolveGatewayInstallToken({ config: cfg, env: process.env }); for (const warning of tokenResolution.warnings) note(warning, "Gateway"); if (tokenResolution.unavailableReason) { installError = [ "Gateway install blocked:", tokenResolution.unavailableReason, "Fix gateway auth config/token input and rerun configure." ].join(" "); progress.setLabel("Gateway service install blocked."); return; } const { programArguments, workingDirectory, environment, environmentValueSources } = await buildGatewayInstallPlan({ env: process.env, port: params.port, runtime: daemonRuntime, warn: (message, title) => note(message, title), config: cfg }); progress.setLabel("Installing Gateway service…"); try { await service.install({ env: process.env, stdout: process.stdout, programArguments, workingDirectory, environment, environmentValueSources }); progress.setLabel("Gateway service installed."); } catch (err) { installError = formatErrorMessage(err); progress.setLabel("Gateway service install failed."); } }); if (installError) { note("Gateway service install failed: ".concat(installError), "Gateway"); note(gatewayInstallErrorHint(), "Gateway"); return; } shouldCheckLinger = true; } if (shouldCheckLinger) await ensureSystemdUserLingerInteractive({ runtime: params.runtime, prompter: { confirm: async (p) => guardCancel(await confirm(p), params.runtime), note }, reason: "Linux installs use a systemd user service. Without lingering, systemd stops the user session on logout/idle and kills the Gateway.", requireConfirm: true }); } //#endregion //#region src/commands/configure.gateway-auth.ts /** Reject undefined, empty, and common JS string-coercion artifacts for token auth. */ function sanitizeTokenValue(value) { if (typeof value !== "string") return; const trimmed = value.trim(); if (!trimmed || trimmed === "undefined" || trimmed === "null") return; return trimmed; } async function resolveProviderChoiceModelPrompt(params) { const { resolvePluginProviders, resolveProviderPluginChoice } = await import("./provider-auth-choice.runtime.js"); const resolved = resolveProviderPluginChoice({ providers: resolvePluginProviders({ config: params.config, workspaceDir: params.workspaceDir, env: params.env, mode: "setup" }), choice: params.authChoice }); const wizard = resolved?.provider.wizard?.setup; if (!wizard) return resolved?.provider.id ? { provider: resolved.provider.id } : void 0; return { provider: resolved.provider.id, ...wizard.modelAllowlist, ...wizard.modelSelection?.promptWhenAuthChoiceProvided === true ? { loadCatalog: true } : {} }; } function hasConfiguredProviderModels(cfg, provider) { if (!provider) return false; if ((cfg.models?.providers?.[provider]?.models?.length ?? 0) > 0) return true; const providerPrefix = `${provider}/`; return Object.keys(cfg.agents?.defaults?.models ?? {}).some((key) => key.trim().startsWith(providerPrefix)); } function hasStaticManifestCatalogRows(cfg, provider) { if (!provider) return false; return loadStaticManifestCatalogRowsForList({ cfg, providerFilter: provider }).length > 0; } function listConfiguredModelProviders(cfg) { return Object.entries(cfg.models?.providers ?? {}).filter(([, provider]) => (provider.models?.length ?? 0) > 0).map(([provider]) => provider); } function resolveSingleConfiguredProvider(cfg) { const configuredProviders = listConfiguredModelProviders(cfg); return configuredProviders.length === 1 ? configuredProviders[0] : void 0; } function resolveProviderFromModelRef(model) { const trimmed = model?.trim(); const slashIndex = trimmed?.indexOf("/") ?? -1; return slashIndex > 0 ? trimmed?.slice(0, slashIndex) : void 0; } function resolveCanonicalOpenAISelectionForLegacyCodexPrimary(cfg, selectedModels) { const currentModel = cfg.agents?.defaults?.model; const primary = typeof currentModel === "string" ? currentModel.trim() : currentModel && typeof currentModel === "object" && typeof currentModel.primary === "string" ? currentModel.primary.trim() : void 0; const modelId = primary?.startsWith("codex/") ? primary.slice(6).trim() : ""; if (!modelId) return; const canonical = `openai/${modelId}`; return selectedModels.find((model) => model.trim() === canonical); } function resolveConfiguredProviderFromAuthChange(params) { if (hasConfiguredProviderModels(params.after, params.preferredProvider)) return params.preferredProvider; const beforeProviders = params.before.models?.providers ?? {}; const configuredProviders = listConfiguredModelProviders(params.after); const changedProviders = configuredProviders.filter((provider) => { const beforeCount = beforeProviders[provider]?.models?.length ?? 0; return (params.after.models?.providers?.[provider]?.models?.length ?? 0) > beforeCount; }); if (changedProviders.length === 1) return changedProviders[0]; return params.preferredProvider ?? (configuredProviders.length === 1 ? configuredProviders[0] : void 0); } /** Build gateway auth config, preserving Tailscale allowance and generating missing tokens. */ function buildGatewayAuthConfig(params) { const allowTailscale = params.existing?.allowTailscale; const base = {}; if (typeof allowTailscale === "boolean") base.allowTailscale = allowTailscale; if (params.mode === "token") { if (isSecretRef(params.token)) return { ...base, mode: "token", token: params.token }; const token = sanitizeTokenValue(params.token) ?? randomToken(); return { ...base, mode: "token", token }; } if (params.mode === "password") { const password = params.password?.trim(); return { ...base, mode: "password", ...password && { password } }; } if (params.mode === "trusted-proxy") { if (!params.trustedProxy) throw new Error(`trustedProxy config is required when mode is trusted-proxy. Run ${formatCliCommand("openclaw configure --section gateway")} to configure Gateway auth interactively.`); return { ...base, mode: "trusted-proxy", trustedProxy: params.trustedProxy }; } return base; } /** Prompt for model provider credentials and default model allowlist settings. */ async function promptAuthConfig(cfg, runtime, prompter) { let next = cfg; let authChoice = "skip"; let preferredProvider; while (true) { authChoice = await promptAuthChoiceGrouped({ prompter, store: ensureAuthProfileStore(void 0, { allowKeychainPrompt: false }), includeSkip: true, config: next }); preferredProvider = authChoice === "skip" ? void 0 : await resolvePreferredProviderForAuthChoice({ choice: authChoice, config: next }); if (authChoice === "custom-api-key") { next = (await promptCustomApiConfig({ prompter, runtime, config: next })).config; break; } if (authChoice === "skip") { const modelSelection = await promptDefaultModel({ config: next, prompter, allowKeep: true, ignoreAllowlist: true, includeProviderPluginSetups: false, loadCatalog: true, browseCatalogOnDemand: true, preferredProvider, workspaceDir: resolveDefaultAgentWorkspaceDir(), runtime }); if (modelSelection.config) next = modelSelection.config; if (modelSelection.model) { next = applyPrimaryModel(next, modelSelection.model); preferredProvider = resolveProviderFromModelRef(modelSelection.model) ?? preferredProvider; } break; } const beforeAuthConfig = next; const applied = await applyAuthChoice({ authChoice, config: next, prompter, runtime, setDefaultModel: true, preserveExistingDefaultModel: true }); next = applied.config; preferredProvider = resolveConfiguredProviderFromAuthChange({ before: beforeAuthConfig, after: next, preferredProvider }); if (applied.retrySelection) continue; break; } if (authChoice !== "custom-api-key") { const modelPrompt = await resolveProviderChoiceModelPrompt({ authChoice, config: next, workspaceDir: resolveDefaultAgentWorkspaceDir(), env: process.env }); const promptProvider = modelPrompt?.provider ?? preferredProvider ?? resolveSingleConfiguredProvider(next); const hasPromptProviderConfiguredModels = hasConfiguredProviderModels(next, promptProvider); const hasPromptProviderStaticManifestRows = hasStaticManifestCatalogRows(next, promptProvider); const shouldLoadModelCatalog = modelPrompt?.loadCatalog ?? (hasPromptProviderConfiguredModels || hasPromptProviderStaticManifestRows); const useProviderScopedCatalog = Boolean(promptProvider && shouldLoadModelCatalog && (modelPrompt?.loadCatalog === true || hasPromptProviderConfiguredModels)); const allowlistSelection = await promptModelAllowlist({ config: next, prompter, workspaceDir: resolveDefaultAgentWorkspaceDir(), env: process.env, allowedKeys: modelPrompt?.allowedKeys, initialSelections: modelPrompt?.initialSelections, message: modelPrompt?.message, preferredProvider: promptProvider, providerScopedCatalog: useProviderScopedCatalog, loadCatalog: shouldLoadModelCatalog }); if (allowlistSelection.models) { const canonicalPrimary = resolveCanonicalOpenAISelectionForLegacyCodexPrimary(next, allowlistSelection.models); if (canonicalPrimary) next = applyPrimaryModel(next, canonicalPrimary); next = applyModelFallbacksFromSelection(next, allowlistSelection.models, { scopeKeys: allowlistSelection.scopeKeys }); next = applyModelAllowlist(next, allowlistSelection.models, { scopeKeys: allowlistSelection.scopeKeys }); } } return next; } //#endregion //#region src/commands/configure.gateway.ts function validateGatewayPortInput$1(value) { if (parsePort(value) === null) return formatPortRangeHint(); } /** Prompt for local Gateway network/auth settings and return config plus call token. */ async function promptGatewayConfig(cfg, runtime) { const port = parsePort(guardCancel(await text({ message: "Gateway port", initialValue: String(resolveGatewayPort(cfg)), validate: validateGatewayPortInput$1 }), runtime)) ?? resolveGatewayPort(cfg); let bind = guardCancel(await select({ message: "Gateway bind mode", options: [ { value: "loopback", label: "Loopback (Local only)", hint: "Bind to 127.0.0.1 - secure, local-only access" }, { value: "tailnet", label: "Tailnet (Tailscale IP)", hint: "Bind to your Tailscale IP only (100.x.x.x)" }, { value: "auto", label: "Auto (Loopback → LAN)", hint: "Prefer loopback; fall back to all interfaces if unavailable" }, { value: "lan", label: "LAN (All interfaces)", hint: "Bind to 0.0.0.0 - accessible from anywhere on your network" }, { value: "custom", label: "Custom IP", hint: "Specify a specific IP address, with 0.0.0.0 fallback if unavailable" } ] }), runtime); let customBindHost; if (bind === "custom") customBindHost = readStringValue(guardCancel(await text({ message: "Custom IP address", placeholder: "192.168.1.100", validate: validateIPv4AddressInput }), runtime)); let authMode = guardCancel(await select({ message: "Gateway access protection", options: [ { value: "token", label: "Token (recommended)", hint: "Recommended default" }, { value: "password", label: "Password" }, { value: "trusted-proxy", label: "Trusted Proxy", hint: "Behind reverse proxy (Pomerium, Caddy, Traefik, etc.)" } ], initialValue: "token" }), runtime); let tailscaleMode = guardCancel(await select({ message: "Tailscale exposure", options: [...TAILSCALE_EXPOSURE_OPTIONS] }), runtime); let tailscaleBin = null; if (tailscaleMode !== "off") { tailscaleBin = await findTailscaleBinary(); if (!tailscaleBin) note(TAILSCALE_MISSING_BIN_NOTE_LINES.join("\n"), "Tailscale Warning"); } let tailscaleResetOnExit = false; if (tailscaleMode !== "off") { note(TAILSCALE_DOCS_LINES.join("\n"), "Tailscale"); tailscaleResetOnExit = guardCancel(await confirm({ message: "Reset Tailscale serve/funnel on exit?", initialValue: false }), runtime); } if (tailscaleMode !== "off" && bind !== "loopback") { note("Tailscale requires bind=loopback. Adjusting bind to loopback.", "Note"); bind = "loopback"; } if (tailscaleMode === "funnel" && authMode !== "password") { note("Tailscale funnel requires password auth.", "Note"); authMode = "password"; } if (authMode === "trusted-proxy" && tailscaleMode !== "off") { note("Trusted proxy auth is incompatible with Tailscale serve/funnel. Disabling Tailscale.", "Note"); tailscaleMode = "off"; tailscaleResetOnExit = false; } let gatewayToken; let gatewayTokenForCalls; let gatewayPassword; let trustedProxyConfig; let trustedProxies; let next = cfg; if (authMode === "token") if (guardCancel(await select({ message: "Gateway token source", options: [{ value: "plaintext", label: "Generate/store plaintext token", hint: "Default" }, { value: "ref", label: "Use SecretRef", hint: "Store an env-backed reference instead of plaintext" }], initialValue: "plaintext" }), runtime) === "ref") { const envVarName = normalizeOptionalString(guardCancel(await text({ message: "Gateway token env var", initialValue: "OPENCLAW_GATEWAY_TOKEN", placeholder: "OPENCLAW_GATEWAY_TOKEN", validate: (value) => { const candidate = normalizeOptionalString(value) ?? ""; if (!isValidEnvSecretRefId(candidate)) return "Use an env var name like OPENCLAW_GATEWAY_TOKEN."; if (!process.env[candidate]?.trim()) return `Environment variable "${candidate}" is missing or empty in this session.`; } }), runtime)) ?? ""; gatewayToken = { source: "env", provider: resolveDefaultSecretProviderAlias(cfg, "env", { preferFirstProviderForSource: true }), id: envVarName }; note(`Validated ${envVarName}. OpenClaw will store a token SecretRef.`, "Gateway token"); } else { gatewayTokenForCalls = normalizeGatewayTokenInput(guardCancel(await text({ message: "Gateway token (blank to generate)", initialValue: randomToken() }), runtime)) || randomToken(); gatewayToken = gatewayTokenForCalls; } if (authMode === "password") gatewayPassword = normalizeOptionalString(guardCancel(await text({ message: "Gateway password", validate: validateGatewayPasswordInput }), runtime)) ?? ""; if (authMode === "trusted-proxy") { note([ "Trusted proxy mode: OpenClaw trusts user identity from a reverse proxy.", "The proxy must authenticate users and pass identity via headers.", "Only requests from specified proxy IPs will be trusted.", "", "Common use cases: Pomerium, Caddy + OAuth, Traefik + forward auth", "Docs: https://docs.openclaw.ai/gateway/trusted-proxy-auth" ].join("\n"), "Trusted Proxy Auth"); const userHeader = guardCancel(await text({ message: "Header containing user identity", placeholder: "x-forwarded-user", initialValue: "x-forwarded-user", validate: (value) => value?.trim() ? void 0 : "User header is required" }), runtime); const requiredHeadersRaw = guardCancel(await text({ message: "Required headers (comma-separated, optional)", placeholder: "x-forwarded-proto,x-forwarded-host" }), runtime); const requiredHeaders = requiredHeadersRaw ? normalizeStringEntries(requiredHeadersRaw.split(",")) : []; const allowUsersRaw = guardCancel(await text({ message: "Allowed users (comma-separated, blank = all authenticated users)", placeholder: "nick@example.com,admin@company.com" }), runtime); const allowUsers = allowUsersRaw ? normalizeStringEntries(allowUsersRaw.split(",")) : []; trustedProxies = normalizeStringEntries(guardCancel(await text({ message: "Trusted proxy IPs (comma-separated)", placeholder: "10.0.1.10,192.168.1.5", validate: (value) => { if (!normalizeOptionalString(value)) return "At least one trusted proxy IP is required"; } }), runtime).split(",")); trustedProxyConfig = { userHeader: normalizeOptionalString(userHeader) ?? "", requiredHeaders: requiredHeaders.length > 0 ? requiredHeaders : void 0, allowUsers: allowUsers.length > 0 ? allowUsers : void 0 }; } const authConfig = buildGatewayAuthConfig({ existing: next.gateway?.auth, mode: authMode, token: gatewayToken, password: gatewayPassword, trustedProxy: trustedProxyConfig }); next = { ...next, gateway: { ...next.gateway, mode: "local", port, bind, auth: authConfig, ...customBindHost && { customBindHost }, ...trustedProxies && { trustedProxies }, tailscale: { ...next.gateway?.tailscale, mode: tailscaleMode, resetOnExit: tailscaleResetOnExit } } }; next = await maybeAddTailnetOriginToControlUiAllowedOrigins({ config: next, tailscaleMode, tailscaleBin }); return { config: next, port, token: gatewayTokenForCalls }; } //#endregion //#region src/commands/configure.wizard.ts const GATEWAY_HINT_PROBE_TIMEOUT_MS = 300; const setupPluginConfigModuleLoader = createLazyImportLoader(() => import("./setup.plugin-config-BjGF-JLz.js")); function validateGatewayPortInput(value) { if (parsePort(value) === null) return formatPortRangeHint(); } function loadSetupPluginConfigModule() { return setupPluginConfigModuleLoader.load(); } function mergeWizardConfigOntoLatest(current, base, next) { if (isDeepStrictEqual(next, base)) return current; if (isPlainObject(current) && isPlainObject(base) && isPlainObject(next)) { const merged = { ...current }; const keys = new Set([ ...Object.keys(current), ...Object.keys(base), ...Object.keys(next) ]); for (const key of keys) { const mergedValue = mergeWizardConfigOntoLatest(current[key], base[key], next[key]); if (mergedValue === void 0) delete merged[key]; else merged[key] = mergedValue; } return merged; } return structuredClone(next); } async function resolveGatewaySecretInputForWizard(params) { try { return await resolveSetupSecretInputString({ config: params.cfg, value: params.value, path: params.path, env: process.env }); } catch { return; } } async function runGatewayHealthCheck(params) { const localLinks = resolveControlUiLinks({ bind: params.cfg.gateway?.bind ?? "loopback", port: params.port, customBindHost: params.cfg.gateway?.customBindHost, basePath: void 0, tlsEnabled: params.cfg.gateway?.tls?.enabled === true }); const remoteUrl = params.cfg.gateway?.remote?.url?.trim(); const wsUrl = params.cfg.gateway?.mode === "remote" && remoteUrl ? remoteUrl : localLinks.wsUrl; const configuredToken = await resolveGatewaySecretInputForWizard({ cfg: params.cfg, value: params.cfg.gateway?.auth?.token, path: "gateway.auth.token" }); const configuredPassword = await resolveGatewaySecretInputForWizard({ cfg: params.cfg, value: params.cfg.gateway?.auth?.password, path: "gateway.auth.password" }); await waitForGatewayReachable({ url: wsUrl, token: process.env.OPENCLAW_GATEWAY_TOKEN ?? configuredToken, password: process.env.OPENCLAW_GATEWAY_PASSWORD ?? configuredPassword, deadlineMs: 15e3 }); try { await healthCommand({ json: false, timeoutMs: 1e4 }, params.runtime); } catch (err) { params.runtime.error(formatHealthCheckFailure(err)); note([ "Docs:", "https://docs.openclaw.ai/gateway/health", "https://docs.openclaw.ai/gateway/troubleshooting" ].join("\n"), "Health check help"); } } async function promptConfigureSection(runtime, hasSelection) { return guardCancel(await select({ message: "What do you want to configure?", options: [...CONFIGURE_SECTION_OPTIONS, { value: "__continue", label: hasSelection ? "Done" : "Skip for now" }], initialValue: CONFIGURE_SECTION_OPTIONS[0]?.value }), runtime); } async function promptChannelMode(runtime) { return guardCancel(await select({ message: "Channel setup", options: [{ value: "configure", label: "Add or update channels", hint: "Configure accounts and disable unselected accounts" }, { value: "remove", label: "Remove channel config", hint: "Delete channel tokens/settings from openclaw.json" }], initialValue: "configure" }), runtime); } async function promptWebToolsConfig(nextConfig, runtime, prompter) { const existingSearch = nextConfig.tools?.web?.search; const existingFetch = nextConfig.tools?.web?.fetch; const { isCodexNativeWebSearchRelevant } = await import("./codex-native-web-search-edGnsU7Z.js"); const hasManagedSearchProviders = resolvePluginContributionOwners({ config: nextConfig, contribution: "contracts", matches: "webSearchProviders" }).length > 0; note([ "Web search lets your agent look things up online using the `web_search` tool.", "Choose a managed provider now, and Codex-capable models can also use native Codex web search.", "Docs: https://docs.openclaw.ai/tools/web" ].join("\n"), "Web search"); const enableSearch = guardCancel(await confirm({ message: "Enable web_search?", initialValue: existingSearch?.enabled ?? hasManagedSearchProviders }), runtime); let nextSearch = { ...existingSearch, enabled: enableSearch }; let workingConfig = nextConfig; if (enableSearch) { const codexRelevant = isCodexNativeWebSearchRelevant({ config: nextConfig }); let configureManagedProvider = true; if (codexRelevant) { note([ "Codex-capable models can optionally use native Codex web search.", "Managed web_search still controls non-Codex models.", "If no managed provider is configured, non-Codex models still rely on provider auto-detect and may have no search available.", ...describeCodexNativeWebSearch(nextConfig) ? [describeCodexNativeWebSearch(nextConfig)] : ["Recommended mode: cached."] ].join("\n"), "Codex native search"); if (guardCancel(await confirm({ message: "Enable native Codex web search for Codex-capable models?", initialValue: existingSearch?.openaiCodex?.enabled === true }), runtime)) { const codexMode = guardCancel(await select({ message: "Codex native web search mode", options: [{ value: "cached", label: "cached (recommended)", hint: "Uses cached web content" }, { value: "live", label: "live", hint: "Allows live external web access" }], initialValue: existingSearch?.openaiCodex?.mode ?? "cached" }), runtime); nextSearch = { ...nextSearch, openaiCodex: { ...existingSearch?.openaiCodex, enabled: true, mode: codexMode } }; configureManagedProvider = guardCancel(await confirm({ message: "Configure or change a managed web search provider now?", initialValue: Boolean(existingSearch?.provider) }), runtime); } else nextSearch = { ...nextSearch, openaiCodex: { ...existingSearch?.openaiCodex, enabled: false } }; } if (configureManagedProvider) { const { resolveSearchProviderOptions, setupSearch } = await import("./onboard-search-BaCIbWUO.js"); if (resolveSearchProviderOptions(nextConfig).length === 0) { note([ "No web search providers are currently available under this plugin policy.", "Enable plugins or remove deny rules, then rerun configure.", "Docs: https://docs.openclaw.ai/tools/web" ].join("\n"), "Web search"); if (nextSearch.openaiCodex?.enabled !== true) nextSearch = { ...existingSearch, enabled: false }; } else { workingConfig = await setupSearch(workingConfig, runtime, prompter); nextSearch = { ...workingConfig.tools?.web?.search, enabled: workingConfig.tools?.web?.search?.provider ? true : existingSearch?.enabled, openaiCodex: { ...existingSearch?.openaiCodex, ...nextSearch.openaiCodex } }; } } } const enableFetch = guardCancel(await confirm({ message: "Enable web_fetch (keyless HTTP fetch)?", initialValue: existingFetch?.enabled ?? true }), runtime); const nextFetch = { ...existingFetch, enabled: enableFetch }; return { ...workingConfig, tools: { ...workingConfig.tools, web: { ...workingConfig.tools?.web, search: nextSearch, fetch: nextFetch } } }; } /** Run the configure/update wizard, optionally limited to selected sections. */ async function runConfigureWizard(opts, runtime = defaultRuntime) { try { intro(opts.command === "update" ? "OpenClaw update wizard" : "OpenClaw configure"); const prompter = createClackPrompter(); const snapshot = await readConfigFileSnapshot(); let currentBaseHash = snapshot.hash; const baseConfig = snapshot.valid ? snapshot.sourceConfig ?? snapshot.config : {}; if (snapshot.exists) { const title = snapshot.valid ? "Existing config detected" : "Invalid config"; note(summarizeExistingConfig(baseConfig), title); if (!snapshot.valid && snapshot.issues.length > 0) note([ ...snapshot.issues.map((iss) => `- ${iss.path}: ${iss.message}`), "", "Docs: https://docs.openclaw.ai/gateway/configuration" ].join("\n"), "Config issues"); if (!snapshot.valid) { outro(`Config invalid. Run \`${formatCliCommand("openclaw doctor")}\` to repair it, then re-run configure.`); runtime.exit(1); return; } } const selectedSections = opts.sections; const shouldPromptGatewayRunMode = !selectedSections || selectedSections.includes("gateway") || selectedSections.includes("daemon") || selectedSections.includes("health"); const promptGatewayRunMode = async () => { const localUrl = "ws://127.0.0.1:18789"; const remoteUrl = normalizeOptionalString(baseConfig.gateway?.remote?.url) ?? ""; const localProbePromise = (async () => { const [baseLocalProbeToken, baseLocalProbePassword] = await Promise.all([resolveGatewaySecretInputForWizard({ cfg: baseConfig, value: baseConfig.gateway?.auth?.token, path: "gateway.auth.token" }), resolveGatewaySecretInputForWizard({ cfg: baseConfig, value: baseConfig.gateway?.auth?.password, path: "gateway.auth.password" })]); return probeGatewayReachable({ url: localUrl, token: process.env.OPENCLAW_GATEWAY_TOKEN ?? baseLocalProbeToken, password: process.env.OPENCLAW_GATEWAY_PASSWORD ?? baseLocalProbePassword, timeoutMs: GATEWAY_HINT_PROBE_TIMEOUT_MS }); })(); const remoteProbePromise = remoteUrl ? (async () => { return probeGatewayReachable({ url: remoteUrl, token: await resolveGatewaySecretInputForWizard({ cfg: baseConfig, value: baseConfig.gateway?.remote?.token, path: "gateway.remote.token" }), timeoutMs: GATEWAY_HINT_PROBE_TIMEOUT_MS }); })() : Promise.resolve(null); const [localProbe, remoteProbe] = await Promise.all([localProbePromise, remoteProbePromise]); return guardCancel(await select({ message: "Where will the Gateway run?", options: [{ value: "local", label: "Local (this machine)", hint: localProbe.ok ? `Gateway reachable (${localUrl})` : `No gateway detected (${localUrl})` }, { value: "remote", label: "Remote (info-only)", hint: !remoteUrl ? "No remote URL configured yet" : remoteProbe?.ok ? `Gateway reachable (${remoteUrl})` : `Configured but unreachable (${remoteUrl})` }] }), runtime); }; const mode = shouldPromptGatewayRunMode ? await promptGatewayRunMode() : "local"; const metadataMode = shouldPromptGatewayRunMode || baseConfig.gateway?.mode !== "remote" ? mode : "remote"; const shouldSkipGatewaySummary = !shouldPromptGatewayRunMode; if (shouldPromptGatewayRunMode && mode === "remote") { let remoteConfig = await promptRemoteGatewayConfig(baseConfig, prompter); remoteConfig = applyWizardMetadata(remoteConfig, { command: opts.command, mode: metadataMode }); remoteConfig = (await commitConfigWithPendingPluginInstalls({ nextConfig: remoteConfig, ...currentBaseHash !== void 0 ? { baseHash: currentBaseHash } : {} })).config; currentBaseHash = void 0; logConfigUpdated(runtime); outro("Remote gateway configured."); return; } let nextConfig = { ...baseConfig }; let mergeBaseConfig = structuredClone(baseConfig); let didSetGatewayMode = false; if (shouldPromptGatewayRunMode && nextConfig.gateway?.mode !== "local") { nextConfig = { ...nextConfig, gateway: { ...nextConfig.gateway, mode: "local" } }; didSetGatewayMode = true; } let workspaceDir = nextConfig.agents?.defaults?.workspace ?? baseConfig.agents?.defaults?.workspace ?? DEFAULT_WORKSPACE; let gatewayPort = resolveGatewayPort(baseConfig); const persistConfig = async () => { nextConfig = applyWizardMetadata(nextConfig, { command: opts.command, mode: metadataMode }); const maxRetries = 3; for (let attempt = 0; attempt < maxRetries; attempt++) try { nextConfig = (await commitConfigWithPendingPluginInstalls({ nextConfig, ...currentBaseHash !== void 0 ? { baseHash: currentBaseHash } : {} })).config; currentBaseHash = (await readConfigFileSnapshot()).hash ?? void 0; mergeBaseConfig = structuredClone(nextConfig); logConfigUpdated(runtime); return; } catch (err) { if (err instanceof ConfigMutationConflictError && attempt < maxRetries - 1) { const freshSnapshot = await readConfigFileSnapshot(); currentBaseHash = freshSnapshot.hash ?? void 0; nextConfig = mergeWizardConfigOntoLatest(freshSnapshot.valid ? freshSnapshot.sourceConfig ?? freshSnapshot.config : {}, mergeBaseConfig, nextConfig); continue; } throw err; } }; const configureWorkspace = async () => { workspaceDir = resolveUserPath(normalizeOptionalString(guardCancel(await text({ message: "Workspace directory", initialValue: workspaceDir }), runtime) ?? "") || DEFAULT_WORKSPACE); if (!snapshot.exists) { const indicators = [ "MEMORY.md", "memory", ".git" ].map((name) => path.join(workspaceDir, name)); if ((await Promise.all(indicators.map(async (candidate) => { try { await fs.access(candidate); return true; } catch { return false; } }))).some(Boolean)) note([`Existing workspace detected at ${workspaceDir}`, "Existing files are preserved. Missing templates may be created, never overwritten."].join("\n"), "Existing workspace"); } nextConfig = { ...nextConfig, agents: { ...nextConfig.agents, defaults: { ...nextConfig.agents?.defaults, workspace: workspaceDir } } }; await ensureWorkspaceAndSessions(workspaceDir, runtime, { skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap), skipOptionalBootstrapFiles: nextConfig.agents?.defaults?.skipOptionalBootstrapFiles }); }; const configureChannelsSection = async () => { if (await promptChannelMode(runtime) === "configure") nextConfig = await setupChannels(nextConfig, runtime, prompter, { allowDisable: true, allowSignalInstall: true, deferStatusUntilSelection: true, skipConfirm: true, skipStatusNote: true }); else nextConfig = await removeChannelConfigWizard(nextConfig, runtime); }; const promptDaemonPort = async () => { gatewayPort = parsePort(guardCancel(await text({ message: "Gateway port for service install", initialValue: String(gatewayPort), validate: validateGatewayPortInput }), runtime)) ?? gatewayPort; }; if (selectedSections) { const selected = selectedSections; if (!selected || selected.length === 0) { outro("No configuration changes selected."); return; } if (selected.includes("workspace")) await configureWorkspace(); if (selected.includes("model")) nextConfig = await promptAuthConfig(nextConfig, runtime, prompter); if (selected.includes("web")) nextConfig = await promptWebToolsConfig(nextConfig, runtime, prompter); if (selected.includes("gateway")) { const gateway = await promptGatewayConfig(nextConfig, runtime); nextConfig = gateway.config; gatewayPort = gateway.port; } if (selected.includes("channels")) await configureChannelsSection(); if (selected.includes("plugins")) { const { configurePluginConfig } = await loadSetupPluginConfigModule(); nextConfig = await configurePluginConfig({ config: nextConfig, prompter, workspaceDir: resolveUserPath(workspaceDir) }); } if (selected.includes("skills")) { const wsDir = resolveUserPath(workspaceDir); nextConfig = await setupSkills(nextConfig, wsDir, runtime, prompter); } await persistConfig(); if (selected.includes("daemon")) { if (!selected.includes("gateway")) await promptDaemonPort(); await maybeInstallDaemon({ runtime, port: gatewayPort }); } if (selected.includes("health")) await runGatewayHealthCheck({ cfg: nextConfig, runtime, port: gatewayPort }); } else { let ranSection = false; let didConfigureGateway = false; while (true) { const choice = await promptConfigureSection(runtime, ranSection); if (choice === "__continue") break; ranSection = true; if (choice === "workspace") { await configureWorkspace(); await persistConfig(); } if (choice === "model") { nextConfig = await promptAuthConfig(nextConfig, runtime, prompter); await persistConfig(); } if (choice === "web") { nextConfig = await promptWebToolsConfig(nextConfig, runtime, prompter); await persistConfig(); } if (choice === "gateway") { const gateway = await promptGatewayConfig(nextConfig, runtime); nextConfig = gateway.config; gatewayPort = gateway.port; didConfigureGateway = true; await persistConfig(); } if (choice === "channels") { await configureChannelsSection(); await persistConfig(); } if (choice === "plugins") { const { configurePluginConfig } = await loadSetupPluginConfigModule(); nextConfig = await configurePluginConfig({ config: nextConfig, prompter, workspaceDir: resolveUserPath(workspaceDir) }); await persistConfig(); } if (choice === "skills") { const wsDir = resolveUserPath(workspaceDir); nextConfig = await setupSkills(nextConfig, wsDir, runtime, prompter); await persistConfig(); } if (choice === "daemon") { if (!didConfigureGateway) await promptDaemonPort(); await maybeInstallDaemon({ runtime, port: gatewayPort }); } if (choice === "health") await runGatewayHealthCheck({ cfg: nextConfig, runtime, port: gatewayPort }); } if (!ranSection) { if (didSetGatewayMode) { await persistConfig(); outro("Gateway mode set to local."); return; } outro("No configuration changes selected."); return; } } if (shouldSkipGatewaySummary) { const remoteUrl = normalizeOptionalString(nextConfig.gateway?.remote?.url); if (remoteUrl) note([ "Remote Gateway:", remoteUrl, "Docs: https://docs.openclaw.ai/gateway/remote" ].join("\n"), "Gateway"); outro("Configuration updated."); return; } const controlUiAssets = await ensureControlUiAssetsBuilt(runtime); if (!controlUiAssets.ok && controlUiAssets.message) runtime.error(controlUiAssets.message); const links = resolveControlUiLinks({ bind: nextConfig.gateway?.bind ?? "loopback", port: gatewayPort, customBindHost: nextConfig.gateway?.customBindHost, basePath: nextConfig.gateway?.controlUi?.basePath, tlsEnabled: nextConfig.gateway?.tls?.enabled === true }); const newPassword = process.env.OPENCLAW_GATEWAY_PASSWORD ?? await resolveGatewaySecretInputForWizard({ cfg: nextConfig, value: nextConfig.gateway?.auth?.password, path: "gateway.auth.password" }); const oldPassword = process.env.OPENCLAW_GATEWAY_PASSWORD ?? await resolveGatewaySecretInputForWizard({ cfg: baseConfig, value: baseConfig.gateway?.auth?.password, path: "gateway.auth.password" }); const token = process.env.OPENCLAW_GATEWAY_TOKEN ?? await resolveGatewaySecretInputForWizard({ cfg: nextConfig, value: nextConfig.gateway?.auth?.token, path: "gateway.auth.token" }); let gatewayProbe = await probeGatewayReachable({ url: links.wsUrl, token, password: newPassword }); if (!gatewayProbe.ok && newPassword !== oldPassword && oldPassword) gatewayProbe = await probeGatewayReachable({ url: links.wsUrl, token, password: oldPassword }); const gatewayStatusLine = gatewayProbe.ok ? "Gateway: reachable" : `Gateway: not detected${gatewayProbe.detail ? ` (${gatewayProbe.detail})` : ""}`; note([ `Web UI: ${links.httpUrl}`, `Gateway WS: ${links.wsUrl}`, gatewayStatusLine, "Docs: https://docs.openclaw.ai/web/control-ui" ].join("\n"), "Control UI"); outro("Configuration updated."); } catch (err) { if (err instanceof WizardCancelledError) { runtime.exit(1); return; } throw err; } } //#endregion //#region src/commands/configure.commands.ts async function configureCommand(runtime = defaultRuntime) { await runConfigureWizard({ command: "configure" }, runtime); } async function configureCommandWithSections(sections, runtime = defaultRuntime) { await runConfigureWizard({ command: "configure", sections }, runtime); } /** Parse `--section` input and run the requested configure wizard sections. */ async function configureCommandFromSectionsArg(rawSections, runtime = defaultRuntime) { const { sections, invalid } = parseConfigureWizardSections(rawSections); if (sections.length === 0) { await configureCommand(runtime); return; } if (invalid.length > 0) { runtime.error(`Invalid --section: ${invalid.join(", ")}. Expected one of: ${CONFIGURE_WIZARD_SECTIONS.join(", ")}. Run ${formatCliCommand("openclaw configure")} without --section to use the full wizard.`); runtime.exit(1); return; } await configureCommandWithSections(sections, runtime); } //#endregion export { buildGatewayAuthConfig as n, configureCommandFromSectionsArg as t };