Add runtime quiting functionality to doctor.ts

This commit is contained in:
gleb
2026-02-16 12:05:17 -08:00
committed by Peter Steinberger
parent 2540417170
commit 78c34bcf33

View File

@@ -6,9 +6,9 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import { loadModelCatalog } from "../agents/model-catalog.js"; import { loadModelCatalog } from "../agents/model-catalog.js";
import { import {
getModelRefStatus, getModelRefStatus,
resolveConfiguredModelRef, resolveConfiguredModelRef,
resolveHooksGmailModel, resolveHooksGmailModel,
} from "../agents/model-selection.js"; } from "../agents/model-selection.js";
import { formatCliCommand } from "../cli/command-format.js"; import { formatCliCommand } from "../cli/command-format.js";
import { CONFIG_PATH, readConfigFileSnapshot, writeConfigFile } from "../config/config.js"; import { CONFIG_PATH, readConfigFileSnapshot, writeConfigFile } from "../config/config.js";
@@ -22,32 +22,32 @@ import { note } from "../terminal/note.js";
import { stylePromptTitle } from "../terminal/prompt-style.js"; import { stylePromptTitle } from "../terminal/prompt-style.js";
import { shortenHomePath } from "../utils.js"; import { shortenHomePath } from "../utils.js";
import { import {
maybeRemoveDeprecatedCliAuthProfiles, maybeRemoveDeprecatedCliAuthProfiles,
maybeRepairAnthropicOAuthProfileId, maybeRepairAnthropicOAuthProfileId,
noteAuthProfileHealth, noteAuthProfileHealth,
} from "./doctor-auth.js"; } from "./doctor-auth.js";
import { doctorShellCompletion } from "./doctor-completion.js"; import { doctorShellCompletion } from "./doctor-completion.js";
import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js"; import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
import { maybeRepairGatewayDaemon } from "./doctor-gateway-daemon-flow.js"; import { maybeRepairGatewayDaemon } from "./doctor-gateway-daemon-flow.js";
import { checkGatewayHealth } from "./doctor-gateway-health.js"; import { checkGatewayHealth } from "./doctor-gateway-health.js";
import { import {
maybeRepairGatewayServiceConfig, maybeRepairGatewayServiceConfig,
maybeScanExtraGatewayServices, maybeScanExtraGatewayServices,
} from "./doctor-gateway-services.js"; } from "./doctor-gateway-services.js";
import { noteSourceInstallIssues } from "./doctor-install.js"; import { noteSourceInstallIssues } from "./doctor-install.js";
import { noteMemorySearchHealth } from "./doctor-memory-search.js"; import { noteMemorySearchHealth } from "./doctor-memory-search.js";
import { import {
noteMacLaunchAgentOverrides, noteMacLaunchAgentOverrides,
noteMacLaunchctlGatewayEnvOverrides, noteMacLaunchctlGatewayEnvOverrides,
noteDeprecatedLegacyEnvVars, noteDeprecatedLegacyEnvVars,
} from "./doctor-platform-notes.js"; } from "./doctor-platform-notes.js";
import { createDoctorPrompter, type DoctorOptions } from "./doctor-prompter.js"; import { createDoctorPrompter, type DoctorOptions } from "./doctor-prompter.js";
import { maybeRepairSandboxImages, noteSandboxScopeWarnings } from "./doctor-sandbox.js"; import { maybeRepairSandboxImages, noteSandboxScopeWarnings } from "./doctor-sandbox.js";
import { noteSecurityWarnings } from "./doctor-security.js"; import { noteSecurityWarnings } from "./doctor-security.js";
import { noteStateIntegrity, noteWorkspaceBackupTip } from "./doctor-state-integrity.js"; import { noteStateIntegrity, noteWorkspaceBackupTip } from "./doctor-state-integrity.js";
import { import {
detectLegacyStateMigrations, detectLegacyStateMigrations,
runLegacyStateMigrations, runLegacyStateMigrations,
} from "./doctor-state-migrations.js"; } from "./doctor-state-migrations.js";
import { maybeRepairUiProtocolFreshness } from "./doctor-ui.js"; import { maybeRepairUiProtocolFreshness } from "./doctor-ui.js";
import { maybeOfferUpdateBeforeDoctor } from "./doctor-update.js"; import { maybeOfferUpdateBeforeDoctor } from "./doctor-update.js";
@@ -60,257 +60,257 @@ const intro = (message: string) => clackIntro(stylePromptTitle(message) ?? messa
const outro = (message: string) => clackOutro(stylePromptTitle(message) ?? message); const outro = (message: string) => clackOutro(stylePromptTitle(message) ?? message);
function resolveMode(cfg: OpenClawConfig): "local" | "remote" { function resolveMode(cfg: OpenClawConfig): "local" | "remote" {
return cfg.gateway?.mode === "remote" ? "remote" : "local"; return cfg.gateway?.mode === "remote" ? "remote" : "local";
} }
export async function doctorCommand( export async function doctorCommand(
runtime: RuntimeEnv = defaultRuntime, runtime: RuntimeEnv = defaultRuntime,
options: DoctorOptions = {}, options: DoctorOptions = {},
) { ) {
const prompter = createDoctorPrompter({ runtime, options }); const prompter = createDoctorPrompter({ runtime, options });
printWizardHeader(runtime); printWizardHeader(runtime);
intro("OpenClaw doctor"); intro("OpenClaw doctor");
const root = await resolveOpenClawPackageRoot({ const root = await resolveOpenClawPackageRoot({
moduleUrl: import.meta.url, moduleUrl: import.meta.url,
argv1: process.argv[1], argv1: process.argv[1],
cwd: process.cwd(), cwd: process.cwd(),
}); });
const updateResult = await maybeOfferUpdateBeforeDoctor({ const updateResult = await maybeOfferUpdateBeforeDoctor({
runtime, runtime,
options, options,
root, root,
confirm: (p) => prompter.confirm(p), confirm: (p) => prompter.confirm(p),
outro, outro,
}); });
if (updateResult.handled) { if (updateResult.handled) {
return; return;
}
await maybeRepairUiProtocolFreshness(runtime, prompter);
noteSourceInstallIssues(root);
noteDeprecatedLegacyEnvVars();
const configResult = await loadAndMaybeMigrateDoctorConfig({
options,
confirm: (p) => prompter.confirm(p),
});
let cfg: OpenClawConfig = configResult.cfg;
const configPath = configResult.path ?? CONFIG_PATH;
if (!cfg.gateway?.mode) {
const lines = [
"gateway.mode is unset; gateway start will be blocked.",
`Fix: run ${formatCliCommand("openclaw configure")} and set Gateway mode (local/remote).`,
`Or set directly: ${formatCliCommand("openclaw config set gateway.mode local")}`,
];
if (!fs.existsSync(configPath)) {
lines.push(`Missing config: run ${formatCliCommand("openclaw setup")} first.`);
} }
note(lines.join("\n"), "Gateway");
}
await maybeRepairUiProtocolFreshness(runtime, prompter); cfg = await maybeRepairAnthropicOAuthProfileId(cfg, prompter);
noteSourceInstallIssues(root); cfg = await maybeRemoveDeprecatedCliAuthProfiles(cfg, prompter);
noteDeprecatedLegacyEnvVars(); await noteAuthProfileHealth({
cfg,
const configResult = await loadAndMaybeMigrateDoctorConfig({ prompter,
options, allowKeychainPrompt: options.nonInteractive !== true && Boolean(process.stdin.isTTY),
confirm: (p) => prompter.confirm(p), });
const gatewayDetails = buildGatewayConnectionDetails({ config: cfg });
if (gatewayDetails.remoteFallbackNote) {
note(gatewayDetails.remoteFallbackNote, "Gateway");
}
if (resolveMode(cfg) === "local") {
const auth = resolveGatewayAuth({
authConfig: cfg.gateway?.auth,
tailscaleMode: cfg.gateway?.tailscale?.mode ?? "off",
}); });
let cfg: OpenClawConfig = configResult.cfg; const needsToken = auth.mode !== "password" && (auth.mode !== "token" || !auth.token);
if (needsToken) {
const configPath = configResult.path ?? CONFIG_PATH; note(
if (!cfg.gateway?.mode) { "Gateway auth is off or missing a token. Token auth is now the recommended default (including loopback).",
const lines = [ "Gateway auth",
"gateway.mode is unset; gateway start will be blocked.", );
`Fix: run ${formatCliCommand("openclaw configure")} and set Gateway mode (local/remote).`, const shouldSetToken =
`Or set directly: ${formatCliCommand("openclaw config set gateway.mode local")}`, options.generateGatewayToken === true
]; ? true
if (!fs.existsSync(configPath)) { : options.nonInteractive === true
lines.push(`Missing config: run ${formatCliCommand("openclaw setup")} first.`); ? false
} : await prompter.confirmRepair({
note(lines.join("\n"), "Gateway"); message: "Generate and configure a gateway token now?",
initialValue: true,
});
if (shouldSetToken) {
const nextToken = randomToken();
cfg = {
...cfg,
gateway: {
...cfg.gateway,
auth: {
...cfg.gateway?.auth,
mode: "token",
token: nextToken,
},
},
};
note("Gateway token configured.", "Gateway auth");
}
} }
}
cfg = await maybeRepairAnthropicOAuthProfileId(cfg, prompter); const legacyState = await detectLegacyStateMigrations({ cfg });
cfg = await maybeRemoveDeprecatedCliAuthProfiles(cfg, prompter); if (legacyState.preview.length > 0) {
await noteAuthProfileHealth({ note(legacyState.preview.join("\n"), "Legacy state detected");
cfg, const migrate =
prompter, options.nonInteractive === true
allowKeychainPrompt: options.nonInteractive !== true && Boolean(process.stdin.isTTY), ? true
: await prompter.confirm({
message: "Migrate legacy state (sessions/agent/WhatsApp auth) now?",
initialValue: true,
});
if (migrate) {
const migrated = await runLegacyStateMigrations({
detected: legacyState,
});
if (migrated.changes.length > 0) {
note(migrated.changes.join("\n"), "Doctor changes");
}
if (migrated.warnings.length > 0) {
note(migrated.warnings.join("\n"), "Doctor warnings");
}
}
}
await noteStateIntegrity(cfg, prompter, configResult.path ?? CONFIG_PATH);
cfg = await maybeRepairSandboxImages(cfg, runtime, prompter);
noteSandboxScopeWarnings(cfg);
await maybeScanExtraGatewayServices(options, runtime, prompter);
await maybeRepairGatewayServiceConfig(cfg, resolveMode(cfg), runtime, prompter);
await noteMacLaunchAgentOverrides();
await noteMacLaunchctlGatewayEnvOverrides(cfg);
await noteSecurityWarnings(cfg);
if (cfg.hooks?.gmail?.model?.trim()) {
const hooksModelRef = resolveHooksGmailModel({
cfg,
defaultProvider: DEFAULT_PROVIDER,
}); });
const gatewayDetails = buildGatewayConnectionDetails({ config: cfg }); if (!hooksModelRef) {
if (gatewayDetails.remoteFallbackNote) { note(`- hooks.gmail.model "${cfg.hooks.gmail.model}" could not be resolved`, "Hooks");
note(gatewayDetails.remoteFallbackNote, "Gateway");
}
if (resolveMode(cfg) === "local") {
const auth = resolveGatewayAuth({
authConfig: cfg.gateway?.auth,
tailscaleMode: cfg.gateway?.tailscale?.mode ?? "off",
});
const needsToken = auth.mode !== "password" && (auth.mode !== "token" || !auth.token);
if (needsToken) {
note(
"Gateway auth is off or missing a token. Token auth is now the recommended default (including loopback).",
"Gateway auth",
);
const shouldSetToken =
options.generateGatewayToken === true
? true
: options.nonInteractive === true
? false
: await prompter.confirmRepair({
message: "Generate and configure a gateway token now?",
initialValue: true,
});
if (shouldSetToken) {
const nextToken = randomToken();
cfg = {
...cfg,
gateway: {
...cfg.gateway,
auth: {
...cfg.gateway?.auth,
mode: "token",
token: nextToken,
},
},
};
note("Gateway token configured.", "Gateway auth");
}
}
}
const legacyState = await detectLegacyStateMigrations({ cfg });
if (legacyState.preview.length > 0) {
note(legacyState.preview.join("\n"), "Legacy state detected");
const migrate =
options.nonInteractive === true
? true
: await prompter.confirm({
message: "Migrate legacy state (sessions/agent/WhatsApp auth) now?",
initialValue: true,
});
if (migrate) {
const migrated = await runLegacyStateMigrations({
detected: legacyState,
});
if (migrated.changes.length > 0) {
note(migrated.changes.join("\n"), "Doctor changes");
}
if (migrated.warnings.length > 0) {
note(migrated.warnings.join("\n"), "Doctor warnings");
}
}
}
await noteStateIntegrity(cfg, prompter, configResult.path ?? CONFIG_PATH);
cfg = await maybeRepairSandboxImages(cfg, runtime, prompter);
noteSandboxScopeWarnings(cfg);
await maybeScanExtraGatewayServices(options, runtime, prompter);
await maybeRepairGatewayServiceConfig(cfg, resolveMode(cfg), runtime, prompter);
await noteMacLaunchAgentOverrides();
await noteMacLaunchctlGatewayEnvOverrides(cfg);
await noteSecurityWarnings(cfg);
if (cfg.hooks?.gmail?.model?.trim()) {
const hooksModelRef = resolveHooksGmailModel({
cfg,
defaultProvider: DEFAULT_PROVIDER,
});
if (!hooksModelRef) {
note(`- hooks.gmail.model "${cfg.hooks.gmail.model}" could not be resolved`, "Hooks");
} else {
const { provider: defaultProvider, model: defaultModel } = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const catalog = await loadModelCatalog({ config: cfg });
const status = getModelRefStatus({
cfg,
catalog,
ref: hooksModelRef,
defaultProvider,
defaultModel,
});
const warnings: string[] = [];
if (!status.allowed) {
warnings.push(
`- hooks.gmail.model "${status.key}" not in agents.defaults.models allowlist (will use primary instead)`,
);
}
if (!status.inCatalog) {
warnings.push(
`- hooks.gmail.model "${status.key}" not in the model catalog (may fail at runtime)`,
);
}
if (warnings.length > 0) {
note(warnings.join("\n"), "Hooks");
}
}
}
if (
options.nonInteractive !== true &&
process.platform === "linux" &&
resolveMode(cfg) === "local"
) {
const service = resolveGatewayService();
let loaded = false;
try {
loaded = await service.isLoaded({ env: process.env });
} catch {
loaded = false;
}
if (loaded) {
await ensureSystemdUserLingerInteractive({
runtime,
prompter: {
confirm: async (p) => prompter.confirm(p),
note,
},
reason:
"Gateway runs as a systemd user service. Without lingering, systemd stops the user session on logout/idle and kills the Gateway.",
requireConfirm: true,
});
}
}
noteWorkspaceStatus(cfg);
await noteMemorySearchHealth(cfg);
// Check and fix shell completion
await doctorShellCompletion(runtime, prompter, {
nonInteractive: options.nonInteractive,
});
const { healthOk } = await checkGatewayHealth({
runtime,
cfg,
timeoutMs: options.nonInteractive === true ? 3000 : 10_000,
});
await maybeRepairGatewayDaemon({
cfg,
runtime,
prompter,
options,
gatewayDetailsMessage: gatewayDetails.message,
healthOk,
});
const shouldWriteConfig = prompter.shouldRepair || configResult.shouldWriteConfig;
if (shouldWriteConfig) {
cfg = applyWizardMetadata(cfg, { command: "doctor", mode: resolveMode(cfg) });
await writeConfigFile(cfg);
logConfigUpdated(runtime);
const backupPath = `${CONFIG_PATH}.bak`;
if (fs.existsSync(backupPath)) {
runtime.log(`Backup: ${shortenHomePath(backupPath)}`);
}
} else { } else {
runtime.log(`Run "${formatCliCommand("openclaw doctor --fix")}" to apply changes.`); const { provider: defaultProvider, model: defaultModel } = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const catalog = await loadModelCatalog({ config: cfg });
const status = getModelRefStatus({
cfg,
catalog,
ref: hooksModelRef,
defaultProvider,
defaultModel,
});
const warnings: string[] = [];
if (!status.allowed) {
warnings.push(
`- hooks.gmail.model "${status.key}" not in agents.defaults.models allowlist (will use primary instead)`,
);
}
if (!status.inCatalog) {
warnings.push(
`- hooks.gmail.model "${status.key}" not in the model catalog (may fail at runtime)`,
);
}
if (warnings.length > 0) {
note(warnings.join("\n"), "Hooks");
}
} }
}
if (options.workspaceSuggestions !== false) { if (
const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)); options.nonInteractive !== true &&
noteWorkspaceBackupTip(workspaceDir); process.platform === "linux" &&
if (await shouldSuggestMemorySystem(workspaceDir)) { resolveMode(cfg) === "local"
note(MEMORY_SYSTEM_PROMPT, "Workspace"); ) {
} const service = resolveGatewayService();
let loaded = false;
try {
loaded = await service.isLoaded({ env: process.env });
} catch {
loaded = false;
} }
if (loaded) {
const finalSnapshot = await readConfigFileSnapshot(); await ensureSystemdUserLingerInteractive({
if (finalSnapshot.exists && !finalSnapshot.valid) { runtime,
runtime.error("Invalid config:"); prompter: {
for (const issue of finalSnapshot.issues) { confirm: async (p) => prompter.confirm(p),
const path = issue.path || "<root>"; note,
runtime.error(`- ${path}: ${issue.message}`); },
} reason:
"Gateway runs as a systemd user service. Without lingering, systemd stops the user session on logout/idle and kills the Gateway.",
requireConfirm: true,
});
} }
}
outro("Doctor complete."); noteWorkspaceStatus(cfg);
runtime.exit(0); await noteMemorySearchHealth(cfg);
// Check and fix shell completion
await doctorShellCompletion(runtime, prompter, {
nonInteractive: options.nonInteractive,
});
const { healthOk } = await checkGatewayHealth({
runtime,
cfg,
timeoutMs: options.nonInteractive === true ? 3000 : 10_000,
});
await maybeRepairGatewayDaemon({
cfg,
runtime,
prompter,
options,
gatewayDetailsMessage: gatewayDetails.message,
healthOk,
});
const shouldWriteConfig = prompter.shouldRepair || configResult.shouldWriteConfig;
if (shouldWriteConfig) {
cfg = applyWizardMetadata(cfg, { command: "doctor", mode: resolveMode(cfg) });
await writeConfigFile(cfg);
logConfigUpdated(runtime);
const backupPath = `${CONFIG_PATH}.bak`;
if (fs.existsSync(backupPath)) {
runtime.log(`Backup: ${shortenHomePath(backupPath)}`);
}
} else {
runtime.log(`Run "${formatCliCommand("openclaw doctor --fix")}" to apply changes.`);
}
if (options.workspaceSuggestions !== false) {
const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
noteWorkspaceBackupTip(workspaceDir);
if (await shouldSuggestMemorySystem(workspaceDir)) {
note(MEMORY_SYSTEM_PROMPT, "Workspace");
}
}
const finalSnapshot = await readConfigFileSnapshot();
if (finalSnapshot.exists && !finalSnapshot.valid) {
runtime.error("Invalid config:");
for (const issue of finalSnapshot.issues) {
const path = issue.path || "<root>";
runtime.error(`- ${path}: ${issue.message}`);
}
}
outro("Doctor complete.");
runtime.exit(0);
} }