mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
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:
committed by
GitHub
parent
f0d277277b
commit
6e5bf3ec55
+16
-12
@@ -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.
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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, []);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user