mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-24 20:33:03 +00:00
perf: isolate doctor core check tests (#84493)
Merged via squash.
Prepared head SHA: 6229656ba1
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Reviewed-by: @frankekn
This commit is contained in:
@@ -2,6 +2,12 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changes
|
||||
|
||||
- Tests/perf: isolate doctor core health check unit coverage from real skills/workspace discovery so `doctor-core-checks` no longer dominates unit perf while keeping one real skills-readiness smoke. (#84493) Thanks @frankekn.
|
||||
|
||||
## 2026.5.20
|
||||
|
||||
### Changes
|
||||
@@ -68,7 +74,6 @@ Docs: https://docs.openclaw.ai
|
||||
- CLI/gateway: include the running Gateway version in `gateway status` JSON output, preserving existing server metadata while falling back to status RPC data for read probes. Fixes #56222. Thanks @galiniliev.
|
||||
- Memory/search: close local embedding providers when active-memory searches time out so pending local model loads and embedding contexts are aborted and released. (#83858) Thanks @brokemac79.
|
||||
- CLI/nodes: request pending node surface approval scopes before `openclaw nodes approve` so exec-capable node approval can use admin-scoped Gateway credentials instead of failing with `missing scope: operator.admin`. (#84392) Thanks @joshavant.
|
||||
|
||||
- Agents: include bounded trajectory queued-writer diagnostics in `pi-trajectory-flush` timeout warnings so flush stalls show pending writes, queued bytes, and append state. Fixes #82961. (#82962) Thanks @galiniliev.
|
||||
- Agents/subagents: recover stale completion announces by retrying unsupported transcript-wait wakes without transcript waiting and forcing a message-tool handoff when the requester run is already stale. Fixes #83699. (#83700) Thanks @galiniliev.
|
||||
- Agents/subagents: constrain wildcard subagent target allowlists to configured agents while preserving explicitly listed compatibility targets. Fixes #84040. (#84357) Thanks @joshavant.
|
||||
|
||||
35
src/commands/doctor-skills-core.ts
Normal file
35
src/commands/doctor-skills-core.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { SkillStatusEntry, SkillStatusReport } from "../agents/skills-status.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
|
||||
export function collectUnavailableAgentSkills(report: SkillStatusReport): SkillStatusEntry[] {
|
||||
return report.skills.filter(
|
||||
(skill) =>
|
||||
!skill.eligible &&
|
||||
!skill.disabled &&
|
||||
!skill.blockedByAllowlist &&
|
||||
!skill.blockedByAgentFilter,
|
||||
);
|
||||
}
|
||||
|
||||
export function disableUnavailableSkillsInConfig(
|
||||
config: OpenClawConfig,
|
||||
skills: readonly SkillStatusEntry[],
|
||||
): OpenClawConfig {
|
||||
if (skills.length === 0) {
|
||||
return config;
|
||||
}
|
||||
const entries = { ...config.skills?.entries };
|
||||
for (const skill of skills) {
|
||||
entries[skill.skillKey] = {
|
||||
...entries[skill.skillKey],
|
||||
enabled: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...config,
|
||||
skills: {
|
||||
...config.skills,
|
||||
entries,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import type { SkillStatusEntry, SkillStatusReport } from "../agents/skills-status.js";
|
||||
import type { SkillStatusEntry } from "../agents/skills-status.js";
|
||||
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
|
||||
import {
|
||||
detectGhConfigDirMismatch,
|
||||
@@ -12,16 +12,15 @@ import { formatCliCommand } from "../cli/command-format.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { note } from "../terminal/note.js";
|
||||
import type { DoctorPrompter } from "./doctor-prompter.js";
|
||||
import {
|
||||
collectUnavailableAgentSkills,
|
||||
disableUnavailableSkillsInConfig,
|
||||
} from "./doctor-skills-core.js";
|
||||
|
||||
export function collectUnavailableAgentSkills(report: SkillStatusReport): SkillStatusEntry[] {
|
||||
return report.skills.filter(
|
||||
(skill) =>
|
||||
!skill.eligible &&
|
||||
!skill.disabled &&
|
||||
!skill.blockedByAllowlist &&
|
||||
!skill.blockedByAgentFilter,
|
||||
);
|
||||
}
|
||||
export {
|
||||
collectUnavailableAgentSkills,
|
||||
disableUnavailableSkillsInConfig,
|
||||
} from "./doctor-skills-core.js";
|
||||
|
||||
function formatMissingSummary(skill: SkillStatusEntry): string {
|
||||
const missing: string[] = [];
|
||||
@@ -100,29 +99,6 @@ export function formatUnavailableSkillDoctorLines(skills: SkillStatusEntry[]): s
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function disableUnavailableSkillsInConfig(
|
||||
config: OpenClawConfig,
|
||||
skills: readonly SkillStatusEntry[],
|
||||
): OpenClawConfig {
|
||||
if (skills.length === 0) {
|
||||
return config;
|
||||
}
|
||||
const entries = { ...config.skills?.entries };
|
||||
for (const skill of skills) {
|
||||
entries[skill.skillKey] = {
|
||||
...entries[skill.skillKey],
|
||||
enabled: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...config,
|
||||
skills: {
|
||||
...config.skills,
|
||||
entries,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function maybeRepairSkillReadiness(params: {
|
||||
cfg: OpenClawConfig;
|
||||
prompter: DoctorPrompter;
|
||||
|
||||
114
src/flows/doctor-core-checks.e2e.test.ts
Normal file
114
src/flows/doctor-core-checks.e2e.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { CORE_HEALTH_CHECKS } from "./doctor-core-checks.js";
|
||||
import type { HealthCheck } from "./health-checks.js";
|
||||
|
||||
const runtime = { log() {}, error() {}, exit() {} };
|
||||
|
||||
function getCheck(id: string): HealthCheck {
|
||||
const check = CORE_HEALTH_CHECKS.find((entry) => entry.id === id);
|
||||
if (!check) {
|
||||
throw new Error(`Missing health check ${id}`);
|
||||
}
|
||||
return check;
|
||||
}
|
||||
|
||||
describe("doctor core skills readiness smoke", () => {
|
||||
let tmp: string | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
if (tmp !== undefined) {
|
||||
await fs.rm(tmp, { recursive: true, force: true });
|
||||
tmp = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it("detects and repairs a real unavailable workspace skill", async () => {
|
||||
tmp = await fs.mkdtemp(join(tmpdir(), "openclaw-health-skills-"));
|
||||
const skillDir = join(tmp, "skills", "missing-tool");
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
join(skillDir, "SKILL.md"),
|
||||
`---
|
||||
name: missing-tool
|
||||
description: Missing tool
|
||||
metadata: '{"openclaw":{"requires":{"bins":["openclaw-test-missing-skill-bin"]}}}'
|
||||
---
|
||||
|
||||
# Missing tool
|
||||
`,
|
||||
"utf-8",
|
||||
);
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: tmp,
|
||||
skills: ["missing-tool"],
|
||||
},
|
||||
},
|
||||
};
|
||||
const check = getCheck("core/doctor/skills-readiness");
|
||||
|
||||
const findings = await check.detect({
|
||||
mode: "lint",
|
||||
runtime,
|
||||
cfg,
|
||||
cwd: tmp,
|
||||
});
|
||||
expect(findings).toContainEqual(
|
||||
expect.objectContaining({
|
||||
checkId: "core/doctor/skills-readiness",
|
||||
severity: "warning",
|
||||
path: "skills.entries.missing-tool.enabled",
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
check.detect(
|
||||
{
|
||||
mode: "fix",
|
||||
runtime,
|
||||
cfg,
|
||||
cwd: tmp,
|
||||
},
|
||||
{ paths: ["skills.entries.other-tool.enabled"] },
|
||||
),
|
||||
).resolves.toEqual([]);
|
||||
await expect(
|
||||
check.detect(
|
||||
{
|
||||
mode: "fix",
|
||||
runtime,
|
||||
cfg,
|
||||
cwd: tmp,
|
||||
},
|
||||
{ paths: ["skills.entries.missing-tool.enabled"] },
|
||||
),
|
||||
).resolves.toContainEqual(
|
||||
expect.objectContaining({
|
||||
path: "skills.entries.missing-tool.enabled",
|
||||
}),
|
||||
);
|
||||
|
||||
const repaired = await check.repair?.(
|
||||
{
|
||||
mode: "fix",
|
||||
runtime,
|
||||
cfg,
|
||||
cwd: tmp,
|
||||
},
|
||||
findings,
|
||||
);
|
||||
expect(repaired?.config?.skills?.entries?.["missing-tool"]).toEqual({ enabled: false });
|
||||
expect(repaired?.changes).toContain("Disabled unavailable skill missing-tool.");
|
||||
expect(repaired?.effects).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "config",
|
||||
action: "disable-skill",
|
||||
target: "skills.entries.missing-tool.enabled",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
14
src/flows/doctor-core-checks.runtime.ts
Normal file
14
src/flows/doctor-core-checks.runtime.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { buildWorkspaceSkillStatus, type SkillStatusEntry } from "../agents/skills-status.js";
|
||||
import { collectUnavailableAgentSkills } from "../commands/doctor-skills-core.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
|
||||
export function detectUnavailableSkills(cfg: OpenClawConfig): SkillStatusEntry[] {
|
||||
const agentId = resolveDefaultAgentId(cfg);
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
||||
const report = buildWorkspaceSkillStatus(workspaceDir, {
|
||||
config: cfg,
|
||||
agentId,
|
||||
});
|
||||
return collectUnavailableAgentSkills(report);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { SkillStatusEntry } from "../agents/skills-status.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import {
|
||||
CORE_HEALTH_CHECKS,
|
||||
createCoreHealthChecks,
|
||||
type CoreHealthCheckDeps,
|
||||
registerCoreHealthChecks,
|
||||
resetCoreHealthChecksForTest,
|
||||
} from "./doctor-core-checks.js";
|
||||
@@ -14,22 +14,76 @@ import {
|
||||
listHealthChecks,
|
||||
registerHealthCheck,
|
||||
} from "./health-check-registry.js";
|
||||
import type { HealthCheck } from "./health-checks.js";
|
||||
|
||||
const runtime = { log() {}, error() {}, exit() {} };
|
||||
|
||||
function createSkill(overrides: Partial<SkillStatusEntry> = {}): SkillStatusEntry {
|
||||
return {
|
||||
name: "missing-tool",
|
||||
description: "Missing tool",
|
||||
source: "workspace",
|
||||
bundled: false,
|
||||
filePath: "/tmp/openclaw-test-workspace/skills/missing-tool/SKILL.md",
|
||||
baseDir: "/tmp/openclaw-test-workspace/skills/missing-tool",
|
||||
skillKey: "missing-tool",
|
||||
always: false,
|
||||
disabled: false,
|
||||
blockedByAllowlist: false,
|
||||
blockedByAgentFilter: false,
|
||||
eligible: false,
|
||||
modelVisible: false,
|
||||
userInvocable: true,
|
||||
commandVisible: false,
|
||||
requirements: {
|
||||
bins: ["openclaw-test-missing-skill-bin"],
|
||||
anyBins: [],
|
||||
env: [],
|
||||
config: [],
|
||||
os: [],
|
||||
},
|
||||
missing: {
|
||||
bins: ["openclaw-test-missing-skill-bin"],
|
||||
anyBins: [],
|
||||
env: [],
|
||||
config: [],
|
||||
os: [],
|
||||
},
|
||||
configChecks: [],
|
||||
install: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createDeps(overrides: Partial<CoreHealthCheckDeps> = {}): CoreHealthCheckDeps {
|
||||
return {
|
||||
async detectUnavailableSkills(): Promise<readonly SkillStatusEntry[]> {
|
||||
return [];
|
||||
},
|
||||
async collectSecurityWarnings(): Promise<readonly string[]> {
|
||||
return [];
|
||||
},
|
||||
async collectWorkspaceSuggestionNotes(): Promise<readonly string[]> {
|
||||
return [];
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function getCheck(checks: readonly HealthCheck[], id: string): HealthCheck {
|
||||
const check = checks.find((entry) => entry.id === id);
|
||||
if (!check) {
|
||||
throw new Error(`Missing health check ${id}`);
|
||||
}
|
||||
return check;
|
||||
}
|
||||
|
||||
describe("registerCoreHealthChecks", () => {
|
||||
let tmp: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
clearHealthChecksForTest();
|
||||
resetCoreHealthChecksForTest();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (tmp !== undefined) {
|
||||
await fs.rm(tmp, { recursive: true, force: true });
|
||||
tmp = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it("registers the built-in health checks once", () => {
|
||||
registerCoreHealthChecks();
|
||||
registerCoreHealthChecks();
|
||||
@@ -88,39 +142,34 @@ describe("registerCoreHealthChecks", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("shows the repair-capable health check shape with skills readiness", async () => {
|
||||
tmp = await fs.mkdtemp(join(tmpdir(), "openclaw-health-skills-"));
|
||||
const skillDir = join(tmp, "skills", "missing-tool");
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
join(skillDir, "SKILL.md"),
|
||||
`---
|
||||
name: missing-tool
|
||||
description: Missing tool
|
||||
metadata: '{"openclaw":{"requires":{"bins":["openclaw-test-missing-skill-bin"]}}}'
|
||||
---
|
||||
|
||||
# Missing tool
|
||||
`,
|
||||
"utf-8",
|
||||
);
|
||||
it("converts unavailable skills into repair-capable health findings", async () => {
|
||||
const unavailableSkill = createSkill();
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: tmp,
|
||||
workspace: "/tmp/openclaw-test-workspace",
|
||||
skills: ["missing-tool"],
|
||||
},
|
||||
},
|
||||
};
|
||||
const check = CORE_HEALTH_CHECKS.find((entry) => entry.id === "core/doctor/skills-readiness");
|
||||
const check = getCheck(
|
||||
createCoreHealthChecks(
|
||||
createDeps({
|
||||
async detectUnavailableSkills(): Promise<readonly SkillStatusEntry[]> {
|
||||
return [unavailableSkill];
|
||||
},
|
||||
}),
|
||||
),
|
||||
"core/doctor/skills-readiness",
|
||||
);
|
||||
|
||||
expect(check?.repair).toBeTypeOf("function");
|
||||
expect(check.repair).toBeTypeOf("function");
|
||||
|
||||
const findings = await check?.detect({
|
||||
const findings = await check.detect({
|
||||
mode: "lint",
|
||||
runtime: { log() {}, error() {}, exit() {} },
|
||||
runtime,
|
||||
cfg,
|
||||
cwd: tmp,
|
||||
cwd: "/tmp/openclaw-test-workspace",
|
||||
});
|
||||
expect(findings).toContainEqual(
|
||||
expect.objectContaining({
|
||||
@@ -130,23 +179,23 @@ metadata: '{"openclaw":{"requires":{"bins":["openclaw-test-missing-skill-bin"]}}
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
check?.detect(
|
||||
check.detect(
|
||||
{
|
||||
mode: "fix",
|
||||
runtime: { log() {}, error() {}, exit() {} },
|
||||
runtime,
|
||||
cfg,
|
||||
cwd: tmp,
|
||||
cwd: "/tmp/openclaw-test-workspace",
|
||||
},
|
||||
{ paths: ["skills.entries.other-tool.enabled"] },
|
||||
),
|
||||
).resolves.toEqual([]);
|
||||
await expect(
|
||||
check?.detect(
|
||||
check.detect(
|
||||
{
|
||||
mode: "fix",
|
||||
runtime: { log() {}, error() {}, exit() {} },
|
||||
runtime,
|
||||
cfg,
|
||||
cwd: tmp,
|
||||
cwd: "/tmp/openclaw-test-workspace",
|
||||
},
|
||||
{ paths: ["skills.entries.missing-tool.enabled"] },
|
||||
),
|
||||
@@ -156,14 +205,14 @@ metadata: '{"openclaw":{"requires":{"bins":["openclaw-test-missing-skill-bin"]}}
|
||||
}),
|
||||
);
|
||||
|
||||
const repaired = await check?.repair?.(
|
||||
const repaired = await check.repair?.(
|
||||
{
|
||||
mode: "fix",
|
||||
runtime: { log() {}, error() {}, exit() {} },
|
||||
runtime,
|
||||
cfg,
|
||||
cwd: tmp,
|
||||
cwd: "/tmp/openclaw-test-workspace",
|
||||
},
|
||||
findings ?? [],
|
||||
findings,
|
||||
);
|
||||
expect(repaired?.config?.skills?.entries?.["missing-tool"]).toEqual({ enabled: false });
|
||||
expect(repaired?.changes).toContain("Disabled unavailable skill missing-tool.");
|
||||
@@ -177,11 +226,23 @@ metadata: '{"openclaw":{"requires":{"bins":["openclaw-test-missing-skill-bin"]}}
|
||||
});
|
||||
|
||||
it("converts security doctor warnings into health findings", async () => {
|
||||
const check = CORE_HEALTH_CHECKS.find((entry) => entry.id === "core/doctor/security");
|
||||
const check = getCheck(
|
||||
createCoreHealthChecks(
|
||||
createDeps({
|
||||
async collectSecurityWarnings(): Promise<readonly string[]> {
|
||||
return [
|
||||
'- CRITICAL: Gateway bound to "lan" (0.0.0.0) without authentication.',
|
||||
'- WARNING: Gateway bound to "lan" (0.0.0.0).',
|
||||
];
|
||||
},
|
||||
}),
|
||||
),
|
||||
"core/doctor/security",
|
||||
);
|
||||
|
||||
const findings = await check?.detect({
|
||||
const findings = await check.detect({
|
||||
mode: "lint",
|
||||
runtime: { log() {}, error() {}, exit() {} },
|
||||
runtime,
|
||||
cfg: {
|
||||
gateway: {
|
||||
bind: "lan",
|
||||
@@ -199,25 +260,44 @@ metadata: '{"openclaw":{"requires":{"bins":["openclaw-test-missing-skill-bin"]}}
|
||||
message: expect.stringContaining("Gateway bound"),
|
||||
}),
|
||||
);
|
||||
expect(findings).toContainEqual(
|
||||
expect.objectContaining({
|
||||
checkId: "core/doctor/security",
|
||||
severity: "warning",
|
||||
message: expect.stringContaining("Gateway bound"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("converts workspace suggestions into info findings", async () => {
|
||||
tmp = await fs.mkdtemp(join(tmpdir(), "openclaw-health-workspace-"));
|
||||
const check = CORE_HEALTH_CHECKS.find(
|
||||
(entry) => entry.id === "core/doctor/workspace-suggestions",
|
||||
const check = getCheck(
|
||||
createCoreHealthChecks(
|
||||
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"),
|
||||
"Memory system not found in workspace.",
|
||||
];
|
||||
},
|
||||
}),
|
||||
),
|
||||
"core/doctor/workspace-suggestions",
|
||||
);
|
||||
|
||||
const findings = await check?.detect({
|
||||
const findings = await check.detect({
|
||||
mode: "lint",
|
||||
runtime: { log() {}, error() {}, exit() {} },
|
||||
runtime,
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: tmp,
|
||||
workspace: "/tmp/openclaw-test-workspace",
|
||||
},
|
||||
},
|
||||
},
|
||||
cwd: tmp,
|
||||
cwd: "/tmp/openclaw-test-workspace",
|
||||
});
|
||||
|
||||
expect(findings).toContainEqual(
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import path from "node:path";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { buildWorkspaceSkillStatus, type SkillStatusEntry } from "../agents/skills-status.js";
|
||||
import type { SkillStatusEntry } from "../agents/skills-status.js";
|
||||
import {
|
||||
detectLegacyClawdBrowserProfileResidue,
|
||||
maybeArchiveLegacyClawdBrowserProfileResidue,
|
||||
type LegacyClawdBrowserProfileResidue,
|
||||
} from "../commands/doctor-browser.js";
|
||||
import { hasConfiguredCommandOwners } from "../commands/doctor-command-owner.js";
|
||||
import {
|
||||
collectUnavailableAgentSkills,
|
||||
disableUnavailableSkillsInConfig,
|
||||
} from "../commands/doctor-skills.js";
|
||||
import { disableUnavailableSkillsInConfig } from "../commands/doctor-skills-core.js";
|
||||
import type { ConfigValidationIssue, OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { resolveSecretInputRef } from "../config/types.secrets.js";
|
||||
import { hasAmbiguousGatewayAuthModeConfig } from "../gateway/auth-mode-policy.js";
|
||||
@@ -21,6 +18,47 @@ import type { HealthCheck, HealthFinding } from "./health-checks.js";
|
||||
const BROWSER_CLAWD_PROFILE_RESIDUE_CHECK_ID = "core/doctor/browser-clawd-profile-residue";
|
||||
const FINAL_CONFIG_VALIDATION_CHECK_ID = "core/doctor/final-config-validation";
|
||||
|
||||
export type CoreHealthCheckDeps = {
|
||||
readonly detectUnavailableSkills: (cfg: OpenClawConfig) => Promise<readonly SkillStatusEntry[]>;
|
||||
readonly collectSecurityWarnings: (cfg: OpenClawConfig) => Promise<readonly string[]>;
|
||||
readonly collectWorkspaceSuggestionNotes: (workspaceDir: string) => Promise<readonly string[]>;
|
||||
};
|
||||
|
||||
async function detectUnavailableSkillsWithRuntime(
|
||||
cfg: OpenClawConfig,
|
||||
): Promise<readonly SkillStatusEntry[]> {
|
||||
const runtime = await import("./doctor-core-checks.runtime.js");
|
||||
return runtime.detectUnavailableSkills(cfg);
|
||||
}
|
||||
|
||||
async function collectSecurityWarningsWithRuntime(cfg: OpenClawConfig): Promise<readonly string[]> {
|
||||
const { collectSecurityWarnings } = await import("../commands/doctor-security.js");
|
||||
return collectSecurityWarnings(cfg);
|
||||
}
|
||||
|
||||
async function collectWorkspaceSuggestionNotesWithRuntime(
|
||||
workspaceDir: string,
|
||||
): Promise<readonly string[]> {
|
||||
const { collectWorkspaceBackupTip } = await import("../commands/doctor-state-integrity.js");
|
||||
const { MEMORY_SYSTEM_PROMPT, shouldSuggestMemorySystem } =
|
||||
await import("../commands/doctor-workspace.js");
|
||||
const notes: string[] = [];
|
||||
const backupTip = collectWorkspaceBackupTip(workspaceDir);
|
||||
if (backupTip) {
|
||||
notes.push(backupTip);
|
||||
}
|
||||
if (await shouldSuggestMemorySystem(workspaceDir)) {
|
||||
notes.push(MEMORY_SYSTEM_PROMPT);
|
||||
}
|
||||
return notes;
|
||||
}
|
||||
|
||||
const defaultCoreHealthCheckDeps: CoreHealthCheckDeps = {
|
||||
detectUnavailableSkills: detectUnavailableSkillsWithRuntime,
|
||||
collectSecurityWarnings: collectSecurityWarningsWithRuntime,
|
||||
collectWorkspaceSuggestionNotes: collectWorkspaceSuggestionNotesWithRuntime,
|
||||
};
|
||||
|
||||
export function configValidationIssuesToHealthFindings(
|
||||
issues: readonly ConfigValidationIssue[],
|
||||
): readonly HealthFinding[] {
|
||||
@@ -385,23 +423,24 @@ const claudeCliCheck: HealthCheck = {
|
||||
},
|
||||
};
|
||||
|
||||
const securityCheck: HealthCheck = {
|
||||
id: "core/doctor/security",
|
||||
kind: "core",
|
||||
description: "Security posture checks produce structured findings.",
|
||||
source: "doctor",
|
||||
async detect(ctx) {
|
||||
const { collectSecurityWarnings } = await import("../commands/doctor-security.js");
|
||||
const warnings = await collectSecurityWarnings(ctx.cfg);
|
||||
return warnings.map((warning) =>
|
||||
noteTextToFinding({
|
||||
checkId: "core/doctor/security",
|
||||
severity: warning.includes("CRITICAL") ? "error" : "warning",
|
||||
text: warning,
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
function createSecurityCheck(deps: CoreHealthCheckDeps): HealthCheck {
|
||||
return {
|
||||
id: "core/doctor/security",
|
||||
kind: "core",
|
||||
description: "Security posture checks produce structured findings.",
|
||||
source: "doctor",
|
||||
async detect(ctx) {
|
||||
const warnings = await deps.collectSecurityWarnings(ctx.cfg);
|
||||
return warnings.map((warning) =>
|
||||
noteTextToFinding({
|
||||
checkId: "core/doctor/security",
|
||||
severity: warning.includes("CRITICAL") ? "error" : "warning",
|
||||
text: warning,
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const openAIOAuthTlsCheck: HealthCheck = {
|
||||
id: "core/doctor/oauth-tls",
|
||||
@@ -513,39 +552,41 @@ const workspaceStatusCheck: HealthCheck = {
|
||||
},
|
||||
};
|
||||
|
||||
const skillsReadinessCheck: HealthCheck = {
|
||||
id: "core/doctor/skills-readiness",
|
||||
kind: "core",
|
||||
description: "Allowed skills are usable in the current runtime environment.",
|
||||
source: "doctor",
|
||||
async detect(ctx, scope) {
|
||||
const unavailable = filterUnavailableSkillsForScope(
|
||||
detectUnavailableSkills(ctx.cfg),
|
||||
scope?.paths,
|
||||
);
|
||||
return unavailable.map(unavailableSkillToFinding);
|
||||
},
|
||||
async repair(ctx, findings) {
|
||||
const unavailable = filterUnavailableSkillsForScope(
|
||||
detectUnavailableSkills(ctx.cfg),
|
||||
findings.map((finding) => finding.path),
|
||||
);
|
||||
if (unavailable.length === 0) {
|
||||
return { changes: [] };
|
||||
}
|
||||
const nextConfig = disableUnavailableSkillsInConfig(ctx.cfg, unavailable);
|
||||
return {
|
||||
config: nextConfig,
|
||||
changes: unavailable.map((skill) => `Disabled unavailable skill ${skill.name}.`),
|
||||
effects: unavailable.map((skill) => ({
|
||||
kind: "config" as const,
|
||||
action: ctx.dryRun === true ? "would-disable-skill" : "disable-skill",
|
||||
target: skillReadinessPath(skill),
|
||||
dryRunSafe: true,
|
||||
})),
|
||||
};
|
||||
},
|
||||
};
|
||||
function createSkillsReadinessCheck(deps: CoreHealthCheckDeps): HealthCheck {
|
||||
return {
|
||||
id: "core/doctor/skills-readiness",
|
||||
kind: "core",
|
||||
description: "Allowed skills are usable in the current runtime environment.",
|
||||
source: "doctor",
|
||||
async detect(ctx, scope) {
|
||||
const unavailable = filterUnavailableSkillsForScope(
|
||||
await deps.detectUnavailableSkills(ctx.cfg),
|
||||
scope?.paths,
|
||||
);
|
||||
return unavailable.map(unavailableSkillToFinding);
|
||||
},
|
||||
async repair(ctx, findings) {
|
||||
const unavailable = filterUnavailableSkillsForScope(
|
||||
await deps.detectUnavailableSkills(ctx.cfg),
|
||||
findings.map((finding) => finding.path),
|
||||
);
|
||||
if (unavailable.length === 0) {
|
||||
return { changes: [] };
|
||||
}
|
||||
const nextConfig = disableUnavailableSkillsInConfig(ctx.cfg, unavailable);
|
||||
return {
|
||||
config: nextConfig,
|
||||
changes: unavailable.map((skill) => `Disabled unavailable skill ${skill.name}.`),
|
||||
effects: unavailable.map((skill) => ({
|
||||
kind: "config" as const,
|
||||
action: ctx.dryRun === true ? "would-disable-skill" : "disable-skill",
|
||||
target: skillReadinessPath(skill),
|
||||
dryRunSafe: true,
|
||||
})),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function unavailableSkillToFinding(skill: SkillStatusEntry): HealthFinding {
|
||||
return {
|
||||
@@ -674,54 +715,42 @@ const finalConfigValidationCheck: HealthCheck = {
|
||||
},
|
||||
};
|
||||
|
||||
const workspaceSuggestionsCheck: HealthCheck = {
|
||||
id: "core/doctor/workspace-suggestions",
|
||||
kind: "core",
|
||||
description:
|
||||
"Workspace backup and memory-system suggestions are captured as structured findings.",
|
||||
source: "doctor",
|
||||
async detect(ctx) {
|
||||
const { collectWorkspaceBackupTip } = await import("../commands/doctor-state-integrity.js");
|
||||
const { MEMORY_SYSTEM_PROMPT, shouldSuggestMemorySystem } =
|
||||
await import("../commands/doctor-workspace.js");
|
||||
const workspaceDir = resolveAgentWorkspaceDir(ctx.cfg, resolveDefaultAgentId(ctx.cfg));
|
||||
const findings: HealthFinding[] = [];
|
||||
const backupTip = collectWorkspaceBackupTip(workspaceDir);
|
||||
if (backupTip) {
|
||||
findings.push(
|
||||
function createWorkspaceSuggestionsCheck(deps: CoreHealthCheckDeps): HealthCheck {
|
||||
return {
|
||||
id: "core/doctor/workspace-suggestions",
|
||||
kind: "core",
|
||||
description:
|
||||
"Workspace backup and memory-system suggestions are captured as structured findings.",
|
||||
source: "doctor",
|
||||
async detect(ctx) {
|
||||
const workspaceDir = resolveAgentWorkspaceDir(ctx.cfg, resolveDefaultAgentId(ctx.cfg));
|
||||
const notes = await deps.collectWorkspaceSuggestionNotes(workspaceDir);
|
||||
return notes.map((text) =>
|
||||
noteTextToFinding({
|
||||
checkId: "core/doctor/workspace-suggestions",
|
||||
severity: "info",
|
||||
text: backupTip,
|
||||
text,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (await shouldSuggestMemorySystem(workspaceDir)) {
|
||||
findings.push(
|
||||
noteTextToFinding({
|
||||
checkId: "core/doctor/workspace-suggestions",
|
||||
severity: "info",
|
||||
text: MEMORY_SYSTEM_PROMPT,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const convertedWorkflowChecks: readonly HealthCheck[] = [
|
||||
claudeCliCheck,
|
||||
gatewayAuthCheck,
|
||||
legacyStateCheck,
|
||||
legacyWhatsAppCrontabCheck,
|
||||
gatewayPlatformNotesCheck,
|
||||
securityCheck,
|
||||
browserCheck,
|
||||
openAIOAuthTlsCheck,
|
||||
hooksModelCheck,
|
||||
bootstrapSizeCheck,
|
||||
workspaceSuggestionsCheck,
|
||||
];
|
||||
function createConvertedWorkflowChecks(deps: CoreHealthCheckDeps): readonly HealthCheck[] {
|
||||
return [
|
||||
claudeCliCheck,
|
||||
gatewayAuthCheck,
|
||||
legacyStateCheck,
|
||||
legacyWhatsAppCrontabCheck,
|
||||
gatewayPlatformNotesCheck,
|
||||
createSecurityCheck(deps),
|
||||
browserCheck,
|
||||
openAIOAuthTlsCheck,
|
||||
hooksModelCheck,
|
||||
bootstrapSizeCheck,
|
||||
createWorkspaceSuggestionsCheck(deps),
|
||||
];
|
||||
}
|
||||
|
||||
let registered = false;
|
||||
|
||||
@@ -729,15 +758,9 @@ export function registerCoreHealthChecks(): void {
|
||||
if (registered) {
|
||||
return;
|
||||
}
|
||||
registerHealthCheck(gatewayConfigCheck);
|
||||
for (const check of convertedWorkflowChecks) {
|
||||
for (const check of CORE_HEALTH_CHECKS) {
|
||||
registerHealthCheck(check);
|
||||
}
|
||||
registerHealthCheck(commandOwnerCheck);
|
||||
registerHealthCheck(workspaceStatusCheck);
|
||||
registerHealthCheck(skillsReadinessCheck);
|
||||
registerHealthCheck(browserClawdProfileResidueCheck);
|
||||
registerHealthCheck(finalConfigValidationCheck);
|
||||
registered = true;
|
||||
}
|
||||
|
||||
@@ -745,26 +768,22 @@ export function resetCoreHealthChecksForTest(): void {
|
||||
registered = false;
|
||||
}
|
||||
|
||||
export const CORE_HEALTH_CHECKS: readonly HealthCheck[] = [
|
||||
gatewayConfigCheck,
|
||||
...convertedWorkflowChecks,
|
||||
commandOwnerCheck,
|
||||
workspaceStatusCheck,
|
||||
skillsReadinessCheck,
|
||||
browserClawdProfileResidueCheck,
|
||||
finalConfigValidationCheck,
|
||||
];
|
||||
|
||||
function detectUnavailableSkills(cfg: OpenClawConfig): SkillStatusEntry[] {
|
||||
const agentId = resolveDefaultAgentId(cfg);
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
||||
const report = buildWorkspaceSkillStatus(workspaceDir, {
|
||||
config: cfg,
|
||||
agentId,
|
||||
});
|
||||
return collectUnavailableAgentSkills(report);
|
||||
export function createCoreHealthChecks(
|
||||
deps: CoreHealthCheckDeps = defaultCoreHealthCheckDeps,
|
||||
): readonly HealthCheck[] {
|
||||
return [
|
||||
gatewayConfigCheck,
|
||||
...createConvertedWorkflowChecks(deps),
|
||||
commandOwnerCheck,
|
||||
workspaceStatusCheck,
|
||||
createSkillsReadinessCheck(deps),
|
||||
browserClawdProfileResidueCheck,
|
||||
finalConfigValidationCheck,
|
||||
];
|
||||
}
|
||||
|
||||
export const CORE_HEALTH_CHECKS: readonly HealthCheck[] = createCoreHealthChecks();
|
||||
|
||||
function formatMissingSkillSummary(skill: SkillStatusEntry): string {
|
||||
const missing: string[] = [];
|
||||
if (skill.missing.bins.length > 0) {
|
||||
|
||||
Reference in New Issue
Block a user