mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
6958e4c969
commit
eb7c151d07
@@ -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">
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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>")}`,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}".`,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user