diff --git a/code-review/document-viewers.md b/code-review/document-viewers.md index f2e20785..0e8cc03a 100644 --- a/code-review/document-viewers.md +++ b/code-review/document-viewers.md @@ -23,6 +23,10 @@ - Direct preview and durable preparation are independent. A direct DOCX view may succeed while searchable extraction is pending or failed; neither state may falsely complete the other. +- Transient external files open with a temporary preview grant scoped strictly + to the requesting window identity. They are read-only, out-of-folder, and + stay out of library indexing and preparation pipelines. Browser-loaded grant + assets carry the window context in their URL path prefix (`/asset-preview-grant/__window//`). ## Trust Boundary diff --git a/code-review/file-transactions.md b/code-review/file-transactions.md index 16e5ced0..a1218330 100644 --- a/code-review/file-transactions.md +++ b/code-review/file-transactions.md @@ -47,12 +47,12 @@ source baseline. The asynchronously rendered dirty indicator is not a durability authority and cannot make context release skip a fresh edit. - A byte-identical save is a no-op and retains the current version. -- Markdown and JSON persistence preserves supported BOM and line-ending +- Markdown, JSON, and CSV persistence preserves supported BOM and line-ending conventions without manufacturing unrelated source changes. -- JSON Tree operations enter this same save path as minimal source patches. - They preserve untouched whitespace, property order, escape spelling, numeric - lexemes, and trailing-newline state; no whole-document serializer is a save - authority. +- JSON Tree and CSV Table operations enter this same save path as minimal source + patches. They preserve untouched whitespace, property order, escape spelling, + numeric lexemes, custom delimiters, quoting styles, leading zeros, and + trailing-newline state; no whole-document serializer is a save authority. - A `FILE_CHANGED` conflict must never automatically retry without `baseVersion`. The dirty editor buffer and newer disk source both remain recoverable until an explicit reload, merge, or overwrite decision. diff --git a/code-review/renderer-workspace.md b/code-review/renderer-workspace.md index 5b6469a1..cd122df9 100644 --- a/code-review/renderer-workspace.md +++ b/code-review/renderer-workspace.md @@ -39,7 +39,7 @@ semantic readiness. The initial renderer contains only window chrome and the minimum workspace shell. Feature surfaces that open on demand remain dynamic entries. The -authoritative budget is `418 KiB` of initial static JavaScript, and the current +authoritative budget is `423 KiB` of initial static JavaScript, and the current required dynamic-entry set lives in `scripts/check-renderer-chunks.mjs`. Change that list or budget only when the ownership of eager shell behavior changes, never to make an accidental dependency pass. diff --git a/code-review/window-lifecycle.md b/code-review/window-lifecycle.md index a4283705..2528c3ae 100644 --- a/code-review/window-lifecycle.md +++ b/code-review/window-lifecycle.md @@ -34,8 +34,13 @@ after readiness, a save failure or timeout keeps the window open. Reload and Force Reload menu and keyboard bypasses are absent. Recovery crosses main's awaited save barrier; if the failed renderer can no longer answer, reload requires a second explicit risk confirmation. -- Closing one window releases only that window's folder and Agent state. Shared - server, daemon, settings, MCP, and other windows remain live. +- Closing one window releases only that window's folder and Agent state, + revokes all active preview grants registered for it, and cleans up its + pending native-open queue. Shared server, daemon, settings, MCP, and other + windows remain live. +- Native file-open requests from OS events, CLI arguments, and second-instance + launches are queued for one target window identity rather than drained + globally, so cold startup and focused windows receive only their own files. - Removing a library folder flushes every window showing it, commits membership removal, and broadcasts the transition. Recovery may rebind only if durable membership still contains the folder. diff --git a/electron/main.cjs b/electron/main.cjs index 27af9575..2a26de81 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -38,6 +38,7 @@ const { WINDOW_ID_ARG_PREFIX, classifyProtocolLaunch, createApplicationMenuTemplate, + createNativeOpenQueueCoordinator, createRendererFlushCoordinator, createRendererFlushReadiness, createSafeReloadCoordinator, @@ -253,6 +254,117 @@ registerBugReportReviewIpc({ const APP_CONFIG_FILE = path.join(os.homedir(), '.stashbase', 'config.json'); +const VIEWABLE_FILE_EXTENSIONS = new Set([ + 'md', 'markdown', 'html', 'htm', 'json', 'csv', 'pdf', + 'png', 'jpg', 'jpeg', 'webp', 'docx', + 'mp3', 'wav', 'm4a', 'flac', 'ogg', 'opus', 'aac', 'aiff', 'aif', + 'mp4', 'mov', 'm4v', 'webm', 'mkv', 'avi' +]); + +function getFileFormat(filePath) { + const ext = path.extname(filePath).toLowerCase().slice(1); + if (ext === 'md' || ext === 'markdown') return 'md'; + if (ext === 'html' || ext === 'htm') return 'html'; + if (ext === 'json') return 'json'; + if (ext === 'csv') return 'csv'; + if (ext === 'pdf') return 'pdf'; + if (ext === 'docx') return 'docx'; + if (['png', 'jpg', 'jpeg', 'webp'].includes(ext)) return 'image'; + if (['mp3', 'wav', 'm4a', 'flac', 'ogg', 'opus', 'aac', 'aiff', 'aif', 'mp4', 'mov', 'm4v', 'webm', 'mkv', 'avi'].includes(ext)) return 'audio'; + return null; +} + +function canonicalizeFilePath(rawPath) { + if (typeof rawPath !== 'string' || !rawPath) return null; + try { + const real = fs.realpathSync(rawPath); + const st = fs.statSync(real); + if (st.isFile()) return real; + } catch { + // Path does not exist or is not accessible + } + return null; +} + +const activePreviewGrants = new Map(); +const nativeOpenQueue = createNativeOpenQueueCoordinator(); + +function sendInternalPost(requestPath, bodyObj) { + return new Promise((resolve) => { + const bodyStr = JSON.stringify(bodyObj); + const req = http.request( + { + host: SERVER_HOST, + port: SERVER_PORT, + path: requestPath, + method: 'POST', + headers: { + 'x-stashbase-shutdown-token': SERVER_SHUTDOWN_TOKEN, + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(bodyStr), + }, + timeout: 1000, + }, + (res) => { + res.resume(); // drain body to free the socket + resolve({ statusCode: res.statusCode }); + } + ); + req.on('error', () => resolve({ statusCode: 500 })); + req.on('timeout', () => { + req.destroy(); + resolve({ statusCode: 504 }); + }); + req.write(bodyStr); + req.end(); + }); +} + +function sendInternalDelete(requestPath) { + return new Promise((resolve) => { + const req = http.request( + { + host: SERVER_HOST, + port: SERVER_PORT, + path: requestPath, + method: 'DELETE', + headers: { + 'x-stashbase-shutdown-token': SERVER_SHUTDOWN_TOKEN, + }, + timeout: 1000, + }, + (res) => { + res.resume(); // drain body to free the socket + resolve({ statusCode: res.statusCode }); + } + ); + req.on('error', () => resolve({ statusCode: 500 })); + req.on('timeout', () => { + req.destroy(); + resolve({ statusCode: 504 }); + }); + req.end(); + }); +} + +function getFilePathsFromArgs(argv) { + const filePaths = []; + const startIndex = app.isPackaged ? 1 : 2; + for (let i = startIndex; i < argv.length; i++) { + const arg = argv[i]; + if (arg.startsWith('-')) continue; + if (arg.startsWith(WINDOW_ID_ARG_PREFIX)) continue; + const canonical = canonicalizeFilePath(arg); + if (canonical) { + const ext = path.extname(canonical).toLowerCase().slice(1); + if (VIEWABLE_FILE_EXTENSIONS.has(ext)) { + filePaths.push(canonical); + } + } + } + return filePaths; +} + function shellQuote(value) { return `'${String(value).replace(/'/g, `'\\''`)}'`; } @@ -896,6 +1008,7 @@ async function createWindow(initialFolder) { const webContentsId = win.webContents.id; const rendererFlushReadiness = createRendererFlushReadiness(); rendererFlushReadinessByWebContents.set(webContentsId, rendererFlushReadiness); + nativeOpenQueue.attachStartupFilesToWindow(webContentsId); mainWindows.add(win); windowRegistry.add(windowId, win, initialFolder); lastMainWindow = win; @@ -904,6 +1017,9 @@ async function createWindow(initialFolder) { offerClipboardImage(win, true); startClipboardPolling(); }); + win.on('blur', () => { + stopClipboardPolling(); + }); win.on('close', (event) => { if (approvedWindowCloses.has(win) || !rendererFlushReadiness.shouldRequest()) return; event.preventDefault(); @@ -931,9 +1047,16 @@ async function createWindow(initialFolder) { rendererFlush.cancel(webContentsId); rendererFlushReadinessByWebContents.delete(webContentsId); bugReports.discardUnreviewedDraftsForSource(webContentsId); + nativeOpenQueue.cleanup(webContentsId); mainWindows.delete(win); windowRegistry.remove(windowId); releaseWindowContext(windowId); + for (const [grantId, grant] of activePreviewGrants) { + if (grant.windowId === windowId) { + activePreviewGrants.delete(grantId); + void sendInternalDelete(`/api/internal/grants/${encodeURIComponent(grantId)}`); + } + } if (lastMainWindow === win) { lastMainWindow = [...mainWindows].find((candidate) => isLiveMainWindow(candidate)) ?? null; } @@ -966,7 +1089,11 @@ async function createWindow(initialFolder) { } win.on('enter-full-screen', pushFullscreen); win.on('leave-full-screen', pushFullscreen); + win.webContents.on('did-start-loading', () => { + nativeOpenQueue.resetReadiness(webContentsId); + }); win.webContents.on('did-finish-load', () => { + nativeOpenQueue.resetReadiness(webContentsId); rendererFlushReadiness.markDocumentLoaded(); pushFullscreen(); win.webContents.send('updates:state', desktopUpdates.getState()); @@ -1262,6 +1389,110 @@ ipcMain.on('clipboard:setAgentComposerFocused', (event, focused) => { else agentComposerFocusedContents.delete(event.sender.id); }); +function getLastMainWindow() { + const focused = BrowserWindow.getFocusedWindow(); + if (isLiveMainWindow(focused) && !pendingWindowCloses.has(focused)) return focused; + if (isLiveMainWindow(lastMainWindow) && !pendingWindowCloses.has(lastMainWindow)) return lastMainWindow; + return [...mainWindows].find((win) => isLiveMainWindow(win) && !pendingWindowCloses.has(win)) ?? null; +} + +function queueFilesForWindow(win, filePaths) { + if (!win || !isLiveMainWindow(win) || !Array.isArray(filePaths) || filePaths.length === 0) return; + nativeOpenQueue.queueFilesForWindow(win.webContents.id, filePaths, (paths) => { + if (!win.isDestroyed()) win.webContents.send('window:open-external-files', paths); + }); +} + +function handleNativeFileOpenRequest(filePath) { + const canonical = canonicalizeFilePath(filePath); + if (!canonical) return; + const ext = path.extname(canonical).toLowerCase().slice(1); + if (!VIEWABLE_FILE_EXTENSIONS.has(ext)) return; + const win = getLastMainWindow(); + if (win) { + queueFilesForWindow(win, [canonical]); + } else { + nativeOpenQueue.handleStartupFiles([canonical]); + if (app.isReady()) { + void initialWindowFlight.run().then(() => { focusLastMainWindow(); }); + } + } +} + +app.on('open-file', (event, filePath) => { + event.preventDefault(); + handleNativeFileOpenRequest(filePath); +}); + +ipcMain.handle('grant:register', async (event, filePath) => { + const senderWindow = BrowserWindow.fromWebContents(event.sender); + const windowId = windowRegistry.idForWindow(senderWindow); + if (!windowId || typeof filePath !== 'string') throw new Error('Invalid arguments'); + + const canonicalPath = canonicalizeFilePath(filePath); + if (!canonicalPath) throw new Error('File does not exist'); + + const ext = path.extname(canonicalPath).toLowerCase().slice(1); + if (!VIEWABLE_FILE_EXTENSIONS.has(ext)) { + throw new Error(`Unsupported file type: .${ext}`); + } + + const activeFolder = windowRegistry.folderForWindowId(windowId); + if (activeFolder) { + let canonicalFolder = null; + try { + canonicalFolder = fs.realpathSync(activeFolder); + } catch { + canonicalFolder = path.resolve(activeFolder); + } + const relative = path.relative(canonicalFolder, canonicalPath); + const isInternal = !relative.startsWith('..') && !path.isAbsolute(relative); + if (isInternal) { + return { isInternal: true, relPath: relative.replace(/\\/g, '/') }; + } + } + + const grantId = crypto.randomUUID(); + const format = getFileFormat(canonicalPath); + if (!format) { + throw new Error(`Unsupported file type: .${ext}`); + } + const name = path.basename(canonicalPath); + + activePreviewGrants.set(grantId, { windowId, filePath: canonicalPath }); + + const res = await sendInternalPost('/api/internal/grants', { grantId, windowId, filePath: canonicalPath }); + if (res.statusCode !== 200) { + activePreviewGrants.delete(grantId); + throw new Error('Failed to register preview grant on server'); + } + + return { + isInternal: false, + grantId, + name, + format, + absolutePath: canonicalPath, + }; +}); + +ipcMain.handle('grant:revoke', async (event, grantId) => { + if (typeof grantId !== 'string') return false; + const senderWindow = BrowserWindow.fromWebContents(event.sender); + const windowId = windowRegistry.idForWindow(senderWindow); + const grant = activePreviewGrants.get(grantId); + if (grant && (!windowId || grant.windowId !== windowId)) return false; + activePreviewGrants.delete(grantId); + await sendInternalDelete(`/api/internal/grants/${encodeURIComponent(grantId)}`); + return true; +}); + +ipcMain.on('renderer:ready-for-native-files', (event) => { + nativeOpenQueue.markReady(event.sender.id, (paths) => { + event.sender.send('window:open-external-files', paths); + }); +}); + const initialWindowFlight = createSingleFlight(() => app.whenReady().then(() => createWindow())); function focusOAuthReturn() { @@ -1319,8 +1550,20 @@ if (!hasSingleInstanceLock) { // A malformed or unsupported stashbase: URL must not fall through to the // ordinary second-launch focus/create behavior. if (protocolLaunch === 'inert') return; - if (!focusLastMainWindow()) { - void initialWindowFlight.run().then(() => { focusLastMainWindow(); }); + const filePaths = getFilePathsFromArgs(argv); + if (filePaths.length > 0) { + const win = getLastMainWindow(); + if (win) { + focusWindow(win); + queueFilesForWindow(win, filePaths); + } else { + nativeOpenQueue.handleStartupFiles(filePaths); + void initialWindowFlight.run().then(() => { focusLastMainWindow(); }); + } + } else { + if (!focusLastMainWindow()) { + void initialWindowFlight.run().then(() => { focusLastMainWindow(); }); + } } }); @@ -1351,6 +1594,8 @@ if (!hasSingleInstanceLock) { console.warn(`[electron] MCP wrapper refresh failed: ${err && err.message ? err.message : err}`); } installApplicationMenu(); + const startupFiles = getFilePathsFromArgs(process.argv); + nativeOpenQueue.handleStartupFiles(startupFiles); await initialWindowFlight.run(); await desktopUpdates.start(); if (initialProtocolLaunch === 'oauth-return') focusOAuthReturn(); diff --git a/electron/multi-window.cjs b/electron/multi-window.cjs index fde49078..1946ed67 100644 --- a/electron/multi-window.cjs +++ b/electron/multi-window.cjs @@ -201,6 +201,9 @@ function createWindowRegistry({ platform = process.platform } = {}) { .filter((record) => record.folderKey === wanted) .map((record) => record.win); }, + folderForWindowId(windowId) { + return records.get(windowId)?.folderKey ?? null; + }, }; } @@ -442,11 +445,65 @@ function createRendererFlushReadiness() { }; } +function createNativeOpenQueueCoordinator() { + const pendingByWebContents = new Map(); + const readyWebContents = new Set(); + const pendingStartupFiles = []; + + return { + queueFilesForWindow(webContentsId, filePaths, sendFn) { + if (!Array.isArray(filePaths) || filePaths.length === 0) return; + if (readyWebContents.has(webContentsId)) { + sendFn(filePaths); + } else { + const list = pendingByWebContents.get(webContentsId) ?? []; + list.push(...filePaths); + pendingByWebContents.set(webContentsId, list); + } + }, + handleStartupFiles(filePaths) { + if (Array.isArray(filePaths)) { + pendingStartupFiles.push(...filePaths); + } + }, + attachStartupFilesToWindow(webContentsId) { + if (pendingStartupFiles.length === 0) return; + const files = [...pendingStartupFiles]; + pendingStartupFiles.length = 0; + const list = pendingByWebContents.get(webContentsId) ?? []; + list.push(...files); + pendingByWebContents.set(webContentsId, list); + }, + markReady(webContentsId, sendFn) { + readyWebContents.add(webContentsId); + const pending = pendingByWebContents.get(webContentsId); + if (pending && pending.length > 0) { + pendingByWebContents.delete(webContentsId); + sendFn(pending); + } + }, + cleanup(webContentsId) { + readyWebContents.delete(webContentsId); + pendingByWebContents.delete(webContentsId); + }, + resetReadiness(webContentsId) { + readyWebContents.delete(webContentsId); + }, + isReady(webContentsId) { + return readyWebContents.has(webContentsId); + }, + getPending(webContentsId) { + return pendingByWebContents.get(webContentsId) ?? []; + }, + }; +} + module.exports = { WINDOW_ID_ARG_PREFIX, buildElectronSmokeArgs, classifyProtocolLaunch, createApplicationMenuTemplate, + createNativeOpenQueueCoordinator, createRendererFlushCoordinator, createRendererFlushReadiness, createSafeReloadCoordinator, diff --git a/electron/multi-window.test.cjs b/electron/multi-window.test.cjs index 32b38fdc..e790f645 100644 --- a/electron/multi-window.test.cjs +++ b/electron/multi-window.test.cjs @@ -13,6 +13,7 @@ const { buildElectronSmokeArgs, classifyProtocolLaunch, createApplicationMenuTemplate, + createNativeOpenQueueCoordinator, createRendererFlushCoordinator, createRendererFlushReadiness, createSafeReloadCoordinator, @@ -649,3 +650,42 @@ test('preload reads and bounds the main-process window identity', () => { 128, ); }); + +test('native-open queue coordinator isolates queues per target window and handles startup files', () => { + const coordinator = createNativeOpenQueueCoordinator(); + const sentWin1 = []; + const sentWin2 = []; + + // Cold startup file arrived before any window exists + coordinator.handleStartupFiles(['/path/to/startup.md']); + + // Window 1 is created: attach startup files to it + coordinator.attachStartupFilesToWindow(101); + assert.deepEqual(coordinator.getPending(101), ['/path/to/startup.md']); + + // Queue a file specifically for Window 2 (not ready yet) + coordinator.queueFilesForWindow(102, ['/path/to/target-win2.pdf'], (paths) => sentWin2.push(...paths)); + assert.deepEqual(coordinator.getPending(102), ['/path/to/target-win2.pdf']); + assert.deepEqual(sentWin2, []); + + // Window 1 announces ready: drains ONLY Window 1's queue + coordinator.markReady(101, (paths) => sentWin1.push(...paths)); + assert.deepEqual(sentWin1, ['/path/to/startup.md']); + assert.deepEqual(coordinator.getPending(101), []); + assert.deepEqual(sentWin2, []); // Window 2's queue was untouched! + + // Window 2 announces ready: drains Window 2's queue + coordinator.markReady(102, (paths) => sentWin2.push(...paths)); + assert.deepEqual(sentWin2, ['/path/to/target-win2.pdf']); + assert.deepEqual(coordinator.getPending(102), []); + + // While Window 1 is ready, subsequent files send immediately + coordinator.queueFilesForWindow(101, ['/path/to/live.docx'], (paths) => sentWin1.push(...paths)); + assert.deepEqual(sentWin1, ['/path/to/startup.md', '/path/to/live.docx']); + + // Cleanup removes readiness and any pending + coordinator.cleanup(101); + coordinator.cleanup(102); + assert.equal(coordinator.isReady(101), false); + assert.equal(coordinator.isReady(102), false); +}); diff --git a/electron/preload.cjs b/electron/preload.cjs index f3b6eafd..1edbc7d7 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -4,7 +4,7 @@ * `window.electron`. Only what the renderer actually needs goes here — * never the raw ipcRenderer. */ -const { contextBridge, ipcRenderer } = require('electron'); +const { contextBridge, ipcRenderer, webUtils } = require('electron'); const { windowIdFromArgv } = require('./multi-window.cjs'); const windowId = windowIdFromArgv(process.argv); @@ -147,4 +147,16 @@ contextBridge.exposeInMainWorld('electron', { /** While the Agent composer owns focus, pasted images become temporary * chat context instead of candidates for library import. */ setAgentComposerFocused: (focused) => ipcRenderer.send('clipboard:setAgentComposerFocused', focused === true), + /** Get absolute path for file from sandboxed renderer drop. */ + getPathForFile: (file) => webUtils.getPathForFile(file), + registerPreviewGrant: (filePath) => ipcRenderer.invoke('grant:register', filePath), + revokePreviewGrant: (grantId) => ipcRenderer.invoke('grant:revoke', grantId), + onOpenExternalFiles: (handler) => { + const wrapped = (_event, paths) => { + if (Array.isArray(paths)) handler(paths); + }; + ipcRenderer.on('window:open-external-files', wrapped); + return () => ipcRenderer.removeListener('window:open-external-files', wrapped); + }, + notifyRendererReadyForNativeFiles: () => ipcRenderer.send('renderer:ready-for-native-files'), }); diff --git a/package.json b/package.json index cb2f2a6c..786efdd6 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "test:docs": "node scripts/check-docs.mjs", "test:inventory": "node scripts/check-test-inventory.mjs", "test:conversion-scheduler": "node --import tsx --test server/filesystem-path.test.ts server/folder-relative-path.test.ts server/folder-window.test.ts server/window-context-route.test.ts server/internal-shutdown-route.test.ts server/files.test.ts server/file-formats.test.ts server/file-hash.test.ts server/http.test.ts server/markdown-source-format.test.ts server/keyword-search.test.ts server/index-status.test.ts server/indexer-mfs-path.test.ts server/semantic-workload.test.ts server/session-path.test.ts server/conversion-scheduler.test.ts server/conversion-auxiliary.test.ts server/conversion-status.test.ts server/conversion.test.ts server/extractor-process.test.ts server/audio-transcription.test.ts server/transcription-models.test.ts server/transcription-provider.test.ts server/transcription-runtime.test.ts server/transcription-tools.test.ts server/upload.test.ts server/client-error.test.ts server/semantic-indexing-state.test.ts", - "test:library-files": "node --import tsx --test server/library-file-mutations.test.ts server/library-operations/index.test.ts server/routes/library-files.test.ts server/routes/attach.test.ts server/__tests__/file-listing.test.ts", + "test:library-files": "node --import tsx --test server/library-file-mutations.test.ts server/library-operations/index.test.ts server/routes/library-files.test.ts server/routes/attach.test.ts server/routes/internal-grants.test.ts server/__tests__/file-listing.test.ts", "test:retrieval": "node --import tsx --test server/retrieval/index.test.ts", "test:agent": "node --import tsx --test server/__tests__/agent.test.ts server/__tests__/agent-contract.test.ts server/__tests__/agent-history-routes.test.ts server/__tests__/agent-projects.test.ts server/__tests__/agent-runtime-installer.test.ts server/__tests__/codex-agent.test.ts server/__tests__/agent-cli.test.ts server/codex-history.test.ts", "test:agent:native": "node --import tsx --test server/__tests__/agent-native-smoke.test.ts", @@ -261,8 +261,8 @@ "yaml": "^2.9.0" }, "devDependencies": { - "@playwright/test": "1.62.1", "@phosphor-icons/core": "2.1.1", + "@playwright/test": "1.62.1", "@tailwindcss/vite": "^4.3.3", "@types/better-sqlite3": "^7.6.13", "@types/express": "^4.17.21", diff --git a/scripts/check-renderer-chunks.mjs b/scripts/check-renderer-chunks.mjs index f17896c0..e10fc282 100644 --- a/scripts/check-renderer-chunks.mjs +++ b/scripts/check-renderer-chunks.mjs @@ -9,11 +9,12 @@ const manifestPath = path.join(outputRoot, '.vite', 'manifest.json'); * not a freeze on shell features. Raised 400 → 416 KiB when the activity * rail became the titlebar controls + a sidebar Settings row; 416 → 418 * when the active-folder header gained the folder-switcher trigger and - * its menu-item builder (the menu body itself stays in the lazy - * ManagedMenu chunk). Both are eager chrome by definition. Raise it only - * for shell UI that must load with the window — anything a user can open - * on demand belongs in a dynamic entry above. */ -const initialJsBudgetBytes = 418 * 1024; + * its menu-item builder (the menu body itself stays in the lazy ManagedMenu + * chunk); 418 → 423 when native-open delivery and the sidebar/main drop-zone + * router became shell-owned behavior. External grant opening and refresh stay + * in dynamic entries. Raise this only for shell UI that must load with the + * window — anything a user can open on demand belongs in a dynamic entry. */ +const initialJsBudgetBytes = 423 * 1024; const expectedEntries = [ 'src/components/ChatPane.tsx', 'src/components/agent/AgentMathMarkdown.tsx', diff --git a/server/http.ts b/server/http.ts index 52ac8fb7..b050b4db 100644 --- a/server/http.ts +++ b/server/http.ts @@ -85,6 +85,8 @@ function assetWindowIdFromPath(reqPath: string): string | undefined { ? '/asset-audio-preview/__window/' : reqPath.startsWith('/asset-derived/__window/') ? '/asset-derived/__window/' + : reqPath.startsWith('/asset-preview-grant/__window/') + ? '/asset-preview-grant/__window/' : reqPath.startsWith('/asset/__window/') ? '/asset/__window/' : null; diff --git a/server/index.ts b/server/index.ts index f5cd1f9e..e1bc4a90 100644 --- a/server/index.ts +++ b/server/index.ts @@ -50,6 +50,7 @@ import { closeStateDb } from './state-db.ts'; import { requireFolder, withWindowContext } from './http.ts'; import { mount as mountWindowContextRoutes } from './routes/window-context.ts'; import { mountInternalShutdownRoute } from './routes/internal-shutdown.ts'; +import { mountInternalGrantsRoute } from './routes/internal-grants.ts'; import { mount as mountLibraryRoutes } from './routes/library.ts'; import { mount as mountEmbedderRoutes } from './routes/embedder.ts'; import { mount as mountAppearanceRoutes } from './routes/appearance.ts'; @@ -271,6 +272,8 @@ mountInternalShutdownRoute(app, { shutdown: () => { void shutdown('Electron request'); }, }); +mountInternalGrantsRoute(app, process.env.STASHBASE_SHUTDOWN_TOKEN ?? ''); + // Static layer is mounted before the API routes for renderer bundle // requests, but data routes must bypass it entirely. In packaged asar // builds, serve-static can still issue directory-normalisation redirects @@ -287,6 +290,8 @@ if (!DEV_VITE) { req.path.startsWith('/asset/') || req.path === '/asset-audio-preview' || req.path.startsWith('/asset-audio-preview/') || + req.path === '/asset-preview-grant' || + req.path.startsWith('/asset-preview-grant/') || req.path === '/mcp' ) { return next(); diff --git a/server/routes/internal-grants.test.ts b/server/routes/internal-grants.test.ts new file mode 100644 index 00000000..4ea5cfc6 --- /dev/null +++ b/server/routes/internal-grants.test.ts @@ -0,0 +1,161 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import fs from 'node:fs'; +import path from 'node:path'; +import express from 'express'; +import { mountInternalGrantsRoute } from './internal-grants.ts'; +import { withWindowContext } from '../http.ts'; + +test('internal preview grants API lifecycle', async () => { + const app = express(); + app.use(express.json()); + app.use(withWindowContext); + + const testToken = 'test-token-12345'; + mountInternalGrantsRoute(app, testToken); + + const server = app.listen(0, '127.0.0.1'); + await new Promise((resolve, reject) => { + server.once('listening', resolve); + server.once('error', reject); + }); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + const baseUrl = `http://127.0.0.1:${address.port}`; + + const tempFile = path.resolve('/tmp/test-grant-spec.md'); + fs.writeFileSync(tempFile, '# Grant Content\nHello World', 'utf8'); + + try { + const grantId = 'test-grant-id'; + const windowId = 'win-test-456'; + + const resFailToken = await fetch(`${baseUrl}/api/internal/grants`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ grantId, windowId, filePath: tempFile }), + }); + assert.equal(resFailToken.status, 403); + + const resReg = await fetch(`${baseUrl}/api/internal/grants`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-stashbase-shutdown-token': testToken, + }, + body: JSON.stringify({ grantId, windowId, filePath: tempFile }), + }); + assert.equal(resReg.status, 200); + + const resWrongWin = await fetch(`${baseUrl}/api/grant/${grantId}/text`, { + headers: { 'x-stashbase-window-id': 'win-wrong' }, + }); + assert.equal(resWrongWin.status, 403); + + const resCorrectWin = await fetch(`${baseUrl}/api/grant/${grantId}/text`, { + headers: { 'x-stashbase-window-id': windowId }, + }); + assert.equal(resCorrectWin.status, 200); + const body = await resCorrectWin.json() as { content: string }; + assert.equal(body.content, '# Grant Content\nHello World'); + + const resAssetWrong = await fetch(`${baseUrl}/asset-preview-grant/${grantId}`, { + headers: { 'x-stashbase-window-id': 'win-wrong' }, + }); + assert.equal(resAssetWrong.status, 403); + + const resAssetCorrect = await fetch(`${baseUrl}/asset-preview-grant/${grantId}`, { + headers: { 'x-stashbase-window-id': windowId }, + }); + assert.equal(resAssetCorrect.status, 200); + const text = await resAssetCorrect.text(); + assert.equal(text, '# Grant Content\nHello World'); + + // Test browser-style subresource loading via __window/ in URL path (no x-stashbase-window-id header) + const resWindowPathWrong = await fetch(`${baseUrl}/asset-preview-grant/__window/win-wrong/${grantId}`); + assert.equal(resWindowPathWrong.status, 403); + + const resWindowPathCorrect = await fetch(`${baseUrl}/asset-preview-grant/__window/${windowId}/${grantId}`); + assert.equal(resWindowPathCorrect.status, 200); + const textWindowPath = await resWindowPathCorrect.text(); + assert.equal(textWindowPath, '# Grant Content\nHello World'); + + const resRevokeFail = await fetch(`${baseUrl}/api/internal/grants/${grantId}`, { + method: 'DELETE', + }); + assert.equal(resRevokeFail.status, 403); + + const resRevoke = await fetch(`${baseUrl}/api/internal/grants/${grantId}`, { + method: 'DELETE', + headers: { 'x-stashbase-shutdown-token': testToken }, + }); + assert.equal(resRevoke.status, 200); + + const resAfterRevoke = await fetch(`${baseUrl}/api/grant/${grantId}/text`, { + headers: { 'x-stashbase-window-id': windowId }, + }); + assert.equal(resAfterRevoke.status, 404); + + // Non-existent path registration rejected with 400 + const resNonExistent = await fetch(`${baseUrl}/api/internal/grants`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-stashbase-shutdown-token': testToken, + }, + body: JSON.stringify({ grantId: 'fake-id', windowId, filePath: '/tmp/does-not-exist-12345.md' }), + }); + assert.equal(resNonExistent.status, 400); + + // Symlink repointing test: registering a valid file then replacing with a symlink to another destination + const validTargetFile = path.resolve('/tmp/test-grant-valid-target.md'); + const secretFile = path.resolve('/tmp/test-grant-secret.md'); + const symlinkPath = path.resolve('/tmp/test-grant-symlink.md'); + + fs.writeFileSync(validTargetFile, 'Valid Target Content', 'utf8'); + fs.writeFileSync(secretFile, 'Secret Unauthorized Content', 'utf8'); + fs.symlinkSync(validTargetFile, symlinkPath); + + try { + const symGrantId = 'symlink-grant-id'; + // Register with canonical target + const resSymReg = await fetch(`${baseUrl}/api/internal/grants`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-stashbase-shutdown-token': testToken, + }, + body: JSON.stringify({ grantId: symGrantId, windowId, filePath: symlinkPath }), + }); + assert.equal(resSymReg.status, 200); + + // Successfully read through canonical target + const resSymRead = await fetch(`${baseUrl}/api/grant/${symGrantId}/text`, { + headers: { 'x-stashbase-window-id': windowId }, + }); + assert.equal(resSymRead.status, 200); + const symBody = await resSymRead.json() as { content: string }; + assert.equal(symBody.content, 'Valid Target Content'); + + // Now repoint the target file to the secret file (or replace the registered canonical file with a symlink) + fs.unlinkSync(validTargetFile); + fs.symlinkSync(secretFile, validTargetFile); + + // Reading must now be rejected with 403 because realpath !== grant.filePath + const resSymExploit = await fetch(`${baseUrl}/api/grant/${symGrantId}/text`, { + headers: { 'x-stashbase-window-id': windowId }, + }); + assert.equal(resSymExploit.status, 403); + + const resAssetSymExploit = await fetch(`${baseUrl}/asset-preview-grant/__window/${windowId}/${symGrantId}`); + assert.equal(resAssetSymExploit.status, 403); + } finally { + fs.rmSync(validTargetFile, { force: true }); + fs.rmSync(secretFile, { force: true }); + fs.rmSync(symlinkPath, { force: true }); + } + } finally { + fs.rmSync(tempFile, { force: true }); + await new Promise((resolve) => server.close(() => resolve())); + } +}); diff --git a/server/routes/internal-grants.ts b/server/routes/internal-grants.ts new file mode 100644 index 00000000..dfd77043 --- /dev/null +++ b/server/routes/internal-grants.ts @@ -0,0 +1,120 @@ +import crypto from 'node:crypto'; +import express from 'express'; +import fs from 'node:fs'; +import path from 'node:path'; +import { analyzeHtml } from '../html.ts'; +import { sendError } from '../http.ts'; +import { currentWindowId } from '../folder.ts'; + +interface Grant { + windowId: string; + filePath: string; +} + +const serverPreviewGrants = new Map(); + +export function getGrant(grantId: string): Grant | undefined { + return serverPreviewGrants.get(grantId); +} + +function tokenMatches(actual: string | undefined, expected: string): boolean { + if (!actual || !expected) return false; + const left = Buffer.from(actual); + const right = Buffer.from(expected); + return left.length === right.length && crypto.timingSafeEqual(left, right); +} + +export function mountInternalGrantsRoute( + app: express.Express, + token: string, +): void { + // Register a grant + app.post('/api/internal/grants', (req, res) => { + if (!tokenMatches(req.header('x-stashbase-shutdown-token'), token)) { + return res.status(403).json({ error: 'forbidden' }); + } + const { grantId, windowId, filePath } = req.body; + if (typeof grantId !== 'string' || typeof windowId !== 'string' || typeof filePath !== 'string') { + return res.status(400).json({ error: 'invalid payload' }); + } + try { + const canonical = fs.realpathSync(filePath); + serverPreviewGrants.set(grantId, { windowId, filePath: canonical }); + res.json({ ok: true }); + } catch { + return res.status(400).json({ error: 'invalid file path' }); + } + }); + + // Revoke a grant + app.delete('/api/internal/grants/:grantId', (req, res) => { + if (!tokenMatches(req.header('x-stashbase-shutdown-token'), token)) { + return res.status(403).json({ error: 'forbidden' }); + } + serverPreviewGrants.delete(req.params.grantId); + res.json({ ok: true }); + }); + + // Get external file text content + app.get('/api/grant/:grantId/text', (req, res) => { + const grant = getGrant(req.params.grantId); + if (!grant) return res.status(404).end(); + + const reqWindowId = currentWindowId(); + if (!reqWindowId || grant.windowId !== reqWindowId) { + return res.status(403).json({ error: 'forbidden' }); + } + + try { + const real = fs.realpathSync(grant.filePath); + if (real !== grant.filePath) { + return res.status(403).json({ error: 'forbidden' }); + } + const content = fs.readFileSync(grant.filePath, 'utf8'); + res.json({ content }); + } catch (err: unknown) { + sendError(res, err); + } + }); + + // Serve transient file assets + app.get('/asset-preview-grant/*', (req, res) => { + const rawPath = (req.params as Record)[0] ?? ''; + const windowPrefixMatch = rawPath.match(/^__window\/[^/]+\/(.+)$/); + const grantId = windowPrefixMatch ? windowPrefixMatch[1] : rawPath; + + const grant = getGrant(grantId); + if (!grant) return res.status(404).end(); + + const reqWindowId = currentWindowId(); + if (!reqWindowId || grant.windowId !== reqWindowId) { + return res.status(403).end(); + } + + const abs = grant.filePath; + try { + if (!fs.existsSync(abs)) return res.status(404).end(); + const real = fs.realpathSync(abs); + if (real !== abs) { + return res.status(403).end(); + } + } catch { + return res.status(404).end(); + } + + const ext = path.extname(abs).toLowerCase(); + + if (ext === '.html' || ext === '.htm') { + try { + const raw = fs.readFileSync(abs, 'utf8'); + const { preparedHtml } = analyzeHtml(raw); + res.type('text/html').send(preparedHtml); + } catch (err: unknown) { + sendError(res, err); + } + return; + } + + res.sendFile(abs); + }); +} diff --git a/web-src/src/App.tsx b/web-src/src/App.tsx index 3c0cc64d..9a3a3742 100644 --- a/web-src/src/App.tsx +++ b/web-src/src/App.tsx @@ -63,8 +63,8 @@ export function App() { } function AppBody() { - const veilHot = useGlobalDragDrop(); - const { state, dispatch } = useApp(); + const { hot: veilHot, activeZone } = useGlobalDragDrop(); + const { state, actions, dispatch } = useApp(); const { previewImage, closePreviewImage } = usePreviewMessages(); const { clipboardOffer, saveClipboardOffer, dismissClipboardOffer } = useClipboardImageOffer(); const initialFolderPending = useRef(new URLSearchParams(window.location.search).has('folder')); @@ -131,6 +131,18 @@ function AppBody() { // late boot push could clobber the harness's registration. if (state.booted) document.body.dataset.bootSettled = '1'; }, [state.booted, state.folderPath]); + useEffect(() => { + const bridge = (window as { electron?: any }).electron; + if (!bridge) return; + bridge.notifyRendererReadyForNativeFiles?.(); + return bridge.onOpenExternalFiles?.((paths: string[]) => { + void (async () => { + for (const filePath of paths) { + await actions.openExternalFilePath(filePath); + } + })(); + }); + }, [actions]); // macOS fullscreen toggles the `is-fullscreen` body class so the sidebar // can drop its traffic-light drag zone. That's owned entirely by the // preload (registered before page load, so it catches the initial state @@ -177,7 +189,12 @@ function AppBody() { )} - + {state.ctxMenu && ( ('PATCH', agentSessionBase(agent) + '/' + encodeURIComponent(id) + sessionScopeQuery(scope), { title }), deleteSession: (id: string, agent: 'claude' | 'codex' = 'claude', scope?: SessionScopeParams) => send>('DELETE', agentSessionBase(agent) + '/' + encodeURIComponent(id) + sessionScopeQuery(scope)), + getExternalFileText: (grantId: string): Promise<{ content: string }> => + getJson<{ content: string }>(`/api/grant/${encodeURIComponent(grantId)}/text`), }; function agentSessionBase(agent: 'claude' | 'codex'): string { @@ -391,7 +393,10 @@ export function assetUrl(name: string, folder?: string): string { return assetScopePrefix('/asset/', folder) + encodePath(name); } -export function versionedAssetUrl(name: string, version: string, folder?: string): string { +export function versionedAssetUrl(name: string, version: string, folder?: string, grantId?: string): string { + if (grantId) { + return `${assetWindowPrefix('/asset-preview-grant/')}${encodeURIComponent(grantId)}?v=${encodeURIComponent(version)}`; + } const url = assetUrl(name, folder); const sep = url.includes('?') ? '&' : '?'; return `${url}${sep}v=${encodeURIComponent(version)}`; diff --git a/web-src/src/components/AudioPreview.tsx b/web-src/src/components/AudioPreview.tsx index a5d0131b..afda5463 100644 --- a/web-src/src/components/AudioPreview.tsx +++ b/web-src/src/components/AudioPreview.tsx @@ -22,12 +22,17 @@ import { StatusMessage } from './ui/status'; export function AudioPreview({ name }: { name: string }) { const { state, activeTab, actions } = useApp(); + const isExternal = activeTab?.file?.name === name && Boolean(activeTab.file.isExternal); const version = activeTab?.file?.name === name ? activeTab.file.version ?? '' : ''; // Out-of-folder tab: every URL and prepare/transcript request must carry // the file's own member folder instead of the window's. const sourceFolder = activeTab?.file?.name === name ? activeTab.file.folder : undefined; + const sourceGrantId = activeTab?.file?.name === name ? activeTab.file.grantId : undefined; const requestFolder = sourceFolder ?? state.folderPath; - const directSrc = useMemo(() => versionedAssetUrl(name, version, sourceFolder), [name, version, sourceFolder]); + const directSrc = useMemo( + () => versionedAssetUrl(name, version, sourceFolder, sourceGrantId), + [name, version, sourceFolder, sourceGrantId], + ); const fallbackSrc = useMemo(() => audioPreviewAssetUrl(name, version, sourceFolder), [name, version, sourceFolder]); const [positionMs, setPositionMs] = useState(0); const audioRef = useRef(null); @@ -37,18 +42,20 @@ export function AudioPreview({ name }: { name: string }) { folder: requestFolder, directSrc, fallbackSrc, + enabled: !isExternal, }); const transcription = useAudioTranscriptController({ name, folder: requestFolder, version, conversionRevision: state.conversionRevision, + enabled: !isExternal, }); useEffect(() => { setPositionMs(0); - playbackPositionRef.current.setSourceIdentity(JSON.stringify([requestFolder, name, version])); - }, [name, requestFolder, version]); + playbackPositionRef.current.setSourceIdentity(JSON.stringify([requestFolder, name, version, sourceGrantId])); + }, [name, requestFolder, version, sourceGrantId]); useEffect(() => { const highlight = activeTab?.pendingHighlight; @@ -139,7 +146,7 @@ export function AudioPreview({ name }: { name: string }) { )} - {(transcription.state?.status === 'ready' || transcription.state?.status === 'failed' || transcription.state?.status === 'cancelled') && ( + {!isExternal && (transcription.state?.status === 'ready' || transcription.state?.status === 'failed' || transcription.state?.status === 'cancelled') && (