diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 60b1ebf114a7..4835bfc6a7a2 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -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`. @@ -469,19 +469,20 @@ That stages grounded durable candidates into the short-term dreaming store while - 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. If running as a systemd user service, doctor ensures lingering is enabled so the gateway stays alive after logout. - 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. diff --git a/src/cli/plugins-cli.list.test.ts b/src/cli/plugins-cli.list.test.ts index 2cd7b53d5156..1a01113efbb4 100644 --- a/src/cli/plugins-cli.list.test.ts +++ b/src/cli/plugins-cli.list.test.ts @@ -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".', diff --git a/src/commands/doctor-auth.profile-health.test.ts b/src/commands/doctor-auth.profile-health.test.ts index dbe0921b7da0..271138a22194 100644 --- a/src/commands/doctor-auth.profile-health.test.ts +++ b/src/commands/doctor-auth.profile-health.test.ts @@ -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 () => { diff --git a/src/commands/doctor-auth.ts b/src/commands/doctor-auth.ts index 8d96a6b184cb..9b77c3d5d45f 100644 --- a/src/commands/doctor-auth.ts +++ b/src/commands/doctor-auth.ts @@ -456,7 +456,7 @@ async function noteAuthProfileHealthForTarget(params: { allowKeychainPrompt: boolean; target: AuthProfileHealthTarget; labelAgents: boolean; -}): Promise { +}): Promise { 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>(); 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(); + 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"); } diff --git a/src/commands/doctor-claude-cli.test.ts b/src/commands/doctor-claude-cli.test.ts index 0415eb076edb..f72380c04f83 100644 --- a/src/commands/doctor-claude-cli.test.ts +++ b/src/commands/doctor-claude-cli.test.ts @@ -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}`); + }); + }); }); diff --git a/src/commands/doctor-claude-cli.ts b/src/commands/doctor-claude-cli.ts index 8db0c2c6f07d..503c99fa6fa1 100644 --- a/src/commands/doctor-claude-cli.ts +++ b/src/commands/doctor-claude-cli.ts @@ -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); } diff --git a/src/commands/doctor-security.test.ts b/src/commands/doctor-security.test.ts index 253483b17e4c..e1480a532f10 100644 --- a/src/commands/doctor-security.test.ts +++ b/src/commands/doctor-security.test.ts @@ -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 () => { diff --git a/src/commands/doctor-security.ts b/src/commands/doctor-security.ts index d1cf5117ebab..740dd584e6bc 100644 --- a/src/commands/doctor-security.ts +++ b/src/commands/doctor-security.ts @@ -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"); + } } diff --git a/src/commands/doctor-skills.test.ts b/src/commands/doctor-skills.test.ts index 5d2c5f362597..e1a192392a83 100644 --- a/src/commands/doctor-skills.test.ts +++ b/src/commands/doctor-skills.test.ts @@ -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 or openclaw skills info --agent ", + ]); + }); + + 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", () => { diff --git a/src/commands/doctor-skills.ts b/src/commands/doctor-skills.ts index 6ecf2d8a5ee2..c6d4d5ac4843 100644 --- a/src/commands/doctor-skills.ts +++ b/src/commands/doctor-skills.ts @@ -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 ")} or ${formatCliCommand("openclaw skills info --agent ")}`, diff --git a/src/commands/doctor-state-integrity.ts b/src/commands/doctor-state-integrity.ts index 2dd29b3d8149..2e9d14b33e56 100644 --- a/src/commands/doctor-state-integrity.ts +++ b/src/commands/doctor-state-integrity.ts @@ -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. */ diff --git a/src/commands/doctor-workspace-status.test.ts b/src/commands/doctor-workspace-status.test.ts index 8b1228fcd849..f662be9a813d 100644 --- a/src/commands/doctor-workspace-status.test.ts +++ b/src/commands/doctor-workspace-status.test.ts @@ -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(); - } - }); }); diff --git a/src/commands/doctor-workspace-status.ts b/src/commands/doctor-workspace-status.ts index 62e44fbc856a..c27948630e23 100644 --- a/src/commands/doctor-workspace-status.ts +++ b/src/commands/doctor-workspace-status.ts @@ -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); diff --git a/src/commands/doctor/shared/codex-native-assets.test.ts b/src/commands/doctor/shared/codex-native-assets.test.ts index a0c0f11248f4..59b86a4ffc76 100644 --- a/src/commands/doctor/shared/codex-native-assets.test.ts +++ b/src/commands/doctor/shared/codex-native-assets.test.ts @@ -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"), ]); }); diff --git a/src/commands/doctor/shared/codex-native-assets.ts b/src/commands/doctor/shared/codex-native-assets.ts index 29de6279d724..83f3d94c2048 100644 --- a/src/commands/doctor/shared/codex-native-assets.ts +++ b/src/commands/doctor/shared/codex-native-assets.ts @@ -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"), ]; } diff --git a/src/commands/doctor/shared/preview-warnings.test.ts b/src/commands/doctor/shared/preview-warnings.test.ts index ba50de866b49..5468d274891c 100644 --- a/src/commands/doctor/shared/preview-warnings.test.ts +++ b/src/commands/doctor/shared/preview-warnings.test.ts @@ -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"'); diff --git a/src/commands/doctor/shared/stale-plugin-config.test.ts b/src/commands/doctor/shared/stale-plugin-config.test.ts index 8c0f625b3b7b..7704ee94b0f8 100644 --- a/src/commands/doctor/shared/stale-plugin-config.test.ts +++ b/src/commands/doctor/shared/stale-plugin-config.test.ts @@ -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", () => { diff --git a/src/commands/doctor/shared/stale-plugin-config.ts b/src/commands/doctor/shared/stale-plugin-config.ts index 1d228ea7b4f7..4978041a8110 100644 --- a/src/commands/doctor/shared/stale-plugin-config.ts +++ b/src/commands/doctor/shared/stale-plugin-config.ts @@ -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}".`, diff --git a/src/flows/doctor-core-checks.test.ts b/src/flows/doctor-core-checks.test.ts index 2a2e940c619c..10bfc766e34e 100644 --- a/src/flows/doctor-core-checks.test.ts +++ b/src/flows/doctor-core-checks.test.ts @@ -764,10 +764,7 @@ describe("CORE_HEALTH_CHECKS", () => { createDeps({ async collectWorkspaceSuggestionNotes(): Promise { 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( diff --git a/src/flows/doctor-health.ts b/src/flows/doctor-health.ts index b3e17771d905..7c6fccdebaba 100644 --- a/src/flows/doctor-health.ts +++ b/src/flows/doctor-health.ts @@ -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");