// Migrate Hermes tests cover files and skills plugin behavior. import fs from "node:fs/promises"; import path from "node:path"; import { DatabaseSync } from "node:sqlite"; import { loadAuthProfileStoreWithoutExternalProfiles } from "openclaw/plugin-sdk/agent-runtime"; import { MIGRATION_REASON_TARGET_EXISTS } from "openclaw/plugin-sdk/migration"; import { afterEach, describe, expect, it } from "vitest"; import { buildHermesMigrationProvider } from "./provider.js"; import { cleanupTempRoots, makeContext, makeTempRoot, writeFile } from "./test/provider-helpers.js"; describe("Hermes migration file and skill items", () => { afterEach(async () => { await cleanupTempRoots(); }); function configRuntime(config: Record) { return { config: { current: () => config, mutateConfigFile: async ({ mutate, }: { mutate: (draft: Record) => void | Promise; }) => { const next = structuredClone(config); await mutate(next); Object.keys(config).forEach((key) => { delete config[key]; }); Object.assign(config, next); return { nextConfig: next }; }, }, } as never; } function itemById(items: T[], id: string): T | undefined { return items.find((item) => item.id === id); } async function expectPathMissing(targetPath: string): Promise { try { await fs.access(targetPath); } catch (error) { expect((error as NodeJS.ErrnoException).code).toBe("ENOENT"); return; } throw new Error(`Expected path to be missing: ${targetPath}`); } it("reports normalized skill-name collisions instead of overwriting during apply", async () => { const root = await makeTempRoot(); const source = path.join(root, "hermes"); const workspaceDir = path.join(root, "workspace"); const stateDir = path.join(root, "state"); await writeFile(path.join(source, "skills", "Ship It", "SKILL.md"), "# Ship It\n"); await writeFile(path.join(source, "skills", "ship-it", "SKILL.md"), "# ship-it\n"); const provider = buildHermesMigrationProvider(); const plan = await provider.plan(makeContext({ source, stateDir, workspaceDir })); const skillItems = plan.items.filter((item) => item.kind === "skill"); expect(skillItems).toHaveLength(2); const shipIt = itemById(skillItems, "skill:ship-it"); expect(shipIt?.status).toBe("conflict"); expect(shipIt?.reason).toBe('multiple Hermes skill directories normalize to "ship-it"'); expect(shipIt?.target).toBe(path.join(workspaceDir, "skills", "ship-it")); const result = await provider.apply( makeContext({ source, stateDir, workspaceDir, overwrite: true, reportDir: path.join(root, "report"), }), ); expect(result.summary.conflicts).toBe(2); await expectPathMissing(path.join(workspaceDir, "skills", "ship-it")); }); it("reports late-created copy targets as conflicts without overwriting", async () => { const root = await makeTempRoot(); const source = path.join(root, "hermes"); const workspaceDir = path.join(root, "workspace"); const stateDir = path.join(root, "state"); const reportDir = path.join(root, "report"); await writeFile(path.join(source, "AGENTS.md"), "# Hermes agents\n"); const provider = buildHermesMigrationProvider(); const ctx = makeContext({ source, stateDir, workspaceDir, reportDir }); const plan = await provider.plan(ctx); await writeFile(path.join(workspaceDir, "AGENTS.md"), "# Late agents\n"); const result = await provider.apply(ctx, plan); const agents = itemById(result.items, "workspace:AGENTS.md"); expect(agents?.status).toBe("conflict"); expect(agents?.reason).toBe(MIGRATION_REASON_TARGET_EXISTS); expect(result.summary.conflicts).toBe(1); expect(await fs.readFile(path.join(workspaceDir, "AGENTS.md"), "utf8")).toBe("# Late agents\n"); }); it("applies files, appended memories, item backups, reports, and opt-in API keys", async () => { const root = await makeTempRoot(); const source = path.join(root, "hermes"); const workspaceDir = path.join(root, "workspace"); const stateDir = path.join(root, "state"); const reportDir = path.join(root, "report"); await writeFile(path.join(source, ".env"), "OPENAI_API_KEY=sk-hermes\n"); await writeFile(path.join(source, "AGENTS.md"), "# Hermes agents\n"); await writeFile(path.join(source, "memories", "MEMORY.md"), "memory line\n"); await writeFile(path.join(source, "skills", "Ship It", "SKILL.md"), "# Ship It\n"); await writeFile(path.join(workspaceDir, "AGENTS.md"), "# Existing agents\n"); const provider = buildHermesMigrationProvider(); const config: Record = {}; const result = await provider.apply( makeContext({ source, stateDir, workspaceDir, includeSecrets: true, overwrite: true, reportDir, runtime: configRuntime(config), }), ); expect(result.summary.errors).toBe(0); expect(result.summary.conflicts).toBe(0); expect(await fs.readFile(path.join(workspaceDir, "AGENTS.md"), "utf8")).toBe( "# Hermes agents\n", ); expect( await fs.readFile(path.join(workspaceDir, "skills", "ship-it", "SKILL.md"), "utf8"), ).toBe("# Ship It\n"); await expect(fs.access(path.join(reportDir, "summary.md"))).resolves.toBeUndefined(); expect(await fs.readFile(path.join(workspaceDir, "MEMORY.md"), "utf8")).toContain( "Imported from Hermes", ); const copiedAgentsItem = result.items.find((item) => item.id === "workspace:AGENTS.md"); expect(String(copiedAgentsItem?.details?.backupPath)).toContain("AGENTS.md"); const agentDir = path.join(stateDir, "agents", "main", "agent"); const previousStateDir = process.env.OPENCLAW_STATE_DIR; const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; process.env.OPENCLAW_STATE_DIR = stateDir; process.env.OPENCLAW_AGENT_DIR = agentDir; try { const authStore = loadAuthProfileStoreWithoutExternalProfiles(agentDir); expect(authStore.profiles?.["openai:hermes-import"]).toEqual( expect.objectContaining({ type: "api_key", provider: "openai", key: "sk-hermes", }), ); } finally { if (previousStateDir === undefined) { delete process.env.OPENCLAW_STATE_DIR; } else { process.env.OPENCLAW_STATE_DIR = previousStateDir; } if (previousAgentDir === undefined) { delete process.env.OPENCLAW_AGENT_DIR; } else { process.env.OPENCLAW_AGENT_DIR = previousAgentDir; } } }); it("archives unsupported Hermes state without copying raw auth credentials", async () => { const root = await makeTempRoot(); const source = path.join(root, "hermes"); const workspaceDir = path.join(root, "workspace"); const stateDir = path.join(root, "state"); const reportDir = path.join(root, "report"); await writeFile(path.join(source, "logs", "session.log"), "log line\n"); await writeFile(path.join(source, "auth.json"), '{"token":"opaque"}\n'); const provider = buildHermesMigrationProvider(); const plan = await provider.plan(makeContext({ source, stateDir, workspaceDir, reportDir })); const plannedLogs = itemById(plan.items, "archive:logs"); expect(plannedLogs?.kind).toBe("archive"); expect(plannedLogs?.action).toBe("archive"); expect(plannedLogs?.status).toBe("planned"); expect(plan.items.find((item) => item.id === "archive:auth.json")).toBeUndefined(); expect(plan.warnings).toEqual([ "Some Hermes files are archive-only. They will be copied into the migration report for manual review, not loaded into OpenClaw.", ]); const result = await provider.apply(makeContext({ source, stateDir, workspaceDir, reportDir })); expect(result.summary.errors).toBe(0); const migratedLogs = itemById(result.items, "archive:logs"); expect(migratedLogs?.status).toBe("migrated"); expect(migratedLogs?.target).toBe(path.join(reportDir, "archive", "logs")); expect(await fs.readFile(path.join(reportDir, "archive", "logs", "session.log"), "utf8")).toBe( "log line\n", ); await expectPathMissing(path.join(reportDir, "archive", "auth.json")); await expectPathMissing(path.join(workspaceDir, "logs", "session.log")); }); it("archives committed Hermes SQLite WAL state", async () => { const root = await makeTempRoot(); const source = path.join(root, "hermes"); const workspaceDir = path.join(root, "workspace"); const stateDir = path.join(root, "state"); const reportDir = path.join(root, "report"); const stateDbPath = path.join(source, "state.db"); await fs.mkdir(source, { recursive: true }); const sourceDb = new DatabaseSync(stateDbPath); try { sourceDb.exec(` PRAGMA journal_mode = WAL; CREATE TABLE marker(value TEXT NOT NULL); PRAGMA wal_checkpoint(TRUNCATE); `); sourceDb.prepare("INSERT INTO marker(value) VALUES (?)").run("committed-only-in-wal"); expect((await fs.stat(`${stateDbPath}-wal`)).size).toBeGreaterThan(0); const provider = buildHermesMigrationProvider(); const result = await provider.apply( makeContext({ source, stateDir, workspaceDir, reportDir }), ); const archivedState = itemById(result.items, "archive:state.db"); const archivedStatePath = path.join(reportDir, "archive", "state.db"); expect(archivedState?.status).toBe("migrated"); expect(archivedState?.source).toBe(stateDbPath); expect(archivedState?.target).toBe(archivedStatePath); const archivedDb = new DatabaseSync(archivedStatePath, { readOnly: true }); try { expect(archivedDb.prepare("SELECT value FROM marker").all()).toEqual([ { value: "committed-only-in-wal" }, ]); expect(archivedDb.prepare("PRAGMA integrity_check").get()).toEqual({ integrity_check: "ok", }); } finally { archivedDb.close(); } } finally { sourceDb.close(); } }); it("preserves raw Hermes state when SQLite snapshotting fails", async () => { const root = await makeTempRoot(); const source = path.join(root, "hermes"); const workspaceDir = path.join(root, "workspace"); const stateDir = path.join(root, "state"); const reportDir = path.join(root, "report"); const stateDbPath = path.join(source, "state.db"); const archivedStatePath = path.join(reportDir, "archive", "state.db"); await writeFile(stateDbPath, "legacy non-SQLite Hermes state\n"); const provider = buildHermesMigrationProvider(); const result = await provider.apply(makeContext({ source, stateDir, workspaceDir, reportDir })); const archivedState = itemById(result.items, "archive:state.db"); expect(archivedState?.status).toBe("error"); expect(archivedState?.target).toBe(archivedStatePath); expect(archivedState?.reason).toContain( "SQLite snapshot failed; raw state.db preserved for manual review", ); expect(await fs.readFile(archivedStatePath, "utf8")).toBe("legacy non-SQLite Hermes state\n"); expect(result.summary.errors).toBe(1); }); it("reports legacy Hermes OpenAI auth.json OAuth state as manual reauth work", async () => { const root = await makeTempRoot(); const source = path.join(root, "hermes"); const workspaceDir = path.join(root, "workspace"); const stateDir = path.join(root, "state"); await writeFile( path.join(source, "auth.json"), JSON.stringify({ providers: { openai: { tokens: { access_token: "old-access", refresh_token: "old-refresh", }, }, }, credential_pool: { openai: [ { access_token: "pool-access", refresh_token: "pool-refresh", }, ], }, }), ); const provider = buildHermesMigrationProvider(); const plan = await provider.plan( makeContext({ source, stateDir, workspaceDir, includeSecrets: true }), ); const manualAuth = itemById(plan.items, "manual:legacy-hermes-auth-json"); expect(manualAuth?.kind).toBe("manual"); expect(manualAuth?.status).toBe("skipped"); expect(manualAuth?.message).toContain("no longer imports"); expect(plan.items.some((item) => item.kind === "auth")).toBe(false); expect(plan.warnings).toContain( "Some Hermes settings require manual review before they can be activated safely.", ); }); it("ignores empty Hermes auth.json credential containers", async () => { const root = await makeTempRoot(); const source = path.join(root, "hermes"); const workspaceDir = path.join(root, "workspace"); const stateDir = path.join(root, "state"); await writeFile( path.join(source, "auth.json"), JSON.stringify({ providers: {}, credential_pool: {}, tokens: { anthropic: { access: "other-access", refresh: "other-refresh" } }, }), ); const provider = buildHermesMigrationProvider(); const plan = await provider.plan( makeContext({ source, stateDir, workspaceDir, includeSecrets: true }), ); expect(plan.items.find((item) => item.id === "manual:legacy-hermes-auth-json")).toBeUndefined(); }); });