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