feat: scan extra gateways in doctor

This commit is contained in:
Peter Steinberger
2026-01-07 22:31:08 +01:00
parent e70ff671f5
commit 52e3d28ef4
7 changed files with 74 additions and 20 deletions

View File

@@ -176,10 +176,13 @@ Interactive configuration wizard (models, providers, skills, gateway).
Audit and modernize the local configuration. Audit and modernize the local configuration.
### `doctor` ### `doctor`
Health checks + quick fixes. Health checks + quick fixes (config + gateway + legacy services).
Options: Options:
- `--no-workspace-suggestions`: disable workspace memory hints. - `--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 ## Auth + provider helpers

View File

@@ -15,6 +15,7 @@ read_when:
- Migrates legacy `~/.clawdis/clawdis.json` when no Clawdbot config exists. - 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). - 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 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). - 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. - 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. 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: If you want to review changes before writing, open the config file first:
```bash ```bash

View File

@@ -15,6 +15,7 @@ import {
import { import {
type FindExtraGatewayServicesOptions, type FindExtraGatewayServicesOptions,
findExtraGatewayServices, findExtraGatewayServices,
renderGatewayServiceCleanupHints,
} from "../daemon/inspect.js"; } from "../daemon/inspect.js";
import { findLegacyGatewayServices } from "../daemon/legacy.js"; import { findLegacyGatewayServices } from "../daemon/legacy.js";
import { resolveGatewayProgramArguments } from "../daemon/program-args.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: { async function gatherDaemonStatus(opts: {
rpc: GatewayRpcOpts; rpc: GatewayRpcOpts;
probe: boolean; probe: boolean;

View File

@@ -324,12 +324,14 @@ export function buildProgram() {
"Run without prompts (safe migrations only)", "Run without prompts (safe migrations only)",
false, false,
) )
.option("--deep", "Scan system services for extra gateway installs", false)
.action(async (opts) => { .action(async (opts) => {
try { try {
await doctorCommand(defaultRuntime, { await doctorCommand(defaultRuntime, {
workspaceSuggestions: opts.workspaceSuggestions, workspaceSuggestions: opts.workspaceSuggestions,
yes: Boolean(opts.yes), yes: Boolean(opts.yes),
nonInteractive: Boolean(opts.nonInteractive), nonInteractive: Boolean(opts.nonInteractive),
deep: Boolean(opts.deep),
}); });
} catch (err) { } catch (err) {
defaultRuntime.error(String(err)); defaultRuntime.error(String(err));

View File

@@ -60,6 +60,8 @@ const createConfigIO = vi.fn(() => ({
const findLegacyGatewayServices = vi.fn().mockResolvedValue([]); const findLegacyGatewayServices = vi.fn().mockResolvedValue([]);
const uninstallLegacyGatewayServices = vi.fn().mockResolvedValue([]); const uninstallLegacyGatewayServices = vi.fn().mockResolvedValue([]);
const findExtraGatewayServices = vi.fn().mockResolvedValue([]);
const renderGatewayServiceCleanupHints = vi.fn().mockReturnValue(["cleanup"]);
const resolveGatewayProgramArguments = vi.fn().mockResolvedValue({ const resolveGatewayProgramArguments = vi.fn().mockResolvedValue({
programArguments: ["node", "cli", "gateway-daemon", "--port", "18789"], programArguments: ["node", "cli", "gateway-daemon", "--port", "18789"],
}); });
@@ -98,6 +100,11 @@ vi.mock("../daemon/legacy.js", () => ({
uninstallLegacyGatewayServices, uninstallLegacyGatewayServices,
})); }));
vi.mock("../daemon/inspect.js", () => ({
findExtraGatewayServices,
renderGatewayServiceCleanupHints,
}));
vi.mock("../daemon/program-args.js", () => ({ vi.mock("../daemon/program-args.js", () => ({
resolveGatewayProgramArguments, resolveGatewayProgramArguments,
})); }));

View File

@@ -24,6 +24,10 @@ import {
} from "../config/config.js"; } from "../config/config.js";
import { resolveGatewayPort, resolveIsNixMode } from "../config/paths.js"; import { resolveGatewayPort, resolveIsNixMode } from "../config/paths.js";
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js"; import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
import {
findExtraGatewayServices,
renderGatewayServiceCleanupHints,
} from "../daemon/inspect.js";
import { import {
findLegacyGatewayServices, findLegacyGatewayServices,
uninstallLegacyGatewayServices, uninstallLegacyGatewayServices,
@@ -351,6 +355,7 @@ type DoctorOptions = {
workspaceSuggestions?: boolean; workspaceSuggestions?: boolean;
yes?: boolean; yes?: boolean;
nonInteractive?: boolean; nonInteractive?: boolean;
deep?: boolean;
}; };
type DoctorPrompter = { 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( export async function doctorCommand(
runtime: RuntimeEnv = defaultRuntime, runtime: RuntimeEnv = defaultRuntime,
options: DoctorOptions = {}, options: DoctorOptions = {},
@@ -939,6 +972,7 @@ export async function doctorCommand(
cfg = await maybeRepairSandboxImages(cfg, runtime, prompter); cfg = await maybeRepairSandboxImages(cfg, runtime, prompter);
await maybeMigrateLegacyGatewayService(cfg, runtime, prompter); await maybeMigrateLegacyGatewayService(cfg, runtime, prompter);
await maybeScanExtraGatewayServices(options);
await noteSecurityWarnings(cfg); await noteSecurityWarnings(cfg);

View File

@@ -26,6 +26,25 @@ export type FindExtraGatewayServicesOptions = {
const EXTRA_MARKERS = ["clawdbot", "clawdis", "gateway-daemon"]; const EXTRA_MARKERS = ["clawdbot", "clawdis", "gateway-daemon"];
const execFileAsync = promisify(execFile); 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, string | undefined>): string { function resolveHomeDir(env: Record<string, string | undefined>): string {
const home = env.HOME?.trim() || env.USERPROFILE?.trim(); const home = env.HOME?.trim() || env.USERPROFILE?.trim();
if (!home) throw new Error("Missing HOME"); if (!home) throw new Error("Missing HOME");