diff --git a/src/commands/configure.wizard.default-agent.test.ts b/src/commands/configure.wizard.default-agent.test.ts index 298f24882986..0f6c6d15c9ab 100644 --- a/src/commands/configure.wizard.default-agent.test.ts +++ b/src/commands/configure.wizard.default-agent.test.ts @@ -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")); diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index 28587e67d6c4..91b17d969303 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -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 }, }, }, } diff --git a/src/commands/onboard-agent-target.test.ts b/src/commands/onboard-agent-target.test.ts index 187c3955ef3e..62df215ca991 100644 --- a/src/commands/onboard-agent-target.test.ts +++ b/src/commands/onboard-agent-target.test.ts @@ -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"); diff --git a/src/commands/onboard-agent-target.ts b/src/commands/onboard-agent-target.ts index 2e9c27cd1285..627cc9ab2441 100644 --- a/src/commands/onboard-agent-target.ts +++ b/src/commands/onboard-agent-target.ts @@ -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 } : {}),