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