History

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

Julius Marminge committed 2 months ago

Commit f46a7091 parent f2a914b26 files · +394 −89Browse files

Unified · Split

26 files
  1. apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +9 −2
  2. apps/mobile/src/features/files/filePath.test.ts +14 −1
  3. apps/mobile/src/features/files/filePath.ts +8 −1
  4. apps/mobile/src/features/files/workspaceFileAssetUrl.ts +10 −4
  5. apps/mobile/src/features/threads/ThreadFeed.tsx +36 −2
  6. apps/server/src/assets/AssetAccess.test.ts +6 −4
  7. apps/server/src/assets/AssetAccess.ts +3 −3
  8. apps/server/src/http.test.ts +7 −9
  9. apps/server/src/http.ts +8 −1
  10. apps/server/src/workspace/WorkspaceFileSystem.test.ts +66 −0
  11. apps/server/src/workspace/WorkspaceFileSystem.ts +48 −6
  12. apps/web/src/browser/openFileInPreview.ts +9 −1
  13. apps/web/src/components/ChatMarkdown.tsx +33 −23
  14. apps/web/src/components/RightPanelTabs.tsx +3 −1
  15. apps/web/src/components/files/FilePreviewPanel.tsx +22 −12
  16. apps/web/src/components/files/filePath.test.ts +19 −1
  17. apps/web/src/components/files/filePath.ts +14 −3
  18. apps/web/src/markdown-links.test.ts +16 −0
  19. apps/web/src/markdown-links.ts +14 −6
  20. apps/web/src/rightPanelStore.ts +1 −0
  21. apps/web/src/terminal-links.ts +1 −1
  22. docs/internals/environment-auth.md +16 −7
  23. docs/user/composer.md +9 −0
  24. packages/contracts/src/assets.ts +4 −1
  25. packages/contracts/src/project.ts +2 −0
  26. packages/shared/src/filePreview.ts +16 −0
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+9−2
@@ -60,6 +60,7 @@ import { WorkspaceFileVideoPreview } from "./WorkspaceFileVideoPreview"; · 59 unmodified lines
6060 import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview";
6161 import {
6262 basename,
63+ isAbsolutePath,
6364 isMarkdownPreviewFile,
6465 isSvgImagePreviewFile,
6566 isVideoPreviewFile,
@@ -782,8 +783,14 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { · 716 unmodified lines
782783 );
783784 }
784785
785− const parentDir = relativePath.split("/").slice(0, -1).join("/");
786− const headerSubtitle = [projectName, parentDir].filter(Boolean).join(" · ");
786+ const parentDir = relativePath.slice(
787+ 0,
788+ Math.max(relativePath.lastIndexOf("/"), relativePath.lastIndexOf("\\"), 0),
789+ );
790+ // A host file outside the workspace is not under the project name.
791+ const headerSubtitle = isAbsolutePath(relativePath)
792+ ? parentDir
793+ : [projectName, parentDir].filter(Boolean).join(" · ");
787794
788795 return (
789796 <ReviewHighlighterProvider>
apps/mobile/src/features/files/filePath.test.ts+14−1
@@ -1,6 +1,19 @@
11 import { describe, expect, it } from "vite-plus/test";
22
3−import { isSvgImagePreviewFile, resolveWorkspaceRelativeFilePath } from "./filePath";
3+import {
4+ fileRoutePathSegments,
5+ isSvgImagePreviewFile,
6+ resolveWorkspaceRelativeFilePath,
7+} from "./filePath";
8+
9+describe("fileRoutePathSegments", () => {
10+ it("round-trips workspace-relative and host paths through the route", () => {
11+ expect(fileRoutePathSegments("src/main.ts")).toEqual(["src", "main.ts"]);
12+ expect(fileRoutePathSegments("/tmp/t3-cleanup/report.md").join("/")).toBe(
13+ "/tmp/t3-cleanup/report.md",
14+ );
15+ });
16+});
417
518 describe("resolveWorkspaceRelativeFilePath", () => {
619 it("keeps normalized workspace-relative paths", () => {
apps/mobile/src/features/files/filePath.ts+8−1
@@ -10,10 +10,17 @@ function isWindowsAbsolutePath(value: string): boolean { · 9 unmodified lines
1010 return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\");
1111 }
1212
13−function isAbsolutePath(value: string): boolean {
13+/** A file route holding an absolute path shows a host file outside the workspace. */
14+export function isAbsolutePath(value: string): boolean {
1415 return value.startsWith("/") || isWindowsAbsolutePath(value);
1516 }
1617
18+/** Route segments that `normalizeRoutePath` joins back into the same path, root included. */
19+export function fileRoutePathSegments(path: string): string[] {
20+ const segments = path.split("/").filter((segment) => segment.length > 0);
21+ return path.startsWith("/") ? ["", ...segments] : segments;
22+}
23+
1724 function isWindowsPathStyle(value: string): boolean {
1825 return isWindowsAbsolutePath(value) || /^[A-Za-z]:\\/.test(value);
1926 }
apps/mobile/src/features/files/workspaceFileAssetUrl.ts+10−4
@@ -2,7 +2,7 @@ import type { AssetResource, EnvironmentId, ThreadId } from "@t3tools/contracts" · 1 unmodified line
22 import { useMemo } from "react";
33
44 import { useAssetUrlState, useRefreshAssetUrl } from "../../state/assets";
5−import { isVideoPreviewFile, resolveWorkspaceFilePath } from "./filePath";
5+import { isAbsolutePath, isVideoPreviewFile, resolveWorkspaceFilePath } from "./filePath";
66
77 export function useWorkspaceFileAssetUrlState(props: {
88 readonly cwd: string | null;
@@ -18,16 +18,22 @@ export function useWorkspaceFileAssetUrlState(props: { · 9 unmodified lines
1818 [props.cwd, props.relativePath],
1919 );
2020
21+ // Videos stream from an exact-file URL, and so does anything outside the
22+ // workspace, where no workspace-scoped URL can exist.
23+ const relativePath = props.relativePath;
2124 const resource = useMemo<AssetResource | null>(
2225 () =>
23− absolutePath !== null && props.threadId !== null
26+ absolutePath !== null && relativePath !== null && props.threadId !== null
2427 ? {
25− _tag: isVideoPreviewFile(absolutePath) ? "media-file" : "workspace-file",
28+ _tag:
29+ isVideoPreviewFile(absolutePath) || isAbsolutePath(relativePath)
30+ ? "media-file"
31+ : "workspace-file",
2632 threadId: props.threadId,
2733 path: absolutePath,
2834 }
2935 : null,
30− [absolutePath, props.threadId],
36+ [absolutePath, relativePath, props.threadId],
3137 );
3238 const state = useAssetUrlState(props.environmentId, resource);
3339 const refresh = useRefreshAssetUrl(props.environmentId, resource);
apps/mobile/src/features/threads/ThreadFeed.tsx+36−2
@@ -166,7 +166,12 @@ import { · 165 unmodified lines
166166 import { useAtomQueryRunner } from "../../state/use-atom-query-runner";
167167 import { usePreparedConnection } from "../../state/session";
168168 import * as Option from "effect/Option";
169−import { resolveWorkspaceRelativeFilePath } from "../files/filePath";
169+import {
170+ basename,
171+ fileRoutePathSegments,
172+ isAbsolutePath,
173+ resolveWorkspaceRelativeFilePath,
174+} from "../files/filePath";
170175 import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize";
171176
172177 const WIDE_MARKDOWN_BLOCK_OPTIONS = {
@@ -2068,7 +2073,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 1895 unmodified lines
20682073 navigation.navigate("ThreadFile", {
20692074 environmentId: String(props.environmentId),
20702075 threadId: String(props.threadId),
2071− path: relativePath.split("/").filter((segment) => segment.length > 0),
2076+ path: fileRoutePathSegments(relativePath),
20722077 ...(presentation.line ? { line: String(presentation.line) } : {}),
20732078 });
20742079 return;
@@ -2090,6 +2095,35 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 15 unmodified lines
20902095 return;
20912096 }
20922097
2098+ // A host file outside the workspace, such as a report an agent wrote to
2099+ // a temp directory, opens read-only in the file screen.
2100+ if (presentation.kind === "file" && isAbsolutePath(presentation.path)) {
2101+ void Haptics.selectionAsync();
2102+ if (isPdfFile({ name: presentation.path })) {
2103+ setExpandedFile(
2104+ (current) =>
2105+ current ?? {
2106+ kind: "pdf",
2107+ name: basename(presentation.path),
2108+ environmentId: props.environmentId,
2109+ resource: {
2110+ _tag: "media-file",
2111+ threadId: props.threadId,
2112+ path: presentation.path,
2113+ },
2114+ },
2115+ );
2116+ return;
2117+ }
2118+ navigation.navigate("ThreadFile", {
2119+ environmentId: String(props.environmentId),
2120+ threadId: String(props.threadId),
2121+ path: fileRoutePathSegments(presentation.path),
2122+ ...(presentation.line ? { line: String(presentation.line) } : {}),
2123+ });
2124+ return;
2125+ }
2126+
20932127 if (presentation.kind !== "file" && presentation.href) {
20942128 if (/^https?:\/\//i.test(presentation.href) && isPdfFile({ name: presentation.href })) {
20952129 setExpandedFile(
apps/server/src/assets/AssetAccess.test.ts+6−4
@@ -44,7 +44,7 @@ const testLayer = Layer.mergeAll( · 43 unmodified lines
4444 ).pipe(Layer.provideMerge(NodeServices.layer));
4545
4646 describe("AssetAccess", () => {
47− it.effect("issues exact URLs for images and videos outside the workspace", () =>
47+ it.effect("issues exact URLs for media and browser documents outside the workspace", () =>
4848 Effect.gen(function* () {
4949 const fs = yield* FileSystem.FileSystem;
5050 const path = yield* Path.Path;
@@ -54,6 +54,8 @@ describe("AssetAccess", () => { · 3 unmodified lines
5454 ["screenshot.png", "image/png"],
5555 ["recording.mp4", "video/mp4"],
5656 ["recording.webm", "video/webm"],
57+ ["report.html", "text/html"],
58+ ["report.pdf", "application/pdf"],
5759 ] as const) {
5860 const filePath = path.join(outside, name);
5961 yield* fs.writeFileString(filePath, "media");
@@ -104,12 +106,12 @@ describe("AssetAccess", () => { · 44 unmodified lines
104106 }).pipe(Effect.provide(testLayer)),
105107 );
106108
107− it.effect("rejects non-media files, disguised targets, and directories", () =>
109+ it.effect("rejects non-previewable files, disguised targets, and directories", () =>
108110 Effect.gen(function* () {
109111 const fs = yield* FileSystem.FileSystem;
110112 const path = yield* Path.Path;
111113 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-validation-" });
112− for (const name of ["report.html", "secret.txt", "secret.%70ng", "secret.png#private.txt"]) {
114+ for (const name of ["report.md", "secret.txt", "secret.%70ng", "secret.png#private.txt"]) {
113115 const filePath = path.join(root, name);
114116 yield* fs.writeFileString(filePath, "not media");
115117 const error = yield* issueAssetUrl({
@@ -118,7 +120,7 @@ describe("AssetAccess", () => { · 2 unmodified lines
118120 expect(error).toBeInstanceOf(AssetPreviewTypeValidationError);
119121 }
120122 const disguisedPath = path.join(root, "disguised.png");
121− yield* fs.symlink(path.join(root, "report.html"), disguisedPath);
123+ yield* fs.symlink(path.join(root, "secret.txt"), disguisedPath);
122124 const disguisedError = yield* issueAssetUrl({
123125 resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: disguisedPath },
124126 }).pipe(Effect.flip);
apps/server/src/assets/AssetAccess.ts+3−3
@@ -14,9 +14,9 @@ import { · 13 unmodified lines
1414 AssetWorkspaceRootNormalizationError,
1515 } from "@t3tools/contracts";
1616 import {
17+ hostPreviewMimeTypeFromExtension,
1718 isWorkspaceImagePreviewPath,
1819 isWorkspacePreviewEntryPath,
19− mediaMimeTypeFromExtension,
2020 WORKSPACE_BROWSER_PREVIEW_EXTENSIONS,
2121 WORKSPACE_IMAGE_PREVIEW_EXTENSIONS,
2222 } from "@t3tools/shared/filePreview";
@@ -246,7 +246,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i · 223 unmodified lines
246246 if (!canonicalFile) {
247247 return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource });
248248 }
249− if (mediaMimeTypeFromExtension(path.extname(canonicalFile)) === null) {
249+ if (hostPreviewMimeTypeFromExtension(path.extname(canonicalFile)) === null) {
250250 return yield* new AssetPreviewTypeValidationError({ resource: input.resource });
251251 }
252252 const identity = yield* openMediaFile(canonicalFile).pipe(
@@ -598,7 +598,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( · 345 unmodified lines
598598 Effect.orElseSucceed(() => null),
599599 );
600600 if (canonicalFile !== claims.filePath) return null;
601− const mimeType = mediaMimeTypeFromExtension(path.extname(canonicalFile));
601+ const mimeType = hostPreviewMimeTypeFromExtension(path.extname(canonicalFile));
602602 if (!mimeType) return null;
603603 const file = yield* openMediaFile(canonicalFile, claims).pipe(
604604 Effect.tapError((cause) =>
apps/server/src/http.test.ts+7−9
@@ -324,15 +324,13 @@ describe("assetResponseHeaders", () => { · 323 unmodified lines
324324 "X-Content-Type-Options": "nosniff",
325325 });
326326 });
327− it("declares utf-8 for HTML assets so non-ASCII content renders correctly", () => {
328− expect(assetResponseHeaders("/workspace/page.html")).toHaveProperty(
329− "Content-Type",
330− "text/html; charset=utf-8",
331− );
332− expect(assetResponseHeaders("/workspace/PAGE.HTM")).toHaveProperty(
333− "Content-Type",
334− "text/html; charset=utf-8",
335− );
327+ it("serves HTML assets as utf-8 inside a sandboxed origin", () => {
328+ for (const path of ["/workspace/page.html", "/workspace/PAGE.HTM", "/tmp/report.html"]) {
329+ expect(assetResponseHeaders(path)).toMatchObject({
330+ "Content-Type": "text/html; charset=utf-8",
331+ "Content-Security-Policy": "sandbox allow-scripts allow-forms allow-popups allow-modals",
332+ });
333+ }
336334 });
337335
338336 it("downloads uploaded documents without executing their content", () => {
apps/server/src/http.ts+8−1
@@ -51,6 +51,10 @@ const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; · 50 unmodified lines
5151 const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
5252 const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"];
5353 const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
54+// HTML previews are agent output, not the app. The sandbox gives the document an
55+// opaque origin: scripts run, but same-origin cookies, storage, and API calls are
56+// out of reach. Relative sibling assets still load through their signed URLs.
57+const HTML_CONTENT_SECURITY_POLICY = "sandbox allow-scripts allow-forms allow-popups allow-modals";
5458
5559 // Types a browser may render as a document if a proxy strips the disposition
5660 // header. Downloads of these fall back to octet-stream.
@@ -105,7 +109,10 @@ export function assetResponseHeaders( · 48 unmodified lines
105109 : inlineVideoMimeType !== undefined && isSafeInlineVideoMimeType(inlineVideoMimeType)
106110 ? { "Content-Type": inlineVideoMimeType }
107111 : lowerPath.endsWith(".html") || lowerPath.endsWith(".htm")
108− ? { "Content-Type": "text/html; charset=utf-8" }
112+ ? {
113+ "Content-Type": "text/html; charset=utf-8",
114+ "Content-Security-Policy": HTML_CONTENT_SECURITY_POLICY,
115+ }
109116 : {}),
110117 ...(!options?.download && lowerPath.endsWith(".svg")
111118 ? { "Content-Security-Policy": SVG_CONTENT_SECURITY_POLICY }
apps/server/src/workspace/WorkspaceFileSystem.test.ts+66−0
@@ -1,3 +1,6 @@
1+// @effect-diagnostics nodeBuiltinImport:off - FileSystem cannot create a FIFO.
2+import * as NodeChildProcess from "node:child_process";
3+
14 import * as NodeServices from "@effect/platform-node/NodeServices";
25 import { it, describe, expect } from "@effect/vitest";
36 import * as Effect from "effect/Effect";
@@ -73,6 +76,53 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i · 69 unmodified lines
7376 }),
7477 );
7578
79+ it.effect("reads host files outside the workspace root by absolute path", () =>
80+ Effect.gen(function* () {
81+ const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
82+ const path = yield* Path.Path;
83+ const cwd = yield* makeTempDir;
84+ const outsideDir = yield* makeTempDir;
85+ yield* writeTextFile(outsideDir, "cleanup-report.md", "# Report\n");
86+ const absolutePath = path.join(outsideDir, "cleanup-report.md");
87+
88+ const result = yield* workspaceFileSystem.readFile({
89+ cwd,
90+ relativePath: absolutePath,
91+ });
92+
93+ expect(result).toEqual({
94+ relativePath: absolutePath,
95+ contents: "# Report\n",
96+ byteLength: 9,
97+ truncated: false,
98+ });
99+ }),
100+ );
101+
102+ it.effect("rejects a FIFO without blocking on open", () =>
103+ Effect.gen(function* () {
104+ const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
105+ const path = yield* Path.Path;
106+ const cwd = yield* makeTempDir;
107+ const outsideDir = yield* makeTempDir;
108+ const fifoPath = path.join(outsideDir, "pipe");
109+ yield* Effect.promise(
110+ () =>
111+ new Promise<void>((resolve, reject) =>
112+ NodeChildProcess.execFile("mkfifo", [fifoPath], (error) =>
113+ error ? reject(error) : resolve(),
114+ ),
115+ ),
116+ );
117+
118+ const error = yield* workspaceFileSystem
119+ .readFile({ cwd, relativePath: fifoPath })
120+ .pipe(Effect.flip);
121+
122+ expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspacePathNotFileError);
123+ }),
124+ );
125+
76126 it.effect("rejects reads outside the workspace root", () =>
77127 Effect.gen(function* () {
78128 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
@@ -212,6 +262,22 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i · 133 unmodified lines
212262 }),
213263 );
214264
265+ it.effect("rejects writes by absolute path", () =>
266+ Effect.gen(function* () {
267+ const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
268+ const path = yield* Path.Path;
269+ const cwd = yield* makeTempDir;
270+ const outsideDir = yield* makeTempDir;
271+ const absolutePath = path.join(outsideDir, "cleanup-report.md");
272+
273+ const error = yield* workspaceFileSystem
274+ .writeFile({ cwd, relativePath: absolutePath, contents: "# Edited\n" })
275+ .pipe(Effect.flip);
276+
277+ expect(error).toBeInstanceOf(WorkspacePaths.WorkspacePathOutsideRootError);
278+ }),
279+ );
280+
215281 it.effect("invalidates workspace entry search cache after writes", () =>
216282 Effect.gen(function* () {
217283 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
apps/server/src/workspace/WorkspaceFileSystem.ts+48−6
@@ -3,10 +3,13 @@ · 2 unmodified lines
33 * WorkspaceFileSystem - Effect service contract for workspace file mutations.
44 *
55 * Owns workspace-root-relative file read/write operations and their associated
6− * safety checks and cache invalidation hooks.
6+ * safety checks and cache invalidation hooks. Reads also accept absolute host
7+ * paths so clients can show files an agent left outside the workspace; writes
8+ * never leave the root.
79 *
810 * @module WorkspaceFileSystem
911 */
12+import * as NodeFS from "node:fs";
1013 import * as NodeFSP from "node:fs/promises";
1114
1215 import type {
@@ -104,7 +107,10 @@ export type WorkspaceFileSystemError = typeof WorkspaceFileSystemError.Type; · 91 unmodified lines
104107 export class WorkspaceFileSystem extends Context.Service<
105108 WorkspaceFileSystem,
106109 {
107− /** Read a UTF-8 text file relative to the workspace root. */
110+ /**
111+ * Read a UTF-8 text file relative to the workspace root, or any host file by
112+ * absolute path.
113+ */
108114 readonly readFile: (
109115 input: ProjectReadFileInput,
110116 ) => Effect.Effect<
@@ -132,9 +138,31 @@ export const make = Effect.gen(function* () { · 21 unmodified lines
132138 const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
133139 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
134140
135− const readFile: WorkspaceFileSystem["Service"]["readFile"] = Effect.fn(
136− "WorkspaceFileSystem.readFile",
137− )(function* (input) {
141+ /**
142+ * Resolves the file a read targets. Workspace-relative paths must stay inside the
143+ * root, symlinks included. An absolute path reads a host file in place, such as a
144+ * report an agent wrote to a temp directory; it gets no root check.
145+ */
146+ const resolveReadTarget = Effect.fn("WorkspaceFileSystem.resolveReadTarget")(function* (
147+ input: ProjectReadFileInput,
148+ ) {
149+ const requestedPath = input.relativePath.trim();
150+ if (path.isAbsolute(requestedPath)) {
151+ const realTargetPath = yield* Effect.tryPromise({
152+ try: () => NodeFSP.realpath(requestedPath),
153+ catch: (cause) =>
154+ new WorkspaceFileSystemOperationError({
155+ workspaceRoot: input.cwd,
156+ relativePath: input.relativePath,
157+ resolvedPath: requestedPath,
158+ operationPath: requestedPath,
159+ operation: "realpath-target",
160+ cause,
161+ }),
162+ });
163+ return { relativePath: requestedPath, realTargetPath };
164+ }
165+
138166 const target = yield* workspacePaths.resolveRelativePathWithinRoot({
139167 workspaceRoot: input.cwd,
140168 relativePath: input.relativePath,
@@ -177,10 +205,24 @@ export const make = Effect.gen(function* () { · 36 unmodified lines
177205 resolvedPath: realTargetPath,
178206 });
179207 }
208+ return { relativePath: target.relativePath, realTargetPath };
209+ });
210+
211+ const readFile: WorkspaceFileSystem["Service"]["readFile"] = Effect.fn(
212+ "WorkspaceFileSystem.readFile",
213+ )(function* (input) {
214+ const target = yield* resolveReadTarget(input);
215+ const realTargetPath = target.realTargetPath;
180216
181217 return yield* Effect.acquireUseRelease(
182218 Effect.tryPromise({
183− try: () => NodeFSP.open(realTargetPath, "r"),
219+ // Non-blocking so a FIFO cannot hang the open; the stat below rejects
220+ // it. Regular files ignore the flag. Windows lacks it.
221+ try: () =>
222+ NodeFSP.open(
223+ realTargetPath,
224+ NodeFS.constants.O_RDONLY | (NodeFS.constants.O_NONBLOCK ?? 0),
225+ ),
184226 catch: (cause) =>
185227 new WorkspaceFileSystemOperationError({
186228 workspaceRoot: input.cwd,
apps/web/src/browser/openFileInPreview.ts+9−1
@@ -6,6 +6,7 @@ import type { · 5 unmodified lines
66 PreviewSessionSnapshot,
77 ScopedThreadRef,
88 } from "@t3tools/contracts";
9+import { mediaFileReference } from "@t3tools/client-runtime/media-reference";
910 import {
1011 type AtomCommandResult,
1112 mapAtomCommandResult,
@@ -52,9 +53,14 @@ export async function openUrlInPreview<E>(input: { · 40 unmodified lines
5253 });
5354 }
5455
56+/**
57+ * Opens a browser document in the integrated browser. Inside the workspace the
58+ * page may load sibling assets; a file outside it is served on its own.
59+ */
5560 export async function openFileInPreview<AssetError, PreviewError>(input: {
5661 readonly threadRef: ScopedThreadRef;
5762 readonly filePath: string;
63+ readonly workspaceRoot: string | undefined;
5864 readonly httpBaseUrl: string;
5965 readonly createAssetUrl: (input: {
6066 readonly environmentId: EnvironmentId;
@@ -71,11 +77,13 @@ export async function openFileInPreview<AssetError, PreviewError>(input: { · 10 unmodified lines
7177 ),
7278 );
7379 }
80+ const insideWorkspace =
81+ mediaFileReference(input.filePath, input.workspaceRoot).relativePath !== undefined;
7482 const assetResult = await input.createAssetUrl({
7583 environmentId: input.threadRef.environmentId,
7684 input: {
7785 resource: {
78− _tag: "workspace-file",
86+ _tag: insideWorkspace ? "workspace-file" : "media-file",
7987 threadId: input.threadRef.threadId,
8088 path: input.filePath,
8189 },
apps/web/src/components/ChatMarkdown.tsx+33−23
@@ -164,7 +164,7 @@ import { · 163 unmodified lines
164164 } from "~/lib/openPullRequestLink";
165165 import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
166166 import { isPreviewSupportedInRuntime } from "../previewStateStore";
167−import { resolvePathLinkTarget } from "../terminal-links";
167+import { isAbsolutePath, resolvePathLinkTarget } from "../terminal-links";
168168 import {
169169 isBrowserPreviewFile,
170170 openFileInPreview,
@@ -188,6 +188,8 @@ interface ChatMarkdownProps { · 17 unmodified lines
188188 parseRawHtml?: boolean;
189189 /** Append a prompt that invokes a newly created artifact-template skill. */
190190 onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined;
191+ /** Directory that anchors relative links and images; defaults to `cwd`. Set
192+ to the file's own directory when rendering a markdown file. */
191193 imageBaseDir?: string | undefined;
192194 onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined;
193195 extraRemarkPlugins?: NonNullable<ReactMarkdownOptions["remarkPlugins"]>;
@@ -1017,14 +1019,16 @@ interface MarkdownFileLinkProps { · 823 unmodified lines
10171019 targetPath: string;
10181020 iconPath: string;
10191021 displayPath: string;
1020− workspaceRelativePath: string | null;
1022+ /** What the files panel opens: workspace-relative inside the workspace, the
1023+ absolute host path outside it, null when the panel cannot show the file. */
1024+ panelPath: string | null;
10211025 line?: number | undefined;
10221026 label: string;
10231027 copyMarkdown: string;
10241028 theme: "light" | "dark";
10251029 threadRef?: ScopedThreadRef | undefined;
10261030 onOpen?: ((targetPath: string) => Promise<AtomCommandResult<unknown, unknown>>) | undefined;
1027− onOpenInPanel: (workspaceRelativePath: string, line: number | undefined) => void;
1031+ onOpenInPanel: (panelPath: string, line: number | undefined) => void;
10281032 openInEditorMenuLabel: string;
10291033 onOpenInBrowser?: (() => Promise<AtomCommandResult<unknown, unknown>>) | undefined;
10301034 onOpenMedia?: (() => void) | undefined;
@@ -1603,7 +1607,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ · 572 unmodified lines
16031607 targetPath,
16041608 iconPath,
16051609 displayPath,
1606− workspaceRelativePath,
1610+ panelPath,
16071611 line,
16081612 label,
16091613 copyMarkdown,
@@ -1657,8 +1661,8 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ · 47 unmodified lines
16571661 }, [onOpen, targetPath]);
16581662
16591663 const handleOpenInFilePreview = useCallback(() => {
1660− if (threadRef && workspaceRelativePath) {
1661− onOpenInPanel(workspaceRelativePath, line);
1664+ if (threadRef && panelPath) {
1665+ onOpenInPanel(panelPath, line);
16621666 return;
16631667 }
16641668 if (onOpenMedia) {
@@ -1666,7 +1670,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ · 1 unmodified line
16661670 return;
16671671 }
16681672 handleOpenInEditor();
1669− }, [handleOpenInEditor, line, onOpenInPanel, onOpenMedia, threadRef, workspaceRelativePath]);
1673+ }, [handleOpenInEditor, line, onOpenInPanel, onOpenMedia, panelPath, threadRef]);
16701674
16711675 const handleOpenInBrowser = useCallback(() => {
16721676 if (!onOpenInBrowser) {
@@ -1867,7 +1871,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ · 194 unmodified lines
18671871
18681872 const canOpenInEditor = onOpen !== undefined;
18691873 const canOpenInBrowser = onOpenInBrowser !== undefined;
1870− const canOpenInPanel = threadRef !== undefined && Boolean(workspaceRelativePath);
1874+ const canOpenInPanel = threadRef !== undefined && Boolean(panelPath);
18711875 const hasPrimaryAction = hasMarkdownFilePrimaryAction({
18721876 canOpenInEditor,
18731877 canOpenInBrowser,
@@ -1954,7 +1958,7 @@ function areMarkdownFileLinkPropsEqual( · 80 unmodified lines
19541958 previous.targetPath === next.targetPath &&
19551959 previous.iconPath === next.iconPath &&
19561960 previous.displayPath === next.displayPath &&
1957− previous.workspaceRelativePath === next.workspaceRelativePath &&
1961+ previous.panelPath === next.panelPath &&
19581962 previous.line === next.line &&
19591963 previous.label === next.label &&
19601964 previous.copyMarkdown === next.copyMarkdown &&
@@ -2098,24 +2102,24 @@ function ChatMarkdown({ · 137 unmodified lines
20982102 for (const href of extractMarkdownLinkHrefs(renderCodexFileCitationsAsMarkdown(text))) {
20992103 const normalizedHref = normalizeMarkdownLinkHrefKey(href);
21002104 if (metaByHref.has(normalizedHref)) continue;
2101− const meta = resolveMarkdownFileLinkMeta(normalizedHref, cwd);
2105+ const meta = resolveMarkdownFileLinkMeta(normalizedHref, cwd, imageBaseDir ?? cwd);
21022106 if (meta) {
21032107 metaByHref.set(normalizedHref, meta);
21042108 }
21052109 }
21062110 return metaByHref;
2107− }, [cwd, text]);
2111+ }, [cwd, imageBaseDir, text]);
21082112 const inlineCodeFileLinkMetaByText = useMemo(() => {
21092113 const metaByText = new Map<string, MarkdownFileLinkMeta>();
21102114 for (const span of extractInlineCodeSpans(text)) {
21112115 if (metaByText.has(span)) continue;
2112− const meta = resolveInlineCodeFileLinkMeta(span, cwd);
2116+ const meta = resolveInlineCodeFileLinkMeta(span, cwd, imageBaseDir ?? cwd);
21132117 if (meta) {
21142118 metaByText.set(span, meta);
21152119 }
21162120 }
21172121 return metaByText;
2118− }, [cwd, text]);
2122+ }, [cwd, imageBaseDir, text]);
21192123 const fileLinkParentSuffixByPath = useMemo(() => {
21202124 const filePaths = [
21212125 ...[...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath),
@@ -2223,12 +2227,13 @@ function ChatMarkdown({ · 101 unmodified lines
22232227 return openFileInPreview({
22242228 threadRef,
22252229 filePath: path,
2230+ workspaceRoot: cwd,
22262231 httpBaseUrl: preparedConnection.value.httpBaseUrl,
22272232 createAssetUrl,
22282233 openPreview,
22292234 });
22302235 },
2231− [createAssetUrl, openPreview, preparedConnection, threadRef],
2236+ [createAssetUrl, cwd, openPreview, preparedConnection, threadRef],
22322237 );
22332238 const findWorkspaceBasenameMatch = useCallback(
22342239 async (workspaceRelativePath: string) => {
@@ -2251,23 +2256,23 @@ function ChatMarkdown({ · 16 unmodified lines
22512256 [cwd, environmentId, searchProjectEntries],
22522257 );
22532258 // A bare filename resolves to the workspace root, which is rarely where the
2254− // file is, so ask the index before opening.
2259+ // file is, so ask the index before opening. Absolute host paths open as-is.
22552260 const openFileInPanel = useCallback(
2256− (workspaceRelativePath: string, line: number | undefined) => {
2261+ (panelPath: string, line: number | undefined) => {
22572262 if (!threadRef) return;
22582263 // Claimed on every open so a synchronous one supersedes a lookup already
22592264 // in flight.
22602265 const isLatestLookup = claimWorkspaceBasenameLookup();
22612266 const openAt = (path: string) =>
22622267 useRightPanelStore.getState().openFile(threadRef, path, line);
2263− if (!cwd || !needsWorkspaceBasenameLookup(workspaceRelativePath)) {
2264− openAt(workspaceRelativePath);
2268+ if (!cwd || !needsWorkspaceBasenameLookup(panelPath)) {
2269+ openAt(panelPath);
22652270 return;
22662271 }
22672272 void (async () => {
2268− const match = await findWorkspaceBasenameMatch(workspaceRelativePath);
2273+ const match = await findWorkspaceBasenameMatch(panelPath);
22692274 if (!isLatestLookup()) return;
2270− openAt(match ?? workspaceRelativePath);
2275+ openAt(match ?? panelPath);
22712276 })();
22722277 },
22732278 [cwd, findWorkspaceBasenameMatch, threadRef],
@@ -2310,6 +2315,11 @@ function ChatMarkdown({ · 36 unmodified lines
23102315 mediaMimeTypeFromExtension(
23112316 fileLinkMeta.basename.slice(fileLinkMeta.basename.lastIndexOf(".")),
23122317 ) !== null;
2318+ // Media outside the workspace keeps the expanded preview; other host
2319+ // files (a report in a temp dir) open read-only in the files panel.
2320+ const panelPath =
2321+ fileLinkMeta.workspaceRelativePath ??
2322+ (!canPreviewMedia && isAbsolutePath(fileLinkMeta.filePath) ? fileLinkMeta.filePath : null);
23132323
23142324 return (
23152325 <MarkdownFileLink
@@ -2317,7 +2327,7 @@ function ChatMarkdown({ · 1 unmodified line
23172327 targetPath={fileLinkMeta.targetPath}
23182328 iconPath={fileLinkMeta.filePath}
23192329 displayPath={fileLinkMeta.displayPath}
2320− workspaceRelativePath={fileLinkMeta.workspaceRelativePath}
2330+ panelPath={panelPath}
23212331 line={fileLinkMeta.line}
23222332 label={labelParts.join(" · ")}
23232333 copyMarkdown={copyMarkdown}
@@ -2434,7 +2444,7 @@ function ChatMarkdown({ · 110 unmodified lines
24342444 const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : "";
24352445 const fileLinkMeta = normalizedHref
24362446 ? (markdownFileLinkMetaByHref.get(normalizedHref) ??
2437− resolveMarkdownFileLinkMeta(normalizedHref, cwd))
2447+ resolveMarkdownFileLinkMeta(normalizedHref, cwd, imageBaseDir ?? cwd))
24382448 : null;
24392449 if (!fileLinkMeta) {
24402450 const faviconHost = resolveExternalWebLinkHost(href);
@@ -2580,7 +2590,7 @@ function ChatMarkdown({ · 139 unmodified lines
25802590 const codeText = nodeToPlainText(children);
25812591 const fileLinkMeta =
25822592 inlineCodeFileLinkMetaByText.get(codeText.trim()) ??
2583− resolveInlineCodeFileLinkMeta(codeText, cwd);
2593+ resolveInlineCodeFileLinkMeta(codeText, cwd, imageBaseDir ?? cwd);
25842594 if (fileLinkMeta) {
25852595 return fileLinkChip(
25862596 fileLinkMeta,
apps/web/src/components/RightPanelTabs.tsx+3−1
@@ -498,7 +498,9 @@ function surfaceTitle( · 497 unmodified lines
498498 case "files":
499499 return "Files";
500500 case "file":
501− return surface.relativePath.slice(surface.relativePath.lastIndexOf("/") + 1);
501+ return surface.relativePath.slice(
502+ Math.max(surface.relativePath.lastIndexOf("/"), surface.relativePath.lastIndexOf("\\")) + 1,
503+ );
502504 case "terminal":
503505 return (
504506 terminalLabelsById.get(surface.activeTerminalId) ??
apps/web/src/components/files/FilePreviewPanel.tsx+22−12
@@ -34,7 +34,7 @@ import { DIFF_SURFACE_THEME_UNSAFE_CSS, resolveDiffThemeName } from "~/lib/diffR · 33 unmodified lines
3434 import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting";
3535 import { cn } from "~/lib/utils";
3636 import { isPreviewSupportedInRuntime } from "~/previewStateStore";
37−import { resolvePathLinkTarget } from "~/terminal-links";
37+import { isAbsolutePath, resolvePathLinkTarget } from "~/terminal-links";
3838 import { ScrollArea } from "~/components/ui/scroll-area";
3939 import { Toggle } from "~/components/ui/toggle";
4040 import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
@@ -793,6 +793,7 @@ function RenderedMarkdownSurface({ · 752 unmodified lines
793793 relativePath,
794794 contents,
795795 threadRef,
796+ readOnly,
796797 onPendingChange,
797798 }: Omit<
798799 EditableFileSurfaceProps,
@@ -804,6 +805,7 @@ function RenderedMarkdownSurface({ · 5 unmodified lines
804805 | "onPostRender"
805806 > & {
806807 threadRef: ScopedThreadRef;
808+ readOnly: boolean;
807809 }) {
808810 const saveCoordinator = useFileSaveCoordinator({
809811 environmentId,
@@ -819,15 +821,19 @@ function RenderedMarkdownSurface({ · 9 unmodified lines
819821 cwd={cwd}
820822 relativePath={relativePath}
821823 threadRef={threadRef}
822− onTaskListChange={({ markerOffset, checked }) => {
823− const currentContents =
824− getOptimisticProjectFileQueryData(environmentId, cwd, relativePath)?.contents ??
825− contents;
826− const nextContents = setMarkdownTaskChecked(currentContents, markerOffset, checked);
827− if (nextContents === currentContents) return;
828− setProjectFileQueryData(environmentId, cwd, relativePath, nextContents);
829− saveCoordinator.change(nextContents);
830− }}
824+ onTaskListChange={
825+ readOnly
826+ ? undefined
827+ : ({ markerOffset, checked }) => {
828+ const currentContents =
829+ getOptimisticProjectFileQueryData(environmentId, cwd, relativePath)?.contents ??
830+ contents;
831+ const nextContents = setMarkdownTaskChecked(currentContents, markerOffset, checked);
832+ if (nextContents === currentContents) return;
833+ setProjectFileQueryData(environmentId, cwd, relativePath, nextContents);
834+ saveCoordinator.change(nextContents);
835+ }
836+ }
831837 />
832838 </ScrollArea>
833839 );
@@ -872,6 +878,8 @@ export default function FilePreviewPanel({ · 38 unmodified lines
872878 const isVideo = relativePath !== null && isWorkspaceVideoPreviewPath(relativePath);
873879 const isImage = relativePath !== null && !isVideo && isWorkspaceImagePreviewPath(relativePath);
874880 const isMedia = isImage || isVideo;
881+ // A file outside the workspace (an absolute path) is shown, never edited.
882+ const isHostFile = relativePath !== null && isAbsolutePath(relativePath);
875883 const file = useProjectFileQuery(environmentId, cwd, relativePath, !isMedia);
876884 const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen);
877885 // Reading markdown rendered is a preference, not a property of one file. Keeping
@@ -938,6 +946,7 @@ export default function FilePreviewPanel({ · 60 unmodified lines
938946 const result = await openFileInPreview({
939947 threadRef,
940948 filePath: absolutePath,
949+ workspaceRoot: cwd,
941950 httpBaseUrl: environmentHttpBaseUrl,
942951 createAssetUrl,
943952 openPreview,
@@ -954,7 +963,7 @@ export default function FilePreviewPanel({ · 10 unmodified lines
954963 }),
955964 );
956965 })();
957− }, [absolutePath, createAssetUrl, environmentHttpBaseUrl, openPreview, threadRef]);
966+ }, [absolutePath, createAssetUrl, cwd, environmentHttpBaseUrl, openPreview, threadRef]);
958967
959968 return (
960969 <div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
@@ -1130,9 +1139,10 @@ export default function FilePreviewPanel({ · 169 unmodified lines
11301139 relativePath={relativePath}
11311140 threadRef={threadRef}
11321141 contents={file.data.contents}
1142+ readOnly={isHostFile}
11331143 onPendingChange={onPendingChange}
11341144 />
1135− ) : file.data.truncated ? (
1145+ ) : file.data.truncated || isHostFile ? (
11361146 <Virtualizer
11371147 key={`${relativePath}:${resolvedTheme}:${file.data.byteLength}`}
11381148 className="file-preview-virtualizer min-h-0 flex-1 overflow-auto"
apps/web/src/components/files/filePath.test.ts+19−1
@@ -14,10 +14,28 @@ describe("fileBreadcrumbs", () => { · 13 unmodified lines
1414 });
1515
1616 it("normalizes repeated separators", () => {
17− expect(fileBreadcrumbs("workspace", "/src//index.ts").map((crumb) => crumb.label)).toEqual([
17+ expect(fileBreadcrumbs("workspace", "src//index.ts").map((crumb) => crumb.label)).toEqual([
1818 "workspace",
1919 "src",
2020 "index.ts",
2121 ]);
2222 });
23+
24+ it("starts host paths outside the workspace at the filesystem root", () => {
25+ expect(fileBreadcrumbs("t3code", "/tmp/t3-cleanup/report.md")).toEqual([
26+ { label: "tmp", path: "/tmp", kind: "directory" },
27+ { label: "t3-cleanup", path: "/tmp/t3-cleanup", kind: "directory" },
28+ { label: "report.md", path: "/tmp/t3-cleanup/report.md", kind: "file" },
29+ ]);
30+ expect(fileBreadcrumbs("t3code", "C:\\Temp\\report.md")).toEqual([
31+ { label: "C:", path: "C:", kind: "directory" },
32+ { label: "Temp", path: "C:\\Temp", kind: "directory" },
33+ { label: "report.md", path: "C:\\Temp\\report.md", kind: "file" },
34+ ]);
35+ expect(fileBreadcrumbs("t3code", "\\\\server\\share\\report.md").map((c) => c.path)).toEqual([
36+ "\\\\server",
37+ "\\\\server\\share",
38+ "\\\\server\\share\\report.md",
39+ ]);
40+ });
2341 });
apps/web/src/components/files/filePath.ts+14−3
@@ -1,16 +1,27 @@
1+import { isWindowsAbsolutePath } from "@t3tools/shared/path";
2+
3+import { isAbsolutePath } from "~/terminal-links";
4+
15 export interface FileBreadcrumb {
26 label: string;
37 path: string;
48 kind: "project" | "directory" | "file";
59 }
610
11+/**
12+ * Crumbs for a workspace-relative path start at the project. An absolute host
13+ * path is outside the workspace, so its crumbs start at the filesystem root.
14+ */
715 export function fileBreadcrumbs(projectName: string, relativePath: string): FileBreadcrumb[] {
8− const parts = relativePath.split("/").filter(Boolean);
16+ const hostPath = isAbsolutePath(relativePath);
17+ const separator = isWindowsAbsolutePath(relativePath) ? "\\" : "/";
18+ const parts = relativePath.split(/[\\/]/).filter(Boolean);
19+ const root = relativePath.startsWith("\\\\") ? "\\\\" : hostPath && separator === "/" ? "/" : "";
920 return [
10− { label: projectName, path: "", kind: "project" },
21+ ...(hostPath ? [] : [{ label: projectName, path: "", kind: "project" as const }]),
1122 ...parts.map((part, index) => ({
1223 label: part,
13− path: parts.slice(0, index + 1).join("/"),
24+ path: root + parts.slice(0, index + 1).join(separator),
1425 kind: index === parts.length - 1 ? ("file" as const) : ("directory" as const),
1526 })),
1627 ];
apps/web/src/markdown-links.test.ts+16−0
@@ -275,6 +275,22 @@ describe("resolveMarkdownFileLinkTarget", () => { · 274 unmodified lines
275275 });
276276 });
277277
278+describe("relative links inside a rendered host file", () => {
279+ it("anchor to the file's directory while workspace membership follows cwd", () => {
280+ const meta = resolveMarkdownFileLinkMeta("appendix.md", "/repo", "/tmp/report");
281+ expect(meta).toMatchObject({
282+ filePath: "/tmp/report/appendix.md",
283+ workspaceRelativePath: null,
284+ });
285+ const inline = resolveInlineCodeFileLinkMeta("Makefile:12", "/repo", "/tmp/report");
286+ expect(inline).toMatchObject({ filePath: "/tmp/report/Makefile", line: 12 });
287+ expect(resolveMarkdownFileLinkMeta("src/main.ts", "/repo", "/repo/docs")).toMatchObject({
288+ filePath: "/repo/docs/src/main.ts",
289+ workspaceRelativePath: "docs/src/main.ts",
290+ });
291+ });
292+});
293+
278294 describe("resolveInlineCodeFileLinkMeta", () => {
279295 it("links relative paths with file extensions", () => {
280296 expect(
apps/web/src/markdown-links.ts+14−6
@@ -189,9 +189,15 @@ function hasExternalScheme(path: string): boolean { · 188 unmodified lines
189189 return !POSITION_ONLY_PATTERN.test(rest);
190190 }
191191
192+/**
193+ * `baseDir` anchors relative links; it defaults to the workspace root and is the
194+ * file's own directory when rendering a markdown file. `cwd` stays the workspace
195+ * root so the result still knows whether the target is inside it.
196+ */
192197 export function resolveMarkdownFileLinkTarget(
193198 href: string | undefined,
194199 cwd?: string,
200+ baseDir: string | undefined = cwd,
195201 ): string | null {
196202 if (!href) return null;
197203 const rawHref = normalizeMarkdownLinkDestination(href);
@@ -222,8 +228,8 @@ export function resolveMarkdownFileLinkTarget( · 24 unmodified lines
222228 return pathWithPosition;
223229 }
224230
225− if (!cwd) return null;
226− return resolvePathLinkTarget(pathWithPosition, cwd);
231+ if (!baseDir) return null;
232+ return resolvePathLinkTarget(pathWithPosition, baseDir);
227233 }
228234
229235 /**
@@ -235,18 +241,19 @@ export function resolveMarkdownFileLinkTarget( · 5 unmodified lines
235241 export function resolveInlineCodeFileLinkMeta(
236242 codeText: string,
237243 cwd?: string,
244+ baseDir: string | undefined = cwd,
238245 ): MarkdownFileLinkMeta | null {
239246 const candidate = inlineCodeFilePathCandidate(codeText);
240247 if (candidate === null) return null;
241248
242− const resolved = resolveMarkdownFileLinkMeta(candidate, cwd);
249+ const resolved = resolveMarkdownFileLinkMeta(candidate, cwd, baseDir);
243250 if (resolved) return resolved;
244251
245252 // `Makefile:12` — conventional extensionless names fail the generic
246253 // markdown-link candidate patterns, but here the :line suffix already
247254 // marked the span as a file reference.
248− if (cwd && isConventionalFilePosition(candidate)) {
249− return buildFileLinkMetaFromTarget(resolvePathLinkTarget(candidate, cwd), cwd);
255+ if (baseDir && isConventionalFilePosition(candidate)) {
256+ return buildFileLinkMetaFromTarget(resolvePathLinkTarget(candidate, baseDir), cwd);
250257 }
251258 return null;
252259 }
@@ -276,8 +283,9 @@ function workspaceRelativePath(path: string, workspaceRoot: string | undefined): · 23 unmodified lines
276283 export function resolveMarkdownFileLinkMeta(
277284 href: string | undefined,
278285 cwd?: string,
286+ baseDir: string | undefined = cwd,
279287 ): MarkdownFileLinkMeta | null {
280− const targetPath = resolveMarkdownFileLinkTarget(href, cwd);
288+ const targetPath = resolveMarkdownFileLinkTarget(href, cwd, baseDir);
281289 if (!targetPath) return null;
282290 return buildFileLinkMetaFromTarget(targetPath, cwd);
283291 }
apps/web/src/rightPanelStore.ts+1−0
@@ -41,6 +41,7 @@ export type RightPanelSurface = · 40 unmodified lines
4141 | {
4242 id: `file:${string}`;
4343 kind: "file";
44+ /** Workspace-relative, or absolute for a host file outside the workspace. */
4445 relativePath: string;
4546 revealLine: number | null;
4647 revealRequestId: number;
apps/web/src/terminal-links.ts+1−1
@@ -102,7 +102,7 @@ function isWindowsAbsolutePath(value: string): boolean { · 101 unmodified lines
102102 return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\");
103103 }
104104
105−function isAbsolutePath(value: string): boolean {
105+export function isAbsolutePath(value: string): boolean {
106106 return value.startsWith("/") || isWindowsAbsolutePath(value);
107107 }
108108
docs/internals/environment-auth.md+16−7
@@ -28,22 +28,31 @@ managed relay connectivity: · 27 unmodified lines
2828 The desktop bootstrap credential and command-line administrative bootstrap
2929 credentials additionally grant `access:read access:write relay:write`.
3030
31+## Host file access
32+
33+Clients with `orchestration:read` can read files anywhere the environment's server account can
34+read, following the environment-wide authorization model rather than introducing per-project
35+filesystem permissions. `projects.readFile` accepts an absolute path and returns the text of that
36+host file; only workspace-relative paths pass its root check, and `projects.writeFile` never
37+accepts an absolute path. Clients use this to show files an agent wrote outside the workspace, such
38+as a report in a temp directory, read-only.
39+
3140 ## Media preview access
3241
3342 Clients with `orchestration:read` can request a `media-file` URL through `assets.createUrl` for
34−supported images and videos anywhere the environment's server account can read. A thread ID
35−supplies the workspace for relative paths; absolute paths refer to the environment host, not the
36−client. This follows the environment-wide authorization model rather than introducing per-project
37−filesystem permissions.
43+supported images, videos, HTML, and PDF files anywhere the environment's server account can read.
44+A thread ID supplies the workspace for relative paths; absolute paths refer to the environment
45+host, not the client.
3846
3947 [`AssetAccess.ts`](../../apps/server/src/assets/AssetAccess.ts) resolves symlinks, requires a regular
4048 file, and validates the resolved file's literal extension. It opens the file and signs its canonical
4149 path and device/inode identity for one hour. The token grants access to that exact file, not adjacent
4250 files or its containing directory. Serving rechecks the canonical path, media type, and opened
4351 descriptor's identity, then streams full or partial responses from that descriptor. Replacing a
44−file atomically requires a freshly signed URL; editing it in place does not. The existing workspace
45−boundary still applies to HTML, PDF, and other workspace previews. Uploaded attachments keep their
46−separate asset resource.
52+file atomically requires a freshly signed URL; editing it in place does not. Because the token names
53+one file, an HTML document served this way cannot load sibling assets; the directory-scoped
54+`workspace-file` resource remains the route for HTML inside the workspace. Uploaded attachments keep
55+their separate asset resource.
4756
4857 Signed asset URLs are bearer credentials. Anyone who obtains a URL and can reach the environment
4958 can fetch that file until it expires. Clients should copy the authored reference, not the temporary
docs/user/composer.md+9−0
@@ -94,6 +94,15 @@ have a cached copy. Supported video formats and codecs depend on the browser or · 93 unmodified lines
9494 Bare paths in ordinary prose and paths inside code blocks stay text. Raw HTML `<video>` tags
9595 are not supported; use the Markdown embed syntax above.
9696
97+## Files outside the workspace
98+
99+When an agent links to a file it wrote outside the workspace, such as a Markdown report in
100+`/tmp`, select the link to open it in the file viewer. The viewer shows the file read-only, with
101+rendered Markdown available as usual; it cannot edit files outside the workspace. HTML and PDF
102+files outside the workspace open the same way as ones inside it, including the integrated browser
103+where it is available. Because such a file is served on its own, an HTML page outside the workspace
104+cannot load scripts, styles, or images from files beside it.
105+
97106 ## Changing projects
98107
99108 On web and desktop, changing the project from a new thread keeps the current environment when that
packages/contracts/src/assets.ts+4−1
@@ -15,6 +15,9 @@ export const AssetResource = Schema.Union([ · 14 unmodified lines
1515 threadId: ThreadId,
1616 path: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),
1717 }),
18+ // One file served in place from anywhere the environment host can read:
19+ // images, videos, HTML, and PDF. An absolute path may lie outside the
20+ // workspace; a relative one resolves against the thread's workspace.
1821 Schema.TaggedStruct("media-file", {
1922 threadId: ThreadId,
2023 path: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),
@@ -157,7 +160,7 @@ export class AssetPreviewTypeValidationError extends Schema.TaggedErrorClass<Ass · 136 unmodified lines
157160 ) {
158161 override get message(): string {
159162 return this.resource._tag === "media-file"
160− ? "Only images and videos can be previewed."
163+ ? "Only images, videos, HTML, and PDF files can be previewed."
161164 : "Only browser documents and images can be previewed.";
162165 }
163166 }
packages/contracts/src/project.ts+2−0
@@ -193,6 +193,8 @@ export class ProjectListEntriesError extends Schema.TaggedErrorClass<ProjectList · 192 unmodified lines
193193
194194 export const ProjectReadFileInput = Schema.Struct({
195195 cwd: TrimmedNonEmptyString,
196+ // Workspace-relative, or an absolute host path for a file outside the
197+ // workspace. Only workspace-relative paths can be written back.
196198 relativePath: TrimmedNonEmptyString.check(Schema.isMaxLength(PROJECT_READ_FILE_PATH_MAX_LENGTH)),
197199 });
198200 export type ProjectReadFileInput = typeof ProjectReadFileInput.Type;
packages/shared/src/filePreview.ts+16−0
@@ -24,6 +24,12 @@ const IMAGE_MIME_TYPE_BY_EXTENSION = new Map([ · 23 unmodified lines
2424 [".webp", "image/webp"],
2525 ]);
2626
27+const BROWSER_MIME_TYPE_BY_EXTENSION = new Map([
28+ [".htm", "text/html"],
29+ [".html", "text/html"],
30+ [".pdf", "application/pdf"],
31+]);
32+
2733 /** Classifies a literal filesystem extension, without URL decoding or suffix removal. */
2834 export function mediaMimeTypeFromExtension(extension: string): string | null {
2935 if (!/^\.[a-z0-9]+$/i.test(extension)) return null;
@@ -33,6 +39,16 @@ export function mediaMimeTypeFromExtension(extension: string): string | null { · 3 unmodified lines
3339 );
3440 }
3541
42+/** Files the server serves in place from anywhere on its host: media plus browser documents. */
43+export function hostPreviewMimeTypeFromExtension(extension: string): string | null {
44+ if (!/^\.[a-z0-9]+$/i.test(extension)) return null;
45+ return (
46+ mediaMimeTypeFromExtension(extension) ??
47+ BROWSER_MIME_TYPE_BY_EXTENSION.get(extension.toLowerCase()) ??
48+ null
49+ );
50+}
51+
3652 /** Classifies an authored media path or URL. Filesystem validation uses the literal extension. */
3753 export function mediaMimeType(path: string): string | null {
3854 const trimmed = path.trim();