diff --git a/packages/memory-host-sdk/src/host/config-utils.test.ts b/packages/memory-host-sdk/src/host/config-utils.test.ts index e26401834e2a..63d320bcb73e 100644 --- a/packages/memory-host-sdk/src/host/config-utils.test.ts +++ b/packages/memory-host-sdk/src/host/config-utils.test.ts @@ -1,9 +1,22 @@ import { describe, expect, it } from "vitest"; import { normalizeConfiguredMemoryExtraPaths, + resolveMemoryHostAgentWorkspaceDir, resolveRememberAcrossConversations, } from "./config-utils.js"; +describe("resolveMemoryHostAgentWorkspaceDir", () => { + it("uses the active profile state root for the default agent workspace", () => { + expect( + resolveMemoryHostAgentWorkspaceDir({}, "main", { + HOME: "/home/peter", + OPENCLAW_PROFILE: "work", + OPENCLAW_STATE_DIR: "/home/peter/.openclaw-work", + }), + ).toBe("/home/peter/.openclaw-work/workspace"); + }); +}); + describe("resolveRememberAcrossConversations", () => { it("honors keyed per-agent memory overrides", () => { const config = { diff --git a/packages/memory-host-sdk/src/host/config-utils.ts b/packages/memory-host-sdk/src/host/config-utils.ts index cfb95b9aca4f..30d11570398b 100644 --- a/packages/memory-host-sdk/src/host/config-utils.ts +++ b/packages/memory-host-sdk/src/host/config-utils.ts @@ -227,7 +227,7 @@ function resolveDefaultAgentWorkspaceDir(env: NodeJS.ProcessEnv = process.env): const home = resolveRequiredHomeDir(env, os.homedir); const profile = env.OPENCLAW_PROFILE?.trim(); if (profile && normalizeLowercaseStringOrEmpty(profile) !== "default") { - return path.join(home, ".openclaw", `workspace-${profile}`); + return path.join(resolveStateDir(env), "workspace"); } return path.join(home, ".openclaw", "workspace"); } diff --git a/src/agents/workspace-default.ts b/src/agents/workspace-default.ts index da848bd61dfb..f94bd63d0f24 100644 --- a/src/agents/workspace-default.ts +++ b/src/agents/workspace-default.ts @@ -6,6 +6,7 @@ import os from "node:os"; import path from "node:path"; import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; +import { resolveProfileStateDir } from "../cli/profile-utils.js"; import { resolveRequiredHomeDir } from "../infra/home-dir.js"; /** Resolve the default agent workspace directory from env/profile/home state. */ @@ -20,7 +21,7 @@ export function resolveDefaultAgentWorkspaceDir( const home = resolveRequiredHomeDir(env, homedir); const profile = env.OPENCLAW_PROFILE?.trim(); if (profile && normalizeOptionalLowercaseString(profile) !== "default") { - return path.join(home, ".openclaw", `workspace-${profile}`); + return path.join(resolveProfileStateDir(profile, env, homedir), "workspace"); } return path.join(home, ".openclaw", "workspace"); } diff --git a/src/agents/workspace.test.ts b/src/agents/workspace.test.ts index 2f6f8a39da42..b1569de6dfab 100644 --- a/src/agents/workspace.test.ts +++ b/src/agents/workspace.test.ts @@ -69,9 +69,29 @@ describe("resolveDefaultAgentWorkspaceDir", () => { expect(dir).toBe(path.join(path.resolve("/srv/openclaw-home"), ".openclaw", "workspace")); }); + it("roots named profile workspaces inside the profile state directory", () => { + const dir = resolveDefaultAgentWorkspaceDir({ + OPENCLAW_PROFILE: "work", + OPENCLAW_HOME: "/srv/openclaw-home", + HOME: "/home/other", + } as NodeJS.ProcessEnv); + + expect(dir).toBe(path.join(path.resolve("/srv/openclaw-home"), ".openclaw-work", "workspace")); + }); + + it("rejects invalid environment-only profile names", () => { + expect(() => + resolveDefaultAgentWorkspaceDir({ + OPENCLAW_PROFILE: "../escape", + HOME: "/home/peter", + } as NodeJS.ProcessEnv), + ).toThrow('Invalid profile name: "../escape"'); + }); + it("prefers OPENCLAW_WORKSPACE_DIR for default workspace resolution", () => { const dir = resolveDefaultAgentWorkspaceDir({ OPENCLAW_WORKSPACE_DIR: "/srv/openclaw-workspace", + OPENCLAW_PROFILE: "work", OPENCLAW_HOME: "/srv/openclaw-home", HOME: "/home/other", } as NodeJS.ProcessEnv); diff --git a/src/cli/profile-utils.ts b/src/cli/profile-utils.ts index 0cb15fe3f672..1b5c7f7987b1 100644 --- a/src/cli/profile-utils.ts +++ b/src/cli/profile-utils.ts @@ -1,5 +1,7 @@ // Profile name validation and normalization helpers for root CLI profile routing. +import path from "node:path"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +import { resolveRequiredHomeDir } from "../infra/home-dir.js"; const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i; @@ -24,3 +26,17 @@ export function normalizeProfileName(raw?: string | null): string | null { } return profile; } + +/** Resolve the canonical home-scoped state root for a validated CLI profile. */ +export function resolveProfileStateDir( + profile: string, + env: NodeJS.ProcessEnv, + homedir: () => string, +): string { + const trimmed = profile.trim(); + if (!isValidProfileName(trimmed)) { + throw new Error(`Invalid profile name: ${JSON.stringify(profile)}`); + } + const suffix = normalizeLowercaseStringOrEmpty(trimmed) === "default" ? "" : `-${trimmed}`; + return path.join(resolveRequiredHomeDir(env, homedir), `.openclaw${suffix}`); +} diff --git a/src/cli/profile.ts b/src/cli/profile.ts index 0d6e42fdcab5..3c7a9f1ddcc5 100644 --- a/src/cli/profile.ts +++ b/src/cli/profile.ts @@ -1,18 +1,15 @@ // Root --profile/--dev parsing and environment projection for profile-specific state. import os from "node:os"; import path from "node:path"; -import { - normalizeLowercaseStringOrEmpty, - normalizeOptionalString, -} from "@openclaw/normalization-core/string-coerce"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { resolveGatewayLaunchAgentLabel, resolveGatewaySystemdServiceName, resolveGatewayWindowsTaskName, } from "../daemon/constants.js"; -import { resolveHomeRelativePath, resolveRequiredHomeDir } from "../infra/home-dir.js"; +import { resolveHomeRelativePath } from "../infra/home-dir.js"; import { resolveCliArgvInvocation } from "./argv-invocation.js"; -import { isValidProfileName } from "./profile-utils.js"; +import { isValidProfileName, resolveProfileStateDir } from "./profile-utils.js"; import { scanCliRootOptions } from "./root-option-scan.js"; import { takeCliRootOptionValue } from "./root-option-value.js"; @@ -75,15 +72,6 @@ export function parseCliProfileArgs(argv: string[]): CliProfileParseResult { return { ok: true, profile, argv: scanned.argv }; } -function resolveProfileStateDir( - profile: string, - env: Record, - homedir: () => string, -): string { - const suffix = normalizeLowercaseStringOrEmpty(profile) === "default" ? "" : `-${profile}`; - return path.join(resolveRequiredHomeDir(env as NodeJS.ProcessEnv, homedir), `.openclaw${suffix}`); -} - export function applyCliProfileEnv(params: { profile: string; env?: Record; @@ -99,8 +87,9 @@ export function applyCliProfileEnv(params: { const inheritedProfile = normalizeOptionalString(env.OPENCLAW_PROFILE) ?? "default"; const existingStateDir = normalizeOptionalString(env.OPENCLAW_STATE_DIR); const existingConfigPath = normalizeOptionalString(env.OPENCLAW_CONFIG_PATH); - const inheritedProfileStateDir = resolveProfileStateDir(inheritedProfile, env, homedir); - const selectedProfileStateDir = resolveProfileStateDir(profile, env, homedir); + const profileEnv = env as NodeJS.ProcessEnv; + const inheritedProfileStateDir = resolveProfileStateDir(inheritedProfile, profileEnv, homedir); + const selectedProfileStateDir = resolveProfileStateDir(profile, profileEnv, homedir); const switchesInheritedProfile = inheritedProfileStateDir !== selectedProfileStateDir; const switchesInheritedProfileState = Boolean( existingStateDir && diff --git a/src/commands/doctor-state-migrations.test.ts b/src/commands/doctor-state-migrations.test.ts index 3667e950eff8..08b7242a8885 100644 --- a/src/commands/doctor-state-migrations.test.ts +++ b/src/commands/doctor-state-migrations.test.ts @@ -796,6 +796,33 @@ async function runAutoMigrateLegacyStateWithLog(params: { return { result, log }; } +function getProfileWorkspaceMigrationPaths(root: string, profile = "work") { + return { + legacyDir: path.join(root, ".openclaw", `workspace-${profile}`), + targetDir: path.join(root, `.openclaw-${profile}`, "workspace"), + stateDir: path.join(root, `.openclaw-${profile}`), + }; +} + +async function runProfileWorkspaceDoctorMigration(root: string, profile = "work") { + const paths = getProfileWorkspaceMigrationPaths(root, profile); + fs.mkdirSync(paths.stateDir, { recursive: true }); + const log = { info: vi.fn(), warn: vi.fn() }; + const result = await autoMigrateLegacyState({ + cfg: {}, + env: { + HOME: root, + OPENCLAW_HOME: root, + OPENCLAW_PROFILE: profile, + OPENCLAW_STATE_DIR: paths.stateDir, + } as NodeJS.ProcessEnv, + homedir: () => root, + log, + doctorOnlyStateMigrations: true, + }); + return { log, paths, result }; +} + function expectTargetAlreadyExistsWarning(result: StateDirMigrationResult, targetDir: string) { expect(result.migrated).toBe(false); expect(result.warnings).toEqual([ @@ -4156,6 +4183,51 @@ describe("doctor legacy state migrations", () => { expect(store["agent:main:main"]?.sessionId).toBe("legacy"); }); + it("moves the active profile's legacy workspace into its state root", async () => { + const root = makeDoctorStateDir(); + const paths = getProfileWorkspaceMigrationPaths(root); + fs.mkdirSync(paths.legacyDir, { recursive: true }); + fs.writeFileSync(path.join(paths.legacyDir, "AGENTS.md"), "profile workspace", "utf8"); + + const { log, result } = await runProfileWorkspaceDoctorMigration(root); + + expect(fs.existsSync(paths.legacyDir)).toBe(false); + expect(fs.readFileSync(path.join(paths.targetDir, "AGENTS.md"), "utf8")).toBe( + "profile workspace", + ); + expect(result.changes).toContain(`Profile workspace: ${paths.legacyDir} → ${paths.targetDir}`); + expect(log.info).toHaveBeenCalledWith(expect.stringContaining(paths.targetDir)); + }); + + it("keeps both profile workspaces when the canonical target already exists", async () => { + const root = makeDoctorStateDir(); + const paths = getProfileWorkspaceMigrationPaths(root); + fs.mkdirSync(paths.legacyDir, { recursive: true }); + fs.mkdirSync(paths.targetDir, { recursive: true }); + fs.writeFileSync(path.join(paths.legacyDir, "legacy.txt"), "legacy", "utf8"); + fs.writeFileSync(path.join(paths.targetDir, "current.txt"), "current", "utf8"); + + const { log, result } = await runProfileWorkspaceDoctorMigration(root); + + const warning = `Profile workspace migration skipped: target already exists (${paths.targetDir}). Kept legacy workspace at ${paths.legacyDir}; merge manually.`; + expect(result.warnings).toContain(warning); + expect(log.warn).toHaveBeenCalledWith(expect.stringContaining(warning)); + expect(fs.readFileSync(path.join(paths.legacyDir, "legacy.txt"), "utf8")).toBe("legacy"); + expect(fs.readFileSync(path.join(paths.targetDir, "current.txt"), "utf8")).toBe("current"); + }); + + it("does nothing when the active profile has no legacy workspace", async () => { + const root = makeDoctorStateDir(); + + const { log, paths, result } = await runProfileWorkspaceDoctorMigration(root); + + expect(fs.existsSync(paths.legacyDir)).toBe(false); + expect(fs.existsSync(paths.targetDir)).toBe(false); + expect(result.changes.some((entry) => entry.startsWith("Profile workspace:"))).toBe(false); + expect(result.warnings.some((entry) => entry.includes("Profile workspace"))).toBe(false); + expect(log.warn).not.toHaveBeenCalledWith(expect.stringContaining("Profile workspace")); + }); + it("does nothing when no legacy state dir exists", async () => { const root = makeDoctorStateDir(); const result = await runStateDirMigration(root); diff --git a/src/config/paths.ts b/src/config/paths.ts index c7060f4baf2d..f7ffea7d893f 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { isValidProfileName } from "../cli/profile-utils.js"; +import { resolveProfileStateDir } from "../cli/profile-utils.js"; import { resolveGatewayNativeServiceIdentityConflict } from "../daemon/constants.js"; import { resolveHomeRelativePath, resolveRequiredHomeDir } from "../infra/home-dir.js"; import { parseTcpPort } from "../infra/tcp-port.js"; @@ -126,18 +126,6 @@ export function isDefaultStateDir( ); } -/** Canonical state directory name for the selected profile, mirroring root `--profile`. */ -function profileStateDirName(env: NodeJS.ProcessEnv): string | null { - const profile = env.OPENCLAW_PROFILE?.trim(); - if (!profile || profile.toLowerCase() === "default") { - return NEW_STATE_DIRNAME; - } - if (!isValidProfileName(profile)) { - return null; - } - return `${NEW_STATE_DIRNAME}-${profile}`; -} - export function resolveNativeServiceProfileConflict( env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform, @@ -187,13 +175,14 @@ export function isDefaultInstallIdentity( ) { return false; } - const stateDirName = profileStateDirName(env); - // Environment profiles can bypass root CLI parsing. Reject them before path - // construction so separators or dot segments cannot authorize a host service. - if (!stateDirName) { + let canonicalStateDir: string; + try { + canonicalStateDir = resolveProfileStateDir(env.OPENCLAW_PROFILE ?? "default", env, homedir); + } catch { + // Environment profiles can bypass root CLI parsing. Reject invalid names + // before path construction so separators cannot authorize a host service. return false; } - const canonicalStateDir = path.join(accountHome, stateDirName); if ( normalizePathForComparison(resolveStateDir(env, envHomedir(env))) !== normalizePathForComparison(canonicalStateDir) @@ -202,7 +191,7 @@ export function isDefaultInstallIdentity( } // Default installs historically allow implicit legacy config discovery. // Named profiles must resolve their own config so they cannot inherit the default profile. - if (stateDirName === NEW_STATE_DIRNAME && !env.OPENCLAW_CONFIG_PATH?.trim()) { + if (!isNamedProfile(env) && !env.OPENCLAW_CONFIG_PATH?.trim()) { return true; } return ( diff --git a/src/infra/state-migrations.doctor.ts b/src/infra/state-migrations.doctor.ts index 8a08cde36c0c..c537ab2bde6f 100644 --- a/src/infra/state-migrations.doctor.ts +++ b/src/infra/state-migrations.doctor.ts @@ -134,6 +134,7 @@ import { } from "./state-migrations.session-store.js"; import { autoMigrateLegacyStateDir, + migrateLegacyProfileWorkspace, resetAutoMigrateLegacyTaskStateSidecarsForTest, } from "./state-migrations.state-dir.js"; import { @@ -1335,6 +1336,10 @@ export async function autoMigrateLegacyState(params: { ...(stateDirResult.notices?.length ? { notices: stateDirResult.notices } : {}), }; } + const profileWorkspace = + params.doctorOnlyStateMigrations === true + ? migrateLegacyProfileWorkspace({ env, homedir }) + : { changes: [], warnings: [] }; const pluginDoctorConfig = params.pluginDoctorConfig ?? params.cfg; const configMachineState = migrateLegacyConfigMachineState({ config: pluginDoctorConfig, @@ -1431,6 +1436,7 @@ export async function autoMigrateLegacyState(params: { }); const initialMigrationSources = [ stateDirResult, + profileWorkspace, stateSchema, mediaPersistence, configMachineState, diff --git a/src/infra/state-migrations.state-dir.ts b/src/infra/state-migrations.state-dir.ts index 1581c695258c..39eced5d7447 100644 --- a/src/infra/state-migrations.state-dir.ts +++ b/src/infra/state-migrations.state-dir.ts @@ -1,6 +1,8 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +import { resolveProfileStateDir } from "../cli/profile-utils.js"; import { resolveLegacyStateDirs, resolveNewStateDir, resolveStateDir } from "../config/paths.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { isWithinDir } from "./path-safety.js"; @@ -30,6 +32,65 @@ type StateDirMigrationResult = { notices?: string[]; }; +function lstatIfPresent(filePath: string): fs.Stats | null { + try { + return fs.lstatSync(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return null; + } + throw error; + } +} + +export function migrateLegacyProfileWorkspace(params: { + env?: NodeJS.ProcessEnv; + homedir?: () => string; +}): { changes: string[]; warnings: string[] } { + const env = params.env ?? process.env; + const homedir = params.homedir ?? os.homedir; + const profile = env.OPENCLAW_PROFILE?.trim(); + if (!profile || normalizeLowercaseStringOrEmpty(profile) === "default") { + return { changes: [], warnings: [] }; + } + + try { + const legacyDir = path.join( + resolveProfileStateDir("default", env, homedir), + `workspace-${profile}`, + ); + const targetDir = path.join(resolveProfileStateDir(profile, env, homedir), "workspace"); + const legacyStat = lstatIfPresent(legacyDir); + if (!legacyStat) { + return { changes: [], warnings: [] }; + } + if (!legacyStat.isDirectory() && !legacyStat.isSymbolicLink()) { + return { + changes: [], + warnings: [ + `Profile workspace migration skipped: legacy path is not a directory (${legacyDir}).`, + ], + }; + } + if (lstatIfPresent(targetDir)) { + return { + changes: [], + warnings: [ + `Profile workspace migration skipped: target already exists (${targetDir}). Kept legacy workspace at ${legacyDir}; merge manually.`, + ], + }; + } + fs.mkdirSync(path.dirname(targetDir), { recursive: true }); + fs.renameSync(legacyDir, targetDir); + return { changes: [`Profile workspace: ${legacyDir} → ${targetDir}`], warnings: [] }; + } catch (error) { + return { + changes: [], + warnings: [`Profile workspace migration failed: ${String(error)}`], + }; + } +} + function resolveSymlinkTarget(linkPath: string): string | null { try { const target = fs.readlinkSync(linkPath);