When installing the Gateway daemon via LaunchAgent (macOS) or systemd (Linux), environment variables defined in config.env.vars were not being included in the service environment. This caused API keys and other env vars configured in clawdbot.json5 to be unavailable when the Gateway ran as a service. The fix adds a configEnvVars parameter to buildGatewayInstallPlan() which merges config.env.vars into the service environment. Service-specific variables (CLAWDBOT_*, HOME, PATH) take precedence over config env vars. Fixes the issue where users had to manually edit the LaunchAgent plist to add environment variables like GOOGLE_API_KEY.
476 lines
16 KiB
TypeScript
476 lines
16 KiB
TypeScript
import fs from "node:fs/promises";
|
||
import path from "node:path";
|
||
|
||
import { DEFAULT_BOOTSTRAP_FILENAME } from "../agents/workspace.js";
|
||
import {
|
||
DEFAULT_GATEWAY_DAEMON_RUNTIME,
|
||
GATEWAY_DAEMON_RUNTIME_OPTIONS,
|
||
type GatewayDaemonRuntime,
|
||
} from "../commands/daemon-runtime.js";
|
||
import { healthCommand } from "../commands/health.js";
|
||
import { formatHealthCheckFailure } from "../commands/health-format.js";
|
||
import {
|
||
detectBrowserOpenSupport,
|
||
formatControlUiSshHint,
|
||
openUrl,
|
||
openUrlInBackground,
|
||
probeGatewayReachable,
|
||
waitForGatewayReachable,
|
||
resolveControlUiLinks,
|
||
} from "../commands/onboard-helpers.js";
|
||
import { formatCliCommand } from "../cli/command-format.js";
|
||
import type { OnboardOptions } from "../commands/onboard-types.js";
|
||
import type { ClawdbotConfig } 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 { runTui } from "../tui/tui.js";
|
||
import { resolveUserPath } from "../utils.js";
|
||
import {
|
||
buildGatewayInstallPlan,
|
||
gatewayInstallErrorHint,
|
||
} from "../commands/daemon-install-helpers.js";
|
||
import type { GatewayWizardSettings, WizardFlow } from "./onboarding.types.js";
|
||
import type { WizardPrompter } from "./prompts.js";
|
||
|
||
type FinalizeOnboardingOptions = {
|
||
flow: WizardFlow;
|
||
opts: OnboardOptions;
|
||
baseConfig: ClawdbotConfig;
|
||
nextConfig: ClawdbotConfig;
|
||
workspaceDir: string;
|
||
settings: GatewayWizardSettings;
|
||
prompter: WizardPrompter;
|
||
runtime: RuntimeEnv;
|
||
};
|
||
|
||
export async function finalizeOnboardingWizard(options: FinalizeOnboardingOptions) {
|
||
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 as GatewayDaemonRuntime)
|
||
: ((await prompter.select({
|
||
message: "Gateway service runtime",
|
||
options: GATEWAY_DAEMON_RUNTIME_OPTIONS,
|
||
initialValue: opts.daemonRuntime ?? DEFAULT_GATEWAY_DAEMON_RUNTIME,
|
||
})) as GatewayDaemonRuntime);
|
||
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" },
|
||
],
|
||
})) as "restart" | "reinstall" | "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 })) === false)) {
|
||
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),
|
||
configEnvVars: nextConfig.env?.vars,
|
||
});
|
||
|
||
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.clawd.bot/gateway/health",
|
||
"https://docs.clawd.bot/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 tokenParam =
|
||
settings.authMode === "token" && settings.gatewayToken
|
||
? `?token=${encodeURIComponent(settings.gatewayToken)}`
|
||
: "";
|
||
const authedUrl = `${links.httpUrl}${tokenParam}`;
|
||
const gatewayProbe = await probeGatewayReachable({
|
||
url: links.wsUrl,
|
||
token: settings.authMode === "token" ? settings.gatewayToken : undefined,
|
||
password: settings.authMode === "password" ? nextConfig.gateway?.auth?.password : "",
|
||
});
|
||
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}`,
|
||
tokenParam ? `Web UI (with token): ${authedUrl}` : undefined,
|
||
`Gateway WS: ${links.wsUrl}`,
|
||
gatewayStatusLine,
|
||
"Docs: https://docs.clawd.bot/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;
|
||
|
||
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: ~/.clawdbot/clawdbot.json (gateway.auth.token) or CLAWDBOT_GATEWAY_TOKEN.",
|
||
"Web UI stores a copy in this browser's localStorage (clawdbot.control.settings.v1).",
|
||
`Get the tokenized link anytime: ${formatCliCommand("clawdbot dashboard --no-open")}`,
|
||
].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",
|
||
})) as "tui" | "web" | "later";
|
||
|
||
if (hatchChoice === "tui") {
|
||
await runTui({
|
||
url: links.wsUrl,
|
||
token: settings.authMode === "token" ? settings.gatewayToken : undefined,
|
||
password: settings.authMode === "password" ? nextConfig.gateway?.auth?.password : "",
|
||
// Safety: onboarding TUI should not auto-deliver to lastProvider/lastTo.
|
||
deliver: false,
|
||
message: hasBootstrap ? "Wake up, my friend!" : undefined,
|
||
});
|
||
if (settings.authMode === "token" && settings.gatewayToken) {
|
||
seededInBackground = await openUrlInBackground(authedUrl);
|
||
}
|
||
if (seededInBackground) {
|
||
await prompter.note(
|
||
`Web UI seeded in the background. Open later with: ${formatCliCommand(
|
||
"clawdbot dashboard --no-open",
|
||
)}`,
|
||
"Web UI",
|
||
);
|
||
}
|
||
} 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.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 Clawdbot."
|
||
: "Copy/paste this URL in a browser on this machine to control Clawdbot.",
|
||
controlUiOpenHint,
|
||
]
|
||
.filter(Boolean)
|
||
.join("\n"),
|
||
"Dashboard ready",
|
||
);
|
||
} else {
|
||
await prompter.note(
|
||
`When you're ready: ${formatCliCommand("clawdbot 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.clawd.bot/concepts/agent-workspace"].join(
|
||
"\n",
|
||
),
|
||
"Workspace backup",
|
||
);
|
||
|
||
await prompter.note(
|
||
"Running agents on your computer is risky — harden your setup: https://docs.clawd.bot/security",
|
||
"Security",
|
||
);
|
||
|
||
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 Clawdbot."
|
||
: "Copy/paste this URL in a browser on this machine to control Clawdbot.",
|
||
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.clawd.bot/tools/web",
|
||
].join("\n")
|
||
: [
|
||
"If you want your agent to be able to search the web, you’ll need an API key.",
|
||
"",
|
||
"Clawdbot uses Brave Search for the `web_search` tool. Without a Brave Search API key, web search won’t work.",
|
||
"",
|
||
"Set it up interactively:",
|
||
`- Run: ${formatCliCommand("clawdbot 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.clawd.bot/tools/web",
|
||
].join("\n"),
|
||
"Web search (optional)",
|
||
);
|
||
|
||
await prompter.note(
|
||
'What now: https://clawd.bot/showcase ("What People Are Building").',
|
||
"What now",
|
||
);
|
||
|
||
await prompter.outro(
|
||
controlUiOpened
|
||
? "Onboarding complete. Dashboard opened with your token; keep that tab to control Clawdbot."
|
||
: seededInBackground
|
||
? "Onboarding complete. Web UI seeded in the background; open it anytime with the tokenized link above."
|
||
: "Onboarding complete. Use the tokenized dashboard link above to control Clawdbot.",
|
||
);
|
||
}
|