diff --git a/packages/cli/src/commands/import-memories.ts b/packages/cli/src/commands/import-memories.ts index 10390e6a..e5e0fc73 100644 --- a/packages/cli/src/commands/import-memories.ts +++ b/packages/cli/src/commands/import-memories.ts @@ -59,11 +59,23 @@ cmd.action( ); } - const result = importMemories(payload, { - dbPath: resolveDbPath(resolveDbOpt(opts)), - remapProject: opts.remapProject, - dryRun: opts.dryRun, - }); + let result: ReturnType; + try { + result = importMemories(payload, { + dbPath: resolveDbPath(resolveDbOpt(opts)), + remapProject: opts.remapProject, + dryRun: opts.dryRun, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Import failed"; + if (opts.json) { + emitJsonError("import_failed", message); + } else { + p.log.error(message); + process.exitCode = 1; + } + return; + } if (opts.json) { console.log( diff --git a/packages/core/src/export-import.test.ts b/packages/core/src/export-import.test.ts index 6d9f0603..90119b2a 100644 --- a/packages/core/src/export-import.test.ts +++ b/packages/core/src/export-import.test.ts @@ -57,6 +57,62 @@ function seedSourceDb(dbPath: string): void { } } +function grantScope(db: Database.Database, scopeId: string, deviceId = "local"): void { + const now = "2026-01-01T00:00:00Z"; + db.prepare( + `INSERT INTO replication_scopes( + scope_id, label, kind, authority_type, membership_epoch, status, created_at, updated_at + ) VALUES (?, ?, 'team', 'coordinator', 1, 'active', ?, ?)`, + ).run(scopeId, scopeId, now, now); + db.prepare( + `INSERT INTO scope_memberships(scope_id, device_id, role, status, membership_epoch, updated_at) + VALUES (?, ?, 'member', 'active', 1, ?)`, + ).run(scopeId, deviceId, now); +} + +function minimalPayload(scopeId: string): ReturnType { + return { + version: "1.0", + exported_at: "2026-03-01T00:00:00Z", + export_metadata: { + tool_version: "codemem", + projects: ["codemem"], + total_memories: 1, + total_sessions: 1, + include_inactive: false, + filters: {}, + }, + sessions: [ + { + id: 1, + started_at: "2026-03-01T00:00:00Z", + cwd: "/tmp/codemem", + project: "codemem", + user: "test", + tool_version: "test", + metadata_json: {}, + import_key: "session-1", + }, + ], + memory_items: [ + { + id: 100, + session_id: 1, + kind: "discovery", + title: "Scoped import", + body_text: "Scoped body", + created_at: "2026-03-01T00:00:01Z", + updated_at: "2026-03-01T00:00:01Z", + metadata_json: {}, + import_key: "memory-100", + scope_id: scopeId, + }, + ], + session_summaries: [], + user_prompts: [], + }; +} + describe("export/import", () => { it("exports parsed JSON fields and prompt import key links", () => { const dbPath = createDbPath("source"); @@ -71,9 +127,74 @@ describe("export/import", () => { expect(payload.user_prompts).toHaveLength(1); expect(payload.sessions[0]?.metadata_json).toEqual({ k: 1 }); expect(payload.memory_items[0]?.facts).toEqual(["fact"]); + expect(payload.memory_items[0]?.scope_id).toBe("local-default"); expect(payload.memory_items[0]?.user_prompt_import_key).toBe("prompt-1"); }); + it("exports only locally authorized scopes and tags source scope ids", () => { + const dbPath = createDbPath("scoped-export"); + const db = new Database(dbPath); + try { + initTestSchema(db); + grantScope(db, "authorized-team"); + db.prepare( + `INSERT INTO replication_scopes( + scope_id, label, kind, authority_type, membership_epoch, status, created_at, updated_at + ) VALUES ('unauthorized-team', 'unauthorized-team', 'team', 'coordinator', 1, 'active', ?, ?)`, + ).run("2026-01-01T00:00:00Z", "2026-01-01T00:00:00Z"); + db.prepare( + `INSERT INTO sessions(id, started_at, cwd, project, user, tool_version, metadata_json, import_key) + VALUES (1, '2026-03-01T00:00:00Z', '/tmp/visible', 'visible', 'test', 'test', '{}', 'session-visible'), + (2, '2026-03-01T00:00:00Z', '/tmp/hidden', 'hidden', 'test', 'test', '{}', 'session-hidden')`, + ).run(); + db.prepare( + `INSERT INTO memory_items( + id, session_id, kind, title, body_text, active, created_at, updated_at, metadata_json, import_key, scope_id + ) VALUES + (100, 1, 'discovery', 'Visible scoped export', 'visible', 1, '2026-03-01T00:00:01Z', '2026-03-01T00:00:01Z', '{}', 'memory-visible', 'authorized-team'), + (101, 2, 'discovery', 'Hidden scoped export', 'hidden', 1, '2026-03-01T00:00:02Z', '2026-03-01T00:00:02Z', '{}', 'memory-hidden', 'unauthorized-team')`, + ).run(); + } finally { + db.close(); + } + + const payload = exportMemories({ dbPath, allProjects: true }); + + expect(payload.sessions.map((session) => session.import_key)).toEqual(["session-visible"]); + expect(payload.memory_items.map((memory) => memory.title)).toEqual(["Visible scoped export"]); + expect(payload.memory_items[0]?.scope_id).toBe("authorized-team"); + }); + + it("exports null-scope legacy rows as local-default even when project mappings exist", () => { + const dbPath = createDbPath("mapped-null-scope-export"); + const db = new Database(dbPath); + try { + initTestSchema(db); + grantScope(db, "authorized-team"); + db.prepare( + `INSERT INTO project_scope_mappings( + workspace_identity, project_pattern, scope_id, priority, source, created_at, updated_at + ) VALUES ('/tmp/mapped', '/tmp/mapped', 'authorized-team', 10, 'user', ?, ?)`, + ).run("2026-01-01T00:00:00Z", "2026-01-01T00:00:00Z"); + db.prepare( + `INSERT INTO sessions(id, started_at, cwd, project, user, tool_version, metadata_json, import_key) + VALUES (1, '2026-03-01T00:00:00Z', '/tmp/mapped', 'mapped', 'test', 'test', '{}', 'session-mapped')`, + ).run(); + db.prepare( + `INSERT INTO memory_items( + id, session_id, kind, title, body_text, active, created_at, updated_at, metadata_json, import_key, scope_id + ) VALUES (100, 1, 'discovery', 'Legacy null scope', 'legacy', 1, '2026-03-01T00:00:01Z', '2026-03-01T00:00:01Z', '{}', 'memory-legacy', NULL)`, + ).run(); + } finally { + db.close(); + } + + const payload = exportMemories({ dbPath, allProjects: true }); + + expect(payload.memory_items).toHaveLength(1); + expect(payload.memory_items[0]?.scope_id).toBe("local-default"); + }); + it("includes inactive memories when requested", () => { const dbPath = createDbPath("inactive"); seedSourceDb(dbPath); @@ -157,6 +278,78 @@ describe("export/import", () => { } }); + it("preserves imported source scopes only when locally authorized", () => { + const authorizedDestPath = createDbPath("authorized-import-scope"); + const authorizedDb = new Database(authorizedDestPath); + try { + initTestSchema(authorizedDb); + grantScope(authorizedDb, "authorized-team"); + } finally { + authorizedDb.close(); + } + + const result = importMemories(minimalPayload("authorized-team"), { + dbPath: authorizedDestPath, + }); + expect(result.memory_items).toBe(1); + const checkDb = new Database(authorizedDestPath, { readonly: true }); + try { + const row = checkDb.prepare("SELECT scope_id FROM memory_items LIMIT 1").get() as { + scope_id: string; + }; + expect(row.scope_id).toBe("authorized-team"); + } finally { + checkDb.close(); + } + + const unauthorizedDestPath = createDbPath("unauthorized-import-scope"); + const unauthorizedDb = new Database(unauthorizedDestPath); + try { + initTestSchema(unauthorizedDb); + } finally { + unauthorizedDb.close(); + } + expect(() => + importMemories(minimalPayload("authorized-team"), { dbPath: unauthorizedDestPath }), + ).toThrow(/unauthorized_scope: authorized-team/); + expect(() => + importMemories(minimalPayload("legacy-shared-review"), { dbPath: unauthorizedDestPath }), + ).toThrow(/unauthorized_scope: legacy-shared-review/); + }); + + it("re-imports idempotently after a previously-authorized scope loses authorization", () => { + // Initial import: destination has authority for the source scope. + const destPath = createDbPath("revoked-scope-reimport"); + const grantedDb = new Database(destPath); + try { + initTestSchema(grantedDb); + grantScope(grantedDb, "previously-authorized-team"); + } finally { + grantedDb.close(); + } + + const payload = minimalPayload("previously-authorized-team"); + const initial = importMemories(payload, { dbPath: destPath }); + expect(initial.memory_items).toBe(1); + + // Revoke the scope membership/authority. + const revokeDb = new Database(destPath); + try { + revokeDb + .prepare("UPDATE replication_scopes SET status = 'archived' WHERE scope_id = ?") + .run("previously-authorized-team"); + revokeDb + .prepare("UPDATE scope_memberships SET status = 'revoked' WHERE scope_id = ?") + .run("previously-authorized-team"); + } finally { + revokeDb.close(); + } + + // Re-importing the exact same payload must be a no-op, not a hard reject. + const second = importMemories(payload, { dbPath: destPath }); + expect(second.memory_items).toBe(0); + }); + it("reads import payload from file", () => { const file = join(mkdtempSync(join(tmpdir(), "codemem-export-file-")), "export.json"); writeFileSync( diff --git a/packages/core/src/export-import.ts b/packages/core/src/export-import.ts index 103c9172..405a1f09 100644 --- a/packages/core/src/export-import.ts +++ b/packages/core/src/export-import.ts @@ -9,14 +9,21 @@ import { toJson, toJsonNullable, } from "./db.js"; +import { buildFilterClausesWithContext } from "./filters.js"; import { expandUserPath } from "./observer-config.js"; import { projectColumnClause, resolveProject as resolveProjectName } from "./project.js"; import * as schema from "./schema.js"; +import { LOCAL_DEFAULT_SCOPE_ID } from "./scope-resolution.js"; import { resolveSessionScopeId } from "./scope-stamping.js"; type JsonObject = Record; type MemoryInsert = typeof schema.memoryItems.$inferInsert; +interface ScopeFilter { + clauses: string[]; + params: unknown[]; +} + export interface ExportOptions { dbPath?: string; project?: string | null; @@ -80,6 +87,58 @@ function nowEpochMs(): number { return Date.now(); } +function cleanString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +function resolveLocalDeviceId(db: Database): string { + const envDeviceId = cleanString(process.env.CODEMEM_DEVICE_ID); + if (envDeviceId) return envDeviceId; + try { + const row = db.prepare("SELECT device_id FROM sync_device LIMIT 1").get() as + | { device_id: string | null } + | undefined; + return cleanString(row?.device_id) ?? "local"; + } catch { + return "local"; + } +} + +function buildScopeFilter(db: Database): ScopeFilter { + return buildFilterClausesWithContext(null, { + actorId: "export", + deviceId: resolveLocalDeviceId(db), + enforceScopeVisibility: true, + }); +} + +function scopeCanBeImported(db: Database, scopeId: string, deviceId: string): boolean { + if (scopeId === LOCAL_DEFAULT_SCOPE_ID) return true; + const row = db + .prepare( + `SELECT 1 AS ok + FROM replication_scopes rs + WHERE rs.scope_id = ? + AND rs.status = 'active' + AND ( + rs.authority_type = 'local' + OR EXISTS ( + SELECT 1 + FROM scope_memberships sm + WHERE sm.scope_id = rs.scope_id + AND sm.device_id = ? + AND sm.status = 'active' + AND sm.membership_epoch >= rs.membership_epoch + ) + ) + LIMIT 1`, + ) + .get(scopeId, deviceId) as { ok: number } | undefined; + return row != null; +} + function parseDbObject(raw: unknown): unknown { if (typeof raw !== "string" || raw.trim().length === 0) return null; try { @@ -169,7 +228,13 @@ function resolveExportProject(opts: ExportOptions): string | null { return resolveProjectName(opts.cwd ?? process.cwd(), opts.project ?? null); } -function querySessions(db: Database, project: string | null, since: string | null): JsonObject[] { +function querySessions( + db: Database, + project: string | null, + since: string | null, + scopeFilter: ScopeFilter, + includeInactive: boolean, +): JsonObject[] { let sql = "SELECT * FROM sessions"; const params: unknown[] = []; const clauses: string[] = []; @@ -184,6 +249,10 @@ function querySessions(db: Database, project: string | null, since: string | nul clauses.push("started_at >= ?"); params.push(since); } + const memoryClauses = ["memory_items.session_id = sessions.id", ...scopeFilter.clauses]; + if (!includeInactive) memoryClauses.splice(1, 0, "memory_items.active = 1"); + clauses.push(`EXISTS (SELECT 1 FROM memory_items WHERE ${memoryClauses.join(" AND ")})`); + params.push(...scopeFilter.params); if (clauses.length > 0) sql += ` WHERE ${clauses.join(" AND ")}`; sql += " ORDER BY started_at ASC"; const rows = db.prepare(sql).all(...params) as JsonObject[]; @@ -203,6 +272,40 @@ function fetchBySessionIds( return db.prepare(sql).all(...sessionIds) as JsonObject[]; } +function exportedMemoryScopeId(row: JsonObject): string { + const existing = cleanString(row.scope_id); + return existing ?? LOCAL_DEFAULT_SCOPE_ID; +} + +function parseMemoryExportRow(row: JsonObject): JsonObject { + return { + ...parseRowJsonFields(row, [ + "metadata_json", + "facts", + "concepts", + "files_read", + "files_modified", + ]), + scope_id: exportedMemoryScopeId(row), + }; +} + +function fetchMemoryRows( + db: Database, + sessionIds: number[], + scopeFilter: ScopeFilter, + includeInactive: boolean, +): JsonObject[] { + if (sessionIds.length === 0) return []; + const placeholders = sessionIds.map(() => "?").join(","); + const clauses = [`session_id IN (${placeholders})`, ...scopeFilter.clauses]; + const params: unknown[] = [...sessionIds, ...scopeFilter.params]; + if (!includeInactive) clauses.push("active = 1"); + return db + .prepare(`SELECT * FROM memory_items WHERE ${clauses.join(" AND ")} ORDER BY created_at ASC`) + .all(...params) as JsonObject[]; +} + export function exportMemories(opts: ExportOptions = {}): ExportPayload { const db = connect(resolveDbPath(opts.dbPath)); try { @@ -211,25 +314,23 @@ export function exportMemories(opts: ExportOptions = {}): ExportPayload { const filters: JsonObject = {}; if (resolvedProject) filters.project = resolvedProject; if (opts.since) filters.since = opts.since; + const scopeFilter = buildScopeFilter(db); - const sessions = querySessions(db, resolvedProject, opts.since ?? null); + const sessions = querySessions( + db, + resolvedProject, + opts.since ?? null, + scopeFilter, + Boolean(opts.includeInactive), + ); const sessionIds = sessions.map((row) => Number(row.id)).filter(Number.isFinite); - const memories = fetchBySessionIds( + const memories = fetchMemoryRows( db, - "memory_items", sessionIds, - "created_at ASC", - opts.includeInactive ? "" : " AND active = 1", - ).map((row) => - parseRowJsonFields(row, [ - "metadata_json", - "facts", - "concepts", - "files_read", - "files_modified", - ]), - ); + scopeFilter, + Boolean(opts.includeInactive), + ).map((row) => parseMemoryExportRow(row)); const summaries = fetchBySessionIds( db, @@ -347,18 +448,58 @@ function insertPrompt(d: DrizzleDb, row: JsonObject): number { return id; } -function insertMemory(db: Database, d: DrizzleDb, row: JsonObject): number { +function importedMemoryScopeId(db: Database, row: JsonObject, deviceId: string): string { + const sourceScopeId = cleanString(row.scope_id); + if (sourceScopeId) { + if (!scopeCanBeImported(db, sourceScopeId, deviceId)) { + throw new Error(`unauthorized_scope: ${sourceScopeId}`); + } + return sourceScopeId; + } + return resolveSessionScopeId(db, { + sessionId: Number(row.session_id), + workspaceId: cleanString(row.workspace_id), + }); +} + +function validateImportScopes( + db: Database, + memories: JsonObject[], + deviceId: string, + remapProject: string | null, +): void { + const unauthorized = new Set(); + for (const memory of memories) { + const sourceScopeId = cleanString(memory.scope_id); + if (!sourceScopeId) continue; + // Skip rows that would dedupe — they don't trigger an insert, so they + // should not block re-imports after authorization is revoked. + const project = remapProject ?? normalizeImportedProject(memory.project); + const memoryImportKey = + typeof memory.import_key === "string" && memory.import_key.trim() + ? memory.import_key.trim() + : buildImportKey("export", "memory", memory.id, { + project, + createdAt: typeof memory.created_at === "string" ? memory.created_at : null, + }); + if (findImportedId(db, "memory_items", memoryImportKey) != null) continue; + if (!scopeCanBeImported(db, sourceScopeId, deviceId)) { + unauthorized.add(sourceScopeId); + } + } + if (unauthorized.size > 0) { + throw new Error(`unauthorized_scope: ${[...unauthorized].sort().join(", ")}`); + } +} + +function insertMemory(db: Database, d: DrizzleDb, row: JsonObject, deviceId: string): number { const now = nowIso(); const parsedActive = row.active == null ? 1 : Number(row.active); const active = Number.isFinite(parsedActive) ? parsedActive : 1; const deletedAt = typeof row.deleted_at === "string" && row.deleted_at.trim().length > 0 ? row.deleted_at : null; const workspaceId = row.workspace_id == null ? null : String(row.workspace_id); - // Imports are local writes: resolve against destination policy instead of preserving source scope. - const scopeId = resolveSessionScopeId(db, { - sessionId: Number(row.session_id), - workspaceId, - }); + const scopeId = importedMemoryScopeId(db, row, deviceId); const values: MemoryInsert = { session_id: Number(row.session_id), kind: String(row.kind ?? "observation"), @@ -435,19 +576,20 @@ export function importMemories(payload: ExportPayload, opts: ImportOptions = {}) const summariesData = Array.isArray(payload.session_summaries) ? payload.session_summaries : []; const promptsData = Array.isArray(payload.user_prompts) ? payload.user_prompts : []; - if (opts.dryRun) { - return { - sessions: sessionsData.length, - user_prompts: promptsData.length, - memory_items: memoriesData.length, - session_summaries: summariesData.length, - dryRun: true, - }; - } - const db = connect(resolveDbPath(opts.dbPath)); try { assertSchemaReady(db); + const deviceId = resolveLocalDeviceId(db); + validateImportScopes(db, memoriesData, deviceId, opts.remapProject ?? null); + if (opts.dryRun) { + return { + sessions: sessionsData.length, + user_prompts: promptsData.length, + memory_items: memoriesData.length, + session_summaries: summariesData.length, + dryRun: true, + }; + } const d = drizzle(db, { schema }); return db.transaction(() => { const sessionMapping = new Map(); @@ -569,14 +711,19 @@ export function importMemories(payload: ExportPayload, opts: ImportOptions = {}) ? mergeSummaryMetadata(baseMetadata, memory.metadata_json ?? null) : baseMetadata; - insertMemory(db, d, { - ...memory, - session_id: newSessionId, - project, - user_prompt_id: linkedPromptId, - metadata_json: metadata, - import_key: memoryImportKey, - }); + insertMemory( + db, + d, + { + ...memory, + session_id: newSessionId, + project, + user_prompt_id: linkedPromptId, + metadata_json: metadata, + import_key: memoryImportKey, + }, + deviceId, + ); importedMemories += 1; }