fix(doctor): stop false failures on multi-agent profiles (#124010)

* fix(doctor): resolve multi-agent health owners

* fix(doctor): keep bare json exit advisory
This commit is contained in:
Peter Steinberger
2026-08-14 21:45:55 -07:00
committed by GitHub
parent f0d277277b
commit 6e5bf3ec55
22 changed files with 569 additions and 178 deletions
+16 -12
View File
@@ -28,15 +28,15 @@ Related:
Doctor has five postures:
| Posture | Command | Behavior |
| ------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------- |
| Inspect | `openclaw doctor` | Human-oriented checks and guided prompts. |
| Repair | `openclaw doctor --fix` | Applies supported repairs, using prompts unless non-interactive repair is safe. |
| Lint | `openclaw doctor --json` | Read-only JSON findings for deployment preflight and CI gates. |
| Shared SQLite maintenance | `openclaw doctor --state-sqlite compact` | Explicitly checkpoints, compacts, and verifies the canonical shared state DB. |
| Session SQLite migration | `openclaw doctor --session-sqlite <mode>` | Inspects, imports, validates, compacts, recovers, or restores session state. |
| Posture | Command | Behavior |
| ------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------- |
| Inspect | `openclaw doctor` / `openclaw doctor --json` | Advisory checks in human or machine-readable form. |
| Repair | `openclaw doctor --fix` | Applies supported repairs, using prompts unless non-interactive repair is safe. |
| Lint | `openclaw doctor --lint [--json]` | Read-only findings with threshold-based exit codes for CI gates. |
| Shared SQLite maintenance | `openclaw doctor --state-sqlite compact` | Explicitly checkpoints, compacts, and verifies the canonical shared state DB. |
| Session SQLite migration | `openclaw doctor --session-sqlite <mode>` | Inspects, imports, validates, compacts, recovers, or restores session state. |
Use `openclaw doctor --json` as the machine-readable deployment preflight. It runs the same read-only checks, JSON output, and exit codes as `openclaw doctor --lint --json`. Prefer `--fix` when a human operator wants doctor to edit config or state.
Use `openclaw doctor --json` when an operator or script wants the advisory Doctor report as JSON. It exits successfully after producing a report; inspect `ok` and `findings` for health state. Use explicit `openclaw doctor --lint --json` when CI should exit nonzero for findings at the selected severity threshold. Prefer `--fix` when a human operator wants Doctor to edit config or state.
## Examples
@@ -94,17 +94,19 @@ openclaw channels status --probe
| `--session-sqlite-agent <id>` | With `--session-sqlite`: select one configured agent. |
| `--session-sqlite-all-agents` | With `--session-sqlite`: select configured and discovered agent stores. |
| `--github-issue` | With `--session-sqlite recover`: prepare a sanitized openclaw/openclaw issue report; doctor creates it with `gh` after `--yes` or interactive confirmation. |
| `--json` | Run lint checks in read-only mode and emit JSON. With another machine mode, emit that mode's existing JSON report. |
| `--json` | Emit read-only JSON. Bare `--json` is advisory; combine with `--lint` for threshold-based exit codes. With another machine mode, emit that mode's existing JSON report. |
| `--severity-min <level>` | With `--lint`: drop findings below `info`, `warning`, or `error`. |
| `--all` | With `--lint`: run all registered checks, including opt-in checks excluded from the default set. |
| `--skip <id>` | With `--lint`: skip a check id. Repeatable. |
| `--only <id>` | With `--lint`: run only the given check id(s). Repeatable. |
`--severity-min`, `--all`, `--only`, and `--skip` are only accepted together with `--lint`. Bare `--json` implies lint mode. It cannot be combined with `--repair`, `--fix`, or `--force` unless another machine mode owns the command.
`--severity-min`, `--all`, `--only`, and `--skip` are only accepted together with `--lint`. Bare `--json` uses the default read-only lint check selection but keeps Doctor's advisory exit behavior. It cannot be combined with `--repair`, `--fix`, or `--force` unless another machine mode owns the command.
## Lint mode
`openclaw doctor --json` is the deployment-preflight form of lint mode. It is read-only and non-interactive: no prompts, repairs, or config/state rewrites. `openclaw doctor --lint --json` remains an equivalent explicit spelling.
Bare `openclaw doctor --json` is read-only and non-interactive: no prompts, repairs, or config/state rewrites. It emits the same default findings as lint mode, but exits `0` after a report is produced so output formatting does not change ordinary Doctor's advisory success contract. Read the payload's `ok` and `findings` fields to determine health.
Explicit `openclaw doctor --lint` is the deployment-preflight posture. Add `--json` for machine-readable output without changing lint's threshold-based exit code.
```bash
openclaw doctor --json
@@ -144,7 +146,7 @@ JSON output is the scripting surface:
}
```
Exit codes:
Explicit lint exit codes:
| Code | Meaning |
| ---- | ------------------------------------------------------------- |
@@ -154,6 +156,8 @@ Exit codes:
`--severity-min` controls both which findings print and the exit threshold: `openclaw doctor --lint --severity-min error` can print nothing and exit `0` even when lower-severity `info`/`warning` findings exist.
Bare `openclaw doctor --json` exits `0` once it emits a findings payload, including when `ok` is `false`. Argument errors or runtime failures before a payload can be produced remain nonzero.
`--all` controls which checks are selected before severity filtering. The default lint run excludes checks that are deep, historical, or more likely to surface repairable legacy residue; use `--all` for the complete inventory. `--only <id>` is the most precise selector and can run any registered check by id.
`core/doctor/local-audio-acceleration` reports the auto-selected local STT command, separate capable/requested/observed backend evidence, and fallback order without loading a speech model. It emits an informational finding, so include `--severity-min info` to display it.
+9 -6
View File
@@ -84,11 +84,12 @@ cat ~/.openclaw/openclaw.json
`openclaw doctor --fix`. They share the same Doctor rule registry, but they do
not select or act on rules in the same way:
| Mode | Prompts | Writes config/state | Output | Use it for |
| ------------------------ | --------- | ----------------------- | ---------------------- | ------------------------------- |
| `openclaw doctor` | yes | no | friendly health report | a human checking status |
| `openclaw doctor --fix` | sometimes | yes, with repair policy | friendly repair log | applying approved repairs |
| `openclaw doctor --lint` | no | no | structured findings | CI, preflight, and review gates |
| Mode | Prompts | Writes config/state | Output | Use it for |
| ------------------------ | --------- | ----------------------- | ---------------------- | -------------------------------- |
| `openclaw doctor` | yes | no | friendly health report | a human checking status |
| `openclaw doctor --json` | no | no | JSON advisory report | machine-readable operator checks |
| `openclaw doctor --fix` | sometimes | yes, with repair policy | friendly repair log | applying approved repairs |
| `openclaw doctor --lint` | no | no | structured findings | CI, preflight, and review gates |
Default `doctor --lint` runs the broad-safe automation profile: checks that are
static, local, and useful in CI or preflight output. It skips opt-in checks that
@@ -134,13 +135,15 @@ Exit codes:
| `1` | one or more findings met the selected threshold |
| `2` | command/runtime failure before findings could be emitted |
These threshold-based exit codes belong to explicit `--lint` mode, with or without `--json`. Bare `openclaw doctor --json` preserves ordinary Doctor's advisory exit `0` after producing its payload; machine consumers should read `ok` and `findings`. Fatal errors before output remain nonzero.
Flags:
- `--severity-min info|warning|error` (default `warning`): controls both what prints and what causes a non-zero exit.
- `--all`: runs every registered lint check, including opt-in checks excluded from the default automation set.
- `--only <id>` (repeatable): run only the named check id(s); an unknown id is reported as an error finding.
- `--skip <id>` (repeatable): exclude a check while keeping the rest of the run active.
- `--json`, `--severity-min`, `--all`, `--only`, and `--skip` require `--lint`; plain `openclaw doctor` and `--fix` runs reject them.
- `--severity-min`, `--all`, `--only`, and `--skip` require `--lint`. Bare `--json` is allowed for an advisory machine-readable report; `--fix` rejects it unless another machine mode owns the output.
## What it does (summary)
@@ -73,6 +73,54 @@ describe("memory-lancedb doctor migration", () => {
migratedConnection.close();
});
test("assigns explicit-roster legacy rows to the configured system agent", async () => {
const connection = await lancedb.connect(getDbPath());
const table = await connection.createTable("memories", [
{
id: "12121212-1212-4121-8121-121212121212",
text: "legacy system memory",
vector: [1, 0],
importance: 0.7,
category: "fact",
createdAt: 1,
},
]);
table.close();
connection.close();
const params = {
config: {
agents: {
ownership: "explicit" as const,
defaults: { systemAgent: { agentId: "Main Agent" } },
entries: { "Main Agent": {}, helper: {}, third: {} },
},
plugins: {
entries: { "memory-lancedb": { config: { dbPath: getDbPath() } } },
},
},
env: { ...process.env, HOME: getTmpDir() },
stateDir: getTmpDir(),
oauthDir: path.join(getTmpDir(), "oauth"),
context: unusedDoctorContext,
};
const migration = expectDefined(stateMigrations[0], "memory-lancedb state migration");
await expect(migration.detectLegacyState(params)).resolves.toMatchObject({
preview: [expect.stringContaining("system agent main-agent")],
});
await expect(migration.migrateLegacyState(params)).resolves.toEqual({
changes: ["Assigned 1 legacy Memory LanceDB row to system agent main-agent"],
warnings: [],
});
const migratedConnection = await lancedb.connect(getDbPath());
const migratedTable = await migratedConnection.openTable("memories");
await expect(migratedTable.countRows("agentId = 'main-agent'")).resolves.toBe(1);
migratedTable.close();
migratedConnection.close();
});
test("deletes only structurally complete legacy envelope rows", async () => {
const benignRows = [
{
@@ -4,6 +4,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-scope-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
import type { PluginDoctorStateMigration } from "openclaw/plugin-sdk/runtime-doctor-migrations";
import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
@@ -20,6 +21,19 @@ type LanceDbTable = Awaited<ReturnType<LanceDbConnection["openTable"]>>;
const LEGACY_ENVELOPE_DELETE_BATCH_SIZE = 500;
function resolveLegacyMemoryOwner(config: OpenClawConfig): {
agentId: string;
label: "default" | "system";
} {
const explicitSystemAgentId =
config.agents?.ownership === "explicit"
? config.agents.defaults?.systemAgent?.agentId?.trim()
: undefined;
return explicitSystemAgentId
? { agentId: normalizeAgentId(explicitSystemAgentId), label: "system" }
: { agentId: resolveDefaultAgentId(config), label: "default" };
}
// Doctor deletes rows containing a complete known legacy sentinel line, a legacy
// label followed by a fenced JSON body, or the complete legacy external-content
// header line. Bare label-like prose and partial header prefixes survive.
@@ -172,11 +186,11 @@ export function createMemoryLanceDbStateMigrations(
if (!opened.table || hasAgentScopeColumn(await opened.table.schema())) {
return null;
}
const defaultAgentId = resolveDefaultAgentId(params.config);
const owner = resolveLegacyMemoryOwner(params.config);
const count = await opened.table.countRows();
return {
preview: [
`- Memory LanceDB: assign ${count} legacy ${count === 1 ? "row" : "rows"} at ${opened.dbPath} to default agent ${defaultAgentId}`,
`- Memory LanceDB: assign ${count} legacy ${count === 1 ? "row" : "rows"} at ${opened.dbPath} to ${owner.label} agent ${owner.agentId}`,
],
};
} finally {
@@ -190,23 +204,23 @@ export function createMemoryLanceDbStateMigrations(
if (!opened.table || hasAgentScopeColumn(await opened.table.schema())) {
return { changes: [], warnings: [] };
}
const defaultAgentId = resolveDefaultAgentId(params.config);
const owner = resolveLegacyMemoryOwner(params.config);
const rowCount = await opened.table.countRows();
await opened.table.addColumns([
{
name: MEMORY_AGENT_ID_COLUMN,
valueSql: quoteLanceSqlString(defaultAgentId),
valueSql: quoteLanceSqlString(owner.agentId),
},
]);
if (
!hasAgentScopeColumn(await opened.table.schema()) ||
(await opened.table.countRows(memoryAgentPredicate(defaultAgentId))) !== rowCount
(await opened.table.countRows(memoryAgentPredicate(owner.agentId))) !== rowCount
) {
throw new Error("LanceDB agent-scope migration verification failed");
}
return {
changes: [
`Assigned ${rowCount} legacy Memory LanceDB ${rowCount === 1 ? "row" : "rows"} to default agent ${defaultAgentId}`,
`Assigned ${rowCount} legacy Memory LanceDB ${rowCount === 1 ? "row" : "rows"} to ${owner.label} agent ${owner.agentId}`,
],
warnings: [],
};
+13 -5
View File
@@ -319,15 +319,17 @@ describe("registerMaintenanceCommands doctor action", () => {
expect(runtime.exit).toHaveBeenCalledWith(1);
});
it("treats bare --json as lint mode and emits machine-readable output", async () => {
it("keeps bare --json advisory while preserving machine-readable findings", async () => {
const output: string[] = [];
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk) => {
output.push(String(chunk));
return true;
});
runDoctorLintCli.mockImplementationOnce(async () => {
process.stdout.write('{"ok":true,"checksRun":1,"checksSkipped":0,"findings":[]}\n');
return 0;
process.stdout.write(
'{"ok":false,"checksRun":1,"checksSkipped":0,"findings":[{"checkId":"core/example","severity":"error","message":"broken"}]}\n',
);
return 1;
});
try {
@@ -344,10 +346,16 @@ describe("registerMaintenanceCommands doctor action", () => {
deep: false,
});
expect(JSON.parse(output.join(""))).toEqual({
ok: true,
ok: false,
checksRun: 1,
checksSkipped: 0,
findings: [],
findings: [
{
checkId: "core/example",
severity: "error",
message: "broken",
},
],
});
expect(runtime.exit).toHaveBeenCalledWith(0);
} finally {
+2 -6
View File
@@ -86,11 +86,7 @@ export function registerMaintenanceCommands(program: Command) {
"With --session-sqlite recover: prepare and optionally create an openclaw/openclaw issue",
false,
)
.option(
"--json",
"Run read-only lint checks as JSON (or emit JSON for another machine mode)",
false,
)
.option("--json", "Emit JSON; bare --json runs advisory read-only health checks", false)
.option(
"--severity-min <level>",
"With --lint: drop findings below this severity (info|warning|error)",
@@ -145,7 +141,7 @@ export function registerMaintenanceCommands(program: Command) {
allowExec: Boolean(opts.allowExec),
deep: Boolean(opts.deep),
});
defaultRuntime.exit(exitCode);
defaultRuntime.exit(jsonImpliesLint ? 0 : exitCode);
},
(err) => exitDoctorError(formatError(err), opts.json === true || !process.stdout.isTTY),
);
@@ -195,6 +195,38 @@ describe("doctor model catalog credential migration", () => {
expect(loadPersistedAuthProfileStore(childAgentDir)).toBeNull();
});
it("scans an explicit multi-agent roster without requiring a legacy default", async () => {
const state = createState();
const helperAgentDir = path.join(state.stateDir, "agents", "helper", "agent");
const thirdAgentDir = path.join(state.stateDir, "agents", "third", "agent");
fs.mkdirSync(helperAgentDir, { recursive: true });
fs.mkdirSync(thirdAgentDir, { recursive: true });
fs.writeFileSync(
path.join(helperAgentDir, "models.json"),
`${JSON.stringify({ providers: { custom: provider("helper-catalog-secret") } })}\n`,
);
const cfg = {
agents: {
ownership: "explicit",
defaults: { systemAgent: { agentId: "main" } },
entries: {
main: { agentDir: state.agentDir },
helper: { agentDir: helperAgentDir },
third: { agentDir: thirdAgentDir },
},
},
} satisfies OpenClawConfig;
await expect(maybeMigrateModelCatalogCredentials(migrationParams(state, cfg))).resolves.toEqual(
{ detected: 1, migrated: 1, warnings: [] },
);
expect(loadPersistedAuthProfileStore(helperAgentDir)?.profiles["custom:default"]).toMatchObject(
{
key: "helper-catalog-secret",
},
);
});
it("allocates a global config profile that child stores cannot shadow", async () => {
const state = createState();
const childAgentDir = path.join(state.stateDir, "agents", "child", "agent");
@@ -4,7 +4,7 @@ import path from "node:path";
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { note } from "../../packages/terminal-core/src/note.js";
import { resolveDefaultAgentDir } from "../agents/agent-scope.js";
import { listAgentIds, resolveAgentDir, resolveDefaultAgentDir } from "../agents/agent-scope.js";
import { AUTH_STORE_VERSION } from "../agents/auth-profiles/constants.js";
import {
loadPersistedAuthProfileStore,
@@ -242,9 +242,12 @@ export async function maybeMigrateModelCatalogCredentials(params: {
const discoveredAgentDirs = listAgentModelsJsonPaths(params.cfg, stateDir, env).map(
(modelsPath) => path.dirname(modelsPath),
);
const agentDirs = [
...new Set([mainAgentDir, resolveDefaultAgentDir(params.cfg, env), ...discoveredAgentDirs]),
];
const agentIds = listAgentIds(params.cfg);
const configuredAgentDirs =
agentIds.length > 0
? agentIds.map((agentId) => resolveAgentDir(params.cfg, agentId, env))
: [resolveDefaultAgentDir(params.cfg, env)];
const agentDirs = [...new Set([mainAgentDir, ...configuredAgentDirs, ...discoveredAgentDirs])];
const mainStore = loadPersistedSharedAuthProfileStore(env) ?? emptyStore();
const catalogs = agentDirs.map((agentDir) => collectAgentCatalogs(agentDir, warnings));
const effectiveStores = catalogs.map(({ localStore }) =>
@@ -190,6 +190,43 @@ describe("doctor generated plugin model catalog migration", () => {
expect(fs.existsSync(workerPath)).toBe(false);
});
it("discovers every explicit-roster agent without requiring a legacy default", async () => {
const mainDir = createAgentDir();
const helperDir = createAgentDir();
const thirdDir = createAgentDir();
const mainContents = generatedCatalog("openai", "main-explicit-provider-test-key");
const helperContents = generatedCatalog("anthropic", "helper-explicit-provider-test-key");
writeLegacyCatalog(mainDir, "openai", mainContents);
writeLegacyCatalog(helperDir, "anthropic", helperContents);
const params = {
...migrationParams([], true),
agentDirs: undefined,
cfg: {
agents: {
ownership: "explicit",
defaults: { systemAgent: { agentId: "main" } },
entries: {
main: { agentDir: mainDir },
helper: { agentDir: helperDir },
third: { agentDir: thirdDir },
},
},
} satisfies OpenClawConfig,
};
await expect(maybeMigrateLegacyPluginModelCatalogs(params)).resolves.toEqual({
detected: 2,
migrated: 2,
warnings: [],
});
expect(listPersistedPluginModelCatalogs(mainDir)).toEqual([
{ pluginId: "openai", contents: mainContents },
]);
expect(listPersistedPluginModelCatalogs(helperDir)).toEqual([
{ pluginId: "anthropic", contents: helperContents },
]);
});
it("preserves legacy credentials and does not create SQLite when repair is declined", async () => {
const agentDir = createAgentDir();
const contents = generatedCatalog("zai");
+6 -6
View File
@@ -32,12 +32,12 @@ function resolveMigrationAgentDirs(params: {
return [...new Set(params.agentDirs)].toSorted((left, right) => left.localeCompare(right));
}
const env = params.env ?? process.env;
return [
...new Set([
resolveDefaultAgentDir(params.cfg, env),
...listAgentIds(params.cfg).map((agentId) => resolveAgentDir(params.cfg, agentId, env)),
]),
].toSorted((left, right) => left.localeCompare(right));
const agentIds = listAgentIds(params.cfg);
const configuredAgentDirs =
agentIds.length > 0
? agentIds.map((agentId) => resolveAgentDir(params.cfg, agentId, env))
: [resolveDefaultAgentDir(params.cfg, env)];
return [...new Set(configuredAgentDirs)].toSorted((left, right) => left.localeCompare(right));
}
async function readLegacyPluginCatalogContents(params: {
+50
View File
@@ -401,6 +401,56 @@ describe("maybeRepairLegacyCronStore", () => {
expect(advisory).not.toContain("Support legacy cap");
});
it("uses the system agent for agent-less legacy-cap diagnostics", async () => {
const storePath = await makeTempStorePath();
const mainWorkspace = path.join(path.dirname(storePath), "main-workspace");
await writeCurrentCronStore(storePath, [
createCurrentCronJob({
id: "ambient-job",
name: "Ambient legacy cap",
payload: {
kind: "agentTurn",
message: "ambient",
toolsAllow: ["read"],
toolsAllowIsDefault: true,
},
}),
]);
const cfg = {
cron: { store: storePath },
agents: {
ownership: "explicit",
defaults: { systemAgent: { agentId: "main" } },
entries: {
main: { workspace: mainWorkspace },
helper: {},
third: {},
},
},
mcp: {
servers: {
notes: {
transport: "stdio",
command: "notes-mcp",
codex: { agents: ["main"] },
},
},
},
} as OpenClawConfig;
await expect(
maybeRepairLegacyCronStore({ cfg, options: {}, prompter: makePrompter(true) }),
).resolves.toBeUndefined();
const advisory = noteMock.mock.calls.find(
([message, title]) =>
title === "Cron" &&
typeof message === "string" &&
message.includes("inherited default tool cap"),
)?.[0];
expect(advisory).toContain("Ambient legacy cap");
});
it("reports quarantined cron rows even when the active store is already sanitized", async () => {
const storePath = await makeTempStorePath();
await writeCurrentCronStore(storePath, []);
+6 -2
View File
@@ -2,10 +2,11 @@
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { note } from "../../../../packages/terminal-core/src/note.js";
import { resolveStaticSessionMcpServerNames } from "../../../agents/agent-bundle-mcp-runtime-config.js";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../../agents/agent-scope.js";
import { resolveAgentWorkspaceDir } from "../../../agents/agent-scope.js";
import { resolveCodexMcpToolOverridesForAgent } from "../../../agents/cli-runner/bundle-mcp-codex.js";
import { formatCliCommand } from "../../../cli/command-format.js";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import { tryResolveCronDefaultAgentId } from "../../../cron/agent-id.js";
import { loadCronQuarantinedJobs, resolveCronJobsStorePath } from "../../../cron/store.js";
import type { HealthFinding } from "../../../flows/health-checks.js";
import { formatErrorMessage as errorMessage } from "../../../infra/errors.js";
@@ -506,7 +507,10 @@ export async function maybeRepairLegacyCronStore(params: {
const agentId =
typeof job.agentId === "string" && job.agentId.trim()
? job.agentId.trim()
: resolveDefaultAgentId(params.cfg);
: tryResolveCronDefaultAgentId(params.cfg);
if (!agentId) {
return false;
}
const workspaceDir = resolveAgentWorkspaceDir(params.cfg, agentId);
const cacheKey = `${agentId}\0${workspaceDir}`;
let hasStaticMcp = staticMcpByAgentWorkspace.get(cacheKey);
@@ -11,11 +11,6 @@ import {
maybeRepairContextEngineHostCompatibility,
} from "./context-engine-host-compat.js";
vi.mock("../../../agents/agent-scope-config.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../../agents/agent-scope-config.js")>()),
resolveDefaultAgentDir: vi.fn(() => "/tmp/openclaw-doctor-host-compat"),
}));
vi.mock("../../../agents/cli-backends.js", () => ({
resolveCliBackendConfig: vi.fn((runtimeId: string) => ({ id: runtimeId })),
}));
@@ -154,6 +149,29 @@ describe("doctor context-engine host compatibility", () => {
expect(warnings).toEqual([]);
});
it("uses the system agent when inspecting an explicit multi-agent roster", async () => {
const engineId = registerEngine([]);
const warnings = await collectContextEngineHostCompatibilityWarnings({
cfg: configWithEngine(engineId, {
agents: {
ownership: "explicit",
defaults: {
systemAgent: { agentId: "main" },
model: "anthropic/claude-sonnet-4-6",
},
entries: {
main: { agentDir: "/tmp/openclaw-doctor-host-compat" },
helper: {},
third: {},
},
},
}),
doctorFixCommand: "openclaw doctor --fix",
});
expect(warnings).toEqual([]);
});
it("repairs an incompatible context engine by switching the global slot to legacy", async () => {
const engineId = registerEngine(["assemble-before-prompt"]);
const result = await maybeRepairContextEngineHostCompatibility({
@@ -4,7 +4,10 @@ import { uniqueStrings } from "@openclaw/normalization-core/string-normalization
import { normalizeEmbeddedAgentRuntime } from "../../../agents/agent-runtime-id.js";
import {
listAgentEntriesWithSource,
resolveDefaultAgentDir,
resolveAgentDir,
resolveDefaultAgentId,
tryResolveLegacyCompatibilityAgentId,
tryResolveSystemAgentTargetAgentId,
} from "../../../agents/agent-scope-config.js";
import { resolveCliBackendConfig } from "../../../agents/cli-backends.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../../agents/defaults.js";
@@ -278,9 +281,16 @@ async function resolveSelectedContextEngineInfo(params: {
}
try {
const agentId =
tryResolveLegacyCompatibilityAgentId(params.cfg) ??
tryResolveSystemAgentTargetAgentId(params.cfg) ??
resolveDefaultAgentId(params.cfg, {
surface: "context-engine Doctor checks",
hint: "Set agents.defaults.systemAgent.agentId before running Doctor.",
});
const resolve = () =>
resolveContextEngine(params.cfg, {
agentDir: resolveDefaultAgentDir(params.cfg, params.env),
agentDir: resolveAgentDir(params.cfg, agentId, params.env),
workspaceDir: params.cfg.agents?.defaults?.workspace
? resolveUserPath(params.cfg.agents.defaults.workspace, params.env)
: undefined,
+30
View File
@@ -110,6 +110,36 @@ describe("resolveChannelSetupSelectionContributions", () => {
isChannelConfigured.mockReturnValue(false);
});
it("uses the configured system agent workspace for explicit multi-agent setup", async () => {
const cfg = {
agents: {
ownership: "explicit",
defaults: { systemAgent: { agentId: "main" } },
entries: {
main: { workspace: "/tmp/openclaw-main-workspace" },
helper: { workspace: "/tmp/openclaw-helper-workspace" },
third: { workspace: "/tmp/openclaw-third-workspace" },
},
},
} as const;
await collectChannelStatus({
cfg,
accountOverrides: {},
installedPlugins: [],
});
resolveChannelSelectionNoteLines({ cfg, installedPlugins: [], selection: [] });
expect(resolveChannelSetupEntries).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ workspaceDir: "/tmp/openclaw-main-workspace" }),
);
expect(resolveChannelSetupEntries).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ workspaceDir: "/tmp/openclaw-main-workspace" }),
);
});
it("sorts channels alphabetically by picker label", () => {
const contributions = resolveChannelSetupSelectionContributions({
entries: [
+19 -3
View File
@@ -1,7 +1,12 @@
// Channel setup status helpers format channel setup progress and docs links.
import { formatDocsLink } from "../../packages/terminal-core/src/links.js";
import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import {
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
tryResolveLegacyCompatibilityAgentId,
tryResolveSystemAgentTargetAgentId,
} from "../agents/agent-scope.js";
import { listChatChannels } from "../channels/chat-meta.js";
import type { ChannelPluginCatalogEntry } from "../channels/plugins/catalog.js";
import { listChannelSetupPlugins } from "../channels/plugins/setup-registry.js";
@@ -55,6 +60,17 @@ type ChannelSetupSelectionEntry = {
};
};
export function resolveChannelSetupWorkspaceDir(cfg: OpenClawConfig): string {
const agentId =
tryResolveLegacyCompatibilityAgentId(cfg) ??
tryResolveSystemAgentTargetAgentId(cfg) ??
resolveDefaultAgentId(cfg, {
surface: "channel setup",
hint: "Set agents.defaults.systemAgent.agentId before configuring channels.",
});
return resolveAgentWorkspaceDir(cfg, agentId);
}
const CHANNEL_PRIMER_BLURB_KEYS: Record<string, string> = {
clickclack: "wizard.channelsPrimer.blurbs.clickclack",
discord: "wizard.channelsPrimer.blurbs.discord",
@@ -343,7 +359,7 @@ export async function collectChannelStatus(params: {
resolveAdapter?: (channel: ChannelChoice) => ChannelSetupWizardAdapter | undefined;
}): Promise<ChannelStatusSummary> {
const installedPlugins = params.installedPlugins ?? listChannelSetupPlugins();
const workspaceDir = resolveAgentWorkspaceDir(params.cfg, resolveDefaultAgentId(params.cfg));
const workspaceDir = resolveChannelSetupWorkspaceDir(params.cfg);
const { installedCatalogEntries, installableCatalogEntries } = resolveChannelSetupEntries({
cfg: params.cfg,
installedPlugins,
@@ -534,7 +550,7 @@ export function resolveChannelSelectionNoteLines(params: {
const { entries } = resolveChannelSetupEntries({
cfg: params.cfg,
installedPlugins: params.installedPlugins,
workspaceDir: resolveAgentWorkspaceDir(params.cfg, resolveDefaultAgentId(params.cfg)),
workspaceDir: resolveChannelSetupWorkspaceDir(params.cfg),
});
const selectionNotes = new Map<string, string>();
for (const entry of entries) {
+29
View File
@@ -132,6 +132,9 @@ const collectChannelStatus = vi.hoisted(() =>
statusLines: [],
})),
);
const resolveChannelSetupWorkspaceDir = vi.hoisted(() =>
vi.fn((_cfg?: unknown) => "/tmp/openclaw-workspace"),
);
const isChannelConfigured = vi.hoisted(() => vi.fn((_cfg?: unknown, _channel?: unknown) => true));
vi.mock("../agents/agent-scope.js", () => ({
@@ -201,6 +204,7 @@ vi.mock("./channel-setup.status.js", () => ({
resolveCatalogChannelSelectionHint: vi.fn(() => "download from <npm>"),
resolveChannelSelectionNoteLines: vi.fn(() => []),
resolveChannelSetupSelectionContributions: vi.fn(() => []),
resolveChannelSetupWorkspaceDir: (cfg?: unknown) => resolveChannelSetupWorkspaceDir(cfg),
resolveQuickstartDefault: vi.fn(() => undefined),
}));
@@ -211,6 +215,7 @@ describe("setupChannels workspace shadow exclusion", () => {
vi.clearAllMocks();
resolveAgentWorkspaceDir.mockReturnValue("/tmp/openclaw-workspace");
resolveDefaultAgentId.mockReturnValue("default");
resolveChannelSetupWorkspaceDir.mockReturnValue("/tmp/openclaw-workspace");
listTrustedChannelPluginCatalogEntries.mockReturnValue([
{
id: "external-chat",
@@ -265,6 +270,30 @@ describe("setupChannels workspace shadow exclusion", () => {
expect(registryInput.workspaceDir).toBe("/tmp/openclaw-workspace");
});
it("resolves plugin discovery through the channel setup workspace owner", async () => {
const cfg = {
agents: {
ownership: "explicit",
defaults: { systemAgent: { agentId: "main" } },
entries: { main: {}, helper: {}, third: {} },
},
} as unknown as OpenClawConfig;
resolveDefaultAgentId.mockImplementationOnce(() => {
throw new Error("legacy default resolver must not own channel setup");
});
await setupChannels(
cfg,
{} as never,
{
confirm: vi.fn(async () => false),
note: vi.fn(async () => undefined),
} as never,
);
expect(resolveChannelSetupWorkspaceDir).toHaveBeenCalledWith(cfg);
});
it("keeps trusted workspace overrides eligible during preload", async () => {
listTrustedChannelPluginCatalogEntries.mockReturnValue([
{ id: "external-chat", pluginId: "trusted-external-chat-shadow", origin: "workspace" },
+2 -2
View File
@@ -1,5 +1,4 @@
// Channel setup flow configures channels, auth, and workspace bindings.
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { getBundledChannelSetupPlugin } from "../channels/plugins/bundled.js";
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import { listActiveChannelSetupPlugins } from "../channels/plugins/setup-registry.js";
@@ -51,6 +50,7 @@ import {
resolveCatalogChannelSelectionHint,
resolveChannelSelectionNoteLines,
resolveChannelSetupSelectionContributions,
resolveChannelSetupWorkspaceDir,
resolveQuickstartDefault,
} from "./channel-setup.status.js";
@@ -124,7 +124,7 @@ export async function setupChannels(
...options?.accountIds,
};
const scopedPluginsById = new Map<ChannelChoice, ChannelSetupPlugin>();
const resolveWorkspaceDir = () => resolveAgentWorkspaceDir(next, resolveDefaultAgentId(next));
const resolveWorkspaceDir = () => resolveChannelSetupWorkspaceDir(next);
const rememberScopedPlugin = (plugin: ChannelSetupPlugin) => {
const channel = plugin.id;
scopedPluginsById.set(channel, plugin);
@@ -0,0 +1,71 @@
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 { CORE_HEALTH_CHECKS } from "./doctor-core-checks.js";
import type { HealthCheck } from "./health-checks.js";
const runtime = { log() {}, error() {}, exit() {} };
function getBootstrapSizeCheck(): HealthCheck {
const check = CORE_HEALTH_CHECKS.find(
(candidate) => candidate.id === "core/doctor/bootstrap-size",
);
if (!check || !("detect" in check)) {
throw new Error("missing bootstrap-size health check");
}
return check;
}
describe("core/doctor/bootstrap-size", () => {
let tmp: string | undefined;
afterEach(async () => {
if (tmp !== undefined) {
await fs.rm(tmp, { recursive: true, force: true });
tmp = undefined;
}
});
it("honors the per-agent bootstrapMaxChars override in health findings", async () => {
tmp = await fs.mkdtemp(join(tmpdir(), "openclaw-health-bootstrap-"));
await fs.writeFile(join(tmp, "AGENTS.md"), "a".repeat(15_000), "utf-8");
const check = getBootstrapSizeCheck();
const findings = await check.detect({
mode: "lint",
runtime,
cfg: {
agents: {
defaults: { workspace: tmp, bootstrapMaxChars: 20_000 },
list: [{ id: "custom-agent", default: true, bootstrapMaxChars: 10_000 }],
},
},
cwd: tmp,
});
expect(findings).toContainEqual(
expect.objectContaining({
checkId: "core/doctor/bootstrap-size",
severity: "warning",
message: expect.stringContaining("AGENTS.md"),
fixHint: expect.stringContaining("agents.entries.*.bootstrapMaxChars"),
}),
);
await expect(
check.detect({
mode: "lint",
runtime,
cfg: {
agents: {
defaults: { bootstrapMaxChars: 20_000 },
list: [
{ id: "alpha", default: true, workspace: tmp, bootstrapMaxChars: 10_000 },
{ id: "beta" },
],
},
},
}),
).resolves.toEqual([]);
});
});
-101
View File
@@ -327,50 +327,6 @@ describe("CORE_HEALTH_CHECKS", () => {
);
});
it("warns when autonomous Skill Workshop capture is enabled but policy hides its tool", async () => {
const check = getCheck(
createCoreHealthChecks(createDeps()),
"core/doctor/skill-workshop-tool-policy",
);
const findings = await check.detect({
mode: "doctor",
runtime,
cfg: {
skills: { workshop: { autonomous: { mode: "propose" } } },
tools: { profile: "messaging" },
},
});
expect(findings).toEqual([
expect.objectContaining({
checkId: "core/doctor/skill-workshop-tool-policy",
severity: "warning",
message: 'tools.profile: "messaging" does not include "skill_workshop".',
path: "tools.profile",
fixHint: 'Add tools.alsoAllow: ["skill_workshop"].',
}),
]);
});
it("does not warn when autonomous Skill Workshop capture is disabled", async () => {
const check = getCheck(
createCoreHealthChecks(createDeps()),
"core/doctor/skill-workshop-tool-policy",
);
await expect(
check.detect({
mode: "doctor",
runtime,
cfg: {
skills: { workshop: { autonomous: { mode: "off" } } },
tools: { profile: "messaging" },
},
}),
).resolves.toEqual([]);
});
it("threads deep mode into structured extra gateway service detection", async () => {
const check = getCheck(
createCoreHealthChecks(createDeps()),
@@ -1019,60 +975,3 @@ describe("CORE_HEALTH_CHECKS", () => {
);
});
});
describe("core/doctor/bootstrap-size", () => {
let tmp: string | undefined;
afterEach(async () => {
if (tmp !== undefined) {
await fs.rm(tmp, { recursive: true, force: true });
tmp = undefined;
}
});
it("honors the per-agent bootstrapMaxChars override in health findings", async () => {
tmp = await fs.mkdtemp(join(tmpdir(), "openclaw-health-bootstrap-"));
// This size fits the global default but exceeds the default agent's effective budget.
await fs.writeFile(join(tmp, "AGENTS.md"), "a".repeat(15_000), "utf-8");
const check = getCheck(CORE_HEALTH_CHECKS, "core/doctor/bootstrap-size");
const findings = await check.detect({
mode: "lint",
runtime,
cfg: {
agents: {
defaults: {
workspace: tmp,
bootstrapMaxChars: 20_000,
},
list: [{ id: "custom-agent", default: true, bootstrapMaxChars: 10_000 }],
},
},
cwd: tmp,
});
expect(findings).toContainEqual(
expect.objectContaining({
checkId: "core/doctor/bootstrap-size",
severity: "warning",
message: expect.stringContaining("AGENTS.md"),
fixHint: expect.stringContaining("agents.entries.*.bootstrapMaxChars"),
}),
);
await expect(
check.detect({
mode: "lint",
runtime,
cfg: {
agents: {
defaults: { bootstrapMaxChars: 20_000 },
list: [
{ id: "alpha", default: true, workspace: tmp, bootstrapMaxChars: 10_000 },
{ id: "beta" },
],
},
},
}),
).resolves.toEqual([]);
});
});
+21 -18
View File
@@ -1,6 +1,6 @@
// Doctor core checks collect environment, config, and runtime readiness diagnostics.
import path from "node:path";
import { tryResolveSoleAgentId } from "../agents/agent-scope.js";
import { listAgentIds, tryResolveSoleAgentId } from "../agents/agent-scope.js";
import { isExperimentalClawsEnabled } from "../claws/experimental.js";
import {
detectLegacyClawdBrowserProfileResidue,
@@ -318,24 +318,27 @@ const skillWorkshopToolPolicyCheck: HealthCheck = {
description: "Autonomous Skill Workshop capture has a callable review tool.",
source: "doctor",
async detect(ctx) {
const diagnostic = detectSkillWorkshopToolPolicyDiagnostic({
config: ctx.cfg,
workshopEnabled: resolveSkillWorkshopConfig(ctx.cfg).autonomous.mode !== "off",
});
if (!diagnostic) {
return [];
}
return [
{
checkId: SKILL_WORKSHOP_TOOL_POLICY_CHECK_ID,
severity: "warning",
message: diagnostic.detail,
path: diagnostic.source,
target: diagnostic.agentId,
requirement: "Autonomous Skill Workshop review requires the skill_workshop tool.",
fixHint: diagnostic.fix,
const workshopEnabled = resolveSkillWorkshopConfig(ctx.cfg).autonomous.mode !== "off";
const listedAgentIds = listAgentIds(ctx.cfg);
const diagnostics = (listedAgentIds.length > 0 ? listedAgentIds : [undefined]).flatMap(
(agentId) => {
const diagnostic = detectSkillWorkshopToolPolicyDiagnostic({
config: ctx.cfg,
workshopEnabled,
...(agentId ? { agentId } : {}),
});
return diagnostic ? [diagnostic] : [];
},
];
);
return diagnostics.map((diagnostic) => ({
checkId: SKILL_WORKSHOP_TOOL_POLICY_CHECK_ID,
severity: "warning",
message: diagnostic.detail,
path: diagnostic.source,
target: diagnostic.agentId,
requirement: "Autonomous Skill Workshop review requires the skill_workshop tool.",
fixHint: diagnostic.fix,
}));
},
};
@@ -0,0 +1,116 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { createCoreHealthChecks } from "./doctor-core-checks.js";
import { runDoctorLintChecks } from "./doctor-lint-flow.js";
import type { HealthCheck } from "./health-checks.js";
const runtime = { log() {}, error() {}, exit() {} };
function getSkillWorkshopCheck(): HealthCheck {
const check = createCoreHealthChecks().find(
(candidate) => candidate.id === "core/doctor/skill-workshop-tool-policy",
);
if (!check || !("detect" in check)) {
throw new Error("missing Skill Workshop health check");
}
return check;
}
describe("core/doctor/skill-workshop-tool-policy", () => {
it("warns when autonomous capture is enabled but policy hides its tool", async () => {
const findings = await getSkillWorkshopCheck().detect({
mode: "doctor",
runtime,
cfg: {
skills: { workshop: { autonomous: { mode: "propose" } } },
tools: { profile: "messaging" },
},
});
expect(findings).toEqual([
expect.objectContaining({
severity: "warning",
message: 'tools.profile: "messaging" does not include "skill_workshop".',
path: "tools.profile",
fixHint: 'Add tools.alsoAllow: ["skill_workshop"].',
}),
]);
});
it("checks every explicit-roster agent without turning selection into a health error", async () => {
const cfg: OpenClawConfig = {
skills: { workshop: { autonomous: { mode: "propose" } } },
agents: {
ownership: "explicit",
defaults: { systemAgent: { agentId: "main" } },
entries: {
main: { tools: { profile: "coding" } },
helper: { tools: { profile: "messaging" } },
third: { tools: { profile: "coding" } },
},
},
};
const result = await runDoctorLintChecks(
{ mode: "lint", runtime, cfg },
{ checks: [getSkillWorkshopCheck()] },
);
expect(result.findings).toEqual([
expect.objectContaining({
severity: "warning",
target: "helper",
path: "agents.entries.helper.tools.profile",
}),
]);
expect(result.findings).not.toContainEqual(
expect.objectContaining({ message: expect.stringContaining("health check threw") }),
);
});
it.each([
{
label: "sole-agent roster",
cfg: {
agents: { entries: { solo: { tools: { profile: "messaging" } } } },
} satisfies OpenClawConfig,
target: "solo",
},
{
label: "legacy-default roster",
cfg: {
agents: {
list: [
{ id: "owner", default: true, tools: { profile: "messaging" } },
{ id: "helper", tools: { profile: "coding" } },
],
},
} satisfies OpenClawConfig,
target: "owner",
},
])("preserves normal diagnostics for a $label", async ({ cfg, target }) => {
const findings = await getSkillWorkshopCheck().detect({
mode: "doctor",
runtime,
cfg: {
...cfg,
skills: { workshop: { autonomous: { mode: "propose" } } },
},
});
expect(findings).toEqual([expect.objectContaining({ severity: "warning", target })]);
});
it("does not warn when autonomous capture is disabled", async () => {
await expect(
getSkillWorkshopCheck().detect({
mode: "doctor",
runtime,
cfg: {
skills: { workshop: { autonomous: { mode: "off" } } },
tools: { profile: "messaging" },
},
}),
).resolves.toEqual([]);
});
});