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
This commit is contained in:
Peter Steinberger
2026-07-22 19:05:21 -04:00
committed by GitHub
parent 0724dfda21
commit bf922f59fe
20 changed files with 1026 additions and 105 deletions
+8 -3
View File
@@ -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 () => {
+9 -6
View File
@@ -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);
@@ -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();
});
});
+50 -22
View File
@@ -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();
}
@@ -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();
});
});
+49
View File
@@ -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" });
});
});
});
+86
View File
@@ -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] ?? {},
},
},
},
},
};
}
@@ -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" }),
);
});
});
+10 -6
View File
@@ -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,
@@ -100,6 +100,12 @@ function createRuntime() {
};
}
const target = {
agentId: "main",
agentDir: "/tmp/main-agent",
workspaceDir: "/tmp/workspace",
};
type MockCalls = { mock: { calls: Array<Array<unknown>> } };
function mockCall(mock: MockCalls, callIndex = 0): Array<unknown> {
@@ -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(),
});
@@ -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<OpenClawConfig | null | undefined> {
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()
@@ -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([
@@ -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<OpenClawConfig | null> {
const { opts, runtime, baseConfig } = params;
let authChoice = normalizeApiKeyTokenProviderAuthChoice({
@@ -93,6 +94,7 @@ export async function applyNonInteractiveAuthChoice(params: {
const resolveApiKey = (input: Parameters<typeof resolveNonInteractiveApiKey>[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,
+28 -5
View File
@@ -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<typeof loadStaticManifestCatalogRowsForList>[number],
): ModelCatalogEntry {
@@ -783,6 +804,7 @@ export async function promptDefaultModel(
params: PromptDefaultModelParams,
): Promise<PromptDefaultModelResult> {
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. */
+12 -5
View File
@@ -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" }),
);
});
+6 -10
View File
@@ -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),
+228
View File
@@ -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<void>,
) => 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 }),
);
});
});
+103 -5
View File
@@ -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);
});
+43 -14
View File
@@ -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<SetupModelAuthCandidate> {
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 };
+11 -11
View File
@@ -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<OpenClawConfig>;
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,