diff --git a/.changeset/workspace-files-agent-visible.md b/.changeset/workspace-files-agent-visible.md new file mode 100644 index 000000000..0e9955544 --- /dev/null +++ b/.changeset/workspace-files-agent-visible.md @@ -0,0 +1,6 @@ +--- +"@open-codesign/desktop": patch +"@open-codesign/ui": patch +--- + +Expose bound workspace files to the agent and preview before the first generation, including workspace asset loading through `workspace://`, with bounded async workspace seeding. diff --git a/apps/desktop/src/main/design-workspace.test.ts b/apps/desktop/src/main/design-workspace.test.ts index 6c1b15a09..0723837d2 100644 --- a/apps/desktop/src/main/design-workspace.test.ts +++ b/apps/desktop/src/main/design-workspace.test.ts @@ -133,19 +133,22 @@ describe('bindWorkspace', () => { expect(await stat(path.join(workspace, 'tracked.txt'))).toEqual(destinationBefore); }); - it('throws when another active design already owns the workspace path', async () => { + it('allows another design to share an already-bound workspace path', async () => { const db = initInMemoryDb(); const design = createDesign(db); const otherDesign = createDesign(db); - const conflictPath = normalizeWorkspacePath(await makeTempDir('ocd-ws-conflict-')); - updateDesignWorkspace(db, otherDesign.id, conflictPath); + const sharedPath = normalizeWorkspacePath(await makeTempDir('ocd-ws-shared-')); + updateDesignWorkspace(db, otherDesign.id, sharedPath); - await expect(bindWorkspace(db, design.id, conflictPath, false)).rejects.toThrow( - 'Workspace path is already bound to another design', - ); + const bound = await bindWorkspace(db, design.id, sharedPath, false); + + expect(bound.workspacePath).toBe(sharedPath); expect(db.prepare('SELECT workspace_path FROM designs WHERE id = ?').get(design.id)).toEqual({ - workspace_path: null, + workspace_path: sharedPath, }); + expect( + db.prepare('SELECT workspace_path FROM designs WHERE id = ?').get(otherDesign.id), + ).toEqual({ workspace_path: sharedPath }); }); it('treats case-only workspace differences as the same path on Windows for the same design', async () => { @@ -164,16 +167,16 @@ describe('bindWorkspace', () => { }); }); - it('treats case-only workspace differences as conflicts on Windows across designs', async () => { + it('case-only differences on Windows still resolve to a shared bind across designs', async () => { await withMockedPlatform('win32', async () => { const db = initInMemoryDb(); const design = createDesign(db); const otherDesign = createDesign(db); - updateDesignWorkspace(db, otherDesign.id, normalizeWorkspacePath('/Users/Roy/Workspace')); + const stored = normalizeWorkspacePath('/Users/Roy/Workspace'); + updateDesignWorkspace(db, otherDesign.id, stored); - await expect(bindWorkspace(db, design.id, '/users/roy/workspace', false)).rejects.toThrow( - 'Workspace path is already bound to another design', - ); + const bound = await bindWorkspace(db, design.id, '/users/roy/workspace', false); + expect(bound.workspacePath).toBe(normalizeWorkspacePath('/users/roy/workspace')); }); }); diff --git a/apps/desktop/src/main/design-workspace.ts b/apps/desktop/src/main/design-workspace.ts index 1efc2ef0f..cd69073a8 100644 --- a/apps/desktop/src/main/design-workspace.ts +++ b/apps/desktop/src/main/design-workspace.ts @@ -137,8 +137,14 @@ export async function bindWorkspace( logger.info('workspace.bind.noop', { designId, workspacePath: normalizedPath }); return current; } + // Upstream rejected binding the same folder to two designs. Their own v0.2 + // doc explicitly says multiple sessions can share a workspace, so the + // conflict guard is over-zealous: it forces the user to either duplicate + // the folder or shuffle bindings just to spin up a second design view of + // the same project. We log the overlap (so the issue is auditable) but + // let the bind proceed. if (checkWorkspaceConflict(db, designId, normalizedPath)) { - throw new Error('Workspace path is already bound to another design'); + logger.info('workspace.bind.shared', { designId, workspacePath: normalizedPath }); } logger.info('workspace.bind.start', { diff --git a/apps/desktop/src/main/done-verify.ts b/apps/desktop/src/main/done-verify.ts index 3d713da64..6f4d5e427 100644 --- a/apps/desktop/src/main/done-verify.ts +++ b/apps/desktop/src/main/done-verify.ts @@ -66,7 +66,7 @@ export function makeRuntimeVerifier(): DoneRuntimeVerifier { const onConsole = (...args: unknown[]) => { // Electron 35+ emits a single Event-like object; older majors emit // positional (event, level, message, line, sourceId). Detect by - // arity — a single object argument means the new shape. + // arity: a single object argument means the new shape. let level: ConsoleMessageEvent['level']; let message: string; let line: number | undefined; @@ -99,7 +99,7 @@ export function makeRuntimeVerifier(): DoneRuntimeVerifier { }; try { - // Cast through `any` for the event-name overloads — Electron's WebContents + // Cast through `any` for the event-name overloads: Electron's WebContents // event union doesn't include 'did-fail-load' / 'preload-error' in the // overload set this TS lib resolves, even though the events fire at runtime. const wc = win.webContents as unknown as { diff --git a/apps/desktop/src/main/electron-runtime.ts b/apps/desktop/src/main/electron-runtime.ts index a8a2a7c98..9d2657171 100644 --- a/apps/desktop/src/main/electron-runtime.ts +++ b/apps/desktop/src/main/electron-runtime.ts @@ -4,4 +4,5 @@ const require = createRequire(import.meta.url); export const electron = require('electron') as typeof import('electron'); -export const { app, BrowserWindow, clipboard, dialog, ipcMain, safeStorage, shell } = electron; +export const { app, BrowserWindow, clipboard, dialog, ipcMain, protocol, safeStorage, shell } = + electron; diff --git a/apps/desktop/src/main/files-ipc.test.ts b/apps/desktop/src/main/files-ipc.test.ts new file mode 100644 index 000000000..f240e1d4d --- /dev/null +++ b/apps/desktop/src/main/files-ipc.test.ts @@ -0,0 +1,110 @@ +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { walkWorkspaceFiles } from './files-ipc'; + +describe('walkWorkspaceFiles', () => { + let workspaceDir: string; + + beforeEach(async () => { + workspaceDir = await mkdtemp(path.join(os.tmpdir(), 'oc-files-ipc-')); + }); + + it('returns empty array for an empty workspace', async () => { + const files = await walkWorkspaceFiles(workspaceDir); + expect(files).toEqual([]); + }); + + it('lists html and asset files at the root', async () => { + await writeFile(path.join(workspaceDir, 'index.html'), ''); + await writeFile(path.join(workspaceDir, 'styles.css'), 'body{}'); + await writeFile(path.join(workspaceDir, 'app.js'), ''); + const files = await walkWorkspaceFiles(workspaceDir); + const paths = files.map((f) => f.path); + expect(paths).toContain('index.html'); + expect(paths).toContain('styles.css'); + expect(paths).toContain('app.js'); + }); + + it('marks .html and .htm as kind=html', async () => { + await writeFile(path.join(workspaceDir, 'a.html'), ''); + await writeFile(path.join(workspaceDir, 'b.htm'), ''); + await writeFile(path.join(workspaceDir, 'c.css'), ''); + const files = await walkWorkspaceFiles(workspaceDir); + const byPath = Object.fromEntries(files.map((f) => [f.path, f.kind])); + expect(byPath['a.html']).toBe('html'); + expect(byPath['b.htm']).toBe('html'); + expect(byPath['c.css']).toBe('asset'); + }); + + it('sorts html files first, then assets, both alphabetical', async () => { + await writeFile(path.join(workspaceDir, 'zzz.html'), ''); + await writeFile(path.join(workspaceDir, 'aaa.css'), ''); + await writeFile(path.join(workspaceDir, 'bbb.html'), ''); + const files = await walkWorkspaceFiles(workspaceDir); + expect(files.map((f) => f.path)).toEqual(['bbb.html', 'zzz.html', 'aaa.css']); + }); + + it('walks nested directories', async () => { + await mkdir(path.join(workspaceDir, 'sub')); + await writeFile(path.join(workspaceDir, 'sub', 'page.html'), ''); + await writeFile(path.join(workspaceDir, 'sub', 'logo.svg'), ''); + const files = await walkWorkspaceFiles(workspaceDir); + const paths = files.map((f) => f.path); + expect(paths).toContain('sub/page.html'); + expect(paths).toContain('sub/logo.svg'); + }); + + it('skips standard heavy/build directories', async () => { + for (const dir of ['node_modules', '.git', 'dist', 'build', '.next']) { + await mkdir(path.join(workspaceDir, dir)); + await writeFile(path.join(workspaceDir, dir, 'inside.html'), ''); + } + await writeFile(path.join(workspaceDir, 'visible.html'), ''); + const files = await walkWorkspaceFiles(workspaceDir); + const paths = files.map((f) => f.path); + expect(paths).toEqual(['visible.html']); + }); + + it('skips hidden (dotfile) entries', async () => { + await writeFile(path.join(workspaceDir, '.env'), 'SECRET=1'); + await writeFile(path.join(workspaceDir, '.hidden.html'), ''); + await writeFile(path.join(workspaceDir, 'visible.html'), ''); + const files = await walkWorkspaceFiles(workspaceDir); + expect(files.map((f) => f.path)).toEqual(['visible.html']); + }); + + it('skips files without an allowed extension', async () => { + await writeFile(path.join(workspaceDir, 'binary.exe'), ''); + await writeFile(path.join(workspaceDir, 'Makefile'), ''); + await writeFile(path.join(workspaceDir, 'page.html'), ''); + const files = await walkWorkspaceFiles(workspaceDir); + expect(files.map((f) => f.path)).toEqual(['page.html']); + }); + + it('returns size and ISO mtime per entry', async () => { + const content = 'hello world'; + await writeFile(path.join(workspaceDir, 'page.html'), content); + const files = await walkWorkspaceFiles(workspaceDir); + expect(files).toHaveLength(1); + expect(files[0]?.size).toBe(content.length); + expect(files[0]?.updatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it('respects the max-files cap', async () => { + for (let i = 0; i < 10; i++) { + await writeFile(path.join(workspaceDir, `file-${i}.html`), ''); + } + const files = await walkWorkspaceFiles(workspaceDir, 3); + expect(files).toHaveLength(3); + }); + + it('handles unreadable directories gracefully', async () => { + // Walking a non-existent path returns [] rather than throwing -- the + // workspace folder might have been deleted between the bind and the + // list call and the panel should just go empty. + const files = await walkWorkspaceFiles(path.join(workspaceDir, 'does-not-exist')); + expect(files).toEqual([]); + }); +}); diff --git a/apps/desktop/src/main/files-ipc.ts b/apps/desktop/src/main/files-ipc.ts new file mode 100644 index 000000000..2f09e0c60 --- /dev/null +++ b/apps/desktop/src/main/files-ipc.ts @@ -0,0 +1,176 @@ +import type { Dirent } from 'node:fs'; +import { readdir, stat } from 'node:fs/promises'; +import path from 'node:path'; +import type { CoreLogger } from '@open-codesign/core'; +import { CodesignError } from '@open-codesign/shared'; +import type Database from 'better-sqlite3'; +import { ipcMain } from './electron-runtime'; +import { getDesign } from './snapshots-db'; +import { + WORKSPACE_WALK_MAX_FILES, + shouldSkipDirEntry, + shouldSkipFileEntry, +} from './workspace-walk'; + +const HTML_EXTS = new Set(['.html', '.htm']); +// Anything renderable in an iframe directly (or useful to surface in the +// Files panel) gets an entry. Non-listed extensions are skipped to keep +// the panel uncluttered -- if the agent or user needs a niche file type +// they can edit the list here. +const ASSET_EXTS = new Set([ + '.css', + '.js', + '.mjs', + '.cjs', + '.jsx', + '.ts', + '.tsx', + '.json', + '.svg', + '.png', + '.jpg', + '.jpeg', + '.gif', + '.webp', + '.avif', + '.ico', + '.bmp', + '.woff', + '.woff2', + '.ttf', + '.otf', + '.md', + '.txt', + '.xml', + '.yaml', + '.yml', + '.toml', +]); + +export type FilesIpcEntryKind = 'html' | 'asset'; + +export interface FilesIpcEntry { + path: string; + kind: FilesIpcEntryKind; + size: number; + updatedAt: string; +} + +export async function walkWorkspaceFiles( + workspacePath: string, + max: number = WORKSPACE_WALK_MAX_FILES, +): Promise { + const out: FilesIpcEntry[] = []; + + async function walk(absDir: string, relDir: string): Promise { + if (out.length >= max) return; + let entries: Dirent[]; + try { + entries = await readdir(absDir, { withFileTypes: true }); + } catch { + return; + } + entries.sort((a, b) => a.name.localeCompare(b.name)); + + for (const entry of entries) { + if (out.length >= max) return; + const absPath = path.join(absDir, entry.name); + const relPath = relDir === '' ? entry.name : `${relDir}/${entry.name}`; + + if (entry.isDirectory()) { + if (shouldSkipDirEntry(entry.name)) continue; + await walk(absPath, relPath); + continue; + } + if (!entry.isFile() || shouldSkipFileEntry(entry.name)) continue; + + const ext = path.extname(entry.name).toLowerCase(); + const kind: FilesIpcEntryKind | null = HTML_EXTS.has(ext) + ? 'html' + : ASSET_EXTS.has(ext) + ? 'asset' + : null; + if (kind === null) continue; + + try { + const st = await stat(absPath); + out.push({ + path: relPath, + kind, + size: st.size, + updatedAt: st.mtime.toISOString(), + }); + } catch { + // unreadable entries are skipped silently; the Files panel doesn't + // surface them and the user already has read access via the picker. + } + } + } + + await walk(workspacePath, ''); + // HTML files first (the user-visible focus), then everything else. + // Stable secondary sort by path keeps the order deterministic. + out.sort((a, b) => { + if (a.kind !== b.kind) return a.kind === 'html' ? -1 : 1; + return a.path.localeCompare(b.path); + }); + return out; +} + +export interface RegisterFilesIpcOptions { + db: Database.Database; + logger: Pick; +} + +export function registerFilesIpc(opts: RegisterFilesIpcOptions): void { + const { db, logger } = opts; + + ipcMain.handle( + 'files:list:v1', + async (_e: unknown, raw: unknown): Promise<{ files: FilesIpcEntry[] }> => { + if (typeof raw !== 'object' || raw === null) { + throw new CodesignError('files:list:v1 expects an object payload', 'IPC_BAD_INPUT'); + } + const r = raw as Record; + if (typeof r['designId'] !== 'string' || r['designId'].trim().length === 0) { + throw new CodesignError('designId must be a non-empty string', 'IPC_BAD_INPUT'); + } + const designId = r['designId'] as string; + + let design: ReturnType; + try { + design = getDesign(db, designId); + } catch (err) { + logger.error('files.list.db.fail', { + designId, + message: err instanceof Error ? err.message : String(err), + }); + throw new CodesignError('files lookup failed', 'IPC_DB_ERROR', { + cause: err instanceof Error ? err : undefined, + }); + } + + if (design === null) { + throw new CodesignError('Design not found', 'IPC_NOT_FOUND'); + } + if (design.workspacePath === null) { + // No workspace bound -- renderer falls back to virtual index.html. + return { files: [] }; + } + + try { + const files = await walkWorkspaceFiles(design.workspacePath); + return { files }; + } catch (err) { + logger.error('files.list.walk.fail', { + designId, + workspacePath: design.workspacePath, + message: err instanceof Error ? err.message : String(err), + }); + throw new CodesignError('Failed to scan workspace folder', 'IPC_DB_ERROR', { + cause: err instanceof Error ? err : undefined, + }); + } + }, + ); +} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index d06d8e439..22b26a066 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -1,5 +1,5 @@ import { mkdirSync } from 'node:fs'; -import { mkdir, stat, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises'; import path_module from 'node:path'; import { basename, dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -46,6 +46,7 @@ import { registerDiagnosticsIpc } from './diagnostics-ipc'; import { makeRuntimeVerifier } from './done-verify'; import { BrowserWindow, app, clipboard, dialog, ipcMain, shell } from './electron-runtime'; import { registerExporterIpc } from './exporter-ipc'; +import { registerFilesIpc } from './files-ipc'; import { armGenerationTimeout, cancelGenerationRequest, @@ -89,6 +90,12 @@ import { registerWorkspaceIpc, } from './snapshots-ipc'; import { initStorageSettings } from './storage-settings'; +import { registerWorkspaceProtocolHandler, registerWorkspaceScheme } from './workspace-protocol'; +import { + WORKSPACE_WALK_MAX_FILES, + shouldSkipDirEntry, + shouldSkipFileEntry, +} from './workspace-walk'; // ESM shim: package.json "type": "module" means the built bundle is ESM and // __dirname/__filename don't exist. Derive them from import.meta.url so the @@ -99,7 +106,7 @@ const __dirname = dirname(__filename); let mainWindow: ElectronBrowserWindow | null = null; // Cached update-available payload so a window opened after the event still // shows the banner. Cleared only on app quit (matching the one-shot nature -// of autoUpdater — a new check will re-emit if still applicable). +// of autoUpdater -- a new check will re-emit if still applicable). let pendingUpdateAvailable: unknown = null; const defaultUserDataDir = app.getPath('userData'); @@ -111,7 +118,7 @@ if (storageLocations.dataDir !== undefined) { /** * Workstream B Phase 1 feature flag. When truthy, `codesign:*:generate` routes - * through `generateViaAgent()` (pi-agent-core, zero tools). Default off — any + * through `generateViaAgent()` (pi-agent-core, zero tools). Default off -- any * other value (including unset / empty) keeps the legacy `generate()` path. * * Read once at module init: changing the env var mid-session requires an app @@ -169,7 +176,7 @@ function createWindow(): void { // Replay any update event that fired before this window was ready // (macOS: user closed window, triggered a manual Check for Updates from - // the app menu, then reopened — the event would otherwise be lost). + // the app menu, then reopened -- the event would otherwise be lost). mainWindow.webContents.on('did-finish-load', () => { if (pendingUpdateAvailable !== null) { mainWindow?.webContents.send('codesign:update-available', pendingUpdateAvailable); @@ -187,7 +194,7 @@ type Database = BetterSqlite3.Database; /** * Pull an HTTP status code out of a caught provider error. Mirrors - * `packages/providers/src/retry.ts::extractStatus` intentionally — we don't + * `packages/providers/src/retry.ts::extractStatus` intentionally -- we don't * import from retry.ts to avoid coupling main to a retry-internal helper * that might get reshaped. Used by the generate catch block to tag the * thrown err with `upstream_status` so the renderer's diagnose pipeline @@ -271,16 +278,157 @@ function allocateAssetPath( return path; } +// Workspace seeding: when a design is bound to a folder, load its existing +// text files into fsMap so the agent's view/list_files tools see what the +// user already has. Without this, the workspace mirror is write-only and the +// agent thinks the folder is empty even when it isn't. +// +// Cap per file so a stray multi-MB log inside the workspace can't blow up +// the agent's context window or stall the main thread on a single read. +const WORKSPACE_SEED_MAX_FILE_BYTES = 1_000_000; +export const WORKSPACE_SEED_MAX_TOTAL_BYTES = 5_000_000; +const WORKSPACE_SEED_TEXT_EXTENSIONS: ReadonlySet = new Set([ + '.html', + '.htm', + '.css', + '.js', + '.jsx', + '.ts', + '.tsx', + '.mjs', + '.cjs', + '.json', + '.md', + '.txt', + '.svg', + '.xml', + '.yaml', + '.yml', + '.toml', +]); + +type WorkspaceSeedLogger = Pick & Partial>; + +export interface WorkspaceSeedResult { + filesLoaded: number; + filesSkipped: number; + bytesLoaded: number; + truncated: boolean; +} + +export async function seedFsMapFromWorkspace( + workspacePath: string, + fsMap: Map, + logger: WorkspaceSeedLogger, +): Promise { + let filesLoaded = 0; + let filesSkipped = 0; + let bytesLoaded = 0; + let truncated = false; + + const walk = async (absDir: string, relDir: string): Promise => { + if (truncated) return; + let entries: import('node:fs').Dirent[]; + try { + entries = await readdir(absDir, { withFileTypes: true }); + } catch (err) { + logger.error('workspace.seed.readdir.fail', { + path: absDir, + message: err instanceof Error ? err.message : String(err), + }); + return; + } + entries.sort((a, b) => a.name.localeCompare(b.name)); + + for (const entry of entries) { + if (filesLoaded >= WORKSPACE_WALK_MAX_FILES) { + truncated = true; + return; + } + const absPath = path_module.join(absDir, entry.name); + const relPath = relDir === '' ? entry.name : `${relDir}/${entry.name}`; + + if (entry.isDirectory()) { + if (shouldSkipDirEntry(entry.name)) { + filesSkipped += 1; + continue; + } + await walk(absPath, relPath); + continue; + } + + if (!entry.isFile() || shouldSkipFileEntry(entry.name)) { + filesSkipped += 1; + continue; + } + + const ext = path_module.extname(entry.name).toLowerCase(); + if (!WORKSPACE_SEED_TEXT_EXTENSIONS.has(ext)) { + filesSkipped += 1; + continue; + } + + let size: number; + try { + size = (await stat(absPath)).size; + } catch (err) { + logger.error('workspace.seed.stat.fail', { + path: absPath, + message: err instanceof Error ? err.message : String(err), + }); + filesSkipped += 1; + continue; + } + + if (size > WORKSPACE_SEED_MAX_FILE_BYTES) { + filesSkipped += 1; + continue; + } + if (bytesLoaded + size > WORKSPACE_SEED_MAX_TOTAL_BYTES) { + filesSkipped += 1; + truncated = true; + return; + } + + let content: string; + try { + content = await readFile(absPath, 'utf8'); + } catch (err) { + logger.error('workspace.seed.read.fail', { + path: absPath, + message: err instanceof Error ? err.message : String(err), + }); + filesSkipped += 1; + continue; + } + + const contentBytes = Buffer.byteLength(content, 'utf8'); + if (bytesLoaded + contentBytes > WORKSPACE_SEED_MAX_TOTAL_BYTES) { + filesSkipped += 1; + truncated = true; + return; + } + + fsMap.set(relPath, content); + filesLoaded += 1; + bytesLoaded += contentBytes; + } + }; + + await walk(workspacePath, ''); + return { filesLoaded, filesSkipped, bytesLoaded, truncated }; +} + interface CreateRuntimeTextEditorFsOptions { db: BetterSqlite3.Database | null; generationId: string; designId: string | null; previousHtml: string | null; sendEvent: (event: AgentStreamEvent) => void; - logger: Pick; + logger: WorkspaceSeedLogger; } -export function createRuntimeTextEditorFs({ +export async function createRuntimeTextEditorFs({ db, generationId, designId, @@ -300,6 +448,33 @@ export function createRuntimeTextEditorFs({ fsMap.set(`skills/${name}`, content); } + // Workspace folder, when bound, is the source of truth for what's "in" the + // design. Seed fsMap with its current text files so the agent's first + // list_files / view sees the user's actual content, not just the bundled + // frames/skills. Failures are logged and non-fatal -- generation still runs + // with whatever was successfully loaded. + if (designId !== null && db !== null) { + try { + const design = getDesign(db, designId); + if (design?.workspacePath) { + const seed = await seedFsMapFromWorkspace(design.workspacePath, fsMap, logger); + logger.info?.('workspace.seed.ok', { + designId, + workspacePath: design.workspacePath, + filesLoaded: seed.filesLoaded, + filesSkipped: seed.filesSkipped, + bytesLoaded: seed.bytesLoaded, + truncated: seed.truncated, + }); + } + } catch (err) { + logger.error('workspace.seed.fail', { + designId, + message: err instanceof Error ? err.message : String(err), + }); + } + } + function emitFsUpdated(filePath: string, content: string): void { if (designId === null) return; const resolved = filePath === 'index.html' ? resolveLocalAssetRefs(content, fsMap) : content; @@ -377,15 +552,19 @@ export function createRuntimeTextEditorFs({ return { path }; }, listDir(dir: string) { + // Recursive listing: return every file path under `dir`, not just the + // first segment. The agent reads the full tree in a single tool call + // instead of recursing one directory at a time -- critical when the + // workspace contains a real project with nested folders. const prefix = dir.length === 0 || dir === '.' ? '' : `${dir.replace(/\/+$/, '')}/`; - const entries = new Set(); + const entries: string[] = []; for (const p of fsMap.keys()) { if (!p.startsWith(prefix)) continue; const rest = p.slice(prefix.length); - const firstSegment = rest.split('/')[0]; - if (firstSegment) entries.add(firstSegment); + if (rest.length === 0) continue; + entries.push(rest); } - return [...entries].sort(); + return entries.sort(); }, }; @@ -397,7 +576,7 @@ function registerIpcHandlers(db: Database | null): void { // Cache of the last NormalizedProviderError seen per run, so recordFinalError // can attach it to the final (non-transient) row. Without this, the row the - // user actually reports lacks upstream_request_id / status — those fields + // user actually reports lacks upstream_request_id / status -- those fields // lived only on the hidden transient sibling row emitted by retry.ts. // Implementation + LRU eviction lives in ./provider-context.ts. const providerContext = createProviderContextStore(50); @@ -434,7 +613,7 @@ function registerIpcHandlers(db: Database | null): void { * * Only `provider.error` (retry in flight, transient=true) is persisted from * this adapter; the `provider.error.final` event is NOT recorded because the - * outer handler's catch block calls `recordFinalError` — recording both + * outer handler's catch block calls `recordFinalError` -- recording both * would double-count the same failure with two distinct fingerprints. */ const coreLoggerFor = (id: string): CoreLogger => ({ info: (event, data) => logIpc.info(event, { generationId: id, ...(data ?? {}) }), @@ -447,7 +626,7 @@ function registerIpcHandlers(db: Database | null): void { ? (data['upstream_message'] as string) : event; // Fingerprint basis: errorCode + synthetic frame containing the two - // fields that truly differentiate provider errors — upstream_status + // fields that truly differentiate provider errors -- upstream_status // and upstream_code. JSON-stringifying `data` and passing it as // `stack` would produce an identical 8-hex for every provider error // because `extractTopFrames` requires lines starting with "at ". @@ -457,7 +636,7 @@ function registerIpcHandlers(db: Database | null): void { typeof data?.['upstream_code'] === 'string' ? data['upstream_code'] : 'unknown'; const syntheticFrame = ` at provider (${status}:${upstreamCode})`; // Stash the normalized context so recordFinalError can attach it to - // the final non-transient row — otherwise the reported row loses + // the final non-transient row -- otherwise the reported row loses // upstream_request_id / upstream_status, which lived only on this // hidden transient sibling. if (data !== undefined) providerContext.remember(id, data); @@ -488,7 +667,7 @@ function registerIpcHandlers(db: Database | null): void { * `agent:event:v1` so the sidebar chat can render incremental output * instead of waiting for the full final message. */ - const runGenerate = ( + const runGenerate = async ( input: Parameters[0], id: string, designId: string | null, @@ -500,8 +679,18 @@ function registerIpcHandlers(db: Database | null): void { }; const baseCtx = { designId: designId ?? '', generationId: id } as const; const toolStartedAt = new Map(); - const runtimeVerify = makeRuntimeVerifier(); - const { fs, fsMap } = createRuntimeTextEditorFs({ + // The runtime verifier wraps the artifact in buildSrcdoc(), which expects + // a JSX module (TWEAK_DEFAULTS + ReactDOM.createRoot). Real workspace + // files are plain HTML / framework code that doesn't fit that mold, so + // the verifier flags them as broken and the agent self-heals by flatten- + // ing the user's actual project into a self-contained doc -- silently + // destroying real source files. Skip verification when a workspace is + // bound; the user's tooling (their dev server, linters, browser) is the + // source of truth in that mode. + const designHasWorkspace = + designId !== null && db !== null ? (getDesign(db, designId)?.workspacePath ?? null) : null; + const runtimeVerify = designHasWorkspace !== null ? undefined : makeRuntimeVerifier(); + const { fs, fsMap } = await createRuntimeTextEditorFs({ db, designId, generationId: id, @@ -652,14 +841,14 @@ function registerIpcHandlers(db: Database | null): void { ) .map((c) => c.text) .join(''); - // Strip ... blocks — artifact content is + // Strip ... blocks -- artifact content is // delivered via fs_updated / artifact_delivered, not the chat text. const finalText = rawText.replace(//g, '').trim(); sendEvent({ ...baseCtx, type: 'turn_end', finalText }); return; } if (event.type === 'agent_end') { - // Final boundary of an agent run — renderer uses this to persist a + // Final boundary of an agent run -- renderer uses this to persist a // SQLite snapshot from the in-memory previewHtml so the design // survives an app restart. Without this the next switchDesign() at // boot finds no snapshot and falls back to the empty welcome state. @@ -676,7 +865,7 @@ function registerIpcHandlers(db: Database | null): void { })); }; - /** In-flight requests: generationId → AbortController */ + /** In-flight requests: generationId -> AbortController */ const inFlight = new Map(); const armTimeout = (id: string, controller: AbortController) => @@ -698,7 +887,7 @@ function registerIpcHandlers(db: Database | null): void { // directly to dry-run an artifact without going through the agent loop. // The agent itself uses the same verifier as an injected callback (see // runGenerate above), so this handler is NOT in the hot path. Hidden - // BrowserWindow + Babel makes vitest unworkable here — manual verification + // BrowserWindow + Babel makes vitest unworkable here -- manual verification // path documented in done-verify.ts. const sharedRuntimeVerifier = makeRuntimeVerifier(); ipcMain.handle('done:verify:v1', async (_e, raw: unknown) => { @@ -787,8 +976,8 @@ function registerIpcHandlers(db: Database | null): void { 'CONFIG_MISSING', ); } - // Snap to the canonical active provider in cachedConfig — the SAME source - // the Settings UI uses for the Active badge — so the actual call cannot + // Snap to the canonical active provider in cachedConfig -- the SAME source + // the Settings UI uses for the Active badge -- so the actual call cannot // diverge from what the user sees. const active = resolveActiveModel(cfg, payload.model); const allowKeyless = active.allowKeyless; @@ -800,7 +989,7 @@ function registerIpcHandlers(db: Database | null): void { throw err; } // Once we've snapped to the canonical active provider, the renderer-supplied - // baseUrl can no longer be trusted — it may belong to a different (stale) + // baseUrl can no longer be trusted -- it may belong to a different (stale) // provider and would route the active provider's API key to the wrong host. // Always use the per-provider baseUrl from cached config, and mutate the // payload itself so any downstream reader cannot accidentally pick up the @@ -903,7 +1092,7 @@ function registerIpcHandlers(db: Database | null): void { return result; } catch (err) { // Attach upstream metadata to the thrown err so the renderer's - // diagnostic pipeline (store.ts::applyGenerateError → + // diagnostic pipeline (store.ts::applyGenerateError -> // diagnoseGenerateFailure) can map this failure to a "most likely // cause + suggested fix" hypothesis. Without this, renderer only // sees err.message + err.code and cannot offer actionable hints @@ -949,7 +1138,7 @@ function registerIpcHandlers(db: Database | null): void { }); }); - // Legacy shim — kept for one minor release while older renderer builds still + // Legacy shim -- kept for one minor release while older renderer builds still // send codesign:generate without schemaVersion. Remove after v0.3. ipcMain.handle('codesign:generate', async (_e, raw: unknown) => { logIpc.warn('legacy codesign:generate channel used, schedule removal next minor'); @@ -978,7 +1167,7 @@ function registerIpcHandlers(db: Database | null): void { inFlight.delete(id); throw err; } - // See codesign:v1:generate above — renderer baseUrl is ignored post-snap. + // See codesign:v1:generate above -- renderer baseUrl is ignored post-snap. const baseUrl = active.baseUrl ?? undefined; if (active.overridden) { payload.baseUrl = baseUrl; @@ -1252,6 +1441,11 @@ async function scheduleStartupUpdateCheck(): Promise { } if (!IS_VITEST) { + // Privileged scheme registration must happen synchronously before + // app.whenReady so Chromium picks it up during process init. Calling it + // later raises "Schemes can only be registered while the app is initializing". + registerWorkspaceScheme(); + void app.whenReady().then(async () => { // Extracted so the outer try/catch AND post-init listeners (whose callbacks // fire outside this block) can route failures through the same boot-fallback @@ -1289,8 +1483,8 @@ if (!IS_VITEST) { initLogger(); // Single-instance lock. Two simultaneous Electron instances would race // `cleanupStaleTmps` vs `writeAtomic` (B's cleanup unlinks A's in-flight - // tmp → ENOENT rename) and collide on the SQLite WAL. macOS usually - // enforces this at the OS level, but `open -n` defeats that — so we + // tmp -> ENOENT rename) and collide on the SQLite WAL. macOS usually + // enforces this at the OS level, but `open -n` defeats that -- so we // acquire the lock explicitly before touching any shared files. const gotLock = app.requestSingleInstanceLock(); if (!gotLock) { @@ -1313,7 +1507,7 @@ if (!IS_VITEST) { // crashes. pid changes across restarts so without this the config dir // accumulates 0o600 litter forever. cleanupStaleTmps(join(configDir(), 'reported-fingerprints.json')); - // Snapshot persistence is best-effort at boot — a failure here (corrupt DB, + // Snapshot persistence is best-effort at boot -- a failure here (corrupt DB, // permission denied, missing native binding) must NOT block the BrowserWindow // from opening. Surface it via an error dialog and skip registering the // snapshots IPC channels; the rest of the app stays usable. @@ -1324,6 +1518,14 @@ if (!IS_VITEST) { registerWorkspaceIpc(dbResult.db, () => mainWindow); registerChatMessagesIpc(dbResult.db); registerCommentsIpc(dbResult.db); + registerWorkspaceProtocolHandler({ + db: dbResult.db, + logger: getLogger('workspace-protocol'), + }); + registerFilesIpc({ + db: dbResult.db, + logger: getLogger('files-ipc'), + }); try { pruneDiagnosticEvents(dbResult.db, 500); } catch (err) { @@ -1339,7 +1541,7 @@ if (!IS_VITEST) { }); // Install stub handlers so renderer-side calls reject with a typed // SNAPSHOTS_UNAVAILABLE CodesignError instead of Electron's opaque - // "No handler registered" rejection — see snapshots-ipc.ts. + // "No handler registered" rejection -- see snapshots-ipc.ts. registerSnapshotsUnavailableIpc(dbResult.error.message); registerChatMessagesUnavailableIpc(dbResult.error.message); registerCommentsUnavailableIpc(dbResult.error.message); @@ -1374,7 +1576,7 @@ if (!IS_VITEST) { } catch (err) { // Last-resort boot-phase handler. Reached when something before // `initLogger()` finishes (or during the first few setup calls) - // throws — our electron-log sink might not exist yet, so write a + // throws -- our electron-log sink might not exist yet, so write a // best-effort sync log and show a native three-button dialog. handleBootFailure( err, diff --git a/apps/desktop/src/main/index.workspace.test.ts b/apps/desktop/src/main/index.workspace.test.ts index 9185e3acf..e353e1cc7 100644 --- a/apps/desktop/src/main/index.workspace.test.ts +++ b/apps/desktop/src/main/index.workspace.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; import { existsSync, rmSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -11,6 +11,7 @@ import { updateDesignWorkspace, viewDesignFile, } from './snapshots-db'; +import { WORKSPACE_WALK_MAX_FILES } from './workspace-walk'; vi.mock('electron', () => ({ dialog: { @@ -69,7 +70,11 @@ vi.mock('./storage-settings', () => ({ initStorageSettings: vi.fn(() => ({})), })); -import { createRuntimeTextEditorFs } from './index'; +import { + WORKSPACE_SEED_MAX_TOTAL_BYTES, + createRuntimeTextEditorFs, + seedFsMapFromWorkspace, +} from './index'; function makeTempDir(prefix: string): string { return mkdtempSync(path.join(os.tmpdir(), prefix)); @@ -95,7 +100,7 @@ describe('createRuntimeTextEditorFs', () => { const design = createDesign(db, 'Workspaceless'); const sendEvent = vi.fn(); const logger = { error: vi.fn() }; - const { fs } = createRuntimeTextEditorFs({ + const { fs } = await createRuntimeTextEditorFs({ db, designId: design.id, generationId: 'gen-create-db-only', @@ -120,7 +125,7 @@ describe('createRuntimeTextEditorFs', () => { updateDesignWorkspace(db, design.id, normalizeWorkspacePath(workspaceDir)); const sendEvent = vi.fn(); const logger = { error: vi.fn() }; - const { fs } = createRuntimeTextEditorFs({ + const { fs } = await createRuntimeTextEditorFs({ db, designId: design.id, generationId: 'gen-create-workspace', @@ -152,7 +157,7 @@ describe('createRuntimeTextEditorFs', () => { updateDesignWorkspace(db, design.id, normalizeWorkspacePath(workspaceFile)); const sendEvent = vi.fn(); const logger = { error: vi.fn() }; - const { fs } = createRuntimeTextEditorFs({ + const { fs } = await createRuntimeTextEditorFs({ db, designId: design.id, generationId: 'gen-create-workspace-fail', @@ -182,7 +187,7 @@ describe('createRuntimeTextEditorFs', () => { updateDesignWorkspace(db, design.id, normalizeWorkspacePath(workspaceDir)); const sendEvent = vi.fn(); const logger = { error: vi.fn() }; - const { fs } = createRuntimeTextEditorFs({ + const { fs } = await createRuntimeTextEditorFs({ db, designId: design.id, generationId: 'gen-replace-workspace', @@ -219,7 +224,7 @@ describe('createRuntimeTextEditorFs', () => { writeFileSync(workspaceFile, 'occupied', 'utf8'); const sendEvent = vi.fn(); const logger = { error: vi.fn() }; - const { fs } = createRuntimeTextEditorFs({ + const { fs } = await createRuntimeTextEditorFs({ db, designId: design.id, generationId: 'gen-replace-workspace-fail', @@ -253,7 +258,7 @@ describe('createRuntimeTextEditorFs', () => { writeFileSync(workspaceFile, 'occupied', 'utf8'); const sendEvent = vi.fn(); const logger = { error: vi.fn() }; - const { fs } = createRuntimeTextEditorFs({ + const { fs } = await createRuntimeTextEditorFs({ db, designId: design.id, generationId: 'gen-insert-workspace-fail', @@ -286,7 +291,7 @@ describe('createRuntimeTextEditorFs', () => { updateDesignWorkspace(db, design.id, normalizeWorkspacePath(workspaceDir)); const sendEvent = vi.fn(); const logger = { error: vi.fn() }; - const { fs } = createRuntimeTextEditorFs({ + const { fs } = await createRuntimeTextEditorFs({ db, designId: design.id, generationId: 'gen-insert-workspace', @@ -323,7 +328,7 @@ describe('createRuntimeTextEditorFs', () => { const workspaceDir = makeTempDir('ocd-runtime-null-workspace-'); const sendEvent = vi.fn(); const logger = { error: vi.fn() }; - const { fs } = createRuntimeTextEditorFs({ + const { fs } = await createRuntimeTextEditorFs({ db, designId: design.id, generationId: 'gen-null-workspace', @@ -351,7 +356,7 @@ describe('createRuntimeTextEditorFs', () => { it('emits fs_updated for anonymous mutations without db persistence', async () => { const sendEvent = vi.fn(); const logger = { error: vi.fn() }; - const { fs } = createRuntimeTextEditorFs({ + const { fs } = await createRuntimeTextEditorFs({ db: initInMemoryDb(), designId: null, generationId: 'gen-anon', @@ -368,3 +373,63 @@ describe('createRuntimeTextEditorFs', () => { expect(logger.error).not.toHaveBeenCalled(); }); }); + +describe('seedFsMapFromWorkspace', () => { + it('truncates after the workspace file-count cap', async () => { + const workspaceDir = makeTempDir('ocd-runtime-seed-file-cap-'); + const fsMap = new Map(); + const logger = { error: vi.fn(), info: vi.fn() }; + + try { + for (let i = 0; i < WORKSPACE_WALK_MAX_FILES + 1; i += 1) { + writeFileSync(path.join(workspaceDir, `${String(i).padStart(3, '0')}.html`), 'ok', 'utf8'); + } + + const result = await seedFsMapFromWorkspace(workspaceDir, fsMap, logger); + + expect(result.filesLoaded).toBe(WORKSPACE_WALK_MAX_FILES); + expect(result.filesSkipped).toBe(0); + expect(result.bytesLoaded).toBe(WORKSPACE_WALK_MAX_FILES * 2); + expect(result.truncated).toBe(true); + expect(fsMap.size).toBe(WORKSPACE_WALK_MAX_FILES); + expect(fsMap.has('500.html')).toBe(false); + expect(logger.error).not.toHaveBeenCalled(); + } finally { + cleanupDir(workspaceDir); + } + }); + + it('caps seeded workspace text by per-file bytes and total bytes', async () => { + const workspaceDir = makeTempDir('ocd-runtime-seed-caps-'); + const fsMap = new Map(); + const logger = { error: vi.fn(), info: vi.fn() }; + + try { + writeFileSync(path.join(workspaceDir, '0-oversize.json'), 'o'.repeat(1_000_001), 'utf8'); + writeFileSync(path.join(workspaceDir, 'a.html'), 'a'.repeat(1_000_000), 'utf8'); + writeFileSync(path.join(workspaceDir, 'b.css'), 'b'.repeat(1_000_000), 'utf8'); + writeFileSync(path.join(workspaceDir, 'c.js'), 'c'.repeat(1_000_000), 'utf8'); + writeFileSync(path.join(workspaceDir, 'd.ts'), 'd'.repeat(1_000_000), 'utf8'); + writeFileSync(path.join(workspaceDir, 'e.md'), 'e'.repeat(1_000_000), 'utf8'); + writeFileSync(path.join(workspaceDir, 'f.txt'), 'f', 'utf8'); + mkdirSync(path.join(workspaceDir, 'node_modules')); + writeFileSync(path.join(workspaceDir, 'node_modules', 'ignored.js'), 'ignored', 'utf8'); + + const result = await seedFsMapFromWorkspace(workspaceDir, fsMap, logger); + + expect(result).toEqual({ + filesLoaded: 5, + filesSkipped: 2, + bytesLoaded: WORKSPACE_SEED_MAX_TOTAL_BYTES, + truncated: true, + }); + expect(fsMap.size).toBe(5); + expect(fsMap.has('f.txt')).toBe(false); + expect(fsMap.has('0-oversize.json')).toBe(false); + expect(fsMap.has('node_modules/ignored.js')).toBe(false); + expect(logger.error).not.toHaveBeenCalled(); + } finally { + cleanupDir(workspaceDir); + } + }); +}); diff --git a/apps/desktop/src/main/workspace-protocol.test.ts b/apps/desktop/src/main/workspace-protocol.test.ts new file mode 100644 index 000000000..02c67bba1 --- /dev/null +++ b/apps/desktop/src/main/workspace-protocol.test.ts @@ -0,0 +1,194 @@ +import { mkdtemp, realpath, symlink, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { resolveWorkspaceRealPath, resolveWorkspaceUrl } from './workspace-protocol'; + +describe('resolveWorkspaceUrl', () => { + let workspaceDir: string; + const designId = 'abc123'; + const resolveWorkspace = (id: string): string | null => { + if (id === designId) return workspaceDir; + return null; + }; + + beforeEach(async () => { + workspaceDir = await mkdtemp(path.join(os.tmpdir(), 'oc-wsproto-')); + await writeFile(path.join(workspaceDir, 'index.html'), ''); + }); + + afterEach(async () => { + // best-effort -- vitest cleans tmp eventually + }); + + it('resolves a basic workspace url to the file inside the workspace', () => { + const r = resolveWorkspaceUrl(`workspace://${designId}/index.html`, resolveWorkspace); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.value.absPath).toBe(path.resolve(workspaceDir, 'index.html')); + expect(r.value.mime).toMatch(/^text\/html/); + expect(r.value.designId).toBe(designId); + expect(r.value.relPath).toBe('index.html'); + } + }); + + it('defaults empty path to index.html', () => { + const r = resolveWorkspaceUrl(`workspace://${designId}/`, resolveWorkspace); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value.relPath).toBe('index.html'); + }); + + it('defaults trailing-slash directory path to /index.html', () => { + const r = resolveWorkspaceUrl(`workspace://${designId}/sub/`, resolveWorkspace); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value.relPath).toBe('sub/index.html'); + }); + + it('rejects non-workspace scheme', () => { + const r = resolveWorkspaceUrl(`https://${designId}/index.html`, resolveWorkspace); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toBe('bad_url'); + }); + + it('rejects empty designId', () => { + const r = resolveWorkspaceUrl('workspace:///index.html', resolveWorkspace); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toBe('bad_url'); + }); + + it('rejects designId with invalid characters', () => { + const r = resolveWorkspaceUrl('workspace://bad..id/index.html', resolveWorkspace); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toBe('bad_url'); + }); + + it('rejects designId not in DB', () => { + const r = resolveWorkspaceUrl('workspace://unknown_id/index.html', resolveWorkspace); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toBe('unknown_design'); + }); + + it('cannot escape workspace via plain ../ (URL parser normalizes)', () => { + // WHATWG URL normalizes `..` segments inside the path before our code + // sees them, so `..` collapses against the host root rather than walking + // above the workspace. The final path is always inside the workspace. + const r = resolveWorkspaceUrl(`workspace://${designId}/../etc/passwd.html`, resolveWorkspace); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.value.relPath).toBe('etc/passwd.html'); + expect(r.value.absPath.startsWith(workspaceDir)).toBe(true); + } + }); + + it('cannot escape workspace via deeply nested ../', () => { + const r = resolveWorkspaceUrl( + `workspace://${designId}/sub/../../../etc/passwd.html`, + resolveWorkspace, + ); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.value.relPath).toBe('etc/passwd.html'); + expect(r.value.absPath.startsWith(workspaceDir)).toBe(true); + } + }); + + it('cannot escape workspace via URL-encoded ../ (parser collapses %2e%2e too)', () => { + // WHATWG URL normalizes both plain and percent-encoded double-dot + // segments, so even `%2e%2e` cannot walk above the host. Defense-in-depth + // path.resolve guard remains in place for any future parser quirks. + const r = resolveWorkspaceUrl( + `workspace://${designId}/%2e%2e/%2e%2e/etc/passwd.html`, + resolveWorkspace, + ); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.value.relPath).toBe('etc/passwd.html'); + expect(r.value.absPath.startsWith(workspaceDir)).toBe(true); + } + }); + + it('rejects null byte injection', () => { + const r = resolveWorkspaceUrl(`workspace://${designId}/index.html%00.png`, resolveWorkspace); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toBe('bad_url'); + }); + + it('rejects unsupported MIME types (.exe)', () => { + const r = resolveWorkspaceUrl(`workspace://${designId}/malware.exe`, resolveWorkspace); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toBe('unsupported_mime'); + }); + + it('rejects files without an extension', () => { + const r = resolveWorkspaceUrl(`workspace://${designId}/Makefile`, resolveWorkspace); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toBe('unsupported_mime'); + }); + + it('accepts common static assets', () => { + const cases = [ + ['styles.css', 'text/css'], + ['app.js', 'application/javascript'], + ['module.mjs', 'application/javascript'], + ['logo.png', 'image/png'], + ['photo.jpg', 'image/jpeg'], + ['icon.svg', 'image/svg+xml'], + ['font.woff2', 'font/woff2'], + ['data.json', 'application/json'], + ] as const; + for (const [file, mimePrefix] of cases) { + const r = resolveWorkspaceUrl(`workspace://${designId}/${file}`, resolveWorkspace); + expect(r.ok, `${file} should resolve`).toBe(true); + if (r.ok) expect(r.value.mime.startsWith(mimePrefix)).toBe(true); + } + }); + + it('preserves nested paths', () => { + const r = resolveWorkspaceUrl( + `workspace://${designId}/assets/icons/check.svg`, + resolveWorkspace, + ); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value.relPath).toBe('assets/icons/check.svg'); + }); + + it('strips query string when resolving (cache busters do not change path)', () => { + const r = resolveWorkspaceUrl(`workspace://${designId}/index.html?v=abc123`, resolveWorkspace); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value.relPath).toBe('index.html'); + }); + + it('handles URL-encoded spaces in filenames', () => { + const r = resolveWorkspaceUrl(`workspace://${designId}/My%20File.html`, resolveWorkspace); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value.relPath).toBe('My File.html'); + }); + + it('rejects symlinks that resolve outside the workspace', async () => { + const outsideDir = await mkdtemp(path.join(os.tmpdir(), 'oc-wsproto-outside-')); + const outsideFile = path.join(outsideDir, 'secret.html'); + await writeFile(outsideFile, 'secret'); + await symlink(outsideFile, path.join(workspaceDir, 'linked.html')); + + const r = resolveWorkspaceUrl(`workspace://${designId}/linked.html`, resolveWorkspace); + expect(r.ok).toBe(true); + if (!r.ok) return; + + const checked = await resolveWorkspaceRealPath(r.value); + expect(checked).toEqual({ ok: false, error: 'traversal' }); + }); + + it('accepts symlinks that stay inside the workspace', async () => { + await writeFile(path.join(workspaceDir, 'target.html'), 'target'); + await symlink(path.join(workspaceDir, 'target.html'), path.join(workspaceDir, 'inside.html')); + + const r = resolveWorkspaceUrl(`workspace://${designId}/inside.html`, resolveWorkspace); + expect(r.ok).toBe(true); + if (!r.ok) return; + + const checked = await resolveWorkspaceRealPath(r.value); + expect(checked.ok).toBe(true); + if (checked.ok) + expect(checked.value.absPath).toBe(await realpath(path.join(workspaceDir, 'target.html'))); + }); +}); diff --git a/apps/desktop/src/main/workspace-protocol.ts b/apps/desktop/src/main/workspace-protocol.ts new file mode 100644 index 000000000..8a5e2be47 --- /dev/null +++ b/apps/desktop/src/main/workspace-protocol.ts @@ -0,0 +1,298 @@ +import { readFile, realpath } from 'node:fs/promises'; +import path_module from 'node:path'; +import type { CoreLogger } from '@open-codesign/core'; +import type Database from 'better-sqlite3'; +import { protocol } from './electron-runtime'; +import { getDesign } from './snapshots-db'; + +export const WORKSPACE_SCHEME = 'workspace'; + +const ALLOWED_MIME_BY_EXT = new Map([ + ['.html', 'text/html; charset=utf-8'], + ['.htm', 'text/html; charset=utf-8'], + ['.css', 'text/css; charset=utf-8'], + ['.js', 'application/javascript; charset=utf-8'], + ['.mjs', 'application/javascript; charset=utf-8'], + ['.cjs', 'application/javascript; charset=utf-8'], + // Source files often referenced by ``; + +export type WorkspaceProtocolError = + | 'bad_url' + | 'unknown_design' + | 'no_workspace' + | 'traversal' + | 'unsupported_mime'; + +export interface WorkspaceResolution { + absPath: string; + mime: string; + designId: string; + relPath: string; + workspacePath: string; +} + +export interface WorkspaceResolveResult { + ok: true; + value: WorkspaceResolution; +} + +export interface WorkspaceResolveFailure { + ok: false; + error: WorkspaceProtocolError; +} + +export function resolveWorkspaceUrl( + rawUrl: string, + resolveWorkspacePath: (designId: string) => string | null, +): WorkspaceResolveResult | WorkspaceResolveFailure { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + return { ok: false, error: 'bad_url' }; + } + if (url.protocol !== `${WORKSPACE_SCHEME}:`) { + return { ok: false, error: 'bad_url' }; + } + + const designId = url.hostname; + if (!designId || !VALID_DESIGN_ID.test(designId)) { + return { ok: false, error: 'bad_url' }; + } + + const workspacePath = resolveWorkspacePath(designId); + if (workspacePath === null) { + return { ok: false, error: 'unknown_design' }; + } + + let relPath: string; + try { + relPath = decodeURIComponent(url.pathname).replace(/^\/+/, ''); + } catch { + return { ok: false, error: 'bad_url' }; + } + + if (relPath === '' || relPath.endsWith('/')) { + relPath = `${relPath}index.html`; + } + + if (relPath.includes('\0')) { + return { ok: false, error: 'bad_url' }; + } + + const normalizedWorkspace = path_module.resolve(workspacePath); + const absPath = path_module.resolve(normalizedWorkspace, relPath); + const sep = path_module.sep; + const isInside = + absPath === normalizedWorkspace || absPath.startsWith(`${normalizedWorkspace}${sep}`); + if (!isInside) { + return { ok: false, error: 'traversal' }; + } + + const ext = path_module.extname(absPath).toLowerCase(); + const mime = ALLOWED_MIME_BY_EXT.get(ext); + if (mime === undefined) { + return { ok: false, error: 'unsupported_mime' }; + } + + return { + ok: true, + value: { absPath, mime, designId, relPath, workspacePath: normalizedWorkspace }, + }; +} + +export async function resolveWorkspaceRealPath( + resolution: WorkspaceResolution, +): Promise { + const realWorkspace = await realpath(resolution.workspacePath); + const realAbsPath = await realpath(resolution.absPath); + const sep = path_module.sep; + const isInside = + realAbsPath === realWorkspace || realAbsPath.startsWith(`${realWorkspace}${sep}`); + if (!isInside) { + return { ok: false, error: 'traversal' }; + } + return { + ok: true, + value: { + ...resolution, + absPath: realAbsPath, + workspacePath: realWorkspace, + }, + }; +} + +export function registerWorkspaceScheme(): void { + // Must be called BEFORE app.whenReady so Chromium treats workspace:// as a + // standard, secure scheme. Without standard:true, relative URL resolution + // (./styles.css from index.html) breaks. Without secure:true, the iframe + // is treated as mixed content and Chromium blocks subresource loads. + protocol.registerSchemesAsPrivileged([ + { + scheme: WORKSPACE_SCHEME, + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + stream: true, + }, + }, + ]); +} + +export interface RegisterWorkspaceProtocolOptions { + db: Database.Database; + logger: Pick; +} + +export function registerWorkspaceProtocolHandler(opts: RegisterWorkspaceProtocolOptions): void { + const { db, logger } = opts; + + const resolveWorkspacePath = (designId: string): string | null => { + try { + const design = getDesign(db, designId); + return design?.workspacePath ?? null; + } catch (err) { + logger.error('workspace.protocol.db.fail', { + designId, + message: err instanceof Error ? err.message : String(err), + }); + return null; + } + }; + + protocol.handle(WORKSPACE_SCHEME, async (request) => { + const result = resolveWorkspaceUrl(request.url, resolveWorkspacePath); + + if (!result.ok) { + const status: Record = { + bad_url: 400, + unknown_design: 404, + no_workspace: 404, + traversal: 403, + unsupported_mime: 415, + }; + logger.warn('workspace.protocol.reject', { url: request.url, error: result.error }); + return new Response(result.error, { + status: status[result.error], + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, + }); + } + + try { + const realPathResult = await resolveWorkspaceRealPath(result.value); + if (!realPathResult.ok) { + logger.warn('workspace.protocol.reject', { url: request.url, error: realPathResult.error }); + return new Response(realPathResult.error, { + status: 403, + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, + }); + } + + const data = await readFile(realPathResult.value.absPath); + const isHtml = realPathResult.value.mime.startsWith('text/html'); + const body: string | Uint8Array = isHtml + ? `${data.toString('utf8')}${WORKSPACE_NAV_INTERCEPT_SCRIPT}` + : (data as unknown as Uint8Array); + return new Response(body, { + status: 200, + headers: { + 'Content-Type': realPathResult.value.mime, + // Iframe is sandboxed (opaque origin); permissive CORS keeps fetch() + // and dynamic