fix(onboard): never silently remap an existing fleet's workspaces (#111787)

* fix(onboard): preserve agent workspaces on rerun

* fix(onboard): preserve first-run workspace setup
This commit is contained in:
Peter Steinberger
2026-07-20 16:16:04 -07:00
committed by GitHub
parent b628882d0b
commit 7cafe35bd5
16 changed files with 507 additions and 54 deletions
+4 -1
View File
@@ -118,7 +118,10 @@ unchanged until OpenClaw starts.
In guided mode, `--workspace <dir>` supplies OpenClaw's proposed workspace
and the isolated inference context. It is not persisted until you approve the
OpenClaw setup proposal. Classic and noninteractive onboarding persist their
workspace through their normal setup flow.
workspace through their normal setup flow. On a rerun with an existing agent
roster, onboarding preserves the configured fleet workspace: the classic
wizard shows both paths and requires explicit confirmation before moving it,
while non-interactive setup warns and keeps the current value.
After inference passes, onboarding checks for memories from supported local AI
tools: Claude Code auto-memory, Codex consolidated memories, and Hermes memory
+24 -21
View File
@@ -28,7 +28,10 @@ Routing order:
In guided mode, `--workspace <dir>` is the workspace proposed to OpenClaw;
it is persisted only after you approve that proposal. Baseline, classic, and
noninteractive setup persist the supplied workspace through their normal flow.
noninteractive setup persist the supplied workspace through their normal flow
on a fresh install. When an existing agent roster would be remapped, the
classic wizard requires explicit confirmation; noninteractive setup keeps the
current fleet workspace and prints a warning.
Guided inference detection runs on the Gateway host on macOS or Linux. The CLI
and macOS app call the same Gateway-owned detector, which checks configured
@@ -59,26 +62,26 @@ entry for the same inference-gated OpenClaw assistant.
## Options
| Flag | Description |
| -------------------------- | ----------------------------------------------------------------------------------------------------- |
| `-m, --message <text>` | Run one OpenClaw request. |
| `--yes` | Approve persistent config writes for one `--message` request. |
| `--workspace <dir>` | Workspace proposal in guided mode; persisted directly by baseline, classic, and noninteractive setup. |
| `--baseline` | Create baseline config/workspace/session folders without onboarding. |
| `--wizard` | Force interactive onboarding. |
| `--tui` | Use the terminal hatch instead of the browser handoff. |
| `--non-interactive` | Run onboarding without prompts. |
| `--accept-risk` | Acknowledge full-system agent access risk; required with `--non-interactive`. |
| `--mode <mode>` | Onboarding mode: `local` or `remote`. |
| `--flow <flow>` | Onboard flow: `quickstart`, `advanced`, `manual`, or `import`. |
| `--reset` | Reset config + credentials + sessions before onboarding (workspace only with `--reset-scope full`). |
| `--reset-scope <scope>` | Reset scope: `config`, `config+creds+sessions`, or `full`. |
| `--import-from <provider>` | Migration provider to run during onboarding. |
| `--import-source <path>` | Source agent home for `--import-from`. |
| `--import-secrets` | Import supported secrets during onboarding migration. |
| `--remote-url <url>` | Remote Gateway WebSocket URL. |
| `--remote-token <token>` | Remote Gateway token (optional). |
| `--json` | Configured system: OpenClaw overview. Onboarding route: onboarding summary. |
| Flag | Description |
| -------------------------- | ---------------------------------------------------------------------------------------------------- |
| `-m, --message <text>` | Run one OpenClaw request. |
| `--yes` | Approve persistent config writes for one `--message` request. |
| `--workspace <dir>` | Workspace proposal; existing fleets require classic confirmation and are preserved noninteractively. |
| `--baseline` | Create baseline config/workspace/session folders without onboarding. |
| `--wizard` | Force interactive onboarding. |
| `--tui` | Use the terminal hatch instead of the browser handoff. |
| `--non-interactive` | Run onboarding without prompts. |
| `--accept-risk` | Acknowledge full-system agent access risk; required with `--non-interactive`. |
| `--mode <mode>` | Onboarding mode: `local` or `remote`. |
| `--flow <flow>` | Onboard flow: `quickstart`, `advanced`, `manual`, or `import`. |
| `--reset` | Reset config + credentials + sessions before onboarding (workspace only with `--reset-scope full`). |
| `--reset-scope <scope>` | Reset scope: `config`, `config+creds+sessions`, or `full`. |
| `--import-from <provider>` | Migration provider to run during onboarding. |
| `--import-source <path>` | Source agent home for `--import-from`. |
| `--import-secrets` | Import supported secrets during onboarding migration. |
| `--remote-url <url>` | Remote Gateway WebSocket URL. |
| `--remote-token <token>` | Remote Gateway token (optional). |
| `--json` | Configured system: OpenClaw overview. Onboarding route: onboarding summary. |
`--classic` and `--non-interactive` are mutually exclusive: classic opens the
prompted wizard, while noninteractive setup uses the automation path.
+3
View File
@@ -49,6 +49,9 @@ not install or modify anything on the remote host.
<Step title="Workspace">
- Default `~/.openclaw/workspace` (configurable).
- Seeds workspace files needed for first-run bootstrap.
- On rerun, an existing agent roster keeps its fleet-wide workspace unless
you explicitly confirm the move. Non-interactive reruns warn and preserve
the current value.
- Workspace layout: [Agent workspace](/concepts/agent-workspace).
</Step>
+105 -2
View File
@@ -1,7 +1,14 @@
// Onboard config tests cover workspace, bootstrap, and local setup config mutations.
import { describe, expect, it } from "vitest";
import nodeFs from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { applyLocalSetupWorkspaceConfig } from "./onboard-config.js";
import {
applyLocalSetupWorkspaceConfig,
resolveOnboardingWorkspaceConflict,
} from "./onboard-config.js";
describe("applyLocalSetupWorkspaceConfig", () => {
it("leaves dmScope unset when not configured", () => {
@@ -71,4 +78,100 @@ describe("applyLocalSetupWorkspaceConfig", () => {
expect(result.agents?.list?.map((a) => a.id)).toEqual(["alpha", "beta"]);
expect(result.bindings).toEqual(baseConfig.bindings);
});
it("keeps fresh-install workspace writes unchanged", () => {
const result = applyLocalSetupWorkspaceConfig({}, "/tmp/new-workspace", {
env: { HOME: "/tmp/fresh-home", OPENCLAW_STATE_DIR: "/tmp/fresh-state" },
});
expect(result.agents?.defaults?.workspace).toBe("/tmp/new-workspace");
});
it("preserves the current workspace when an agent roster exists", () => {
const baseConfig: OpenClawConfig = {
agents: {
defaults: { workspace: "/tmp/current-workspace" },
list: [{ id: "main" }, { id: "ops" }],
},
};
const conflict = resolveOnboardingWorkspaceConflict(baseConfig, "/tmp/requested-workspace");
const result = applyLocalSetupWorkspaceConfig(baseConfig, "/tmp/requested-workspace");
expect(conflict).toEqual({
currentWorkspaceDir: "/tmp/current-workspace",
requestedWorkspaceDir: "/tmp/requested-workspace",
});
expect(result.agents?.defaults?.workspace).toBe("/tmp/current-workspace");
});
it("does not materialize a fleet default for an existing roster", () => {
const env = { HOME: "/tmp/fleet-home", OPENCLAW_STATE_DIR: "/tmp/fleet-state" };
const baseConfig: OpenClawConfig = {
agents: { list: [{ id: "main" }, { id: "ops" }] },
};
const result = applyLocalSetupWorkspaceConfig(
baseConfig,
"/tmp/fleet-home/.openclaw/workspace",
{ env },
);
expect(result.agents?.defaults?.workspace).toBeUndefined();
});
it("keeps fresh-install workspace writes when only inference state exists on disk", async () => {
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-onboard-state-"));
try {
await fs.mkdir(path.join(stateDir, "agents", "main", "sessions"), { recursive: true });
const env = { HOME: stateDir, OPENCLAW_STATE_DIR: stateDir };
const result = applyLocalSetupWorkspaceConfig({}, "/tmp/requested-workspace", {
env,
});
expect(result.agents?.defaults?.workspace).toBe("/tmp/requested-workspace");
const rerun = applyLocalSetupWorkspaceConfig(
{ agents: { defaults: { workspace: "/tmp/current-workspace" } } },
"/tmp/requested-workspace",
{ env },
);
expect(rerun.agents?.defaults?.workspace).toBe("/tmp/current-workspace");
} finally {
await fs.rm(stateDir, { recursive: true, force: true });
}
});
it("fails closed when existing agent state cannot be inspected", () => {
const read = vi.spyOn(nodeFs, "readdirSync").mockImplementationOnce(() => {
throw Object.assign(new Error("permission denied"), { code: "EACCES" });
});
try {
const result = applyLocalSetupWorkspaceConfig(
{ agents: { defaults: { workspace: "/tmp/current-workspace" } } },
"/tmp/requested-workspace",
{
env: { HOME: "/tmp/unreadable-home", OPENCLAW_STATE_DIR: "/tmp/unreadable-state" },
},
);
expect(result.agents?.defaults?.workspace).toBe("/tmp/current-workspace");
} finally {
read.mockRestore();
}
});
it("allows an explicitly confirmed workspace move", () => {
const baseConfig: OpenClawConfig = {
agents: {
defaults: { workspace: "/tmp/current-workspace" },
list: [{ id: "main" }],
},
};
const result = applyLocalSetupWorkspaceConfig(baseConfig, "/tmp/requested-workspace", {
allowWorkspaceChange: true,
});
expect(result.agents?.defaults?.workspace).toBe("/tmp/requested-workspace");
});
});
+82 -7
View File
@@ -1,11 +1,68 @@
/** Shared config mutations used by interactive and non-interactive onboarding. */
import fs from "node:fs";
import path from "node:path";
import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace-default.js";
import { setConfigValueAtPath } from "../config/config-paths.js";
import { resolveStateDir } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { ToolProfileId } from "../config/types.tools.js";
import { resolveUserPath } from "../utils.js";
/** Default tool profile selected during local onboarding. */
const ONBOARDING_DEFAULT_TOOLS_PROFILE: ToolProfileId = "coding";
export type OnboardingWorkspaceConflict = {
currentWorkspaceDir: string;
requestedWorkspaceDir: string;
};
function hasExistingAgentState(env: NodeJS.ProcessEnv): boolean {
const stateDir = resolveStateDir(env);
const agentsDir = path.join(stateDir, "agents");
try {
if (fs.readdirSync(agentsDir, { withFileTypes: true }).some((entry) => entry.isDirectory())) {
return true;
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
return true;
}
}
return [path.join(stateDir, "agent"), path.join(stateDir, "sessions")].some((candidate) => {
try {
return fs.statSync(candidate).isDirectory();
} catch (error) {
return (error as NodeJS.ErrnoException).code !== "ENOENT";
}
});
}
/** Detects a workspace change that could remap an existing agent fleet. */
export function resolveOnboardingWorkspaceConflict(
baseConfig: OpenClawConfig,
requestedWorkspaceDir: string,
env: NodeJS.ProcessEnv = process.env,
): OnboardingWorkspaceConflict | undefined {
const configuredWorkspace = baseConfig.agents?.defaults?.workspace?.trim();
const currentWorkspaceDir = configuredWorkspace
? resolveUserPath(configuredWorkspace, env)
: resolveDefaultAgentWorkspaceDir(env);
const normalizedCurrent = path.resolve(currentWorkspaceDir);
const normalizedRequested = path.resolve(resolveUserPath(requestedWorkspaceDir, env));
if (normalizedCurrent === normalizedRequested) {
return undefined;
}
const hasRoster = Array.isArray(baseConfig.agents?.list) && baseConfig.agents.list.length > 0;
if (!hasRoster && !(configuredWorkspace && hasExistingAgentState(env))) {
return undefined;
}
return {
currentWorkspaceDir: normalizedCurrent,
requestedWorkspaceDir: normalizedRequested,
};
}
/** Applies local gateway/workspace defaults without overwriting explicit user defaults. */
// Deliberately writes no session.dmScope: the schema default "main" (one rolling
// personal-agent session across channels) is the product default. Multi-user DM
@@ -13,16 +70,34 @@ const ONBOARDING_DEFAULT_TOOLS_PROFILE: ToolProfileId = "coding";
export function applyLocalSetupWorkspaceConfig(
baseConfig: OpenClawConfig,
workspaceDir: string,
options: {
allowWorkspaceChange?: boolean;
preserveWorkspace?: boolean;
env?: NodeJS.ProcessEnv;
} = {},
): OpenClawConfig {
const workspaceConflict = resolveOnboardingWorkspaceConflict(
baseConfig,
workspaceDir,
options.env,
);
const hasRoster = Array.isArray(baseConfig.agents?.list) && baseConfig.agents.list.length > 0;
const shouldUpdateWorkspace =
!options.preserveWorkspace &&
(options.allowWorkspaceChange || (!hasRoster && !workspaceConflict));
return {
...baseConfig,
agents: {
...baseConfig.agents,
defaults: {
...baseConfig.agents?.defaults,
workspace: workspaceDir,
},
},
...(shouldUpdateWorkspace
? {
agents: {
...baseConfig.agents,
defaults: {
...baseConfig.agents?.defaults,
workspace: workspaceDir,
},
},
}
: {}),
gateway: {
...baseConfig.gateway,
mode: "local",
+24 -2
View File
@@ -413,8 +413,25 @@ async function runGuidedOnboardingFlow(
// persisted gateway config (quickstart writes it when setup applies).
// Wizard timestamps are shared with configure/doctor and prove nothing here.
const alreadyConfigured = Boolean(detection?.setupComplete || existingConfig.gateway);
const { resolveSetupWorkspaceSelection } = await import("../wizard/setup.workspace.js");
const workspaceSelection = await resolveSetupWorkspaceSelection({
baseConfig: existingConfig,
requestedWorkspaceDir: workspace,
prompter,
canConfirmMove: !alreadyConfigured,
});
const { allowWorkspaceChange, conflict: workspaceConflict } = workspaceSelection;
const appliedWorkspace = workspaceSelection.workspaceDir;
if (alreadyConfigured) {
await prompter.note(t("wizard.guided.alreadySetUp"), t("wizard.guided.welcomeTitle"));
if (workspaceConflict) {
await prompter.note(
t("wizard.guided.workspaceConflictClassic", {
command: formatCliCommand("openclaw onboard --classic"),
}),
t("wizard.setup.workspaceConflictTitle"),
);
}
} else {
// Announced default: apply the same setup plan the conversational "yes"
// would, then hand off to the hatch instead of parking in the OpenClaw chat.
@@ -423,7 +440,12 @@ async function runGuidedOnboardingFlow(
const applyProgress = prompter.progress(t("wizard.guided.settingUp"));
try {
const applied = await withConsoleSubsystemsSuppressed(() =>
applySetup({ workspace, surface: "cli", runtime }),
applySetup({
workspace,
...(allowWorkspaceChange ? { allowWorkspaceChange: true } : {}),
surface: "cli",
runtime,
}),
);
applyProgress.stop(t("wizard.guided.setupDone"));
if (applied.lines.length > 0) {
@@ -483,7 +505,7 @@ async function runGuidedOnboardingFlow(
? resolveUserPath(
existingConfig.agents?.defaults?.workspace?.trim() || onboardHelpers.DEFAULT_WORKSPACE,
)
: workspace;
: appliedWorkspace;
if (opts.tui !== true) {
const probeBrowserHandoffGateway =
deps.probeBrowserHandoffGateway ??
@@ -407,6 +407,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
it("preserves existing config on onboard rerun (openclaw#84692)", async () => {
await withStateDir("state-preserve-agents-", async (stateDir) => {
const workspace = path.join(stateDir, "openclaw");
const warningRuntime = { ...runtime, error: vi.fn() };
const passwordRef = { source: "env" as const, provider: "default", id: "GATEWAY_PASSWORD" };
const seededAgents = [
{ id: "alpha", model: "anthropic/claude-3-5-sonnet" },
@@ -446,25 +447,23 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
{
nonInteractive: true,
mode: "local",
workspace,
workspace: path.join(stateDir, "requested-workspace"),
authChoice: "skip",
skipSkills: true,
skipHealth: true,
installDaemon: false,
},
runtime,
warningRuntime,
);
const cfg = readTestConfig();
expect(cfg.agents?.list?.map((a) => a.id)).toEqual(["alpha", "beta"]);
expect(cfg.agents?.defaults?.workspace).toBe(workspace);
expect(cfg.bindings).toEqual(seededBindings);
expect(cfg.gateway).toEqual({
mode: "local",
port: 24680,
bind: "loopback",
auth: { mode: "password", password: passwordRef },
tailscale: { mode: "serve", resetOnExit: true },
});
expect(warningRuntime.error).toHaveBeenCalledWith(
expect.stringContaining("existing agents keep their current workspace"),
);
expect(cfg.gateway?.port).toBe(24680);
const onboardWrite = capturedReplaceConfigFileCalls.at(-1);
expect(onboardWrite?.writeOptions?.allowConfigSizeDrop).toBe(false);
+22 -3
View File
@@ -12,7 +12,11 @@ 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 { applyLocalSetupWorkspaceConfig, applySkipBootstrapConfig } from "../onboard-config.js";
import {
applyLocalSetupWorkspaceConfig,
applySkipBootstrapConfig,
resolveOnboardingWorkspaceConflict,
} from "../onboard-config.js";
import {
applyWizardMetadata,
DEFAULT_WORKSPACE,
@@ -167,13 +171,28 @@ export async function runNonInteractiveLocalSetup(params: {
const { opts, runtime, baseConfig, baseHash } = params;
const mode = "local" as const;
const workspaceDir = resolveNonInteractiveWorkspaceDir({
const requestedWorkspaceDir = resolveNonInteractiveWorkspaceDir({
opts,
baseConfig,
defaultWorkspaceDir: DEFAULT_WORKSPACE,
});
const workspaceConflict = resolveOnboardingWorkspaceConflict(baseConfig, requestedWorkspaceDir);
const workspaceDir = workspaceConflict?.currentWorkspaceDir ?? requestedWorkspaceDir;
if (workspaceConflict) {
runtime.error(
[
"Warning: existing agents keep their current workspace during non-interactive onboarding.",
`Current workspace: ${workspaceConflict.currentWorkspaceDir}`,
`Requested workspace: ${workspaceConflict.requestedWorkspaceDir}`,
`Run \`${formatCliCommand("openclaw onboard --classic")}\` to confirm moving the existing agent fleet.`,
].join("\n"),
);
}
let nextConfig: OpenClawConfig = applyLocalSetupWorkspaceConfig(baseConfig, workspaceDir);
let nextConfig: OpenClawConfig = applyLocalSetupWorkspaceConfig(
baseConfig,
requestedWorkspaceDir,
);
if (opts.skipBootstrap) {
nextConfig = applySkipBootstrapConfig(nextConfig);
}
+50
View File
@@ -328,6 +328,56 @@ describe("applySystemAgentSetup transaction boundaries", () => {
});
});
it("does not mistake a proposal-created roster for an existing fleet", async () => {
const absent = snapshot(null, {});
mocks.state.initialSnapshot = absent;
mocks.state.commitConfig = {};
mocks.state.commitSnapshot = absent;
mocks.state.commitPreviousHash = null;
await applySystemAgentSetup(
baseParams({
expectedConfigHash: null,
workspace: "/tmp/requested-workspace",
configPatch: { agents: { list: [{ id: "main" }] } },
}),
);
expect(mocks.state.persistedConfig?.agents).toMatchObject({
defaults: { workspace: "/tmp/requested-workspace" },
list: [{ id: "main" }],
});
});
it("keeps an existing fleet workspace for unconfirmed setup apply", async () => {
const config = {
agents: {
defaults: { workspace: "/tmp/current-workspace" },
list: [{ id: "main" }, { id: "ops" }],
},
} satisfies OpenClawConfig;
mocks.state.initialSnapshot = snapshot("probe", config);
mocks.state.commitConfig = structuredClone(config);
mocks.state.commitSnapshot = snapshot("probe", config);
await applySystemAgentSetup(
baseParams({
workspace: "/tmp/requested-workspace",
configPatch: {
agents: { defaults: { workspace: "/tmp/patch-workspace" }, list: null },
},
}),
);
expect(mocks.state.persistedConfig?.agents?.defaults?.workspace).toBe("/tmp/current-workspace");
expect(mocks.state.persistedConfig?.agents?.list).toEqual([{ id: "main" }, { id: "ops" }]);
expect(mocks.ensureWorkspace).toHaveBeenCalledWith(
"/tmp/current-workspace",
runtime,
expect.any(Object),
);
});
it("rejects invalid config before any setup mutation", async () => {
mocks.state.initialSnapshot = {
...snapshot("invalid", {}),
+48 -7
View File
@@ -32,6 +32,8 @@ import { requireValidSystemAgentSetupSnapshot } from "./setup-config-snapshot.js
*/
export type SystemAgentSetupApplyParams = {
workspace: string;
/** Explicit interactive approval to replace an existing fleet workspace root. */
allowWorkspaceChange?: boolean;
model?: string;
agentRuntimeId?: string;
/** Pin the selected model to the exact credential that passed inference. */
@@ -276,7 +278,7 @@ export async function applySystemAgentSetup(
const [
{ readSetupConfigFileSnapshot, resolveQuickstartGatewayDefaults },
onboardHelpers,
{ applyLocalSetupWorkspaceConfig },
{ applyLocalSetupWorkspaceConfig, resolveOnboardingWorkspaceConflict },
{ transformConfigWithPendingPluginInstalls },
] = await Promise.all([
import("../wizard/setup.shared.js"),
@@ -369,6 +371,11 @@ export async function applySystemAgentSetup(
const prompter = createQuickstartNotePrompter(runtime);
const { configureGatewayForSetup } = await import("../wizard/setup.gateway-config.js");
const buildSetupCandidate = async (currentBaseConfig: OpenClawConfig) => {
const workspaceConflict = resolveOnboardingWorkspaceConflict(currentBaseConfig, workspace);
const currentHasRoster =
Array.isArray(currentBaseConfig.agents?.list) && currentBaseConfig.agents.list.length > 0;
const allowWorkspaceWrite =
params.allowWorkspaceChange || (!workspaceConflict && !currentHasRoster);
let setupBaseConfig = currentBaseConfig;
if (enablePluginId) {
const enabled = enablePluginInConfig(setupBaseConfig, enablePluginId);
@@ -380,8 +387,36 @@ export async function applySystemAgentSetup(
if (configPatch !== undefined) {
setupBaseConfig = applyMergePatch(setupBaseConfig, configPatch) as OpenClawConfig;
}
if (currentHasRoster) {
setupBaseConfig = {
...setupBaseConfig,
agents: { ...setupBaseConfig.agents, list: currentBaseConfig.agents?.list },
};
}
const preserveWorkspace =
(currentHasRoster || Boolean(workspaceConflict)) && !params.allowWorkspaceChange;
if (preserveWorkspace) {
const defaults = { ...setupBaseConfig.agents?.defaults };
const currentDefaults = currentBaseConfig.agents?.defaults;
if (currentDefaults && Object.hasOwn(currentDefaults, "workspace")) {
defaults.workspace = currentDefaults.workspace;
} else {
delete defaults.workspace;
}
setupBaseConfig = {
...setupBaseConfig,
agents: { ...setupBaseConfig.agents, defaults },
};
}
let candidate = applyLocalSetupWorkspaceConfig(setupBaseConfig, workspace);
const effectiveWorkspace =
workspaceConflict && !params.allowWorkspaceChange
? workspaceConflict.currentWorkspaceDir
: workspace;
let candidate = applyLocalSetupWorkspaceConfig(setupBaseConfig, workspace, {
allowWorkspaceChange: allowWorkspaceWrite,
preserveWorkspace,
});
if (model) {
candidate = await applySystemAgentModelSelection({
config: candidate,
@@ -406,6 +441,7 @@ export async function applySystemAgentSetup(
mode: "local",
}),
settings: gateway.settings,
workspace: effectiveWorkspace,
};
};
const committed = await commit(
@@ -448,16 +484,21 @@ export async function applySystemAgentSetup(
assertCommitPreconditions?.();
return {
nextConfig: finalizedConfig,
result: { settings: setupCandidate.settings },
result: {
settings: setupCandidate.settings,
workspace: setupCandidate.workspace,
},
};
},
}),
);
const nextConfig = committed.nextConfig;
const settings = committed.result?.settings;
const setupResult = committed.result;
const settings = setupResult?.settings;
if (!settings) {
throw new Error("OpenClaw setup committed without resolved Gateway settings.");
}
const effectiveWorkspace = setupResult.workspace;
if (params.expectedInferenceRoute) {
const afterRead = await readConfigFileSnapshotWithPluginMetadata();
const afterSnapshot = afterRead.snapshot;
@@ -485,7 +526,7 @@ export async function applySystemAgentSetup(
}
const lines: string[] = [
`Workspace: ${shortenHomePath(workspace)}`,
`Workspace: ${shortenHomePath(effectiveWorkspace)}`,
model ? `Default model: ${model}` : undefined,
].filter((line): line is string => line !== undefined);
@@ -513,7 +554,7 @@ export async function applySystemAgentSetup(
const workspaceResult = await runCommittedFollowUp(
async () =>
await onboardHelpers.ensureWorkspaceAndSessions(workspace, runtime, {
await onboardHelpers.ensureWorkspaceAndSessions(effectiveWorkspace, runtime, {
skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap),
skipOptionalBootstrapFiles: nextConfig.agents?.defaults?.skipOptionalBootstrapFiles,
}),
@@ -552,7 +593,7 @@ export async function applySystemAgentSetup(
await refreshPluginRegistryAfterConfigMutation({
config: nextConfig,
reason: "source-changed",
workspaceDir: workspace,
workspaceDir: effectiveWorkspace,
traceCommand: "openclaw-setup",
logger: {
warn: (message) => lines.push(message),
+6
View File
@@ -352,6 +352,8 @@ export const en = {
ttyRequired:
"Onboarding needs an interactive TTY. Use `openclaw onboard --non-interactive --accept-risk ...` for automation.",
welcomeTitle: "Setup choices",
workspaceConflictClassic:
"This verification run kept the configured workspace. Run `{command}` to review and explicitly approve moving the existing agent fleet.",
},
setup: {
authChoiceFailedRetry: "Pick another provider or auth method, or choose Skip for now.",
@@ -414,6 +416,10 @@ export const en = {
testAiSuccess: "AI access works. Replied in {seconds}s.",
testAiTitle: "AI access test",
whatSetup: "What do you want to set up?",
workspaceConflictConfirm: "Move the existing agent fleet to the requested workspace?",
workspaceConflictNotice:
"Existing agents currently use {current}. The requested workspace is {requested}. Changing this fleet-wide default can disconnect agents from their memory and bootstrap files.",
workspaceConflictTitle: "Existing agent workspace",
workspaceDirectory: "Workspace directory",
},
security: {
+6
View File
@@ -345,6 +345,8 @@ export const zh_CN = {
ttyRequired:
"Onboarding 需要交互式 TTY。自动化请使用 `openclaw onboard --non-interactive --accept-risk ...`。",
welcomeTitle: "设置选项",
workspaceConflictClassic:
"本次验证保留了已配置的工作区。运行 `{command}` 以查看并明确批准迁移现有 agent fleet。",
},
setup: {
authChoiceFailedRetry: "请选择其他提供商或认证方式,或选择暂时跳过。",
@@ -407,6 +409,10 @@ export const zh_CN = {
testAiSuccess: "AI 访问正常,在 {seconds} 秒内回复。",
testAiTitle: "AI 访问测试",
whatSetup: "你想设置什么?",
workspaceConflictConfirm: "将现有 agent fleet 迁移到请求的工作区吗?",
workspaceConflictNotice:
"现有 agent 当前使用 {current}。请求的工作区是 {requested}。更改此 fleet-wide 默认值可能会使 agent 与其记忆和 bootstrap 文件断开连接。",
workspaceConflictTitle: "现有 agent 工作区",
workspaceDirectory: "工作区目录",
},
security: {
+6
View File
@@ -345,6 +345,8 @@ export const zh_TW = {
ttyRequired:
"Onboarding 需要互動式 TTY。自動化請使用 `openclaw onboard --non-interactive --accept-risk ...`。",
welcomeTitle: "設定選項",
workspaceConflictClassic:
"本次驗證保留了已設定的工作區。執行 `{command}` 以檢視並明確核准移動現有 agent fleet。",
},
setup: {
authChoiceFailedRetry: "請選擇其他提供商或認證方式,或選擇暫時跳過。",
@@ -407,6 +409,10 @@ export const zh_TW = {
testAiSuccess: "AI 存取正常,在 {seconds} 秒內回覆。",
testAiTitle: "AI 存取測試",
whatSetup: "你想設定什麼?",
workspaceConflictConfirm: "要將現有 agent fleet 移動到要求的工作區嗎?",
workspaceConflictNotice:
"現有 agent 目前使用 {current}。要求的工作區是 {requested}。變更此 fleet-wide 預設值可能會使 agent 與其記憶和 bootstrap 檔案中斷連線。",
workspaceConflictTitle: "現有 agent 工作區",
workspaceDirectory: "工作區目錄",
},
security: {
+57
View File
@@ -1360,6 +1360,63 @@ describe("runSetupWizard", () => {
expect(model.primary).toBe("openai/gpt-5.5");
});
it("moves an existing fleet workspace only after explicit confirmation", async () => {
const currentWorkspace = await makeCaseDir("current-fleet-workspace-");
const requestedWorkspace = await makeCaseDir("requested-fleet-workspace-");
readConfigFileSnapshot.mockResolvedValueOnce({
path: "/tmp/.openclaw/openclaw.json",
exists: true,
raw: "{}",
parsed: {},
resolved: {},
valid: true,
config: {
wizard: { securityAcknowledgedAt: "2026-06-30T00:00:00.000Z" },
agents: {
defaults: { workspace: currentWorkspace },
list: [{ id: "main" }, { id: "ops" }],
},
},
issues: [],
warnings: [],
legacyIssues: [],
});
const confirm = vi.fn(async () => true);
const prompter = buildWizardPrompter({ confirm });
await runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
authChoice: "skip",
installDaemon: false,
skipChannels: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
workspace: requestedWorkspace,
},
createRuntime(),
prompter,
);
expect(confirm).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("Move the existing agent fleet"),
initialValue: false,
}),
);
expect(getWizardNoteCalls(prompter.note).flat().join("\n")).toContain(currentWorkspace);
const finalConfig = persistedWizardConfigs().at(-1);
expect(finalConfig?.agents?.defaults?.workspace).toBe(requestedWorkspace);
expect(ensureWorkspaceAndSessions).toHaveBeenCalledWith(
requestedWorkspace,
expect.anything(),
expect.any(Object),
);
});
async function runTuiHatchTestAndExpectLaunch(params: {
writeBootstrapFile: boolean;
expectedMessage: string | undefined;
+14 -2
View File
@@ -33,6 +33,7 @@ import {
writeWizardConfigFile,
} from "./setup.shared.js";
import type { QuickstartGatewayDefaults, WizardFlow } from "./setup.types.js";
import { resolveSetupWorkspaceSelection } from "./setup.workspace.js";
type SetupFlowChoice = WizardFlow | "import" | "keep-model" | `import:${string}`;
@@ -559,11 +560,22 @@ async function runSetupWizardOnce(
initialValue: baseConfig.agents?.defaults?.workspace ?? onboardHelpers.DEFAULT_WORKSPACE,
}));
const workspaceDir = resolveUserPath(workspaceInput.trim() || onboardHelpers.DEFAULT_WORKSPACE);
const requestedWorkspaceDir = resolveUserPath(
workspaceInput.trim() || onboardHelpers.DEFAULT_WORKSPACE,
);
const { applyLocalSetupWorkspaceConfig, applySkipBootstrapConfig } =
await loadOnboardConfigModule();
let nextConfig: OpenClawConfig = applyLocalSetupWorkspaceConfig(baseConfig, workspaceDir);
const { workspaceDir, allowWorkspaceChange } = await resolveSetupWorkspaceSelection({
baseConfig,
requestedWorkspaceDir,
prompter,
});
let nextConfig: OpenClawConfig = applyLocalSetupWorkspaceConfig(
baseConfig,
requestedWorkspaceDir,
{ allowWorkspaceChange },
);
if (opts.skipBootstrap) {
nextConfig = applySkipBootstrapConfig(nextConfig);
}
+48
View File
@@ -0,0 +1,48 @@
import {
resolveOnboardingWorkspaceConflict,
type OnboardingWorkspaceConflict,
} from "../commands/onboard-config.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { shortenHomePath } from "../utils.js";
import { t } from "./i18n/index.js";
import type { WizardPrompter } from "./prompts.js";
/** Resolves a proposed setup workspace without silently remapping an existing fleet. */
export async function resolveSetupWorkspaceSelection(params: {
baseConfig: OpenClawConfig;
requestedWorkspaceDir: string;
prompter: WizardPrompter;
canConfirmMove?: boolean;
}): Promise<{
workspaceDir: string;
allowWorkspaceChange: boolean;
conflict?: OnboardingWorkspaceConflict;
}> {
const conflict = resolveOnboardingWorkspaceConflict(
params.baseConfig,
params.requestedWorkspaceDir,
);
if (!conflict) {
return { workspaceDir: params.requestedWorkspaceDir, allowWorkspaceChange: false };
}
await params.prompter.note(
t("wizard.setup.workspaceConflictNotice", {
current: shortenHomePath(conflict.currentWorkspaceDir),
requested: shortenHomePath(conflict.requestedWorkspaceDir),
}),
t("wizard.setup.workspaceConflictTitle"),
);
const allowWorkspaceChange =
params.canConfirmMove !== false &&
(await params.prompter.confirm({
message: t("wizard.setup.workspaceConflictConfirm"),
initialValue: false,
}));
return {
workspaceDir: allowWorkspaceChange
? params.requestedWorkspaceDir
: conflict.currentWorkspaceDir,
allowWorkspaceChange,
conflict,
};
}