fix(agents): root profile workspaces in profile state (#122733)

This commit is contained in:
Peter Steinberger
2026-08-12 10:31:04 -07:00
committed by GitHub
parent 6e880b5107
commit 4fe4f34a9e
10 changed files with 205 additions and 38 deletions
@@ -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 = {
@@ -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");
}
+2 -1
View File
@@ -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");
}
+20
View File
@@ -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);
+16
View File
@@ -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}`);
}
+6 -17
View File
@@ -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<string, string | undefined>,
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<string, string | undefined>;
@@ -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 &&
@@ -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);
+8 -19
View File
@@ -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 (
+6
View File
@@ -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,
+61
View File
@@ -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);