fix(migrate): harden importer path resolution and memory copy safety (#109314)

This commit is contained in:
Peter Steinberger
2026-07-16 12:56:43 -07:00
committed by GitHub
parent ce919593bf
commit 54eb03fcf0
12 changed files with 426 additions and 53 deletions
+34 -9
View File
@@ -7,9 +7,25 @@ import {
canonicalPathFromExistingAncestor,
isPathInside,
} from "openclaw/plugin-sdk/security-runtime";
import { exists } from "./helpers.js";
import type { CodexMemorySource } from "./source-files.js";
const MIGRATION_REASON_TARGET_NOT_REGULAR = "target is not a regular file";
async function lstatIfExists(filePath: string) {
try {
return await fs.lstat(filePath);
} catch (error) {
const code =
error && typeof error === "object" && "code" in error
? String((error as { code?: unknown }).code)
: undefined;
if (code === "ENOENT" || code === "ENOTDIR") {
return undefined;
}
throw error;
}
}
async function assertSafeMemoryDestination(params: {
source: string;
workspaceDir: string;
@@ -45,12 +61,17 @@ export async function buildCodexMemoryItems(params: {
"codex",
path.basename(memory.path),
);
await assertSafeMemoryDestination({
source: memory.path,
workspaceDir: params.workspaceDir,
target,
});
const targetExists = await exists(target);
const targetStat = await lstatIfExists(target);
const targetExists = targetStat !== undefined;
const targetNotRegular = targetExists && !targetStat.isFile();
if (!targetNotRegular) {
await assertSafeMemoryDestination({
source: memory.path,
workspaceDir: params.workspaceDir,
target,
});
}
const targetConflict = targetNotRegular || (targetExists && !params.overwrite);
items.push(
createMigrationItem({
id: memory.id,
@@ -58,8 +79,12 @@ export async function buildCodexMemoryItems(params: {
action: "copy",
source: memory.path,
target,
status: targetExists && !params.overwrite ? "conflict" : "planned",
reason: targetExists && !params.overwrite ? MIGRATION_REASON_TARGET_EXISTS : undefined,
status: targetConflict ? "conflict" : "planned",
reason: targetNotRegular
? MIGRATION_REASON_TARGET_NOT_REGULAR
: targetConflict
? MIGRATION_REASON_TARGET_EXISTS
: undefined,
message: "Copy consolidated Codex memory into the OpenClaw memory index.",
details: {
sourceType: "codex-memory",
@@ -14,6 +14,7 @@ import { CODEX_PLUGINS_MARKETPLACE_NAME } from "../app-server/config.js";
import { buildCodexPluginAppCacheKey } from "../app-server/plugin-app-cache-key.js";
import type { CodexGetAccountResponse, v2 } from "../app-server/protocol.js";
import { buildCodexMigrationProvider } from "./provider.js";
import { discoverCodexSource } from "./source.js";
const appServerRequest = vi.hoisted(() => vi.fn());
@@ -200,6 +201,20 @@ describe("buildCodexMigrationProvider", () => {
appServerRequest.mockRejectedValue(new Error("codex app-server unavailable"));
});
it("preserves whitespace in nonempty CODEX_HOME values", async () => {
const root = await makeTempRoot();
const codexHome = path.join(root, " spaced ");
await writeFile(path.join(codexHome, "memories", "MEMORY.md"), "# Memory\n");
vi.stubEnv("CODEX_HOME", codexHome);
const source = await discoverCodexSource({ memoryOnly: true });
expect(source.codexHome).toBe(codexHome);
expect(source.memoryFiles.map((entry) => entry.path)).toEqual([
path.join(codexHome, "memories", "MEMORY.md"),
]);
});
it("plans and imports only consolidated Codex memory into the selected agent", async () => {
const fixture = await createCodexFixture();
const targetWorkspace = path.join(fixture.root, "workspace-research");
@@ -333,6 +348,33 @@ describe("buildCodexMigrationProvider", () => {
},
);
it.runIf(process.platform !== "win32")(
"marks a dangling Codex memory destination symlink as a conflict",
async () => {
const fixture = await createCodexFixture();
const target = path.join(fixture.workspaceDir, "memory", "imports", "codex", "MEMORY.md");
await writeFile(path.join(fixture.codexHome, "memories", "MEMORY.md"), "# Memory\n");
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.symlink(path.join(fixture.root, "missing-memory.md"), target);
const provider = buildCodexMigrationProvider();
const plan = await provider.plan(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
itemKinds: ["memory"],
overwrite: true,
}),
);
expect(findItem(plan.items, "memory:codex:MEMORY.md")).toMatchObject({
status: "conflict",
reason: "target is not a regular file",
});
},
);
it("plans Codex skills while keeping plugins and native config explicit", async () => {
const fixture = await createCodexFixture();
const provider = buildCodexMigrationProvider();
+5 -1
View File
@@ -74,7 +74,11 @@ type PluginReadResult =
};
function defaultCodexHome(): string {
return resolveHomePath(process.env.CODEX_HOME?.trim() || "~/.codex");
const configuredHome = process.env.CODEX_HOME;
// Codex preserves nonempty CODEX_HOME verbatim; --from remains trimmed below as CLI convenience.
return resolveHomePath(
configuredHome !== undefined && configuredHome.length > 0 ? configuredHome : "~/.codex",
);
}
function personalAgentsSkillsDir(): string {
+39 -9
View File
@@ -8,7 +8,6 @@ import {
canonicalPathFromExistingAncestor,
isPathInside,
} from "openclaw/plugin-sdk/security-runtime";
import { exists } from "./helpers.js";
import {
CLAUDE_AUTO_MEMORY_MAX_FILES,
CLAUDE_AUTO_MEMORY_MAX_SCAN_ENTRIES,
@@ -16,6 +15,23 @@ import {
} from "./source.js";
import type { PlannedTargets } from "./targets.js";
const MIGRATION_REASON_TARGET_NOT_REGULAR = "target is not a regular file";
async function lstatIfExists(filePath: string) {
try {
return await fs.lstat(filePath);
} catch (error) {
const code =
error && typeof error === "object" && "code" in error
? String((error as { code?: unknown }).code)
: undefined;
if (code === "ENOENT" || code === "ENOTDIR") {
return undefined;
}
throw error;
}
}
async function addMemoryItem(params: {
items: MigrationItem[];
id: string;
@@ -28,8 +44,12 @@ async function addMemoryItem(params: {
if (!params.source) {
return;
}
const targetExists = await exists(params.target);
const targetStat = await lstatIfExists(params.target);
const targetExists = targetStat !== undefined;
const targetNotRegular = targetExists && !targetStat.isFile();
const action = params.copyWhenMissing && !targetExists ? "copy" : "append";
const targetConflict =
targetNotRegular || (action === "copy" && targetExists && !params.overwrite);
params.items.push(
createMigrationItem({
id: params.id,
@@ -39,9 +59,10 @@ async function addMemoryItem(params: {
action,
source: params.source,
target: params.target,
status: action === "copy" && targetExists && !params.overwrite ? "conflict" : "planned",
reason:
action === "copy" && targetExists && !params.overwrite
status: targetConflict ? "conflict" : "planned",
reason: targetNotRegular
? MIGRATION_REASON_TARGET_NOT_REGULAR
: targetConflict
? MIGRATION_REASON_TARGET_EXISTS
: undefined,
details: { sourceLabel: params.sourceLabel },
@@ -174,8 +195,13 @@ async function buildAutoMemoryItems(params: {
for (const relativePath of files) {
const source = path.join(collection.path, relativePath);
const target = path.join(targetRoot, relativePath);
await assertSafeMemoryDestination(destinationBoundary, target);
const targetExists = await exists(target);
const targetStat = await lstatIfExists(target);
const targetExists = targetStat !== undefined;
const targetNotRegular = targetExists && !targetStat.isFile();
if (!targetNotRegular) {
await assertSafeMemoryDestination(destinationBoundary, target);
}
const targetConflict = targetNotRegular || (targetExists && !params.overwrite);
items.push(
createMigrationItem({
id: `memory:claude-auto:${collection.id}:${relativePath.replaceAll(path.sep, "/")}`,
@@ -183,8 +209,12 @@ async function buildAutoMemoryItems(params: {
action: "copy",
source,
target,
status: targetExists && !params.overwrite ? "conflict" : "planned",
reason: targetExists && !params.overwrite ? MIGRATION_REASON_TARGET_EXISTS : undefined,
status: targetConflict ? "conflict" : "planned",
reason: targetNotRegular
? MIGRATION_REASON_TARGET_NOT_REGULAR
: targetConflict
? MIGRATION_REASON_TARGET_EXISTS
: undefined,
message: "Copy Claude Code auto-memory Markdown into the OpenClaw memory index.",
details: {
sourceType: "claude-auto-memory",
+117 -2
View File
@@ -3,11 +3,11 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { redactMigrationPlan } from "openclaw/plugin-sdk/migration";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveHomePath } from "./helpers.js";
import { buildMemoryItems } from "./memory.js";
import { buildClaudeMigrationProvider } from "./provider.js";
import { CLAUDE_AUTO_MEMORY_MAX_FILES, type ClaudeSource } from "./source.js";
import { CLAUDE_AUTO_MEMORY_MAX_FILES, type ClaudeSource, discoverClaudeSource } from "./source.js";
import {
cleanupTempRoots,
makeConfigRuntime,
@@ -29,6 +29,7 @@ function planItemById(
describe("Claude migration provider", () => {
afterEach(async () => {
vi.unstubAllEnvs();
await cleanupTempRoots();
});
@@ -140,6 +141,32 @@ describe("Claude migration provider", () => {
expect(plan.items[0]?.source).toBe(path.join(customMemory, "MEMORY.md"));
});
it("honors CLAUDE_CONFIG_DIR for a relocated Claude home", async () => {
const root = await makeTempRoot();
const relocatedHome = path.join(root, "relocated-claude");
const memoryDir = path.join(relocatedHome, "projects", "-tmp-project", "memory");
await writeFile(path.join(memoryDir, "MEMORY.md"), "# Relocated memory\n");
vi.stubEnv("CLAUDE_CONFIG_DIR", relocatedHome);
const source = await discoverClaudeSource();
expect(source.root).toBe(relocatedHome);
expect(source.homeDir).toBe(relocatedHome);
expect(source.autoMemorySources.map((entry) => entry.path)).toEqual([memoryDir]);
});
it("treats an explicit repo root with a top-level projects/ dir as a project, not a home", async () => {
const root = await makeTempRoot();
const projectRoot = path.join(root, "my-monorepo");
await writeFile(path.join(projectRoot, "projects", "svc-a", "readme.md"), "# svc\n");
await writeFile(path.join(projectRoot, "settings.json"), "{}\n");
const source = await discoverClaudeSource(projectRoot);
expect(source.projectDir).toBe(projectRoot);
expect(source.homeDir).toBeUndefined();
});
it.runIf(process.platform !== "win32")(
"reports an unreadable configured Claude Code auto-memory directory",
async () => {
@@ -171,6 +198,38 @@ describe("Claude migration provider", () => {
},
);
it.runIf(process.platform !== "win32" && process.getuid?.() !== 0)(
"reports an inaccessible configured Claude Code auto-memory directory",
async () => {
const root = await makeTempRoot();
const source = path.join(root, ".claude");
const lockedParent = path.join(root, "locked-parent");
const customMemory = path.join(lockedParent, "custom-memory");
await writeFile(
path.join(source, "settings.json"),
JSON.stringify({ autoMemoryDirectory: customMemory }),
);
await writeFile(path.join(customMemory, "MEMORY.md"), "# Custom memory\n");
await fs.chmod(lockedParent, 0o000);
const provider = buildClaudeMigrationProvider();
try {
await expect(
provider.plan(
makeContext({
source,
stateDir: path.join(root, "state"),
workspaceDir: path.join(root, "workspace"),
itemKinds: ["memory"],
}),
),
).rejects.toThrow(customMemory);
} finally {
await fs.chmod(lockedParent, 0o700);
}
},
);
it.runIf(process.platform !== "win32")(
"reports an unreadable standard Claude Code projects directory",
async () => {
@@ -219,6 +278,27 @@ describe("Claude migration provider", () => {
).rejects.toThrow("autoMemoryDirectory must be absolute or start with ~/");
});
it('rejects bare "~" as a Claude Code auto-memory directory', async () => {
const root = await makeTempRoot();
const source = path.join(root, ".claude");
await writeFile(
path.join(source, "settings.json"),
JSON.stringify({ autoMemoryDirectory: "~" }),
);
const provider = buildClaudeMigrationProvider();
await expect(
provider.plan(
makeContext({
source,
stateDir: path.join(root, "state"),
workspaceDir: path.join(root, "workspace"),
itemKinds: ["memory"],
}),
),
).rejects.toThrow("autoMemoryDirectory must be absolute or start with ~/");
});
it("rejects Claude Code auto-memory that contains the import destination", async () => {
const root = await makeTempRoot();
const source = path.join(root, ".claude");
@@ -268,6 +348,41 @@ describe("Claude migration provider", () => {
},
);
it.runIf(process.platform !== "win32")(
"marks a dangling Claude Code memory destination symlink as a conflict",
async () => {
const root = await makeTempRoot();
const source = path.join(root, ".claude");
const workspaceDir = path.join(root, "workspace");
await writeFile(
path.join(source, "projects", "-tmp-linked", "memory", "MEMORY.md"),
"# Source memory\n",
);
const provider = buildClaudeMigrationProvider();
const context = makeContext({
source,
stateDir: path.join(root, "state"),
workspaceDir,
itemKinds: ["memory"],
overwrite: true,
});
const initial = await provider.plan(context);
const target = initial.items[0]?.target;
if (!target) {
throw new Error("expected planned Claude memory target");
}
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.symlink(path.join(root, "missing-memory.md"), target);
const plan = await provider.plan(context);
expect(plan.items[0]).toMatchObject({
status: "conflict",
reason: "target is not a regular file",
});
},
);
it("fails planning when a discovered Claude Code memory directory cannot be read", async () => {
const root = await makeTempRoot();
const missingMemory = path.join(root, "missing-memory");
+39 -10
View File
@@ -53,7 +53,10 @@ const HOME_ARCHIVE_DIRS = ["projects", "cache", "plans"] as const;
const PROJECT_ARCHIVE_FILES = [".claude/scheduled_tasks.json"] as const;
function defaultClaudeHome(): string {
return path.join(os.homedir(), ".claude");
// Preserve a nonempty CLAUDE_CONFIG_DIR verbatim (only an empty value is
// unset); trimming would change valid paths whose bytes include spaces.
const configuredDir = process.env.CLAUDE_CONFIG_DIR;
return configuredDir ? resolveHomePath(configuredDir) : path.join(os.homedir(), ".claude");
}
function defaultDesktopConfig(): string {
@@ -102,6 +105,23 @@ async function readMemoryDir(dir: string): Promise<Dirent[]> {
}
}
async function isConfiguredAutoMemoryDirectory(dir: string): Promise<boolean> {
try {
return (await fs.stat(dir)).isDirectory();
} catch (error) {
const code =
error && typeof error === "object" && "code" in error
? String((error as { code?: unknown }).code)
: undefined;
if (code === "ENOENT" || code === "ENOTDIR") {
return false;
}
throw new Error(`Unable to access configured Claude Code auto-memory directory: ${dir}`, {
cause: error,
});
}
}
async function probeMarkdownFiles(root: string): Promise<"found" | "absent" | "truncated"> {
const pending = [root];
let visited = 0;
@@ -135,7 +155,7 @@ async function discoverAutoMemorySources(params: {
homeProjectsDir?: string;
userSettingsPath?: string;
}): Promise<ClaudeAutoMemorySource[]> {
const candidates: Array<{ label: string; path: string }> = [];
const candidates: Array<{ configured?: boolean; label: string; path: string }> = [];
if (params.homeProjectsDir) {
for (const entry of await safeReadDir(params.homeProjectsDir)) {
if (!entry.isDirectory()) {
@@ -151,15 +171,16 @@ async function discoverAutoMemorySources(params: {
const customDirectory = userSettings.autoMemoryDirectory;
if (typeof customDirectory === "string" && customDirectory.trim()) {
const configuredPath = customDirectory.trim();
if (
!path.isAbsolute(configuredPath) &&
configuredPath !== "~" &&
!configuredPath.startsWith("~/")
) {
// Bare ~ would select the whole home tree; Claude only permits absolute or ~/-prefixed paths.
if (!path.isAbsolute(configuredPath) && !configuredPath.startsWith("~/")) {
throw new Error("Claude autoMemoryDirectory must be absolute or start with ~/.");
}
const customPath = resolveHomePath(configuredPath);
candidates.push({ label: path.basename(customPath) || "custom", path: customPath });
candidates.push({
configured: true,
label: path.basename(customPath) || "custom",
path: customPath,
});
}
if (path.basename(params.root) === "memory") {
candidates.push({
@@ -171,7 +192,10 @@ async function discoverAutoMemorySources(params: {
const seen = new Set<string>();
const sources: ClaudeAutoMemorySource[] = [];
for (const candidate of candidates) {
if (!(await isDirectory(candidate.path))) {
const directoryExists = candidate.configured
? await isConfiguredAutoMemoryDirectory(candidate.path)
: await isDirectory(candidate.path);
if (!directoryExists) {
continue;
}
// A capped discovery probe must remain conservative: planning performs the
@@ -196,7 +220,12 @@ async function discoverAutoMemorySources(params: {
export async function discoverClaudeSource(input?: string): Promise<ClaudeSource> {
const explicitInput = Boolean(input?.trim());
const root = resolveHomePath(input?.trim() || defaultClaudeHome());
const rootIsHome = path.basename(root) === ".claude";
// Home detection stays on unambiguous signals only: the `.claude` basename or
// the resolved default (which honors CLAUDE_CONFIG_DIR). An explicit `--from`
// is treated as a project root otherwise — inferring a relocated home from
// generic markers like `projects/` or `settings.json` misreads ordinary repos.
const rootIsHome =
path.basename(root) === ".claude" || (!explicitInput && root === defaultClaudeHome());
const inspectGlobal = !explicitInput || rootIsHome;
const homeDir = inspectGlobal ? (rootIsHome ? root : defaultClaudeHome()) : undefined;
const projectDir = rootIsHome ? undefined : root;
@@ -87,7 +87,41 @@ describe("Hermes migration file and skill items", () => {
);
});
it("honors explicit Hermes home, active profiles, and Windows legacy state", async () => {
it("resolves HERMES_HOME through active_profile unless it already names a profile", async () => {
const root = await makeTempRoot();
const hermesRoot = path.join(root, "hermes");
const profileRoot = path.join(hermesRoot, "profiles", "coder");
await writeFile(path.join(hermesRoot, "active_profile"), "coder\n");
await writeFile(path.join(profileRoot, "memories", "MEMORY.md"), "coder memory\n");
expect(
(
await discoverHermesSource(undefined, {
env: { HERMES_HOME: hermesRoot },
platform: "darwin",
})
).root,
).toBe(profileRoot);
expect(
(
await discoverHermesSource(undefined, {
env: { HERMES_HOME: profileRoot },
platform: "darwin",
})
).root,
).toBe(profileRoot);
// Supervised default slot pins to the root profile, ignoring active_profile.
expect(
(
await discoverHermesSource(undefined, {
env: { HERMES_HOME: hermesRoot, HERMES_S6_SUPERVISED_CHILD: "1" },
platform: "darwin",
})
).root,
).toBe(hermesRoot);
});
it("honors implicit Hermes active profiles and Windows legacy state", async () => {
const root = await makeTempRoot();
const home = path.join(root, "home");
const defaultRoot = path.join(home, ".hermes");
@@ -95,14 +129,6 @@ describe("Hermes migration file and skill items", () => {
await writeFile(path.join(defaultRoot, "active_profile"), "coder\n");
await writeFile(path.join(profileRoot, "config.yaml"), "model: openai/gpt-5.6\n");
expect(
(
await discoverHermesSource(undefined, {
env: { HERMES_HOME: defaultRoot },
platform: "darwin",
})
).root,
).toBe(defaultRoot);
expect(
(await discoverHermesSource(undefined, { env: { HOME: home }, platform: "darwin" })).root,
).toBe(profileRoot);
+28 -4
View File
@@ -1,4 +1,5 @@
// Migrate Hermes plugin module implements memory-only import planning.
import fs from "node:fs/promises";
import path from "node:path";
import {
createMigrationItem,
@@ -10,10 +11,26 @@ import type {
MigrationPlan,
MigrationProviderContext,
} from "openclaw/plugin-sdk/plugin-entry";
import { exists } from "./helpers.js";
import type { HermesSource } from "./source.js";
import { resolveTargets } from "./targets.js";
const MIGRATION_REASON_TARGET_NOT_REGULAR = "target is not a regular file";
async function lstatIfExists(filePath: string) {
try {
return await fs.lstat(filePath);
} catch (error) {
const code =
error && typeof error === "object" && "code" in error
? String((error as { code?: unknown }).code)
: undefined;
if (code === "ENOENT" || code === "ENOTDIR") {
return undefined;
}
throw error;
}
}
export function isMemoryOnlyMigration(ctx: MigrationProviderContext): boolean {
return Boolean(
ctx.itemKinds && ctx.itemKinds.length > 0 && ctx.itemKinds.every((kind) => kind === "memory"),
@@ -31,15 +48,22 @@ async function buildMemoryItem(params: {
if (!params.source) {
return undefined;
}
const targetExists = await exists(params.target);
const targetStat = await lstatIfExists(params.target);
const targetExists = targetStat !== undefined;
const targetNotRegular = targetExists && !targetStat.isFile();
const targetConflict = targetNotRegular || (targetExists && !params.overwrite);
return createMigrationItem({
id: params.id,
kind: "memory",
action: "copy",
source: params.source,
target: params.target,
status: targetExists && !params.overwrite ? "conflict" : "planned",
reason: targetExists && !params.overwrite ? MIGRATION_REASON_TARGET_EXISTS : undefined,
status: targetConflict ? "conflict" : "planned",
reason: targetNotRegular
? MIGRATION_REASON_TARGET_NOT_REGULAR
: targetConflict
? MIGRATION_REASON_TARGET_EXISTS
: undefined,
message: "Copy Hermes memory into the OpenClaw memory index.",
details: {
sourceType: "hermes-memory",
@@ -202,6 +202,35 @@ describe("Hermes migration provider", () => {
});
});
it.runIf(process.platform !== "win32")(
"marks a dangling Hermes memory destination symlink as a conflict",
async () => {
const root = await makeTempRoot();
const source = path.join(root, "hermes");
const workspaceDir = path.join(root, "workspace");
const target = path.join(workspaceDir, "memory", "imports", "hermes", "MEMORY.md");
await writeFile(path.join(source, "memories", "MEMORY.md"), "remember this\n");
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.symlink(path.join(root, "missing-memory.md"), target);
const provider = buildHermesMigrationProvider();
const plan = await provider.plan(
makeContext({
source,
stateDir: path.join(root, "state"),
workspaceDir,
itemKinds: ["memory"],
overwrite: true,
}),
);
expect(itemById(plan.items, "memory:MEMORY.md")).toMatchObject({
status: "conflict",
reason: "target is not a regular file",
});
},
);
it("copies memory bytes through the memory migration runtime", async () => {
const root = await makeTempRoot();
const source = path.join(root, "hermes");
+17 -1
View File
@@ -183,9 +183,21 @@ async function resolveImplicitHermesRoot(
env: NodeJS.ProcessEnv,
platform: NodeJS.Platform,
): Promise<string> {
// Hermes pins the reserved supervised default slot to the root profile and
// never follows active_profile there (hermes_cli/main.py:487). Mirror that so
// a migration launched from that process keeps the default profile.
const supervisedChild = Boolean(env.HERMES_S6_SUPERVISED_CHILD?.trim());
const configuredHome = env.HERMES_HOME?.trim();
if (configuredHome) {
return resolveHomePath(configuredHome);
const configuredRoot = resolveHomePath(configuredHome);
// Mirror Hermes itself (hermes-agent hermes_cli/main.py:461-473, issue #22502):
// trust HERMES_HOME verbatim only when it already names a profile dir
// (parent basename `profiles`); when it names the root (e.g. a hardcoded
// HERMES_HOME=~/.hermes) still honor active_profile.
if (supervisedChild || path.basename(path.dirname(configuredRoot)) === "profiles") {
return configuredRoot;
}
return await resolveActiveHermesProfile(configuredRoot);
}
const userHome =
(platform === "win32" ? env.USERPROFILE?.trim() : env.HOME?.trim()) || resolveHomePath("~");
@@ -203,6 +215,10 @@ async function resolveImplicitHermesRoot(
} else {
root = path.resolve(userHome, ".hermes");
}
return supervisedChild ? root : await resolveActiveHermesProfile(root);
}
async function resolveActiveHermesProfile(root: string): Promise<string> {
const activeProfile = (await readText(path.join(root, "active_profile")))?.trim();
if (!activeProfile || activeProfile === "default" || !HERMES_PROFILE_RE.test(activeProfile)) {
return root;
+31
View File
@@ -204,6 +204,37 @@ describe("copyMemoryMigrationFileItem", () => {
await expect(fs.access(target)).rejects.toThrow();
});
it.runIf(process.platform !== "win32")(
"rejects a hardlinked memory source without creating the destination",
async () => {
const root = await fs.realpath(tempDirs.make("openclaw-memory-copy-"));
const workspaceDir = path.join(root, "workspace");
const outside = path.join(root, "outside", "outside.md");
const source = path.join(root, "source", "MEMORY.md");
const target = path.join(workspaceDir, "memory", "imports", "codex", "MEMORY.md");
await writeFile(outside, "outside bytes");
await fs.mkdir(path.dirname(source), { recursive: true });
await fs.link(outside, source);
expect((await fs.stat(source)).nlink).toBeGreaterThan(1);
const result = await copyMemoryMigrationFileItem(
createMigrationItem({
id: "memory:codex:MEMORY.md",
kind: "memory",
action: "copy",
source,
target,
}),
path.join(root, "report"),
{ workspaceDir },
);
expect(result.status).toBe("error");
expect(result.reason).toContain("hardlink");
await expect(fs.access(target)).rejects.toThrow();
},
);
it("does not read source paths for non-actionable memory items", async () => {
const missingSource = path.join(tempDirs.make("openclaw-memory-copy-"), "missing.md");
const item = createMigrationItem({
+10 -8
View File
@@ -6,12 +6,7 @@ import path from "node:path";
import { writeTextAtomic } from "@openclaw/fs-safe/atomic";
import { resolveAgentConfig } from "../agents/agent-scope-config.js";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import {
ensureAbsoluteDirectory,
pathExists,
readRegularFile,
root as openFsSafeRoot,
} from "../infra/fs-safe.js";
import { ensureAbsoluteDirectory, pathExists, root as openFsSafeRoot } from "../infra/fs-safe.js";
import { resolveHomeRelativePath } from "../infra/home-dir.js";
import type {
MigrationApplyResult,
@@ -343,9 +338,16 @@ export async function copyMemoryMigrationFileItem(
const workspaceDir = path.resolve(opts.workspaceDir);
relativeTarget = path.relative(workspaceDir, path.resolve(item.target));
safeRoot = await openMemoryMigrationRoot(workspaceDir);
const { buffer: sourceBuffer } = await readRegularFile({
filePath: item.source,
// A hardlink inside a source tree can alias sensitive bytes outside that tree.
const sourceRoot = await openFsSafeRoot(path.dirname(item.source), {
hardlinks: "reject",
maxBytes: MAX_MEMORY_MIGRATION_FILE_BYTES,
symlinks: "reject",
});
const { buffer: sourceBuffer } = await sourceRoot.read(path.basename(item.source), {
hardlinks: "reject",
maxBytes: MAX_MEMORY_MIGRATION_FILE_BYTES,
symlinks: "reject",
});
assertMemoryMigrationSourceRevision(item, sourceBuffer);
const replaceExisting = opts.overwrite === true && (await safeRoot.exists(relativeTarget));