openclaw/src/wizard/onboarding.finalize.ts
Josh Avant 806803b7ef
feat(secrets): expand SecretRef coverage across user-supplied credentials (#29580)
* feat(secrets): expand secret target coverage and gateway tooling

* docs(secrets): align gateway and CLI secret docs

* chore(protocol): regenerate swift gateway models for secrets methods

* fix(config): restore talk apiKey fallback and stabilize runner test

* ci(windows): reduce test worker count for shard stability

* ci(windows): raise node heap for test shard stability

* test(feishu): make proxy env precedence assertion windows-safe

* fix(gateway): resolve auth password SecretInput refs for clients

* fix(gateway): resolve remote SecretInput credentials for clients

* fix(secrets): skip inactive refs in command snapshot assignments

* fix(secrets): scope gateway.remote refs to effective auth surfaces

* fix(secrets): ignore memory defaults when enabled agents disable search

* fix(secrets): honor Google Chat serviceAccountRef inheritance

* fix(secrets): address tsgo errors in command and gateway collectors

* fix(secrets): avoid auth-store load in providers-only configure

* fix(gateway): defer local password ref resolution by precedence

* fix(secrets): gate telegram webhook secret refs by webhook mode

* fix(secrets): gate slack signing secret refs to http mode

* fix(secrets): skip telegram botToken refs when tokenFile is set

* fix(secrets): gate discord pluralkit refs by enabled flag

* fix(secrets): gate discord voice tts refs by voice enabled

* test(secrets): make runtime fixture modes explicit

* fix(cli): resolve local qr password secret refs

* fix(cli): fail when gateway leaves command refs unresolved

* fix(gateway): fail when local password SecretRef is unresolved

* fix(gateway): fail when required remote SecretRefs are unresolved

* fix(gateway): resolve local password refs only when password can win

* fix(cli): skip local password SecretRef resolution on qr token override

* test(gateway): cast SecretRef fixtures to OpenClawConfig

* test(secrets): activate mode-gated targets in runtime coverage fixture

* fix(cron): support SecretInput webhook tokens safely

* fix(bluebubbles): support SecretInput passwords across config paths

* fix(msteams): make appPassword SecretInput-safe in onboarding/token paths

* fix(bluebubbles): align SecretInput schema helper typing

* fix(cli): clarify secrets.resolve version-skew errors

* refactor(secrets): return structured inactive paths from secrets.resolve

* refactor(gateway): type onboarding secret writes as SecretInput

* chore(protocol): regenerate swift models for secrets.resolve

* feat(secrets): expand extension credential secretref support

* fix(secrets): gate web-search refs by active provider

* fix(onboarding): detect SecretRef credentials in extension status

* fix(onboarding): allow keeping existing ref in secret prompt

* fix(onboarding): resolve gateway password SecretRefs for probe and tui

* fix(onboarding): honor secret-input-mode for local gateway auth

* fix(acp): resolve gateway SecretInput credentials

* fix(secrets): gate gateway.remote refs to remote surfaces

* test(secrets): cover pattern matching and inactive array refs

* docs(secrets): clarify secrets.resolve and remote active surfaces

* fix(bluebubbles): keep existing SecretRef during onboarding

* fix(tests): resolve CI type errors in new SecretRef coverage

* fix(extensions): replace raw fetch with SSRF-guarded fetch

* test(secrets): mark gateway remote targets active in runtime coverage

* test(infra): normalize home-prefix expectation across platforms

* fix(cli): only resolve local qr password refs in password mode

* test(cli): cover local qr token mode with unresolved password ref

* docs(cli): clarify local qr password ref resolution behavior

* refactor(extensions): reuse sdk SecretInput helpers

* fix(wizard): resolve onboarding env-template secrets before plaintext

* fix(cli): surface secrets.resolve diagnostics in memory and qr

* test(secrets): repair post-rebase runtime and fixtures

* fix(gateway): skip remote password ref resolution when token wins

* fix(secrets): treat tailscale remote gateway refs as active

* fix(gateway): allow remote password fallback when token ref is unresolved

* fix(gateway): ignore stale local password refs for none and trusted-proxy

* fix(gateway): skip remote secret ref resolution on local call paths

* test(cli): cover qr remote tailscale secret ref resolution

* fix(secrets): align gateway password active-surface with auth inference

* fix(cli): resolve inferred local gateway password refs in qr

* fix(gateway): prefer resolvable remote password over token ref pre-resolution

* test(gateway): cover none and trusted-proxy stale password refs

* docs(secrets): sync qr and gateway active-surface behavior

* fix: restore stability blockers from pre-release audit

* Secrets: fix collector/runtime precedence contradictions

* docs: align secrets and web credential docs

* fix(rebase): resolve integration regressions after main rebase

* fix(node-host): resolve gateway secret refs for auth

* fix(secrets): harden secretinput runtime readers

* gateway: skip inactive auth secretref resolution

* cli: avoid gateway preflight for inactive secret refs

* extensions: allow unresolved refs in onboarding status

* tests: fix qr-cli module mock hoist ordering

* Security: align audit checks with SecretInput resolution

* Gateway: resolve local-mode remote fallback secret refs

* Node host: avoid resolving inactive password secret refs

* Secrets runtime: mark Slack appToken inactive for HTTP mode

* secrets: keep inactive gateway remote refs non-blocking

* cli: include agent memory secret targets in runtime resolution

* docs(secrets): sync docs with active-surface and web search behavior

* fix(secrets): keep telegram top-level token refs active for blank account tokens

* fix(daemon): resolve gateway password secret refs for probe auth

* fix(secrets): skip IRC NickServ ref resolution when NickServ is disabled

* fix(secrets): align token inheritance and exec timeout defaults

* docs(secrets): clarify active-surface notes in cli docs

* cli: require secrets.resolve gateway capability

* gateway: log auth secret surface diagnostics

* secrets: remove dead provider resolver module

* fix(secrets): restore gateway auth precedence and fallback resolution

* fix(tests): align plugin runtime mock typings

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-03-03 02:58:20 +00:00

500 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import fs from "node:fs/promises";
import path from "node:path";
import { DEFAULT_BOOTSTRAP_FILENAME } from "../agents/workspace.js";
import { formatCliCommand } from "../cli/command-format.js";
import {
buildGatewayInstallPlan,
gatewayInstallErrorHint,
} from "../commands/daemon-install-helpers.js";
import {
DEFAULT_GATEWAY_DAEMON_RUNTIME,
GATEWAY_DAEMON_RUNTIME_OPTIONS,
} from "../commands/daemon-runtime.js";
import { formatHealthCheckFailure } from "../commands/health-format.js";
import { healthCommand } from "../commands/health.js";
import {
detectBrowserOpenSupport,
formatControlUiSshHint,
openUrl,
probeGatewayReachable,
waitForGatewayReachable,
resolveControlUiLinks,
} from "../commands/onboard-helpers.js";
import type { OnboardOptions } from "../commands/onboard-types.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveGatewayService } from "../daemon/service.js";
import { isSystemdUserServiceAvailable } from "../daemon/systemd.js";
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
import type { RuntimeEnv } from "../runtime.js";
import { restoreTerminalState } from "../terminal/restore.js";
import { runTui } from "../tui/tui.js";
import { resolveUserPath } from "../utils.js";
import { setupOnboardingShellCompletion } from "./onboarding.completion.js";
import { resolveOnboardingSecretInputString } from "./onboarding.secret-input.js";
import type { GatewayWizardSettings, WizardFlow } from "./onboarding.types.js";
import type { WizardPrompter } from "./prompts.js";
type FinalizeOnboardingOptions = {
flow: WizardFlow;
opts: OnboardOptions;
baseConfig: OpenClawConfig;
nextConfig: OpenClawConfig;
workspaceDir: string;
settings: GatewayWizardSettings;
prompter: WizardPrompter;
runtime: RuntimeEnv;
};
export async function finalizeOnboardingWizard(
options: FinalizeOnboardingOptions,
): Promise<{ launchedTui: boolean }> {
const { flow, opts, baseConfig, nextConfig, settings, prompter, runtime } = options;
const withWizardProgress = async <T>(
label: string,
options: { doneMessage?: string },
work: (progress: { update: (message: string) => void }) => Promise<T>,
): Promise<T> => {
const progress = prompter.progress(label);
try {
return await work(progress);
} finally {
progress.stop(options.doneMessage);
}
};
const systemdAvailable =
process.platform === "linux" ? await isSystemdUserServiceAvailable() : true;
if (process.platform === "linux" && !systemdAvailable) {
await prompter.note(
"Systemd user services are unavailable. Skipping lingering checks and service install.",
"Systemd",
);
}
if (process.platform === "linux" && systemdAvailable) {
const { ensureSystemdUserLingerInteractive } = await import("../commands/systemd-linger.js");
await ensureSystemdUserLingerInteractive({
runtime,
prompter: {
confirm: prompter.confirm,
note: prompter.note,
},
reason:
"Linux installs use a systemd user service by default. Without lingering, systemd stops the user session on logout/idle and kills the Gateway.",
requireConfirm: false,
});
}
const explicitInstallDaemon =
typeof opts.installDaemon === "boolean" ? opts.installDaemon : undefined;
let installDaemon: boolean;
if (explicitInstallDaemon !== undefined) {
installDaemon = explicitInstallDaemon;
} else if (process.platform === "linux" && !systemdAvailable) {
installDaemon = false;
} else if (flow === "quickstart") {
installDaemon = true;
} else {
installDaemon = await prompter.confirm({
message: "Install Gateway service (recommended)",
initialValue: true,
});
}
if (process.platform === "linux" && !systemdAvailable && installDaemon) {
await prompter.note(
"Systemd user services are unavailable; skipping service install. Use your container supervisor or `docker compose up -d`.",
"Gateway service",
);
installDaemon = false;
}
if (installDaemon) {
const daemonRuntime =
flow === "quickstart"
? DEFAULT_GATEWAY_DAEMON_RUNTIME
: await prompter.select({
message: "Gateway service runtime",
options: GATEWAY_DAEMON_RUNTIME_OPTIONS,
initialValue: opts.daemonRuntime ?? DEFAULT_GATEWAY_DAEMON_RUNTIME,
});
if (flow === "quickstart") {
await prompter.note(
"QuickStart uses Node for the Gateway service (stable + supported).",
"Gateway service runtime",
);
}
const service = resolveGatewayService();
const loaded = await service.isLoaded({ env: process.env });
if (loaded) {
const action = await prompter.select({
message: "Gateway service already installed",
options: [
{ value: "restart", label: "Restart" },
{ value: "reinstall", label: "Reinstall" },
{ value: "skip", label: "Skip" },
],
});
if (action === "restart") {
await withWizardProgress(
"Gateway service",
{ doneMessage: "Gateway service restarted." },
async (progress) => {
progress.update("Restarting Gateway service…");
await service.restart({
env: process.env,
stdout: process.stdout,
});
},
);
} else if (action === "reinstall") {
await withWizardProgress(
"Gateway service",
{ doneMessage: "Gateway service uninstalled." },
async (progress) => {
progress.update("Uninstalling Gateway service…");
await service.uninstall({ env: process.env, stdout: process.stdout });
},
);
}
}
if (!loaded || (loaded && !(await service.isLoaded({ env: process.env })))) {
const progress = prompter.progress("Gateway service");
let installError: string | null = null;
try {
progress.update("Preparing Gateway service…");
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
env: process.env,
port: settings.port,
token: settings.gatewayToken,
runtime: daemonRuntime,
warn: (message, title) => prompter.note(message, title),
config: nextConfig,
});
progress.update("Installing Gateway service…");
await service.install({
env: process.env,
stdout: process.stdout,
programArguments,
workingDirectory,
environment,
});
} catch (err) {
installError = err instanceof Error ? err.message : String(err);
} finally {
progress.stop(
installError ? "Gateway service install failed." : "Gateway service installed.",
);
}
if (installError) {
await prompter.note(`Gateway service install failed: ${installError}`, "Gateway");
await prompter.note(gatewayInstallErrorHint(), "Gateway");
}
}
}
if (!opts.skipHealth) {
const probeLinks = resolveControlUiLinks({
bind: nextConfig.gateway?.bind ?? "loopback",
port: settings.port,
customBindHost: nextConfig.gateway?.customBindHost,
basePath: undefined,
});
// Daemon install/restart can briefly flap the WS; wait a bit so health check doesn't false-fail.
await waitForGatewayReachable({
url: probeLinks.wsUrl,
token: settings.gatewayToken,
deadlineMs: 15_000,
});
try {
await healthCommand({ json: false, timeoutMs: 10_000 }, runtime);
} catch (err) {
runtime.error(formatHealthCheckFailure(err));
await prompter.note(
[
"Docs:",
"https://docs.openclaw.ai/gateway/health",
"https://docs.openclaw.ai/gateway/troubleshooting",
].join("\n"),
"Health check help",
);
}
}
const controlUiEnabled =
nextConfig.gateway?.controlUi?.enabled ?? baseConfig.gateway?.controlUi?.enabled ?? true;
if (!opts.skipUi && controlUiEnabled) {
const controlUiAssets = await ensureControlUiAssetsBuilt(runtime);
if (!controlUiAssets.ok && controlUiAssets.message) {
runtime.error(controlUiAssets.message);
}
}
await prompter.note(
[
"Add nodes for extra features:",
"- macOS app (system + notifications)",
"- iOS app (camera/canvas)",
"- Android app (camera/canvas)",
].join("\n"),
"Optional apps",
);
const controlUiBasePath =
nextConfig.gateway?.controlUi?.basePath ?? baseConfig.gateway?.controlUi?.basePath;
const links = resolveControlUiLinks({
bind: settings.bind,
port: settings.port,
customBindHost: settings.customBindHost,
basePath: controlUiBasePath,
});
const authedUrl =
settings.authMode === "token" && settings.gatewayToken
? `${links.httpUrl}#token=${encodeURIComponent(settings.gatewayToken)}`
: links.httpUrl;
let resolvedGatewayPassword = "";
if (settings.authMode === "password") {
try {
resolvedGatewayPassword =
(await resolveOnboardingSecretInputString({
config: nextConfig,
value: nextConfig.gateway?.auth?.password,
path: "gateway.auth.password",
env: process.env,
})) ?? "";
} catch (error) {
await prompter.note(
[
"Could not resolve gateway.auth.password SecretRef for onboarding auth.",
error instanceof Error ? error.message : String(error),
].join("\n"),
"Gateway auth",
);
}
}
const gatewayProbe = await probeGatewayReachable({
url: links.wsUrl,
token: settings.authMode === "token" ? settings.gatewayToken : undefined,
password: settings.authMode === "password" ? resolvedGatewayPassword : "",
});
const gatewayStatusLine = gatewayProbe.ok
? "Gateway: reachable"
: `Gateway: not detected${gatewayProbe.detail ? ` (${gatewayProbe.detail})` : ""}`;
const bootstrapPath = path.join(
resolveUserPath(options.workspaceDir),
DEFAULT_BOOTSTRAP_FILENAME,
);
const hasBootstrap = await fs
.access(bootstrapPath)
.then(() => true)
.catch(() => false);
await prompter.note(
[
`Web UI: ${links.httpUrl}`,
settings.authMode === "token" && settings.gatewayToken
? `Web UI (with token): ${authedUrl}`
: undefined,
`Gateway WS: ${links.wsUrl}`,
gatewayStatusLine,
"Docs: https://docs.openclaw.ai/web/control-ui",
]
.filter(Boolean)
.join("\n"),
"Control UI",
);
let controlUiOpened = false;
let controlUiOpenHint: string | undefined;
let seededInBackground = false;
let hatchChoice: "tui" | "web" | "later" | null = null;
let launchedTui = false;
if (!opts.skipUi && gatewayProbe.ok) {
if (hasBootstrap) {
await prompter.note(
[
"This is the defining action that makes your agent you.",
"Please take your time.",
"The more you tell it, the better the experience will be.",
'We will send: "Wake up, my friend!"',
].join("\n"),
"Start TUI (best option!)",
);
}
await prompter.note(
[
"Gateway token: shared auth for the Gateway + Control UI.",
"Stored in: ~/.openclaw/openclaw.json (gateway.auth.token) or OPENCLAW_GATEWAY_TOKEN.",
`View token: ${formatCliCommand("openclaw config get gateway.auth.token")}`,
`Generate token: ${formatCliCommand("openclaw doctor --generate-gateway-token")}`,
"Web UI stores a copy in this browser's localStorage (openclaw.control.settings.v1).",
`Open the dashboard anytime: ${formatCliCommand("openclaw dashboard --no-open")}`,
"If prompted: paste the token into Control UI settings (or use the tokenized dashboard URL).",
].join("\n"),
"Token",
);
hatchChoice = await prompter.select({
message: "How do you want to hatch your bot?",
options: [
{ value: "tui", label: "Hatch in TUI (recommended)" },
{ value: "web", label: "Open the Web UI" },
{ value: "later", label: "Do this later" },
],
initialValue: "tui",
});
if (hatchChoice === "tui") {
restoreTerminalState("pre-onboarding tui", { resumeStdinIfPaused: true });
await runTui({
url: links.wsUrl,
token: settings.authMode === "token" ? settings.gatewayToken : undefined,
password: settings.authMode === "password" ? resolvedGatewayPassword : "",
// Safety: onboarding TUI should not auto-deliver to lastProvider/lastTo.
deliver: false,
message: hasBootstrap ? "Wake up, my friend!" : undefined,
});
launchedTui = true;
} else if (hatchChoice === "web") {
const browserSupport = await detectBrowserOpenSupport();
if (browserSupport.ok) {
controlUiOpened = await openUrl(authedUrl);
if (!controlUiOpened) {
controlUiOpenHint = formatControlUiSshHint({
port: settings.port,
basePath: controlUiBasePath,
token: settings.authMode === "token" ? settings.gatewayToken : undefined,
});
}
} else {
controlUiOpenHint = formatControlUiSshHint({
port: settings.port,
basePath: controlUiBasePath,
token: settings.authMode === "token" ? settings.gatewayToken : undefined,
});
}
await prompter.note(
[
`Dashboard link (with token): ${authedUrl}`,
controlUiOpened
? "Opened in your browser. Keep that tab to control OpenClaw."
: "Copy/paste this URL in a browser on this machine to control OpenClaw.",
controlUiOpenHint,
]
.filter(Boolean)
.join("\n"),
"Dashboard ready",
);
} else {
await prompter.note(
`When you're ready: ${formatCliCommand("openclaw dashboard --no-open")}`,
"Later",
);
}
} else if (opts.skipUi) {
await prompter.note("Skipping Control UI/TUI prompts.", "Control UI");
}
await prompter.note(
[
"Back up your agent workspace.",
"Docs: https://docs.openclaw.ai/concepts/agent-workspace",
].join("\n"),
"Workspace backup",
);
await prompter.note(
"Running agents on your computer is risky — harden your setup: https://docs.openclaw.ai/security",
"Security",
);
await setupOnboardingShellCompletion({ flow, prompter });
const shouldOpenControlUi =
!opts.skipUi &&
settings.authMode === "token" &&
Boolean(settings.gatewayToken) &&
hatchChoice === null;
if (shouldOpenControlUi) {
const browserSupport = await detectBrowserOpenSupport();
if (browserSupport.ok) {
controlUiOpened = await openUrl(authedUrl);
if (!controlUiOpened) {
controlUiOpenHint = formatControlUiSshHint({
port: settings.port,
basePath: controlUiBasePath,
token: settings.gatewayToken,
});
}
} else {
controlUiOpenHint = formatControlUiSshHint({
port: settings.port,
basePath: controlUiBasePath,
token: settings.gatewayToken,
});
}
await prompter.note(
[
`Dashboard link (with token): ${authedUrl}`,
controlUiOpened
? "Opened in your browser. Keep that tab to control OpenClaw."
: "Copy/paste this URL in a browser on this machine to control OpenClaw.",
controlUiOpenHint,
]
.filter(Boolean)
.join("\n"),
"Dashboard ready",
);
}
const webSearchKey = (nextConfig.tools?.web?.search?.apiKey ?? "").trim();
const webSearchEnv = (process.env.BRAVE_API_KEY ?? "").trim();
const hasWebSearchKey = Boolean(webSearchKey || webSearchEnv);
await prompter.note(
hasWebSearchKey
? [
"Web search is enabled, so your agent can look things up online when needed.",
"",
webSearchKey
? "API key: stored in config (tools.web.search.apiKey)."
: "API key: provided via BRAVE_API_KEY env var (Gateway environment).",
"Docs: https://docs.openclaw.ai/tools/web",
].join("\n")
: [
"If you want your agent to be able to search the web, youll need an API key.",
"",
"OpenClaw uses Brave Search for the `web_search` tool. Without a Brave Search API key, web search wont work.",
"",
"Set it up interactively:",
`- Run: ${formatCliCommand("openclaw configure --section web")}`,
"- Enable web_search and paste your Brave Search API key",
"",
"Alternative: set BRAVE_API_KEY in the Gateway environment (no config changes).",
"Docs: https://docs.openclaw.ai/tools/web",
].join("\n"),
"Web search (optional)",
);
await prompter.note(
'What now: https://openclaw.ai/showcase ("What People Are Building").',
"What now",
);
await prompter.outro(
controlUiOpened
? "Onboarding complete. Dashboard opened; keep that tab to control OpenClaw."
: seededInBackground
? "Onboarding complete. Web UI seeded in the background; open it anytime with the dashboard link above."
: "Onboarding complete. Use the dashboard link above to control OpenClaw.",
);
return { launchedTui };
}