feat(files): open markdown, HTML, and PDF files outside the workspace#9140

merged3
main
t3code/open-outside-workspace-files
openedSep 2, 2026byJJulius Marminge
View on GitHubTwo-way sync enabledPR last imported 4d ago

Agents regularly write a report to a temp directory and link it in their reply. That chip was a dead end: the files panel only accepted workspace-relative paths, and projects.readFile rejected anything outside the root. HTML and PDF links outside the workspace failed one layer down, because the browser preview always minted a workspace-scoped asset URL.

How

  • Server: WorkspaceFileSystem.readFile accepts an absolute path and reads that host file in place. Workspace-relative paths keep the root and symlink containment checks, and writeFile still rejects absolute paths, so host files are read-only. The media-file asset resource now also accepts .html, .htm, and .pdf, using the same exact-file, inode-pinned token; workspace HTML keeps using the directory-scoped workspace-file resource so sibling assets load.
  • Web: file chips carry a panelPath that falls back to the absolute host path for non-media files outside the workspace, so they open in the files panel. The panel renders host files read-only (rendered Markdown works, checkboxes do not write back) with breadcrumbs starting at the filesystem root. openFileInPreview picks workspace-file or media-file by workspace membership, so the panel's browser button and PDF chips work for host files.
  • Mobile: outside-workspace chips route to the file screen (PDFs to the existing PDF preview with a media-file resource), and the asset URL hook picks media-file for host paths.
  • Docs: user note in composer.md; environment-auth.md updated, since it explicitly stated the workspace boundary still applied to HTML and PDF.

No wire changes: media-file and readFile keep their shapes, so older clients keep working.

One deliberate widening to flag: an authenticated orchestration:read client can now read any text file the server account can read, not only workspace files. That matches what filesystem.browse and media-file already allow and is documented as such.

Demo

Clicking a /tmp/...cleanup-report.md chip opens it in the files panel, toggling to rendered Markdown, then opening the .html chip beside it:

https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/7191626f13944a44/outside-workspace-files.webm

Verification

  • apps/server: WorkspaceFileSystem.test.ts (absolute read, absolute write rejected), AssetAccess.test.ts (HTML/PDF minting), http.test.ts
  • apps/web: filePath.test.ts (host breadcrumbs), markdown-links.test.ts, ChatMarkdown.*.test.tsx, FilePreviewPanel.test.ts
  • apps/mobile: filePath.test.ts (route segments round-trip)
  • Per-package typecheck and lint on the changed files; web flow exercised end to end in the recording above.

Written by Claude Fable 5 in Claude Code.

πŸ€– Generated with Claude Code


Note

Medium Risk Expands what authenticated orchestration:read clients can read and preview on the host (any text file via absolute readFile, HTML/PDF via media-file), though writes stay workspace-bound and HTML gets a sandbox CSP.

Overview Agents can link files outside the project (e.g. /tmp/.../report.md); this PR wires those links through end-to-end instead of stopping at workspace-only paths.

Server: projects.readFile / WorkspaceFileSystem.readFile now accept an absolute host path and read that file in place (still read-onlyβ€”writeFile rejects absolutes). Workspace-relative reads keep root/symlink containment; opens use non-blocking mode so FIFOs fail instead of hanging. The media-file asset resource now allows HTML and PDF (via hostPreviewMimeTypeFromExtension), and inline HTML responses add a sandbox CSP.

Web & mobile: File chips carry a panelPath that can be the absolute host path; the files panel shows host files read-only (markdown render on, task-list edits off) with breadcrumbs from the filesystem root. openFileInPreview and asset URL minting choose workspace-file vs media-file based on whether the path is inside the workspace. Mobile thread links navigate to the file screen (or PDF preview) for absolute paths. Markdown relative links can anchor on imageBaseDir when rendering a file outside the repo.

Docs/contracts: User and environment-auth docs describe host file read and expanded media preview; contract comments/error text updatedβ€”no wire shape changes.

Reviewed by Cursor Bugbot for commit c0fa311c308958fa1d6249e57d233e77254d6724. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Support opening markdown, HTML, and PDF files outside the workspace

  • Server WorkspaceFileSystem.readFile now accepts absolute host paths via resolveReadTarget, resolving them through realpath without applying the workspace-root boundary check; writes remain workspace-relative only.
  • Asset URL issuance and resolution in AssetAccess accept HTML and PDF (in addition to images and videos) using the new shared hostPreviewMimeTypeFromExtension classifier; unsupported extensions still return no asset.
  • Web and mobile clients classify absolute paths as host files, render them read-only in the file panel, and use exact media-file asset URLs for out-of-workspace previews. Markdown link resolution gains an optional baseDir so relative links anchor to the rendered file's directory rather than only the workspace cwd.
  • HTML asset responses now include a sandbox content-security policy (permitting scripts, forms, popups, modals) alongside the UTF-8 content type.
  • File breadcrumbs and path utilities handle POSIX, Windows drive, and UNC absolute paths, starting at the filesystem root instead of the project name.
  • Risk: WorkspaceFileSystem.resolveReadTarget bypasses the workspace-root boundary check for absolute reads β€” any reachable host file path is readable. HTML sandbox CSP permits scripts and forms within the sandboxed context; separately served HTML cannot load adjacent scripts, styles, or images.

Macroscope summarized c0fa311.

Synced from GitHub

Thread transfer impact

βœ… Thread transfer remains within every enforced ceiling.

ProviderMetricMain baselineThis PRImpactPR ceiling
CodexTotal thread wire13.4 KiB13.2 KiBβˆ’242 B (βˆ’1.8%)15.1 KiBβœ…
CodexThread snapshot wire6.9 KiB6.9 KiBβˆ’8 B (βˆ’0.1%)7.3 KiBβœ…
CodexLive turn WebSocket wire6.5 KiB6.3 KiBβˆ’234 B (βˆ’3.5%)7.8 KiBβœ…
CodexLive turn WebSocket decoded57.0 KiB55.5 KiBβˆ’1.5 KiB (βˆ’2.6%)66.4 KiBβœ…
CodexLive turn messages108βˆ’2 (βˆ’20.0%)21βœ…
ClaudeTotal thread wire13.5 KiB13.4 KiBβˆ’23 B (βˆ’0.2%)15.1 KiBβœ…
ClaudeThread snapshot wire6.9 KiB6.9 KiBβˆ’5 B (βˆ’0.1%)7.3 KiBβœ…
ClaudeLive turn WebSocket wire6.6 KiB6.6 KiBβˆ’18 B (βˆ’0.3%)7.8 KiBβœ…
ClaudeLive turn WebSocket decoded57.8 KiB57.8 KiB0 B (0.0%)66.4 KiBβœ…
ClaudeLive turn messages10100 (0.0%)21βœ…

Baseline: ea71a19 Β· PR result: c0fa311 Β· Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 109.4 KiB
  • Claude decoded thread snapshot: 110.1 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

apps/mobile/src/features/files/ThreadFilesRouteScreen.tsxline 783Outdated Β· e52e413Open thread
……@@ -777,7 +778,10 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
780- const headerSubtitle = [projectName, parentDir].filter(Boolean).join(" Β· ");
781+ // A host file outside the workspace is not under the project name.
782+ const headerSubtitle = isAbsolutePath(relativePath)
783+ ? parentDir
MmacroscopeappBoton line 783Sep 2, 2026

🟑 Medium files/ThreadFilesRouteScreen.tsx:783

Windows absolute paths such as C:\temp\report.md render with an empty header subtitle instead of C:\temp. parentDir splits only on /, so the backslash-separated path remains one segment and .slice(0, -1) produces an empty string; compute it with both path separators.

πŸš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx around line 783:
2
3Windows absolute paths such as `C:\temp\report.md` render with an empty header subtitle instead of `C:\temp`. `parentDir` splits only on `/`, so the backslash-separated path remains one segment and `.slice(0, -1)` produces an empty string; compute it with both path separators.

Fixed in c1d8c40: parentDir is computed from the last separator of either kind.

apps/server/src/workspace/WorkspaceFileSystem.tsfileOutdated Β· e52e413Open thread

🟠 High

https://github.com/pingdotgg/t3code/blob/e52e413fde48a5cfd3ac5926eea284a4e5f66a10/apps/server/src/workspace/WorkspaceFileSystem.ts#L218

Reading an absolute host FIFO such as /tmp/pipe blocks in NodeFSP.open(realTargetPath, "r"), so stat.isFile() is never reached and the request can leave a fiber blocked indefinitely when no writer exists. Stat the resolved target before opening it and reject non-regular files first.

πŸš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/server/src/workspace/WorkspaceFileSystem.ts around line 218:
2
3Reading an absolute host FIFO such as `/tmp/pipe` blocks in `NodeFSP.open(realTargetPath, "r")`, so `stat.isFile()` is never reached and the request can leave a fiber blocked indefinitely when no writer exists. Stat the resolved target before opening it and reject non-regular files first.

Fixed in c1d8c40: the open now passes O_RDONLY | O_NONBLOCK (same as MediaFile.ts), so a FIFO returns immediately and the existing stat.isFile() check rejects it. Added a mkfifo test.

apps/web/src/components/files/filePath.tslines 19–19Outdated Β· e52e413Open thread
……@@ -1,16 +1,27 @@
16+ const hostPath = isAbsolutePath(relativePath);
17+ const separator = isWindowsAbsolutePath(relativePath) ? "\\" : "/";
18+ const parts = relativePath.split(/[\\/]/).filter(Boolean);
19+ const root = hostPath && separator === "/" ? "/" : "";
MmacroscopeappBoton lines 19–19Sep 2, 2026

🟑 Medium files/filePath.ts:19

fileBreadcrumbs drops the UNC prefix from paths such as \\server\share\report.md, so FileBreadcrumb.path and its tooltip show server\share... as a relative path. Preserve the two leading backslashes when constructing the root for UNC paths.

diff
1- const root = hostPath && separator === "/" ? "/" : "";
2+ const root = relativePath.startsWith("\\\\") ? "\\\\" : hostPath && separator === "/" ? "/" : "";
πŸš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/web/src/components/files/filePath.ts around line 19:
2
3`fileBreadcrumbs` drops the UNC prefix from paths such as `\\server\share\report.md`, so `FileBreadcrumb.path` and its tooltip show `server\share...` as a relative path. Preserve the two leading backslashes when constructing the root for UNC paths.

Fixed in c1d8c40: UNC paths keep their leading backslashes as the root; test added.

CcursorBotreviewed Β· 1 inline threadSep 2, 2026
Synced from GitHub

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix is ON, but a cloud agent failed to start.

Reviewed by Cursor Bugbot for commit e52e413fde48a5cfd3ac5926eea284a4e5f66a10. Configure here.

apps/web/src/components/ChatMarkdown.tsxline 2320Outdated Β· e52e413Open thread
……@@ -2310,14 +2313,19 @@ function ChatMarkdown({
2317+ // files (a report in a temp dir) open read-only in the files panel.
2318+ const panelPath =
2319+ fileLinkMeta.workspaceRelativePath ??
2320+ (!canPreviewMedia && isAbsolutePath(fileLinkMeta.filePath) ? fileLinkMeta.filePath : null);
CcursorBoton line 2320Sep 2, 2026

Binary host chips open in panel

Medium Severity

panelPath is set for every non-media absolute host path, so the chip's primary click opens the files panel. readFile then rejects binaries, so host PDFs (when the integrated browser is unavailable), xlsx, and similar agent outputs error instead of falling through to the editor as they did before.

Additional Locations (2)

Reviewed by Cursor Bugbot for commit e52e413fde48a5cfd3ac5926eea284a4e5f66a10. Configure here.

Intentional, matching existing behavior: a binary file inside the workspace (xlsx, pdf on web) already opens the panel and shows the same 'binary' message today, so host files behave identically rather than worse. The context menu still offers the editor and Reveal, and PDFs render in the panel in the stacked #9143. Dismissing.

Synced from GitHub

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable β€” This PR adds an end-to-end capability for authenticated clients to read server-host files by absolute path and preview external HTML/PDF, changing filesystem and browser security boundaries across server, web, and mobile. The changed test also adds a file-level static-analysis diagnostic suppression, so the authorization expansion and analysis override require human review.

You can add or adjust custom eligibility rules. Learn more.

Added 2 commits

  • feat(files): open markdown, HTML, and PDF files outside the workspace89d3b26
  • fix(files): reject FIFOs on open and keep UNC and Windows roots in file pathsc1d8c40
apps/server/src/assets/AssetAccess.tsline 249Outdated Β· c1d8c40Open thread
……@@ -246,7 +246,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
247247 return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource });
248248 }
249- if (mediaMimeTypeFromExtension(path.extname(canonicalFile)) === null) {
249+ if (hostPreviewMimeTypeFromExtension(path.extname(canonicalFile)) === null) {
MmacroscopeappBoton line 249Sep 2, 2026

🟠 High assets/AssetAccess.ts:249

hostPreviewMimeTypeFromExtension allows .html and .htm files to be minted as inline asset URLs, so an untrusted host HTML file is served as text/html at the server application's origin and can execute same-origin script in the user's authenticated browser. Keep media-file issuance restricted to media extensions, or apply an HTML isolation policy such as a CSP or sandbox before permitting these files inline.

πŸš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/server/src/assets/AssetAccess.ts around line 249:
2
3`hostPreviewMimeTypeFromExtension` allows `.html` and `.htm` files to be minted as inline asset URLs, so an untrusted host HTML file is served as `text/html` at the server application's origin and can execute same-origin script in the user's authenticated browser. Keep media-file issuance restricted to media extensions, or apply an HTML isolation policy such as a CSP or `sandbox` before permitting these files inline.

Fixed in c0fa311: inline HTML asset responses now carry Content-Security-Policy: sandbox allow-scripts allow-forms allow-popups allow-modals, which gives the document an opaque origin, so it cannot use the session cookie or reach same-origin APIs or storage. Scripts and sibling assets still work for reports. This applies to workspace HTML too, which had the same exposure before this PR. Test added in http.test.ts.

apps/web/src/components/files/FilePreviewPanel.tsxline 1140Outdated Β· c1d8c40Open thread
……@@ -1128,9 +1137,10 @@ export default function FilePreviewPanel({
11281137 relativePath={relativePath}
11291138 threadRef={threadRef}
11301139 contents={file.data.contents}
1140+ readOnly={isHostFile}
MmacroscopeappBoton line 1140Sep 2, 2026

🟑 Medium files/FilePreviewPanel.tsx:1140

Absolute Markdown files resolve relative links against the workspace cwd, so ./appendix.md in /tmp/report.md opens /workspace/appendix.md (or a same-named workspace file) instead of /tmp/appendix.md. RenderedMarkdownSurface passes cwd to FileMarkdownPreview, whose ChatMarkdown uses it for ordinary links; derive the link base from the host file’s directory when rendering an absolute path.

πŸš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/web/src/components/files/FilePreviewPanel.tsx around line 1140:
2
3Absolute Markdown files resolve relative links against the workspace `cwd`, so `./appendix.md` in `/tmp/report.md` opens `/workspace/appendix.md` (or a same-named workspace file) instead of `/tmp/appendix.md`. `RenderedMarkdownSurface` passes `cwd` to `FileMarkdownPreview`, whose `ChatMarkdown` uses it for ordinary links; derive the link base from the host file’s directory when rendering an absolute path.

Fixed in c0fa311: link resolution takes a base directory that FileMarkdownPreview already computed for images, so relative links in a rendered file anchor to that file's directory while workspace membership still follows cwd. Test added in markdown-links.test.ts.

Added 1 commit

  • fix(files): sandbox inline HTML assets and anchor links to the rendered filec0fa311