diff --git a/docs/cli/index.md b/docs/cli/index.md index 0de6e00ff..d7c4faca0 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -176,10 +176,13 @@ Interactive configuration wizard (models, providers, skills, gateway). Audit and modernize the local configuration. ### `doctor` -Health checks + quick fixes. +Health checks + quick fixes (config + gateway + legacy services). Options: - `--no-workspace-suggestions`: disable workspace memory hints. +- `--yes`: accept defaults without prompting (headless). +- `--non-interactive`: skip prompts; apply safe migrations only. +- `--deep`: scan system services for extra gateway installs. ## Auth + provider helpers diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 38e9ee334..4075b88a3 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -15,6 +15,7 @@ read_when: - Migrates legacy `~/.clawdis/clawdis.json` when no Clawdbot config exists. - Checks sandbox Docker images when sandboxing is enabled (offers to build or switch to legacy names). - Detects legacy Clawdis services (launchd/systemd; legacy schtasks for native Windows) and offers to migrate them. +- Detects other gateway-like services and prints cleanup hints (optional deep scan for system services). - On Linux, checks if systemd user lingering is enabled and can enable it (required to keep the Gateway alive after logout). - Migrates legacy on-disk state layouts (sessions, agentDir, provider auth dirs) into the current per-agent/per-account structure. @@ -70,6 +71,12 @@ clawdbot doctor --non-interactive Run without prompts and only apply safe migrations (config normalization + on-disk state moves). Skips restart/service/sandbox actions that require human confirmation. +```bash +clawdbot doctor --deep +``` + +Scan system services for extra gateway installs (launchd/systemd/schtasks). + If you want to review changes before writing, open the config file first: ```bash diff --git a/src/cli/daemon-cli.ts b/src/cli/daemon-cli.ts index 92532726b..0d6403482 100644 --- a/src/cli/daemon-cli.ts +++ b/src/cli/daemon-cli.ts @@ -15,6 +15,7 @@ import { import { type FindExtraGatewayServicesOptions, findExtraGatewayServices, + renderGatewayServiceCleanupHints, } from "../daemon/inspect.js"; import { findLegacyGatewayServices } from "../daemon/legacy.js"; import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; @@ -110,25 +111,6 @@ function renderGatewayServiceStartHints(): string[] { } } -function renderGatewayServiceCleanupHints(): string[] { - switch (process.platform) { - case "darwin": - return [ - `launchctl bootout gui/$UID/${GATEWAY_LAUNCH_AGENT_LABEL}`, - `rm ~/Library/LaunchAgents/${GATEWAY_LAUNCH_AGENT_LABEL}.plist`, - ]; - case "linux": - return [ - `systemctl --user disable --now ${GATEWAY_SYSTEMD_SERVICE_NAME}.service`, - `rm ~/.config/systemd/user/${GATEWAY_SYSTEMD_SERVICE_NAME}.service`, - ]; - case "win32": - return [`schtasks /Delete /TN "${GATEWAY_WINDOWS_TASK_NAME}" /F`]; - default: - return []; - } -} - async function gatherDaemonStatus(opts: { rpc: GatewayRpcOpts; probe: boolean; diff --git a/src/cli/program.ts b/src/cli/program.ts index 196c506e2..bf315f06f 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -324,12 +324,14 @@ export function buildProgram() { "Run without prompts (safe migrations only)", false, ) + .option("--deep", "Scan system services for extra gateway installs", false) .action(async (opts) => { try { await doctorCommand(defaultRuntime, { workspaceSuggestions: opts.workspaceSuggestions, yes: Boolean(opts.yes), nonInteractive: Boolean(opts.nonInteractive), + deep: Boolean(opts.deep), }); } catch (err) { defaultRuntime.error(String(err)); diff --git a/src/commands/doctor.test.ts b/src/commands/doctor.test.ts index db25b6962..e48a01ce2 100644 --- a/src/commands/doctor.test.ts +++ b/src/commands/doctor.test.ts @@ -60,6 +60,8 @@ const createConfigIO = vi.fn(() => ({ const findLegacyGatewayServices = vi.fn().mockResolvedValue([]); const uninstallLegacyGatewayServices = vi.fn().mockResolvedValue([]); +const findExtraGatewayServices = vi.fn().mockResolvedValue([]); +const renderGatewayServiceCleanupHints = vi.fn().mockReturnValue(["cleanup"]); const resolveGatewayProgramArguments = vi.fn().mockResolvedValue({ programArguments: ["node", "cli", "gateway-daemon", "--port", "18789"], }); @@ -98,6 +100,11 @@ vi.mock("../daemon/legacy.js", () => ({ uninstallLegacyGatewayServices, })); +vi.mock("../daemon/inspect.js", () => ({ + findExtraGatewayServices, + renderGatewayServiceCleanupHints, +})); + vi.mock("../daemon/program-args.js", () => ({ resolveGatewayProgramArguments, })); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 4e958c4a7..b1027bfc3 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -24,6 +24,10 @@ import { } 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, @@ -351,6 +355,7 @@ type DoctorOptions = { workspaceSuggestions?: boolean; yes?: boolean; nonInteractive?: boolean; + deep?: boolean; }; type DoctorPrompter = { @@ -863,6 +868,34 @@ async function maybeMigrateLegacyGatewayService( }); } +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 = {}, @@ -939,6 +972,7 @@ export async function doctorCommand( cfg = await maybeRepairSandboxImages(cfg, runtime, prompter); await maybeMigrateLegacyGatewayService(cfg, runtime, prompter); + await maybeScanExtraGatewayServices(options); await noteSecurityWarnings(cfg); diff --git a/src/daemon/inspect.ts b/src/daemon/inspect.ts index 3f8ce4c97..327ca3702 100644 --- a/src/daemon/inspect.ts +++ b/src/daemon/inspect.ts @@ -26,6 +26,25 @@ export type FindExtraGatewayServicesOptions = { const EXTRA_MARKERS = ["clawdbot", "clawdis", "gateway-daemon"]; const execFileAsync = promisify(execFile); +export function renderGatewayServiceCleanupHints(): string[] { + switch (process.platform) { + case "darwin": + return [ + `launchctl bootout gui/$UID/${GATEWAY_LAUNCH_AGENT_LABEL}`, + `rm ~/Library/LaunchAgents/${GATEWAY_LAUNCH_AGENT_LABEL}.plist`, + ]; + case "linux": + return [ + `systemctl --user disable --now ${GATEWAY_SYSTEMD_SERVICE_NAME}.service`, + `rm ~/.config/systemd/user/${GATEWAY_SYSTEMD_SERVICE_NAME}.service`, + ]; + case "win32": + return [`schtasks /Delete /TN "${GATEWAY_WINDOWS_TASK_NAME}" /F`]; + default: + return []; + } +} + function resolveHomeDir(env: Record): string { const home = env.HOME?.trim() || env.USERPROFILE?.trim(); if (!home) throw new Error("Missing HOME");