diff --git a/src/commands/doctor-auth-flat-profiles.test.ts b/src/commands/doctor-auth-flat-profiles.test.ts index f83b1066caac..e9348bb3c577 100644 --- a/src/commands/doctor-auth-flat-profiles.test.ts +++ b/src/commands/doctor-auth-flat-profiles.test.ts @@ -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, { diff --git a/src/commands/doctor-auth-flat-profiles.ts b/src/commands/doctor-auth-flat-profiles.ts index 2a2af3c91406..3c629db98b7d 100644 --- a/src/commands/doctor-auth-flat-profiles.ts +++ b/src/commands/doctor-auth-flat-profiles.ts @@ -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; } diff --git a/src/commands/doctor-config-analysis.test.ts b/src/commands/doctor-config-analysis.test.ts index 326a7ac1bbb6..bb62df6031ba 100644 --- a/src/commands/doctor-config-analysis.test.ts +++ b/src/commands/doctor-config-analysis.test.ts @@ -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" }, diff --git a/src/commands/doctor-config-analysis.ts b/src/commands/doctor-config-analysis.ts index 6b860a149d05..ab6915fcaed1 100644 --- a/src/commands/doctor-config-analysis.ts +++ b/src/commands/doctor-config-analysis.ts @@ -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 = diff --git a/src/commands/doctor-sandbox.ts b/src/commands/doctor-sandbox.ts index 18b944e865a7..5a025687a3a7 100644 --- a/src/commands/doctor-sandbox.ts +++ b/src/commands/doctor-sandbox.ts @@ -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"), ); diff --git a/src/commands/doctor-security.test.ts b/src/commands/doctor-security.test.ts index 051c83a1888c..e2ad7e253bd0 100644 --- a/src/commands/doctor-security.test.ts +++ b/src/commands/doctor-security.test.ts @@ -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); diff --git a/src/commands/doctor-security.ts b/src/commands/doctor-security.ts index 6c9c093383f8..0888cdf6513c 100644 --- a/src/commands/doctor-security.ts +++ b/src/commands/doctor-security.ts @@ -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}"`, }); } diff --git a/src/commands/doctor-state-integrity.test.ts b/src/commands/doctor-state-integrity.test.ts index 75cdafed576d..6fcf3d179c6d 100644 --- a/src/commands/doctor-state-integrity.test.ts +++ b/src/commands/doctor-state-integrity.test.ts @@ -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(); diff --git a/src/commands/doctor-state-integrity.ts b/src/commands/doctor-state-integrity.ts index dc6ef5b86f3d..e6f71aa6ed38 100644 --- a/src/commands/doctor-state-integrity.ts +++ b/src/commands/doctor-state-integrity.ts @@ -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"), ); } diff --git a/src/commands/doctor.warns-per-agent-sandbox-docker-browser-prune.e2e.test.ts b/src/commands/doctor.warns-per-agent-sandbox-docker-browser-prune.e2e.test.ts index 65a3228048ea..a69e9c7b5d99 100644 --- a/src/commands/doctor.warns-per-agent-sandbox-docker-browser-prune.e2e.test.ts +++ b/src/commands/doctor.warns-per-agent-sandbox-docker-browser-prune.e2e.test.ts @@ -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: { diff --git a/src/commands/doctor/shared/preview-warnings.test.ts b/src/commands/doctor/shared/preview-warnings.test.ts index 9ab1fb50411b..d37bf8670e19 100644 --- a/src/commands/doctor/shared/preview-warnings.test.ts +++ b/src/commands/doctor/shared/preview-warnings.test.ts @@ -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: { diff --git a/src/commands/doctor/shared/preview-warnings.ts b/src/commands/doctor/shared/preview-warnings.ts index 08758045f1eb..f3b446b5cee0 100644 --- a/src/commands/doctor/shared/preview-warnings.ts +++ b/src/commands/doctor/shared/preview-warnings.ts @@ -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; } diff --git a/src/config/codex-plugin-diagnostics.ts b/src/config/codex-plugin-diagnostics.ts index 7c3fd09ad4db..d120dd34c8ad 100644 --- a/src/config/codex-plugin-diagnostics.ts +++ b/src/config/codex-plugin-diagnostics.ts @@ -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 }); }); } diff --git a/src/config/config.plugin-validation.test.ts b/src/config/config.plugin-validation.test.ts index 1701ff21eb9b..2bfa8b4740f2 100644 --- a/src/config/config.plugin-validation.test.ts +++ b/src/config/config.plugin-validation.test.ts @@ -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: { diff --git a/src/docker-setup.e2e.test.ts b/src/docker-setup.e2e.test.ts index 7ea810fbba13..37fbbe8f61e9 100644 --- a/src/docker-setup.e2e.test.ts +++ b/src/docker-setup.e2e.test.ts @@ -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";