|
| 1 | +import { mkdir, readFile, writeFile } from "node:fs/promises"; |
| 2 | +import { homedir } from "node:os"; |
| 3 | +import { dirname, join } from "node:path"; |
| 4 | + |
| 5 | +import { getLogger } from "@intx/log"; |
| 6 | + |
| 7 | +import { canonicalGrantTool } from "../agent/canonical-tool-name.js"; |
| 8 | +import { LOG_NAMESPACE_ROOT, SETTINGS_DIR_NAME } from "../branding.js"; |
| 9 | +import { sessionDir } from "../session/index.js"; |
| 10 | + |
| 11 | +const log = getLogger([LOG_NAMESPACE_ROOT, "permission", "approval-migration"]); |
| 12 | + |
| 13 | +export interface ApprovalStoreMigrationFileResult { |
| 14 | + path: string; |
| 15 | + purged: number; |
| 16 | + backupPath?: string | undefined; |
| 17 | +} |
| 18 | + |
| 19 | +export interface ApprovalStoreMigrationResult { |
| 20 | + purged: number; |
| 21 | + backups: string[]; |
| 22 | + files: ApprovalStoreMigrationFileResult[]; |
| 23 | +} |
| 24 | + |
| 25 | +// canonicalGrantTool is the single owner of "is an update_plan key": it maps |
| 26 | +// update_plan (any case, default.-prefixed, or doubled) to null so the |
| 27 | +// load-time normalizer drops it fail-closed. The migration delegates to it so |
| 28 | +// disk and memory can never disagree about which keys purge. |
| 29 | +function isUpdatePlanKey(tool: unknown): boolean { |
| 30 | + return typeof tool === "string" && canonicalGrantTool(tool) === null; |
| 31 | +} |
| 32 | + |
| 33 | +function isUpdatePlanEntry(entry: unknown): boolean { |
| 34 | + return ( |
| 35 | + typeof entry === "object" && |
| 36 | + entry !== null && |
| 37 | + !Array.isArray(entry) && |
| 38 | + isUpdatePlanKey((entry as Record<string, unknown>).tool) |
| 39 | + ); |
| 40 | +} |
| 41 | + |
| 42 | +function purgeList( |
| 43 | + list: unknown, |
| 44 | + onPurge: () => void, |
| 45 | +): { kept: unknown[]; changed: boolean } { |
| 46 | + if (!Array.isArray(list)) return { kept: [], changed: false }; |
| 47 | + const kept: unknown[] = []; |
| 48 | + for (const entry of list) { |
| 49 | + if (isUpdatePlanEntry(entry)) onPurge(); |
| 50 | + else kept.push(entry); |
| 51 | + } |
| 52 | + return { kept, changed: kept.length !== list.length }; |
| 53 | +} |
| 54 | + |
| 55 | +function isFileExistsError(err: unknown): boolean { |
| 56 | + return ( |
| 57 | + typeof err === "object" && |
| 58 | + err !== null && |
| 59 | + "code" in err && |
| 60 | + (err as { code?: unknown }).code === "EEXIST" |
| 61 | + ); |
| 62 | +} |
| 63 | + |
| 64 | +// Purge update_plan keys from one approvals file (session, project, or |
| 65 | +// global store shape: an `approvals` array plus, for the global file, a |
| 66 | +// `providerModels` map of arrays). Backup-then-rewrite: the pre-migration |
| 67 | +// bytes are saved to `<path>.bak` first (an existing backup is kept, so the |
| 68 | +// first backup always holds the true original), and a file with nothing to |
| 69 | +// left untouched (no backup, byte-identical) so re-runs are no-ops. Entries |
| 70 | +// that are not positive update_plan matches are kept verbatim — pure renames |
| 71 | +// are never collapsed here; that stays the load-time normalizer's job. |
| 72 | +// Missing, unreadable, or corrupt files are no-ops; write failures propagate. |
| 73 | +export async function migrateApprovalStoreFile( |
| 74 | + path: string, |
| 75 | +): Promise<ApprovalStoreMigrationFileResult> { |
| 76 | + let raw: string; |
| 77 | + try { |
| 78 | + raw = await readFile(path, "utf-8"); |
| 79 | + } catch { |
| 80 | + return { path, purged: 0 }; |
| 81 | + } |
| 82 | + let parsed: unknown; |
| 83 | + try { |
| 84 | + parsed = JSON.parse(raw) as unknown; |
| 85 | + } catch { |
| 86 | + return { path, purged: 0 }; |
| 87 | + } |
| 88 | + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { |
| 89 | + return { path, purged: 0 }; |
| 90 | + } |
| 91 | + const record = parsed as Record<string, unknown>; |
| 92 | + const next: Record<string, unknown> = { ...record }; |
| 93 | + let purged = 0; |
| 94 | + const onPurge = (): void => { |
| 95 | + purged += 1; |
| 96 | + }; |
| 97 | + if (Array.isArray(record.approvals)) { |
| 98 | + const { kept, changed } = purgeList(record.approvals, onPurge); |
| 99 | + if (changed) next.approvals = kept; |
| 100 | + } |
| 101 | + const providerModels = record.providerModels; |
| 102 | + if ( |
| 103 | + typeof providerModels === "object" && |
| 104 | + providerModels !== null && |
| 105 | + !Array.isArray(providerModels) |
| 106 | + ) { |
| 107 | + const map = providerModels as Record<string, unknown>; |
| 108 | + const nextMap: Record<string, unknown> = {}; |
| 109 | + let mapChanged = false; |
| 110 | + for (const [key, list] of Object.entries(map)) { |
| 111 | + const { kept, changed } = purgeList(list, onPurge); |
| 112 | + if (changed) { |
| 113 | + nextMap[key] = kept; |
| 114 | + mapChanged = true; |
| 115 | + } else { |
| 116 | + nextMap[key] = list; |
| 117 | + } |
| 118 | + } |
| 119 | + if (mapChanged) next.providerModels = nextMap; |
| 120 | + } |
| 121 | + if (purged === 0) return { path, purged: 0 }; |
| 122 | + const backupPath = `${path}.bak`; |
| 123 | + try { |
| 124 | + await writeFile(backupPath, raw, { flag: "wx" }); |
| 125 | + } catch (err) { |
| 126 | + if (!isFileExistsError(err)) { |
| 127 | + log.warn("Skipping approval-store migration for {path}: {error}", { |
| 128 | + path, |
| 129 | + error: err instanceof Error ? err.message : String(err), |
| 130 | + }); |
| 131 | + return { path, purged: 0 }; |
| 132 | + } |
| 133 | + } |
| 134 | + await mkdir(dirname(path), { recursive: true }); |
| 135 | + await writeFile(path, JSON.stringify(next, null, 2)); |
| 136 | + return { path, purged, backupPath }; |
| 137 | +} |
| 138 | + |
| 139 | +// One-time migration over the persisted approval stores (session, project, |
| 140 | +// global including provider-model grants): purge on-disk update_plan keys so |
| 141 | +// removing the load-time normalizer later cannot resurrect the hole. The |
| 142 | +// paths mirror store.ts; the files array in the result keeps them explicit. |
| 143 | +// Best-effort and idempotent — never throws, and a clean tree is a no-op. |
| 144 | +export async function migratePersistedApprovalStores( |
| 145 | + cwd: string, |
| 146 | + sessionId: string, |
| 147 | + home: string = homedir(), |
| 148 | +): Promise<ApprovalStoreMigrationResult> { |
| 149 | + const paths = [ |
| 150 | + join(sessionDir(cwd, sessionId, home), "permissions.json"), |
| 151 | + join(cwd, SETTINGS_DIR_NAME, "permissions.json"), |
| 152 | + join(home, SETTINGS_DIR_NAME, "permissions.json"), |
| 153 | + ]; |
| 154 | + const files: ApprovalStoreMigrationFileResult[] = []; |
| 155 | + for (const path of paths) { |
| 156 | + try { |
| 157 | + files.push(await migrateApprovalStoreFile(path)); |
| 158 | + } catch (err) { |
| 159 | + log.warn("Skipping approval-store migration for {path}: {error}", { |
| 160 | + path, |
| 161 | + error: err instanceof Error ? err.message : String(err), |
| 162 | + }); |
| 163 | + files.push({ path, purged: 0 }); |
| 164 | + } |
| 165 | + } |
| 166 | + return { |
| 167 | + purged: files.reduce((total, file) => total + file.purged, 0), |
| 168 | + backups: files.flatMap((file) => |
| 169 | + file.backupPath !== undefined ? [file.backupPath] : [], |
| 170 | + ), |
| 171 | + files, |
| 172 | + }; |
| 173 | +} |
0 commit comments