diff --git a/extensions/codex/src/migration/memory-plan.ts b/extensions/codex/src/migration/memory-plan.ts index 309fbfbe3a5..4f06e6e51fc 100644 --- a/extensions/codex/src/migration/memory-plan.ts +++ b/extensions/codex/src/migration/memory-plan.ts @@ -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", diff --git a/extensions/codex/src/migration/provider.test.ts b/extensions/codex/src/migration/provider.test.ts index 5198fac9cb4..5e902c68c80 100644 --- a/extensions/codex/src/migration/provider.test.ts +++ b/extensions/codex/src/migration/provider.test.ts @@ -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(); diff --git a/extensions/codex/src/migration/source.ts b/extensions/codex/src/migration/source.ts index ad40f792c2b..32a0985b2bb 100644 --- a/extensions/codex/src/migration/source.ts +++ b/extensions/codex/src/migration/source.ts @@ -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 { diff --git a/extensions/migrate-claude/memory.ts b/extensions/migrate-claude/memory.ts index a836df01c1c..5d5edea64eb 100644 --- a/extensions/migrate-claude/memory.ts +++ b/extensions/migrate-claude/memory.ts @@ -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", diff --git a/extensions/migrate-claude/provider.test.ts b/extensions/migrate-claude/provider.test.ts index 9185fefa4fd..bacd5342455 100644 --- a/extensions/migrate-claude/provider.test.ts +++ b/extensions/migrate-claude/provider.test.ts @@ -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"); diff --git a/extensions/migrate-claude/source.ts b/extensions/migrate-claude/source.ts index 1b9426af21f..8471b4de26a 100644 --- a/extensions/migrate-claude/source.ts +++ b/extensions/migrate-claude/source.ts @@ -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 { } } +async function isConfiguredAutoMemoryDirectory(dir: string): Promise { + 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 { - 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(); 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 { 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; diff --git a/extensions/migrate-hermes/files-and-skills.test.ts b/extensions/migrate-hermes/files-and-skills.test.ts index 535518181fe..d191d273526 100644 --- a/extensions/migrate-hermes/files-and-skills.test.ts +++ b/extensions/migrate-hermes/files-and-skills.test.ts @@ -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); diff --git a/extensions/migrate-hermes/memory.ts b/extensions/migrate-hermes/memory.ts index 696db257c96..cd0e28d4a0e 100644 --- a/extensions/migrate-hermes/memory.ts +++ b/extensions/migrate-hermes/memory.ts @@ -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", diff --git a/extensions/migrate-hermes/provider.test.ts b/extensions/migrate-hermes/provider.test.ts index 0db46e37797..d27d93a2984 100644 --- a/extensions/migrate-hermes/provider.test.ts +++ b/extensions/migrate-hermes/provider.test.ts @@ -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"); diff --git a/extensions/migrate-hermes/source.ts b/extensions/migrate-hermes/source.ts index c31350b55ed..4e1526c7b97 100644 --- a/extensions/migrate-hermes/source.ts +++ b/extensions/migrate-hermes/source.ts @@ -183,9 +183,21 @@ async function resolveImplicitHermesRoot( env: NodeJS.ProcessEnv, platform: NodeJS.Platform, ): Promise { + // 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 { const activeProfile = (await readText(path.join(root, "active_profile")))?.trim(); if (!activeProfile || activeProfile === "default" || !HERMES_PROFILE_RE.test(activeProfile)) { return root; diff --git a/src/plugin-sdk/migration-runtime.test.ts b/src/plugin-sdk/migration-runtime.test.ts index c41abf6291d..f5b4a3b8050 100644 --- a/src/plugin-sdk/migration-runtime.test.ts +++ b/src/plugin-sdk/migration-runtime.test.ts @@ -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({ diff --git a/src/plugin-sdk/migration-runtime.ts b/src/plugin-sdk/migration-runtime.ts index 09367cd5129..3cd9ba5bfe9 100644 --- a/src/plugin-sdk/migration-runtime.ts +++ b/src/plugin-sdk/migration-runtime.ts @@ -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));