fix(cli): honor explicit system agent in configure (#125377)

* fix(cli): honor explicit system agent in configure

* fix(cli): persist explicit agent workspace

* fix(cli): preserve authored agent key casing

* fix(cli): preserve configured system agent key

* fix(configure): preserve legacy onboarding owner

* fix(configure): preserve workspace ownership semantics

* fix(configure): retain migrated legacy owner

* fix(configure): retain legacy owner marker

---------

Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
This commit is contained in:
Jacqueline Henriksen
2026-08-17 23:46:30 -07:00
committed by GitHub
parent 47af5a0960
commit 5dcc91df99
4 changed files with 257 additions and 6 deletions
@@ -1,5 +1,6 @@
// Configure wizard tests keep workspace-owned effects on the configured default agent.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { retainLegacyDefaultAgentId } from "../config/legacy.default-agent-owner.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { RuntimeEnv } from "../runtime.js";
@@ -159,6 +160,192 @@ describe("runConfigureWizard default-agent ownership", () => {
);
});
it("uses the configured system agent when ownership is explicit", async () => {
const baseConfig = {
agents: {
ownership: "explicit",
defaults: {
workspace: "/tmp/global-workspace",
systemAgent: { agentId: "main" },
},
entries: {
MAIN: {
agentDir: "/tmp/main-agent",
},
ops: {
agentDir: "/tmp/ops-agent",
workspace: "/tmp/ops-workspace",
},
},
},
} satisfies OpenClawConfig;
mocks.state.snapshot = {
exists: true,
valid: true,
hash: "config-hash",
config: baseConfig,
sourceConfig: baseConfig,
issues: [],
};
mocks.text.mockResolvedValue("/tmp/new-main-workspace");
await runConfigureWizard(
{ command: "configure", sections: ["workspace", "plugins", "skills"] },
runtime,
);
expect(mocks.commitConfig).toHaveBeenCalledWith(
expect.objectContaining({
nextConfig: expect.objectContaining({
agents: expect.objectContaining({
defaults: expect.objectContaining({ workspace: "/tmp/global-workspace" }),
entries: {
MAIN: {
agentDir: "/tmp/main-agent",
workspace: "/tmp/new-main-workspace",
},
ops: {
agentDir: "/tmp/ops-agent",
workspace: "/tmp/ops-workspace",
},
},
}),
}),
}),
);
expect(mocks.setupPluginConfig).toHaveBeenCalledWith(
expect.objectContaining({ workspaceDir: "/tmp/new-main-workspace" }),
);
expect(mocks.setupSkills).toHaveBeenCalledWith(
expect.any(Object),
"/tmp/new-main-workspace",
runtime,
expect.any(Object),
);
expect(mocks.ensureWorkspaceAndSessions).toHaveBeenCalledWith(
"/tmp/new-main-workspace",
runtime,
expect.objectContaining({ agentId: "main" }),
);
});
it("preserves the legacy default owner when system-agent ownership is not explicit", async () => {
const baseConfig = {
agents: {
defaults: {
workspace: "/tmp/global-workspace",
systemAgent: { agentId: "main" },
},
entries: {
MAIN: { agentDir: "/tmp/main-agent" },
ops: {
default: true,
agentDir: "/tmp/ops-agent",
workspace: "/tmp/ops-workspace",
},
},
},
} satisfies OpenClawConfig;
mocks.state.snapshot = {
exists: true,
valid: true,
hash: "config-hash",
config: baseConfig,
sourceConfig: baseConfig,
issues: [],
};
mocks.text.mockResolvedValue("/tmp/new-ops-workspace");
await runConfigureWizard(
{ command: "configure", sections: ["workspace", "plugins", "skills"] },
runtime,
);
expect(mocks.setupPluginConfig).toHaveBeenCalledWith(
expect.objectContaining({ workspaceDir: "/tmp/new-ops-workspace" }),
);
expect(mocks.setupSkills).toHaveBeenCalledWith(
expect.any(Object),
"/tmp/new-ops-workspace",
runtime,
expect.any(Object),
);
expect(mocks.ensureWorkspaceAndSessions).toHaveBeenCalledWith(
"/tmp/new-ops-workspace",
runtime,
expect.objectContaining({ agentId: "ops" }),
);
expect(mocks.commitConfig).toHaveBeenCalledWith(
expect.objectContaining({
nextConfig: expect.objectContaining({
agents: expect.objectContaining({
entries: expect.objectContaining({
MAIN: expect.not.objectContaining({ workspace: "/tmp/new-ops-workspace" }),
ops: expect.objectContaining({ workspace: "/tmp/new-ops-workspace" }),
}),
}),
}),
}),
);
});
it("keeps a workspace-less legacy owner on the global workspace", async () => {
const baseConfig = {
agents: {
defaults: {
workspace: "/tmp/global-workspace",
systemAgent: { agentId: "main" },
},
entries: {
main: { agentDir: "/tmp/main-agent" },
ops: {
agentDir: "/tmp/ops-agent",
},
},
},
} satisfies OpenClawConfig;
retainLegacyDefaultAgentId(baseConfig, "ops");
mocks.state.snapshot = {
exists: true,
valid: true,
hash: "config-hash",
config: baseConfig,
sourceConfig: baseConfig,
issues: [],
};
mocks.text.mockResolvedValue("/tmp/new-global-workspace");
await runConfigureWizard(
{ command: "configure", sections: ["workspace", "plugins", "skills"] },
runtime,
);
expect(mocks.commitConfig).toHaveBeenCalledWith(
expect.objectContaining({
nextConfig: expect.objectContaining({
agents: expect.objectContaining({
defaults: expect.objectContaining({ workspace: "/tmp/new-global-workspace" }),
entries: baseConfig.agents.entries,
}),
}),
}),
);
expect(mocks.setupPluginConfig).toHaveBeenCalledWith(
expect.objectContaining({ workspaceDir: "/tmp/new-global-workspace" }),
);
expect(mocks.setupSkills).toHaveBeenCalledWith(
expect.any(Object),
"/tmp/new-global-workspace",
runtime,
expect.any(Object),
);
expect(mocks.ensureWorkspaceAndSessions).toHaveBeenCalledWith(
"/tmp/new-global-workspace",
runtime,
expect.objectContaining({ agentId: "ops" }),
);
});
it("does not persist an unprovisionable workspace", async () => {
mocks.ensureWorkspaceAndSessions.mockRejectedValueOnce(new Error("workspace is unwritable"));
+20 -4
View File
@@ -8,12 +8,14 @@ import { formatCliCommand } from "../cli/command-format.js";
import { formatPortRangeHint } from "../cli/error-format.js";
import { parsePort } from "../cli/shared/parse-port.js";
import { readConfigFileSnapshotForWrite, resolveGatewayPort } from "../config/config.js";
import { inheritLegacyDefaultAgentId } from "../config/legacy.default-agent-owner.js";
import { logConfigUpdated } from "../config/logging.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveGatewayProbeAuthSafeWithSecretInputs } from "../gateway/probe-auth.js";
import { formatWindowsGatewayFirewallGuidance } from "../infra/windows-gateway-firewall-diagnostics.js";
import { commitConfigWithPendingPluginInstalls } from "../plugins/install-record-commit.js";
import { resolvePluginContributionOwners } from "../plugins/plugin-registry.js";
import { normalizeAgentId } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { createLazyImportLoader } from "../shared/lazy-promise.js";
@@ -44,6 +46,7 @@ import { healthCommand } from "./health.js";
import {
ensureOnboardingAgentWorkspace,
resolveOnboardingAgentTarget,
resolveSystemAgentOnboardingTarget,
} from "./onboard-agent-target.js";
import { setupChannels } from "./onboard-channels.js";
import {
@@ -608,7 +611,12 @@ export async function runConfigureWizard(
};
didSetGatewayMode = true;
}
const resolveSetupTarget = () => resolveOnboardingAgentTarget(nextConfig);
// Configure keeps legacy default-owner semantics; only explicit fleets opt into
// the System Agent target used unconditionally by setup and recovery callers.
const resolveSetupTarget = () =>
nextConfig.agents?.ownership === "explicit"
? resolveSystemAgentOnboardingTarget(nextConfig)
: resolveOnboardingAgentTarget(inheritLegacyDefaultAgentId(baseConfig, nextConfig));
let workspaceDir = resolveSetupTarget().workspaceDir;
let gatewayPort = resolveGatewayPort(baseConfig);
@@ -665,16 +673,24 @@ export async function runConfigureWizard(
}
}
const target = resolveSetupTarget();
const targetEntry = nextConfig.agents?.entries?.[target.agentId];
const authoredEntryKey = Object.keys(nextConfig.agents?.entries ?? {}).find(
(key) => normalizeAgentId(key) === target.agentId,
);
const targetEntry = authoredEntryKey
? nextConfig.agents?.entries?.[authoredEntryKey]
: undefined;
// Explicit fleets own workspace at the selected entry even when it inherited
// the global default; legacy owners stay global until they author an override.
nextConfig =
targetEntry?.workspace !== undefined
targetEntry?.workspace !== undefined ||
(nextConfig.agents?.ownership === "explicit" && targetEntry !== undefined)
? {
...nextConfig,
agents: {
...nextConfig.agents,
entries: {
...nextConfig.agents?.entries,
[target.agentId]: { ...targetEntry, workspace: workspaceDir },
[authoredEntryKey ?? target.agentId]: { ...targetEntry, workspace: workspaceDir },
},
},
}
+45
View File
@@ -7,13 +7,34 @@ import { retainLegacyDefaultAgentId } from "../config/legacy.default-agent-owner
import type { RuntimeEnv } from "../runtime.js";
import { withEnvAsync } from "../test-utils/env.js";
import {
applyOnboardingPrimaryModel,
ensureOnboardingAgentWorkspace,
resolveOnboardingAgentTarget,
resolveSystemAgentOnboardingTarget,
} from "./onboard-agent-target.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
describe("onboarding agent target", () => {
it("preserves an uppercase authored entry key when applying the primary model", () => {
const config = {
agents: {
ownership: "explicit" as const,
entries: {
MAIN: { model: "openai/old" },
},
},
};
const target = resolveOnboardingAgentTarget(config, "main");
expect(applyOnboardingPrimaryModel(config, target, "openai/new").agents?.entries).toEqual({
MAIN: {
model: { primary: "openai/new" },
models: { "openai/new": {} },
},
});
});
it("uses the retained compatibility owner after the marker is removed", () => {
const config = retainLegacyDefaultAgentId(
{ agents: { entries: { main: {}, ops: { workspace: "/srv/ops" } } } },
@@ -26,6 +47,30 @@ describe("onboarding agent target", () => {
});
});
it("resolves shared system-agent setup to the configured system agent on a legacy roster", () => {
const config = {
agents: {
defaults: {
workspace: "/srv/global",
systemAgent: { agentId: "main" },
},
entries: {
main: { workspace: "/srv/main" },
ops: { default: true, workspace: "/srv/ops" },
},
},
};
expect(resolveOnboardingAgentTarget(config)).toMatchObject({
agentId: "ops",
workspaceDir: "/srv/ops",
});
expect(resolveSystemAgentOnboardingTarget(config)).toMatchObject({
agentId: "main",
workspaceDir: "/srv/main",
});
});
it("provisions the configured default agent workspace and sessions", async () => {
const stateDir = tempDirs.make("openclaw-onboard-target-");
const globalWorkspace = path.join(stateDir, "global-workspace");
+5 -2
View File
@@ -61,7 +61,10 @@ export function applyOnboardingPrimaryModel(
target: OnboardingAgentTarget,
model: string,
): OpenClawConfig {
const entry = config.agents?.entries?.[target.agentId];
const authoredEntryKey = Object.keys(config.agents?.entries ?? {}).find(
(key) => normalizeAgentId(key) === target.agentId,
);
const entry = authoredEntryKey ? config.agents?.entries?.[authoredEntryKey] : undefined;
if (entry?.model === undefined) {
return applyPrimaryModel(config, model);
}
@@ -77,7 +80,7 @@ export function applyOnboardingPrimaryModel(
...config.agents,
entries: {
...config.agents?.entries,
[target.agentId]: {
[authoredEntryKey ?? target.agentId]: {
...entry,
model: {
...(fallbackValues.length > 0 ? { fallbacks: fallbackValues } : {}),