authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-01 19:31:55-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-01 19:31:55-07:00
logf46a709ee487e1e6e04423fcb7032a26eb103129
tree986044155abf813709022d10b15e847629c52d4a
parentf2a914b8588bf3038472c14b129fbc5ddb3a2992
signature Signed by PGP key B5690EEEBB952194

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

Co-authored-by: Claude Code <noreply@anthropic.com>

26 files changed, 394 insertions(+), 89 deletions(-)

apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+9-2
...@@ -60,6 +60,7 @@ import { WorkspaceFileVideoPreview } from "./WorkspaceFileVideoPreview";...@@ -60,6 +60,7 @@ import { WorkspaceFileVideoPreview } from "./WorkspaceFileVideoPreview";
60import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview";60import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview";
61import {61import {
62 basename,62 basename,
63 isAbsolutePath,
63 isMarkdownPreviewFile,64 isMarkdownPreviewFile,
64 isSvgImagePreviewFile,65 isSvgImagePreviewFile,
65 isVideoPreviewFile,66 isVideoPreviewFile,
...@@ -782,8 +783,14 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {...@@ -782,8 +783,14 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
782 );783 );
783 }784 }
784785
785 const parentDir = relativePath.split("/").slice(0, -1).join("/");786 const parentDir = relativePath.slice(
786 const headerSubtitle = [projectName, parentDir].filter(Boolean).join(" · ");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
788 return (795 return (
789 <ReviewHighlighterProvider>796 <ReviewHighlighterProvider>
apps/mobile/src/features/files/filePath.test.ts+14-1
...@@ -1,6 +1,19 @@...@@ -1,6 +1,19 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
22
3import { isSvgImagePreviewFile, resolveWorkspaceRelativeFilePath } from "./filePath";3import {
4 fileRoutePathSegments,
5 isSvgImagePreviewFile,
6 resolveWorkspaceRelativeFilePath,
7} from "./filePath";
8
9describe("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
5describe("resolveWorkspaceRelativeFilePath", () => {18describe("resolveWorkspaceRelativeFilePath", () => {
6 it("keeps normalized workspace-relative paths", () => {19 it("keeps normalized workspace-relative paths", () => {
apps/mobile/src/features/files/filePath.ts+8-1
...@@ -10,10 +10,17 @@ function isWindowsAbsolutePath(value: string): boolean {...@@ -10,10 +10,17 @@ function isWindowsAbsolutePath(value: string): boolean {
10 return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\");10 return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\");
11}11}
1212
13function isAbsolutePath(value: string): boolean {13/** A file route holding an absolute path shows a host file outside the workspace. */
14export function isAbsolutePath(value: string): boolean {
14 return value.startsWith("/") || isWindowsAbsolutePath(value);15 return value.startsWith("/") || isWindowsAbsolutePath(value);
15}16}
1617
18/** Route segments that `normalizeRoutePath` joins back into the same path, root included. */
19export function fileRoutePathSegments(path: string): string[] {
20 const segments = path.split("/").filter((segment) => segment.length > 0);
21 return path.startsWith("/") ? ["", ...segments] : segments;
22}
23
17function isWindowsPathStyle(value: string): boolean {24function isWindowsPathStyle(value: string): boolean {
18 return isWindowsAbsolutePath(value) || /^[A-Za-z]:\\/.test(value);25 return isWindowsAbsolutePath(value) || /^[A-Za-z]:\\/.test(value);
19}26}
apps/mobile/src/features/files/workspaceFileAssetUrl.ts+10-4
...@@ -2,7 +2,7 @@ import type { AssetResource, EnvironmentId, ThreadId } from "@t3tools/contracts"...@@ -2,7 +2,7 @@ import type { AssetResource, EnvironmentId, ThreadId } from "@t3tools/contracts"
2import { useMemo } from "react";2import { useMemo } from "react";
33
4import { useAssetUrlState, useRefreshAssetUrl } from "../../state/assets";4import { useAssetUrlState, useRefreshAssetUrl } from "../../state/assets";
5import { isVideoPreviewFile, resolveWorkspaceFilePath } from "./filePath";5import { isAbsolutePath, isVideoPreviewFile, resolveWorkspaceFilePath } from "./filePath";
66
7export function useWorkspaceFileAssetUrlState(props: {7export function useWorkspaceFileAssetUrlState(props: {
8 readonly cwd: string | null;8 readonly cwd: string | null;
...@@ -18,16 +18,22 @@ export function useWorkspaceFileAssetUrlState(props: {...@@ -18,16 +18,22 @@ export function useWorkspaceFileAssetUrlState(props: {
18 [props.cwd, props.relativePath],18 [props.cwd, props.relativePath],
19 );19 );
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;
21 const resource = useMemo<AssetResource | null>(24 const resource = useMemo<AssetResource | null>(
22 () =>25 () =>
23 absolutePath !== null && props.threadId !== null26 absolutePath !== null && relativePath !== null && props.threadId !== null
24 ? {27 ? {
25 _tag: isVideoPreviewFile(absolutePath) ? "media-file" : "workspace-file",28 _tag:
29 isVideoPreviewFile(absolutePath) || isAbsolutePath(relativePath)
30 ? "media-file"
31 : "workspace-file",
26 threadId: props.threadId,32 threadId: props.threadId,
27 path: absolutePath,33 path: absolutePath,
28 }34 }
29 : null,35 : null,
30 [absolutePath, props.threadId],36 [absolutePath, relativePath, props.threadId],
31 );37 );
32 const state = useAssetUrlState(props.environmentId, resource);38 const state = useAssetUrlState(props.environmentId, resource);
33 const refresh = useRefreshAssetUrl(props.environmentId, resource);39 const refresh = useRefreshAssetUrl(props.environmentId, resource);
apps/mobile/src/features/threads/ThreadFeed.tsx+36-2
...@@ -166,7 +166,12 @@ import {...@@ -166,7 +166,12 @@ import {
166import { useAtomQueryRunner } from "../../state/use-atom-query-runner";166import { useAtomQueryRunner } from "../../state/use-atom-query-runner";
167import { usePreparedConnection } from "../../state/session";167import { usePreparedConnection } from "../../state/session";
168import * as Option from "effect/Option";168import * as Option from "effect/Option";
169import { resolveWorkspaceRelativeFilePath } from "../files/filePath";169import {
170 basename,
171 fileRoutePathSegments,
172 isAbsolutePath,
173 resolveWorkspaceRelativeFilePath,
174} from "../files/filePath";
170import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize";175import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize";
171176
172const WIDE_MARKDOWN_BLOCK_OPTIONS = {177const WIDE_MARKDOWN_BLOCK_OPTIONS = {
...@@ -2068,7 +2073,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2068,7 +2073,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2068 navigation.navigate("ThreadFile", {2073 navigation.navigate("ThreadFile", {
2069 environmentId: String(props.environmentId),2074 environmentId: String(props.environmentId),
2070 threadId: String(props.threadId),2075 threadId: String(props.threadId),
2071 path: relativePath.split("/").filter((segment) => segment.length > 0),2076 path: fileRoutePathSegments(relativePath),
2072 ...(presentation.line ? { line: String(presentation.line) } : {}),2077 ...(presentation.line ? { line: String(presentation.line) } : {}),
2073 });2078 });
2074 return;2079 return;
...@@ -2090,6 +2095,35 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2090,6 +2095,35 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2090 return;2095 return;
2091 }2096 }
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
2093 if (presentation.kind !== "file" && presentation.href) {2127 if (presentation.kind !== "file" && presentation.href) {
2094 if (/^https?:\/\//i.test(presentation.href) && isPdfFile({ name: presentation.href })) {2128 if (/^https?:\/\//i.test(presentation.href) && isPdfFile({ name: presentation.href })) {
2095 setExpandedFile(2129 setExpandedFile(
apps/server/src/assets/AssetAccess.test.ts+6-4
...@@ -44,7 +44,7 @@ const testLayer = Layer.mergeAll(...@@ -44,7 +44,7 @@ const testLayer = Layer.mergeAll(
44).pipe(Layer.provideMerge(NodeServices.layer));44).pipe(Layer.provideMerge(NodeServices.layer));
4545
46describe("AssetAccess", () => {46describe("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", () =>
48 Effect.gen(function* () {48 Effect.gen(function* () {
49 const fs = yield* FileSystem.FileSystem;49 const fs = yield* FileSystem.FileSystem;
50 const path = yield* Path.Path;50 const path = yield* Path.Path;
...@@ -54,6 +54,8 @@ describe("AssetAccess", () => {...@@ -54,6 +54,8 @@ describe("AssetAccess", () => {
54 ["screenshot.png", "image/png"],54 ["screenshot.png", "image/png"],
55 ["recording.mp4", "video/mp4"],55 ["recording.mp4", "video/mp4"],
56 ["recording.webm", "video/webm"],56 ["recording.webm", "video/webm"],
57 ["report.html", "text/html"],
58 ["report.pdf", "application/pdf"],
57 ] as const) {59 ] as const) {
58 const filePath = path.join(outside, name);60 const filePath = path.join(outside, name);
59 yield* fs.writeFileString(filePath, "media");61 yield* fs.writeFileString(filePath, "media");
...@@ -104,12 +106,12 @@ describe("AssetAccess", () => {...@@ -104,12 +106,12 @@ describe("AssetAccess", () => {
104 }).pipe(Effect.provide(testLayer)),106 }).pipe(Effect.provide(testLayer)),
105 );107 );
106108
107 it.effect("rejects non-media files, disguised targets, and directories", () =>109 it.effect("rejects non-previewable files, disguised targets, and directories", () =>
108 Effect.gen(function* () {110 Effect.gen(function* () {
109 const fs = yield* FileSystem.FileSystem;111 const fs = yield* FileSystem.FileSystem;
110 const path = yield* Path.Path;112 const path = yield* Path.Path;
111 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-validation-" });113 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"]) {
113 const filePath = path.join(root, name);115 const filePath = path.join(root, name);
114 yield* fs.writeFileString(filePath, "not media");116 yield* fs.writeFileString(filePath, "not media");
115 const error = yield* issueAssetUrl({117 const error = yield* issueAssetUrl({
...@@ -118,7 +120,7 @@ describe("AssetAccess", () => {...@@ -118,7 +120,7 @@ describe("AssetAccess", () => {
118 expect(error).toBeInstanceOf(AssetPreviewTypeValidationError);120 expect(error).toBeInstanceOf(AssetPreviewTypeValidationError);
119 }121 }
120 const disguisedPath = path.join(root, "disguised.png");122 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);
122 const disguisedError = yield* issueAssetUrl({124 const disguisedError = yield* issueAssetUrl({
123 resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: disguisedPath },125 resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: disguisedPath },
124 }).pipe(Effect.flip);126 }).pipe(Effect.flip);
apps/server/src/assets/AssetAccess.ts+3-3
...@@ -14,9 +14,9 @@ import {...@@ -14,9 +14,9 @@ import {
14 AssetWorkspaceRootNormalizationError,14 AssetWorkspaceRootNormalizationError,
15} from "@t3tools/contracts";15} from "@t3tools/contracts";
16import {16import {
17 hostPreviewMimeTypeFromExtension,
17 isWorkspaceImagePreviewPath,18 isWorkspaceImagePreviewPath,
18 isWorkspacePreviewEntryPath,19 isWorkspacePreviewEntryPath,
19 mediaMimeTypeFromExtension,
20 WORKSPACE_BROWSER_PREVIEW_EXTENSIONS,20 WORKSPACE_BROWSER_PREVIEW_EXTENSIONS,
21 WORKSPACE_IMAGE_PREVIEW_EXTENSIONS,21 WORKSPACE_IMAGE_PREVIEW_EXTENSIONS,
22} from "@t3tools/shared/filePreview";22} from "@t3tools/shared/filePreview";
...@@ -246,7 +246,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i...@@ -246,7 +246,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
246 if (!canonicalFile) {246 if (!canonicalFile) {
247 return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource });247 return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource });
248 }248 }
249 if (mediaMimeTypeFromExtension(path.extname(canonicalFile)) === null) {249 if (hostPreviewMimeTypeFromExtension(path.extname(canonicalFile)) === null) {
250 return yield* new AssetPreviewTypeValidationError({ resource: input.resource });250 return yield* new AssetPreviewTypeValidationError({ resource: input.resource });
251 }251 }
252 const identity = yield* openMediaFile(canonicalFile).pipe(252 const identity = yield* openMediaFile(canonicalFile).pipe(
...@@ -598,7 +598,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (...@@ -598,7 +598,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
598 Effect.orElseSucceed(() => null),598 Effect.orElseSucceed(() => null),
599 );599 );
600 if (canonicalFile !== claims.filePath) return null;600 if (canonicalFile !== claims.filePath) return null;
601 const mimeType = mediaMimeTypeFromExtension(path.extname(canonicalFile));601 const mimeType = hostPreviewMimeTypeFromExtension(path.extname(canonicalFile));
602 if (!mimeType) return null;602 if (!mimeType) return null;
603 const file = yield* openMediaFile(canonicalFile, claims).pipe(603 const file = yield* openMediaFile(canonicalFile, claims).pipe(
604 Effect.tapError((cause) =>604 Effect.tapError((cause) =>
apps/server/src/http.test.ts+7-9
...@@ -324,15 +324,13 @@ describe("assetResponseHeaders", () => {...@@ -324,15 +324,13 @@ describe("assetResponseHeaders", () => {
324 "X-Content-Type-Options": "nosniff",324 "X-Content-Type-Options": "nosniff",
325 });325 });
326 });326 });
327 it("declares utf-8 for HTML assets so non-ASCII content renders correctly", () => {327 it("serves HTML assets as utf-8 inside a sandboxed origin", () => {
328 expect(assetResponseHeaders("/workspace/page.html")).toHaveProperty(328 for (const path of ["/workspace/page.html", "/workspace/PAGE.HTM", "/tmp/report.html"]) {
329 "Content-Type",329 expect(assetResponseHeaders(path)).toMatchObject({
330 "text/html; charset=utf-8",330 "Content-Type": "text/html; charset=utf-8",
331 );331 "Content-Security-Policy": "sandbox allow-scripts allow-forms allow-popups allow-modals",
332 expect(assetResponseHeaders("/workspace/PAGE.HTM")).toHaveProperty(332 });
333 "Content-Type",333 }
334 "text/html; charset=utf-8",
335 );
336 });334 });
337335
338 it("downloads uploaded documents without executing their content", () => {336 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";...@@ -51,6 +51,10 @@ const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces";
51const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);51const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
52const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"];52const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"];
53const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox";53const 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.
57const HTML_CONTENT_SECURITY_POLICY = "sandbox allow-scripts allow-forms allow-popups allow-modals";
5458
55// Types a browser may render as a document if a proxy strips the disposition59// Types a browser may render as a document if a proxy strips the disposition
56// header. Downloads of these fall back to octet-stream.60// header. Downloads of these fall back to octet-stream.
...@@ -105,7 +109,10 @@ export function assetResponseHeaders(...@@ -105,7 +109,10 @@ export function assetResponseHeaders(
105 : inlineVideoMimeType !== undefined && isSafeInlineVideoMimeType(inlineVideoMimeType)109 : inlineVideoMimeType !== undefined && isSafeInlineVideoMimeType(inlineVideoMimeType)
106 ? { "Content-Type": inlineVideoMimeType }110 ? { "Content-Type": inlineVideoMimeType }
107 : lowerPath.endsWith(".html") || lowerPath.endsWith(".htm")111 : 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 }
109 : {}),116 : {}),
110 ...(!options?.download && lowerPath.endsWith(".svg")117 ...(!options?.download && lowerPath.endsWith(".svg")
111 ? { "Content-Security-Policy": SVG_CONTENT_SECURITY_POLICY }118 ? { "Content-Security-Policy": SVG_CONTENT_SECURITY_POLICY }
apps/server/src/workspace/WorkspaceFileSystem.test.ts+66
...@@ -1,3 +1,6 @@...@@ -1,3 +1,6 @@
1// @effect-diagnostics nodeBuiltinImport:off - FileSystem cannot create a FIFO.
2import * as NodeChildProcess from "node:child_process";
3
1import * as NodeServices from "@effect/platform-node/NodeServices";4import * as NodeServices from "@effect/platform-node/NodeServices";
2import { it, describe, expect } from "@effect/vitest";5import { it, describe, expect } from "@effect/vitest";
3import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
...@@ -73,6 +76,53 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i...@@ -73,6 +76,53 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i
73 }),76 }),
74 );77 );
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
76 it.effect("rejects reads outside the workspace root", () =>126 it.effect("rejects reads outside the workspace root", () =>
77 Effect.gen(function* () {127 Effect.gen(function* () {
78 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;128 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
...@@ -212,6 +262,22 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i...@@ -212,6 +262,22 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i
212 }),262 }),
213 );263 );
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
215 it.effect("invalidates workspace entry search cache after writes", () =>281 it.effect("invalidates workspace entry search cache after writes", () =>
216 Effect.gen(function* () {282 Effect.gen(function* () {
217 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;283 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
apps/server/src/workspace/WorkspaceFileSystem.ts+48-6
...@@ -3,10 +3,13 @@...@@ -3,10 +3,13 @@
3 * WorkspaceFileSystem - Effect service contract for workspace file mutations.3 * WorkspaceFileSystem - Effect service contract for workspace file mutations.
4 *4 *
5 * Owns workspace-root-relative file read/write operations and their associated5 * 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.
7 *9 *
8 * @module WorkspaceFileSystem10 * @module WorkspaceFileSystem
9 */11 */
12import * as NodeFS from "node:fs";
10import * as NodeFSP from "node:fs/promises";13import * as NodeFSP from "node:fs/promises";
1114
12import type {15import type {
...@@ -104,7 +107,10 @@ export type WorkspaceFileSystemError = typeof WorkspaceFileSystemError.Type;...@@ -104,7 +107,10 @@ export type WorkspaceFileSystemError = typeof WorkspaceFileSystemError.Type;
104export class WorkspaceFileSystem extends Context.Service<107export class WorkspaceFileSystem extends Context.Service<
105 WorkspaceFileSystem,108 WorkspaceFileSystem,
106 {109 {
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 */
108 readonly readFile: (114 readonly readFile: (
109 input: ProjectReadFileInput,115 input: ProjectReadFileInput,
110 ) => Effect.Effect<116 ) => Effect.Effect<
...@@ -132,9 +138,31 @@ export const make = Effect.gen(function* () {...@@ -132,9 +138,31 @@ export const make = Effect.gen(function* () {
132 const workspacePaths = yield* WorkspacePaths.WorkspacePaths;138 const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
133 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;139 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
134140
135 const readFile: WorkspaceFileSystem["Service"]["readFile"] = Effect.fn(141 /**
136 "WorkspaceFileSystem.readFile",142 * Resolves the file a read targets. Workspace-relative paths must stay inside the
137 )(function* (input) {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
138 const target = yield* workspacePaths.resolveRelativePathWithinRoot({166 const target = yield* workspacePaths.resolveRelativePathWithinRoot({
139 workspaceRoot: input.cwd,167 workspaceRoot: input.cwd,
140 relativePath: input.relativePath,168 relativePath: input.relativePath,
...@@ -177,10 +205,24 @@ export const make = Effect.gen(function* () {...@@ -177,10 +205,24 @@ export const make = Effect.gen(function* () {
177 resolvedPath: realTargetPath,205 resolvedPath: realTargetPath,
178 });206 });
179 }207 }
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
181 return yield* Effect.acquireUseRelease(217 return yield* Effect.acquireUseRelease(
182 Effect.tryPromise({218 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 ),
184 catch: (cause) =>226 catch: (cause) =>
185 new WorkspaceFileSystemOperationError({227 new WorkspaceFileSystemOperationError({
186 workspaceRoot: input.cwd,228 workspaceRoot: input.cwd,
apps/web/src/browser/openFileInPreview.ts+9-1
...@@ -6,6 +6,7 @@ import type {...@@ -6,6 +6,7 @@ import type {
6 PreviewSessionSnapshot,6 PreviewSessionSnapshot,
7 ScopedThreadRef,7 ScopedThreadRef,
8} from "@t3tools/contracts";8} from "@t3tools/contracts";
9import { mediaFileReference } from "@t3tools/client-runtime/media-reference";
9import {10import {
10 type AtomCommandResult,11 type AtomCommandResult,
11 mapAtomCommandResult,12 mapAtomCommandResult,
...@@ -52,9 +53,14 @@ export async function openUrlInPreview<E>(input: {...@@ -52,9 +53,14 @@ export async function openUrlInPreview<E>(input: {
52 });53 });
53}54}
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 */
55export async function openFileInPreview<AssetError, PreviewError>(input: {60export async function openFileInPreview<AssetError, PreviewError>(input: {
56 readonly threadRef: ScopedThreadRef;61 readonly threadRef: ScopedThreadRef;
57 readonly filePath: string;62 readonly filePath: string;
63 readonly workspaceRoot: string | undefined;
58 readonly httpBaseUrl: string;64 readonly httpBaseUrl: string;
59 readonly createAssetUrl: (input: {65 readonly createAssetUrl: (input: {
60 readonly environmentId: EnvironmentId;66 readonly environmentId: EnvironmentId;
...@@ -71,11 +77,13 @@ export async function openFileInPreview<AssetError, PreviewError>(input: {...@@ -71,11 +77,13 @@ export async function openFileInPreview<AssetError, PreviewError>(input: {
71 ),77 ),
72 );78 );
73 }79 }
80 const insideWorkspace =
81 mediaFileReference(input.filePath, input.workspaceRoot).relativePath !== undefined;
74 const assetResult = await input.createAssetUrl({82 const assetResult = await input.createAssetUrl({
75 environmentId: input.threadRef.environmentId,83 environmentId: input.threadRef.environmentId,
76 input: {84 input: {
77 resource: {85 resource: {
78 _tag: "workspace-file",86 _tag: insideWorkspace ? "workspace-file" : "media-file",
79 threadId: input.threadRef.threadId,87 threadId: input.threadRef.threadId,
80 path: input.filePath,88 path: input.filePath,
81 },89 },
apps/web/src/components/ChatMarkdown.tsx+33-23
...@@ -164,7 +164,7 @@ import {...@@ -164,7 +164,7 @@ import {
164} from "~/lib/openPullRequestLink";164} from "~/lib/openPullRequestLink";
165import { writeTextToClipboard } from "../hooks/useCopyToClipboard";165import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
166import { isPreviewSupportedInRuntime } from "../previewStateStore";166import { isPreviewSupportedInRuntime } from "../previewStateStore";
167import { resolvePathLinkTarget } from "../terminal-links";167import { isAbsolutePath, resolvePathLinkTarget } from "../terminal-links";
168import {168import {
169 isBrowserPreviewFile,169 isBrowserPreviewFile,
170 openFileInPreview,170 openFileInPreview,
...@@ -188,6 +188,8 @@ interface ChatMarkdownProps {...@@ -188,6 +188,8 @@ interface ChatMarkdownProps {
188 parseRawHtml?: boolean;188 parseRawHtml?: boolean;
189 /** Append a prompt that invokes a newly created artifact-template skill. */189 /** Append a prompt that invokes a newly created artifact-template skill. */
190 onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined;190 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. */
191 imageBaseDir?: string | undefined;193 imageBaseDir?: string | undefined;
192 onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined;194 onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined;
193 extraRemarkPlugins?: NonNullable<ReactMarkdownOptions["remarkPlugins"]>;195 extraRemarkPlugins?: NonNullable<ReactMarkdownOptions["remarkPlugins"]>;
...@@ -1017,14 +1019,16 @@ interface MarkdownFileLinkProps {...@@ -1017,14 +1019,16 @@ interface MarkdownFileLinkProps {
1017 targetPath: string;1019 targetPath: string;
1018 iconPath: string;1020 iconPath: string;
1019 displayPath: string;1021 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;
1021 line?: number | undefined;1025 line?: number | undefined;
1022 label: string;1026 label: string;
1023 copyMarkdown: string;1027 copyMarkdown: string;
1024 theme: "light" | "dark";1028 theme: "light" | "dark";
1025 threadRef?: ScopedThreadRef | undefined;1029 threadRef?: ScopedThreadRef | undefined;
1026 onOpen?: ((targetPath: string) => Promise<AtomCommandResult<unknown, unknown>>) | undefined;1030 onOpen?: ((targetPath: string) => Promise<AtomCommandResult<unknown, unknown>>) | undefined;
1027 onOpenInPanel: (workspaceRelativePath: string, line: number | undefined) => void;1031 onOpenInPanel: (panelPath: string, line: number | undefined) => void;
1028 openInEditorMenuLabel: string;1032 openInEditorMenuLabel: string;
1029 onOpenInBrowser?: (() => Promise<AtomCommandResult<unknown, unknown>>) | undefined;1033 onOpenInBrowser?: (() => Promise<AtomCommandResult<unknown, unknown>>) | undefined;
1030 onOpenMedia?: (() => void) | undefined;1034 onOpenMedia?: (() => void) | undefined;
...@@ -1603,7 +1607,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({...@@ -1603,7 +1607,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
1603 targetPath,1607 targetPath,
1604 iconPath,1608 iconPath,
1605 displayPath,1609 displayPath,
1606 workspaceRelativePath,1610 panelPath,
1607 line,1611 line,
1608 label,1612 label,
1609 copyMarkdown,1613 copyMarkdown,
...@@ -1657,8 +1661,8 @@ const MarkdownFileLink = memo(function MarkdownFileLink({...@@ -1657,8 +1661,8 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
1657 }, [onOpen, targetPath]);1661 }, [onOpen, targetPath]);
16581662
1659 const handleOpenInFilePreview = useCallback(() => {1663 const handleOpenInFilePreview = useCallback(() => {
1660 if (threadRef && workspaceRelativePath) {1664 if (threadRef && panelPath) {
1661 onOpenInPanel(workspaceRelativePath, line);1665 onOpenInPanel(panelPath, line);
1662 return;1666 return;
1663 }1667 }
1664 if (onOpenMedia) {1668 if (onOpenMedia) {
...@@ -1666,7 +1670,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({...@@ -1666,7 +1670,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
1666 return;1670 return;
1667 }1671 }
1668 handleOpenInEditor();1672 handleOpenInEditor();
1669 }, [handleOpenInEditor, line, onOpenInPanel, onOpenMedia, threadRef, workspaceRelativePath]);1673 }, [handleOpenInEditor, line, onOpenInPanel, onOpenMedia, panelPath, threadRef]);
16701674
1671 const handleOpenInBrowser = useCallback(() => {1675 const handleOpenInBrowser = useCallback(() => {
1672 if (!onOpenInBrowser) {1676 if (!onOpenInBrowser) {
...@@ -1867,7 +1871,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({...@@ -1867,7 +1871,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
18671871
1868 const canOpenInEditor = onOpen !== undefined;1872 const canOpenInEditor = onOpen !== undefined;
1869 const canOpenInBrowser = onOpenInBrowser !== undefined;1873 const canOpenInBrowser = onOpenInBrowser !== undefined;
1870 const canOpenInPanel = threadRef !== undefined && Boolean(workspaceRelativePath);1874 const canOpenInPanel = threadRef !== undefined && Boolean(panelPath);
1871 const hasPrimaryAction = hasMarkdownFilePrimaryAction({1875 const hasPrimaryAction = hasMarkdownFilePrimaryAction({
1872 canOpenInEditor,1876 canOpenInEditor,
1873 canOpenInBrowser,1877 canOpenInBrowser,
...@@ -1954,7 +1958,7 @@ function areMarkdownFileLinkPropsEqual(...@@ -1954,7 +1958,7 @@ function areMarkdownFileLinkPropsEqual(
1954 previous.targetPath === next.targetPath &&1958 previous.targetPath === next.targetPath &&
1955 previous.iconPath === next.iconPath &&1959 previous.iconPath === next.iconPath &&
1956 previous.displayPath === next.displayPath &&1960 previous.displayPath === next.displayPath &&
1957 previous.workspaceRelativePath === next.workspaceRelativePath &&1961 previous.panelPath === next.panelPath &&
1958 previous.line === next.line &&1962 previous.line === next.line &&
1959 previous.label === next.label &&1963 previous.label === next.label &&
1960 previous.copyMarkdown === next.copyMarkdown &&1964 previous.copyMarkdown === next.copyMarkdown &&
...@@ -2098,24 +2102,24 @@ function ChatMarkdown({...@@ -2098,24 +2102,24 @@ function ChatMarkdown({
2098 for (const href of extractMarkdownLinkHrefs(renderCodexFileCitationsAsMarkdown(text))) {2102 for (const href of extractMarkdownLinkHrefs(renderCodexFileCitationsAsMarkdown(text))) {
2099 const normalizedHref = normalizeMarkdownLinkHrefKey(href);2103 const normalizedHref = normalizeMarkdownLinkHrefKey(href);
2100 if (metaByHref.has(normalizedHref)) continue;2104 if (metaByHref.has(normalizedHref)) continue;
2101 const meta = resolveMarkdownFileLinkMeta(normalizedHref, cwd);2105 const meta = resolveMarkdownFileLinkMeta(normalizedHref, cwd, imageBaseDir ?? cwd);
2102 if (meta) {2106 if (meta) {
2103 metaByHref.set(normalizedHref, meta);2107 metaByHref.set(normalizedHref, meta);
2104 }2108 }
2105 }2109 }
2106 return metaByHref;2110 return metaByHref;
2107 }, [cwd, text]);2111 }, [cwd, imageBaseDir, text]);
2108 const inlineCodeFileLinkMetaByText = useMemo(() => {2112 const inlineCodeFileLinkMetaByText = useMemo(() => {
2109 const metaByText = new Map<string, MarkdownFileLinkMeta>();2113 const metaByText = new Map<string, MarkdownFileLinkMeta>();
2110 for (const span of extractInlineCodeSpans(text)) {2114 for (const span of extractInlineCodeSpans(text)) {
2111 if (metaByText.has(span)) continue;2115 if (metaByText.has(span)) continue;
2112 const meta = resolveInlineCodeFileLinkMeta(span, cwd);2116 const meta = resolveInlineCodeFileLinkMeta(span, cwd, imageBaseDir ?? cwd);
2113 if (meta) {2117 if (meta) {
2114 metaByText.set(span, meta);2118 metaByText.set(span, meta);
2115 }2119 }
2116 }2120 }
2117 return metaByText;2121 return metaByText;
2118 }, [cwd, text]);2122 }, [cwd, imageBaseDir, text]);
2119 const fileLinkParentSuffixByPath = useMemo(() => {2123 const fileLinkParentSuffixByPath = useMemo(() => {
2120 const filePaths = [2124 const filePaths = [
2121 ...[...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath),2125 ...[...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath),
...@@ -2223,12 +2227,13 @@ function ChatMarkdown({...@@ -2223,12 +2227,13 @@ function ChatMarkdown({
2223 return openFileInPreview({2227 return openFileInPreview({
2224 threadRef,2228 threadRef,
2225 filePath: path,2229 filePath: path,
2230 workspaceRoot: cwd,
2226 httpBaseUrl: preparedConnection.value.httpBaseUrl,2231 httpBaseUrl: preparedConnection.value.httpBaseUrl,
2227 createAssetUrl,2232 createAssetUrl,
2228 openPreview,2233 openPreview,
2229 });2234 });
2230 },2235 },
2231 [createAssetUrl, openPreview, preparedConnection, threadRef],2236 [createAssetUrl, cwd, openPreview, preparedConnection, threadRef],
2232 );2237 );
2233 const findWorkspaceBasenameMatch = useCallback(2238 const findWorkspaceBasenameMatch = useCallback(
2234 async (workspaceRelativePath: string) => {2239 async (workspaceRelativePath: string) => {
...@@ -2251,23 +2256,23 @@ function ChatMarkdown({...@@ -2251,23 +2256,23 @@ function ChatMarkdown({
2251 [cwd, environmentId, searchProjectEntries],2256 [cwd, environmentId, searchProjectEntries],
2252 );2257 );
2253 // A bare filename resolves to the workspace root, which is rarely where the2258 // 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.
2255 const openFileInPanel = useCallback(2260 const openFileInPanel = useCallback(
2256 (workspaceRelativePath: string, line: number | undefined) => {2261 (panelPath: string, line: number | undefined) => {
2257 if (!threadRef) return;2262 if (!threadRef) return;
2258 // Claimed on every open so a synchronous one supersedes a lookup already2263 // Claimed on every open so a synchronous one supersedes a lookup already
2259 // in flight.2264 // in flight.
2260 const isLatestLookup = claimWorkspaceBasenameLookup();2265 const isLatestLookup = claimWorkspaceBasenameLookup();
2261 const openAt = (path: string) =>2266 const openAt = (path: string) =>
2262 useRightPanelStore.getState().openFile(threadRef, path, line);2267 useRightPanelStore.getState().openFile(threadRef, path, line);
2263 if (!cwd || !needsWorkspaceBasenameLookup(workspaceRelativePath)) {2268 if (!cwd || !needsWorkspaceBasenameLookup(panelPath)) {
2264 openAt(workspaceRelativePath);2269 openAt(panelPath);
2265 return;2270 return;
2266 }2271 }
2267 void (async () => {2272 void (async () => {
2268 const match = await findWorkspaceBasenameMatch(workspaceRelativePath);2273 const match = await findWorkspaceBasenameMatch(panelPath);
2269 if (!isLatestLookup()) return;2274 if (!isLatestLookup()) return;
2270 openAt(match ?? workspaceRelativePath);2275 openAt(match ?? panelPath);
2271 })();2276 })();
2272 },2277 },
2273 [cwd, findWorkspaceBasenameMatch, threadRef],2278 [cwd, findWorkspaceBasenameMatch, threadRef],
...@@ -2310,6 +2315,11 @@ function ChatMarkdown({...@@ -2310,6 +2315,11 @@ function ChatMarkdown({
2310 mediaMimeTypeFromExtension(2315 mediaMimeTypeFromExtension(
2311 fileLinkMeta.basename.slice(fileLinkMeta.basename.lastIndexOf(".")),2316 fileLinkMeta.basename.slice(fileLinkMeta.basename.lastIndexOf(".")),
2312 ) !== null;2317 ) !== 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
2314 return (2324 return (
2315 <MarkdownFileLink2325 <MarkdownFileLink
...@@ -2317,7 +2327,7 @@ function ChatMarkdown({...@@ -2317,7 +2327,7 @@ function ChatMarkdown({
2317 targetPath={fileLinkMeta.targetPath}2327 targetPath={fileLinkMeta.targetPath}
2318 iconPath={fileLinkMeta.filePath}2328 iconPath={fileLinkMeta.filePath}
2319 displayPath={fileLinkMeta.displayPath}2329 displayPath={fileLinkMeta.displayPath}
2320 workspaceRelativePath={fileLinkMeta.workspaceRelativePath}2330 panelPath={panelPath}
2321 line={fileLinkMeta.line}2331 line={fileLinkMeta.line}
2322 label={labelParts.join(" · ")}2332 label={labelParts.join(" · ")}
2323 copyMarkdown={copyMarkdown}2333 copyMarkdown={copyMarkdown}
...@@ -2434,7 +2444,7 @@ function ChatMarkdown({...@@ -2434,7 +2444,7 @@ function ChatMarkdown({
2434 const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : "";2444 const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : "";
2435 const fileLinkMeta = normalizedHref2445 const fileLinkMeta = normalizedHref
2436 ? (markdownFileLinkMetaByHref.get(normalizedHref) ??2446 ? (markdownFileLinkMetaByHref.get(normalizedHref) ??
2437 resolveMarkdownFileLinkMeta(normalizedHref, cwd))2447 resolveMarkdownFileLinkMeta(normalizedHref, cwd, imageBaseDir ?? cwd))
2438 : null;2448 : null;
2439 if (!fileLinkMeta) {2449 if (!fileLinkMeta) {
2440 const faviconHost = resolveExternalWebLinkHost(href);2450 const faviconHost = resolveExternalWebLinkHost(href);
...@@ -2580,7 +2590,7 @@ function ChatMarkdown({...@@ -2580,7 +2590,7 @@ function ChatMarkdown({
2580 const codeText = nodeToPlainText(children);2590 const codeText = nodeToPlainText(children);
2581 const fileLinkMeta =2591 const fileLinkMeta =
2582 inlineCodeFileLinkMetaByText.get(codeText.trim()) ??2592 inlineCodeFileLinkMetaByText.get(codeText.trim()) ??
2583 resolveInlineCodeFileLinkMeta(codeText, cwd);2593 resolveInlineCodeFileLinkMeta(codeText, cwd, imageBaseDir ?? cwd);
2584 if (fileLinkMeta) {2594 if (fileLinkMeta) {
2585 return fileLinkChip(2595 return fileLinkChip(
2586 fileLinkMeta,2596 fileLinkMeta,
apps/web/src/components/RightPanelTabs.tsx+3-1
...@@ -498,7 +498,9 @@ function surfaceTitle(...@@ -498,7 +498,9 @@ function surfaceTitle(
498 case "files":498 case "files":
499 return "Files";499 return "Files";
500 case "file":500 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 );
502 case "terminal":504 case "terminal":
503 return (505 return (
504 terminalLabelsById.get(surface.activeTerminalId) ??506 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...@@ -34,7 +34,7 @@ import { DIFF_SURFACE_THEME_UNSAFE_CSS, resolveDiffThemeName } from "~/lib/diffR
34import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting";34import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting";
35import { cn } from "~/lib/utils";35import { cn } from "~/lib/utils";
36import { isPreviewSupportedInRuntime } from "~/previewStateStore";36import { isPreviewSupportedInRuntime } from "~/previewStateStore";
37import { resolvePathLinkTarget } from "~/terminal-links";37import { isAbsolutePath, resolvePathLinkTarget } from "~/terminal-links";
38import { ScrollArea } from "~/components/ui/scroll-area";38import { ScrollArea } from "~/components/ui/scroll-area";
39import { Toggle } from "~/components/ui/toggle";39import { Toggle } from "~/components/ui/toggle";
40import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";40import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
...@@ -793,6 +793,7 @@ function RenderedMarkdownSurface({...@@ -793,6 +793,7 @@ function RenderedMarkdownSurface({
793 relativePath,793 relativePath,
794 contents,794 contents,
795 threadRef,795 threadRef,
796 readOnly,
796 onPendingChange,797 onPendingChange,
797}: Omit<798}: Omit<
798 EditableFileSurfaceProps,799 EditableFileSurfaceProps,
...@@ -804,6 +805,7 @@ function RenderedMarkdownSurface({...@@ -804,6 +805,7 @@ function RenderedMarkdownSurface({
804 | "onPostRender"805 | "onPostRender"
805> & {806> & {
806 threadRef: ScopedThreadRef;807 threadRef: ScopedThreadRef;
808 readOnly: boolean;
807}) {809}) {
808 const saveCoordinator = useFileSaveCoordinator({810 const saveCoordinator = useFileSaveCoordinator({
809 environmentId,811 environmentId,
...@@ -819,15 +821,19 @@ function RenderedMarkdownSurface({...@@ -819,15 +821,19 @@ function RenderedMarkdownSurface({
819 cwd={cwd}821 cwd={cwd}
820 relativePath={relativePath}822 relativePath={relativePath}
821 threadRef={threadRef}823 threadRef={threadRef}
822 onTaskListChange={({ markerOffset, checked }) => {824 onTaskListChange={
823 const currentContents =825 readOnly
824 getOptimisticProjectFileQueryData(environmentId, cwd, relativePath)?.contents ??826 ? undefined
825 contents;827 : ({ markerOffset, checked }) => {
826 const nextContents = setMarkdownTaskChecked(currentContents, markerOffset, checked);828 const currentContents =
827 if (nextContents === currentContents) return;829 getOptimisticProjectFileQueryData(environmentId, cwd, relativePath)?.contents ??
828 setProjectFileQueryData(environmentId, cwd, relativePath, nextContents);830 contents;
829 saveCoordinator.change(nextContents);831 const nextContents = setMarkdownTaskChecked(currentContents, markerOffset, checked);
830 }}832 if (nextContents === currentContents) return;
833 setProjectFileQueryData(environmentId, cwd, relativePath, nextContents);
834 saveCoordinator.change(nextContents);
835 }
836 }
831 />837 />
832 </ScrollArea>838 </ScrollArea>
833 );839 );
...@@ -872,6 +878,8 @@ export default function FilePreviewPanel({...@@ -872,6 +878,8 @@ export default function FilePreviewPanel({
872 const isVideo = relativePath !== null && isWorkspaceVideoPreviewPath(relativePath);878 const isVideo = relativePath !== null && isWorkspaceVideoPreviewPath(relativePath);
873 const isImage = relativePath !== null && !isVideo && isWorkspaceImagePreviewPath(relativePath);879 const isImage = relativePath !== null && !isVideo && isWorkspaceImagePreviewPath(relativePath);
874 const isMedia = isImage || isVideo;880 const isMedia = isImage || isVideo;
881 // A file outside the workspace (an absolute path) is shown, never edited.
882 const isHostFile = relativePath !== null && isAbsolutePath(relativePath);
875 const file = useProjectFileQuery(environmentId, cwd, relativePath, !isMedia);883 const file = useProjectFileQuery(environmentId, cwd, relativePath, !isMedia);
876 const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen);884 const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen);
877 // Reading markdown rendered is a preference, not a property of one file. Keeping885 // Reading markdown rendered is a preference, not a property of one file. Keeping
...@@ -938,6 +946,7 @@ export default function FilePreviewPanel({...@@ -938,6 +946,7 @@ export default function FilePreviewPanel({
938 const result = await openFileInPreview({946 const result = await openFileInPreview({
939 threadRef,947 threadRef,
940 filePath: absolutePath,948 filePath: absolutePath,
949 workspaceRoot: cwd,
941 httpBaseUrl: environmentHttpBaseUrl,950 httpBaseUrl: environmentHttpBaseUrl,
942 createAssetUrl,951 createAssetUrl,
943 openPreview,952 openPreview,
...@@ -954,7 +963,7 @@ export default function FilePreviewPanel({...@@ -954,7 +963,7 @@ export default function FilePreviewPanel({
954 }),963 }),
955 );964 );
956 })();965 })();
957 }, [absolutePath, createAssetUrl, environmentHttpBaseUrl, openPreview, threadRef]);966 }, [absolutePath, createAssetUrl, cwd, environmentHttpBaseUrl, openPreview, threadRef]);
958967
959 return (968 return (
960 <div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">969 <div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
...@@ -1130,9 +1139,10 @@ export default function FilePreviewPanel({...@@ -1130,9 +1139,10 @@ export default function FilePreviewPanel({
1130 relativePath={relativePath}1139 relativePath={relativePath}
1131 threadRef={threadRef}1140 threadRef={threadRef}
1132 contents={file.data.contents}1141 contents={file.data.contents}
1142 readOnly={isHostFile}
1133 onPendingChange={onPendingChange}1143 onPendingChange={onPendingChange}
1134 />1144 />
1135 ) : file.data.truncated ? (1145 ) : file.data.truncated || isHostFile ? (
1136 <Virtualizer1146 <Virtualizer
1137 key={`${relativePath}:${resolvedTheme}:${file.data.byteLength}`}1147 key={`${relativePath}:${resolvedTheme}:${file.data.byteLength}`}
1138 className="file-preview-virtualizer min-h-0 flex-1 overflow-auto"1148 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", () => {...@@ -14,10 +14,28 @@ describe("fileBreadcrumbs", () => {
14 });14 });
1515
16 it("normalizes repeated separators", () => {16 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([
18 "workspace",18 "workspace",
19 "src",19 "src",
20 "index.ts",20 "index.ts",
21 ]);21 ]);
22 });22 });
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 });
23});41});
apps/web/src/components/files/filePath.ts+14-3
...@@ -1,16 +1,27 @@...@@ -1,16 +1,27 @@
1import { isWindowsAbsolutePath } from "@t3tools/shared/path";
2
3import { isAbsolutePath } from "~/terminal-links";
4
1export interface FileBreadcrumb {5export interface FileBreadcrumb {
2 label: string;6 label: string;
3 path: string;7 path: string;
4 kind: "project" | "directory" | "file";8 kind: "project" | "directory" | "file";
5}9}
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 */
7export function fileBreadcrumbs(projectName: string, relativePath: string): FileBreadcrumb[] {15export 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 === "/" ? "/" : "";
9 return [20 return [
10 { label: projectName, path: "", kind: "project" },21 ...(hostPath ? [] : [{ label: projectName, path: "", kind: "project" as const }]),
11 ...parts.map((part, index) => ({22 ...parts.map((part, index) => ({
12 label: part,23 label: part,
13 path: parts.slice(0, index + 1).join("/"),24 path: root + parts.slice(0, index + 1).join(separator),
14 kind: index === parts.length - 1 ? ("file" as const) : ("directory" as const),25 kind: index === parts.length - 1 ? ("file" as const) : ("directory" as const),
15 })),26 })),
16 ];27 ];
apps/web/src/markdown-links.test.ts+16
...@@ -275,6 +275,22 @@ describe("resolveMarkdownFileLinkTarget", () => {...@@ -275,6 +275,22 @@ describe("resolveMarkdownFileLinkTarget", () => {
275 });275 });
276});276});
277277
278describe("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
278describe("resolveInlineCodeFileLinkMeta", () => {294describe("resolveInlineCodeFileLinkMeta", () => {
279 it("links relative paths with file extensions", () => {295 it("links relative paths with file extensions", () => {
280 expect(296 expect(
apps/web/src/markdown-links.ts+14-6
...@@ -189,9 +189,15 @@ function hasExternalScheme(path: string): boolean {...@@ -189,9 +189,15 @@ function hasExternalScheme(path: string): boolean {
189 return !POSITION_ONLY_PATTERN.test(rest);189 return !POSITION_ONLY_PATTERN.test(rest);
190}190}
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 */
192export function resolveMarkdownFileLinkTarget(197export function resolveMarkdownFileLinkTarget(
193 href: string | undefined,198 href: string | undefined,
194 cwd?: string,199 cwd?: string,
200 baseDir: string | undefined = cwd,
195): string | null {201): string | null {
196 if (!href) return null;202 if (!href) return null;
197 const rawHref = normalizeMarkdownLinkDestination(href);203 const rawHref = normalizeMarkdownLinkDestination(href);
...@@ -222,8 +228,8 @@ export function resolveMarkdownFileLinkTarget(...@@ -222,8 +228,8 @@ export function resolveMarkdownFileLinkTarget(
222 return pathWithPosition;228 return pathWithPosition;
223 }229 }
224230
225 if (!cwd) return null;231 if (!baseDir) return null;
226 return resolvePathLinkTarget(pathWithPosition, cwd);232 return resolvePathLinkTarget(pathWithPosition, baseDir);
227}233}
228234
229/**235/**
...@@ -235,18 +241,19 @@ export function resolveMarkdownFileLinkTarget(...@@ -235,18 +241,19 @@ export function resolveMarkdownFileLinkTarget(
235export function resolveInlineCodeFileLinkMeta(241export function resolveInlineCodeFileLinkMeta(
236 codeText: string,242 codeText: string,
237 cwd?: string,243 cwd?: string,
244 baseDir: string | undefined = cwd,
238): MarkdownFileLinkMeta | null {245): MarkdownFileLinkMeta | null {
239 const candidate = inlineCodeFilePathCandidate(codeText);246 const candidate = inlineCodeFilePathCandidate(codeText);
240 if (candidate === null) return null;247 if (candidate === null) return null;
241248
242 const resolved = resolveMarkdownFileLinkMeta(candidate, cwd);249 const resolved = resolveMarkdownFileLinkMeta(candidate, cwd, baseDir);
243 if (resolved) return resolved;250 if (resolved) return resolved;
244251
245 // `Makefile:12` — conventional extensionless names fail the generic252 // `Makefile:12` — conventional extensionless names fail the generic
246 // markdown-link candidate patterns, but here the :line suffix already253 // markdown-link candidate patterns, but here the :line suffix already
247 // marked the span as a file reference.254 // marked the span as a file reference.
248 if (cwd && isConventionalFilePosition(candidate)) {255 if (baseDir && isConventionalFilePosition(candidate)) {
249 return buildFileLinkMetaFromTarget(resolvePathLinkTarget(candidate, cwd), cwd);256 return buildFileLinkMetaFromTarget(resolvePathLinkTarget(candidate, baseDir), cwd);
250 }257 }
251 return null;258 return null;
252}259}
...@@ -276,8 +283,9 @@ function workspaceRelativePath(path: string, workspaceRoot: string | undefined):...@@ -276,8 +283,9 @@ function workspaceRelativePath(path: string, workspaceRoot: string | undefined):
276export function resolveMarkdownFileLinkMeta(283export function resolveMarkdownFileLinkMeta(
277 href: string | undefined,284 href: string | undefined,
278 cwd?: string,285 cwd?: string,
286 baseDir: string | undefined = cwd,
279): MarkdownFileLinkMeta | null {287): MarkdownFileLinkMeta | null {
280 const targetPath = resolveMarkdownFileLinkTarget(href, cwd);288 const targetPath = resolveMarkdownFileLinkTarget(href, cwd, baseDir);
281 if (!targetPath) return null;289 if (!targetPath) return null;
282 return buildFileLinkMetaFromTarget(targetPath, cwd);290 return buildFileLinkMetaFromTarget(targetPath, cwd);
283}291}
apps/web/src/rightPanelStore.ts+1
...@@ -41,6 +41,7 @@ export type RightPanelSurface =...@@ -41,6 +41,7 @@ export type RightPanelSurface =
41 | {41 | {
42 id: `file:${string}`;42 id: `file:${string}`;
43 kind: "file";43 kind: "file";
44 /** Workspace-relative, or absolute for a host file outside the workspace. */
44 relativePath: string;45 relativePath: string;
45 revealLine: number | null;46 revealLine: number | null;
46 revealRequestId: number;47 revealRequestId: number;
apps/web/src/terminal-links.ts+1-1
...@@ -102,7 +102,7 @@ function isWindowsAbsolutePath(value: string): boolean {...@@ -102,7 +102,7 @@ function isWindowsAbsolutePath(value: string): boolean {
102 return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\");102 return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\");
103}103}
104104
105function isAbsolutePath(value: string): boolean {105export function isAbsolutePath(value: string): boolean {
106 return value.startsWith("/") || isWindowsAbsolutePath(value);106 return value.startsWith("/") || isWindowsAbsolutePath(value);
107}107}
108108
docs/internals/environment-auth.md+16-7
...@@ -28,22 +28,31 @@ managed relay connectivity:...@@ -28,22 +28,31 @@ managed relay connectivity:
28The desktop bootstrap credential and command-line administrative bootstrap28The desktop bootstrap credential and command-line administrative bootstrap
29credentials additionally grant `access:read access:write relay:write`.29credentials additionally grant `access:read access:write relay:write`.
3030
31## Host file access
32
33Clients with `orchestration:read` can read files anywhere the environment's server account can
34read, following the environment-wide authorization model rather than introducing per-project
35filesystem permissions. `projects.readFile` accepts an absolute path and returns the text of that
36host file; only workspace-relative paths pass its root check, and `projects.writeFile` never
37accepts an absolute path. Clients use this to show files an agent wrote outside the workspace, such
38as a report in a temp directory, read-only.
39
31## Media preview access40## Media preview access
3241
33Clients with `orchestration:read` can request a `media-file` URL through `assets.createUrl` for42Clients with `orchestration:read` can request a `media-file` URL through `assets.createUrl` for
34supported images and videos anywhere the environment's server account can read. A thread ID43supported images, videos, HTML, and PDF files anywhere the environment's server account can read.
35supplies the workspace for relative paths; absolute paths refer to the environment host, not the44A thread ID supplies the workspace for relative paths; absolute paths refer to the environment
36client. This follows the environment-wide authorization model rather than introducing per-project45host, not the client.
37filesystem permissions.
3846
39[`AssetAccess.ts`](../../apps/server/src/assets/AssetAccess.ts) resolves symlinks, requires a regular47[`AssetAccess.ts`](../../apps/server/src/assets/AssetAccess.ts) resolves symlinks, requires a regular
40file, and validates the resolved file's literal extension. It opens the file and signs its canonical48file, and validates the resolved file's literal extension. It opens the file and signs its canonical
41path and device/inode identity for one hour. The token grants access to that exact file, not adjacent49path and device/inode identity for one hour. The token grants access to that exact file, not adjacent
42files or its containing directory. Serving rechecks the canonical path, media type, and opened50files or its containing directory. Serving rechecks the canonical path, media type, and opened
43descriptor's identity, then streams full or partial responses from that descriptor. Replacing a51descriptor's identity, then streams full or partial responses from that descriptor. Replacing a
44file atomically requires a freshly signed URL; editing it in place does not. The existing workspace52file atomically requires a freshly signed URL; editing it in place does not. Because the token names
45boundary still applies to HTML, PDF, and other workspace previews. Uploaded attachments keep their53one file, an HTML document served this way cannot load sibling assets; the directory-scoped
46separate asset resource.54`workspace-file` resource remains the route for HTML inside the workspace. Uploaded attachments keep
55their separate asset resource.
4756
48Signed asset URLs are bearer credentials. Anyone who obtains a URL and can reach the environment57Signed asset URLs are bearer credentials. Anyone who obtains a URL and can reach the environment
49can fetch that file until it expires. Clients should copy the authored reference, not the temporary58can fetch that file until it expires. Clients should copy the authored reference, not the temporary
docs/user/composer.md+9
...@@ -94,6 +94,15 @@ have a cached copy. Supported video formats and codecs depend on the browser or...@@ -94,6 +94,15 @@ have a cached copy. Supported video formats and codecs depend on the browser or
94Bare paths in ordinary prose and paths inside code blocks stay text. Raw HTML `<video>` tags94Bare paths in ordinary prose and paths inside code blocks stay text. Raw HTML `<video>` tags
95are not supported; use the Markdown embed syntax above.95are not supported; use the Markdown embed syntax above.
9696
97## Files outside the workspace
98
99When 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
101rendered Markdown available as usual; it cannot edit files outside the workspace. HTML and PDF
102files outside the workspace open the same way as ones inside it, including the integrated browser
103where it is available. Because such a file is served on its own, an HTML page outside the workspace
104cannot load scripts, styles, or images from files beside it.
105
97## Changing projects106## Changing projects
98107
99On web and desktop, changing the project from a new thread keeps the current environment when that108On 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([...@@ -15,6 +15,9 @@ export const AssetResource = Schema.Union([
15 threadId: ThreadId,15 threadId: ThreadId,
16 path: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),16 path: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),
17 }),17 }),
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.
18 Schema.TaggedStruct("media-file", {21 Schema.TaggedStruct("media-file", {
19 threadId: ThreadId,22 threadId: ThreadId,
20 path: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),23 path: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),
...@@ -157,7 +160,7 @@ export class AssetPreviewTypeValidationError extends Schema.TaggedErrorClass<Ass...@@ -157,7 +160,7 @@ export class AssetPreviewTypeValidationError extends Schema.TaggedErrorClass<Ass
157) {160) {
158 override get message(): string {161 override get message(): string {
159 return this.resource._tag === "media-file"162 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."
161 : "Only browser documents and images can be previewed.";164 : "Only browser documents and images can be previewed.";
162 }165 }
163}166}
packages/contracts/src/project.ts+2
...@@ -193,6 +193,8 @@ export class ProjectListEntriesError extends Schema.TaggedErrorClass<ProjectList...@@ -193,6 +193,8 @@ export class ProjectListEntriesError extends Schema.TaggedErrorClass<ProjectList
193193
194export const ProjectReadFileInput = Schema.Struct({194export const ProjectReadFileInput = Schema.Struct({
195 cwd: TrimmedNonEmptyString,195 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.
196 relativePath: TrimmedNonEmptyString.check(Schema.isMaxLength(PROJECT_READ_FILE_PATH_MAX_LENGTH)),198 relativePath: TrimmedNonEmptyString.check(Schema.isMaxLength(PROJECT_READ_FILE_PATH_MAX_LENGTH)),
197});199});
198export type ProjectReadFileInput = typeof ProjectReadFileInput.Type;200export type ProjectReadFileInput = typeof ProjectReadFileInput.Type;
packages/shared/src/filePreview.ts+16
...@@ -24,6 +24,12 @@ const IMAGE_MIME_TYPE_BY_EXTENSION = new Map([...@@ -24,6 +24,12 @@ const IMAGE_MIME_TYPE_BY_EXTENSION = new Map([
24 [".webp", "image/webp"],24 [".webp", "image/webp"],
25]);25]);
2626
27const BROWSER_MIME_TYPE_BY_EXTENSION = new Map([
28 [".htm", "text/html"],
29 [".html", "text/html"],
30 [".pdf", "application/pdf"],
31]);
32
27/** Classifies a literal filesystem extension, without URL decoding or suffix removal. */33/** Classifies a literal filesystem extension, without URL decoding or suffix removal. */
28export function mediaMimeTypeFromExtension(extension: string): string | null {34export function mediaMimeTypeFromExtension(extension: string): string | null {
29 if (!/^\.[a-z0-9]+$/i.test(extension)) return null;35 if (!/^\.[a-z0-9]+$/i.test(extension)) return null;
...@@ -33,6 +39,16 @@ export function mediaMimeTypeFromExtension(extension: string): string | null {...@@ -33,6 +39,16 @@ export function mediaMimeTypeFromExtension(extension: string): string | null {
33 );39 );
34}40}
3541
42/** Files the server serves in place from anywhere on its host: media plus browser documents. */
43export 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
36/** Classifies an authored media path or URL. Filesystem validation uses the literal extension. */52/** Classifies an authored media path or URL. Filesystem validation uses the literal extension. */
37export function mediaMimeType(path: string): string | null {53export function mediaMimeType(path: string): string | null {
38 const trimmed = path.trim();54 const trimmed = path.trim();