452 lines
15 KiB
TypeScript
452 lines
15 KiB
TypeScript
import { ensureAuthProfileStore } from "../agents/auth-profiles.js";
|
||
import { listChannelPlugins } from "../channels/plugins/index.js";
|
||
import {
|
||
applyAuthChoice,
|
||
resolvePreferredProviderForAuthChoice,
|
||
warnIfModelConfigLooksOff,
|
||
} from "../commands/auth-choice.js";
|
||
import { promptAuthChoiceGrouped } from "../commands/auth-choice-prompt.js";
|
||
import { applyPrimaryModel, promptDefaultModel } from "../commands/model-picker.js";
|
||
import { setupChannels } from "../commands/onboard-channels.js";
|
||
import {
|
||
applyWizardMetadata,
|
||
DEFAULT_WORKSPACE,
|
||
ensureWorkspaceAndSessions,
|
||
handleReset,
|
||
printWizardHeader,
|
||
probeGatewayReachable,
|
||
summarizeExistingConfig,
|
||
} from "../commands/onboard-helpers.js";
|
||
import { promptRemoteGatewayConfig } from "../commands/onboard-remote.js";
|
||
import { setupSkills } from "../commands/onboard-skills.js";
|
||
import { setupInternalHooks } from "../commands/onboard-hooks.js";
|
||
import type {
|
||
GatewayAuthChoice,
|
||
OnboardMode,
|
||
OnboardOptions,
|
||
ResetScope,
|
||
} from "../commands/onboard-types.js";
|
||
import { formatCliCommand } from "../cli/command-format.js";
|
||
import type { ClawdbotConfig } from "../config/config.js";
|
||
import {
|
||
DEFAULT_GATEWAY_PORT,
|
||
readConfigFileSnapshot,
|
||
resolveGatewayPort,
|
||
writeConfigFile,
|
||
} from "../config/config.js";
|
||
import { logConfigUpdated } from "../config/logging.js";
|
||
import type { RuntimeEnv } from "../runtime.js";
|
||
import { defaultRuntime } from "../runtime.js";
|
||
import { resolveUserPath } from "../utils.js";
|
||
import { finalizeOnboardingWizard } from "./onboarding.finalize.js";
|
||
import { configureGatewayForOnboarding } from "./onboarding.gateway-config.js";
|
||
import type { QuickstartGatewayDefaults, WizardFlow } from "./onboarding.types.js";
|
||
import { WizardCancelledError, type WizardPrompter } from "./prompts.js";
|
||
|
||
async function requireRiskAcknowledgement(params: {
|
||
opts: OnboardOptions;
|
||
prompter: WizardPrompter;
|
||
}) {
|
||
if (params.opts.acceptRisk === true) return;
|
||
|
||
await params.prompter.note(
|
||
[
|
||
"Security warning — please read.",
|
||
"",
|
||
"Clawdbot is a hobby project and still in beta. Expect sharp edges.",
|
||
"This bot can read files and run actions if tools are enabled.",
|
||
"A bad prompt can trick it into doing unsafe things.",
|
||
"",
|
||
"If you’re not comfortable with basic security and access control, don’t run Clawdbot.",
|
||
"Ask someone experienced to help before enabling tools or exposing it to the internet.",
|
||
"",
|
||
"Recommended baseline:",
|
||
"- Pairing/allowlists + mention gating.",
|
||
"- Sandbox + least-privilege tools.",
|
||
"- Keep secrets out of the agent’s reachable filesystem.",
|
||
"- Use the strongest available model for any bot with tools or untrusted inboxes.",
|
||
"",
|
||
"Run regularly:",
|
||
"clawdbot security audit --deep",
|
||
"clawdbot security audit --fix",
|
||
"",
|
||
"Must read: https://docs.clawd.bot/gateway/security",
|
||
].join("\n"),
|
||
"Security",
|
||
);
|
||
|
||
const ok = await params.prompter.confirm({
|
||
message: "I understand this is powerful and inherently risky. Continue?",
|
||
initialValue: false,
|
||
});
|
||
if (!ok) {
|
||
throw new WizardCancelledError("risk not accepted");
|
||
}
|
||
}
|
||
|
||
export async function runOnboardingWizard(
|
||
opts: OnboardOptions,
|
||
runtime: RuntimeEnv = defaultRuntime,
|
||
prompter: WizardPrompter,
|
||
) {
|
||
printWizardHeader(runtime);
|
||
await prompter.intro("Clawdbot onboarding");
|
||
await requireRiskAcknowledgement({ opts, prompter });
|
||
|
||
const snapshot = await readConfigFileSnapshot();
|
||
let baseConfig: ClawdbotConfig = snapshot.valid ? snapshot.config : {};
|
||
|
||
if (snapshot.exists && !snapshot.valid) {
|
||
await prompter.note(summarizeExistingConfig(baseConfig), "Invalid config");
|
||
if (snapshot.issues.length > 0) {
|
||
await prompter.note(
|
||
[
|
||
...snapshot.issues.map((iss) => `- ${iss.path}: ${iss.message}`),
|
||
"",
|
||
"Docs: https://docs.clawd.bot/gateway/configuration",
|
||
].join("\n"),
|
||
"Config issues",
|
||
);
|
||
}
|
||
await prompter.outro(
|
||
`Config invalid. Run \`${formatCliCommand("clawdbot doctor")}\` to repair it, then re-run onboarding.`,
|
||
);
|
||
runtime.exit(1);
|
||
return;
|
||
}
|
||
|
||
const quickstartHint = `Configure details later via ${formatCliCommand("clawdbot configure")}.`;
|
||
const manualHint = "Configure port, network, Tailscale, and auth options.";
|
||
const explicitFlowRaw = opts.flow?.trim();
|
||
const normalizedExplicitFlow = explicitFlowRaw === "manual" ? "advanced" : explicitFlowRaw;
|
||
if (
|
||
normalizedExplicitFlow &&
|
||
normalizedExplicitFlow !== "quickstart" &&
|
||
normalizedExplicitFlow !== "advanced"
|
||
) {
|
||
runtime.error("Invalid --flow (use quickstart, manual, or advanced).");
|
||
runtime.exit(1);
|
||
return;
|
||
}
|
||
const explicitFlow: WizardFlow | undefined =
|
||
normalizedExplicitFlow === "quickstart" || normalizedExplicitFlow === "advanced"
|
||
? normalizedExplicitFlow
|
||
: undefined;
|
||
let flow: WizardFlow =
|
||
explicitFlow ??
|
||
((await prompter.select({
|
||
message: "Onboarding mode",
|
||
options: [
|
||
{ value: "quickstart", label: "QuickStart", hint: quickstartHint },
|
||
{ value: "advanced", label: "Manual", hint: manualHint },
|
||
],
|
||
initialValue: "quickstart",
|
||
})) as "quickstart" | "advanced");
|
||
|
||
if (opts.mode === "remote" && flow === "quickstart") {
|
||
await prompter.note(
|
||
"QuickStart only supports local gateways. Switching to Manual mode.",
|
||
"QuickStart",
|
||
);
|
||
flow = "advanced";
|
||
}
|
||
|
||
if (snapshot.exists) {
|
||
await prompter.note(summarizeExistingConfig(baseConfig), "Existing config detected");
|
||
|
||
const action = (await prompter.select({
|
||
message: "Config handling",
|
||
options: [
|
||
{ value: "keep", label: "Use existing values" },
|
||
{ value: "modify", label: "Update values" },
|
||
{ value: "reset", label: "Reset" },
|
||
],
|
||
})) as "keep" | "modify" | "reset";
|
||
|
||
if (action === "reset") {
|
||
const workspaceDefault = baseConfig.agents?.defaults?.workspace ?? DEFAULT_WORKSPACE;
|
||
const resetScope = (await prompter.select({
|
||
message: "Reset scope",
|
||
options: [
|
||
{ value: "config", label: "Config only" },
|
||
{
|
||
value: "config+creds+sessions",
|
||
label: "Config + creds + sessions",
|
||
},
|
||
{
|
||
value: "full",
|
||
label: "Full reset (config + creds + sessions + workspace)",
|
||
},
|
||
],
|
||
})) as ResetScope;
|
||
await handleReset(resetScope, resolveUserPath(workspaceDefault), runtime);
|
||
baseConfig = {};
|
||
}
|
||
}
|
||
|
||
const quickstartGateway: QuickstartGatewayDefaults = (() => {
|
||
const hasExisting =
|
||
typeof baseConfig.gateway?.port === "number" ||
|
||
baseConfig.gateway?.bind !== undefined ||
|
||
baseConfig.gateway?.auth?.mode !== undefined ||
|
||
baseConfig.gateway?.auth?.token !== undefined ||
|
||
baseConfig.gateway?.auth?.password !== undefined ||
|
||
baseConfig.gateway?.customBindHost !== undefined ||
|
||
baseConfig.gateway?.tailscale?.mode !== undefined;
|
||
|
||
const bindRaw = baseConfig.gateway?.bind;
|
||
const bind =
|
||
bindRaw === "loopback" ||
|
||
bindRaw === "lan" ||
|
||
bindRaw === "auto" ||
|
||
bindRaw === "custom" ||
|
||
bindRaw === "tailnet"
|
||
? bindRaw
|
||
: "loopback";
|
||
|
||
let authMode: GatewayAuthChoice = "token";
|
||
if (
|
||
baseConfig.gateway?.auth?.mode === "token" ||
|
||
baseConfig.gateway?.auth?.mode === "password"
|
||
) {
|
||
authMode = baseConfig.gateway.auth.mode;
|
||
} else if (baseConfig.gateway?.auth?.token) {
|
||
authMode = "token";
|
||
} else if (baseConfig.gateway?.auth?.password) {
|
||
authMode = "password";
|
||
}
|
||
|
||
const tailscaleRaw = baseConfig.gateway?.tailscale?.mode;
|
||
const tailscaleMode =
|
||
tailscaleRaw === "off" || tailscaleRaw === "serve" || tailscaleRaw === "funnel"
|
||
? tailscaleRaw
|
||
: "off";
|
||
|
||
return {
|
||
hasExisting,
|
||
port: resolveGatewayPort(baseConfig),
|
||
bind,
|
||
authMode,
|
||
tailscaleMode,
|
||
token: baseConfig.gateway?.auth?.token,
|
||
password: baseConfig.gateway?.auth?.password,
|
||
customBindHost: baseConfig.gateway?.customBindHost,
|
||
tailscaleResetOnExit: baseConfig.gateway?.tailscale?.resetOnExit ?? false,
|
||
};
|
||
})();
|
||
|
||
if (flow === "quickstart") {
|
||
const formatBind = (value: "loopback" | "lan" | "auto" | "custom" | "tailnet") => {
|
||
if (value === "loopback") return "Loopback (127.0.0.1)";
|
||
if (value === "lan") return "LAN";
|
||
if (value === "custom") return "Custom IP";
|
||
if (value === "tailnet") return "Tailnet (Tailscale IP)";
|
||
return "Auto";
|
||
};
|
||
const formatAuth = (value: GatewayAuthChoice) => {
|
||
if (value === "token") return "Token (default)";
|
||
return "Password";
|
||
};
|
||
const formatTailscale = (value: "off" | "serve" | "funnel") => {
|
||
if (value === "off") return "Off";
|
||
if (value === "serve") return "Serve";
|
||
return "Funnel";
|
||
};
|
||
const quickstartLines = quickstartGateway.hasExisting
|
||
? [
|
||
"Keeping your current gateway settings:",
|
||
`Gateway port: ${quickstartGateway.port}`,
|
||
`Gateway bind: ${formatBind(quickstartGateway.bind)}`,
|
||
...(quickstartGateway.bind === "custom" && quickstartGateway.customBindHost
|
||
? [`Gateway custom IP: ${quickstartGateway.customBindHost}`]
|
||
: []),
|
||
`Gateway auth: ${formatAuth(quickstartGateway.authMode)}`,
|
||
`Tailscale exposure: ${formatTailscale(quickstartGateway.tailscaleMode)}`,
|
||
"Direct to chat channels.",
|
||
]
|
||
: [
|
||
`Gateway port: ${DEFAULT_GATEWAY_PORT}`,
|
||
"Gateway bind: Loopback (127.0.0.1)",
|
||
"Gateway auth: Token (default)",
|
||
"Tailscale exposure: Off",
|
||
"Direct to chat channels.",
|
||
];
|
||
await prompter.note(quickstartLines.join("\n"), "QuickStart");
|
||
}
|
||
|
||
const localPort = resolveGatewayPort(baseConfig);
|
||
const localUrl = `ws://127.0.0.1:${localPort}`;
|
||
const localProbe = await probeGatewayReachable({
|
||
url: localUrl,
|
||
token: baseConfig.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
|
||
password: baseConfig.gateway?.auth?.password ?? process.env.CLAWDBOT_GATEWAY_PASSWORD,
|
||
});
|
||
const remoteUrl = baseConfig.gateway?.remote?.url?.trim() ?? "";
|
||
const remoteProbe = remoteUrl
|
||
? await probeGatewayReachable({
|
||
url: remoteUrl,
|
||
token: baseConfig.gateway?.remote?.token,
|
||
})
|
||
: null;
|
||
|
||
const mode =
|
||
opts.mode ??
|
||
(flow === "quickstart"
|
||
? "local"
|
||
: ((await prompter.select({
|
||
message: "What do you want to set up?",
|
||
options: [
|
||
{
|
||
value: "local",
|
||
label: "Local gateway (this machine)",
|
||
hint: localProbe.ok
|
||
? `Gateway reachable (${localUrl})`
|
||
: `No gateway detected (${localUrl})`,
|
||
},
|
||
{
|
||
value: "remote",
|
||
label: "Remote gateway (info-only)",
|
||
hint: !remoteUrl
|
||
? "No remote URL configured yet"
|
||
: remoteProbe?.ok
|
||
? `Gateway reachable (${remoteUrl})`
|
||
: `Configured but unreachable (${remoteUrl})`,
|
||
},
|
||
],
|
||
})) as OnboardMode));
|
||
|
||
if (mode === "remote") {
|
||
let nextConfig = await promptRemoteGatewayConfig(baseConfig, prompter);
|
||
nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode });
|
||
await writeConfigFile(nextConfig);
|
||
logConfigUpdated(runtime);
|
||
await prompter.outro("Remote gateway configured.");
|
||
return;
|
||
}
|
||
|
||
const workspaceInput =
|
||
opts.workspace ??
|
||
(flow === "quickstart"
|
||
? (baseConfig.agents?.defaults?.workspace ?? DEFAULT_WORKSPACE)
|
||
: await prompter.text({
|
||
message: "Workspace directory",
|
||
initialValue: baseConfig.agents?.defaults?.workspace ?? DEFAULT_WORKSPACE,
|
||
}));
|
||
|
||
const workspaceDir = resolveUserPath(workspaceInput.trim() || DEFAULT_WORKSPACE);
|
||
|
||
let nextConfig: ClawdbotConfig = {
|
||
...baseConfig,
|
||
agents: {
|
||
...baseConfig.agents,
|
||
defaults: {
|
||
...baseConfig.agents?.defaults,
|
||
workspace: workspaceDir,
|
||
},
|
||
},
|
||
gateway: {
|
||
...baseConfig.gateway,
|
||
mode: "local",
|
||
},
|
||
};
|
||
|
||
const authStore = ensureAuthProfileStore(undefined, {
|
||
allowKeychainPrompt: false,
|
||
});
|
||
const authChoiceFromPrompt = opts.authChoice === undefined;
|
||
const authChoice =
|
||
opts.authChoice ??
|
||
(await promptAuthChoiceGrouped({
|
||
prompter,
|
||
store: authStore,
|
||
includeSkip: true,
|
||
}));
|
||
|
||
const authResult = await applyAuthChoice({
|
||
authChoice,
|
||
config: nextConfig,
|
||
prompter,
|
||
runtime,
|
||
setDefaultModel: true,
|
||
opts: {
|
||
tokenProvider: opts.tokenProvider,
|
||
token: opts.authChoice === "apiKey" && opts.token ? opts.token : undefined,
|
||
},
|
||
});
|
||
nextConfig = authResult.config;
|
||
|
||
if (authChoiceFromPrompt) {
|
||
const modelSelection = await promptDefaultModel({
|
||
config: nextConfig,
|
||
prompter,
|
||
allowKeep: true,
|
||
ignoreAllowlist: true,
|
||
preferredProvider: resolvePreferredProviderForAuthChoice(authChoice),
|
||
});
|
||
if (modelSelection.model) {
|
||
nextConfig = applyPrimaryModel(nextConfig, modelSelection.model);
|
||
}
|
||
}
|
||
|
||
await warnIfModelConfigLooksOff(nextConfig, prompter);
|
||
|
||
const gateway = await configureGatewayForOnboarding({
|
||
flow,
|
||
baseConfig,
|
||
nextConfig,
|
||
localPort,
|
||
quickstartGateway,
|
||
prompter,
|
||
runtime,
|
||
});
|
||
nextConfig = gateway.nextConfig;
|
||
const settings = gateway.settings;
|
||
|
||
if (opts.skipChannels ?? opts.skipProviders) {
|
||
await prompter.note("Skipping channel setup.", "Channels");
|
||
} else {
|
||
const quickstartAllowFromChannels =
|
||
flow === "quickstart"
|
||
? listChannelPlugins()
|
||
.filter((plugin) => plugin.meta.quickstartAllowFrom)
|
||
.map((plugin) => plugin.id)
|
||
: [];
|
||
nextConfig = await setupChannels(nextConfig, runtime, prompter, {
|
||
allowSignalInstall: true,
|
||
forceAllowFromChannels: quickstartAllowFromChannels,
|
||
skipDmPolicyPrompt: flow === "quickstart",
|
||
skipConfirm: flow === "quickstart",
|
||
quickstartDefaults: flow === "quickstart",
|
||
});
|
||
}
|
||
|
||
await writeConfigFile(nextConfig);
|
||
logConfigUpdated(runtime);
|
||
await ensureWorkspaceAndSessions(workspaceDir, runtime, {
|
||
skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap),
|
||
});
|
||
|
||
if (opts.skipSkills) {
|
||
await prompter.note("Skipping skills setup.", "Skills");
|
||
} else {
|
||
nextConfig = await setupSkills(nextConfig, workspaceDir, runtime, prompter);
|
||
}
|
||
|
||
// Setup hooks (session memory on /new)
|
||
nextConfig = await setupInternalHooks(nextConfig, runtime, prompter);
|
||
|
||
nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode });
|
||
await writeConfigFile(nextConfig);
|
||
|
||
await finalizeOnboardingWizard({
|
||
flow,
|
||
opts,
|
||
baseConfig,
|
||
nextConfig,
|
||
workspaceDir,
|
||
settings,
|
||
prompter,
|
||
runtime,
|
||
});
|
||
}
|