mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 04:47:03 -06:00
fix(agents): root profile workspaces in profile state (#122733)
This commit is contained in:
committed by
GitHub
parent
6e880b5107
commit
4fe4f34a9e
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user