mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 14:51:40 +00:00
fix(migrate): harden importer path resolution and memory copy safety (#109314)
This commit is contained in:
committed by
GitHub
parent
ce919593bf
commit
54eb03fcf0
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user