mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
+4
-1
@@ -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
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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", {}),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user