diff --git a/docs/cli/doctor.md b/docs/cli/doctor.md index a3a9347b2156..7635b5f29fd5 100644 --- a/docs/cli/doctor.md +++ b/docs/cli/doctor.md @@ -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 ` | 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 ` | 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 ` | 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 ` | 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 ` | With `--lint`: skip a check id. Repeatable. | | `--only ` | 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 ` 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. diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index d0fa776509b0..8505dcad84c5 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -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 ` (repeatable): run only the named check id(s); an unknown id is reported as an error finding. - `--skip ` (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) diff --git a/extensions/memory-lancedb/doctor-contract-api.test.ts b/extensions/memory-lancedb/doctor-contract-api.test.ts index f9e0ace02126..6585622922a1 100644 --- a/extensions/memory-lancedb/doctor-contract-api.test.ts +++ b/extensions/memory-lancedb/doctor-contract-api.test.ts @@ -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 = [ { diff --git a/extensions/memory-lancedb/doctor-contract-api.ts b/extensions/memory-lancedb/doctor-contract-api.ts index d488f28e9bad..26e77fddece1 100644 --- a/extensions/memory-lancedb/doctor-contract-api.ts +++ b/extensions/memory-lancedb/doctor-contract-api.ts @@ -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>; 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: [], }; diff --git a/src/cli/program/register.maintenance.test.ts b/src/cli/program/register.maintenance.test.ts index d6d0f3b3dcba..08c5ddd3942b 100644 --- a/src/cli/program/register.maintenance.test.ts +++ b/src/cli/program/register.maintenance.test.ts @@ -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 { diff --git a/src/cli/program/register.maintenance.ts b/src/cli/program/register.maintenance.ts index 3215e2cae12c..836e9ebdb907 100644 --- a/src/cli/program/register.maintenance.ts +++ b/src/cli/program/register.maintenance.ts @@ -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 ", "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), ); diff --git a/src/commands/doctor-model-catalog-credentials.test.ts b/src/commands/doctor-model-catalog-credentials.test.ts index 84a19927c15b..0e5fff854cca 100644 --- a/src/commands/doctor-model-catalog-credentials.test.ts +++ b/src/commands/doctor-model-catalog-credentials.test.ts @@ -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"); diff --git a/src/commands/doctor-model-catalog-credentials.ts b/src/commands/doctor-model-catalog-credentials.ts index 6b7375045820..19c56f599c62 100644 --- a/src/commands/doctor-model-catalog-credentials.ts +++ b/src/commands/doctor-model-catalog-credentials.ts @@ -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 }) => diff --git a/src/commands/doctor-plugin-model-catalog.test.ts b/src/commands/doctor-plugin-model-catalog.test.ts index e16c9805fb9c..422c72fcdf87 100644 --- a/src/commands/doctor-plugin-model-catalog.test.ts +++ b/src/commands/doctor-plugin-model-catalog.test.ts @@ -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"); diff --git a/src/commands/doctor-plugin-model-catalog.ts b/src/commands/doctor-plugin-model-catalog.ts index ed5f807908f7..a16b3c704296 100644 --- a/src/commands/doctor-plugin-model-catalog.ts +++ b/src/commands/doctor-plugin-model-catalog.ts @@ -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: { diff --git a/src/commands/doctor/cron/index.test.ts b/src/commands/doctor/cron/index.test.ts index 26a3541611e6..83bd87724884 100644 --- a/src/commands/doctor/cron/index.test.ts +++ b/src/commands/doctor/cron/index.test.ts @@ -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, []); diff --git a/src/commands/doctor/cron/index.ts b/src/commands/doctor/cron/index.ts index 953b12e2690d..d9298bd1d8a8 100644 --- a/src/commands/doctor/cron/index.ts +++ b/src/commands/doctor/cron/index.ts @@ -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); diff --git a/src/commands/doctor/shared/context-engine-host-compat.test.ts b/src/commands/doctor/shared/context-engine-host-compat.test.ts index 7a82de8f2d9a..a788eec93990 100644 --- a/src/commands/doctor/shared/context-engine-host-compat.test.ts +++ b/src/commands/doctor/shared/context-engine-host-compat.test.ts @@ -11,11 +11,6 @@ import { maybeRepairContextEngineHostCompatibility, } from "./context-engine-host-compat.js"; -vi.mock("../../../agents/agent-scope-config.js", async (importOriginal) => ({ - ...(await importOriginal()), - 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({ diff --git a/src/commands/doctor/shared/context-engine-host-compat.ts b/src/commands/doctor/shared/context-engine-host-compat.ts index 998e252c3892..519fee7d2f75 100644 --- a/src/commands/doctor/shared/context-engine-host-compat.ts +++ b/src/commands/doctor/shared/context-engine-host-compat.ts @@ -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, diff --git a/src/flows/channel-setup.status.test.ts b/src/flows/channel-setup.status.test.ts index 5f9b9a34ffbd..9b0aec081d77 100644 --- a/src/flows/channel-setup.status.test.ts +++ b/src/flows/channel-setup.status.test.ts @@ -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: [ diff --git a/src/flows/channel-setup.status.ts b/src/flows/channel-setup.status.ts index 964a20d4a94a..68593b796b30 100644 --- a/src/flows/channel-setup.status.ts +++ b/src/flows/channel-setup.status.ts @@ -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 = { 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 { 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(); for (const entry of entries) { diff --git a/src/flows/channel-setup.test.ts b/src/flows/channel-setup.test.ts index 89053cb34569..dbcb47557ddd 100644 --- a/src/flows/channel-setup.test.ts +++ b/src/flows/channel-setup.test.ts @@ -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 "), 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" }, diff --git a/src/flows/channel-setup.ts b/src/flows/channel-setup.ts index fa0f1d7f79e9..8dff4b4f2ba5 100644 --- a/src/flows/channel-setup.ts +++ b/src/flows/channel-setup.ts @@ -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(); - const resolveWorkspaceDir = () => resolveAgentWorkspaceDir(next, resolveDefaultAgentId(next)); + const resolveWorkspaceDir = () => resolveChannelSetupWorkspaceDir(next); const rememberScopedPlugin = (plugin: ChannelSetupPlugin) => { const channel = plugin.id; scopedPluginsById.set(channel, plugin); diff --git a/src/flows/doctor-core-bootstrap-size-check.test.ts b/src/flows/doctor-core-bootstrap-size-check.test.ts new file mode 100644 index 000000000000..89586ca49fe2 --- /dev/null +++ b/src/flows/doctor-core-bootstrap-size-check.test.ts @@ -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([]); + }); +}); diff --git a/src/flows/doctor-core-checks.test.ts b/src/flows/doctor-core-checks.test.ts index f201ce5e8e11..bdaa3eba96e4 100644 --- a/src/flows/doctor-core-checks.test.ts +++ b/src/flows/doctor-core-checks.test.ts @@ -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([]); - }); -}); diff --git a/src/flows/doctor-core-checks.ts b/src/flows/doctor-core-checks.ts index b9177af1015b..06867eeacd87 100644 --- a/src/flows/doctor-core-checks.ts +++ b/src/flows/doctor-core-checks.ts @@ -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, + })); }, }; diff --git a/src/flows/doctor-core-skill-workshop-check.test.ts b/src/flows/doctor-core-skill-workshop-check.test.ts new file mode 100644 index 000000000000..138dd5d46e04 --- /dev/null +++ b/src/flows/doctor-core-skill-workshop-check.test.ts @@ -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([]); + }); +});