fix(doctor): diagnose keyed agent configuration

This commit is contained in:
Dallin Romney
2026-08-13 15:42:53 +08:00
parent eb07eecd40
commit dd187d36d9
15 changed files with 408 additions and 43 deletions
@@ -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, {
+3 -5
View File
@@ -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" },
+12 -4
View File
@@ -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 =
+4 -3
View File
@@ -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"),
);
+22 -4
View File
@@ -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);
+6 -3
View File
@@ -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}"`,
});
}
+41 -7
View File
@@ -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();
+5 -2
View File
@@ -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: {
+11 -4
View File
@@ -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;
}
+5 -2
View File
@@ -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: {
+26
View File
@@ -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";