improve(doctor): keep regular runs problem-focused and compact (#106968)

A regular openclaw doctor run buried findings under healthy-state noise:
wizard banner, skills/plugins inventory boxes, healthy confirmations,
per-skill install hints, identical per-agent model-auth boxes, and one
warning line per stale plugin config path.

Doctor now prints problems and fixes only: security/Claude CLI notes are
silent when healthy, plugins box lists only errored ids, unusable skills
collapse to a count plus name list, model-auth findings aggregate across
agents with (agents: ...) attribution, and stale plugins.allow/deny/entries
references group into one line. Inventory stays in openclaw skills check,
openclaw plugins list, openclaw security audit, and openclaw models status.
docs/gateway/doctor.md updated.
This commit is contained in:
Peter Steinberger
2026-07-13 19:25:29 -07:00
committed by GitHub
parent 6958e4c969
commit eb7c151d07
20 changed files with 330 additions and 344 deletions
+6 -5
View File
@@ -149,7 +149,7 @@ Flags:
- Optional pre-flight update for git installs (interactive only).
- UI protocol freshness check (rebuilds Control UI when the protocol schema is newer).
- Health check + restart prompt.
- Skills status summary (eligible/missing/blocked) and plugin status.
- Problem-only skill and plugin notes; healthy inventory stays in `openclaw skills check` and `openclaw plugins list`.
</Accordion>
<Accordion title="Config and migrations">
@@ -469,19 +469,20 @@ That stages grounded durable candidates into the short-term dreaming store while
</Accordion>
<Accordion title="9. Security warnings">
Doctor emits warnings when a provider is open to DMs without an allowlist, or when a policy is configured in a dangerous way.
Doctor emits a Security note only when it finds a warning, such as a provider open to DMs without an allowlist or a dangerously configured policy. Use `openclaw security audit` for the full security inventory.
</Accordion>
<Accordion title="10. systemd linger (Linux)">
If running as a systemd user service, doctor ensures lingering is enabled so the gateway stays alive after logout.
</Accordion>
<Accordion title="11. Workspace status (skills, plugins, and TaskFlows)">
Doctor prints a summary of the workspace state for the default agent:
Doctor prints problems and actions for the default agent, not healthy-state inventory:
- **Skills status**: counts eligible, missing-requirements, and allowlist-blocked skills.
- **Plugin status**: counts enabled/disabled/errored plugins; lists plugin IDs for any errors; reports bundle plugin capabilities.
- **Skills**: lists allowed but unusable skill names; use `openclaw skills check` for requirement details and full counts.
- **Plugins**: reports only errored plugin IDs; use `openclaw plugins list` for loaded, imported, disabled, and bundle-plugin inventory.
- **Plugin compatibility warnings**: flags plugins that have compatibility issues with the current runtime.
- **Plugin diagnostics**: surfaces any load-time warnings or errors emitted by the plugin registry.
- **TaskFlow recovery**: surfaces suspicious managed TaskFlows that need manual inspection or cancellation.
- **Claude CLI**: reports only binary, authentication, profile, workspace, or project-directory problems; healthy probe details are omitted.
</Accordion>
<Accordion title="11b. Bootstrap file size">
+1 -2
View File
@@ -133,9 +133,8 @@ describe("plugins cli list", () => {
const output = runtimeLogs.join("\n");
expect(output).toContain("Plugin configuration:");
expect(output).toContain('plugins.allow: stale plugin reference "lossless-claw" was found.');
expect(output).toContain(
'plugins.entries.lossless-claw: stale plugin reference "lossless-claw" was found.',
"Stale plugin references (plugins.allow/deny/entries): lossless-claw.",
);
expect(output).toContain(
'plugins.slots.contextEngine: slot references missing plugin "lossless-claw".',
@@ -251,7 +251,7 @@ describe("noteAuthProfileHealth", () => {
});
});
it("labels model auth diagnostics by agent when multiple agent auth stores are checked", async () => {
it("aggregates model auth diagnostics and labels strict agent subsets", async () => {
const now = 1_700_000_000_000;
vi.spyOn(Date, "now").mockReturnValue(now);
const mainDir = path.join(tempDir, "main-agent");
@@ -285,14 +285,48 @@ describe("noteAuthProfileHealth", () => {
allowKeychainPrompt: false,
});
expect(noteMock).toHaveBeenCalledWith(
expect.stringContaining("openai-codex:main"),
"Model auth (agent: main)",
);
expect(noteMock).toHaveBeenCalledWith(
expect.stringContaining("openai-codex:coder"),
"Model auth (agent: coder)",
const modelAuthCalls = noteMock.mock.calls.filter(([, title]) => title === "Model auth");
expect(modelAuthCalls).toHaveLength(1);
const body = String(modelAuthCalls[0]?.[0]);
expect(body).toContain("openai-codex:coder");
expect(body).toContain("(agents: coder)");
expect(body).toContain("openai-codex:main");
expect(body).toContain("(agents: main)");
});
it("deduplicates model auth diagnostics shared by every agent", async () => {
const now = 1_700_000_000_000;
vi.spyOn(Date, "now").mockReturnValue(now);
const mainDir = path.join(tempDir, "main-agent");
const coderDir = path.join(tempDir, "coder-agent");
writeAuthStore(mainDir);
writeAuthStore(coderDir);
authProfileMocks.hasAnyAuthProfileStoreSource.mockReturnValue(true);
authProfileMocks.hasLocalAuthProfileStoreSource.mockReturnValue(true);
authProfileMocks.ensureAuthProfileStore.mockReturnValue(
expiredStore("openai-codex:shared", now - 60_000),
);
await noteAuthProfileHealth({
cfg: {
agents: {
list: [
{ id: "main", default: true, agentDir: mainDir },
{ id: "coder", agentDir: coderDir },
],
},
} as OpenClawConfig,
prompter: {
confirmAutoFix: vi.fn(async () => false),
} as unknown as DoctorPrompter,
allowKeychainPrompt: false,
});
const modelAuthCalls = noteMock.mock.calls.filter(([, title]) => title === "Model auth");
expect(modelAuthCalls).toHaveLength(1);
const body = String(modelAuthCalls[0]?.[0]);
expect(body.match(/openai-codex:shared/g)).toHaveLength(1);
expect(body).not.toContain("(agents:");
});
it("does not treat inherited main auth as a local secondary-agent source", async () => {
+20 -25
View File
@@ -456,7 +456,7 @@ async function noteAuthProfileHealthForTarget(params: {
allowKeychainPrompt: boolean;
target: AuthProfileHealthTarget;
labelAgents: boolean;
}): Promise<void> {
}): Promise<string[]> {
const store = ensureAuthProfileStore(params.target.agentDir, {
allowKeychainPrompt: params.allowKeychainPrompt,
});
@@ -500,7 +500,7 @@ async function noteAuthProfileHealthForTarget(params: {
let issues = findIssues();
if (issues.length === 0) {
return;
return [];
}
const refreshTargets = issues.filter(
@@ -548,24 +548,7 @@ async function noteAuthProfileHealthForTarget(params: {
issues = findIssues();
}
if (issues.length > 0) {
const issueLines = await Promise.all(
issues.map((issue) =>
formatAuthIssueLine(
{
profileId: issue.profileId,
provider: issue.provider,
status: issue.status,
reasonCode: issue.reasonCode,
remainingMs: issue.remainingMs,
},
params.cfg,
store,
),
),
);
note(issueLines.join("\n"), noteTitle("Model auth"));
}
return Promise.all(issues.map((issue) => formatAuthIssueLine(issue, params.cfg, store)));
}
/** Checks configured agent auth stores and emits doctor notes for stale or unusable profiles. */
@@ -586,11 +569,23 @@ export async function noteAuthProfileHealth(params: {
}
const labelAgents = activeTargets.length > 1;
const agentsByIssueLine = new Map<string, Set<string>>();
for (const target of activeTargets) {
await noteAuthProfileHealthForTarget({
...params,
target,
labelAgents,
});
for (const line of await noteAuthProfileHealthForTarget({ ...params, target, labelAgents })) {
const agentIds = agentsByIssueLine.get(line) ?? new Set<string>();
agentsByIssueLine.set(line, agentIds.add(target.agentId));
}
}
if (agentsByIssueLine.size === 0) {
return;
}
// One aggregated note; a line shared by every checked agent needs no attribution.
const lines = [...agentsByIssueLine.entries()]
.toSorted(([left], [right]) => left.localeCompare(right))
.map(([line, agentIds]) =>
agentIds.size === activeTargets.length
? line
: `${line} (agents: ${[...agentIds].toSorted().join(", ")})`,
);
note(lines.join("\n"), "Model auth");
}
+71 -27
View File
@@ -85,7 +85,7 @@ describe("noteClaudeCliHealth", () => {
expect(noteFn).not.toHaveBeenCalled();
});
it("reports a healthy claude-cli setup with the resolved Claude project dir", async () => {
it("stays quiet for a healthy claude-cli setup", async () => {
await withTempHome(({ homeDir, workspaceDir }) => {
const projectDir = resolveClaudeCliProjectDirForWorkspace({ workspaceDir, homeDir });
fs.mkdirSync(projectDir, { recursive: true });
@@ -107,8 +107,8 @@ describe("noteClaudeCliHealth", () => {
[CLAUDE_CLI_PROFILE_ID]: {
type: "oauth",
provider: "claude-cli",
access: "token-a",
refresh: "token-r",
access: "test-auth-token",
refresh: "test-token-placeholder",
expires: Date.now() + 60_000,
},
}),
@@ -120,22 +120,11 @@ describe("noteClaudeCliHealth", () => {
},
);
expect(noteFn).toHaveBeenCalledTimes(1);
expect(noteTitle(noteFn)).toBe("Claude CLI");
const body = noteBody(noteFn);
expect(body).toContain("Binary: /opt/homebrew/bin/claude.");
expect(body).toContain("Headless Claude auth: OK (oauth).");
expect(body).toContain(
`OpenClaw auth profile: ${CLAUDE_CLI_PROFILE_ID} (provider claude-cli).`,
);
expect(body).toContain("Workspace:");
expect(body).toContain("(writable).");
expect(body).toContain("Claude project dir:");
expect(body).toContain("(present).");
expect(noteFn).not.toHaveBeenCalled();
});
});
it("reports the Claude CLI workspace for a non-default runtime agent", async () => {
it("stays quiet for a healthy non-default Claude CLI runtime agent", async () => {
await withTempHome(({ homeDir, workspaceDir }) => {
const root = path.dirname(workspaceDir);
const defaultWorkspace = path.join(root, "workspace-coder");
@@ -179,8 +168,8 @@ describe("noteClaudeCliHealth", () => {
[CLAUDE_CLI_PROFILE_ID]: {
type: "oauth",
provider: "claude-cli",
access: "token-a",
refresh: "token-r",
access: "test-auth-token",
refresh: "test-token-placeholder",
expires: Date.now() + 60_000,
},
}),
@@ -192,11 +181,7 @@ describe("noteClaudeCliHealth", () => {
},
);
expect(noteFn).toHaveBeenCalledTimes(1);
const body = noteBody(noteFn);
expect(body).toContain(`Agent xiaoao workspace: ${claudeWorkspace} (writable).`);
expect(body).toContain(`Agent xiaoao Claude project dir: ${projectDir} (present).`);
expect(body).not.toContain(defaultWorkspace);
expect(noteFn).not.toHaveBeenCalled();
});
});
@@ -225,14 +210,12 @@ describe("noteClaudeCliHealth", () => {
);
const body = noteBody(noteFn);
expect(body).toContain("Headless Claude auth: OK (oauth).");
expect(body).toContain(`OpenClaw auth profile: missing (${CLAUDE_CLI_PROFILE_ID})`);
expect(body).toContain(
"openclaw models auth login --provider anthropic --method cli --set-default",
);
expect(body).toContain(
"not created yet; it appears after the first Claude CLI turn in this workspace",
);
expect(body).not.toContain("Headless Claude auth: OK");
expect(body).not.toContain("not created yet");
});
});
@@ -263,4 +246,65 @@ describe("noteClaudeCliHealth", () => {
expect(body).toContain("claude auth login");
});
});
it("lists Claude CLI agents only when a problem is reported", async () => {
await withTempHome(({ homeDir, workspaceDir }) => {
const root = path.dirname(workspaceDir);
const alphaWorkspace = path.join(root, "workspace-alpha");
const zetaWorkspace = path.join(root, "workspace-zeta");
fs.writeFileSync(alphaWorkspace, "not a directory");
fs.mkdirSync(zetaWorkspace, { recursive: true });
const runtimeModel = "anthropic/claude-opus-4-7";
const noteFn = vi.fn();
noteClaudeCliHealth(
{
agents: {
defaults: { model: { primary: runtimeModel } },
list: [
{
id: "zeta",
default: true,
workspace: zetaWorkspace,
model: runtimeModel,
models: { [runtimeModel]: { agentRuntime: { id: "claude-cli" } } },
},
{
id: "alpha",
workspace: alphaWorkspace,
model: runtimeModel,
models: { [runtimeModel]: { agentRuntime: { id: "claude-cli" } } },
},
],
},
},
{
homeDir,
noteFn,
store: createStore({
[CLAUDE_CLI_PROFILE_ID]: {
type: "oauth",
provider: "claude-cli",
access: "test-auth-token",
refresh: "test-token-placeholder",
expires: Date.now() + 60_000,
},
}),
readClaudeCliCredentials: () => ({
type: "oauth",
expires: Date.now() + 60_000,
}),
resolveCommandPath: () => "/opt/homebrew/bin/claude",
},
);
expect(noteTitle(noteFn)).toBe("Claude CLI");
const body = noteBody(noteFn);
expect(body).toContain(
`Agent alpha workspace: ${alphaWorkspace} exists but is not a directory.`,
);
expect(body).toContain("Agents using Claude CLI: alpha, zeta.");
expect(body).not.toContain(`Agent zeta workspace: ${zetaWorkspace}`);
});
});
});
+34 -35
View File
@@ -83,25 +83,15 @@ function probeDirectoryHealth(dirPath: string): ClaudeCliDirHealth {
return "present";
}
function formatCredentialLabel(credential: ClaudeCliReadableCredential): string {
if (credential.type === "oauth" || credential.type === "token") {
return credential.type;
}
return "unknown";
}
function formatWorkspaceHealthLine(
function formatWorkspaceProblemLine(
workspaceDir: string,
health: ClaudeCliDirHealth,
agentId?: string,
): string {
): string | null {
const label = agentId ? `Agent ${agentId} workspace` : "Workspace";
const display = shortenHomePath(workspaceDir);
if (health === "present") {
return `- ${label}: ${display} (writable).`;
}
if (health === "missing") {
return `- ${label}: ${display} (missing; OpenClaw will create it on first run).`;
if (health === "present" || health === "missing") {
return null;
}
if (health === "not_directory") {
return `- ${label}: ${display} exists but is not a directory.`;
@@ -112,18 +102,15 @@ function formatWorkspaceHealthLine(
return `- ${label}: ${display} is not writable by this user.`;
}
function formatProjectDirHealthLine(
function formatProjectDirProblemLine(
projectDir: string,
health: ClaudeCliDirHealth,
agentId?: string,
): string {
): string | null {
const label = agentId ? `Agent ${agentId} Claude project dir` : "Claude project dir";
const display = shortenHomePath(projectDir);
if (health === "present") {
return `- ${label}: ${display} (present).`;
}
if (health === "missing") {
return `- ${label}: ${display} (not created yet; it appears after the first Claude CLI turn in this workspace).`;
if (health === "present" || health === "missing") {
return null;
}
if (health === "not_directory") {
return `- ${label}: ${display} exists but is not a directory.`;
@@ -242,18 +229,14 @@ export function noteClaudeCliHealth(
const lines: string[] = [];
const fixHints: string[] = [];
if (commandPath) {
lines.push(`- Binary: ${shortenHomePath(commandPath)}.`);
} else {
if (!commandPath) {
lines.push(`- Binary: command "${command}" was not found on PATH.`);
fixHints.push(
"- Fix: install Claude CLI or set agents.defaults.cliBackends.claude-cli.command to the real binary path.",
);
}
if (credential) {
lines.push(`- Headless Claude auth: OK (${formatCredentialLabel(credential)}).`);
} else {
if (!credential) {
lines.push("- Headless Claude auth: unavailable without interactive prompting.");
fixHints.push(
`- Fix: run ${formatCliCommand("claude auth login")}, then ${formatCliCommand(
@@ -278,15 +261,18 @@ export function noteClaudeCliHealth(
"openclaw models auth login --provider anthropic --method cli --set-default",
)} to rewrite the profile cleanly.`,
);
} else {
lines.push(
`- OpenClaw auth profile: ${CLAUDE_CLI_PROFILE_ID} (provider ${CLAUDE_CLI_PROVIDER}).`,
);
}
for (const target of workspaceTargets) {
const agentLabel = showAgentLabels ? target.agentId : undefined;
lines.push(formatWorkspaceHealthLine(target.workspaceDir, target.workspaceHealth, agentLabel));
const workspaceProblem = formatWorkspaceProblemLine(
target.workspaceDir,
target.workspaceHealth,
agentLabel,
);
if (workspaceProblem) {
lines.push(workspaceProblem);
}
if (
target.workspaceHealth === "readonly" ||
target.workspaceHealth === "unreadable" ||
@@ -299,7 +285,14 @@ export function noteClaudeCliHealth(
);
}
lines.push(formatProjectDirHealthLine(target.projectDir, target.projectDirHealth, agentLabel));
const projectDirProblem = formatProjectDirProblemLine(
target.projectDir,
target.projectDirHealth,
agentLabel,
);
if (projectDirProblem) {
lines.push(projectDirProblem);
}
if (target.projectDirHealth === "unreadable" || target.projectDirHealth === "not_directory") {
fixHints.push(
`- Fix: make ${
@@ -309,12 +302,18 @@ export function noteClaudeCliHealth(
}
}
if (workspaceTargets.length > 1) {
if (lines.length > 0 && workspaceTargets.length > 1) {
lines.push(
`- Agents using Claude CLI: ${workspaceTargets.map((target) => target.agentId).join(", ")}.`,
`- Agents using Claude CLI: ${workspaceTargets
.map((target) => target.agentId)
.toSorted((a, b) => a.localeCompare(b))
.join(", ")}.`,
);
}
if (lines.length === 0 && fixHints.length === 0) {
return;
}
if (fixHints.length > 0) {
lines.push(...fixHints);
}
+5 -12
View File
@@ -144,6 +144,7 @@ describe("noteSecurityWarnings gateway exposure", () => {
expect(message).toContain("without authentication");
expect(message).toContain("Safer remote access");
expect(message).toContain("ssh -N -L 18789:127.0.0.1:18789");
expect(message).toContain("openclaw security audit --deep");
});
it("uses env token to avoid critical warning", async () => {
@@ -247,17 +248,13 @@ describe("noteSecurityWarnings gateway exposure", () => {
it("skips warning for loopback bind", async () => {
const cfg = { gateway: { bind: "loopback" } } as OpenClawConfig;
await noteSecurityWarnings(cfg);
const message = lastMessage();
expect(message).toContain("No channel security warnings detected");
expect(message).not.toContain("Gateway bound");
expect(note).not.toHaveBeenCalled();
});
it("treats unset bind as loopback for host-side doctor checks", async () => {
const cfg = { gateway: {} } as OpenClawConfig;
await noteSecurityWarnings(cfg);
const message = lastMessage();
expect(message).toContain("No channel security warnings detected");
expect(message).not.toContain("Gateway bound");
expect(note).not.toHaveBeenCalled();
});
it("shows explicit dmScope config command for multi-user DMs", async () => {
@@ -514,9 +511,7 @@ describe("noteSecurityWarnings gateway exposure", () => {
},
);
const message = lastMessage();
expect(message).toContain("No channel security warnings detected");
expect(message).not.toContain('security="deny"');
expect(note).not.toHaveBeenCalled();
});
it("does not invent an on-miss host ask policy when exec-approvals defaults.ask is unset", async () => {
@@ -536,9 +531,7 @@ describe("noteSecurityWarnings gateway exposure", () => {
},
);
const message = lastMessage();
expect(message).toContain("No channel security warnings detected");
expect(message).not.toContain('ask="on-miss"');
expect(note).not.toHaveBeenCalled();
});
it("warns when a per-agent exec policy is broader than the matching host agent policy", async () => {
+4 -5
View File
@@ -439,9 +439,8 @@ export async function collectSecurityWarnings(
/** Emits security warnings plus the deep audit follow-up command. */
export async function noteSecurityWarnings(cfg: OpenClawConfig) {
const warnings = await collectSecurityWarnings(cfg);
const auditHint = `- Run: ${formatCliCommand("openclaw security audit --deep")}`;
const lines = warnings.length > 0 ? warnings : ["- No channel security warnings detected."];
lines.push(auditHint);
note(lines.join("\n"), "Security");
if (warnings.length > 0) {
warnings.push(`- Run: ${formatCliCommand("openclaw security audit --deep")}`);
note(warnings.join("\n"), "Security");
}
}
+18 -4
View File
@@ -66,7 +66,7 @@ describe("doctor skills", () => {
expect(collectUnavailableAgentSkills(report)).toEqual([unavailable]);
});
it("formats actionable missing requirement lines without secret values", () => {
it("formats unavailable skill names compactly and alphabetically", () => {
const lines = formatUnavailableSkillDoctorLines([
createSkill({
name: "places",
@@ -88,11 +88,25 @@ describe("doctor skills", () => {
},
],
}),
createSkill({
name: "calendar",
eligible: false,
platformIncompatible: false,
}),
]);
expect(lines.join("\n")).toContain("places: bins: goplaces; env: GOOGLE_MAPS_API_KEY");
expect(lines.join("\n")).toContain("install option: Install goplaces (brew)");
expect(lines.join("\n")).toContain("openclaw doctor --fix");
expect(lines).toEqual([
"2 allowed skills are not usable in this environment (missing binaries, env vars, or config).",
"- calendar, places",
"Disable unused skills: openclaw doctor --fix",
"Inspect details: openclaw skills check --agent <id> or openclaw skills info <name> --agent <id>",
]);
});
it("uses singular grammar for one unavailable skill", () => {
expect(formatUnavailableSkillDoctorLines([createSkill({ name: "places" })])[0]).toBe(
"1 allowed skill is not usable in this environment (missing binaries, env vars, or config).",
);
});
it("surfaces a GH_CONFIG_DIR hint when the github skill is eligible but auth lives at a different HOME", () => {
+7 -14
View File
@@ -16,16 +16,8 @@ import type { DoctorPrompter } from "./doctor-prompter.js";
import {
collectUnavailableAgentSkills,
disableUnavailableSkillsInConfig,
formatMissingSkillSummary,
} from "./doctor-skills-core.js";
function formatInstallHints(skill: SkillStatusEntry): string[] {
if (skill.install.length === 0) {
return [];
}
return skill.install.slice(0, 2).map((entry) => ` install option: ${entry.label}`);
}
function defaultGhConfigDiscoveryInput(): GhConfigDiscoveryInput {
return {
platform: process.platform,
@@ -65,13 +57,14 @@ export function describeGhConfigDirHintFromDiscovery(
/** Formats doctor note lines for skills that are allowed but unavailable. */
export function formatUnavailableSkillDoctorLines(skills: SkillStatusEntry[]): string[] {
const lines: string[] = [
"Some skills are allowed for this agent but are not usable in the current runtime environment.",
const count = skills.length;
const lines = [
`${count} allowed skill${count === 1 ? " is" : "s are"} not usable in this environment (missing binaries, env vars, or config).`,
`- ${skills
.map((skill) => skill.name)
.toSorted((a, b) => a.localeCompare(b))
.join(", ")}`,
];
for (const skill of skills) {
lines.push(`- ${skill.name}: ${formatMissingSkillSummary(skill)}`);
lines.push(...formatInstallHints(skill));
}
lines.push(`Disable unused skills: ${formatCliCommand("openclaw doctor --fix")}`);
lines.push(
`Inspect details: ${formatCliCommand("openclaw skills check --agent <id>")} or ${formatCliCommand("openclaw skills info <name> --agent <id>")}`,
+1 -5
View File
@@ -1483,11 +1483,7 @@ export function collectWorkspaceBackupTip(workspaceDir: string): string | null {
if (fs.existsSync(gitMarker)) {
return null;
}
return [
"- Tip: back up the workspace in a private git repo (GitHub or GitLab).",
"- Keep ~/.openclaw out of git; it contains credentials and session history.",
"- Details: /concepts/agent-workspace#git-backup-recommended",
].join("\n");
return "- Tip: back up the agent workspace in a private git repo; keep ~/.openclaw out of git (credentials, sessions). Details: /concepts/agent-workspace#git-backup-recommended";
}
/** Emits the workspace backup tip when applicable. */
+34 -84
View File
@@ -17,7 +17,6 @@ import {
const mocks = vi.hoisted(() => ({
resolveAgentWorkspaceDir: vi.fn(),
resolveDefaultAgentId: vi.fn(),
buildWorkspaceSkillStatus: vi.fn(),
buildPluginRegistrySnapshotReport: vi.fn(),
buildPluginCompatibilityWarnings: vi.fn(),
listTaskFlowRecords: vi.fn<() => unknown[]>(() => []),
@@ -29,10 +28,6 @@ vi.mock("../agents/agent-scope.js", () => ({
resolveDefaultAgentId: (...args: unknown[]) => mocks.resolveDefaultAgentId(...args),
}));
vi.mock("../skills/discovery/status.js", () => ({
buildWorkspaceSkillStatus: (...args: unknown[]) => mocks.buildWorkspaceSkillStatus(...args),
}));
vi.mock("../plugins/status.js", () => ({
buildPluginRegistrySnapshotReport: (...args: unknown[]) =>
mocks.buildPluginRegistrySnapshotReport(...args),
@@ -61,9 +56,6 @@ async function runNoteWorkspaceStatusForTest(
const cfg: OpenClawConfig = opts?.cfg ?? {};
mocks.resolveDefaultAgentId.mockReturnValue("default");
mocks.resolveAgentWorkspaceDir.mockReturnValue("/workspace");
mocks.buildWorkspaceSkillStatus.mockReturnValue({
skills: [],
});
mocks.buildPluginRegistrySnapshotReport.mockReturnValue({
workspaceDir: "/workspace",
...loadResult,
@@ -111,7 +103,7 @@ describe("noteWorkspaceStatus", () => {
}
});
it("surfaces bundle plugin capabilities in the plugins note", async () => {
it("omits healthy plugin inventory", async () => {
const noteSpy = await runNoteWorkspaceStatusForTest(
createPluginLoadResult({
plugins: [
@@ -127,36 +119,52 @@ describe("noteWorkspaceStatus", () => {
}),
);
try {
const pluginCalls = noteSpy.mock.calls.filter(([, title]) => title === "Plugins");
expect(pluginCalls).toHaveLength(1);
const [body] = expectDefined(pluginCalls[0], "(pluginCalls)[0] test invariant");
expect(body).toContain("Bundle plugins: 1");
expect(body).toContain("agents, commands, skills");
expect(noteSpy.mock.calls.filter(([, title]) => title === "Plugins")).toHaveLength(0);
} finally {
noteSpy.mockRestore();
}
});
it("includes imported plugin counts in the plugins note", async () => {
it("lists only errored plugin ids in deterministic order with truncation", async () => {
const pluginIds = [
"zulu",
"bravo",
"alpha",
"lima",
"charlie",
"kilo",
"delta",
"juliet",
"echo",
"india",
"foxtrot",
"hotel",
];
const noteSpy = await runNoteWorkspaceStatusForTest(
createPluginLoadResult({
plugins: [
createPluginRecord({
id: "imported-plugin",
imported: true,
}),
createPluginRecord({
id: "cold-plugin",
imported: false,
}),
],
plugins: pluginIds.map((id) => createPluginRecord({ id, status: "error" })),
}),
);
try {
const pluginCalls = noteSpy.mock.calls.filter(([, title]) => title === "Plugins");
expect(pluginCalls).toHaveLength(1);
const [body] = expectDefined(pluginCalls[0], "(pluginCalls)[0] test invariant");
expect(body).toContain("Imported: 1");
expect(body).toBe(
[
"Errors: 12",
"- alpha",
"- bravo",
"- charlie",
"- delta",
"- echo",
"- foxtrot",
"- hotel",
"- india",
"- juliet",
"- kilo",
"- ...",
].join("\n"),
);
} finally {
noteSpy.mockRestore();
}
@@ -479,62 +487,4 @@ describe("noteWorkspaceStatus", () => {
noteSpy.mockRestore();
}
});
const makeSkill = (
skillKey: string,
fields: { eligible: boolean; platformIncompatible: boolean },
) =>
({
skillKey,
disabled: false,
blockedByAllowlist: false,
eligible: fields.eligible,
platformIncompatible: fields.platformIncompatible,
}) as never;
async function runWithSkills(skills: unknown[]) {
mocks.resolveDefaultAgentId.mockReturnValue("default");
mocks.resolveAgentWorkspaceDir.mockReturnValue("/workspace");
mocks.buildWorkspaceSkillStatus.mockReturnValue({ skills });
mocks.buildPluginRegistrySnapshotReport.mockReturnValue({
workspaceDir: "/workspace",
...createPluginLoadResult(),
});
mocks.buildPluginCompatibilityWarnings.mockReturnValue([]);
mocks.listTaskFlowRecords.mockReturnValue([]);
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
noteWorkspaceStatus({});
return noteSpy;
}
it("surfaces a platform-incompatible rollup and keeps those skills out of Missing requirements", async () => {
const noteSpy = await runWithSkills([
makeSkill("mac-only", { eligible: false, platformIncompatible: true }),
makeSkill("broken", { eligible: false, platformIncompatible: false }),
]);
try {
const skillsCall = noteSpy.mock.calls.find(([, title]) => title === "Skills status");
expect(skillsCall).toBeDefined();
const [body] = skillsCall as [string, string];
expect(body).toContain("Incompatible (platform mismatch, auto-skipped): 1");
expect(body).toContain("Missing requirements: 1");
} finally {
noteSpy.mockRestore();
}
});
it("omits the platform-incompatible rollup when the count is zero", async () => {
const noteSpy = await runWithSkills([
makeSkill("broken", { eligible: false, platformIncompatible: false }),
]);
try {
const skillsCall = noteSpy.mock.calls.find(([, title]) => title === "Skills status");
expect(skillsCall).toBeDefined();
const [body] = skillsCall as [string, string];
expect(body).not.toContain("Incompatible (platform mismatch");
expect(body).toContain("Missing requirements: 1");
} finally {
noteSpy.mockRestore();
}
});
});
+10 -49
View File
@@ -12,7 +12,6 @@ import {
buildPluginCompatibilityWarnings,
buildPluginRegistrySnapshotReport,
} from "../plugins/status.js";
import { buildWorkspaceSkillStatus } from "../skills/discovery/status.js";
import { listTasksForFlowId } from "../tasks/runtime-internal.js";
import { listTaskFlowRecords } from "../tasks/task-flow-runtime-internal.js";
@@ -184,62 +183,24 @@ function notePluginVersionDrift(drift: PluginVersionDriftReport | undefined) {
note(lines.join("\n"), "Plugin version drift");
}
/** Emits workspace, skills, plugin, and TaskFlow recovery status notes for doctor. */
/** Emits plugin and TaskFlow recovery problem notes for doctor. */
export function noteWorkspaceStatus(cfg: OpenClawConfig, options: NoteWorkspaceStatusOptions = {}) {
const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
const skillsReport = buildWorkspaceSkillStatus(workspaceDir, { config: cfg });
const platformIncompatibleCount = skillsReport.skills.filter(
(s) => s.platformIncompatible && !s.disabled && !s.blockedByAllowlist,
).length;
note(
[
`Eligible: ${skillsReport.skills.filter((s) => s.eligible).length}`,
`Missing requirements: ${
skillsReport.skills.filter(
(s) => !s.eligible && !s.disabled && !s.blockedByAllowlist && !s.platformIncompatible,
).length
}`,
platformIncompatibleCount > 0
? `Incompatible (platform mismatch, auto-skipped): ${platformIncompatibleCount}`
: null,
`Blocked by allowlist: ${skillsReport.skills.filter((s) => s.blockedByAllowlist).length}`,
]
.filter((line): line is string => Boolean(line))
.join("\n"),
"Skills status",
);
const pluginRegistry = buildPluginRegistrySnapshotReport({
config: cfg,
workspaceDir,
});
if (pluginRegistry.plugins.length > 0) {
const loaded = pluginRegistry.plugins.filter((p) => p.status === "loaded");
const disabled = pluginRegistry.plugins.filter((p) => p.status === "disabled");
const errored = pluginRegistry.plugins.filter((p) => p.status === "error");
const imported = pluginRegistry.plugins.filter((p) => p.imported);
const errored = pluginRegistry.plugins
.filter((plugin) => plugin.status === "error")
.toSorted((a, b) => a.id.localeCompare(b.id));
if (errored.length > 0) {
const lines = [
`Loaded: ${loaded.length}`,
`Imported: ${imported.length}`,
`Disabled: ${disabled.length}`,
`Errors: ${errored.length}`,
errored.length > 0
? `- ${errored
.slice(0, 10)
.map((p) => p.id)
.join("\n- ")}${errored.length > 10 ? "\n- ..." : ""}`
: null,
].filter((line): line is string => Boolean(line));
const bundlePlugins = loaded.filter(
(p) => p.format === "bundle" && (p.bundleCapabilities?.length ?? 0) > 0,
);
if (bundlePlugins.length > 0) {
const allCaps = new Set(bundlePlugins.flatMap((p) => p.bundleCapabilities ?? []));
lines.push(`Bundle plugins: ${bundlePlugins.length} (${[...allCaps].toSorted().join(", ")})`);
}
`- ${errored
.slice(0, 10)
.map((plugin) => plugin.id)
.join("\n- ")}${errored.length > 10 ? "\n- ..." : ""}`,
];
note(lines.join("\n"), "Plugins");
}
notePluginVersionDrift(options.pluginVersionDrift);
@@ -121,10 +121,8 @@ describe("collectCodexNativeAssetInfoNotes", () => {
expect(notes).toStrictEqual([
[
"- Personal Codex CLI assets were found, but native Codex-mode OpenClaw agents use isolated per-agent Codex homes.",
`- Sources: ${codexHome} and ${path.join(root, ".agents", "skills")} (1 skill, 0 plugins, 0 config files, 0 hook files).`,
"- These assets will not be loaded by the Codex app-server child unless you intentionally promote them.",
"- If the Codex plugin is not installed, run `openclaw plugins install npm:@openclaw/codex` first. Then run `openclaw migrate plan codex` to inventory them. Applying that migration copies skills into the current OpenClaw agent workspace; Codex plugins, hooks, and config stay manual-review only.",
`- Personal Codex CLI assets found (1 skill, 0 plugins, 0 config files, 0 hook files) in ${codexHome} and ${path.join(root, ".agents", "skills")}; native Codex-mode agents use isolated per-agent homes and will not load them.`,
"- To review or promote them: install the Codex plugin (openclaw plugins install npm:@openclaw/codex), then run openclaw migrate plan codex.",
].join("\n"),
]);
});
@@ -198,10 +198,8 @@ export async function collectCodexNativeAssetInfoNotes(params: {
];
return [
[
"- Personal Codex CLI assets were found, but native Codex-mode OpenClaw agents use isolated per-agent Codex homes.",
`- Sources: ${resolveCodexHome(env)} and ${resolvePersonalAgentSkillsDir(env)} (${counts.join(", ")}).`,
"- These assets will not be loaded by the Codex app-server child unless you intentionally promote them.",
"- If the Codex plugin is not installed, run `openclaw plugins install npm:@openclaw/codex` first. Then run `openclaw migrate plan codex` to inventory them. Applying that migration copies skills into the current OpenClaw agent workspace; Codex plugins, hooks, and config stay manual-review only.",
`- Personal Codex CLI assets found (${counts.join(", ")}) in ${resolveCodexHome(env)} and ${resolvePersonalAgentSkillsDir(env)}; native Codex-mode agents use isolated per-agent homes and will not load them.`,
"- To review or promote them: install the Codex plugin (openclaw plugins install npm:@openclaw/codex), then run openclaw migrate plan codex.",
].join("\n"),
];
}
@@ -230,18 +230,24 @@ vi.mock("./stale-plugin-config.js", () => ({
autoRepairBlocked: boolean;
doctorFixCommand: string;
hits: Array<{ id: string; surface: string }>;
}) =>
hits.map((hit) => {
const prefix =
hit.surface === "channel"
? `channels.${hit.id}: dangling channel config.`
: `plugins.allow: stale plugin reference "${hit.id}". plugins.entries.${hit.id} is unused.`;
return `${prefix} ${
autoRepairBlocked
? `Auto-removal is paused; rerun "${doctorFixCommand}".`
: `Run "${doctorFixCommand}".`
}`;
}),
}) => {
const pluginIds = hits
.filter((hit) => hit.surface !== "channel")
.map((hit) => hit.id)
.toSorted();
const lines = [
pluginIds.length > 0
? `Stale plugin references (plugins.allow/deny/entries): ${pluginIds.join(", ")}.`
: null,
...hits
.filter((hit) => hit.surface === "channel")
.map((hit) => `channels.${hit.id}: dangling channel config.`),
autoRepairBlocked
? `Auto-removal is paused; rerun "${doctorFixCommand}".`
: `Run "${doctorFixCommand}".`,
];
return lines.filter((line): line is string => line !== null);
},
}));
vi.mock("./bundled-plugin-load-paths.js", () => ({
@@ -370,8 +376,8 @@ describe("doctor preview warnings", () => {
env: { CODEX_HOME: codexHome, HOME: root },
});
expect(notes.infoNotes.join("\n")).toContain("Personal Codex CLI assets were found");
expect(notes.warningNotes.join("\n")).not.toContain("Personal Codex CLI assets were found");
expect(notes.infoNotes.join("\n")).toContain("Personal Codex CLI assets found");
expect(notes.warningNotes.join("\n")).not.toContain("Personal Codex CLI assets found");
});
it("collects provider and shared preview warnings", async () => {
@@ -535,9 +541,8 @@ describe("doctor preview warnings", () => {
const warning = expectSingleWarningContaining(
warnings,
'plugins.allow: stale plugin reference "acpx"',
"Stale plugin references (plugins.allow/deny/entries): acpx",
);
expect(warning).toContain("plugins.entries.acpx");
expect(warning).toContain('Run "openclaw doctor --fix"');
expect(warning).not.toContain("Auto-removal is paused");
});
@@ -637,7 +642,7 @@ describe("doctor preview warnings", () => {
const warning = expectSingleWarningContaining(
warnings,
'plugins.allow: stale plugin reference "acpx"',
"Stale plugin references (plugins.allow/deny/entries): acpx",
);
expect(warning).toContain("Auto-removal is paused");
expect(warning).toContain('rerun "openclaw doctor --fix"');
@@ -159,17 +159,33 @@ describe("doctor stale plugin config helpers", () => {
it("formats stale plugin warnings with a doctor hint", () => {
const warnings = collectStalePluginConfigWarnings({
hits: [
{
pluginId: "zeta",
pathLabel: "plugins.deny",
surface: "deny",
},
{
pluginId: "acpx",
pathLabel: "plugins.allow",
surface: "allow",
},
{
pluginId: "acpx",
pathLabel: "plugins.entries.acpx",
surface: "entries",
},
{
pluginId: "missing-memory",
pathLabel: "plugins.slots.memory",
surface: "slot",
},
],
doctorFixCommand: "openclaw doctor --fix",
});
expect(warnings).toEqual([
'- plugins.allow: stale plugin reference "acpx" was found.',
"- Stale plugin references (plugins.allow/deny/entries): acpx, zeta.",
'- plugins.slots.memory: slot references missing plugin "missing-memory".',
'- Run "openclaw doctor --fix" to remove stale plugin ids and dangling channel references.',
]);
});
@@ -378,7 +394,7 @@ describe("doctor stale plugin config helpers", () => {
doctorFixCommand: "openclaw doctor --fix",
autoRepairBlocked: true,
});
expect(warnings[2]).toContain("Auto-removal is paused");
expect(warnings.at(-1)).toContain("Auto-removal is paused");
});
it("keeps an intentionally unavailable Codex plugin entry out of stale diagnostics", () => {
@@ -120,38 +120,19 @@ function scanStalePluginConfigWithState(
const hits: StalePluginConfigHit[] = [];
const staleEvidenceIds = new Set(registryState.missingInstalledIds);
const allow = Array.isArray(plugins?.allow) ? plugins.allow : [];
for (const rawPluginId of allow) {
if (typeof rawPluginId !== "string") {
continue;
for (const surface of ["allow", "deny"] as const) {
const list = Array.isArray(plugins?.[surface]) ? plugins[surface] : [];
for (const rawPluginId of list) {
if (typeof rawPluginId !== "string") {
continue;
}
const pluginId = normalizePluginId(rawPluginId);
if (!pluginId || knownIds.has(pluginId) || registryState.knownChannelIds.has(pluginId)) {
continue;
}
hits.push({ pluginId: rawPluginId, pathLabel: `plugins.${surface}`, surface });
staleEvidenceIds.add(pluginId);
}
const pluginId = normalizePluginId(rawPluginId);
if (!pluginId || knownIds.has(pluginId) || registryState.knownChannelIds.has(pluginId)) {
continue;
}
hits.push({
pluginId: rawPluginId,
pathLabel: "plugins.allow",
surface: "allow",
});
staleEvidenceIds.add(pluginId);
}
const deny = Array.isArray(plugins?.deny) ? plugins.deny : [];
for (const rawPluginId of deny) {
if (typeof rawPluginId !== "string") {
continue;
}
const pluginId = normalizePluginId(rawPluginId);
if (!pluginId || knownIds.has(pluginId) || registryState.knownChannelIds.has(pluginId)) {
continue;
}
hits.push({
pluginId: rawPluginId,
pathLabel: "plugins.deny",
surface: "deny",
});
staleEvidenceIds.add(pluginId);
}
const entries = asObjectRecord(plugins?.entries);
@@ -301,9 +282,13 @@ function collectDependentChannelConfigHits(
return hits;
}
function formatStalePluginHitWarning(hit: StalePluginConfigHit): string {
if (hit.surface === "allow" || hit.surface === "deny" || hit.surface === "entries") {
return `- ${hit.pathLabel}: stale plugin reference "${hit.pluginId}" was found.`;
// Policy-list hits collapse into one grouped warning line instead of one line per path.
const isPolicySurfaceHit = (hit: StalePluginConfigHit) =>
hit.surface === "allow" || hit.surface === "deny" || hit.surface === "entries";
function formatStalePluginHitWarning(hit: StalePluginConfigHit): string | null {
if (isPolicySurfaceHit(hit)) {
return null;
}
if (hit.surface === "slot") {
return `- ${hit.pathLabel}: slot references missing plugin "${hit.pluginId}".`;
@@ -326,7 +311,17 @@ export function collectStalePluginConfigWarnings(params: {
if (params.hits.length === 0) {
return [];
}
const lines = params.hits.map((hit) => formatStalePluginHitWarning(hit));
const policyPluginIds = [
...new Set(params.hits.filter(isPolicySurfaceHit).map((hit) => hit.pluginId)),
].toSorted((a, b) => a.localeCompare(b));
const lines = params.hits
.map((hit) => formatStalePluginHitWarning(hit))
.filter((line): line is string => line !== null);
if (policyPluginIds.length > 0) {
lines.unshift(
`- Stale plugin references (plugins.allow/deny/entries): ${policyPluginIds.join(", ")}.`,
);
}
if (params.autoRepairBlocked) {
lines.push(
`- Auto-removal is paused because plugin discovery currently has errors. Fix plugin discovery first, then rerun "${params.doctorFixCommand}".`,
+3 -5
View File
@@ -764,10 +764,7 @@ describe("CORE_HEALTH_CHECKS", () => {
createDeps({
async collectWorkspaceSuggestionNotes(): Promise<readonly string[]> {
return [
[
"- Tip: back up the workspace in a private git repo (GitHub or GitLab).",
"- Keep ~/.openclaw out of git; it contains credentials and session history.",
].join("\n"),
"- Tip: back up the agent workspace in a private git repo; keep ~/.openclaw out of git (credentials, sessions). Details: /concepts/agent-workspace#git-backup-recommended",
"Memory system not found in workspace.",
];
},
@@ -793,7 +790,8 @@ describe("CORE_HEALTH_CHECKS", () => {
expect.objectContaining({
checkId: "core/doctor/workspace-suggestions",
severity: "info",
message: "Tip: back up the workspace in a private git repo (GitHub or GitLab).",
message:
"Tip: back up the agent workspace in a private git repo; keep ~/.openclaw out of git (credentials, sessions). Details: /concepts/agent-workspace#git-backup-recommended",
}),
);
expect(findings).toContainEqual(
-2
View File
@@ -21,9 +21,7 @@ export async function doctorCommand(runtime?: RuntimeEnv, options: DoctorOptions
}
const { createDoctorPrompter } = await import("../commands/doctor-prompter.js");
const { printWizardHeader } = await import("../commands/onboard-helpers.js");
const prompter = createDoctorPrompter({ runtime: effectiveRuntime, options });
await printWizardHeader(effectiveRuntime);
intro("OpenClaw doctor");
const { resolveOpenClawPackageRoot } = await import("../infra/openclaw-root.js");