docs/backend-migration/plans/2026-04-29-preview-module-fix-plan.md
Source of truth for the preview module cleanup after the backend migration. Focuses on Electron mode — WebUI / Web fallback is intentionally out of scope.
Not started. Three PRs, each self-testable end-to-end.
Fix the preview regressions exposed by the IPC → HTTP migration:
$HOME and $TMPDIR cannot preview any workspace file.read_file silently returns Ok(None) and the frontend throws TypeError: Cannot read properties of null. The toast just says "preview failed"./api/*-preview/start) have no sandbox validation at all — officecli can be spawned against arbitrary absolute paths./api/document/convert has the same problem — anyone could convert /etc/passwd.Non-goals:
/api/fs/serve binary streaming route.ipcBridge.fs.{read,write,getImageBase64,metadata,readBuffer,fetchRemoteImage} routes through /api/fs/* and hits FileService in aionui-backend.ipcBridge.{word,excel,ppt}Preview.{start,stop,status} routes through /api/*-preview/*, spawns officecli, and proxies through /api/office-watch-proxy/{port}/* and /api/ppt-proxy/{port}/*.ipcBridge.previewHistory.{list,save,getContent} routes through /api/preview-history/*.ipcBridge.document.convert routes through /api/document/convert.preview.open, {word,excel,ppt}-preview.status, fileStream.contentUpdate, fileWatch.fileChanged are wired.| Preview type | Symptom | Root cause |
|---|---|---|
| md / txt / code / html / diff | "preview failed" toast for any workspace outside $HOME / $TMPDIR | FileService.allowed_roots is fixed at startup; read_file returns Ok(None) on reject; frontend treats result as string and throws TypeError |
| png / jpg / svg etc. | Same as above | get_image_base64 rejects via sandbox; same frontend silent swallow |
| word / excel / ppt | No crash, but spawns officecli on any absolute path including paths outside the sandbox | start_{word,excel,ppt}_preview / start_office_watch / document/convert bypass validate_path entirely |
FileService::new takes allowed_roots: Vec<PathBuf> at construction. state_builders.rs:239 currently sets [temp_dir(), home_dir()].validate_path canonicalizes and checks starts_with. Strict.has_traversal substring-matches .. — too eager; rejects foo..bar.md.AppError::Forbidden may or may not exist (verify before adding); 403 with code: "PATH_OUTSIDE_SANDBOX" is the target shape.workspace: Option<String> field in each request body. No shared mutable state, no register_workspace lifecycle, no persistent sandbox. Callers that already know the workspace pass it; callers that don't rely on the default [temp_dir(), home_dir()].officecli is assumed installed on the developer machine and CI. No mocking.<webview src="file://"> which does not touch FileService.officecli-backed routes + document/convert. Adds sandbox validation only; does not refactor the officecli lifecycle.Scope: md / txt / code / html / image / diff. No office, no PDF.
aionui-backend)crates/aionui-api-types/src/file.rs
workspace: Option<String> to ReadFileRequest, ReadFileBufferRequest, GetImageBase64Request, GetFileMetadataRequest.crates/aionui-file/src/path_safety.rs
has_traversal body: walk Path::new(path).components(), reject Component::ParentDir only; keep the \0 check.pub fn validate_path_with_extra_root(path, base_roots, extra) -> Result<PathBuf, AppError> that appends extra to base_roots and calls validate_path.crates/aionui-common/src/error.rs
AppError::Forbidden(String) variant exists. If missing, add it; map to HTTP 403 with code: "PATH_OUTSIDE_SANDBOX".crates/aionui-file/src/traits.rs
extra_root: Option<&Path> to read_file, read_file_buffer, get_image_base64, get_file_metadata.crates/aionui-file/src/service.rs
read_file: remove the Ok(None) on reject branch (L639-642). Reject with AppError::Forbidden. Keep Ok(None) only for "file does not exist inside sandbox".read_file_buffer: same change.get_image_base64, get_file_metadata: accept extra_root, call validate_path_with_extra_root.crates/aionui-file/src/routes.rs
read_file, read_file_buffer, get_image_base64, get_file_metadata): pull workspace from body, pass as extra_root.aionui-backend)crates/aionui-file/src/path_safety.rs unit tests
has_traversal_allows_legal_filename_with_dots — foo..bar.md, README..old, my..file.txt all allowed.has_traversal_still_rejects_parent_dir — ../etc, a/../b, .., /foo/../bar all rejected.validate_path_accepts_extra_workspace_root — path under tempdir, extra root = tempdir, returns Ok.crates/aionui-file/tests/file_read_write.rs integration tests
read_file_with_extra_workspace_root_outside_home — create tempdir outside home, pass as extra_root, read succeeds.read_file_rejects_outside_sandbox_without_workspace — assert AppError::Forbidden, not Ok(None).read_file_returns_none_for_missing_file_in_sandbox — path inside sandbox but file does not exist → Ok(None).read_file_buffer_with_extra_workspace_root — buffer version.crates/aionui-file/tests/image_processing.rs
image_base64_with_extra_workspace_root — png inside tempdir, extra_root = tempdir, returns base64.crates/aionui-app/tests/file_e2e.rs
read_file_with_workspace_field_accepts_non_home_path — POST /api/fs/read with body including workspace, returns 200 + content.read_file_without_workspace_rejects_non_sandbox_path — same path, no workspace, returns 403 with code: "PATH_OUTSIDE_SANDBOX".read_file_non_existent_within_sandbox_returns_null — path in $HOME but file missing → 200, data: null.image_base64_with_workspace_field_accepts_non_home_path — same for image-base64 route.AionUi)src/common/adapter/ipcBridge.ts
fs.readFile, fs.readFileBuffer, fs.getImageBase64, fs.getFileMetadata: add optional workspace to params; allow null return for readFile / readFileBuffer / getImageBase64.src/renderer/utils/previewError.ts (new)
type PreviewErrorKind = 'sandbox' | 'not_found' | 'timeout' | 'too_large' | 'unknown'classifyPreviewError(error: unknown): PreviewErrorKind — map BackendHttpError.code === 'PATH_OUTSIDE_SANDBOX' to sandbox, FILE_NOT_FOUND to not_found, message match /timeout/i to timeout, null return to not_found, else unknown.previewErrorToI18nKey(kind) — returns i18n key.src/renderer/hooks/file/usePreviewLauncher.ts
ipcBridge.fs.readFile.invoke / getImageBase64.invoke calls (L129, L150): add workspace from context.null return from readFile: treat as not_found via classifyPreviewError.catch (L176-178) with error classification; surface kind on a returned state or emit through the existing message channel.src/renderer/pages/conversation/Workspace/hooks/useWorkspaceFileOps.ts
handlePreviewFile (L293-412): pass workspace to ipcBridge.fs.readFile.invoke (L386) and getImageBase64.invoke (L383).previewErrorToI18nKey.src/renderer/pages/conversation/Preview/components/viewers/MarkdownViewer.tsx
workspace prop. Pass from PreviewContext.fetchRemoteImage.invoke: no change (remote, no sandbox).getImageBase64.invoke: pass workspace.src/renderer/pages/conversation/Preview/components/renderers/HTMLRenderer.tsx
workspace.src/renderer/pages/conversation/Preview/components/viewers/ImageViewer.tsx
workspace prop. L44: pass workspace.src/renderer/pages/conversation/Preview/context/PreviewContext.tsx
workspace to getImageBase64 / readFile.workspace through context consumers.src/renderer/components/media/FilePreview.tsx, LocalImageView.tsx, src/renderer/utils/file/download.ts, src/renderer/pages/conversation/Messages/components/MessageToolGroup.tsx, src/renderer/pages/conversation/Workspace/components/FileChangeList.tsx, src/renderer/pages/conversation/components/SkillRuleGenerator.tsx
ipcBridge.fs.readFile.invoke / getImageBase64.invoke / readFileBuffer.invoke call. Pass workspace where a conversation context is available. For global UI (settings, css theme), workspace is absent and caller relies on default sandbox.i18n — locales/{en,zh,zh-Hant,...}/translation.json
conversation.workspace.preview.errors.outsideSandbox — "File is outside the workspace sandbox, cannot preview." / "文件不在工作区范围内,无法预览。"conversation.workspace.preview.errors.notFound — "File does not exist or has been deleted." / "文件不存在或已被删除。"conversation.workspace.preview.errors.timeout — "Reading file timed out. Please retry." / "读取文件超时,请稍后重试。"bun run i18n:types && node scripts/check-i18n.js.AionUi)tests/unit/usePreviewLauncher.dom.test.ts (new)
launchPreview_md_file_success — mock bridge returns content, assert openPreview called with correct metadata.launchPreview_md_file_sandbox_error — mock bridge throws BackendHttpError with code: 'PATH_OUTSIDE_SANDBOX'; assert loading cleared, error kind = sandbox.launchPreview_md_file_null_returned — mock bridge returns null; assert kind = not_found.launchPreview_md_file_timeout — mock bridge rejects with Error('File read timeout'); assert kind = timeout.launchPreview_image_passes_workspace — spy on invoke, assert workspace in args.launchPreview_pdf_skips_read — contentType pdf, assert readFile not called, openPreview called with empty content.tests/unit/useWorkspaceFileOps.dom.test.ts (new)
previewFile_md_calls_readFile_with_workspace — spy invoke args.previewFile_png_calls_getImageBase64_with_workspace — same.previewFile_outside_sandbox_shows_outsideSandbox_toast — mock bridge throws sandbox error; assert messageApi.error called with translated text matching i18n key.previewFile_missing_file_shows_notFound_toast — same for null return.Samples to copy: tests/unit/FilePreview.dom.test.tsx, tests/unit/previewFileWatch.dom.test.ts, tests/unit/useAutoPreviewOfficeFiles.dom.test.ts.
Playwright E2E — tests/e2e/features/previews/preview-panel.e2e.ts (extend)
preview files in non-home workspace.beforeAll: fs.mkdtemp(path.join(os.tmpdir(), 'aionui-e2e-non-home-')); write a.md, b.txt, c.png (1x1 minimal bytes), d.html. Create a conversation with extra.workspace = <tempdir> through invokeBridge.opens markdown — click tree node, assert panel visible, assert rendered content matches a.md.opens txt — assert code viewer has expected text.opens png — assert `` present in preview panel.opens html — assert iframe with expected title.afterAll: fs.rm(tempdir, { recursive: true, force: true }).# Backend
cd aionui-backend
cargo fmt --all
cargo clippy -p aionui-file -p aionui-app -p aionui-common --all-targets -- -D warnings
cargo test -p aionui-file
cargo test -p aionui-app file_e2e
# Backend live smoke
cargo run -p aionui-app -- --port 25808 --local --data-dir /tmp/aionui-e2e-data &
mkdir -p /tmp/aionui-test-ws && echo '# hello' > /tmp/aionui-test-ws/a.md
curl -s http://127.0.0.1:25808/api/fs/read \
-H 'Content-Type: application/json' \
-d '{"path":"/tmp/aionui-test-ws/a.md","workspace":"/tmp/aionui-test-ws"}' | jq .
curl -s http://127.0.0.1:25808/api/fs/read \
-H 'Content-Type: application/json' \
-d '{"path":"/opt/nowhere/x.md"}' | jq .
# Frontend
cd AionUi
bun run lint:fix
bun run format
bunx tsc --noEmit
bun run test
bun run test:e2e -- preview-panel
bun run i18n:types
node scripts/check-i18n.js
prek run --from-ref origin/main --to-ref HEAD
# Manual smoke in Electron
npm run dev
# set workspace to /Users/zhoukai/Documents/测试数据 then click md/txt/png/html in the workspace tree
prek green/Users/zhoukai/Documents/测试数据/tmp/aionui-test-ws preview correctlyoutsideSandbox toast, not a generic "preview failed"aionui-backend and AionUi with cross-links and Closes #<n>Scope: word / excel / ppt / document convert / office-watch. Depends on PR 1 being merged so the workspace field shape is stable.
aionui-backend)crates/aionui-api-types/src/office.rs
workspace: Option<String> to StartPreviewRequest, DocumentConversionRequest.workspace: Option<String> to OfficeWatchStartRequest (in aionui-api-types/src/file.rs if defined there).crates/aionui-office/src/routes.rs
start_word_preview, start_excel_preview, start_ppt_preview: call validate_path_with_extra_root on file_path before handing off to watch_manager.start. On failure return AppError::Forbidden.convert_document: same validation.start_*_preview handlers need a reference to FileService.allowed_roots. Either inject FileService into OfficeRouterState or pass the base roots at state build time. Pick whichever touches fewer crates.crates/aionui-file/src/routes.rs
start_office_watch: same validation.crates/aionui-app/src/state_builders.rs
allowed_roots into OfficeRouterState. Verify no other consumer breaks.Error-code normalization in crates/aionui-office/src/
OfficeError variants surface to HTTP with stable code strings: OFFICECLI_NOT_FOUND, OFFICECLI_INSTALL_FAILED, OFFICECLI_PORT_TIMEOUT, OFFICECLI_START_FAILED, PATH_OUTSIDE_SANDBOX.{ error, url } shape; switch to proper 4xx/5xx for sandbox and invalid inputs.Proxy route validation — crates/aionui-office/src/proxy.rs
is_active_port(port, doc_type) is already called. Verify it binds port↔doc_type tightly; otherwise patch so a PPT port cannot be proxied via /api/office-watch-proxy and vice versa.aionui-backend)crates/aionui-office/tests/ (integration)
start_word_preview_accepts_path_in_extra_workspace — tempdir outside home, returns successful start (requires officecli).start_word_preview_rejects_path_outside_sandbox — returns 403 with code: "PATH_OUTSIDE_SANDBOX", no officecli spawn.convert_document_rejects_outside_sandbox — 403.crates/aionui-app/tests/office_e2e.rs
office_preview_flow_with_workspace — full lifecycle: start with workspace field, poll status, stop. Assert url returned and reachable.office_preview_rejects_without_workspace_for_non_home_path — 403.document_convert_rejects_outside_sandbox — 403.Proxy route — crates/aionui-office/tests/proxy_integration.rs
proxy_rejects_mismatched_doc_type — PPT port accessed via word proxy returns 403 or 404.AionUi)src/common/adapter/ipcBridge.ts
pptPreview.start, wordPreview.start, excelPreview.start: add optional workspace to params.document.convert: add optional workspace to request.workspaceOfficeWatch.scan: already has workspace; confirm.src/renderer/pages/conversation/Preview/components/viewers/OfficeWatchViewer.tsx
workspace prop. Pass to bridge.start.invoke({ file_path, workspace }).OFFICECLI_NOT_FOUND → install guide + linkOFFICECLI_INSTALL_FAILED → retry button + logs hintOFFICECLI_PORT_TIMEOUT → retry buttonOFFICECLI_START_FAILED → generic failurePATH_OUTSIDE_SANDBOX → workspace hint (should be very rare after PR 1's fix)src/renderer/hooks/file/useAutoPreviewOfficeFiles.ts
workspace into auto-preview start calls.src/renderer/pages/conversation/Preview/components/viewers/{PptViewer,OfficeDocViewer,ExcelViewer}.tsx
workspace prop to OfficeWatchViewer.i18n
preview.office.errors.officecliNotFound / .installFailed / .portTimeout / .startFailed / .outsideSandboxpreview.office.installLinkText.AionUi)tests/unit/OfficeWatchViewer.dom.test.tsx (new)
bridge.start.invoke to return each error variant; assert the correct UI block is rendered.<WebviewHost> / <iframe> appears with the returned URL.workspace is forwarded in start.invoke args.Playwright E2E — tests/e2e/features/previews/office-preview.e2e.ts (new or extend)
beforeAll: fs.mkdtemp(path.join(os.tmpdir(), 'aionui-e2e-office-')); copy fixture .docx, .xlsx, .pptx files into it (add fixtures under tests/e2e/fixtures/office/). Create conversation with extra.workspace = <tempdir>.officecli):
opens docx in non-home workspace — assert <webview> or iframe becomes visible and status flips to ready.opens xlsx in non-home workspace — same.opens pptx in non-home workspace — same.rejects docx outside sandbox — call pptPreview.start.invoke({ file_path: '/opt/nowhere.pptx' }) directly via invokeBridge, assert 403 error returned (does not spawn officecli).afterAll: stop preview bridges, remove tempdir.cd aionui-backend
cargo fmt --all
cargo clippy -p aionui-office -p aionui-file -p aionui-app --all-targets -- -D warnings
cargo test -p aionui-office
cargo test -p aionui-app office_e2e
cd AionUi
bun run lint:fix && bun run format
bunx tsc --noEmit
bun run test
bun run test:e2e -- office-preview
bun run i18n:types && node scripts/check-i18n.js
prek run --from-ref origin/main --to-ref HEAD
# Manual smoke in Electron
npm run dev
# set workspace to /Users/zhoukai/Documents/测试数据, open a docx, xlsx, pptx
/Users/zhoukai/Documents/测试数据 all preview in ElectronpptPreview.start with path outside sandbox, error surfaces with outsideSandbox messageCloses #<n>Scope: frontend only, no backend changes.
src/renderer/utils/previewError.ts
code → PreviewErrorKind → i18n key table.Extension detection consolidation
src/renderer/pages/conversation/Preview/fileUtils.ts
FILE_EXTENSION_MAP to include odt, odp, ods, csv, tiff, avif, mdown, mkd.getContentTypeByExtension as the canonical source.src/renderer/pages/conversation/Workspace/hooks/useWorkspaceFileOps.ts
getContentTypeByExtension instead.Large-text truncate banner
src/renderer/pages/conversation/Preview/components/PreviewPanel/PreviewPanel.tsx
metadata.truncated === true, render a sticky banner "Content truncated to first 800KB. Click download to see full file."src/renderer/hooks/file/usePreviewLauncher.ts and useWorkspaceFileOps.ts
truncated: boolean through openPreview metadata.PDF path encoding
src/renderer/pages/conversation/Preview/components/viewers/PDFViewer.tsx
const pdfSrc = file_path ? \file://${encodeURI(file_path)}` : content || ''`encodeURI handles Chinese / spaces / unicode correctly.Office proxy URL refactor
src/renderer/pages/conversation/Preview/components/viewers/OfficeWatchViewer.tsx
(window as Window & { __backendPort?: number }).__backendPort with a shared helper from src/common/adapter/httpBridge.ts (getBaseUrl() or new getBackendOrigin()).isElectronDesktop() branch for direct localhost:port usage.tests/unit/previewError.test.ts (new)
code to the expected kind.kind to the expected i18n key.tests/unit/fileUtils.test.ts (extend)
md ↔ markdown case-insensitive.tests/unit/usePreviewLauncher.dom.test.ts (extend)
truncated: true passed through metadata when content > threshold.tests/unit/PDFViewer.dom.test.tsx (new)
webview src contains encodeURI output for path with unicode and space.Playwright (optional)
.. in filename like v1..v2.md, assert no error.cd AionUi
bun run lint:fix && bun run format
bunx tsc --noEmit
bun run test
bun run i18n:types && node scripts/check-i18n.js
prek run --from-ref origin/main --to-ref HEAD
Closes #<n>main — reuses workspace field conventionreadFile without a workspace in Electron depend on home-dir fallback. After this change they keep working because the default allowed_roots still includes home. The behavior change for users affected is a net positive — previously silent failure becomes a meaningful error.officecli against arbitrary paths. Search for callers that pass non-workspace paths; none should exist in normal user flows.Rollback plan: each PR is independent and revertible. Backend changes ship as additive fields with safe defaults.
None pending — all decisions captured above.
docs/backend-migration/handoffs/ — other module migrations (assistant, model-config, cron) for style reference..claude/skills/testing/SKILL.md — testing standards for this repo..claude/skills/oss-pr/SKILL.md — PR workflow.