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
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) { · 716 unmodified lines
782 );783 );
783 }784 }
784 785
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(" · ");
787 794
788 return (795 return (
789 <ReviewHighlighterProvider>796 <ReviewHighlighterProvider>
apps/mobile/src/features/files/filePath.test.ts+14−1
@@ -1,6 +1,19 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2 2
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});
4 17
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 { · 9 unmodified lines
10 return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\");10 return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\");
11}11}
12 12
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}
16 17
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" · 1 unmodified line
2import { useMemo } from "react";2import { useMemo } from "react";
3 3
4import { useAssetUrlState, useRefreshAssetUrl } from "../../state/assets";4import { useAssetUrlState, useRefreshAssetUrl } from "../../state/assets";
5import { isVideoPreviewFile, resolveWorkspaceFilePath } from "./filePath";5import { isAbsolutePath, isVideoPreviewFile, resolveWorkspaceFilePath } from "./filePath";
6 6
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: { · 9 unmodified lines
18 [props.cwd, props.relativePath],18 [props.cwd, props.relativePath],
19 );19 );
20 20
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 { · 165 unmodified lines
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";
171 176
172const WIDE_MARKDOWN_BLOCK_OPTIONS = {177const WIDE_MARKDOWN_BLOCK_OPTIONS = {
@@ -2068,7 +2073,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 1895 unmodified lines
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) { · 15 unmodified lines
2090 return;2095 return;
2091 }2096 }
2092 2097
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( · 43 unmodified lines
44).pipe(Layer.provideMerge(NodeServices.layer));44).pipe(Layer.provideMerge(NodeServices.layer));
45 45
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", () => { · 3 unmodified lines
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", () => { · 44 unmodified lines
104 }).pipe(Effect.provide(testLayer)),106 }).pipe(Effect.provide(testLayer)),
105 );107 );
106 108
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", () => { · 2 unmodified lines
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 { · 13 unmodified lines
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 · 223 unmodified lines
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* ( · 345 unmodified lines
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", () => { · 323 unmodified lines
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 });
337 335
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"; · 50 unmodified lines
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";
54 58
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( · 48 unmodified lines
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−0
@@ -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 · 69 unmodified lines
73 }),76 }),
74 );77 );
75 78
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 · 133 unmodified lines
212 }),262 }),
213 );263 );
214 264
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 @@ · 2 unmodified lines
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";
11 14
12import type {15import type {
@@ -104,7 +107,10 @@ export type WorkspaceFileSystemError = typeof WorkspaceFileSystemError.Type; · 91 unmodified lines
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* () { · 21 unmodified lines
132 const workspacePaths = yield* WorkspacePaths.WorkspacePaths;138 const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
133 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;139 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
134 140
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* () { · 36 unmodified lines
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;
180 216
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 { · 5 unmodified lines
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: { · 40 unmodified lines
52 });53 });
53}54}
54 55
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: { · 10 unmodified lines
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 { · 163 unmodified lines
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 { · 17 unmodified lines
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 { · 823 unmodified lines
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({ · 572 unmodified lines
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({ · 47 unmodified lines
1657 }, [onOpen, targetPath]);1661 }, [onOpen, targetPath]);
1658 1662
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({ · 1 unmodified line
1666 return;1670 return;
1667 }1671 }
1668 handleOpenInEditor();1672 handleOpenInEditor();
1669 }, [handleOpenInEditor, line, onOpenInPanel, onOpenMedia, threadRef, workspaceRelativePath]);1673 }, [handleOpenInEditor, line, onOpenInPanel, onOpenMedia, panelPath, threadRef]);
1670 1674
1671 const handleOpenInBrowser = useCallback(() => {1675 const handleOpenInBrowser = useCallback(() => {
1672 if (!onOpenInBrowser) {1676 if (!onOpenInBrowser) {
@@ -1867,7 +1871,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ · 194 unmodified lines
1867 1871
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( · 80 unmodified lines
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({ · 137 unmodified lines
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({ · 101 unmodified lines
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({ · 16 unmodified lines
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({ · 36 unmodified lines
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);
2313 2323
2314 return (2324 return (
2315 <MarkdownFileLink2325 <MarkdownFileLink
@@ -2317,7 +2327,7 @@ function ChatMarkdown({ · 1 unmodified line
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({ · 110 unmodified lines
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({ · 139 unmodified lines
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( · 497 unmodified lines
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 · 33 unmodified lines
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({ · 752 unmodified lines
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({ · 5 unmodified lines
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({ · 9 unmodified lines
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({ · 38 unmodified lines
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({ · 60 unmodified lines
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({ · 10 unmodified lines
954 }),963 }),
955 );964 );
956 })();965 })();
957 }, [absolutePath, createAssetUrl, environmentHttpBaseUrl, openPreview, threadRef]);966 }, [absolutePath, createAssetUrl, cwd, environmentHttpBaseUrl, openPreview, threadRef]);
958 967
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({ · 169 unmodified lines
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", () => { · 13 unmodified lines
14 });14 });
15 15
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 @@
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}
6 10
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−0
@@ -275,6 +275,22 @@ describe("resolveMarkdownFileLinkTarget", () => { · 274 unmodified lines
275 });275 });
276});276});
277 277
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 { · 188 unmodified lines
189 return !POSITION_ONLY_PATTERN.test(rest);189 return !POSITION_ONLY_PATTERN.test(rest);
190}190}
191 191
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( · 24 unmodified lines
222 return pathWithPosition;228 return pathWithPosition;
223 }229 }
224 230
225 if (!cwd) return null;231 if (!baseDir) return null;
226 return resolvePathLinkTarget(pathWithPosition, cwd);232 return resolvePathLinkTarget(pathWithPosition, baseDir);
227}233}
228 234
229/**235/**
@@ -235,18 +241,19 @@ export function resolveMarkdownFileLinkTarget( · 5 unmodified lines
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;
241 248
242 const resolved = resolveMarkdownFileLinkMeta(candidate, cwd);249 const resolved = resolveMarkdownFileLinkMeta(candidate, cwd, baseDir);
243 if (resolved) return resolved;250 if (resolved) return resolved;
244 251
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): · 23 unmodified lines
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−0
@@ -41,6 +41,7 @@ export type RightPanelSurface = · 40 unmodified lines
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 { · 101 unmodified lines
102 return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\");102 return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\");
103}103}
104 104
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}
108 108
docs/internals/environment-auth.md+16−7
@@ -28,22 +28,31 @@ managed relay connectivity: · 27 unmodified lines
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`.
30 30
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
32 41
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.
38 46
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.
47 56
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−0
@@ -94,6 +94,15 @@ have a cached copy. Supported video formats and codecs depend on the browser or · 93 unmodified lines
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.
96 96
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
98 107
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([ · 14 unmodified lines
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 · 136 unmodified lines
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−0
@@ -193,6 +193,8 @@ export class ProjectListEntriesError extends Schema.TaggedErrorClass<ProjectList · 192 unmodified lines
193 193
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−0
@@ -24,6 +24,12 @@ const IMAGE_MIME_TYPE_BY_EXTENSION = new Map([ · 23 unmodified lines
24 [".webp", "image/webp"],24 [".webp", "image/webp"],
25]);25]);
26 26
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 { · 3 unmodified lines
33 );39 );
34}40}
35 41
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();