diff --git a/backend/app/controller/file_controller.py b/backend/app/controller/file_controller.py index 2f9d1f11..21fdc320 100644 --- a/backend/app/controller/file_controller.py +++ b/backend/app/controller/file_controller.py @@ -288,9 +288,16 @@ def _list_task_changed_files( except (OSError, ValueError): continue + try: + stat_result = path.stat() + except OSError: + # The artifact list races with tools that atomically replace or + # remove generated files. One vanished file must not fail the + # whole Files changed panel. + continue + seen_paths.add(identity) remaining -= 1 - stat_result = path.stat() result.append( { "filename": path.name, diff --git a/backend/app/controller/run_controller.py b/backend/app/controller/run_controller.py index c84dc015..a22a357f 100644 --- a/backend/app/controller/run_controller.py +++ b/backend/app/controller/run_controller.py @@ -30,7 +30,7 @@ from contextlib import suppress from dataclasses import asdict from typing import Any -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, Header, HTTPException, Query from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field @@ -554,10 +554,19 @@ async def get_run_events( async def stream_run_events( run_id: str, after_sequence: int = Query(default=0, ge=0), + last_event_id: str | None = Header(default=None, alias="Last-Event-ID"), ): await _load_run_or_404(run_id) + reconnect_sequence = after_sequence + if isinstance(last_event_id, str): + try: + parsed_last_event_id = int(last_event_id) + except ValueError: + parsed_last_event_id = -1 + if parsed_last_event_id >= 0: + reconnect_sequence = max(reconnect_sequence, parsed_last_event_id) return StreamingResponse( - _durable_event_stream(run_id, after_sequence=after_sequence), + _durable_event_stream(run_id, after_sequence=reconnect_sequence), media_type="text/event-stream", ) diff --git a/backend/app/run_journal/store.py b/backend/app/run_journal/store.py index 6a5bdac7..6a25cc00 100644 --- a/backend/app/run_journal/store.py +++ b/backend/app/run_journal/store.py @@ -7666,7 +7666,12 @@ class SQLiteRunJournal: connection.execute( """ UPDATE run_attempts - SET status = ?, ended_at = COALESCE(ended_at, ?), + SET status = ?, ended_at = COALESCE( + ended_at, + last_consumer_heartbeat_at, + started_at, + ? + ), outcome = COALESCE(outcome, ?) WHERE run_id = ? AND status IN ('pending', 'running', 'waiting_for_user') diff --git a/backend/tests/app/controller/test_file_controller.py b/backend/tests/app/controller/test_file_controller.py index de9ec476..7b0f5218 100644 --- a/backend/tests/app/controller/test_file_controller.py +++ b/backend/tests/app/controller/test_file_controller.py @@ -14,6 +14,7 @@ import os import time +from pathlib import Path from types import SimpleNamespace from unittest.mock import MagicMock @@ -187,6 +188,43 @@ def test_task_changes_exclude_files_written_after_run_attempt(tmp_path): ] +def test_task_changes_skip_file_deleted_between_walk_and_stat( + monkeypatch, tmp_path +): + output_root = tmp_path / "outputs" + output_root.mkdir() + disappearing = output_root / "ephemeral.txt" + disappearing.write_text("temporary", encoding="utf-8") + original_stat = Path.stat + target_stat_calls = 0 + + monkeypatch.setattr( + file_controller, + "list_files", + lambda *_args, **_kwargs: [str(disappearing)], + ) + + def disappearing_stat(path, *args, **kwargs): + nonlocal target_stat_calls + if path == disappearing: + target_stat_calls += 1 + if target_stat_calls >= 2: + raise FileNotFoundError(path) + return original_stat(path, *args, **kwargs) + + monkeypatch.setattr(Path, "stat", disappearing_stat) + + files = file_controller._list_task_changed_files( + SimpleNamespace( + task_output_root=str(output_root), + working_directory=str(output_root), + task_start_time=time.time() - 10, + ) + ) + + assert files == [] + + def test_task_changes_endpoint_freezes_completed_run_manifest( monkeypatch, tmp_path ): diff --git a/backend/tests/app/controller/test_run_control_api.py b/backend/tests/app/controller/test_run_control_api.py index 1fa97ab7..8e53bd0f 100644 --- a/backend/tests/app/controller/test_run_control_api.py +++ b/backend/tests/app/controller/test_run_control_api.py @@ -238,4 +238,6 @@ async def test_list_project_runs_reads_canonical_interrupted_state(tmp_path): assert [run["run_id"] for run in result["runs"]] == ["older"] assert result["runs"][0]["status"] == "interrupted" - assert result["runs"][0]["total_attempt_elapsed_ms"] == 1000 + # Startup recovery must not count the unobserved process-down interval as + # active execution time. This attempt never persisted a later heartbeat. + assert result["runs"][0]["total_attempt_elapsed_ms"] == 0 diff --git a/backend/tests/app/controller/test_run_controller.py b/backend/tests/app/controller/test_run_controller.py index 943b25ae..3143cfae 100644 --- a/backend/tests/app/controller/test_run_controller.py +++ b/backend/tests/app/controller/test_run_controller.py @@ -206,3 +206,36 @@ async def test_stream_subscribes_before_replay_and_deduplicates_by_sequence(): await stream.__anext__() await coordinator.close() + + +@pytest.mark.asyncio +async def test_stream_resumes_from_last_event_id_on_transport_reconnect(): + events = [_event(1, "confirmed"), _event(2, "end")] + journal = MagicMock() + journal.get_run.return_value = _run_record() + journal.list_events.side_effect = lambda run_id, *, after_sequence, limit: [ + event for event in events if event.sequence > after_sequence + ][:limit] + coordinator = RunCoordinator() + + with ( + patch( + "app.controller.run_controller.get_default_run_journal", + return_value=journal, + ), + patch( + "app.controller.run_controller.get_default_run_coordinator", + return_value=coordinator, + ), + ): + response = await stream_run_events( + "run-1", after_sequence=0, last_event_id="1" + ) + stream = response.body_iterator + event_id, event_type, payload = _decode_sse(await stream.__anext__()) + + assert (event_id, event_type, payload["sequence"]) == (2, "run_event", 2) + journal.list_events.assert_called_with( + "run-1", after_sequence=1, limit=500 + ) + await coordinator.close() diff --git a/backend/tests/app/run_journal/test_recovery.py b/backend/tests/app/run_journal/test_recovery.py index 236ae5f5..7b77aa92 100644 --- a/backend/tests/app/run_journal/test_recovery.py +++ b/backend/tests/app/run_journal/test_recovery.py @@ -42,6 +42,29 @@ def test_attempt_admission_is_idempotent_and_startup_interrupts_it(tmp_path): ) +def test_startup_interruption_ends_attempt_at_last_consumer_heartbeat(tmp_path): + path = tmp_path / "journal.sqlite3" + with SQLiteRunJournal(path) as journal: + journal.ensure_run(run_id="run-1", project_id="project-1") + attempt = journal.create_run_attempt( + "run-1", + request_id="initial:run-1", + reason="initial_execution", + activate=True, + now=10, + ) + journal.heartbeat_attempt(attempt.attempt_id, now=12) + + with SQLiteRunJournal(path) as reopened: + reopened.reconcile_startup(now=20 * 60 * 60) + recovered = reopened.get_run_attempt(attempt.attempt_id) + + assert recovered is not None + assert recovered.status == "interrupted" + assert recovered.ended_at == 12 + assert recovered.elapsed_active_ms == 2000 + + def test_attempt_replay_precedes_terminal_and_cancel_guards(tmp_path): with SQLiteRunJournal(tmp_path / "journal.sqlite3") as journal: journal.ensure_run(run_id="run-1", project_id="project-1") diff --git a/electron/main/index.ts b/electron/main/index.ts index 96424cdc..3ac9e53c 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -53,6 +53,11 @@ import { getInstallationStatus, PromiseReturnType, } from './install-deps'; +import { + authorizeLocalFilePath, + isExecutableExternalOpenPath, + isMainRendererSender, +} from './localFileSecurity'; import { filePathFromLocalFileUrl } from './localFileUrl'; import { setRoundedCorners } from './native/macos-window'; import { @@ -104,6 +109,7 @@ let browser_port = 9222; let use_external_cdp = false; let proxyUrl: string | null = null; const LEGACY_DESKTOP_INSTANCE_STORAGE_KEY = 'eigent_desktop_instance_id'; +const activeLocalFileRoots = new Set(); function resolveDesktopInstanceId(legacyRendererId?: string | null): string { if (!desktopInstanceId) { @@ -143,6 +149,39 @@ const isHttpOrHttpsUrl = (url: unknown): url is string => { } }; +function assertMainRendererSender(event: Electron.IpcMainInvokeEvent): void { + if ( + !win || + win.isDestroyed() || + !isMainRendererSender(event.sender.id, win.webContents.id) || + event.senderFrame !== event.sender.mainFrame + ) { + throw new Error('This operation is restricted to the main renderer'); + } +} + +function localFileAllowedRoots(): string[] { + // Only active Space roots plus the renderer's static application assets are + // readable through localfile://. In particular, HOME, userData and the OS + // temp directory are not trust boundaries for agent-authored HTML. + return [...activeLocalFileRoots, RENDERER_DIST, VITE_PUBLIC]; +} + +async function requireAuthorizedPreviewFile( + event: Electron.IpcMainInvokeEvent, + filePath: string +): Promise { + assertMainRendererSender(event); + const authorization = await authorizeLocalFilePath( + filePath, + localFileAllowedRoots() + ); + if (!authorization.allowed) { + throw new Error('Preview file is outside the active workspace'); + } + return authorization.filePath; +} + // CDP Browser Pool interface CdpBrowser { id: string; @@ -1109,6 +1148,32 @@ function registerIpcHandlers() { typeof legacyRendererId === 'string' ? legacyRendererId : null ); }); + ipcMain.handle( + 'set-local-file-preview-roots', + async (event, roots: unknown) => { + assertMainRendererSender(event); + if (!Array.isArray(roots) || roots.length > 4) { + throw new Error('Invalid local file preview roots'); + } + + const nextRoots = new Set(); + for (const root of roots) { + if (typeof root !== 'string' || !path.isAbsolute(root)) { + throw new Error('Local file preview roots must be absolute paths'); + } + const realRoot = await fsp.realpath(root); + const stats = await fsp.stat(realRoot); + if (!stats.isDirectory()) { + throw new Error('Local file preview roots must be directories'); + } + nextRoots.add(realRoot); + } + + activeLocalFileRoots.clear(); + nextRoots.forEach((root) => activeLocalFileRoots.add(root)); + return { success: true, roots: activeLocalFileRoots.size }; + } + ); // ==================== restart app handler ==================== ipcMain.handle('restart-app', async () => { @@ -1229,15 +1294,20 @@ function registerIpcHandlers() { ipcMain.handle('read-file-dataurl', async (event, filePath) => { try { - const stats = await fsp.stat(filePath); + const authorizedPath = await requireAuthorizedPreviewFile( + event, + filePath + ); + const stats = await fsp.stat(authorizedPath); if (stats.size > FILE_PREVIEW_LIMITS.imageBytes) { throw new Error( `FILE_PREVIEW_TOO_LARGE:${stats.size}:${FILE_PREVIEW_LIMITS.imageBytes}` ); } - const file = fs.readFileSync(filePath); + const file = fs.readFileSync(authorizedPath); const mimeType = - mime.getType(path.extname(filePath)) || 'application/octet-stream'; + mime.getType(path.extname(authorizedPath)) || + 'application/octet-stream'; return `data:${mimeType};base64,${file.toString('base64')}`; } catch (error: any) { log.error('Failed to read file as data URL:', filePath, error); @@ -1759,12 +1829,27 @@ function registerIpcHandlers() { } }); - ipcMain.handle('open-local-file', async (_event, filePath: string) => { - const stats = await fsp.stat(filePath).catch(() => null); + ipcMain.handle('open-local-file', async (event, filePath: string) => { + assertMainRendererSender(event); + const authorization = await authorizeLocalFilePath( + filePath, + localFileAllowedRoots() + ); + if (!authorization.allowed) { + return { success: false, error: 'File is outside the active workspace' }; + } + const stats = await fsp.stat(authorization.filePath).catch(() => null); if (!stats?.isFile()) { return { success: false, error: 'File does not exist' }; } - const error = await shell.openPath(filePath); + if (isExecutableExternalOpenPath(authorization.filePath, stats.mode)) { + shell.showItemInFolder(authorization.filePath); + return { + success: false, + error: 'Executable files cannot be opened from an agent result', + }; + } + const error = await shell.openPath(authorization.filePath); return error ? { success: false, error } : { success: true, error: undefined }; @@ -2139,27 +2224,48 @@ function registerIpcHandlers() { // ==================== FileReader handler ==================== ipcMain.handle( 'open-file', - async (_, type: string, filePath: string, isShowSourceCode: boolean) => { + async ( + event, + type: string, + filePath: string, + isShowSourceCode: boolean + ) => { const manager = checkManagerInstance(fileReader, 'FileReader'); - return manager.openFile(type, filePath, isShowSourceCode); + const authorizedPath = await requireAuthorizedPreviewFile( + event, + filePath + ); + return manager.openFile(type, authorizedPath, isShowSourceCode); } ); - ipcMain.handle('get-file-preview-metadata', async (_, filePath: string) => { - const manager = checkManagerInstance(fileReader, 'FileReader'); - return manager.getPreviewMetadata(filePath); - }); + ipcMain.handle( + 'get-file-preview-metadata', + async (event, filePath: string) => { + const manager = checkManagerInstance(fileReader, 'FileReader'); + const authorizedPath = await requireAuthorizedPreviewFile( + event, + filePath + ); + return manager.getPreviewMetadata(authorizedPath); + } + ); - ipcMain.handle('preview-csv-file', async (_, filePath: string) => { + ipcMain.handle('preview-csv-file', async (event, filePath: string) => { const manager = checkManagerInstance(fileReader, 'FileReader'); - return manager.previewCsvFile(filePath); + const authorizedPath = await requireAuthorizedPreviewFile(event, filePath); + return manager.previewCsvFile(authorizedPath); }); ipcMain.handle( 'preview-text-file', - async (_, filePath: string, limit?: number) => { + async (event, filePath: string, limit?: number) => { const manager = checkManagerInstance(fileReader, 'FileReader'); - return manager.previewTextFile(filePath, limit); + const authorizedPath = await requireAuthorizedPreviewFile( + event, + filePath + ); + return manager.previewTextFile(authorizedPath, limit); } ); @@ -2701,7 +2807,7 @@ async function createWindowInternal() { // Use a dedicated partition for main window to isolate from webviews // This ensures main window's auth data (localStorage) is stored separately and persists across restarts partition: 'persist:main_window', - webSecurity: false, + webSecurity: true, preload, nodeIntegration: true, contextIsolation: true, @@ -3204,6 +3310,16 @@ const setupExternalLinkHandling = () => { } // For internal URLs (localhost, hash navigation), allow navigation to proceed }); + + // srcDoc report previews are sandboxed child frames. Never turn a scripted + // child-frame navigation into an outbound request: it could encode local + // workspace contents in the URL even when connect-src is disabled. + win.webContents.on('will-frame-navigate', (details) => { + if (!details.isMainFrame && isExternalUrl(details.url)) { + details.preventDefault(); + log.warn('[HTML PREVIEW] Blocked external frame navigation'); + } + }); }; // ==================== check and start backend ==================== @@ -3473,44 +3589,27 @@ app.whenReady().then(async () => { log.info(`[PROTOCOL] Handling localfile request: ${request.url}`); log.info(`[PROTOCOL] Resolved path: ${filePath}`); - // Security: Restrict file access to allowed directories only. - // Without this check, path traversal (e.g. /../../../etc/passwd) - // would allow reading arbitrary files on the filesystem. - const allowedBases = [ - os.homedir(), - app.getPath('userData'), - app.getPath('temp'), - ]; - - const isPathAllowed = allowedBases.some((base) => { - const resolvedBase = path.resolve(base); - return ( - filePath === resolvedBase || - filePath.startsWith(resolvedBase + path.sep) - ); - }); - - if (!isPathAllowed) { + const authorization = await authorizeLocalFilePath( + filePath, + localFileAllowedRoots() + ); + if (!authorization.allowed) { log.error( - `[PROTOCOL] Security: Blocked access to path outside allowed directories: ${filePath}` + `[PROTOCOL] Security: Blocked local file (${authorization.reason}): ${filePath}` + ); + return new Response( + authorization.reason === 'missing' ? 'File Not Found' : 'Forbidden', + { status: authorization.reason === 'missing' ? 404 : 403 } ); - return new Response('Forbidden', { status: 403 }); } + const authorizedFilePath = authorization.filePath; try { // Check if file exists - const fileExists = await fsp - .access(filePath) - .then(() => true) - .catch(() => false); - if (!fileExists) { - log.error(`[PROTOCOL] File not found: ${filePath}`); - return new Response('File Not Found', { status: 404 }); - } - - const stats = await fsp.stat(filePath); + const stats = await fsp.stat(authorizedFilePath); if (!stats.isFile()) return new Response('Not Found', { status: 404 }); - const contentType = mime.getType(filePath) || 'application/octet-stream'; + const contentType = + mime.getType(authorizedFilePath) || 'application/octet-stream'; const resolvedRange = resolveFileByteRange( request.headers.get('range'), stats.size @@ -3536,7 +3635,7 @@ app.whenReady().then(async () => { return new Response(null, { status, headers }); } - const stream = fs.createReadStream(filePath, { start, end }); + const stream = fs.createReadStream(authorizedFilePath, { start, end }); return new Response(Readable.toWeb(stream) as BodyInit, { status, headers, diff --git a/electron/main/localFileSecurity.ts b/electron/main/localFileSecurity.ts new file mode 100644 index 00000000..2163c8d7 --- /dev/null +++ b/electron/main/localFileSecurity.ts @@ -0,0 +1,110 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import fsp from 'node:fs/promises'; +import path from 'node:path'; + +const EXECUTABLE_FILE_EXTENSIONS = new Set([ + '.app', + '.applescript', + '.bat', + '.cmd', + '.com', + '.command', + '.desktop', + '.dmg', + '.exe', + '.hta', + '.jar', + '.js', + '.jse', + '.lnk', + '.msi', + '.msp', + '.pkg', + '.ps1', + '.reg', + '.scpt', + '.sh', + '.url', + '.vbs', + '.vbe', + '.wsf', +]); + +export type LocalFileAuthorization = + | { allowed: true; filePath: string } + | { allowed: false; reason: 'invalid' | 'missing' | 'outside-roots' }; + +export function isMainRendererSender( + senderId: number, + mainRendererId: number | null | undefined +): boolean { + return typeof mainRendererId === 'number' && senderId === mainRendererId; +} + +function isPathInsideRoot(candidate: string, root: string): boolean { + const relative = path.relative(root, candidate); + return ( + relative === '' || + (!relative.startsWith('..') && !path.isAbsolute(relative)) + ); +} + +/** + * Authorize an existing path against real filesystem roots. + * + * Both the candidate and roots are resolved through realpath so a symlink + * stored inside a workspace cannot escape to ~/.ssh, ~/.aws, or another + * location outside the active Space. + */ +export async function authorizeLocalFilePath( + candidatePath: string, + allowedRoots: Iterable +): Promise { + if (!candidatePath || !path.isAbsolute(candidatePath)) { + return { allowed: false, reason: 'invalid' }; + } + + let realCandidate: string; + try { + realCandidate = await fsp.realpath(candidatePath); + } catch { + return { allowed: false, reason: 'missing' }; + } + + for (const root of allowedRoots) { + if (!root || !path.isAbsolute(root)) continue; + try { + const realRoot = await fsp.realpath(root); + if (isPathInsideRoot(realCandidate, realRoot)) { + return { allowed: true, filePath: realCandidate }; + } + } catch { + // A stale or missing workspace binding grants no access. + } + } + + return { allowed: false, reason: 'outside-roots' }; +} + +export function isExecutableExternalOpenPath( + filePath: string, + mode = 0 +): boolean { + return ( + EXECUTABLE_FILE_EXTENSIONS.has(path.extname(filePath).toLowerCase()) || + (mode & 0o111) !== 0 + ); +} diff --git a/src/components/Folder/index.tsx b/src/components/Folder/index.tsx index f1b920fb..40028baa 100644 --- a/src/components/Folder/index.tsx +++ b/src/components/Folder/index.tsx @@ -72,7 +72,10 @@ import { inlineLocalProjectImagePaths, toLocalFileUrl, } from '@/lib/htmlLocalAssets'; -import { containsDangerousContent } from '@/lib/htmlSanitization'; +import { + containsDangerousContent, + injectPreviewContentSecurityPolicy, +} from '@/lib/htmlSanitization'; import { isLocalWorkspaceSpace } from '@/lib/spaceLabel'; import { formatFileSize, @@ -2623,7 +2626,9 @@ function HtmlRenderer({ const htmlWithStorageShim = injectSandboxStorageShim(htmlWithInlineImages); setProcessedHtml( - injectFontStyles(deferInlineScriptsUntilLoad(htmlWithStorageShim)) + injectPreviewContentSecurityPolicy( + injectFontStyles(deferInlineScriptsUntilLoad(htmlWithStorageShim)) + ) ); return; } @@ -2718,12 +2723,20 @@ function HtmlRenderer({ ); // Set the processed HTML with font styles - iframe sandbox provides security - setProcessedHtml(injectFontStyles(htmlWithDeferredScripts)); + setProcessedHtml( + injectPreviewContentSecurityPolicy( + injectFontStyles(htmlWithDeferredScripts) + ) + ); }; processHtml().catch((error) => { console.error('[HtmlRenderer] Failed to process HTML:', error); - setProcessedHtml(injectFontStyles(selectedFile.content || '')); + setProcessedHtml( + injectPreviewContentSecurityPolicy( + injectFontStyles(selectedFile.content || '') + ) + ); }); }, [selectedFile, projectFiles, ipcRenderer, electronAPI]); @@ -2903,6 +2916,7 @@ export function FileViewerPanel({ onRevealFile, onBreadcrumbSegmentClick, onDownloadFile, + onOpenExternalFile, onToggleSourceCode, headerActionsExtra, emptyState, diff --git a/src/components/Layout/index.tsx b/src/components/Layout/index.tsx index d9005843..1aa41008 100644 --- a/src/components/Layout/index.tsx +++ b/src/components/Layout/index.tsx @@ -20,6 +20,7 @@ import { useHost } from '@/host'; import { useAuthStore } from '@/store/authStore'; import { hasAnyActiveRun } from '@/store/chatStore'; import { useInstallationUI } from '@/store/installationStore'; +import { useSpaceStore } from '@/store/spaceStore'; import { useEffect, useState } from 'react'; import { Outlet } from 'react-router-dom'; import CloseNoticeDialog from '../Dialog/CloseNotice'; @@ -35,6 +36,10 @@ const Layout = () => { setInitState: _setInitState, } = useAuthStore(); const [noticeOpen, setNoticeOpen] = useState(false); + const activeWorkspaceRoot = useSpaceStore((state) => { + const activeSpaceId = state.activeSpaceId; + return activeSpaceId ? state.spaces[activeSpaceId]?.rootPath || null : null; + }); //Get Chatstore for the active project's task const { chatStore } = useChatStoreAdapter(); @@ -53,6 +58,21 @@ const Layout = () => { useInstallationSetup(); + useEffect(() => { + if (!host?.ipcRenderer?.invoke) return; + void host.ipcRenderer + .invoke( + 'set-local-file-preview-roots', + activeWorkspaceRoot ? [activeWorkspaceRoot] : [] + ) + .catch((error: unknown) => { + console.warn( + '[Layout] Failed to register the active workspace preview root:', + error + ); + }); + }, [activeWorkspaceRoot, host]); + useEffect(() => { if (!host?.ipcRenderer || !host?.electronAPI) return; diff --git a/src/lib/htmlSanitization.test.ts b/src/lib/htmlSanitization.test.ts new file mode 100644 index 00000000..0732c7dc --- /dev/null +++ b/src/lib/htmlSanitization.test.ts @@ -0,0 +1,40 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import { describe, expect, it } from 'vitest'; +import { + injectPreviewContentSecurityPolicy, + PREVIEW_CONTENT_SECURITY_POLICY, +} from './htmlSanitization'; + +describe('HTML preview CSP', () => { + it('replaces an agent-authored policy with the application policy', () => { + const html = injectPreviewContentSecurityPolicy(` + + + `); + const doc = new DOMParser().parseFromString(html, 'text/html'); + const policies = doc.querySelectorAll( + 'meta[http-equiv="Content-Security-Policy" i]' + ); + + expect(policies).toHaveLength(1); + expect(policies[0].getAttribute('content')).toBe( + PREVIEW_CONTENT_SECURITY_POLICY + ); + expect(PREVIEW_CONTENT_SECURITY_POLICY).toContain("default-src 'none'"); + expect(PREVIEW_CONTENT_SECURITY_POLICY).toContain("connect-src 'none'"); + expect(PREVIEW_CONTENT_SECURITY_POLICY).not.toContain('https:'); + }); +}); diff --git a/src/lib/htmlSanitization.ts b/src/lib/htmlSanitization.ts index ffa434f3..4b265fc6 100644 --- a/src/lib/htmlSanitization.ts +++ b/src/lib/htmlSanitization.ts @@ -31,6 +31,53 @@ export const DANGEROUS_PATTERNS = [ /contextIsolation/i, ]; +export const PREVIEW_CONTENT_SECURITY_POLICY = [ + "default-src 'none'", + "script-src 'unsafe-inline' data: blob: localfile:", + "style-src 'unsafe-inline' data: blob: localfile:", + 'img-src data: blob: localfile:', + 'font-src data: blob: localfile:', + 'media-src data: blob: localfile:', + "connect-src 'none'", + "object-src 'none'", + "frame-src 'none'", + "child-src 'none'", + "form-action 'none'", + "navigate-to 'none'", + 'worker-src blob:', +].join('; '); + +/** + * Give every srcDoc preview its own deny-by-default policy. The iframe may run + * report scripts, but those scripts cannot fetch, beacon, submit, frame, or + * otherwise exfiltrate files obtained through localfile://. + */ +export function injectPreviewContentSecurityPolicy(html: string): string { + if (typeof DOMParser === 'undefined') return html; + + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + const doctype = html.match(/]*>/i)?.[0] || ''; + const head = doc.head || doc.createElement('head'); + const existing = head.querySelector( + 'meta[http-equiv="Content-Security-Policy" i]' + ); + existing?.remove(); + + const policy = doc.createElement('meta'); + policy.setAttribute('http-equiv', 'Content-Security-Policy'); + policy.setAttribute('content', PREVIEW_CONTENT_SECURITY_POLICY); + head.prepend(policy); + + if (!doc.head) { + const htmlElement = doc.documentElement || doc.createElement('html'); + htmlElement.prepend(head); + if (!doc.documentElement) doc.appendChild(htmlElement); + } + + return `${doctype}${doc.documentElement?.outerHTML || html}`; +} + /** * Check if HTML content contains dangerous patterns that could attempt * to access Electron/Node.js APIs. diff --git a/src/lib/localFileSecurity.test.ts b/src/lib/localFileSecurity.test.ts new file mode 100644 index 00000000..d48a37e3 --- /dev/null +++ b/src/lib/localFileSecurity.test.ts @@ -0,0 +1,110 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import fsp from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + authorizeLocalFilePath, + isExecutableExternalOpenPath, + isMainRendererSender, +} from '../../electron/main/localFileSecurity'; + +const temporaryDirectories: string[] = []; + +async function temporaryDirectory(): Promise { + const directory = await fsp.mkdtemp(path.join(os.tmpdir(), 'eigent-local-')); + temporaryDirectories.push(directory); + return directory; +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => fsp.rm(directory, { recursive: true, force: true })) + ); +}); + +describe('local file security', () => { + it('keeps the main renderer web security and frame-navigation guard enabled', async () => { + const mainSource = await fsp.readFile( + path.resolve(process.cwd(), 'electron/main/index.ts'), + 'utf8' + ); + + expect(mainSource).toContain('webSecurity: true'); + expect(mainSource).not.toContain('webSecurity: false'); + expect(mainSource).toContain("'will-frame-navigate'"); + }); + + it('allows files inside the active workspace and rejects traversal outside it', async () => { + const root = await temporaryDirectory(); + const workspace = path.join(root, 'workspace'); + const outside = path.join(root, 'credentials'); + await fsp.mkdir(workspace); + await fsp.writeFile(path.join(workspace, 'report.html'), '

safe

'); + await fsp.writeFile(outside, 'secret'); + const realReportPath = await fsp.realpath( + path.join(workspace, 'report.html') + ); + + await expect( + authorizeLocalFilePath(path.join(workspace, 'report.html'), [workspace]) + ).resolves.toEqual({ + allowed: true, + filePath: realReportPath, + }); + await expect( + authorizeLocalFilePath(path.join(workspace, '..', 'credentials'), [ + workspace, + ]) + ).resolves.toEqual({ allowed: false, reason: 'outside-roots' }); + }); + + it('rejects a symlink that escapes the active workspace', async () => { + const root = await temporaryDirectory(); + const workspace = path.join(root, 'workspace'); + const secret = path.join(root, 'secret.txt'); + const link = path.join(workspace, 'linked-secret.txt'); + await fsp.mkdir(workspace); + await fsp.writeFile(secret, 'secret'); + await fsp.symlink(secret, link); + + await expect(authorizeLocalFilePath(link, [workspace])).resolves.toEqual({ + allowed: false, + reason: 'outside-roots', + }); + }); + + it('blocks executable files from external-open actions', () => { + expect(isExecutableExternalOpenPath('/workspace/install.command')).toBe( + true + ); + expect(isExecutableExternalOpenPath('C:\\workspace\\payload.BAT')).toBe( + true + ); + expect(isExecutableExternalOpenPath('/workspace/no-extension', 0o755)).toBe( + true + ); + expect(isExecutableExternalOpenPath('/workspace/report.pdf')).toBe(false); + }); + + it('accepts IPC only from the main renderer', () => { + expect(isMainRendererSender(7, 7)).toBe(true); + expect(isMainRendererSender(8, 7)).toBe(false); + expect(isMainRendererSender(7, null)).toBe(false); + }); +}); diff --git a/src/shared/filePreviewContract.ts b/src/shared/filePreviewContract.ts index 47813f4c..d34ab90b 100644 --- a/src/shared/filePreviewContract.ts +++ b/src/shared/filePreviewContract.ts @@ -217,9 +217,14 @@ export function decideFilePreview( return { mode: 'full', limit }; } - const limit = OFFICE_TYPES.has(normalized) - ? FILE_PREVIEW_LIMITS.officeBytes - : FILE_PREVIEW_LIMITS.defaultBytes; + if (!OFFICE_TYPES.has(normalized)) { + // Unknown files may be binary and their size says nothing about the cost + // of decoding and mounting a giant
. Keep every unrecognised format
+    // on the same bounded reader used by known text files.
+    return { mode: 'bounded-text', limit: FILE_PREVIEW_LIMITS.textBytes };
+  }
+
+  const limit = FILE_PREVIEW_LIMITS.officeBytes;
   if (size === null) {
     return {
       mode: 'blocked',
diff --git a/src/store/chatStore.durableReplay.test.ts b/src/store/chatStore.durableReplay.test.ts
index 65674c58..69af2596 100644
--- a/src/store/chatStore.durableReplay.test.ts
+++ b/src/store/chatStore.durableReplay.test.ts
@@ -12,13 +12,32 @@
 // limitations under the License.
 // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
 
-import { describe, expect, it } from 'vitest';
+import { describe, expect, it, vi } from 'vitest';
 import {
+  acceptCanonicalRunEvent,
+  admitDurableRunResume,
   canonicalRunEventToLegacyMessage,
+  createCanonicalRunEventCursor,
+  mergeFileInfoLists,
   normalizeTaskArtifactFileList,
 } from './chatStore';
 
 describe('canonical Run replay projection', () => {
+  it('surfaces Resume admission failures before execution starts', async () => {
+    const error = Object.assign(new Error('Unsafe tool outcome'), {
+      status: 409,
+    });
+    const post = vi.fn().mockRejectedValue(error);
+
+    await expect(
+      admitDurableRunResume('run/unsafe', 'resume-request-1', post)
+    ).rejects.toBe(error);
+    expect(post).toHaveBeenCalledWith('/runs/run%2Funsafe/resume', {
+      request_id: 'resume-request-1',
+      reason: 'explicit_resume',
+    });
+  });
+
   it('unwraps legacy UI events and ignores typed-only/control events', () => {
     expect(
       canonicalRunEventToLegacyMessage({
@@ -47,6 +66,27 @@ describe('canonical Run replay projection', () => {
     ).toBeNull();
   });
 
+  it('deduplicates reconnect replay by sequence and event_id', () => {
+    const cursor = createCanonicalRunEventCursor();
+    const first = { sequence: 1, event_id: 'event-1' };
+
+    expect(acceptCanonicalRunEvent(cursor, first, '1')).toBe(true);
+    expect(acceptCanonicalRunEvent(cursor, first, '1')).toBe(false);
+    expect(
+      acceptCanonicalRunEvent(cursor, {
+        sequence: 2,
+        event_id: 'event-1',
+      })
+    ).toBe(false);
+    expect(
+      acceptCanonicalRunEvent(cursor, {
+        sequence: 2,
+        event_id: 'event-2',
+      })
+    ).toBe(true);
+    expect(cursor.lastSequence).toBe(2);
+  });
+
   it('keeps same-named files when their workspace-relative paths differ', () => {
     const artifacts = Array.from({ length: 21 }, (_, index) => ({
       filename: 'index.html',
@@ -70,5 +110,68 @@ describe('canonical Run replay projection', () => {
         artifactChange: 'generated',
       })
     );
+
+    expect(mergeFileInfoLists([], files)).toHaveLength(21);
+
+    expect(
+      mergeFileInfoLists(
+        [
+          {
+            name: 'index.html',
+            type: 'html',
+            path: '/workspace/chapter-2/lesson-1/index.html',
+          },
+        ],
+        [files[0]]
+      )
+    ).toHaveLength(1);
+  });
+
+  it('matches URL-encoded stream paths and legacy x-prefixed paths', () => {
+    expect(
+      mergeFileInfoLists(
+        [
+          {
+            name: 'report.csv',
+            type: 'csv',
+            path: '/files/stream?path=reports%2Freport.csv&project_id=1',
+            isRemote: true,
+          },
+        ],
+        [
+          {
+            name: 'report.csv',
+            type: 'csv',
+            path: '/workspace/reports/report.csv',
+            relativePath: 'reports/report.csv',
+            artifactChange: 'changed',
+          },
+        ]
+      )
+    ).toMatchObject([
+      {
+        path: '/files/stream?path=reports%2Freport.csv&project_id=1',
+        artifactChange: 'changed',
+      },
+    ]);
+
+    expect(
+      mergeFileInfoLists(
+        [
+          {
+            name: 'report.csv',
+            type: 'csv',
+            path: 'x:/Users/test/report.csv',
+          },
+        ],
+        [
+          {
+            name: 'report.csv',
+            type: 'csv',
+            path: '/Users/test/report.csv',
+          },
+        ]
+      )
+    ).toHaveLength(1);
   });
 });
diff --git a/src/store/chatStore.ts b/src/store/chatStore.ts
index 8e483a63..61deef3a 100644
--- a/src/store/chatStore.ts
+++ b/src/store/chatStore.ts
@@ -86,6 +86,17 @@ const PROJECT_CONTEXT_MAX_RUNS = 8;
 // end step.
 const MAX_CHAT_HISTORY_SUMMARY_LENGTH = 1024;
 
+export async function admitDurableRunResume(
+  runId: string,
+  requestId: string,
+  post: typeof fetchPost = fetchPost
+): Promise {
+  await post(`/runs/${encodeURIComponent(runId)}/resume`, {
+    request_id: requestId,
+    reason: 'explicit_resume',
+  });
+}
+
 /** Adapt a canonical RunEvent to the legacy message reducer during migration. */
 export const canonicalRunEventToLegacyMessage = (
   value: unknown
@@ -112,6 +123,62 @@ export const canonicalRunEventToLegacyMessage = (
   } as AgentMessage;
 };
 
+export interface CanonicalRunEventCursor {
+  lastSequence: number;
+  recentEventIds: Set;
+  eventIdOrder: string[];
+}
+
+const CANONICAL_EVENT_ID_WINDOW = 2048;
+
+export function createCanonicalRunEventCursor(
+  afterSequence = 0
+): CanonicalRunEventCursor {
+  return {
+    lastSequence: Math.max(0, Math.trunc(afterSequence)),
+    recentEventIds: new Set(),
+    eventIdOrder: [],
+  };
+}
+
+/** Sequence is the primary replay boundary; event_id is defense in depth. */
+export function acceptCanonicalRunEvent(
+  cursor: CanonicalRunEventCursor,
+  value: unknown,
+  sseEventId?: unknown
+): boolean {
+  if (!value || typeof value !== 'object') return false;
+  const envelope = value as { sequence?: unknown; event_id?: unknown };
+  const sequenceCandidate =
+    typeof envelope.sequence === 'number'
+      ? envelope.sequence
+      : typeof sseEventId === 'string' && sseEventId.trim()
+        ? Number(sseEventId)
+        : NaN;
+  const sequence = Number.isInteger(sequenceCandidate)
+    ? sequenceCandidate
+    : null;
+  const eventId =
+    typeof envelope.event_id === 'string' && envelope.event_id
+      ? envelope.event_id
+      : undefined;
+
+  if (sequence === null && !eventId) return false;
+  if (sequence !== null && sequence <= cursor.lastSequence) return false;
+  if (eventId && cursor.recentEventIds.has(eventId)) return false;
+
+  if (sequence !== null) cursor.lastSequence = sequence;
+  if (eventId) {
+    cursor.recentEventIds.add(eventId);
+    cursor.eventIdOrder.push(eventId);
+    if (cursor.eventIdOrder.length > CANONICAL_EVENT_ID_WINDOW) {
+      const expired = cursor.eventIdOrder.shift();
+      if (expired) cursor.recentEventIds.delete(expired);
+    }
+  }
+  return true;
+}
+
 const clampHistorySummary = (
   value: string | undefined | null
 ): string | undefined =>
@@ -1153,15 +1220,69 @@ async function loadTaskArtifactFileList({
   }
 }
 
-function getFileInfoIdentities(file: FileInfo): string[] {
-  return [
-    file.relativePath,
-    file.path,
-    file.name,
-    getOutputFileNameFromPath(file.path || ''),
-  ]
-    .filter(Boolean)
-    .map((value) => normalizeOutputPath(value as string).toLowerCase());
+function normalizedFileIdentity(value: string | undefined): string {
+  if (!value) return '';
+
+  let identity = normalizeOutputPath(value);
+  const queryIndex = identity.indexOf('?');
+  if (queryIndex !== -1) {
+    try {
+      const remoteUrl = new URL(identity, 'http://eigent.local');
+      const streamedPath = remoteUrl.searchParams.get('path');
+      if (streamedPath) identity = normalizeOutputPath(streamedPath);
+    } catch {
+      // Keep the original value when it is not a valid URL-shaped path.
+    }
+  }
+
+  // Older Desktop builds accidentally prefixed POSIX sandbox paths with a
+  // synthetic `x:` drive. It is identity noise, not part of the real path.
+  return identity.replace(/^x:(?=\/)/i, '').toLowerCase();
+}
+
+function pathEndsWithRelativePath(
+  absoluteOrRemotePath: string,
+  relativePath: string
+): boolean {
+  return (
+    absoluteOrRemotePath === relativePath ||
+    absoluteOrRemotePath.endsWith(`/${relativePath.replace(/^\/+/, '')}`)
+  );
+}
+
+function fileInfoMatches(left: FileInfo, right: FileInfo): boolean {
+  const leftPath = normalizedFileIdentity(left.path);
+  const rightPath = normalizedFileIdentity(right.path);
+  const leftRelative = normalizedFileIdentity(left.relativePath);
+  const rightRelative = normalizedFileIdentity(right.relativePath);
+
+  if (leftRelative && rightRelative && leftRelative === rightRelative) {
+    return true;
+  }
+  if (leftPath && rightPath && leftPath === rightPath) return true;
+  if (
+    leftRelative &&
+    rightPath &&
+    pathEndsWithRelativePath(rightPath, leftRelative)
+  ) {
+    return true;
+  }
+  if (
+    rightRelative &&
+    leftPath &&
+    pathEndsWithRelativePath(leftPath, rightRelative)
+  ) {
+    return true;
+  }
+
+  // Name-only rows are legacy data with no path identity. Use the basename
+  // only when neither side has enough path information to distinguish files.
+  if (!leftPath && !rightPath && !leftRelative && !rightRelative) {
+    return (
+      normalizedFileIdentity(left.name) === normalizedFileIdentity(right.name)
+    );
+  }
+  return false;
 }
 
 function isLegacySandboxDrivePath(
@@ -1178,17 +1299,14 @@ export function mergeFileInfoLists(
   extractedFileList: FileInfo[]
 ): FileInfo[] {
   const merged = [...existingFileList];
-  const mergedIdentities = merged.map(getFileInfoIdentities);
 
   extractedFileList.forEach((file) => {
-    const identities = getFileInfoIdentities(file);
-    const existingIndex = mergedIdentities.findIndex((existingIdentities) =>
-      identities.some((identity) => existingIdentities.includes(identity))
+    const existingIndex = merged.findIndex((existing) =>
+      fileInfoMatches(existing, file)
     );
 
     if (existingIndex === -1) {
       merged.push(file);
-      mergedIdentities.push(identities);
       return;
     }
 
@@ -1201,7 +1319,6 @@ export function mergeFileInfoLists(
         ...existingFile,
         ...file,
       };
-      mergedIdentities[existingIndex] = identities;
       return;
     }
 
@@ -1645,6 +1762,10 @@ const chatStore = (initial?: Partial) =>
       if (type === 'replay') {
         setDelayTime(taskId, delayTime as number);
         setType(taskId, type);
+        // A replay reconstructs persisted execution time from event
+        // timestamps. Never carry a stale live clock across an idle/restart
+        // interval or the END reducer will count that interval as work.
+        get().setTaskTime(taskId, 0);
       }
 
       //ProjectStore must exist as chatStore is already
@@ -1843,12 +1964,13 @@ const chatStore = (initial?: Partial) =>
       const serverBaseUrl = import.meta.env.DEV
         ? window.location.origin
         : import.meta.env.VITE_BASE_URL;
+      const canonicalReplayCursor = createCanonicalRunEventCursor();
       const api =
         type == 'share'
           ? `${serverBaseUrl}/api/v1/chat/share/playback/${shareToken}?delay_time=${delayTime}`
           : type == 'replay'
             ? startOptions.replaySource === 'local_durable'
-              ? `/runs/${encodeURIComponent(newTaskId)}/stream?after_sequence=0`
+              ? `/runs/${encodeURIComponent(newTaskId)}/stream?after_sequence=${canonicalReplayCursor.lastSequence}`
               : `${serverBaseUrl}/api/v1/chat/steps/playback/${newTaskId}?delay_time=${delayTime}`
             : '/chat';
 
@@ -2262,6 +2384,19 @@ const chatStore = (initial?: Partial) =>
         }
       }
 
+      // Use the Run control API as the authoritative Resume admission gate.
+      // It returns real 404/409 errors for terminal, cancelled, cloud-restored,
+      // or unsafe-to-replay Runs. Only after all local model/workspace preflight
+      // has succeeded do we create the durable pending Attempt.
+      if (startOptions.resumeRequestId) {
+        try {
+          await admitDurableRunResume(newTaskId, startOptions.resumeRequestId);
+        } catch (error) {
+          finishStartupFailure();
+          throw error;
+        }
+      }
+
       // Lock the chatStore reference at the start of SSE session to prevent focus changes
       // during active message processing
       let lockedChatStore = targetChatStore;
@@ -2365,6 +2500,16 @@ const chatStore = (initial?: Partial) =>
           }
         : undefined;
 
+      let resumeStreamOpened = false;
+      let resolveResumeStreamOpen: (() => void) | undefined;
+      let rejectResumeStreamOpen: ((error: unknown) => void) | undefined;
+      const resumeStreamOpenPromise = startOptions.resumeRequestId
+        ? new Promise((resolve, reject) => {
+            resolveResumeStreamOpen = resolve;
+            rejectResumeStreamOpen = reject;
+          })
+        : null;
+
       const ssePromise = sseTransport({
         url: api,
         method: !type ? 'POST' : 'GET',
@@ -2381,6 +2526,15 @@ const chatStore = (initial?: Partial) =>
           try {
             const parsed = JSON.parse(event.data);
             if (startOptions.replaySource === 'local_durable') {
+              if (
+                !acceptCanonicalRunEvent(
+                  canonicalReplayCursor,
+                  parsed,
+                  event.id
+                )
+              ) {
+                return;
+              }
               // /runs/{id}/stream returns the canonical RunEvent envelope.
               // The existing Desktop reducer remains legacy-shaped during
               // migration, so typed-only/control events advance the stream
@@ -4538,13 +4692,28 @@ const chatStore = (initial?: Partial) =>
         },
         async onopen(respond) {
           console.log('open', respond);
-          if (!respond.ok) {
+          const contentType = respond.headers.get('content-type') || '';
+          if (!respond.ok || !contentType.startsWith('text/event-stream')) {
+            let detail = `HTTP ${respond.status}`;
+            try {
+              const body = await respond.clone().json();
+              const bodyDetail = body?.detail ?? body?.message ?? body?.text;
+              if (typeof bodyDetail === 'string') detail = bodyDetail;
+              else if (bodyDetail) detail = JSON.stringify(bodyDetail);
+            } catch {
+              // Preserve the HTTP fallback for non-JSON error responses.
+            }
             const error: any = new Error(
-              `Replay stream returned HTTP ${respond.status}`
+              contentType.startsWith('text/event-stream')
+                ? `Run stream returned ${detail}`
+                : `Run admission did not return an event stream: ${detail}`
             );
             error.status = respond.status;
+            rejectResumeStreamOpen?.(error);
             throw error;
           }
+          resumeStreamOpened = true;
+          resolveResumeStreamOpen?.();
           const { setAttaches, activeTaskId } = get();
           setAttaches(activeTaskId as string, []);
           return;
@@ -4590,6 +4759,8 @@ const chatStore = (initial?: Partial) =>
             return;
           }
 
+          if (!resumeStreamOpened) rejectResumeStreamOpen?.(err);
+
           const currentTaskId = getCurrentTaskId();
           // Update trigger execution status to Completed for connection closed by server
           updateTriggerExecutionStatus(
@@ -4659,6 +4830,30 @@ const chatStore = (initial?: Partial) =>
           }
         },
       });
+      if (resumeStreamOpenPromise) {
+        try {
+          await Promise.race([
+            resumeStreamOpenPromise,
+            ssePromise.then(() => {
+              if (!resumeStreamOpened) {
+                throw new Error(
+                  'Run stream closed before Resume admission completed'
+                );
+              }
+            }),
+          ]);
+        } catch (error) {
+          finishStartupFailure();
+          abortController.abort();
+          await ssePromise.catch(() => undefined);
+          throw error;
+        }
+        // The caller only waits for admission. Runtime streaming continues in
+        // the background and retains the existing reconnect behavior.
+        void ssePromise.catch((error) => {
+          console.error(`SSE stream failed for task ${newTaskId}:`, error);
+        });
+      }
       if (type === 'replay') {
         try {
           await ssePromise;
diff --git a/test/unit/lib/filePreviewContract.test.ts b/test/unit/lib/filePreviewContract.test.ts
index 093ccac6..c41a8402 100644
--- a/test/unit/lib/filePreviewContract.test.ts
+++ b/test/unit/lib/filePreviewContract.test.ts
@@ -55,4 +55,16 @@ describe('file preview policy', () => {
       decideFilePreview('md', { size: FILE_PREVIEW_LIMITS.textBytes + 1 })
     ).toMatchObject({ mode: 'bounded-text' });
   });
+
+  it('never fully decodes an unknown file in the renderer', () => {
+    expect(decideFilePreview('custom-data', { size: 20 })).toMatchObject({
+      mode: 'bounded-text',
+      limit: FILE_PREVIEW_LIMITS.textBytes,
+    });
+    expect(
+      decideFilePreview('custom-data', {
+        size: FILE_PREVIEW_LIMITS.defaultBytes,
+      })
+    ).toMatchObject({ mode: 'bounded-text' });
+  });
 });