| 1 | import { mkdirSync, readFileSync, renameSync, writeFileSync, rmSync } from "node:fs"; |
| 2 | import { dirname } from "node:path"; |
| 3 | import type { BrowserViewport } from "./viewport.js"; |
| 4 | import { validateViewport } from "./viewport.js"; |
| 5 | |
| 6 | export interface FileReference { source: "workspace" | "presented" | "reference"; path: string; toolCallId: string } |
| 7 | export function validFileReference(value: unknown): value is FileReference { const item = value as FileReference | undefined; return !!item && ["workspace", "presented", "reference"].includes(item.source) && typeof item.path === "string" && item.path.length > 0 && item.path.length <= 8192 && typeof item.toolCallId === "string" && item.toolCallId.length <= 512; } |
| 8 | export interface RecoveredTab { id: string; taskId: string; sessionId: string; url: string; title: string; viewport: BrowserViewport | null; fileReference?: FileReference; [key: string]: unknown } |
| 9 | const prohibited = ["grant", "grantId", "webContentsId", "documentToken", "refs", "observationToken", "pendingOperation", "form", "password"]; |
| 10 | export function recoverableURL(value: string): boolean { |
| 11 | if (value.length > 8192) return false; |
| 12 | try { const url = new URL(value); return ["http:", "https:"].includes(url.protocol) && !url.username && !url.password && !url.pathname.startsWith("/__reasonix_workspace_media/"); } |
| 13 | catch { return false; } |
| 14 | } |
| 15 | export class BrowserRecoveryStore { |
| 16 | private document: Record<string, unknown> = { version: 1, tabs: [] }; |
| 17 | private writable = true; |
| 18 | private lastSaved = ""; |
| 19 | readonly tabs: RecoveredTab[] = []; |
| 20 | constructor(private readonly path: string, private readonly warn: (message: string) => void) { |
| 21 | try { |
| 22 | const data = readFileSync(path, "utf8"); |
| 23 | if (data.length > 256 * 1024) throw new Error("recovery metadata exceeds budget"); |
| 24 | const document = JSON.parse(data) as Record<string, unknown>; |
| 25 | if (document.version !== 1 || !Array.isArray(document.tabs)) { this.writable = false; return; } |
| 26 | this.document = document; |
| 27 | const ids = new Set<string>(); |
| 28 | for (const candidate of document.tabs.slice(0, 32)) { |
| 29 | const tab = candidate as RecoveredTab; |
| 30 | if (!tab || !/^tab-\d+$/.test(tab.id) || !Number.isSafeInteger(Number(tab.id.slice(4))) || ids.has(tab.id) || typeof tab.taskId !== "string" || typeof tab.sessionId !== "string" || typeof tab.url !== "string" || (!recoverableURL(tab.url) && !validFileReference(tab.fileReference))) continue; |
| 31 | let viewport: BrowserViewport | null = null; |
| 32 | try { viewport = tab.viewport ? validateViewport(tab.viewport) : null; } catch { continue; } |
| 33 | ids.add(tab.id); |
| 34 | this.tabs.push({ ...tab, title: typeof tab.title === "string" ? tab.title.slice(0, 512) : "", viewport }); |
| 35 | } |
| 36 | } catch (error) { |
| 37 | if ((error as NodeJS.ErrnoException).code !== "ENOENT") { this.writable = false; warn("Browser tab recovery metadata is unreadable; preserving the original file"); } |
| 38 | } |
| 39 | } |
| 40 | save(tabs: RecoveredTab[]): void { |
| 41 | if (!this.writable) return; |
| 42 | const previous = new Map(this.tabs.map(tab => [tab.id, tab])); |
| 43 | const rows = tabs.filter(tab => recoverableURL(tab.url) || validFileReference(tab.fileReference)).slice(0, 32).map(tab => { |
| 44 | const row: RecoveredTab = { ...previous.get(tab.id), ...tab, title: tab.title.slice(0, 512) }; |
| 45 | if (row.fileReference) row.url = ""; |
| 46 | for (const key of prohibited) delete row[key]; |
| 47 | return row; |
| 48 | }); |
| 49 | const temporary = `${this.path}.tmp`; |
| 50 | try { |
| 51 | const serialized = JSON.stringify({ ...this.document, version: 1, tabs: rows }); |
| 52 | if (serialized === this.lastSaved) return; |
| 53 | if (Buffer.byteLength(serialized) > 256 * 1024) throw new Error("recovery metadata exceeds budget"); |
| 54 | mkdirSync(dirname(this.path), { recursive: true }); |
| 55 | writeFileSync(temporary, serialized, { mode: 0o600 }); |
| 56 | renameSync(temporary, this.path); |
| 57 | this.lastSaved = serialized; |
| 58 | } catch { rmSync(temporary, { force: true }); this.warn("Browser tab recovery metadata could not be saved"); } |
| 59 | } |
| 60 | } |
| 61 |