mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
fix(doctor): diagnose keyed agent configuration
This commit is contained in:
@@ -1302,6 +1302,25 @@ describe("maybeMigrateAuthProfileJsonStoresToSqlite", () => {
|
||||
} as unknown as OpenClawConfig,
|
||||
now: 470,
|
||||
},
|
||||
{
|
||||
profileId: "agent-work",
|
||||
cfg: {
|
||||
auth: { profiles: { "agent-work": { key: "sk-config" } } },
|
||||
agents: {
|
||||
entries: {
|
||||
main: { default: true },
|
||||
ops: {
|
||||
models: {
|
||||
"openai/gpt-5.5": {
|
||||
agentRuntime: { authProfileId: "agent-work" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig,
|
||||
now: 472,
|
||||
},
|
||||
{
|
||||
profileId: "ordered",
|
||||
cfg: {
|
||||
@@ -1340,6 +1359,44 @@ describe("maybeMigrateAuthProfileJsonStoresToSqlite", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("does not infer a credential provider from conflicting keyed-agent model hints", async () => {
|
||||
const state = await makeTestState();
|
||||
const cfg = {
|
||||
auth: { profiles: { ambiguous: { key: "sk-config" } } },
|
||||
agents: {
|
||||
entries: {
|
||||
main: {
|
||||
default: true,
|
||||
models: {
|
||||
"openai/gpt-5.5": {
|
||||
agentRuntime: { authProfileId: "ambiguous" },
|
||||
},
|
||||
},
|
||||
},
|
||||
ops: {
|
||||
models: {
|
||||
"anthropic/claude-sonnet-4-6": {
|
||||
agentRuntime: { authProfileId: "ambiguous" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const result = await maybeMigrateAuthProfileJsonStoresToSqlite({
|
||||
cfg,
|
||||
prompter: makePrompter(true),
|
||||
now: () => 475,
|
||||
});
|
||||
|
||||
expect(result.detected).toStrictEqual([]);
|
||||
expect(result.configChanged).toBeUndefined();
|
||||
expect(result.warnings).toStrictEqual([]);
|
||||
expect(cfg.auth?.profiles?.ambiguous).toEqual({ key: "sk-config" });
|
||||
expect(loadPersistedAuthProfileStore(state.agentDir())).toBeNull();
|
||||
});
|
||||
|
||||
it("imports missing config credentials while preserving legacy JSON precedence", async () => {
|
||||
const state = await makeTestState();
|
||||
const authPath = await writeLegacyAuthProfilesJson(state, {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { collectConfiguredModelRefs } from "@openclaw/model-catalog-core/configu
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { readNonBlankString as readNonEmptyString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { note } from "../../packages/terminal-core/src/note.js";
|
||||
import { listAgentEntries } from "../agents/agent-scope-config.js";
|
||||
import { AUTH_STORE_VERSION } from "../agents/auth-profiles/constants.js";
|
||||
import {
|
||||
clearAuthProfileMigrationDiagnostics,
|
||||
@@ -209,11 +210,8 @@ function collectLegacyConfigAuthProfileProviderHints(
|
||||
const agents = isRecord(root.agents) ? root.agents : null;
|
||||
const defaults = agents && isRecord(agents.defaults) ? agents.defaults : null;
|
||||
addModelHints(defaults?.models);
|
||||
const agentList = agents && Array.isArray(agents.list) ? agents.list : [];
|
||||
for (const agent of agentList) {
|
||||
if (isRecord(agent)) {
|
||||
addModelHints(agent.models);
|
||||
}
|
||||
for (const agent of listAgentEntries(cfg)) {
|
||||
addModelHints(agent.models);
|
||||
}
|
||||
return hints;
|
||||
}
|
||||
|
||||
@@ -289,6 +289,49 @@ describe("collectImplicitFallbackClobberWarnings", () => {
|
||||
} as unknown as OpenClawConfig;
|
||||
}
|
||||
|
||||
it("warns at the canonical config path when a keyed agent clobbers default model fallbacks", () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "openai/gpt-5.5",
|
||||
fallbacks: ["openai/gpt-5.4"],
|
||||
},
|
||||
},
|
||||
entries: {
|
||||
main: { default: true },
|
||||
ops: { model: "openai/gpt-5.3" },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(collectImplicitFallbackClobberWarnings(cfg)).toStrictEqual([
|
||||
[
|
||||
'- agents.entries.ops.model (id=ops) is "openai/gpt-5.3", a bare string with no fallbacks. At runtime this clobbers agents.defaults.model.fallbacks (openai/gpt-5.4), leaving the agent with no fallbacks.',
|
||||
' Fix: add "fallbacks": [...] to inherit or override, or "fallbacks": [] to explicitly disable.',
|
||||
].join("\n"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not warn when a keyed agent explicitly disables model fallbacks", () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "openai/gpt-5.5",
|
||||
fallbacks: ["openai/gpt-5.4"],
|
||||
},
|
||||
},
|
||||
entries: {
|
||||
main: { default: true },
|
||||
ops: { model: { primary: "openai/gpt-5.3", fallbacks: [] } },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(collectImplicitFallbackClobberWarnings(cfg)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty when defaults has no fallbacks", () => {
|
||||
const cfg = buildConfig({
|
||||
defaults: { primary: "openai/gpt-5.5" },
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
||||
import { resolvePrimaryStringValue } from "@openclaw/normalization-core/string-coerce";
|
||||
import type { ZodIssue } from "zod";
|
||||
import { note } from "../../packages/terminal-core/src/note.js";
|
||||
import { listAgentEntriesWithSource } from "../agents/agent-scope-config.js";
|
||||
import { CONFIG_PATH } from "../config/config.js";
|
||||
import { INCLUDE_KEY } from "../config/includes.js";
|
||||
import { resolveAgentModelFallbackValues } from "../config/model-input.js";
|
||||
@@ -195,14 +196,21 @@ function collectImplicitFallbackClobberWarnings(cfg: OpenClawConfig): string[] {
|
||||
return [];
|
||||
}
|
||||
const warnings: string[] = [];
|
||||
const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : [];
|
||||
for (const [index, agent] of agents.entries()) {
|
||||
for (const { entry: agent, source } of listAgentEntriesWithSource(cfg)) {
|
||||
if (!agent || !isImplicitFallbackClobber(agent.model)) {
|
||||
continue;
|
||||
}
|
||||
const id = typeof agent.id === "string" && agent.id.trim() ? agent.id.trim() : String(index);
|
||||
const id =
|
||||
typeof agent.id === "string" && agent.id.trim()
|
||||
? agent.id.trim()
|
||||
: source.kind === "list"
|
||||
? String(source.index)
|
||||
: source.key;
|
||||
const primary = resolvePrimaryStringValue(agent.model);
|
||||
const location = `agents.list[${index}].model (id=${id})`;
|
||||
const location =
|
||||
source.kind === "entries"
|
||||
? `agents.entries.${source.key}.model (id=${id})`
|
||||
: `agents.list[${source.index}].model (id=${id})`;
|
||||
const modelStr =
|
||||
typeof agent.model === "string" ? `"${agent.model}"` : `{ primary: "${primary}" }`;
|
||||
const shape =
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { note } from "../../packages/terminal-core/src/note.js";
|
||||
import { listAgentEntriesWithSource } from "../agents/agent-scope-config.js";
|
||||
import {
|
||||
DEFAULT_SANDBOX_BROWSER_IMAGE,
|
||||
DEFAULT_SANDBOX_COMMON_IMAGE,
|
||||
@@ -493,10 +494,9 @@ export async function maybeRepairSandboxRegistryFiles(prompter: DoctorPrompter):
|
||||
/** Warns when agent sandbox overrides are ignored because sandbox scope resolves to shared. */
|
||||
export function noteSandboxScopeWarnings(cfg: OpenClawConfig) {
|
||||
const globalSandbox = cfg.agents?.defaults?.sandbox;
|
||||
const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
for (const agent of agents) {
|
||||
for (const { entry: agent, source } of listAgentEntriesWithSource(cfg)) {
|
||||
const agentId = agent.id;
|
||||
const agentSandbox = agent.sandbox;
|
||||
if (!agentSandbox) {
|
||||
@@ -526,9 +526,10 @@ export function noteSandboxScopeWarnings(cfg: OpenClawConfig) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const agentPath = source.kind === "entries" ? `agents.entries.${source.key}` : "agents.list";
|
||||
warnings.push(
|
||||
[
|
||||
`- agents.list (id "${agentId}") sandbox ${overrides.join("/")} overrides ignored.`,
|
||||
`- ${agentPath} (id "${agentId}") sandbox ${overrides.join("/")} overrides ignored.`,
|
||||
` scope resolves to "shared".`,
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
@@ -718,6 +718,24 @@ describe("noteSecurityWarnings gateway exposure", () => {
|
||||
expect(message).toContain("direct/DM targets by default");
|
||||
});
|
||||
|
||||
it("warns at the canonical config path for a keyed agent's implicit heartbeat directPolicy", async () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
entries: {
|
||||
main: { default: true },
|
||||
ops: { heartbeat: { target: "last" } },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
await noteSecurityWarnings(cfg);
|
||||
|
||||
const message = lastMessage();
|
||||
expect(message).toContain('Heartbeat agent "ops"');
|
||||
expect(message).toContain("agents.entries.ops.heartbeat.directPolicy");
|
||||
expect(message).toContain("direct/DM targets by default");
|
||||
});
|
||||
|
||||
it("degrades safely when channel account resolution fails in read-only security checks", async () => {
|
||||
pluginRegistry.list = [
|
||||
{
|
||||
@@ -759,15 +777,15 @@ describe("noteSecurityWarnings gateway exposure", () => {
|
||||
target: "none",
|
||||
},
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "ops",
|
||||
entries: {
|
||||
main: { default: true },
|
||||
ops: {
|
||||
heartbeat: {
|
||||
target: "last",
|
||||
directPolicy: "block",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
await noteSecurityWarnings(cfg);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Security warnings for gateway exposure, exec policy drift, channel DMs, and plaintext secrets. */
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { note } from "../../packages/terminal-core/src/note.js";
|
||||
import { listAgentEntriesWithSource } from "../agents/agent-scope-config.js";
|
||||
import { listReadOnlyChannelPluginsForConfig } from "../channels/plugins/read-only.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import type { OpenClawConfig, GatewayBindMode } from "../config/config.js";
|
||||
@@ -55,12 +56,14 @@ function collectImplicitHeartbeatDirectPolicyWarnings(cfg: OpenClawConfig): Secu
|
||||
pathHint: "agents.defaults.heartbeat.directPolicy",
|
||||
});
|
||||
|
||||
const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : [];
|
||||
for (const agent of agents) {
|
||||
for (const { entry: agent, source } of listAgentEntriesWithSource(cfg)) {
|
||||
maybeWarn({
|
||||
label: `Heartbeat agent "${agent.id}"`,
|
||||
heartbeat: agent.heartbeat,
|
||||
pathHint: `heartbeat.directPolicy for agent "${agent.id}"`,
|
||||
pathHint:
|
||||
source.kind === "entries"
|
||||
? `agents.entries.${source.key}.heartbeat.directPolicy`
|
||||
: `heartbeat.directPolicy for agent "${agent.id}"`,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -341,7 +341,7 @@ describe("doctor state integrity oauth dir checks", () => {
|
||||
expect(stateIntegrityText()).toContain("CRITICAL: OAuth dir missing");
|
||||
});
|
||||
|
||||
it("warns about orphaned on-disk agent directories missing from agents.list", async () => {
|
||||
it("preserves list-roster paths in orphaned agent recovery advice", async () => {
|
||||
createAgentDir("big-brain");
|
||||
createAgentDir("cerebro");
|
||||
|
||||
@@ -352,8 +352,42 @@ describe("doctor state integrity oauth dir checks", () => {
|
||||
});
|
||||
|
||||
expect(text).toContain("without a matching agents.list entry");
|
||||
expect(text).toContain("Restore the missing agents.list entries");
|
||||
expect(text).toContain("Examples: big-brain, cerebro");
|
||||
expect(text).toContain("config-driven routing, identity, and model selection will ignore them");
|
||||
expect(text).not.toContain("agents.entries");
|
||||
});
|
||||
|
||||
it("points canonical agent-state recovery at the keyed agent roster", async () => {
|
||||
createAgentDir("orphan");
|
||||
|
||||
const text = await runStateIntegrityText({
|
||||
agents: {
|
||||
entries: { main: { default: true } },
|
||||
},
|
||||
});
|
||||
|
||||
expect(text).toContain("without a matching agents.entries entry");
|
||||
expect(text).toContain("Restore the missing agents.entries entries");
|
||||
expect(text).toContain("Examples: orphan");
|
||||
expect(text).not.toContain("agents.list");
|
||||
});
|
||||
|
||||
it("does not label canonical configured agent directories as orphaned", async () => {
|
||||
createAgentDir("main");
|
||||
createAgentDir("ops");
|
||||
|
||||
const text = await runStateIntegrityText({
|
||||
agents: {
|
||||
entries: {
|
||||
main: { default: true },
|
||||
ops: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(text).not.toContain("on disk without a matching");
|
||||
expect(text).not.toContain("Examples: ops");
|
||||
});
|
||||
|
||||
it("detects orphaned agent dirs even when the on-disk folder casing differs", async () => {
|
||||
@@ -365,7 +399,7 @@ describe("doctor state integrity oauth dir checks", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(text).toContain("without a matching agents.list entry");
|
||||
expect(text).toContain("on disk without a matching");
|
||||
expect(text).toContain("Examples: Research (id research)");
|
||||
});
|
||||
|
||||
@@ -380,7 +414,7 @@ describe("doctor state integrity oauth dir checks", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(text).not.toContain("without a matching agents.list entry");
|
||||
expect(text).not.toContain("on disk without a matching");
|
||||
expect(text).not.toContain("Examples:");
|
||||
});
|
||||
|
||||
@@ -393,7 +427,7 @@ describe("doctor state integrity oauth dir checks", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(text).not.toContain("without a matching agents.list entry");
|
||||
expect(text).not.toContain("on disk without a matching");
|
||||
expect(text).not.toContain("Examples: main");
|
||||
});
|
||||
|
||||
@@ -428,7 +462,7 @@ describe("doctor state integrity oauth dir checks", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(text).toContain("without a matching agents.list entry");
|
||||
expect(text).toContain("on disk without a matching");
|
||||
expect(text).toContain("Examples: legacy");
|
||||
});
|
||||
|
||||
@@ -517,7 +551,7 @@ describe("doctor state integrity oauth dir checks", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(text).toContain("without a matching agents.list entry");
|
||||
expect(text).toContain("on disk without a matching");
|
||||
expect(text).toContain("Examples: Research (id research)");
|
||||
} finally {
|
||||
realpathSpy.mockRestore();
|
||||
@@ -548,7 +582,7 @@ describe("doctor state integrity oauth dir checks", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(text).not.toContain("without a matching agents.list entry");
|
||||
expect(text).not.toContain("on disk without a matching");
|
||||
expect(text).not.toContain("Examples:");
|
||||
} finally {
|
||||
realpathSpy.mockRestore();
|
||||
|
||||
@@ -8,6 +8,7 @@ import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/s
|
||||
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
|
||||
import { note } from "../../packages/terminal-core/src/note.js";
|
||||
import { isSharedAuthStoreOwner } from "../agents/agent-delete-safety.js";
|
||||
import { readAgentRosterProperty } from "../agents/agent-scope-config.js";
|
||||
import {
|
||||
listAgentEntries,
|
||||
resolveDefaultAgentDir,
|
||||
@@ -1314,12 +1315,14 @@ export async function noteStateIntegrity(
|
||||
|
||||
const orphanAgentDirs = listOrphanAgentDirs(cfg, stateDir);
|
||||
if (orphanAgentDirs.length > 0) {
|
||||
const authoredAgentRosterPath =
|
||||
readAgentRosterProperty(cfg)?.kind === "list" ? "agents.list" : "agents.entries";
|
||||
warnings.push(
|
||||
[
|
||||
`- Found ${countLabel(orphanAgentDirs.length, "agent directory", "agent directories")} on disk without a matching agents.list entry.`,
|
||||
`- Found ${countLabel(orphanAgentDirs.length, "agent directory", "agent directories")} on disk without a matching ${authoredAgentRosterPath} entry.`,
|
||||
" These agents can still have sessions/auth state on disk, but config-driven routing, identity, and model selection will ignore them.",
|
||||
` Examples: ${formatOrphanAgentDirPreview(orphanAgentDirs)}`,
|
||||
` Restore the missing agents.list entries or remove stale dirs after confirming they are no longer needed: ${shortenHomePath(path.join(stateDir, "agents"))}`,
|
||||
` Restore the missing ${authoredAgentRosterPath} entries or remove stale dirs after confirming they are no longer needed: ${shortenHomePath(path.join(stateDir, "agents"))}`,
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createDoctorRuntime, mockDoctorConfigSnapshot } from "./doctor.e2e-harness.js";
|
||||
import { loadDoctorCommandForTest, terminalNoteMock } from "./doctor.note-test-helpers.js";
|
||||
import "./doctor.fast-path-mocks.js";
|
||||
@@ -10,10 +10,15 @@ import "./doctor.fast-path-mocks.js";
|
||||
let doctorCommand: typeof import("./doctor.js").doctorCommand;
|
||||
|
||||
describe("doctor command", () => {
|
||||
beforeEach(async () => {
|
||||
beforeAll(async () => {
|
||||
doctorCommand = await loadDoctorCommandForTest({
|
||||
unmockModules: ["./doctor-sandbox.js", "../flows/doctor-health-contributions.js"],
|
||||
});
|
||||
await import("../flows/doctor-health-contributions.js");
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
terminalNoteMock.mockClear();
|
||||
});
|
||||
|
||||
it("warns when per-agent sandbox docker/browser/prune overrides are ignored under shared scope", async () => {
|
||||
@@ -22,23 +27,24 @@ describe("doctor command", () => {
|
||||
agents: {
|
||||
defaults: {
|
||||
sandbox: {
|
||||
mode: "all",
|
||||
mode: "off",
|
||||
scope: "shared",
|
||||
},
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "work",
|
||||
workspace: "~/openclaw-work",
|
||||
entries: {
|
||||
main: { default: true },
|
||||
work: {
|
||||
sandbox: {
|
||||
mode: "all",
|
||||
scope: "shared",
|
||||
docker: {
|
||||
setupCommand: "echo work",
|
||||
},
|
||||
browser: { enabled: true },
|
||||
prune: { idleHours: 24 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -51,13 +57,46 @@ describe("doctor command", () => {
|
||||
}
|
||||
const normalized = message.replace(/\s+/g, " ").trim();
|
||||
return (
|
||||
normalized.includes('agents.list (id "work") sandbox docker') &&
|
||||
normalized.includes('agents.entries.work (id "work") sandbox docker/browser/prune') &&
|
||||
normalized.includes('scope resolves to "shared"')
|
||||
);
|
||||
});
|
||||
expect(matchingSandboxNotes.length).toBeGreaterThan(0);
|
||||
}, 30_000);
|
||||
|
||||
it("does not warn when a keyed agent owns its sandbox overrides", async () => {
|
||||
mockDoctorConfigSnapshot({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: { sandbox: { mode: "off", scope: "shared" } },
|
||||
entries: {
|
||||
main: { default: true },
|
||||
work: {
|
||||
sandbox: {
|
||||
mode: "all",
|
||||
scope: "agent",
|
||||
docker: { setupCommand: "echo work" },
|
||||
browser: { enabled: true },
|
||||
prune: { idleHours: 24 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await doctorCommand(createDoctorRuntime(), { nonInteractive: true });
|
||||
|
||||
expect(
|
||||
terminalNoteMock.mock.calls.some(
|
||||
([message, title]) =>
|
||||
title === "Sandbox" &&
|
||||
typeof message === "string" &&
|
||||
message.includes("agents.entries.work"),
|
||||
),
|
||||
).toBe(false);
|
||||
}, 30_000);
|
||||
|
||||
it("does not warn when only the active workspace is present", async () => {
|
||||
mockDoctorConfigSnapshot({
|
||||
config: {
|
||||
|
||||
@@ -980,6 +980,28 @@ describe("doctor preview warnings", () => {
|
||||
expect(warning).not.toContain("agents.list[0].tools.alsoAllow");
|
||||
});
|
||||
|
||||
it("uses canonical keyed-agent paths for restrictive tool profile advice", async () => {
|
||||
const warnings = await collectProfileConfiguredToolSectionWarningsThroughDoctor({
|
||||
tools: { profile: "messaging" },
|
||||
agents: {
|
||||
entries: {
|
||||
main: { default: true },
|
||||
sage: {
|
||||
tools: {
|
||||
allow: ["message"],
|
||||
exec: { mode: "allowlist" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const warning = expectSingleWarningContaining(warnings, "agents.entries.sage.tools.profile");
|
||||
expect(warning).toContain("Add these grants to agents.entries.sage.tools.allow");
|
||||
expect(warning).toContain('set agents.entries.sage.tools.profile to "full"');
|
||||
expect(warning).not.toContain("agents.list");
|
||||
});
|
||||
|
||||
it("warns when an agent tool section inherits a restrictive provider profile", async () => {
|
||||
const warnings = await collectProfileConfiguredToolSectionWarningsThroughDoctor({
|
||||
tools: {
|
||||
@@ -1013,6 +1035,59 @@ describe("doctor preview warnings", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses canonical keyed-agent paths for inherited provider profile advice", async () => {
|
||||
const warnings = await collectProfileConfiguredToolSectionWarningsThroughDoctor({
|
||||
tools: {
|
||||
byProvider: {
|
||||
openai: { profile: "messaging" },
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
entries: {
|
||||
main: { default: true },
|
||||
sage: {
|
||||
tools: { exec: { mode: "allowlist" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const warning = expectSingleWarningContaining(
|
||||
warnings,
|
||||
'tools.byProvider.openai.profile is "messaging"',
|
||||
);
|
||||
expect(warning).toContain("agents.entries.sage.tools.exec is configured");
|
||||
expect(warning).toContain(
|
||||
'agents.entries.sage.tools.byProvider.openai.alsoAllow: ["exec", "process"]',
|
||||
);
|
||||
expect(warning).not.toContain("agents.list");
|
||||
});
|
||||
|
||||
it("does not warn when a keyed agent already inherits the required provider grants", async () => {
|
||||
const warnings = await collectProfileConfiguredToolSectionWarningsThroughDoctor({
|
||||
tools: {
|
||||
byProvider: {
|
||||
openai: { alsoAllow: ["exec", "process"] },
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
entries: {
|
||||
main: { default: true },
|
||||
sage: {
|
||||
tools: {
|
||||
exec: { mode: "allowlist" },
|
||||
byProvider: {
|
||||
"openai/gpt-5": { profile: "messaging" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(warnings).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("uses inherited provider alsoAllow for agent provider profile warnings", async () => {
|
||||
const warnings = await collectProfileConfiguredToolSectionWarningsThroughDoctor({
|
||||
tools: {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// Doctor preview warning aggregation for config that can surprise users before repair.
|
||||
import { isRecord as hasRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { listAgentEntries, resolveAgentConfig } from "../../../agents/agent-scope-config.js";
|
||||
import {
|
||||
listAgentEntries,
|
||||
listAgentEntriesWithSource,
|
||||
resolveAgentConfig,
|
||||
} from "../../../agents/agent-scope-config.js";
|
||||
import {
|
||||
normalizeToolProviderPolicyKey,
|
||||
resolveProviderToolPolicy,
|
||||
@@ -628,12 +632,15 @@ function collectProfileConfiguredToolSectionWarnings(cfg: OpenClawConfig): strin
|
||||
}),
|
||||
);
|
||||
|
||||
listAgentRecords(cfg).forEach((agent, index) => {
|
||||
for (const { entry: agent, source } of listAgentEntriesWithSource(cfg)) {
|
||||
const agentTools = hasRecord(agent.tools) ? agent.tools : undefined;
|
||||
const agentId = typeof agent.id === "string" ? agent.id : undefined;
|
||||
const agentConfig = agentId ? resolveAgentConfig(cfg, agentId) : undefined;
|
||||
const modelRef = resolveDoctorPrimaryModelRef(cfg, agentConfig?.model);
|
||||
const agentPath = `agents.list[${index}].tools`;
|
||||
const agentPath =
|
||||
source.kind === "entries"
|
||||
? `agents.entries.${source.key}.tools`
|
||||
: `agents.list[${source.index}].tools`;
|
||||
const includeInheritedSections =
|
||||
agentTools !== undefined && typeof agentTools.profile !== "string";
|
||||
const ownAgentConfiguredEntries = collectConfiguredToolSectionGrantEntries({
|
||||
@@ -667,7 +674,7 @@ function collectProfileConfiguredToolSectionWarnings(cfg: OpenClawConfig): strin
|
||||
modelId: modelRef.model,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ function codexPluginEntryEnabled(cfg: OpenClawConfig): boolean | undefined {
|
||||
function configuredRuntimeNeedsCodex(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
agentId?: string;
|
||||
modelId?: string;
|
||||
runtimeId?: string;
|
||||
}): boolean {
|
||||
@@ -63,6 +64,7 @@ function configuredRuntimeNeedsCodex(params: {
|
||||
provider: OPENAI_PROVIDER_ID,
|
||||
modelId: params.modelId,
|
||||
config: params.cfg,
|
||||
agentId: params.agentId,
|
||||
env: params.env,
|
||||
}) === CODEX_PLUGIN_ID
|
||||
);
|
||||
@@ -87,6 +89,7 @@ export function configuredModelRouteNeedsCodex(params: {
|
||||
return configuredRuntimeNeedsCodex({
|
||||
cfg: params.cfg,
|
||||
env: params.env,
|
||||
agentId: params.agentId,
|
||||
modelId: params.route.modelId,
|
||||
runtimeId: runtime,
|
||||
});
|
||||
@@ -183,7 +186,7 @@ function configuredProviderPoliciesNeedCodex(
|
||||
}).policy;
|
||||
if (
|
||||
genericPolicy?.id?.trim() &&
|
||||
configuredRuntimeNeedsCodex({ cfg, env, runtimeId: genericPolicy.id })
|
||||
configuredRuntimeNeedsCodex({ cfg, env, agentId, runtimeId: genericPolicy.id })
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
@@ -283,7 +286,7 @@ function defaultOpenAiRouteNeedsCodex(
|
||||
provider: OPENAI_PROVIDER_ID,
|
||||
agentId,
|
||||
}).policy?.id;
|
||||
return configuredRuntimeNeedsCodex({ cfg, env, runtimeId });
|
||||
return configuredRuntimeNeedsCodex({ cfg, env, agentId, runtimeId });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -440,6 +440,56 @@ describe("config plugin validation", () => {
|
||||
expectNoMissingCodexPluginWarning(res.warnings);
|
||||
});
|
||||
|
||||
it("keeps missing Codex diagnostics scoped to agent-wide provider request parameters", () => {
|
||||
const res = validateWithMissingCodexPlugin({
|
||||
agents: {
|
||||
entries: {
|
||||
openclaw: {
|
||||
default: true,
|
||||
model: { primary: "anthropic/claude-sonnet-4-6", fallbacks: [] },
|
||||
subagents: { model: "anthropic/claude-sonnet-4-6" },
|
||||
},
|
||||
work: {
|
||||
model: { primary: "openai/gpt-5.6", fallbacks: [] },
|
||||
subagents: { model: "openai/gpt-5.6" },
|
||||
params: { temperature: 0.4 },
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: { entries: { codex: {} } },
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expectNoMissingCodexPluginWarning(res.warnings);
|
||||
});
|
||||
|
||||
it("still warns when another agent genuinely needs the missing Codex plugin", () => {
|
||||
const res = validateWithMissingCodexPlugin({
|
||||
agents: {
|
||||
entries: {
|
||||
openclaw: {
|
||||
default: true,
|
||||
model: { primary: "anthropic/claude-sonnet-4-6", fallbacks: [] },
|
||||
subagents: { model: "anthropic/claude-sonnet-4-6" },
|
||||
},
|
||||
work: {
|
||||
model: { primary: "openai/gpt-5.6", fallbacks: [] },
|
||||
subagents: { model: "openai/gpt-5.6" },
|
||||
params: { temperature: 0.4 },
|
||||
},
|
||||
codex: {
|
||||
model: { primary: "openai/gpt-5.6", fallbacks: [] },
|
||||
subagents: { model: "openai/gpt-5.6" },
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: { entries: { codex: {} } },
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expectMissingCodexPluginWarning(res.warnings);
|
||||
});
|
||||
|
||||
it("still warns when only one provider model route is pinned to OpenClaw", () => {
|
||||
const res = validateWithMissingCodexPlugin({
|
||||
models: {
|
||||
|
||||
@@ -104,6 +104,32 @@ describe("scripts/docker/setup.sh", () => {
|
||||
sandbox = null;
|
||||
});
|
||||
|
||||
it("loads Docker resource diagnostics from the isolated setup sandbox", () => {
|
||||
const activeSandbox = requireSandbox(sandbox);
|
||||
const containerHelper = join(
|
||||
activeSandbox.rootDir,
|
||||
"scripts",
|
||||
"lib",
|
||||
"docker-e2e-container.sh",
|
||||
);
|
||||
const result = spawnSync(
|
||||
"bash",
|
||||
[
|
||||
"-c",
|
||||
'set -euo pipefail; source "$1"; declare -F docker_e2e_docker_run_with_resource_diagnostics >/dev/null',
|
||||
"openclaw-docker-setup-resource-diagnostics",
|
||||
containerHelper,
|
||||
],
|
||||
{
|
||||
cwd: activeSandbox.rootDir,
|
||||
env: createEnv(activeSandbox),
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
});
|
||||
|
||||
it("handles env defaults, home-volume mounts, and Docker build args", async () => {
|
||||
const activeSandbox = requireSandbox(sandbox);
|
||||
const buildCommit = "0123456789abcdef0123456789abcdef01234567";
|
||||
|
||||
Reference in New Issue
Block a user