From bf922f59fe3e2cb3b7ca2e81e9046ace60a72fc7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 22 Jul 2026 19:05:21 -0400 Subject: [PATCH] fix(onboard): keep setup effects on the default agent (#112738) * fix(onboard): align default agent setup ownership * chore(onboard): remove stale target assignments * fix(onboard): preserve workspace provisioning boundary --- src/commands/agents.add.test.ts | 11 +- src/commands/agents.commands.add.ts | 15 +- .../configure.wizard.default-agent.test.ts | 164 +++++++++++++ src/commands/configure.wizard.ts | 72 ++++-- .../model-picker.default-agent.test.ts | 47 ++++ src/commands/onboard-agent-target.test.ts | 49 ++++ src/commands/onboard-agent-target.ts | 86 +++++++ .../local.default-agent.test.ts | 116 +++++++++ src/commands/onboard-non-interactive/local.ts | 16 +- .../auth-choice.plugin-providers.test.ts | 23 +- .../local/auth-choice.plugin-providers.ts | 13 +- .../local/auth-choice.test.ts | 21 ++ .../local/auth-choice.ts | 17 +- src/flows/model-picker.ts | 33 ++- src/system-agent/setup-apply.test.ts | 17 +- src/system-agent/setup-apply.ts | 16 +- src/wizard/setup.default-agent.test.ts | 228 ++++++++++++++++++ src/wizard/setup.model-auth.test.ts | 108 ++++++++- src/wizard/setup.model-auth.ts | 57 +++-- src/wizard/setup.ts | 22 +- 20 files changed, 1026 insertions(+), 105 deletions(-) create mode 100644 src/commands/configure.wizard.default-agent.test.ts create mode 100644 src/commands/model-picker.default-agent.test.ts create mode 100644 src/commands/onboard-agent-target.test.ts create mode 100644 src/commands/onboard-agent-target.ts create mode 100644 src/commands/onboard-non-interactive/local.default-agent.test.ts create mode 100644 src/wizard/setup.default-agent.test.ts diff --git a/src/commands/agents.add.test.ts b/src/commands/agents.add.test.ts index 766ff48dba1e..1a15e24aad95 100644 --- a/src/commands/agents.add.test.ts +++ b/src/commands/agents.add.test.ts @@ -272,11 +272,11 @@ describe("agents add command", () => { expect(writeConfigFileMock).not.toHaveBeenCalled(); }); - it("skips catalog validation when checking the interactive wizard model config", async () => { + it("uses the explicit agent target and skips catalog validation", async () => { readConfigFileSnapshotMock.mockResolvedValue({ ...baseConfigSnapshot, - config: { agents: { list: [] } }, - sourceConfig: { agents: { list: [] } }, + config: { agents: { entries: {} } }, + sourceConfig: { agents: { entries: {} } }, }); wizardMocks.createClackPrompter.mockReturnValue({ intro: vi.fn(), @@ -297,6 +297,11 @@ describe("agents add command", () => { validateCatalog: false, }), ); + expect(onboardHelpersMocks.ensureWorkspaceAndSessions).toHaveBeenCalledWith( + "/tmp/openclaw-jon", + runtime, + expect.objectContaining({ agentId: "jon" }), + ); }); it("copies only portable auth profiles when seeding a new agent store", async () => { diff --git a/src/commands/agents.commands.add.ts b/src/commands/agents.commands.add.ts index f86566da5a24..dcdc7318acd7 100644 --- a/src/commands/agents.commands.add.ts +++ b/src/commands/agents.commands.add.ts @@ -36,8 +36,11 @@ import { requireValidConfigFileSnapshot } from "./agents.command-shared.js"; import { applyAgentConfig, listAgentEntries } from "./agents.config.js"; import { promptAuthChoiceGrouped } from "./auth-choice-prompt.js"; import { applyAuthChoice, warnIfModelConfigLooksOff } from "./auth-choice.js"; +import { + ensureOnboardingAgentWorkspace, + resolveOnboardingAgentTarget, +} from "./onboard-agent-target.js"; import { setupChannels } from "./onboard-channels.js"; -import { ensureWorkspaceAndSessions } from "./onboard-helpers.js"; import type { ChannelChoice } from "./onboard-types.js"; type AgentsAddOptions = { @@ -410,17 +413,17 @@ export async function agentsAddCommand( }); nextConfig = committed.config; logConfigUpdated(runtime); - await ensureWorkspaceAndSessions(workspaceDir, runtime, { + const target = resolveOnboardingAgentTarget(nextConfig, agentId); + await ensureOnboardingAgentWorkspace(target, runtime, { skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap), skipOptionalBootstrapFiles: nextConfig.agents?.defaults?.skipOptionalBootstrapFiles, - agentId, }); const payload = { - agentId, + agentId: target.agentId, name: agentName, - workspace: workspaceDir, - agentDir, + workspace: target.workspaceDir, + agentDir: target.agentDir, }; if (opts.json) { writeRuntimeJson(runtime, payload); diff --git a/src/commands/configure.wizard.default-agent.test.ts b/src/commands/configure.wizard.default-agent.test.ts new file mode 100644 index 000000000000..b294dcaa9916 --- /dev/null +++ b/src/commands/configure.wizard.default-agent.test.ts @@ -0,0 +1,164 @@ +// Configure wizard tests keep workspace-owned effects on the configured default agent. +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { RuntimeEnv } from "../runtime.js"; + +const mocks = vi.hoisted(() => ({ + state: { snapshot: undefined as unknown }, + commitConfig: vi.fn(), + ensureWorkspaceAndSessions: vi.fn(), + setupPluginConfig: vi.fn(), + setupSkills: vi.fn(), + text: vi.fn(), +})); + +vi.mock("../config/config.js", () => ({ + createConfigIO: () => ({ + readConfigFileSnapshotForWrite: async () => ({ snapshot: mocks.state.snapshot }), + }), + readConfigFileSnapshotForWrite: async () => ({ + snapshot: mocks.state.snapshot, + writeOptions: { + expectedConfigPath: "/tmp/openclaw.json", + ownedConfigPathForWrite: "/tmp/openclaw.json", + }, + }), + resolveGatewayPort: () => 18789, +})); + +vi.mock("../config/logging.js", () => ({ logConfigUpdated: vi.fn() })); + +vi.mock("../plugins/install-record-commit.js", () => ({ + commitConfigWithPendingPluginInstalls: mocks.commitConfig, +})); + +vi.mock("../wizard/clack-prompter.js", () => ({ + createClackPrompter: () => ({ + intro: vi.fn(), + outro: vi.fn(), + note: vi.fn(), + select: vi.fn(), + multiselect: vi.fn(), + text: vi.fn(), + confirm: vi.fn(), + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + }), +})); + +vi.mock("../wizard/setup.plugin-config.js", () => ({ + configurePluginConfig: mocks.setupPluginConfig, +})); + +vi.mock("./configure.shared.js", () => ({ + CONFIGURE_SECTION_OPTIONS: [], + confirm: vi.fn(), + intro: vi.fn(), + outro: vi.fn(), + select: vi.fn(), + text: mocks.text, +})); + +vi.mock("./onboard-helpers.js", () => ({ + DEFAULT_WORKSPACE: "/tmp/default-workspace", + applyWizardMetadata: (config: OpenClawConfig) => config, + ensureWorkspaceAndSessions: mocks.ensureWorkspaceAndSessions, + guardCancel: (value: unknown) => value, + probeGatewayReachable: vi.fn(), + resolveAdvertisedControlUiLinks: vi.fn(), + resolveLocalControlUiProbeLinks: vi.fn(), + summarizeExistingConfig: vi.fn(() => ""), + waitForGatewayReachable: vi.fn(), +})); + +vi.mock("./onboard-skills.js", () => ({ setupSkills: mocks.setupSkills })); + +import { runConfigureWizard } from "./configure.wizard.js"; + +const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), +} as unknown as RuntimeEnv; + +describe("runConfigureWizard default-agent ownership", () => { + beforeEach(() => { + vi.clearAllMocks(); + const baseConfig = { + agents: { + defaults: { workspace: "/tmp/global-workspace" }, + entries: { + 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"); + mocks.setupPluginConfig.mockImplementation( + async ({ config }: { config: OpenClawConfig }) => config, + ); + mocks.setupSkills.mockImplementation(async (config: OpenClawConfig) => config); + mocks.commitConfig.mockImplementation( + async ({ nextConfig }: { nextConfig: OpenClawConfig }) => ({ config: nextConfig }), + ); + }); + + it("uses the concrete default-agent workspace for provisioning, plugins, and skills", async () => { + 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: expect.objectContaining({ + ops: expect.objectContaining({ workspace: "/tmp/new-ops-workspace" }), + }), + }), + }), + }), + ); + 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" }), + ); + }); + + it("does not persist an unprovisionable workspace", async () => { + mocks.ensureWorkspaceAndSessions.mockRejectedValueOnce(new Error("workspace is unwritable")); + + await expect( + runConfigureWizard( + { command: "configure", sections: ["workspace", "plugins", "skills"] }, + runtime, + ), + ).rejects.toThrow("workspace is unwritable"); + + expect(mocks.setupPluginConfig).not.toHaveBeenCalled(); + expect(mocks.setupSkills).not.toHaveBeenCalled(); + expect(mocks.commitConfig).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index 40f67431d234..169966e3b8e1 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -46,11 +46,14 @@ import { } from "./configure.shared.js"; import { formatHealthCheckFailure } from "./health-format.js"; import { healthCommand } from "./health.js"; +import { + ensureOnboardingAgentWorkspace, + resolveOnboardingAgentTarget, +} from "./onboard-agent-target.js"; import { setupChannels } from "./onboard-channels.js"; import { applyWizardMetadata, DEFAULT_WORKSPACE, - ensureWorkspaceAndSessions, guardCancel, probeGatewayReachable, resolveAdvertisedControlUiLinks, @@ -540,10 +543,8 @@ export async function runConfigureWizard( }; didSetGatewayMode = true; } - let workspaceDir = - nextConfig.agents?.defaults?.workspace ?? - baseConfig.agents?.defaults?.workspace ?? - DEFAULT_WORKSPACE; + const resolveSetupTarget = () => resolveOnboardingAgentTarget(nextConfig); + let workspaceDir = resolveSetupTarget().workspaceDir; let gatewayPort = resolveGatewayPort(baseConfig); const persistConfig = async () => { @@ -630,17 +631,34 @@ export async function runConfigureWizard( ); } } - nextConfig = { - ...nextConfig, - agents: { - ...nextConfig.agents, - defaults: { - ...nextConfig.agents?.defaults, - workspace: workspaceDir, - }, - }, - }; - await ensureWorkspaceAndSessions(workspaceDir, runtime, { + const target = resolveSetupTarget(); + const targetEntry = nextConfig.agents?.entries?.[target.agentId]; + nextConfig = + targetEntry?.workspace !== undefined + ? { + ...nextConfig, + agents: { + ...nextConfig.agents, + entries: { + ...nextConfig.agents?.entries, + [target.agentId]: { ...targetEntry, workspace: workspaceDir }, + }, + }, + } + : { + ...nextConfig, + agents: { + ...nextConfig.agents, + defaults: { + ...nextConfig.agents?.defaults, + workspace: workspaceDir, + }, + }, + }; + }; + + const provisionWorkspace = async () => { + await ensureOnboardingAgentWorkspace(resolveSetupTarget(), runtime, { skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap), skipOptionalBootstrapFiles: nextConfig.agents?.defaults?.skipOptionalBootstrapFiles, }); @@ -684,6 +702,7 @@ export async function runConfigureWizard( if (selected.includes("workspace")) { await configureWorkspace(); + await provisionWorkspace(); } if (selected.includes("model")) { @@ -709,13 +728,17 @@ export async function runConfigureWizard( nextConfig = await configurePluginConfig({ config: nextConfig, prompter, - workspaceDir: resolveUserPath(workspaceDir), + workspaceDir: resolveSetupTarget().workspaceDir, }); } if (selected.includes("skills")) { - const wsDir = resolveUserPath(workspaceDir); - nextConfig = await setupSkills(nextConfig, wsDir, runtime, prompter); + nextConfig = await setupSkills( + nextConfig, + resolveSetupTarget().workspaceDir, + runtime, + prompter, + ); } await persistConfig(); @@ -744,6 +767,7 @@ export async function runConfigureWizard( if (choice === "workspace") { await configureWorkspace(); + await provisionWorkspace(); await persistConfig(); } @@ -775,14 +799,18 @@ export async function runConfigureWizard( nextConfig = await configurePluginConfig({ config: nextConfig, prompter, - workspaceDir: resolveUserPath(workspaceDir), + workspaceDir: resolveSetupTarget().workspaceDir, }); await persistConfig(); } if (choice === "skills") { - const wsDir = resolveUserPath(workspaceDir); - nextConfig = await setupSkills(nextConfig, wsDir, runtime, prompter); + nextConfig = await setupSkills( + nextConfig, + resolveSetupTarget().workspaceDir, + runtime, + prompter, + ); await persistConfig(); } diff --git a/src/commands/model-picker.default-agent.test.ts b/src/commands/model-picker.default-agent.test.ts new file mode 100644 index 000000000000..4fc0c79d17b7 --- /dev/null +++ b/src/commands/model-picker.default-agent.test.ts @@ -0,0 +1,47 @@ +// Model picker tests read the configured target agent without rewriting global defaults. +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { WizardPrompter } from "../wizard/prompts.js"; + +vi.mock("./model-picker.runtime.js", () => ({ + modelPickerRuntime: { resolvePluginProviders: () => [] }, +})); + +import { promptDefaultModel } from "./model-picker.js"; + +describe("promptDefaultModel default-agent ownership", () => { + it("offers the resolved agent override as the current model", async () => { + const config = { + agents: { + defaults: { model: "openai/global-model" }, + entries: { + ops: { + default: true, + model: "anthropic/ops-model", + }, + }, + }, + } satisfies OpenClawConfig; + const select = vi.fn(async (params: { options: Array<{ value: string; label: string }> }) => { + const keep = params.options[0]; + expect(keep?.label).toContain("anthropic/ops-model"); + return keep?.value; + }); + const prompter = { + select, + progress: vi.fn(() => ({ stop: vi.fn(), update: vi.fn() })), + } as unknown as WizardPrompter; + + await expect( + promptDefaultModel({ + config, + prompter, + agentId: "ops", + agentDir: "/tmp/ops-agent", + workspaceDir: "/tmp/ops-workspace", + loadCatalog: false, + }), + ).resolves.toEqual({}); + expect(select).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/commands/onboard-agent-target.test.ts b/src/commands/onboard-agent-target.test.ts new file mode 100644 index 000000000000..16b3b0aa674e --- /dev/null +++ b/src/commands/onboard-agent-target.test.ts @@ -0,0 +1,49 @@ +// Onboarding target tests keep workspace, auth directory, and sessions on one agent owner. +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { withEnvAsync } from "../test-utils/env.js"; +import { + ensureOnboardingAgentWorkspace, + resolveOnboardingAgentTarget, +} from "./onboard-agent-target.js"; + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +describe("onboarding agent target", () => { + it("provisions the configured default agent workspace and sessions", async () => { + const stateDir = tempDirs.make("openclaw-onboard-target-"); + const globalWorkspace = path.join(stateDir, "global-workspace"); + const opsWorkspace = path.join(stateDir, "ops-workspace"); + const runtime = { log: vi.fn() } as unknown as RuntimeEnv; + + await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => { + const config = { + agents: { + defaults: { workspace: globalWorkspace }, + entries: { ops: { default: true, workspace: opsWorkspace } }, + }, + }; + const target = resolveOnboardingAgentTarget(config); + + expect(target).toEqual({ + agentId: "ops", + agentDir: path.join(stateDir, "agents", "ops", "agent"), + workspaceDir: opsWorkspace, + }); + expect(resolveOnboardingAgentTarget(config, " OPS ")).toEqual(target); + await ensureOnboardingAgentWorkspace(target, runtime, { skipBootstrap: true }); + + expect((await fs.stat(opsWorkspace)).isDirectory()).toBe(true); + expect((await fs.stat(path.join(stateDir, "agents", "ops", "sessions"))).isDirectory()).toBe( + true, + ); + await expect(fs.access(globalWorkspace)).rejects.toMatchObject({ code: "ENOENT" }); + await expect( + fs.access(path.join(stateDir, "agents", "main", "sessions")), + ).rejects.toMatchObject({ code: "ENOENT" }); + }); + }); +}); diff --git a/src/commands/onboard-agent-target.ts b/src/commands/onboard-agent-target.ts new file mode 100644 index 000000000000..2e86cb658893 --- /dev/null +++ b/src/commands/onboard-agent-target.ts @@ -0,0 +1,86 @@ +// Resolves one concrete agent owner for onboarding auth, model, workspace, and session effects. +import { + resolveAgentDir, + resolveAgentWorkspaceDir, + resolveDefaultAgentId, +} from "../agents/agent-scope-config.js"; +import { + normalizeAgentModelMapForConfig, + normalizeAgentModelRefForConfig, + resolveAgentModelFallbackValues, +} from "../config/model-input.js"; +import type { OptionalBootstrapFileName } from "../config/types.agent-defaults.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { applyPrimaryModel } from "../plugins/provider-model-primary.js"; +import { normalizeAgentId } from "../routing/session-key.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { ensureWorkspaceAndSessions } from "./onboard-helpers.js"; + +export type OnboardingAgentTarget = { + agentId: string; + agentDir: string; + workspaceDir: string; +}; + +export function resolveOnboardingAgentTarget( + config: OpenClawConfig, + explicitAgentId?: string, +): OnboardingAgentTarget { + const agentId = normalizeAgentId(explicitAgentId ?? resolveDefaultAgentId(config)); + return { + agentId, + agentDir: resolveAgentDir(config, agentId), + workspaceDir: resolveAgentWorkspaceDir(config, agentId), + }; +} + +export async function ensureOnboardingAgentWorkspace( + target: OnboardingAgentTarget, + runtime: RuntimeEnv, + options?: { + skipBootstrap?: boolean; + skipOptionalBootstrapFiles?: OptionalBootstrapFileName[]; + }, +): Promise<{ bootstrapPending: boolean }> { + return ensureWorkspaceAndSessions(target.workspaceDir, runtime, { + ...options, + agentId: target.agentId, + }); +} + +export function applyOnboardingPrimaryModel( + config: OpenClawConfig, + target: OnboardingAgentTarget, + model: string, +): OpenClawConfig { + const entry = config.agents?.entries?.[target.agentId]; + if (entry?.model === undefined) { + return applyPrimaryModel(config, model); + } + + const primary = normalizeAgentModelRefForConfig(model); + const fallbackValues = resolveAgentModelFallbackValues(entry.model).map((fallback) => + normalizeAgentModelRefForConfig(fallback), + ); + const models = normalizeAgentModelMapForConfig(entry.models ?? {}); + return { + ...config, + agents: { + ...config.agents, + entries: { + ...config.agents?.entries, + [target.agentId]: { + ...entry, + model: { + ...(fallbackValues.length > 0 ? { fallbacks: fallbackValues } : {}), + primary, + }, + models: { + ...models, + [primary]: models[primary] ?? {}, + }, + }, + }, + }, + }; +} diff --git a/src/commands/onboard-non-interactive/local.default-agent.test.ts b/src/commands/onboard-non-interactive/local.default-agent.test.ts new file mode 100644 index 000000000000..1b2929b7ab48 --- /dev/null +++ b/src/commands/onboard-non-interactive/local.default-agent.test.ts @@ -0,0 +1,116 @@ +// Non-interactive setup tests keep provisioning and output on the configured default agent. +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { RuntimeEnv } from "../../runtime.js"; + +const mocks = vi.hoisted(() => ({ + commitConfig: vi.fn(), + ensureWorkspaceAndSessions: vi.fn(), + logConfigUpdated: vi.fn(), + logJson: vi.fn(), +})); + +vi.mock("../../config/config.js", () => ({ + resolveGatewayPort: () => 18789, +})); + +vi.mock("../../config/logging.js", () => ({ + logConfigUpdated: mocks.logConfigUpdated, +})); + +vi.mock("../onboard-helpers.js", () => ({ + DEFAULT_WORKSPACE: "/tmp/default-workspace", + applyWizardMetadata: (config: OpenClawConfig) => config, + ensureWorkspaceAndSessions: mocks.ensureWorkspaceAndSessions, + resolveLocalControlUiProbeLinks: vi.fn(), + waitForGatewayReachable: vi.fn(), +})); + +vi.mock("./config-write.js", () => ({ + commitNonInteractiveOnboardConfig: mocks.commitConfig, +})); + +vi.mock("./local/gateway-config.js", () => ({ + applyNonInteractiveGatewayConfig: ({ nextConfig }: { nextConfig: OpenClawConfig }) => ({ + nextConfig, + port: 18789, + bind: "loopback", + authMode: "token", + tailscaleMode: "off", + }), +})); + +vi.mock("./local/output.js", () => ({ + logNonInteractiveOnboardingFailure: vi.fn(), + logNonInteractiveOnboardingJson: mocks.logJson, +})); + +vi.mock("./local/skills-config.js", () => ({ + applyNonInteractiveSkillsConfig: ({ nextConfig }: { nextConfig: OpenClawConfig }) => nextConfig, +})); + +import { runNonInteractiveLocalSetup } from "./local.js"; + +const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), +} as unknown as RuntimeEnv; + +describe("runNonInteractiveLocalSetup default-agent ownership", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.commitConfig.mockImplementation( + async ({ nextConfig }: { nextConfig: OpenClawConfig }) => nextConfig, + ); + }); + + it("provisions and reports the keyed default agent while preserving the global workspace", async () => { + const baseConfig = { + agents: { + defaults: { workspace: "/tmp/global-workspace" }, + entries: { + ops: { + default: true, + agentDir: "/tmp/ops-agent", + workspace: "/tmp/ops-workspace", + }, + }, + }, + } satisfies OpenClawConfig; + + await runNonInteractiveLocalSetup({ + opts: { + nonInteractive: true, + mode: "local", + workspace: "/tmp/global-workspace", + authChoice: "skip", + skipHooks: true, + skipSkills: true, + skipHealth: true, + installDaemon: false, + json: true, + }, + runtime, + baseConfig, + }); + + expect(mocks.commitConfig).toHaveBeenCalledWith( + expect.objectContaining({ + nextConfig: expect.objectContaining({ + agents: expect.objectContaining({ + defaults: expect.objectContaining({ workspace: "/tmp/global-workspace" }), + }), + }), + }), + ); + expect(mocks.ensureWorkspaceAndSessions).toHaveBeenCalledWith( + "/tmp/ops-workspace", + runtime, + expect.objectContaining({ agentId: "ops" }), + ); + expect(mocks.logJson).toHaveBeenCalledWith( + expect.objectContaining({ workspaceDir: "/tmp/ops-workspace" }), + ); + }); +}); diff --git a/src/commands/onboard-non-interactive/local.ts b/src/commands/onboard-non-interactive/local.ts index 689f88c08909..6aef9cb05b81 100644 --- a/src/commands/onboard-non-interactive/local.ts +++ b/src/commands/onboard-non-interactive/local.ts @@ -12,6 +12,10 @@ import { resolveGatewayAuthToken } from "../../gateway/auth-token-resolution.js" import { resolveConfiguredSecretInputWithFallback } from "../../gateway/resolve-configured-secret-input-string.js"; import type { RuntimeEnv } from "../../runtime.js"; import { DEFAULT_GATEWAY_DAEMON_RUNTIME } from "../daemon-runtime.js"; +import { + ensureOnboardingAgentWorkspace, + resolveOnboardingAgentTarget, +} from "../onboard-agent-target.js"; import { applyLocalSetupWorkspaceConfig, applySkipBootstrapConfig, @@ -20,7 +24,6 @@ import { import { applyWizardMetadata, DEFAULT_WORKSPACE, - ensureWorkspaceAndSessions, resolveLocalControlUiProbeLinks, waitForGatewayReachable, } from "../onboard-helpers.js"; @@ -177,7 +180,6 @@ export async function runNonInteractiveLocalSetup(params: { defaultWorkspaceDir: DEFAULT_WORKSPACE, }); const workspaceConflict = resolveOnboardingWorkspaceConflict(baseConfig, requestedWorkspaceDir); - const workspaceDir = workspaceConflict?.currentWorkspaceDir ?? requestedWorkspaceDir; if (workspaceConflict) { runtime.error( [ @@ -196,12 +198,13 @@ export async function runNonInteractiveLocalSetup(params: { if (opts.skipBootstrap) { nextConfig = applySkipBootstrapConfig(nextConfig); } + const authTarget = resolveOnboardingAgentTarget(nextConfig); const inferredAuthChoice = opts.authChoice ? undefined : (await import("./local/auth-choice-inference.js")).inferAuthChoiceFromFlags(opts, { config: nextConfig, - workspaceDir, + workspaceDir: authTarget.workspaceDir, env: process.env, }); if (!opts.authChoice && inferredAuthChoice && inferredAuthChoice.matches.length > 1) { @@ -228,7 +231,7 @@ export async function runNonInteractiveLocalSetup(params: { opts, runtime, baseConfig, - workspaceDir, + target: authTarget, }); if (!nextConfigAfterAuth) { return; @@ -262,7 +265,8 @@ export async function runNonInteractiveLocalSetup(params: { }); logConfigUpdated(runtime); - await ensureWorkspaceAndSessions(workspaceDir, runtime, { + const finalTarget = resolveOnboardingAgentTarget(nextConfig); + await ensureOnboardingAgentWorkspace(finalTarget, runtime, { skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap), skipOptionalBootstrapFiles: nextConfig.agents?.defaults?.skipOptionalBootstrapFiles, }); @@ -403,7 +407,7 @@ export async function runNonInteractiveLocalSetup(params: { opts, runtime, mode, - workspaceDir, + workspaceDir: finalTarget.workspaceDir, authChoice, gateway: { port: gatewayResult.port, diff --git a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts index bdc030cb2b34..54660cbf88e6 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts @@ -100,6 +100,12 @@ function createRuntime() { }; } +const target = { + agentId: "main", + agentDir: "/tmp/main-agent", + workspaceDir: "/tmp/workspace", +}; + type MockCalls = { mock: { calls: Array> } }; function mockCall(mock: MockCalls, callIndex = 0): Array { @@ -150,6 +156,7 @@ describe("applyNonInteractivePluginProviderChoice", () => { opts: {} as never, runtime: runtime as never, baseConfig: { agents: { defaults: {} } } as OpenClawConfig, + target, resolveApiKey: vi.fn(), toApiKeyCredential: vi.fn(), }); @@ -162,7 +169,12 @@ describe("applyNonInteractivePluginProviderChoice", () => { expect(providersInput.onlyPluginIds).toEqual(["vllm"]); expect(providersInput.includeUntrustedWorkspacePlugins).toBe(false); expect(resolveProviderPluginChoice).toHaveBeenCalledOnce(); - expect(runNonInteractive).toHaveBeenCalledOnce(); + expect(runNonInteractive).toHaveBeenCalledWith( + expect.objectContaining({ + agentDir: target.agentDir, + workspaceDir: target.workspaceDir, + }), + ); expect(result).toEqual({ plugins: { allow: ["vllm"] } }); }); @@ -210,6 +222,7 @@ describe("applyNonInteractivePluginProviderChoice", () => { opts: { groqApiKey: "groq-key" } as never, runtime: runtime as never, baseConfig: { agents: { defaults: {} } } as OpenClawConfig, + target, resolveApiKey: vi.fn(), toApiKeyCredential: vi.fn(), }); @@ -263,6 +276,7 @@ describe("applyNonInteractivePluginProviderChoice", () => { opts: {} as never, runtime: runtime as never, baseConfig: { agents: { defaults: {} } } as OpenClawConfig, + target, resolveApiKey: vi.fn(), toApiKeyCredential: vi.fn(), }); @@ -286,6 +300,7 @@ describe("applyNonInteractivePluginProviderChoice", () => { opts: {} as never, runtime: runtime as never, baseConfig: { agents: { defaults: {} } } as OpenClawConfig, + target, resolveApiKey: vi.fn(), toApiKeyCredential: vi.fn(), }); @@ -313,6 +328,7 @@ describe("applyNonInteractivePluginProviderChoice", () => { opts: {} as never, runtime: runtime as never, baseConfig: { agents: { defaults: {} } } as OpenClawConfig, + target, resolveApiKey: vi.fn(), toApiKeyCredential: vi.fn(), }); @@ -354,6 +370,7 @@ describe("applyNonInteractivePluginProviderChoice", () => { opts: {} as never, runtime: runtime as never, baseConfig: { agents: { defaults: {} } } as OpenClawConfig, + target, resolveApiKey: vi.fn(), toApiKeyCredential: vi.fn(), }); @@ -376,6 +393,7 @@ describe("applyNonInteractivePluginProviderChoice", () => { opts: {} as never, runtime: runtime as never, baseConfig: { agents: { defaults: {} } } as OpenClawConfig, + target, resolveApiKey: vi.fn(), toApiKeyCredential: vi.fn(), }); @@ -413,6 +431,7 @@ describe("applyNonInteractivePluginProviderChoice", () => { opts: {} as never, runtime: runtime as never, baseConfig: { agents: { defaults: {} } } as OpenClawConfig, + target, resolveApiKey: vi.fn(), toApiKeyCredential: vi.fn(), }); @@ -465,6 +484,7 @@ describe("applyNonInteractivePluginProviderChoice", () => { opts: {} as never, runtime: runtime as never, baseConfig: { agents: { defaults: {} } } as OpenClawConfig, + target, resolveApiKey: vi.fn(), toApiKeyCredential: vi.fn(), }); @@ -500,6 +520,7 @@ describe("applyNonInteractivePluginProviderChoice", () => { opts: {} as never, runtime: runtime as never, baseConfig: { agents: { defaults: {} } } as OpenClawConfig, + target, resolveApiKey: vi.fn(), toApiKeyCredential: vi.fn(), }); diff --git a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts index 7a15e61fa65e..5a6ad3b888f0 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts @@ -4,13 +4,7 @@ * This path resolves trusted plugin providers, delegates setup to their * non-interactive method, and installs runtime plugins required by the model. */ -import { - resolveAgentDir, - resolveDefaultAgentId, - resolveAgentWorkspaceDir, -} from "../../../agents/agent-scope.js"; import type { ApiKeyCredential } from "../../../agents/auth-profiles/types.js"; -import { resolveDefaultAgentWorkspaceDir } from "../../../agents/workspace.js"; import { resolveAgentModelPrimaryValue } from "../../../config/model-input.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { enablePluginInConfig } from "../../../plugins/enable.js"; @@ -33,6 +27,7 @@ import { } from "../../codex-runtime-plugin-install.js"; import { ensureCopilotRuntimePluginForModelSelection } from "../../copilot-runtime-plugin-install.js"; import { createNonInteractiveLoggingPrompter } from "../../non-interactive-prompter.js"; +import type { OnboardingAgentTarget } from "../../onboard-agent-target.js"; import type { OnboardOptions } from "../../onboard-types.js"; const PROVIDER_PLUGIN_CHOICE_PREFIX = "provider-plugin:"; @@ -53,6 +48,7 @@ export async function applyNonInteractivePluginProviderChoice(params: { opts: OnboardOptions; runtime: RuntimeEnv; baseConfig: OpenClawConfig; + target: OnboardingAgentTarget; resolveApiKey: (input: ProviderResolveNonInteractiveApiKeyParams) => Promise<{ key: string; source: "profile" | "env" | "flag"; @@ -62,10 +58,7 @@ export async function applyNonInteractivePluginProviderChoice(params: { input: ProviderNonInteractiveApiKeyCredentialParams, ) => ApiKeyCredential | null; }): Promise { - const agentId = resolveDefaultAgentId(params.nextConfig); - const agentDir = resolveAgentDir(params.nextConfig, agentId); - const workspaceDir = - resolveAgentWorkspaceDir(params.nextConfig, agentId) ?? resolveDefaultAgentWorkspaceDir(); + const { agentDir, workspaceDir } = params.target; let nextConfig = params.nextConfig; const prefixedProviderId = params.authChoice.startsWith(PROVIDER_PLUGIN_CHOICE_PREFIX) ? params.authChoice.slice(PROVIDER_PLUGIN_CHOICE_PREFIX.length).split(":", 1)[0]?.trim() diff --git a/src/commands/onboard-non-interactive/local/auth-choice.test.ts b/src/commands/onboard-non-interactive/local/auth-choice.test.ts index 94642ded59f5..f39a06e3f538 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.test.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.test.ts @@ -53,6 +53,12 @@ function createRuntime() { }; } +const target = { + agentId: "main", + agentDir: "/tmp/main-agent", + workspaceDir: "/tmp/workspace", +}; + describe("applyNonInteractiveAuthChoice", () => { it("rejects an unknown auth choice and lists the valid choices", async () => { const runtime = createRuntime(); @@ -64,6 +70,7 @@ describe("applyNonInteractiveAuthChoice", () => { opts: {} as never, runtime: runtime as never, baseConfig: nextConfig, + target, }); expect(result).toBeNull(); @@ -85,9 +92,13 @@ describe("applyNonInteractiveAuthChoice", () => { opts: {} as never, runtime: runtime as never, baseConfig: nextConfig, + target, }); expect(result).toBe(resolvedConfig); + expect(applyNonInteractivePluginProviderChoice).toHaveBeenCalledWith( + expect.objectContaining({ target }), + ); expect(runtime.error).not.toHaveBeenCalled(); expect(runtime.exit).not.toHaveBeenCalled(); }); @@ -104,6 +115,7 @@ describe("applyNonInteractiveAuthChoice", () => { opts: {} as never, runtime: runtime as never, baseConfig: nextConfig, + target, }); expect(result).toBe(resolvedConfig); @@ -123,6 +135,7 @@ describe("applyNonInteractiveAuthChoice", () => { opts: {} as never, runtime: runtime as never, baseConfig: nextConfig, + target, }); expect(result).toBeNull(); @@ -146,6 +159,7 @@ describe("applyNonInteractiveAuthChoice", () => { opts: {} as never, runtime: runtime as never, baseConfig: nextConfig, + target, }); expect(result).toBeNull(); @@ -169,6 +183,7 @@ describe("applyNonInteractiveAuthChoice", () => { opts: {} as never, runtime: runtime as never, baseConfig: nextConfig, + target, }); expect(result).toBeNull(); @@ -198,6 +213,7 @@ describe("applyNonInteractiveAuthChoice", () => { } as never, runtime: runtime as never, baseConfig: nextConfig, + target, }); expect(result?.models?.providers?.["custom-models-custom-local"]?.apiKey).toEqual({ @@ -214,6 +230,7 @@ describe("applyNonInteractiveAuthChoice", () => { expect(apiKeyParams?.flagName).toBe("--custom-api-key"); expect(apiKeyParams?.envVar).toBe("CUSTOM_API_KEY"); expect(apiKeyParams?.envVarName).toBe("CUSTOM_API_KEY"); + expect(apiKeyParams?.agentDir).toBe(target.agentDir); expect(apiKeyParams?.secretInputMode).toBe("ref"); }); @@ -232,6 +249,7 @@ describe("applyNonInteractiveAuthChoice", () => { } as never, runtime: runtime as never, baseConfig: nextConfig, + target, }); expect(result?.models?.providers?.["custom-models-custom-local"]?.api).toBe("openai-responses"); @@ -252,6 +270,7 @@ describe("applyNonInteractiveAuthChoice", () => { } as never, runtime: runtime as never, baseConfig: nextConfig, + target, }); expect(result?.models?.providers?.["custom-models-custom-local"]?.models?.[0]?.input).toEqual([ @@ -274,6 +293,7 @@ describe("applyNonInteractiveAuthChoice", () => { } as never, runtime: runtime as never, baseConfig: nextConfig, + target, }); expect(result?.models?.providers?.["custom-models-custom-local"]?.models?.[0]?.input).toEqual([ @@ -297,6 +317,7 @@ describe("applyNonInteractiveAuthChoice", () => { } as never, runtime: runtime as never, baseConfig: nextConfig, + target, }); expect(result?.models?.providers?.["custom-models-custom-local"]?.models?.[0]?.input).toEqual([ diff --git a/src/commands/onboard-non-interactive/local/auth-choice.ts b/src/commands/onboard-non-interactive/local/auth-choice.ts index fc460e637b89..7c32e3137d5e 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.ts @@ -21,6 +21,7 @@ import { import { formatAuthChoiceChoicesForCli } from "../../auth-choice-options.js"; import { normalizeSecretInputModeInput } from "../../auth-choice.apply-helpers.js"; import { normalizeApiKeyTokenProviderAuthChoice } from "../../auth-choice.apply.api-providers.js"; +import type { OnboardingAgentTarget } from "../../onboard-agent-target.js"; import { applyCustomApiConfig, CustomApiError, @@ -44,7 +45,7 @@ export async function applyNonInteractiveAuthChoice(params: { opts: OnboardOptions; runtime: RuntimeEnv; baseConfig: OpenClawConfig; - workspaceDir?: string; + target: OnboardingAgentTarget; }): Promise { const { opts, runtime, baseConfig } = params; let authChoice = normalizeApiKeyTokenProviderAuthChoice({ @@ -93,6 +94,7 @@ export async function applyNonInteractiveAuthChoice(params: { const resolveApiKey = (input: Parameters[0]) => resolveNonInteractiveApiKey({ ...input, + agentDir: params.target.agentDir, secretInputMode: requestedSecretInputMode, }); const toApiKeyCredential = (paramsLocal: { @@ -141,7 +143,7 @@ export async function applyNonInteractiveAuthChoice(params: { if ( isDeprecatedAuthChoice(authChoice, { config: nextConfig, - workspaceDir: params.workspaceDir, + workspaceDir: params.target.workspaceDir, env: process.env, }) ) { @@ -149,7 +151,7 @@ export async function applyNonInteractiveAuthChoice(params: { // either plugin dispatch or built-in setup handling. const replacement = resolveDeprecatedAuthChoiceReplacement(authChoice, { config: nextConfig, - workspaceDir: params.workspaceDir, + workspaceDir: params.target.workspaceDir, env: process.env, }); if (replacement) { @@ -159,7 +161,7 @@ export async function applyNonInteractiveAuthChoice(params: { runtime.error( formatDeprecatedNonInteractiveAuthChoiceError(authChoice, { config: nextConfig, - workspaceDir: params.workspaceDir, + workspaceDir: params.target.workspaceDir, env: process.env, })!, ); @@ -170,14 +172,14 @@ export async function applyNonInteractiveAuthChoice(params: { const deprecatedChoice = resolveManifestDeprecatedProviderAuthChoice(authChoice as string, { config: nextConfig, - workspaceDir: params.workspaceDir, + workspaceDir: params.target.workspaceDir, env: process.env, }); const deprecatedInstallChoice = deprecatedChoice ? undefined : resolveDeprecatedProviderInstallCatalogEntry(authChoice as string, { config: nextConfig, - workspaceDir: params.workspaceDir, + workspaceDir: params.target.workspaceDir, env: process.env, includeUntrustedWorkspacePlugins: false, }); @@ -196,7 +198,7 @@ export async function applyNonInteractiveAuthChoice(params: { includeLegacyAliases: false, includeSkip: true, config: nextConfig, - workspaceDir: params.workspaceDir, + workspaceDir: params.target.workspaceDir, env: process.env, }).split("|"), ...GENERIC_NON_INTERACTIVE_AUTH_CHOICES, @@ -216,6 +218,7 @@ export async function applyNonInteractiveAuthChoice(params: { opts, runtime, baseConfig, + target: params.target, resolveApiKey: (input) => resolveApiKey({ ...input, diff --git a/src/flows/model-picker.ts b/src/flows/model-picker.ts index 216b36de7086..3648120e0832 100644 --- a/src/flows/model-picker.ts +++ b/src/flows/model-picker.ts @@ -1,7 +1,7 @@ // Model picker flow lets users select provider models for config defaults. import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { sortUniqueStrings } from "@openclaw/normalization-core/string-normalization"; -import { resolveDefaultAgentDir } from "../agents/agent-scope.js"; +import { resolveAgentConfig, resolveDefaultAgentDir } from "../agents/agent-scope.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { resolveAgentHarnessPolicy } from "../agents/harness/policy.js"; import { @@ -107,6 +107,7 @@ type PromptDefaultModelParams = { loadCatalog?: boolean; browseCatalogOnDemand?: boolean; preferredProvider?: string; + agentId?: string; agentDir?: string; workspaceDir?: string; env?: NodeJS.ProcessEnv; @@ -137,6 +138,26 @@ function resolveConfiguredModelKeys(cfg: OpenClawConfig): string[] { .filter((key) => key.length > 0); } +function resolveModelPickerConfig(cfg: OpenClawConfig, agentId?: string): OpenClawConfig { + const agent = agentId ? resolveAgentConfig(cfg, agentId) : undefined; + if (agent?.model === undefined && agent?.models === undefined) { + return cfg; + } + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + ...(agent.model !== undefined ? { model: agent.model } : {}), + ...(agent.models !== undefined + ? { models: { ...cfg.agents?.defaults?.models, ...agent.models } } + : {}), + }, + }, + }; +} + function toPickerCatalogEntry( row: ReturnType[number], ): ModelCatalogEntry { @@ -783,6 +804,7 @@ export async function promptDefaultModel( params: PromptDefaultModelParams, ): Promise { const cfg = params.config; + const pickerConfig = resolveModelPickerConfig(cfg, params.agentId); const pickerAgentDir = resolvePickerAgentDir({ cfg, ...(params.agentDir !== undefined ? { agentDir: params.agentDir } : {}), @@ -798,10 +820,10 @@ export async function promptDefaultModel( const preferredProvider = preferredProviderRaw ? normalizeProviderId(preferredProviderRaw) : undefined; - const configuredRaw = resolveConfiguredModelRaw(cfg); + const configuredRaw = resolveConfiguredModelRaw(pickerConfig); const useStaticModelNormalization = !loadCatalog || browseCatalogOnDemand; const resolved = resolveConfiguredModelRef({ - cfg, + cfg: pickerConfig, defaultProvider: DEFAULT_PROVIDER, defaultModel: DEFAULT_MODEL, allowPluginNormalization: useStaticModelNormalization ? false : undefined, @@ -954,7 +976,7 @@ export async function promptDefaultModel( } const aliasIndex = buildModelAliasIndex({ - cfg, + cfg: pickerConfig, defaultProvider: DEFAULT_PROVIDER, }); const hasAuth = createProviderAuthChecker({ @@ -968,7 +990,7 @@ export async function promptDefaultModel( env: params.env, }); const models = await resolvePickerLogicalCatalog({ - cfg, + cfg: pickerConfig, catalog, routeVariants: catalogSnapshot.routeVariants, defaultProvider: DEFAULT_PROVIDER, @@ -1733,4 +1755,5 @@ function mergeFallbackSelection(params: { } return fallbacks; } + /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/system-agent/setup-apply.test.ts b/src/system-agent/setup-apply.test.ts index f2e58d066356..7e8a22a11698 100644 --- a/src/system-agent/setup-apply.test.ts +++ b/src/system-agent/setup-apply.test.ts @@ -348,11 +348,18 @@ describe("applySystemAgentSetup transaction boundaries", () => { }); }); - it("keeps an existing fleet workspace for unconfirmed setup apply", async () => { + it("keeps the fleet workspace and provisions the configured default agent", async () => { const config = { agents: { defaults: { workspace: "/tmp/current-workspace" }, - entries: { main: {}, ops: {} }, + entries: { + main: {}, + ops: { + default: true, + agentDir: "/agents/ops", + workspace: "/tmp/ops-workspace", + }, + }, }, } satisfies OpenClawConfig; mocks.state.initialSnapshot = snapshot("probe", config); @@ -369,11 +376,11 @@ describe("applySystemAgentSetup transaction boundaries", () => { ); expect(mocks.state.persistedConfig?.agents?.defaults?.workspace).toBe("/tmp/current-workspace"); - expect(mocks.state.persistedConfig?.agents?.entries).toEqual({ main: {}, ops: {} }); + expect(mocks.state.persistedConfig?.agents?.entries).toEqual(config.agents.entries); expect(mocks.ensureWorkspace).toHaveBeenCalledWith( - "/tmp/current-workspace", + "/tmp/ops-workspace", runtime, - expect.any(Object), + expect.objectContaining({ agentId: "ops" }), ); }); diff --git a/src/system-agent/setup-apply.ts b/src/system-agent/setup-apply.ts index b4c3a64cbfd6..d8c8ffab0382 100644 --- a/src/system-agent/setup-apply.ts +++ b/src/system-agent/setup-apply.ts @@ -1,6 +1,7 @@ // Applies OpenClaw's conversational setup: config, workspace files, gateway. import { isDeepStrictEqual } from "node:util"; import { listAgentEntries } from "../agents/agent-scope-config.js"; +import { resolveOnboardingAgentTarget } from "../commands/onboard-agent-target.js"; import { readConfigFileSnapshot, readConfigFileSnapshotWithPluginMetadata, @@ -416,10 +417,6 @@ export async function applySystemAgentSetup( }; } - const effectiveWorkspace = - workspaceConflict && !params.allowWorkspaceChange - ? workspaceConflict.currentWorkspaceDir - : workspace; let candidate = applyLocalSetupWorkspaceConfig(setupBaseConfig, workspace, { allowWorkspaceChange: allowWorkspaceWrite, preserveWorkspace, @@ -448,7 +445,6 @@ export async function applySystemAgentSetup( mode: "local", }), settings: gateway.settings, - workspace: effectiveWorkspace, }; }; const committed = await commit( @@ -493,7 +489,6 @@ export async function applySystemAgentSetup( nextConfig: finalizedConfig, result: { settings: setupCandidate.settings, - workspace: setupCandidate.workspace, }, }; }, @@ -505,7 +500,7 @@ export async function applySystemAgentSetup( if (!settings) { throw new Error("OpenClaw setup committed without resolved Gateway settings."); } - const effectiveWorkspace = setupResult.workspace; + const onboardingTarget = resolveOnboardingAgentTarget(nextConfig); if (params.expectedInferenceRoute) { const afterRead = await readConfigFileSnapshotWithPluginMetadata(); const afterSnapshot = afterRead.snapshot; @@ -533,7 +528,7 @@ export async function applySystemAgentSetup( } const lines: string[] = [ - `Workspace: ${shortenHomePath(effectiveWorkspace)}`, + `Workspace: ${shortenHomePath(onboardingTarget.workspaceDir)}`, model ? `Default model: ${model}` : undefined, ].filter((line): line is string => line !== undefined); @@ -561,9 +556,10 @@ export async function applySystemAgentSetup( const workspaceResult = await runCommittedFollowUp( async () => - await onboardHelpers.ensureWorkspaceAndSessions(effectiveWorkspace, runtime, { + await onboardHelpers.ensureWorkspaceAndSessions(onboardingTarget.workspaceDir, runtime, { skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap), skipOptionalBootstrapFiles: nextConfig.agents?.defaults?.skipOptionalBootstrapFiles, + agentId: onboardingTarget.agentId, }), (error) => lines.push(`Workspace files: ${formatErrorMessage(error)}`), ); @@ -600,7 +596,7 @@ export async function applySystemAgentSetup( await refreshPluginRegistryAfterConfigMutation({ config: nextConfig, reason: "source-changed", - workspaceDir: effectiveWorkspace, + workspaceDir: onboardingTarget.workspaceDir, traceCommand: "openclaw-setup", logger: { warn: (message) => lines.push(message), diff --git a/src/wizard/setup.default-agent.test.ts b/src/wizard/setup.default-agent.test.ts new file mode 100644 index 000000000000..66126b6dc043 --- /dev/null +++ b/src/wizard/setup.default-agent.test.ts @@ -0,0 +1,228 @@ +// Classic setup tests keep every workspace-owned effect on the configured default agent. +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { RuntimeEnv } from "../runtime.js"; +import type { WizardPrompter } from "./prompts.js"; + +const mocks = vi.hoisted(() => ({ + readSnapshot: vi.fn(), + writeConfig: vi.fn(), + ensureWorkspaceAndSessions: vi.fn(), + setupSkills: vi.fn(), + setupOfficialPlugins: vi.fn(), + setupRecommendations: vi.fn(), + setupPluginConfig: vi.fn(), + finalizeSetup: vi.fn(), +})); + +vi.mock("./navigation-prompter.js", () => ({ + runWizardWithPromptNavigation: async ( + prompter: WizardPrompter, + run: (prompter: WizardPrompter) => Promise, + ) => await run(prompter), +})); + +vi.mock("./setup.shared.js", () => ({ + readSetupConfigFileSnapshot: mocks.readSnapshot, + readValidSetupConfigFile: vi.fn(), + requireRiskAcknowledgement: async ({ config }: { config: OpenClawConfig }) => config, + resolveQuickstartGatewayDefaults: () => ({ + hasExisting: false, + port: 18789, + bind: "loopback", + authMode: "token", + tailscaleMode: "off", + }), + writeWizardConfigFile: mocks.writeConfig, +})); + +vi.mock("./setup.migration-import.js", () => ({ + detectSetupMigrationSources: vi.fn(async () => []), + listSetupMigrationOptions: vi.fn(async () => []), + runSetupMigrationImport: vi.fn(), +})); + +vi.mock("./setup.model-auth.js", () => ({ + runSetupModelAuthStep: async ({ config }: { config: OpenClawConfig }) => ({ + config, + authProfiles: [], + persistAuthProfiles: async () => {}, + }), +})); + +vi.mock("./setup.workspace.js", () => ({ + resolveSetupWorkspaceSelection: async () => ({ + workspaceDir: "/tmp/global-workspace", + allowWorkspaceChange: false, + }), +})); + +vi.mock("./setup.secret-input.js", () => ({ + resolveSetupSecretInputString: vi.fn(async () => undefined), +})); + +vi.mock("./setup.gateway-config.js", () => ({ + configureGatewayForSetup: async ({ nextConfig }: { nextConfig: OpenClawConfig }) => ({ + nextConfig, + settings: { + port: 18789, + bind: "loopback", + authMode: "token", + gatewayToken: "test-token", + tailscaleMode: "off", + tailscaleResetOnExit: false, + }, + }), +})); + +vi.mock("./setup.memory-import.js", () => ({ + runSetupMemoryImportStep: vi.fn(), +})); + +vi.mock("./setup.official-plugins.js", () => ({ + setupOfficialPluginInstalls: mocks.setupOfficialPlugins, +})); + +vi.mock("./setup.app-recommendations.js", () => ({ + setupAppRecommendations: mocks.setupRecommendations, +})); + +vi.mock("./setup.plugin-config.js", () => ({ + setupPluginConfig: mocks.setupPluginConfig, +})); + +vi.mock("./setup.finalize.js", () => ({ + finalizeSetupWizard: mocks.finalizeSetup, +})); + +vi.mock("../commands/onboard-helpers.js", () => ({ + DEFAULT_WORKSPACE: "/tmp/default-workspace", + applyWizardMetadata: (config: OpenClawConfig) => config, + ensureWorkspaceAndSessions: mocks.ensureWorkspaceAndSessions, + printWizardHeader: vi.fn(), + probeGatewayReachable: vi.fn(async () => ({ ok: false })), + summarizeExistingConfig: vi.fn(() => ""), +})); + +vi.mock("../commands/onboard-skills.js", () => ({ setupSkills: mocks.setupSkills })); +vi.mock("../config/config.js", () => ({ resolveGatewayPort: () => 18789 })); +vi.mock("../config/logging.js", () => ({ logConfigUpdated: vi.fn() })); +vi.mock("../plugins/status.js", () => ({ + buildPluginCompatibilitySnapshotNotices: vi.fn(() => []), + formatPluginCompatibilityNotice: vi.fn(), +})); + +import { runSetupWizard } from "./setup.js"; + +const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), +} as unknown as RuntimeEnv; + +const prompter = { + intro: vi.fn(), + outro: vi.fn(), + note: vi.fn(), + select: vi.fn(), + multiselect: vi.fn(), + text: vi.fn(), + confirm: vi.fn(), + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), +} as unknown as WizardPrompter; + +describe("runSetupWizard default-agent ownership", () => { + beforeEach(() => { + vi.clearAllMocks(); + const config = { + wizard: { securityAcknowledgedAt: "2026-07-01T00:00:00.000Z" }, + agents: { + defaults: { workspace: "/tmp/global-workspace" }, + entries: { + ops: { + default: true, + agentDir: "/tmp/ops-agent", + workspace: "/tmp/ops-workspace", + }, + }, + }, + } satisfies OpenClawConfig; + mocks.readSnapshot.mockResolvedValue({ + exists: true, + valid: true, + config, + sourceConfig: config, + issues: [], + }); + mocks.writeConfig.mockImplementation(async (nextConfig: OpenClawConfig) => nextConfig); + mocks.setupSkills.mockImplementation(async (nextConfig: OpenClawConfig) => nextConfig); + mocks.setupOfficialPlugins.mockImplementation( + async ({ config: nextConfig }: { config: OpenClawConfig }) => nextConfig, + ); + mocks.setupRecommendations.mockImplementation( + async ({ config: nextConfig }: { config: OpenClawConfig }) => ({ config: nextConfig }), + ); + mocks.setupPluginConfig.mockImplementation( + async ({ config: nextConfig }: { config: OpenClawConfig }) => nextConfig, + ); + mocks.finalizeSetup.mockResolvedValue({ launchedTui: false }); + }); + + it("uses the keyed default-agent workspace for all classic agent-owned effects", async () => { + await runSetupWizard( + { + acceptRisk: true, + flow: "advanced", + mode: "local", + workspace: "/tmp/global-workspace", + authChoice: "skip", + installDaemon: false, + skipChannels: true, + skipSearch: true, + skipHealth: true, + skipHooks: true, + skipUi: true, + }, + runtime, + prompter, + ); + + expect(mocks.writeConfig).toHaveBeenLastCalledWith( + expect.objectContaining({ + agents: expect.objectContaining({ + defaults: expect.objectContaining({ workspace: "/tmp/global-workspace" }), + }), + }), + expect.any(Object), + ); + const target = { + agentId: "ops", + agentDir: "/tmp/ops-agent", + workspaceDir: "/tmp/ops-workspace", + }; + expect(mocks.ensureWorkspaceAndSessions).toHaveBeenCalledWith( + target.workspaceDir, + runtime, + expect.objectContaining({ agentId: target.agentId }), + ); + expect(mocks.setupSkills).toHaveBeenCalledWith( + expect.any(Object), + target.workspaceDir, + runtime, + prompter, + expect.any(Object), + ); + expect(mocks.setupOfficialPlugins).toHaveBeenCalledWith( + expect.objectContaining({ workspaceDir: target.workspaceDir }), + ); + expect(mocks.setupRecommendations).toHaveBeenCalledWith( + expect.objectContaining({ workspaceDir: target.workspaceDir }), + ); + expect(mocks.setupPluginConfig).toHaveBeenCalledWith( + expect.objectContaining({ workspaceDir: target.workspaceDir }), + ); + expect(mocks.finalizeSetup).toHaveBeenCalledWith( + expect.objectContaining({ workspaceDir: target.workspaceDir }), + ); + }); +}); diff --git a/src/wizard/setup.model-auth.test.ts b/src/wizard/setup.model-auth.test.ts index 02451662bfe2..7c60299fb992 100644 --- a/src/wizard/setup.model-auth.test.ts +++ b/src/wizard/setup.model-auth.test.ts @@ -1,5 +1,6 @@ // Regression tests: provider auth failures re-prompt instead of killing the wizard. import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { RuntimeEnv } from "../runtime.js"; import { WizardCancelledError, type WizardPrompter } from "./prompts.js"; import { runSetupModelAuthStep } from "./setup.model-auth.js"; @@ -10,6 +11,7 @@ const resolvePreferredProviderForAuthChoice = vi.hoisted(() => vi.fn()); const promptDefaultModel = vi.hoisted(() => vi.fn()); const applyPrimaryModel = vi.hoisted(() => vi.fn((config: unknown) => config)); const promptAuthChoiceGrouped = vi.hoisted(() => vi.fn()); +const ensureAuthProfileStore = vi.hoisted(() => vi.fn(() => ({ profiles: {} }))); vi.mock("../commands/auth-choice.js", () => ({ applyAuthChoice, @@ -29,7 +31,7 @@ vi.mock("../commands/auth-choice-prompt.js", () => ({ })); vi.mock("../agents/auth-profiles.runtime.js", () => ({ - ensureAuthProfileStore: vi.fn(() => ({ profiles: {} })), + ensureAuthProfileStore, })); function createPrompter(): WizardPrompter { @@ -50,13 +52,112 @@ function createRuntime(): RuntimeEnv { return { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as unknown as RuntimeEnv; } -describe("runSetupModelAuthStep provider failures", () => { +function createDefaultAgentConfig(): OpenClawConfig { + return { + agents: { + defaults: { workspace: "/tmp/global-workspace" }, + entries: { + ops: { + default: true, + agentDir: "/tmp/ops-agent", + workspace: "/tmp/ops-workspace", + }, + }, + }, + }; +} + +describe("runSetupModelAuthStep", () => { beforeEach(() => { vi.clearAllMocks(); promptDefaultModel.mockResolvedValue({}); warnIfModelConfigLooksOff.mockResolvedValue(undefined); }); + it("targets the configured default agent for auth and model setup", async () => { + const config = createDefaultAgentConfig(); + promptAuthChoiceGrouped.mockResolvedValueOnce("anthropic-cli"); + applyAuthChoice.mockResolvedValueOnce({ + config, + authProfiles: [], + persistAuthProfiles: async () => {}, + }); + + await runSetupModelAuthStep({ + config, + opts: {}, + prompter: createPrompter(), + runtime: createRuntime(), + }); + + expect(ensureAuthProfileStore).toHaveBeenCalledWith("/tmp/ops-agent", { + allowKeychainPrompt: false, + }); + expect(promptAuthChoiceGrouped).toHaveBeenCalledWith( + expect.objectContaining({ workspaceDir: "/tmp/ops-workspace" }), + ); + expect(applyAuthChoice).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: "ops", + agentDir: "/tmp/ops-agent", + }), + ); + expect(promptDefaultModel).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: "ops", + agentDir: "/tmp/ops-agent", + workspaceDir: "/tmp/ops-workspace", + }), + ); + expect(warnIfModelConfigLooksOff).toHaveBeenCalledWith(expect.anything(), expect.anything(), { + agentId: "ops", + agentDir: "/tmp/ops-agent", + validateCatalog: false, + }); + }); + + it("validates an interactive skip against the configured default agent", async () => { + const config = createDefaultAgentConfig(); + promptAuthChoiceGrouped.mockResolvedValueOnce("skip"); + + await runSetupModelAuthStep({ + config, + opts: {}, + prompter: createPrompter(), + runtime: createRuntime(), + }); + + expect(warnIfModelConfigLooksOff).toHaveBeenCalledWith(expect.anything(), expect.anything(), { + agentId: "ops", + agentDir: "/tmp/ops-agent", + validateCatalog: false, + }); + }); + + it("applies an interactive model selection to the agent override", async () => { + const config = createDefaultAgentConfig(); + config.agents!.defaults!.model = "openai/global-model"; + config.agents!.entries!.ops!.model = { + primary: "anthropic/old-model", + fallbacks: ["openai/fallback-model"], + }; + promptAuthChoiceGrouped.mockResolvedValueOnce("skip"); + promptDefaultModel.mockResolvedValueOnce({ model: "google/new-model" }); + + const result = await runSetupModelAuthStep({ + config, + opts: {}, + prompter: createPrompter(), + runtime: createRuntime(), + }); + + expect(result.config.agents?.entries?.ops?.model).toEqual({ + primary: "google/new-model", + fallbacks: ["openai/fallback-model"], + }); + expect(result.config.agents?.defaults?.model).toBe("openai/global-model"); + }); + it("re-prompts after a provider setup error instead of aborting", async () => { promptAuthChoiceGrouped.mockResolvedValueOnce("anthropic-cli").mockResolvedValueOnce("skip"); applyAuthChoice.mockRejectedValueOnce( @@ -69,7 +170,6 @@ describe("runSetupModelAuthStep provider failures", () => { opts: {}, prompter, runtime: createRuntime(), - workspaceDir: "/tmp/workspace", }); expect(result).toEqual({ @@ -95,7 +195,6 @@ describe("runSetupModelAuthStep provider failures", () => { opts: { authChoice: "anthropic-cli" }, prompter: createPrompter(), runtime: createRuntime(), - workspaceDir: "/tmp/workspace", }), ).rejects.toThrow("Claude CLI is not authenticated"); }); @@ -110,7 +209,6 @@ describe("runSetupModelAuthStep provider failures", () => { opts: {}, prompter: createPrompter(), runtime: createRuntime(), - workspaceDir: "/tmp/workspace", }), ).rejects.toThrow(WizardCancelledError); }); diff --git a/src/wizard/setup.model-auth.ts b/src/wizard/setup.model-auth.ts index a3a6a7a7e98e..a94c2e221360 100644 --- a/src/wizard/setup.model-auth.ts +++ b/src/wizard/setup.model-auth.ts @@ -1,5 +1,9 @@ // Model/auth provider selection step shared by the classic wizard and bootstrap onboarding. import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; +import { + applyOnboardingPrimaryModel, + resolveOnboardingAgentTarget, +} from "../commands/onboard-agent-target.js"; import type { AuthChoice, OnboardOptions } from "../commands/onboard-types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { formatErrorMessage } from "../infra/errors.js"; @@ -127,9 +131,8 @@ export async function runSetupModelAuthStep(params: { opts: OnboardOptions; prompter: WizardPrompter; runtime: RuntimeEnv; - workspaceDir: string; }): Promise { - const { opts, prompter, runtime, workspaceDir } = params; + const { opts, prompter, runtime } = params; let nextConfig = params.stagedCandidate?.config ?? params.config; let replacementBaseConfig = params.config; let authProfiles: PreparedAuthChoiceResult["authProfiles"] = @@ -150,18 +153,20 @@ export async function runSetupModelAuthStep(params: { const authChoicePromptModule = await import("../commands/auth-choice-prompt.js"); promptAuthChoiceGrouped = authChoicePromptModule.promptAuthChoiceGrouped; keepCurrentAuthChoice = authChoicePromptModule.KEEP_CURRENT_AUTH_CHOICE; - authStore = ensureAuthProfileStore(undefined, { + const target = resolveOnboardingAgentTarget(nextConfig); + authStore = ensureAuthProfileStore(target.agentDir, { allowKeychainPrompt: false, }); } while (true) { if (authChoiceFromPrompt) { + const target = resolveOnboardingAgentTarget(nextConfig); authChoice = await promptAuthChoiceGrouped!({ prompter, store: authStore!, includeSkip: true, config: nextConfig, - workspaceDir, + workspaceDir: target.workspaceDir, allowKeepCurrentProvider: true, }); } @@ -193,7 +198,8 @@ export async function runSetupModelAuthStep(params: { // Explicit skip should stay cold: do not bootstrap auth/profile machinery // or run model/auth checks when the caller already chose to skip setup. if (authChoiceFromPrompt) { - const { applyPrimaryModel, promptDefaultModel } = await loadModelPickerModule(); + const { promptDefaultModel } = await loadModelPickerModule(); + const target = resolveOnboardingAgentTarget(nextConfig); const modelSelection = await promptDefaultModel({ config: nextConfig, prompter, @@ -201,27 +207,35 @@ export async function runSetupModelAuthStep(params: { ignoreAllowlist: true, includeProviderPluginSetups: false, loadCatalog: false, - workspaceDir, + agentId: target.agentId, + agentDir: target.agentDir, + workspaceDir: target.workspaceDir, runtime, }); if (modelSelection.config) { nextConfig = modelSelection.config; } if (modelSelection.model) { - nextConfig = applyPrimaryModel(nextConfig, modelSelection.model); + nextConfig = applyOnboardingPrimaryModel(nextConfig, target, modelSelection.model); } const { warnIfModelConfigLooksOff } = await loadAuthChoiceModule(); - await warnIfModelConfigLooksOff(nextConfig, prompter, { validateCatalog: false }); + const validationTarget = resolveOnboardingAgentTarget(nextConfig); + await warnIfModelConfigLooksOff(nextConfig, prompter, { + agentId: validationTarget.agentId, + agentDir: validationTarget.agentDir, + validateCatalog: false, + }); } break; } const [ { prepareAuthChoice, resolvePreferredProviderForAuthChoice, warnIfModelConfigLooksOff }, - { applyPrimaryModel, promptDefaultModel }, + { promptDefaultModel }, ] = await Promise.all([loadAuthChoiceModule(), loadModelPickerModule()]); prompter.disableBackNavigation?.(); + const target = resolveOnboardingAgentTarget(nextConfig); let authResult: PreparedAuthChoiceResult; try { authResult = await prepareAuthChoice({ @@ -229,6 +243,8 @@ export async function runSetupModelAuthStep(params: { config: nextConfig, prompter, runtime, + agentId: target.agentId, + agentDir: target.agentDir, setDefaultModel: true, preserveExistingDefaultModel: true, opts: { @@ -260,13 +276,19 @@ export async function runSetupModelAuthStep(params: { break; } if (authResult.agentModelOverride) { - nextConfig = applyPrimaryModel(nextConfig, authResult.agentModelOverride); + const overrideTarget = resolveOnboardingAgentTarget(nextConfig); + nextConfig = applyOnboardingPrimaryModel( + nextConfig, + overrideTarget, + authResult.agentModelOverride, + ); } + const updatedTarget = resolveOnboardingAgentTarget(nextConfig); const authChoiceModelSelectionPolicy = await resolveAuthChoiceModelSelectionPolicy({ authChoice, config: nextConfig, - workspaceDir, + workspaceDir: updatedTarget.workspaceDir, resolvePreferredProviderForAuthChoice, }); const shouldPromptModelSelection = @@ -280,18 +302,25 @@ export async function runSetupModelAuthStep(params: { includeProviderPluginSetups: true, preferredProvider: authChoiceModelSelectionPolicy?.preferredProvider, browseCatalogOnDemand: true, - workspaceDir, + agentId: updatedTarget.agentId, + agentDir: updatedTarget.agentDir, + workspaceDir: updatedTarget.workspaceDir, runtime, }); if (modelSelection.config) { nextConfig = modelSelection.config; } if (modelSelection.model) { - nextConfig = applyPrimaryModel(nextConfig, modelSelection.model); + nextConfig = applyOnboardingPrimaryModel(nextConfig, updatedTarget, modelSelection.model); } } - await warnIfModelConfigLooksOff(nextConfig, prompter, { validateCatalog: false }); + const validationTarget = resolveOnboardingAgentTarget(nextConfig); + await warnIfModelConfigLooksOff(nextConfig, prompter, { + agentId: validationTarget.agentId, + agentDir: validationTarget.agentDir, + validateCatalog: false, + }); break; } return { config: nextConfig, authProfiles, persistAuthProfiles }; diff --git a/src/wizard/setup.ts b/src/wizard/setup.ts index c8d8c76d08a1..26563fc4d508 100644 --- a/src/wizard/setup.ts +++ b/src/wizard/setup.ts @@ -1,5 +1,6 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { formatCliCommand } from "../cli/command-format.js"; +import { resolveOnboardingAgentTarget } from "../commands/onboard-agent-target.js"; import type { GatewayAuthChoice, OnboardMode, OnboardOptions } from "../commands/onboard-types.js"; import { resolveGatewayPort } from "../config/config.js"; import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; @@ -53,7 +54,6 @@ async function offerLiveModelVerification(params: { opts: OnboardOptions; prompter: WizardPrompter; runtime: RuntimeEnv; - workspaceDir: string; writeConfig: (config: OpenClawConfig) => Promise; required?: boolean; }): Promise<{ config: OpenClawConfig; verified: boolean }> { @@ -130,7 +130,6 @@ async function offerLiveModelVerification(params: { opts: { ...params.opts, authChoice: undefined }, prompter: params.prompter, runtime: params.runtime, - workspaceDir: params.workspaceDir, }); shouldPersistCandidate = true; } @@ -566,7 +565,7 @@ async function runSetupWizardOnce( const { applyLocalSetupWorkspaceConfig, applySkipBootstrapConfig } = await loadOnboardConfigModule(); - const { workspaceDir, allowWorkspaceChange } = await resolveSetupWorkspaceSelection({ + const { allowWorkspaceChange } = await resolveSetupWorkspaceSelection({ baseConfig, requestedWorkspaceDir, prompter, @@ -586,7 +585,6 @@ async function runSetupWizardOnce( opts, prompter, runtime, - workspaceDir, }); await modelAuth.persistAuthProfiles(); nextConfig = modelAuth.config; @@ -626,7 +624,6 @@ async function runSetupWizardOnce( opts, prompter, runtime, - workspaceDir, writeConfig: async (config) => await writeSetupConfigFile(config, { allowConfigSizeDrop: false }), required: usedImportFlow && keepExistingModelConfig, @@ -662,11 +659,13 @@ async function runSetupWizardOnce( nextConfig = await writeSetupConfigFile(nextConfig, { allowConfigSizeDrop: false, }); + let onboardingTarget = resolveOnboardingAgentTarget(nextConfig); const { logConfigUpdated } = await loadConfigLoggingModule(); logConfigUpdated(runtime); - await onboardHelpers.ensureWorkspaceAndSessions(workspaceDir, runtime, { + await onboardHelpers.ensureWorkspaceAndSessions(onboardingTarget.workspaceDir, runtime, { skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap), skipOptionalBootstrapFiles: nextConfig.agents?.defaults?.skipOptionalBootstrapFiles, + agentId: onboardingTarget.agentId, }); if (!usedImportFlow) { @@ -688,7 +687,7 @@ async function runSetupWizardOnce( await prompter.note(t("wizard.setup.skipSkills"), t("wizard.setup.skillsTitle")); } else { const { setupSkills } = await import("../commands/onboard-skills.js"); - nextConfig = await setupSkills(nextConfig, workspaceDir, runtime, prompter, { + nextConfig = await setupSkills(nextConfig, onboardingTarget.workspaceDir, runtime, prompter, { nodeManager: opts.nodeManager, }); } @@ -700,14 +699,14 @@ async function runSetupWizardOnce( config: nextConfig, prompter, runtime, - workspaceDir, + workspaceDir: onboardingTarget.workspaceDir, }); const { setupAppRecommendations } = await import("./setup.app-recommendations.js"); const recommendationOutcome = await setupAppRecommendations({ config: nextConfig, prompter, runtime, - workspaceDir, + workspaceDir: onboardingTarget.workspaceDir, modelRouteVerified: liveModelVerified, }); nextConfig = recommendationOutcome.config; @@ -716,7 +715,7 @@ async function runSetupWizardOnce( nextConfig = await setupPluginConfig({ config: nextConfig, prompter, - workspaceDir, + workspaceDir: onboardingTarget.workspaceDir, }); } @@ -729,6 +728,7 @@ async function runSetupWizardOnce( nextConfig = await writeSetupConfigFile(nextConfig, { allowConfigSizeDrop: false, }); + onboardingTarget = resolveOnboardingAgentTarget(nextConfig); commitAppRecommendationResult?.(); const { finalizeSetupWizard } = await import("./setup.finalize.js"); @@ -738,7 +738,7 @@ async function runSetupWizardOnce( baseConfig, hadExistingConfig: snapshot.exists, nextConfig, - workspaceDir, + workspaceDir: onboardingTarget.workspaceDir, settings, prompter, runtime,