From 2ca936ee9869e1c77b8edff94d1f8604ea2e1a22 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 7 Jan 2026 23:40:31 +0100 Subject: [PATCH] refactor: split doctor into modules --- src/commands/doctor-auth.ts | 30 + src/commands/doctor-gateway-services.ts | 141 ++++ src/commands/doctor-legacy-config.ts | 205 +++++ src/commands/doctor-prompter.ts | 47 ++ src/commands/doctor-sandbox.ts | 250 +++++++ src/commands/doctor-security.ts | 197 +++++ src/commands/doctor-workspace.ts | 41 + src/commands/doctor.ts | 945 +----------------------- 8 files changed, 937 insertions(+), 919 deletions(-) create mode 100644 src/commands/doctor-auth.ts create mode 100644 src/commands/doctor-gateway-services.ts create mode 100644 src/commands/doctor-legacy-config.ts create mode 100644 src/commands/doctor-prompter.ts create mode 100644 src/commands/doctor-sandbox.ts create mode 100644 src/commands/doctor-security.ts create mode 100644 src/commands/doctor-workspace.ts diff --git a/src/commands/doctor-auth.ts b/src/commands/doctor-auth.ts new file mode 100644 index 000000000..4b6b662bc --- /dev/null +++ b/src/commands/doctor-auth.ts @@ -0,0 +1,30 @@ +import { note } from "@clack/prompts"; + +import { + ensureAuthProfileStore, + repairOAuthProfileIdMismatch, +} from "../agents/auth-profiles.js"; +import type { ClawdbotConfig } from "../config/config.js"; +import type { DoctorPrompter } from "./doctor-prompter.js"; + +export async function maybeRepairAnthropicOAuthProfileId( + cfg: ClawdbotConfig, + prompter: DoctorPrompter, +): Promise { + const store = ensureAuthProfileStore(); + const repair = repairOAuthProfileIdMismatch({ + cfg, + store, + provider: "anthropic", + legacyProfileId: "anthropic:default", + }); + if (!repair.migrated || repair.changes.length === 0) return cfg; + + note(repair.changes.map((c) => `- ${c}`).join("\n"), "Auth profiles"); + const apply = await prompter.confirm({ + message: "Update Anthropic OAuth profile id in config now?", + initialValue: true, + }); + if (!apply) return cfg; + return repair.config; +} diff --git a/src/commands/doctor-gateway-services.ts b/src/commands/doctor-gateway-services.ts new file mode 100644 index 000000000..113fd9b17 --- /dev/null +++ b/src/commands/doctor-gateway-services.ts @@ -0,0 +1,141 @@ +import path from "node:path"; + +import { note } from "@clack/prompts"; + +import type { ClawdbotConfig } from "../config/config.js"; +import { resolveGatewayPort, resolveIsNixMode } from "../config/paths.js"; +import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js"; +import { + findExtraGatewayServices, + renderGatewayServiceCleanupHints, +} from "../daemon/inspect.js"; +import { + findLegacyGatewayServices, + uninstallLegacyGatewayServices, +} from "../daemon/legacy.js"; +import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; +import { resolveGatewayService } from "../daemon/service.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { + DEFAULT_GATEWAY_DAEMON_RUNTIME, + GATEWAY_DAEMON_RUNTIME_OPTIONS, + type GatewayDaemonRuntime, +} from "./daemon-runtime.js"; +import type { DoctorOptions, DoctorPrompter } from "./doctor-prompter.js"; + +export async function maybeMigrateLegacyGatewayService( + cfg: ClawdbotConfig, + mode: "local" | "remote", + runtime: RuntimeEnv, + prompter: DoctorPrompter, +) { + const legacyServices = await findLegacyGatewayServices(process.env); + if (legacyServices.length === 0) return; + + note( + legacyServices + .map((svc) => `- ${svc.label} (${svc.platform}, ${svc.detail})`) + .join("\n"), + "Legacy Clawdis services detected", + ); + + const migrate = await prompter.confirmSkipInNonInteractive({ + message: "Migrate legacy Clawdis services to Clawdbot now?", + initialValue: true, + }); + if (!migrate) return; + + try { + await uninstallLegacyGatewayServices({ + env: process.env, + stdout: process.stdout, + }); + } catch (err) { + runtime.error(`Legacy service cleanup failed: ${String(err)}`); + return; + } + + if (resolveIsNixMode(process.env)) { + note("Nix mode detected; skip installing services.", "Gateway"); + return; + } + + if (mode === "remote") { + note("Gateway mode is remote; skipped local service install.", "Gateway"); + return; + } + + const service = resolveGatewayService(); + const loaded = await service.isLoaded({ env: process.env }); + if (loaded) { + note(`Clawdbot ${service.label} already ${service.loadedText}.`, "Gateway"); + return; + } + + const install = await prompter.confirmSkipInNonInteractive({ + message: "Install Clawdbot gateway service now?", + initialValue: true, + }); + if (!install) return; + + const daemonRuntime = await prompter.select( + { + message: "Gateway daemon runtime", + options: GATEWAY_DAEMON_RUNTIME_OPTIONS, + initialValue: DEFAULT_GATEWAY_DAEMON_RUNTIME, + }, + DEFAULT_GATEWAY_DAEMON_RUNTIME, + ); + const devMode = + process.argv[1]?.includes(`${path.sep}src${path.sep}`) && + process.argv[1]?.endsWith(".ts"); + const port = resolveGatewayPort(cfg, process.env); + const { programArguments, workingDirectory } = + await resolveGatewayProgramArguments({ + port, + dev: devMode, + runtime: daemonRuntime, + }); + const environment: Record = { + PATH: process.env.PATH, + CLAWDBOT_GATEWAY_TOKEN: + cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN, + CLAWDBOT_LAUNCHD_LABEL: + process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined, + }; + await service.install({ + env: process.env, + stdout: process.stdout, + programArguments, + workingDirectory, + environment, + }); +} + +export async function maybeScanExtraGatewayServices(options: DoctorOptions) { + const extraServices = await findExtraGatewayServices(process.env, { + deep: options.deep, + }); + if (extraServices.length === 0) return; + + note( + extraServices + .map((svc) => `- ${svc.label} (${svc.scope}, ${svc.detail})`) + .join("\n"), + "Other gateway-like services detected", + ); + + const cleanupHints = renderGatewayServiceCleanupHints(); + if (cleanupHints.length > 0) { + note(cleanupHints.map((hint) => `- ${hint}`).join("\n"), "Cleanup hints"); + } + + note( + [ + "Recommendation: run a single gateway per machine.", + "One gateway supports multiple agents.", + "If you need multiple gateways, isolate ports + config/state (see docs: /gateway#multiple-gateways-same-host).", + ].join("\n"), + "Gateway recommendation", + ); +} diff --git a/src/commands/doctor-legacy-config.ts b/src/commands/doctor-legacy-config.ts new file mode 100644 index 000000000..db4636487 --- /dev/null +++ b/src/commands/doctor-legacy-config.ts @@ -0,0 +1,205 @@ +import os from "node:os"; +import path from "node:path"; + +import { note } from "@clack/prompts"; + +import type { ClawdbotConfig } from "../config/config.js"; +import { + CONFIG_PATH_CLAWDBOT, + createConfigIO, + migrateLegacyConfig, + readConfigFileSnapshot, + writeConfigFile, +} from "../config/config.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { resolveUserPath } from "../utils.js"; + +function resolveLegacyConfigPath(env: NodeJS.ProcessEnv): string { + const override = env.CLAWDIS_CONFIG_PATH?.trim(); + if (override) return override; + return path.join(os.homedir(), ".clawdis", "clawdis.json"); +} + +function normalizeDefaultWorkspacePath( + value: string | undefined, +): string | undefined { + if (!value) return value; + + const resolved = resolveUserPath(value); + const home = os.homedir(); + + const next = [ + ["clawdis", "clawd"], + ["clawdbot", "clawd"], + ].reduce((acc, [from, to]) => { + const fromPrefix = path.join(home, from); + if (acc === fromPrefix) return path.join(home, to); + const withSep = `${fromPrefix}${path.sep}`; + if (acc.startsWith(withSep)) { + return path.join(home, to).concat(acc.slice(fromPrefix.length)); + } + return acc; + }, resolved); + + return next === resolved ? value : next; +} + +export function replaceLegacyName(value: string | undefined): string | undefined { + if (!value) return value; + const replacedClawdis = value.replace(/clawdis/g, "clawdbot"); + return replacedClawdis.replace(/clawd(?!bot)/g, "clawdbot"); +} + +export function replaceModernName(value: string | undefined): string | undefined { + if (!value) return value; + if (!value.includes("clawdbot")) return value; + return value.replace(/clawdbot/g, "clawdis"); +} + +export function normalizeLegacyConfigValues(cfg: ClawdbotConfig): { + config: ClawdbotConfig; + changes: string[]; +} { + const changes: string[] = []; + let next: ClawdbotConfig = cfg; + + const workspace = cfg.agent?.workspace; + const updatedWorkspace = normalizeDefaultWorkspacePath(workspace); + if (updatedWorkspace && updatedWorkspace !== workspace) { + next = { + ...next, + agent: { + ...next.agent, + workspace: updatedWorkspace, + }, + }; + changes.push(`Updated agent.workspace → ${updatedWorkspace}`); + } + + const workspaceRoot = cfg.agent?.sandbox?.workspaceRoot; + const updatedWorkspaceRoot = normalizeDefaultWorkspacePath(workspaceRoot); + if (updatedWorkspaceRoot && updatedWorkspaceRoot !== workspaceRoot) { + next = { + ...next, + agent: { + ...next.agent, + sandbox: { + ...next.agent?.sandbox, + workspaceRoot: updatedWorkspaceRoot, + }, + }, + }; + changes.push( + `Updated agent.sandbox.workspaceRoot → ${updatedWorkspaceRoot}`, + ); + } + + const dockerImage = cfg.agent?.sandbox?.docker?.image; + const updatedDockerImage = replaceLegacyName(dockerImage); + if (updatedDockerImage && updatedDockerImage !== dockerImage) { + next = { + ...next, + agent: { + ...next.agent, + sandbox: { + ...next.agent?.sandbox, + docker: { + ...next.agent?.sandbox?.docker, + image: updatedDockerImage, + }, + }, + }, + }; + changes.push(`Updated agent.sandbox.docker.image → ${updatedDockerImage}`); + } + + const containerPrefix = cfg.agent?.sandbox?.docker?.containerPrefix; + const updatedContainerPrefix = replaceLegacyName(containerPrefix); + if (updatedContainerPrefix && updatedContainerPrefix !== containerPrefix) { + next = { + ...next, + agent: { + ...next.agent, + sandbox: { + ...next.agent?.sandbox, + docker: { + ...next.agent?.sandbox?.docker, + containerPrefix: updatedContainerPrefix, + }, + }, + }, + }; + changes.push( + `Updated agent.sandbox.docker.containerPrefix → ${updatedContainerPrefix}`, + ); + } + + return { config: next, changes }; +} + +export async function maybeMigrateLegacyConfigFile(runtime: RuntimeEnv) { + const legacyConfigPath = resolveLegacyConfigPath(process.env); + if (legacyConfigPath === CONFIG_PATH_CLAWDBOT) return; + + const legacyIo = createConfigIO({ configPath: legacyConfigPath }); + const legacySnapshot = await legacyIo.readConfigFileSnapshot(); + if (!legacySnapshot.exists) return; + + const currentSnapshot = await readConfigFileSnapshot(); + if (currentSnapshot.exists) { + note( + `Legacy config still exists at ${legacyConfigPath}. Current config at ${CONFIG_PATH_CLAWDBOT}.`, + "Legacy config", + ); + return; + } + + const gatewayMode = + typeof (legacySnapshot.parsed as ClawdbotConfig)?.gateway?.mode === "string" + ? (legacySnapshot.parsed as ClawdbotConfig).gateway?.mode + : undefined; + const gatewayBind = + typeof (legacySnapshot.parsed as ClawdbotConfig)?.gateway?.bind === "string" + ? (legacySnapshot.parsed as ClawdbotConfig).gateway?.bind + : undefined; + const agentWorkspace = + typeof (legacySnapshot.parsed as ClawdbotConfig)?.agent?.workspace === + "string" + ? (legacySnapshot.parsed as ClawdbotConfig).agent?.workspace + : undefined; + + note( + [ + `- File exists at ${legacyConfigPath}`, + gatewayMode ? `- gateway.mode: ${gatewayMode}` : undefined, + gatewayBind ? `- gateway.bind: ${gatewayBind}` : undefined, + agentWorkspace ? `- agent.workspace: ${agentWorkspace}` : undefined, + ] + .filter(Boolean) + .join("\n"), + "Legacy Clawdis config detected", + ); + + let nextConfig = legacySnapshot.valid ? legacySnapshot.config : null; + const { config: migratedConfig, changes } = migrateLegacyConfig( + legacySnapshot.parsed, + ); + if (migratedConfig) { + nextConfig = migratedConfig; + } else if (!nextConfig) { + note( + `Legacy config at ${legacyConfigPath} is invalid; skipping migration.`, + "Legacy config", + ); + return; + } + + const normalized = normalizeLegacyConfigValues(nextConfig); + const mergedChanges = [...changes, ...normalized.changes]; + if (mergedChanges.length > 0) { + note(mergedChanges.join("\n"), "Doctor changes"); + } + + await writeConfigFile(normalized.config); + runtime.log(`Migrated legacy config to ${CONFIG_PATH_CLAWDBOT}`); +} diff --git a/src/commands/doctor-prompter.ts b/src/commands/doctor-prompter.ts new file mode 100644 index 000000000..66476399b --- /dev/null +++ b/src/commands/doctor-prompter.ts @@ -0,0 +1,47 @@ +import { confirm, select } from "@clack/prompts"; + +import type { RuntimeEnv } from "../runtime.js"; +import { guardCancel } from "./onboard-helpers.js"; + +export type DoctorOptions = { + workspaceSuggestions?: boolean; + yes?: boolean; + nonInteractive?: boolean; + deep?: boolean; +}; + +export type DoctorPrompter = { + confirm: (params: Parameters[0]) => Promise; + confirmSkipInNonInteractive: ( + params: Parameters[0], + ) => Promise; + select: (params: Parameters[0], fallback: T) => Promise; +}; + +export function createDoctorPrompter(params: { + runtime: RuntimeEnv; + options: DoctorOptions; +}): DoctorPrompter { + const yes = params.options.yes === true; + const requestedNonInteractive = params.options.nonInteractive === true; + const isTty = Boolean(process.stdin.isTTY); + const nonInteractive = requestedNonInteractive || (!isTty && !yes); + + const canPrompt = isTty && !yes && !nonInteractive; + const confirmDefault = async (p: Parameters[0]) => { + if (!canPrompt) return Boolean(p.initialValue ?? false); + return guardCancel(await confirm(p), params.runtime) === true; + }; + + return { + confirm: confirmDefault, + confirmSkipInNonInteractive: async (p) => { + if (nonInteractive) return false; + return confirmDefault(p); + }, + select: async (p: Parameters[0], fallback: T) => { + if (!canPrompt) return fallback; + return guardCancel(await select(p), params.runtime) as T; + }, + }; +} diff --git a/src/commands/doctor-sandbox.ts b/src/commands/doctor-sandbox.ts new file mode 100644 index 000000000..38a44b47e --- /dev/null +++ b/src/commands/doctor-sandbox.ts @@ -0,0 +1,250 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { note } from "@clack/prompts"; + +import { + DEFAULT_SANDBOX_BROWSER_IMAGE, + DEFAULT_SANDBOX_COMMON_IMAGE, + DEFAULT_SANDBOX_IMAGE, +} from "../agents/sandbox.js"; +import type { ClawdbotConfig } from "../config/config.js"; +import { runCommandWithTimeout, runExec } from "../process/exec.js"; +import type { RuntimeEnv } from "../runtime.js"; +import type { DoctorPrompter } from "./doctor-prompter.js"; +import { replaceModernName } from "./doctor-legacy-config.js"; + +type SandboxScriptInfo = { + scriptPath: string; + cwd: string; +}; + +function resolveSandboxScript(scriptRel: string): SandboxScriptInfo | null { + const candidates = new Set(); + candidates.add(process.cwd()); + const argv1 = process.argv[1]; + if (argv1) { + const normalized = path.resolve(argv1); + candidates.add(path.resolve(path.dirname(normalized), "..")); + candidates.add(path.resolve(path.dirname(normalized))); + } + + for (const root of candidates) { + const scriptPath = path.join(root, scriptRel); + if (fs.existsSync(scriptPath)) { + return { scriptPath, cwd: root }; + } + } + + return null; +} + +async function runSandboxScript( + scriptRel: string, + runtime: RuntimeEnv, +): Promise { + const script = resolveSandboxScript(scriptRel); + if (!script) { + note( + `Unable to locate ${scriptRel}. Run it from the repo root.`, + "Sandbox", + ); + return false; + } + + runtime.log(`Running ${scriptRel}...`); + const result = await runCommandWithTimeout(["bash", script.scriptPath], { + timeoutMs: 20 * 60 * 1000, + cwd: script.cwd, + }); + if (result.code !== 0) { + runtime.error( + `Failed running ${scriptRel}: ${ + result.stderr.trim() || result.stdout.trim() || "unknown error" + }`, + ); + return false; + } + + runtime.log(`Completed ${scriptRel}.`); + return true; +} + +async function isDockerAvailable(): Promise { + try { + await runExec("docker", ["version", "--format", "{{.Server.Version}}"], { + timeoutMs: 5_000, + }); + return true; + } catch { + return false; + } +} + +async function dockerImageExists(image: string): Promise { + try { + await runExec("docker", ["image", "inspect", image], { timeoutMs: 5_000 }); + return true; + } catch { + return false; + } +} + +function resolveSandboxDockerImage(cfg: ClawdbotConfig): string { + const image = cfg.agent?.sandbox?.docker?.image?.trim(); + return image ? image : DEFAULT_SANDBOX_IMAGE; +} + +function resolveSandboxBrowserImage(cfg: ClawdbotConfig): string { + const image = cfg.agent?.sandbox?.browser?.image?.trim(); + return image ? image : DEFAULT_SANDBOX_BROWSER_IMAGE; +} + +function updateSandboxDockerImage( + cfg: ClawdbotConfig, + image: string, +): ClawdbotConfig { + return { + ...cfg, + agent: { + ...cfg.agent, + sandbox: { + ...cfg.agent?.sandbox, + docker: { + ...cfg.agent?.sandbox?.docker, + image, + }, + }, + }, + }; +} + +function updateSandboxBrowserImage( + cfg: ClawdbotConfig, + image: string, +): ClawdbotConfig { + return { + ...cfg, + agent: { + ...cfg.agent, + sandbox: { + ...cfg.agent?.sandbox, + browser: { + ...cfg.agent?.sandbox?.browser, + image, + }, + }, + }, + }; +} + +type SandboxImageCheck = { + label: string; + image: string; + buildScript?: string; + updateConfig: (image: string) => void; +}; + +async function handleMissingSandboxImage( + params: SandboxImageCheck, + runtime: RuntimeEnv, + prompter: DoctorPrompter, +) { + const exists = await dockerImageExists(params.image); + if (exists) return; + + const buildHint = params.buildScript + ? `Build it with ${params.buildScript}.` + : "Build or pull it first."; + note( + `Sandbox ${params.label} image missing: ${params.image}. ${buildHint}`, + "Sandbox", + ); + + let built = false; + if (params.buildScript) { + const build = await prompter.confirmSkipInNonInteractive({ + message: `Build ${params.label} sandbox image now?`, + initialValue: true, + }); + if (build) { + built = await runSandboxScript(params.buildScript, runtime); + } + } + + if (built) return; + + const legacyImage = replaceModernName(params.image); + if (!legacyImage || legacyImage === params.image) return; + const legacyExists = await dockerImageExists(legacyImage); + if (!legacyExists) return; + + const fallback = await prompter.confirmSkipInNonInteractive({ + message: `Switch config to legacy image ${legacyImage}?`, + initialValue: false, + }); + if (!fallback) return; + + params.updateConfig(legacyImage); +} + +export async function maybeRepairSandboxImages( + cfg: ClawdbotConfig, + runtime: RuntimeEnv, + prompter: DoctorPrompter, +): Promise { + const sandbox = cfg.agent?.sandbox; + const mode = sandbox?.mode ?? "off"; + if (!sandbox || mode === "off") return cfg; + + const dockerAvailable = await isDockerAvailable(); + if (!dockerAvailable) { + note("Docker not available; skipping sandbox image checks.", "Sandbox"); + return cfg; + } + + let next = cfg; + const changes: string[] = []; + + const dockerImage = resolveSandboxDockerImage(cfg); + await handleMissingSandboxImage( + { + label: "base", + image: dockerImage, + buildScript: + dockerImage === DEFAULT_SANDBOX_COMMON_IMAGE + ? "scripts/sandbox-common-setup.sh" + : dockerImage === DEFAULT_SANDBOX_IMAGE + ? "scripts/sandbox-setup.sh" + : undefined, + updateConfig: (image) => { + next = updateSandboxDockerImage(next, image); + changes.push(`Updated agent.sandbox.docker.image → ${image}`); + }, + }, + runtime, + prompter, + ); + + if (sandbox.browser?.enabled) { + await handleMissingSandboxImage( + { + label: "browser", + image: resolveSandboxBrowserImage(cfg), + buildScript: "scripts/sandbox-browser-setup.sh", + updateConfig: (image) => { + next = updateSandboxBrowserImage(next, image); + changes.push(`Updated agent.sandbox.browser.image → ${image}`); + }, + }, + runtime, + prompter, + ); + } + + if (changes.length > 0) { + note(changes.join("\n"), "Doctor changes"); + } + + return next; +} diff --git a/src/commands/doctor-security.ts b/src/commands/doctor-security.ts new file mode 100644 index 000000000..f4c89d0a9 --- /dev/null +++ b/src/commands/doctor-security.ts @@ -0,0 +1,197 @@ +import { note } from "@clack/prompts"; + +import type { ClawdbotConfig } from "../config/config.js"; +import { readProviderAllowFromStore } from "../pairing/pairing-store.js"; +import { readTelegramAllowFromStore } from "../telegram/pairing-store.js"; +import { resolveTelegramToken } from "../telegram/token.js"; +import { normalizeE164 } from "../utils.js"; + +export async function noteSecurityWarnings(cfg: ClawdbotConfig) { + const warnings: string[] = []; + + const warnDmPolicy = async (params: { + label: string; + provider: + | "telegram" + | "signal" + | "imessage" + | "discord" + | "slack" + | "whatsapp"; + dmPolicy: string; + allowFrom?: Array | null; + allowFromPath: string; + approveHint: string; + normalizeEntry?: (raw: string) => string; + }) => { + const dmPolicy = params.dmPolicy; + const configAllowFrom = (params.allowFrom ?? []).map((v) => + String(v).trim(), + ); + const hasWildcard = configAllowFrom.includes("*"); + const storeAllowFrom = await readProviderAllowFromStore( + params.provider, + ).catch(() => []); + const normalizedCfg = configAllowFrom + .filter((v) => v !== "*") + .map((v) => (params.normalizeEntry ? params.normalizeEntry(v) : v)) + .map((v) => v.trim()) + .filter(Boolean); + const normalizedStore = storeAllowFrom + .map((v) => (params.normalizeEntry ? params.normalizeEntry(v) : v)) + .map((v) => v.trim()) + .filter(Boolean); + const allowCount = Array.from( + new Set([...normalizedCfg, ...normalizedStore]), + ).length; + + if (dmPolicy === "open") { + const policyPath = `${params.allowFromPath}policy`; + const allowFromPath = `${params.allowFromPath}allowFrom`; + warnings.push( + `- ${params.label} DMs: OPEN (${policyPath}="open"). Anyone can DM it.`, + ); + if (!hasWildcard) { + warnings.push( + `- ${params.label} DMs: config invalid — "open" requires ${allowFromPath} to include "*".`, + ); + } + return; + } + + if (dmPolicy === "disabled") { + const policyPath = `${params.allowFromPath}policy`; + warnings.push( + `- ${params.label} DMs: disabled (${policyPath}="disabled").`, + ); + return; + } + + if (allowCount === 0) { + const policyPath = `${params.allowFromPath}policy`; + warnings.push( + `- ${params.label} DMs: locked (${policyPath}="${dmPolicy}") with no allowlist; unknown senders will be blocked / get a pairing code.`, + ); + warnings.push(` ${params.approveHint}`); + } + }; + + const telegramConfigured = Boolean(cfg.telegram); + const { token: telegramToken } = resolveTelegramToken(cfg); + if (telegramConfigured && telegramToken.trim()) { + const dmPolicy = cfg.telegram?.dmPolicy ?? "pairing"; + const configAllowFrom = (cfg.telegram?.allowFrom ?? []).map((v) => + String(v).trim(), + ); + const hasWildcard = configAllowFrom.includes("*"); + const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []); + const allowCount = Array.from( + new Set([ + ...configAllowFrom + .filter((v) => v !== "*") + .map((v) => v.replace(/^(telegram|tg):/i, "")) + .filter(Boolean), + ...storeAllowFrom.filter((v) => v !== "*"), + ]), + ).length; + + if (dmPolicy === "open") { + warnings.push( + `- Telegram DMs: OPEN (telegram.dmPolicy="open"). Anyone who can find the bot can DM it.`, + ); + if (!hasWildcard) { + warnings.push( + `- Telegram DMs: config invalid — dmPolicy "open" requires telegram.allowFrom to include "*".`, + ); + } + } else if (dmPolicy === "disabled") { + warnings.push(`- Telegram DMs: disabled (telegram.dmPolicy="disabled").`); + } else if (allowCount === 0) { + warnings.push( + `- Telegram DMs: locked (telegram.dmPolicy="${dmPolicy}") with no allowlist; unknown senders will be blocked / get a pairing code.`, + ); + warnings.push( + ` Approve via: clawdbot telegram pairing list / clawdbot telegram pairing approve `, + ); + } + + const groupPolicy = cfg.telegram?.groupPolicy ?? "open"; + const groupAllowlistConfigured = + cfg.telegram?.groups && Object.keys(cfg.telegram.groups).length > 0; + if (groupPolicy === "open" && !groupAllowlistConfigured) { + warnings.push( + `- Telegram groups: open (groupPolicy="open") with no telegram.groups allowlist; mention-gating applies but any group can add + ping.`, + ); + } + } + + if (cfg.discord?.enabled !== false) { + await warnDmPolicy({ + label: "Discord", + provider: "discord", + dmPolicy: cfg.discord?.dm?.policy ?? "pairing", + allowFrom: cfg.discord?.dm?.allowFrom ?? [], + allowFromPath: "discord.dm.", + approveHint: + "Approve via: clawdbot pairing list --provider discord / clawdbot pairing approve --provider discord ", + normalizeEntry: (raw) => + raw.replace(/^(discord|user):/i, "").replace(/^<@!?(\d+)>$/, "$1"), + }); + } + + if (cfg.slack?.enabled !== false) { + await warnDmPolicy({ + label: "Slack", + provider: "slack", + dmPolicy: cfg.slack?.dm?.policy ?? "pairing", + allowFrom: cfg.slack?.dm?.allowFrom ?? [], + allowFromPath: "slack.dm.", + approveHint: + "Approve via: clawdbot pairing list --provider slack / clawdbot pairing approve --provider slack ", + normalizeEntry: (raw) => raw.replace(/^(slack|user):/i, ""), + }); + } + + if (cfg.signal?.enabled !== false) { + await warnDmPolicy({ + label: "Signal", + provider: "signal", + dmPolicy: cfg.signal?.dmPolicy ?? "pairing", + allowFrom: cfg.signal?.allowFrom ?? [], + allowFromPath: "signal.", + approveHint: + "Approve via: clawdbot pairing list --provider signal / clawdbot pairing approve --provider signal ", + normalizeEntry: (raw) => + normalizeE164(raw.replace(/^signal:/i, "").trim()), + }); + } + + if (cfg.imessage?.enabled !== false) { + await warnDmPolicy({ + label: "iMessage", + provider: "imessage", + dmPolicy: cfg.imessage?.dmPolicy ?? "pairing", + allowFrom: cfg.imessage?.allowFrom ?? [], + allowFromPath: "imessage.", + approveHint: + "Approve via: clawdbot pairing list --provider imessage / clawdbot pairing approve --provider imessage ", + }); + } + + if (cfg.whatsapp) { + await warnDmPolicy({ + label: "WhatsApp", + provider: "whatsapp", + dmPolicy: cfg.whatsapp?.dmPolicy ?? "pairing", + allowFrom: cfg.whatsapp?.allowFrom ?? [], + allowFromPath: "whatsapp.", + approveHint: + "Approve via: clawdbot pairing list --provider whatsapp / clawdbot pairing approve --provider whatsapp ", + normalizeEntry: (raw) => normalizeE164(raw), + }); + } + + if (warnings.length > 0) { + note(warnings.join("\n"), "Security"); + } +} diff --git a/src/commands/doctor-workspace.ts b/src/commands/doctor-workspace.ts new file mode 100644 index 000000000..952f719f6 --- /dev/null +++ b/src/commands/doctor-workspace.ts @@ -0,0 +1,41 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { DEFAULT_AGENTS_FILENAME } from "../agents/workspace.js"; + +export const MEMORY_SYSTEM_PROMPT = [ + "Memory system not found in workspace.", + "Paste this into your agent:", + "", + "Install the memory system by applying:", + "https://github.com/clawdbot/clawdbot/commit/9ffea23f31ca1df5183b25668f8f814bee0fb34e", + "https://github.com/clawdbot/clawdbot/commit/7d1fee70e76f2f634f1b41fca927ee663914183a", +].join("\n"); + +export async function shouldSuggestMemorySystem( + workspaceDir: string, +): Promise { + const memoryPaths = [ + path.join(workspaceDir, "MEMORY.md"), + path.join(workspaceDir, "memory.md"), + ]; + + for (const memoryPath of memoryPaths) { + try { + await fs.promises.access(memoryPath); + return false; + } catch { + // keep scanning + } + } + + const agentsPath = path.join(workspaceDir, DEFAULT_AGENTS_FILENAME); + try { + const content = await fs.promises.readFile(agentsPath, "utf-8"); + if (/memory\.md/i.test(content)) return false; + } catch { + // no AGENTS.md or unreadable; treat as missing memory guidance + } + + return true; +} diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index d98d7d7b0..40b824d16 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -1,52 +1,32 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { confirm, intro, note, outro, select } from "@clack/prompts"; -import { - ensureAuthProfileStore, - repairOAuthProfileIdMismatch, -} from "../agents/auth-profiles.js"; -import { - DEFAULT_SANDBOX_BROWSER_IMAGE, - DEFAULT_SANDBOX_COMMON_IMAGE, - DEFAULT_SANDBOX_IMAGE, - resolveSandboxScope, -} from "../agents/sandbox.js"; +import { intro, note, outro } from "@clack/prompts"; import { buildWorkspaceSkillStatus } from "../agents/skills-status.js"; -import { DEFAULT_AGENTS_FILENAME } from "../agents/workspace.js"; import type { ClawdbotConfig } from "../config/config.js"; import { CONFIG_PATH_CLAWDBOT, - createConfigIO, migrateLegacyConfig, readConfigFileSnapshot, writeConfigFile, } from "../config/config.js"; -import { resolveGatewayPort, resolveIsNixMode } from "../config/paths.js"; import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js"; -import { - findExtraGatewayServices, - renderGatewayServiceCleanupHints, -} from "../daemon/inspect.js"; -import { - findLegacyGatewayServices, - uninstallLegacyGatewayServices, -} from "../daemon/legacy.js"; -import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; import { resolveGatewayService } from "../daemon/service.js"; -import { readProviderAllowFromStore } from "../pairing/pairing-store.js"; -import { runCommandWithTimeout, runExec } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; -import { readTelegramAllowFromStore } from "../telegram/pairing-store.js"; -import { resolveTelegramToken } from "../telegram/token.js"; -import { normalizeE164, resolveUserPath, sleep } from "../utils.js"; +import { resolveUserPath, sleep } from "../utils.js"; +import { maybeRepairAnthropicOAuthProfileId } from "./doctor-auth.js"; import { - DEFAULT_GATEWAY_DAEMON_RUNTIME, - GATEWAY_DAEMON_RUNTIME_OPTIONS, - type GatewayDaemonRuntime, -} from "./daemon-runtime.js"; + maybeMigrateLegacyConfigFile, + normalizeLegacyConfigValues, +} from "./doctor-legacy-config.js"; +import { + maybeMigrateLegacyGatewayService, + maybeScanExtraGatewayServices, +} from "./doctor-gateway-services.js"; +import { + createDoctorPrompter, + type DoctorOptions, +} from "./doctor-prompter.js"; +import { maybeRepairSandboxImages } from "./doctor-sandbox.js"; +import { noteSecurityWarnings } from "./doctor-security.js"; import { detectLegacyStateMigrations, runLegacyStateMigrations, @@ -55,11 +35,14 @@ import { noteStateIntegrity, noteWorkspaceBackupTip, } from "./doctor-state-integrity.js"; +import { + MEMORY_SYSTEM_PROMPT, + shouldSuggestMemorySystem, +} from "./doctor-workspace.js"; import { healthCommand } from "./health.js"; import { applyWizardMetadata, DEFAULT_WORKSPACE, - guardCancel, printWizardHeader, } from "./onboard-helpers.js"; import { ensureSystemdUserLingerInteractive } from "./systemd-linger.js"; @@ -68,875 +51,6 @@ function resolveMode(cfg: ClawdbotConfig): "local" | "remote" { return cfg.gateway?.mode === "remote" ? "remote" : "local"; } -function hasObjectOverrides(value?: unknown) { - if (!value || typeof value !== "object") return false; - return Object.values(value).some((entry) => entry !== undefined); -} - -function collectSandboxSharedOverrideWarnings(cfg: ClawdbotConfig) { - const globalSandbox = cfg.agent?.sandbox; - const agents = cfg.routing?.agents; - if (!agents) return []; - - const warnings: string[] = []; - for (const [agentId, agentCfg] of Object.entries(agents)) { - if (!agentCfg || typeof agentCfg !== "object") continue; - const agentSandbox = agentCfg.sandbox; - if (!agentSandbox || typeof agentSandbox !== "object") continue; - - const hasOverrides = - hasObjectOverrides(agentSandbox.docker) || - hasObjectOverrides(agentSandbox.browser) || - hasObjectOverrides(agentSandbox.prune); - if (!hasOverrides) continue; - - const scope = resolveSandboxScope({ - scope: agentSandbox.scope ?? globalSandbox?.scope, - perSession: agentSandbox.perSession ?? globalSandbox?.perSession, - }); - if (scope !== "shared") continue; - - warnings.push( - `- routing.agents.${agentId}.sandbox.{docker,browser,prune}.* is ignored when sandbox scope resolves to "shared" (single shared container).`, - ); - } - - return warnings; -} - -function resolveLegacyConfigPath(env: NodeJS.ProcessEnv): string { - const override = env.CLAWDIS_CONFIG_PATH?.trim(); - if (override) return override; - return path.join(os.homedir(), ".clawdis", "clawdis.json"); -} - -async function noteSecurityWarnings(cfg: ClawdbotConfig) { - const warnings: string[] = []; - - const warnDmPolicy = async (params: { - label: string; - provider: - | "telegram" - | "signal" - | "imessage" - | "discord" - | "slack" - | "whatsapp"; - dmPolicy: string; - allowFrom?: Array | null; - allowFromPath: string; - approveHint: string; - normalizeEntry?: (raw: string) => string; - }) => { - const dmPolicy = params.dmPolicy; - const configAllowFrom = (params.allowFrom ?? []).map((v) => - String(v).trim(), - ); - const hasWildcard = configAllowFrom.includes("*"); - const storeAllowFrom = await readProviderAllowFromStore( - params.provider, - ).catch(() => []); - const normalizedCfg = configAllowFrom - .filter((v) => v !== "*") - .map((v) => (params.normalizeEntry ? params.normalizeEntry(v) : v)) - .map((v) => v.trim()) - .filter(Boolean); - const normalizedStore = storeAllowFrom - .map((v) => (params.normalizeEntry ? params.normalizeEntry(v) : v)) - .map((v) => v.trim()) - .filter(Boolean); - const allowCount = Array.from( - new Set([...normalizedCfg, ...normalizedStore]), - ).length; - - if (dmPolicy === "open") { - const policyPath = `${params.allowFromPath}policy`; - const allowFromPath = `${params.allowFromPath}allowFrom`; - warnings.push( - `- ${params.label} DMs: OPEN (${policyPath}="open"). Anyone can DM it.`, - ); - if (!hasWildcard) { - warnings.push( - `- ${params.label} DMs: config invalid — "open" requires ${allowFromPath} to include "*".`, - ); - } - return; - } - - if (dmPolicy === "disabled") { - const policyPath = `${params.allowFromPath}policy`; - warnings.push( - `- ${params.label} DMs: disabled (${policyPath}="disabled").`, - ); - return; - } - - if (allowCount === 0) { - const policyPath = `${params.allowFromPath}policy`; - warnings.push( - `- ${params.label} DMs: locked (${policyPath}="${dmPolicy}") with no allowlist; unknown senders will be blocked / get a pairing code.`, - ); - warnings.push(` ${params.approveHint}`); - } - }; - - const telegramConfigured = Boolean(cfg.telegram); - const { token: telegramToken } = resolveTelegramToken(cfg); - if (telegramConfigured && telegramToken.trim()) { - const dmPolicy = cfg.telegram?.dmPolicy ?? "pairing"; - const configAllowFrom = (cfg.telegram?.allowFrom ?? []).map((v) => - String(v).trim(), - ); - const hasWildcard = configAllowFrom.includes("*"); - const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []); - const allowCount = Array.from( - new Set([ - ...configAllowFrom - .filter((v) => v !== "*") - .map((v) => v.replace(/^(telegram|tg):/i, "")) - .filter(Boolean), - ...storeAllowFrom.filter((v) => v !== "*"), - ]), - ).length; - - if (dmPolicy === "open") { - warnings.push( - `- Telegram DMs: OPEN (telegram.dmPolicy="open"). Anyone who can find the bot can DM it.`, - ); - if (!hasWildcard) { - warnings.push( - `- Telegram DMs: config invalid — dmPolicy "open" requires telegram.allowFrom to include "*".`, - ); - } - } else if (dmPolicy === "disabled") { - warnings.push(`- Telegram DMs: disabled (telegram.dmPolicy="disabled").`); - } else if (allowCount === 0) { - warnings.push( - `- Telegram DMs: locked (telegram.dmPolicy="${dmPolicy}") with no allowlist; unknown senders will be blocked / get a pairing code.`, - ); - warnings.push( - ` Approve via: clawdbot telegram pairing list / clawdbot telegram pairing approve `, - ); - } - - const groupPolicy = cfg.telegram?.groupPolicy ?? "open"; - const groupAllowlistConfigured = - cfg.telegram?.groups && Object.keys(cfg.telegram.groups).length > 0; - if (groupPolicy === "open" && !groupAllowlistConfigured) { - warnings.push( - `- Telegram groups: open (groupPolicy="open") with no telegram.groups allowlist; mention-gating applies but any group can add + ping.`, - ); - } - } - - if (cfg.discord?.enabled !== false) { - await warnDmPolicy({ - label: "Discord", - provider: "discord", - dmPolicy: cfg.discord?.dm?.policy ?? "pairing", - allowFrom: cfg.discord?.dm?.allowFrom ?? [], - allowFromPath: "discord.dm.", - approveHint: - "Approve via: clawdbot pairing list --provider discord / clawdbot pairing approve --provider discord ", - normalizeEntry: (raw) => - raw.replace(/^(discord|user):/i, "").replace(/^<@!?(\d+)>$/, "$1"), - }); - } - - if (cfg.slack?.enabled !== false) { - await warnDmPolicy({ - label: "Slack", - provider: "slack", - dmPolicy: cfg.slack?.dm?.policy ?? "pairing", - allowFrom: cfg.slack?.dm?.allowFrom ?? [], - allowFromPath: "slack.dm.", - approveHint: - "Approve via: clawdbot pairing list --provider slack / clawdbot pairing approve --provider slack ", - normalizeEntry: (raw) => raw.replace(/^(slack|user):/i, ""), - }); - } - - if (cfg.signal?.enabled !== false) { - await warnDmPolicy({ - label: "Signal", - provider: "signal", - dmPolicy: cfg.signal?.dmPolicy ?? "pairing", - allowFrom: cfg.signal?.allowFrom ?? [], - allowFromPath: "signal.", - approveHint: - "Approve via: clawdbot pairing list --provider signal / clawdbot pairing approve --provider signal ", - normalizeEntry: (raw) => - normalizeE164(raw.replace(/^signal:/i, "").trim()), - }); - } - - if (cfg.imessage?.enabled !== false) { - await warnDmPolicy({ - label: "iMessage", - provider: "imessage", - dmPolicy: cfg.imessage?.dmPolicy ?? "pairing", - allowFrom: cfg.imessage?.allowFrom ?? [], - allowFromPath: "imessage.", - approveHint: - "Approve via: clawdbot pairing list --provider imessage / clawdbot pairing approve --provider imessage ", - }); - } - - if (cfg.whatsapp) { - await warnDmPolicy({ - label: "WhatsApp", - provider: "whatsapp", - dmPolicy: cfg.whatsapp?.dmPolicy ?? "pairing", - allowFrom: cfg.whatsapp?.allowFrom ?? [], - allowFromPath: "whatsapp.", - approveHint: - "Approve via: clawdbot pairing list --provider whatsapp / clawdbot pairing approve --provider whatsapp ", - normalizeEntry: (raw) => normalizeE164(raw), - }); - } - - if (warnings.length > 0) { - note(warnings.join("\n"), "Security"); - } -} - -function normalizeDefaultWorkspacePath( - value: string | undefined, -): string | undefined { - if (!value) return value; - - const resolved = resolveUserPath(value); - const home = os.homedir(); - - const next = [ - ["clawdis", "clawd"], - ["clawdbot", "clawd"], - ].reduce((acc, [from, to]) => { - const fromPrefix = path.join(home, from); - if (acc === fromPrefix) return path.join(home, to); - const withSep = `${fromPrefix}${path.sep}`; - if (acc.startsWith(withSep)) { - return path.join(home, to).concat(acc.slice(fromPrefix.length)); - } - return acc; - }, resolved); - - return next === resolved ? value : next; -} - -function replaceLegacyName(value: string | undefined): string | undefined { - if (!value) return value; - const replacedClawdis = value.replace(/clawdis/g, "clawdbot"); - return replacedClawdis.replace(/clawd(?!bot)/g, "clawdbot"); -} - -function replaceModernName(value: string | undefined): string | undefined { - if (!value) return value; - if (!value.includes("clawdbot")) return value; - return value.replace(/clawdbot/g, "clawdis"); -} - -type SandboxScriptInfo = { - scriptPath: string; - cwd: string; -}; - -function resolveSandboxScript(scriptRel: string): SandboxScriptInfo | null { - const candidates = new Set(); - candidates.add(process.cwd()); - const argv1 = process.argv[1]; - if (argv1) { - const normalized = path.resolve(argv1); - candidates.add(path.resolve(path.dirname(normalized), "..")); - candidates.add(path.resolve(path.dirname(normalized))); - } - - for (const root of candidates) { - const scriptPath = path.join(root, scriptRel); - if (fs.existsSync(scriptPath)) { - return { scriptPath, cwd: root }; - } - } - - return null; -} - -async function runSandboxScript( - scriptRel: string, - runtime: RuntimeEnv, -): Promise { - const script = resolveSandboxScript(scriptRel); - if (!script) { - note( - `Unable to locate ${scriptRel}. Run it from the repo root.`, - "Sandbox", - ); - return false; - } - - runtime.log(`Running ${scriptRel}...`); - const result = await runCommandWithTimeout(["bash", script.scriptPath], { - timeoutMs: 20 * 60 * 1000, - cwd: script.cwd, - }); - if (result.code !== 0) { - runtime.error( - `Failed running ${scriptRel}: ${ - result.stderr.trim() || result.stdout.trim() || "unknown error" - }`, - ); - return false; - } - - runtime.log(`Completed ${scriptRel}.`); - return true; -} - -type DoctorOptions = { - workspaceSuggestions?: boolean; - yes?: boolean; - nonInteractive?: boolean; - deep?: boolean; -}; - -type DoctorPrompter = { - confirm: (params: Parameters[0]) => Promise; - confirmSkipInNonInteractive: ( - params: Parameters[0], - ) => Promise; - select: (params: Parameters[0], fallback: T) => Promise; -}; - -function createDoctorPrompter(params: { - runtime: RuntimeEnv; - options: DoctorOptions; -}): DoctorPrompter { - const yes = params.options.yes === true; - const requestedNonInteractive = params.options.nonInteractive === true; - const isTty = Boolean(process.stdin.isTTY); - const nonInteractive = requestedNonInteractive || (!isTty && !yes); - - const canPrompt = isTty && !yes && !nonInteractive; - const confirmDefault = async (p: Parameters[0]) => { - if (!canPrompt) return Boolean(p.initialValue ?? false); - return guardCancel(await confirm(p), params.runtime) === true; - }; - - return { - confirm: confirmDefault, - confirmSkipInNonInteractive: async (p) => { - if (nonInteractive) return false; - return confirmDefault(p); - }, - select: async (p: Parameters[0], fallback: T) => { - if (!canPrompt) return fallback; - return guardCancel(await select(p), params.runtime) as T; - }, - }; -} - -async function maybeRepairAnthropicOAuthProfileId( - cfg: ClawdbotConfig, - prompter: DoctorPrompter, -): Promise { - const store = ensureAuthProfileStore(); - const repair = repairOAuthProfileIdMismatch({ - cfg, - store, - provider: "anthropic", - legacyProfileId: "anthropic:default", - }); - if (!repair.migrated || repair.changes.length === 0) return cfg; - - note(repair.changes.map((c) => `- ${c}`).join("\n"), "Auth profiles"); - const apply = await prompter.confirm({ - message: "Update Anthropic OAuth profile id in config now?", - initialValue: true, - }); - if (!apply) return cfg; - return repair.config; -} - -const MEMORY_SYSTEM_PROMPT = [ - "Memory system not found in workspace.", - "Paste this into your agent:", - "", - "Install the memory system by applying:", - "https://github.com/clawdbot/clawdbot/commit/9ffea23f31ca1df5183b25668f8f814bee0fb34e", - "https://github.com/clawdbot/clawdbot/commit/7d1fee70e76f2f634f1b41fca927ee663914183a", -].join("\n"); - -async function shouldSuggestMemorySystem( - workspaceDir: string, -): Promise { - const memoryPaths = [ - path.join(workspaceDir, "MEMORY.md"), - path.join(workspaceDir, "memory.md"), - ]; - - for (const memoryPath of memoryPaths) { - try { - await fs.promises.access(memoryPath); - return false; - } catch { - // keep scanning - } - } - - const agentsPath = path.join(workspaceDir, DEFAULT_AGENTS_FILENAME); - try { - const content = await fs.promises.readFile(agentsPath, "utf-8"); - if (/memory\.md/i.test(content)) return false; - } catch { - // no AGENTS.md or unreadable; treat as missing memory guidance - } - - return true; -} - -async function isDockerAvailable(): Promise { - try { - await runExec("docker", ["version", "--format", "{{.Server.Version}}"], { - timeoutMs: 5_000, - }); - return true; - } catch { - return false; - } -} - -async function dockerImageExists(image: string): Promise { - try { - await runExec("docker", ["image", "inspect", image], { timeoutMs: 5_000 }); - return true; - } catch { - return false; - } -} - -function resolveSandboxDockerImage(cfg: ClawdbotConfig): string { - const image = cfg.agent?.sandbox?.docker?.image?.trim(); - return image ? image : DEFAULT_SANDBOX_IMAGE; -} - -function resolveSandboxBrowserImage(cfg: ClawdbotConfig): string { - const image = cfg.agent?.sandbox?.browser?.image?.trim(); - return image ? image : DEFAULT_SANDBOX_BROWSER_IMAGE; -} - -function updateSandboxDockerImage( - cfg: ClawdbotConfig, - image: string, -): ClawdbotConfig { - return { - ...cfg, - agent: { - ...cfg.agent, - sandbox: { - ...cfg.agent?.sandbox, - docker: { - ...cfg.agent?.sandbox?.docker, - image, - }, - }, - }, - }; -} - -function updateSandboxBrowserImage( - cfg: ClawdbotConfig, - image: string, -): ClawdbotConfig { - return { - ...cfg, - agent: { - ...cfg.agent, - sandbox: { - ...cfg.agent?.sandbox, - browser: { - ...cfg.agent?.sandbox?.browser, - image, - }, - }, - }, - }; -} - -type SandboxImageCheck = { - label: string; - image: string; - buildScript?: string; - updateConfig: (image: string) => void; -}; - -async function handleMissingSandboxImage( - params: SandboxImageCheck, - runtime: RuntimeEnv, - prompter: DoctorPrompter, -) { - const exists = await dockerImageExists(params.image); - if (exists) return; - - const buildHint = params.buildScript - ? `Build it with ${params.buildScript}.` - : "Build or pull it first."; - note( - `Sandbox ${params.label} image missing: ${params.image}. ${buildHint}`, - "Sandbox", - ); - - let built = false; - if (params.buildScript) { - const build = await prompter.confirmSkipInNonInteractive({ - message: `Build ${params.label} sandbox image now?`, - initialValue: true, - }); - if (build) { - built = await runSandboxScript(params.buildScript, runtime); - } - } - - if (built) return; - - const legacyImage = replaceModernName(params.image); - if (!legacyImage || legacyImage === params.image) return; - const legacyExists = await dockerImageExists(legacyImage); - if (!legacyExists) return; - - const fallback = await prompter.confirmSkipInNonInteractive({ - message: `Switch config to legacy image ${legacyImage}?`, - initialValue: false, - }); - if (!fallback) return; - - params.updateConfig(legacyImage); -} - -async function maybeRepairSandboxImages( - cfg: ClawdbotConfig, - runtime: RuntimeEnv, - prompter: DoctorPrompter, -): Promise { - const sandbox = cfg.agent?.sandbox; - const mode = sandbox?.mode ?? "off"; - if (!sandbox || mode === "off") return cfg; - - const dockerAvailable = await isDockerAvailable(); - if (!dockerAvailable) { - note("Docker not available; skipping sandbox image checks.", "Sandbox"); - return cfg; - } - - let next = cfg; - const changes: string[] = []; - - const dockerImage = resolveSandboxDockerImage(cfg); - await handleMissingSandboxImage( - { - label: "base", - image: dockerImage, - buildScript: - dockerImage === DEFAULT_SANDBOX_COMMON_IMAGE - ? "scripts/sandbox-common-setup.sh" - : dockerImage === DEFAULT_SANDBOX_IMAGE - ? "scripts/sandbox-setup.sh" - : undefined, - updateConfig: (image) => { - next = updateSandboxDockerImage(next, image); - changes.push(`Updated agent.sandbox.docker.image → ${image}`); - }, - }, - runtime, - prompter, - ); - - if (sandbox.browser?.enabled) { - await handleMissingSandboxImage( - { - label: "browser", - image: resolveSandboxBrowserImage(cfg), - buildScript: "scripts/sandbox-browser-setup.sh", - updateConfig: (image) => { - next = updateSandboxBrowserImage(next, image); - changes.push(`Updated agent.sandbox.browser.image → ${image}`); - }, - }, - runtime, - prompter, - ); - } - - if (changes.length > 0) { - note(changes.join("\n"), "Doctor changes"); - } - - return next; -} - -function normalizeLegacyConfigValues(cfg: ClawdbotConfig): { - config: ClawdbotConfig; - changes: string[]; -} { - const changes: string[] = []; - let next: ClawdbotConfig = cfg; - - const workspace = cfg.agent?.workspace; - const updatedWorkspace = normalizeDefaultWorkspacePath(workspace); - if (updatedWorkspace && updatedWorkspace !== workspace) { - next = { - ...next, - agent: { - ...next.agent, - workspace: updatedWorkspace, - }, - }; - changes.push(`Updated agent.workspace → ${updatedWorkspace}`); - } - - const workspaceRoot = cfg.agent?.sandbox?.workspaceRoot; - const updatedWorkspaceRoot = normalizeDefaultWorkspacePath(workspaceRoot); - if (updatedWorkspaceRoot && updatedWorkspaceRoot !== workspaceRoot) { - next = { - ...next, - agent: { - ...next.agent, - sandbox: { - ...next.agent?.sandbox, - workspaceRoot: updatedWorkspaceRoot, - }, - }, - }; - changes.push( - `Updated agent.sandbox.workspaceRoot → ${updatedWorkspaceRoot}`, - ); - } - - const dockerImage = cfg.agent?.sandbox?.docker?.image; - const updatedDockerImage = replaceLegacyName(dockerImage); - if (updatedDockerImage && updatedDockerImage !== dockerImage) { - next = { - ...next, - agent: { - ...next.agent, - sandbox: { - ...next.agent?.sandbox, - docker: { - ...next.agent?.sandbox?.docker, - image: updatedDockerImage, - }, - }, - }, - }; - changes.push(`Updated agent.sandbox.docker.image → ${updatedDockerImage}`); - } - - const containerPrefix = cfg.agent?.sandbox?.docker?.containerPrefix; - const updatedContainerPrefix = replaceLegacyName(containerPrefix); - if (updatedContainerPrefix && updatedContainerPrefix !== containerPrefix) { - next = { - ...next, - agent: { - ...next.agent, - sandbox: { - ...next.agent?.sandbox, - docker: { - ...next.agent?.sandbox?.docker, - containerPrefix: updatedContainerPrefix, - }, - }, - }, - }; - changes.push( - `Updated agent.sandbox.docker.containerPrefix → ${updatedContainerPrefix}`, - ); - } - - return { config: next, changes }; -} - -async function maybeMigrateLegacyConfigFile(runtime: RuntimeEnv) { - const legacyConfigPath = resolveLegacyConfigPath(process.env); - if (legacyConfigPath === CONFIG_PATH_CLAWDBOT) return; - - const legacyIo = createConfigIO({ configPath: legacyConfigPath }); - const legacySnapshot = await legacyIo.readConfigFileSnapshot(); - if (!legacySnapshot.exists) return; - - const currentSnapshot = await readConfigFileSnapshot(); - if (currentSnapshot.exists) { - note( - `Legacy config still exists at ${legacyConfigPath}. Current config at ${CONFIG_PATH_CLAWDBOT}.`, - "Legacy config", - ); - return; - } - - const gatewayMode = - typeof (legacySnapshot.parsed as ClawdbotConfig)?.gateway?.mode === "string" - ? (legacySnapshot.parsed as ClawdbotConfig).gateway?.mode - : undefined; - const gatewayBind = - typeof (legacySnapshot.parsed as ClawdbotConfig)?.gateway?.bind === "string" - ? (legacySnapshot.parsed as ClawdbotConfig).gateway?.bind - : undefined; - const agentWorkspace = - typeof (legacySnapshot.parsed as ClawdbotConfig)?.agent?.workspace === - "string" - ? (legacySnapshot.parsed as ClawdbotConfig).agent?.workspace - : undefined; - - note( - [ - `- File exists at ${legacyConfigPath}`, - gatewayMode ? `- gateway.mode: ${gatewayMode}` : undefined, - gatewayBind ? `- gateway.bind: ${gatewayBind}` : undefined, - agentWorkspace ? `- agent.workspace: ${agentWorkspace}` : undefined, - ] - .filter(Boolean) - .join("\n"), - "Legacy Clawdis config detected", - ); - - let nextConfig = legacySnapshot.valid ? legacySnapshot.config : null; - const { config: migratedConfig, changes } = migrateLegacyConfig( - legacySnapshot.parsed, - ); - if (migratedConfig) { - nextConfig = migratedConfig; - } else if (!nextConfig) { - note( - `Legacy config at ${legacyConfigPath} is invalid; skipping migration.`, - "Legacy config", - ); - return; - } - - const normalized = normalizeLegacyConfigValues(nextConfig); - const mergedChanges = [...changes, ...normalized.changes]; - if (mergedChanges.length > 0) { - note(mergedChanges.join("\n"), "Doctor changes"); - } - - await writeConfigFile(normalized.config); - runtime.log(`Migrated legacy config to ${CONFIG_PATH_CLAWDBOT}`); -} - -async function maybeMigrateLegacyGatewayService( - cfg: ClawdbotConfig, - runtime: RuntimeEnv, - prompter: DoctorPrompter, -) { - const legacyServices = await findLegacyGatewayServices(process.env); - if (legacyServices.length === 0) return; - - note( - legacyServices - .map((svc) => `- ${svc.label} (${svc.platform}, ${svc.detail})`) - .join("\n"), - "Legacy Clawdis services detected", - ); - - const migrate = await prompter.confirmSkipInNonInteractive({ - message: "Migrate legacy Clawdis services to Clawdbot now?", - initialValue: true, - }); - if (!migrate) return; - - try { - await uninstallLegacyGatewayServices({ - env: process.env, - stdout: process.stdout, - }); - } catch (err) { - runtime.error(`Legacy service cleanup failed: ${String(err)}`); - return; - } - - if (resolveIsNixMode(process.env)) { - note("Nix mode detected; skip installing services.", "Gateway"); - return; - } - - if (resolveMode(cfg) === "remote") { - note("Gateway mode is remote; skipped local service install.", "Gateway"); - return; - } - - const service = resolveGatewayService(); - const loaded = await service.isLoaded({ env: process.env }); - if (loaded) { - note(`Clawdbot ${service.label} already ${service.loadedText}.`, "Gateway"); - return; - } - - const install = await prompter.confirmSkipInNonInteractive({ - message: "Install Clawdbot gateway service now?", - initialValue: true, - }); - if (!install) return; - - const daemonRuntime = await prompter.select( - { - message: "Gateway daemon runtime", - options: GATEWAY_DAEMON_RUNTIME_OPTIONS, - initialValue: DEFAULT_GATEWAY_DAEMON_RUNTIME, - }, - DEFAULT_GATEWAY_DAEMON_RUNTIME, - ); - const devMode = - process.argv[1]?.includes(`${path.sep}src${path.sep}`) && - process.argv[1]?.endsWith(".ts"); - const port = resolveGatewayPort(cfg, process.env); - const { programArguments, workingDirectory } = - await resolveGatewayProgramArguments({ - port, - dev: devMode, - runtime: daemonRuntime, - }); - const environment: Record = { - PATH: process.env.PATH, - CLAWDBOT_GATEWAY_TOKEN: - cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN, - CLAWDBOT_LAUNCHD_LABEL: - process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined, - }; - await service.install({ - env: process.env, - stdout: process.stdout, - programArguments, - workingDirectory, - environment, - }); -} - -async function maybeScanExtraGatewayServices(options: DoctorOptions) { - const extraServices = await findExtraGatewayServices(process.env, { - deep: options.deep, - }); - if (extraServices.length === 0) return; - - note( - extraServices - .map((svc) => `- ${svc.label} (${svc.scope}, ${svc.detail})`) - .join("\n"), - "Other gateway-like services detected", - ); - - const cleanupHints = renderGatewayServiceCleanupHints(); - if (cleanupHints.length > 0) { - note(cleanupHints.map((hint) => `- ${hint}`).join("\n"), "Cleanup hints"); - } - - note( - [ - "Recommendation: run a single gateway per machine.", - "One gateway supports multiple agents.", - "If you need multiple gateways, isolate ports + config/state (see docs: /gateway#multiple-gateways-same-host).", - ].join("\n"), - "Gateway recommendation", - ); -} - export async function doctorCommand( runtime: RuntimeEnv = defaultRuntime, options: DoctorOptions = {}, @@ -1014,23 +128,16 @@ export async function doctorCommand( cfg = await maybeRepairSandboxImages(cfg, runtime, prompter); - await maybeMigrateLegacyGatewayService(cfg, runtime, prompter); + await maybeMigrateLegacyGatewayService( + cfg, + resolveMode(cfg), + runtime, + prompter, + ); await maybeScanExtraGatewayServices(options); await noteSecurityWarnings(cfg); - const sharedOverrideWarnings = collectSandboxSharedOverrideWarnings(cfg); - if (sharedOverrideWarnings.length > 0) { - note( - [ - ...sharedOverrideWarnings, - "", - 'Fix: set scope to "agent"/"session", or move the config to agent.sandbox.{docker,browser,prune} (global).', - ].join("\n"), - "Sandbox", - ); - } - if ( options.nonInteractive !== true && process.platform === "linux" &&