From f46a709ee487e1e6e04423fcb7032a26eb103129 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 1 Sep 2026 19:31:55 -0700 Subject: [PATCH] feat(files): open markdown, HTML, and PDF files outside the workspace (#9140) Co-authored-by: Claude Code --- .../features/files/ThreadFilesRouteScreen.tsx | 11 +++- .../src/features/files/filePath.test.ts | 15 ++++- apps/mobile/src/features/files/filePath.ts | 9 ++- .../features/files/workspaceFileAssetUrl.ts | 14 ++-- .../src/features/threads/ThreadFeed.tsx | 38 ++++++++++- apps/server/src/assets/AssetAccess.test.ts | 10 +-- apps/server/src/assets/AssetAccess.ts | 6 +- apps/server/src/http.test.ts | 16 ++--- apps/server/src/http.ts | 9 ++- .../src/workspace/WorkspaceFileSystem.test.ts | 66 +++++++++++++++++++ .../src/workspace/WorkspaceFileSystem.ts | 54 +++++++++++++-- apps/web/src/browser/openFileInPreview.ts | 10 ++- apps/web/src/components/ChatMarkdown.tsx | 56 +++++++++------- apps/web/src/components/RightPanelTabs.tsx | 4 +- .../src/components/files/FilePreviewPanel.tsx | 34 ++++++---- .../web/src/components/files/filePath.test.ts | 20 +++++- apps/web/src/components/files/filePath.ts | 17 ++++- apps/web/src/markdown-links.test.ts | 16 +++++ apps/web/src/markdown-links.ts | 20 ++++-- apps/web/src/rightPanelStore.ts | 1 + apps/web/src/terminal-links.ts | 2 +- docs/internals/environment-auth.md | 23 +++++-- docs/user/composer.md | 9 +++ packages/contracts/src/assets.ts | 5 +- packages/contracts/src/project.ts | 2 + packages/shared/src/filePreview.ts | 16 +++++ 26 files changed, 394 insertions(+), 89 deletions(-) diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 95177ed3e92ab5585132097f2d06f01ebedac874..c682436cad832428ad2b984f9b89760fc1484725 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -60,6 +60,7 @@ import { WorkspaceFileVideoPreview } from "./WorkspaceFileVideoPreview"; import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview"; import { basename, + isAbsolutePath, isMarkdownPreviewFile, isSvgImagePreviewFile, isVideoPreviewFile, @@ -782,8 +783,14 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { ); } - const parentDir = relativePath.split("/").slice(0, -1).join("/"); - const headerSubtitle = [projectName, parentDir].filter(Boolean).join(" · "); + const parentDir = relativePath.slice( + 0, + Math.max(relativePath.lastIndexOf("/"), relativePath.lastIndexOf("\\"), 0), + ); + // A host file outside the workspace is not under the project name. + const headerSubtitle = isAbsolutePath(relativePath) + ? parentDir + : [projectName, parentDir].filter(Boolean).join(" · "); return ( diff --git a/apps/mobile/src/features/files/filePath.test.ts b/apps/mobile/src/features/files/filePath.test.ts index 376068c211575ed3b7c55673b2f2faee84d1bb3b..cd453b1d2c989b720298fafc59a08a33dff23f43 100644 --- a/apps/mobile/src/features/files/filePath.test.ts +++ b/apps/mobile/src/features/files/filePath.test.ts @@ -1,6 +1,19 @@ import { describe, expect, it } from "vite-plus/test"; -import { isSvgImagePreviewFile, resolveWorkspaceRelativeFilePath } from "./filePath"; +import { + fileRoutePathSegments, + isSvgImagePreviewFile, + resolveWorkspaceRelativeFilePath, +} from "./filePath"; + +describe("fileRoutePathSegments", () => { + it("round-trips workspace-relative and host paths through the route", () => { + expect(fileRoutePathSegments("src/main.ts")).toEqual(["src", "main.ts"]); + expect(fileRoutePathSegments("/tmp/t3-cleanup/report.md").join("/")).toBe( + "/tmp/t3-cleanup/report.md", + ); + }); +}); describe("resolveWorkspaceRelativeFilePath", () => { it("keeps normalized workspace-relative paths", () => { diff --git a/apps/mobile/src/features/files/filePath.ts b/apps/mobile/src/features/files/filePath.ts index 76672ec5658d8ce377040edd750d91f2b3992f1c..f2d377de126f4f5289956539dc4f85ff18d97c6b 100644 --- a/apps/mobile/src/features/files/filePath.ts +++ b/apps/mobile/src/features/files/filePath.ts @@ -10,10 +10,17 @@ function isWindowsAbsolutePath(value: string): boolean { return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\"); } -function isAbsolutePath(value: string): boolean { +/** A file route holding an absolute path shows a host file outside the workspace. */ +export function isAbsolutePath(value: string): boolean { return value.startsWith("/") || isWindowsAbsolutePath(value); } +/** Route segments that `normalizeRoutePath` joins back into the same path, root included. */ +export function fileRoutePathSegments(path: string): string[] { + const segments = path.split("/").filter((segment) => segment.length > 0); + return path.startsWith("/") ? ["", ...segments] : segments; +} + function isWindowsPathStyle(value: string): boolean { return isWindowsAbsolutePath(value) || /^[A-Za-z]:\\/.test(value); } diff --git a/apps/mobile/src/features/files/workspaceFileAssetUrl.ts b/apps/mobile/src/features/files/workspaceFileAssetUrl.ts index eb2ba93b4979f71bc189e16ab2179e42ea9d813e..8ea903f831ba2ce216369ee563fe24c787d0afe2 100644 --- a/apps/mobile/src/features/files/workspaceFileAssetUrl.ts +++ b/apps/mobile/src/features/files/workspaceFileAssetUrl.ts @@ -2,7 +2,7 @@ import type { AssetResource, EnvironmentId, ThreadId } from "@t3tools/contracts" import { useMemo } from "react"; import { useAssetUrlState, useRefreshAssetUrl } from "../../state/assets"; -import { isVideoPreviewFile, resolveWorkspaceFilePath } from "./filePath"; +import { isAbsolutePath, isVideoPreviewFile, resolveWorkspaceFilePath } from "./filePath"; export function useWorkspaceFileAssetUrlState(props: { readonly cwd: string | null; @@ -18,16 +18,22 @@ export function useWorkspaceFileAssetUrlState(props: { [props.cwd, props.relativePath], ); + // Videos stream from an exact-file URL, and so does anything outside the + // workspace, where no workspace-scoped URL can exist. + const relativePath = props.relativePath; const resource = useMemo( () => - absolutePath !== null && props.threadId !== null + absolutePath !== null && relativePath !== null && props.threadId !== null ? { - _tag: isVideoPreviewFile(absolutePath) ? "media-file" : "workspace-file", + _tag: + isVideoPreviewFile(absolutePath) || isAbsolutePath(relativePath) + ? "media-file" + : "workspace-file", threadId: props.threadId, path: absolutePath, } : null, - [absolutePath, props.threadId], + [absolutePath, relativePath, props.threadId], ); const state = useAssetUrlState(props.environmentId, resource); const refresh = useRefreshAssetUrl(props.environmentId, resource); diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 3cbf02efa2ba983390edefef28775532b9dd05c5..5c2c4faba153c67f27b4aaad1a9ea8c79ffbbac5 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -166,7 +166,12 @@ import { import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; import { usePreparedConnection } from "../../state/session"; import * as Option from "effect/Option"; -import { resolveWorkspaceRelativeFilePath } from "../files/filePath"; +import { + basename, + fileRoutePathSegments, + isAbsolutePath, + resolveWorkspaceRelativeFilePath, +} from "../files/filePath"; import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize"; const WIDE_MARKDOWN_BLOCK_OPTIONS = { @@ -2068,7 +2073,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { navigation.navigate("ThreadFile", { environmentId: String(props.environmentId), threadId: String(props.threadId), - path: relativePath.split("/").filter((segment) => segment.length > 0), + path: fileRoutePathSegments(relativePath), ...(presentation.line ? { line: String(presentation.line) } : {}), }); return; @@ -2090,6 +2095,35 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { return; } + // A host file outside the workspace, such as a report an agent wrote to + // a temp directory, opens read-only in the file screen. + if (presentation.kind === "file" && isAbsolutePath(presentation.path)) { + void Haptics.selectionAsync(); + if (isPdfFile({ name: presentation.path })) { + setExpandedFile( + (current) => + current ?? { + kind: "pdf", + name: basename(presentation.path), + environmentId: props.environmentId, + resource: { + _tag: "media-file", + threadId: props.threadId, + path: presentation.path, + }, + }, + ); + return; + } + navigation.navigate("ThreadFile", { + environmentId: String(props.environmentId), + threadId: String(props.threadId), + path: fileRoutePathSegments(presentation.path), + ...(presentation.line ? { line: String(presentation.line) } : {}), + }); + return; + } + if (presentation.kind !== "file" && presentation.href) { if (/^https?:\/\//i.test(presentation.href) && isPdfFile({ name: presentation.href })) { setExpandedFile( diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 4a47a17fabc3d30d623378b2aa10347fab4d74e2..8d7c696cd6255b72a9140ebf237b5979b366a1b0 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -44,7 +44,7 @@ const testLayer = Layer.mergeAll( ).pipe(Layer.provideMerge(NodeServices.layer)); describe("AssetAccess", () => { - it.effect("issues exact URLs for images and videos outside the workspace", () => + it.effect("issues exact URLs for media and browser documents outside the workspace", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -54,6 +54,8 @@ describe("AssetAccess", () => { ["screenshot.png", "image/png"], ["recording.mp4", "video/mp4"], ["recording.webm", "video/webm"], + ["report.html", "text/html"], + ["report.pdf", "application/pdf"], ] as const) { const filePath = path.join(outside, name); yield* fs.writeFileString(filePath, "media"); @@ -104,12 +106,12 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(testLayer)), ); - it.effect("rejects non-media files, disguised targets, and directories", () => + it.effect("rejects non-previewable files, disguised targets, and directories", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-validation-" }); - for (const name of ["report.html", "secret.txt", "secret.%70ng", "secret.png#private.txt"]) { + for (const name of ["report.md", "secret.txt", "secret.%70ng", "secret.png#private.txt"]) { const filePath = path.join(root, name); yield* fs.writeFileString(filePath, "not media"); const error = yield* issueAssetUrl({ @@ -118,7 +120,7 @@ describe("AssetAccess", () => { expect(error).toBeInstanceOf(AssetPreviewTypeValidationError); } const disguisedPath = path.join(root, "disguised.png"); - yield* fs.symlink(path.join(root, "report.html"), disguisedPath); + yield* fs.symlink(path.join(root, "secret.txt"), disguisedPath); const disguisedError = yield* issueAssetUrl({ resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: disguisedPath }, }).pipe(Effect.flip); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 05801acca88f3bef3731a302b30199dc366ca3c5..ad7cb273cf073d0596dc5699afcfd5eb754af075 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -14,9 +14,9 @@ import { AssetWorkspaceRootNormalizationError, } from "@t3tools/contracts"; import { + hostPreviewMimeTypeFromExtension, isWorkspaceImagePreviewPath, isWorkspacePreviewEntryPath, - mediaMimeTypeFromExtension, WORKSPACE_BROWSER_PREVIEW_EXTENSIONS, WORKSPACE_IMAGE_PREVIEW_EXTENSIONS, } from "@t3tools/shared/filePreview"; @@ -246,7 +246,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i if (!canonicalFile) { return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource }); } - if (mediaMimeTypeFromExtension(path.extname(canonicalFile)) === null) { + if (hostPreviewMimeTypeFromExtension(path.extname(canonicalFile)) === null) { return yield* new AssetPreviewTypeValidationError({ resource: input.resource }); } const identity = yield* openMediaFile(canonicalFile).pipe( @@ -598,7 +598,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( Effect.orElseSucceed(() => null), ); if (canonicalFile !== claims.filePath) return null; - const mimeType = mediaMimeTypeFromExtension(path.extname(canonicalFile)); + const mimeType = hostPreviewMimeTypeFromExtension(path.extname(canonicalFile)); if (!mimeType) return null; const file = yield* openMediaFile(canonicalFile, claims).pipe( Effect.tapError((cause) => diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index 9d54adef8eda73d49cd8fbdd23bb2abd8085440d..6b0940856659687d3cb59bad7229bd4d43b09a1e 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -324,15 +324,13 @@ describe("assetResponseHeaders", () => { "X-Content-Type-Options": "nosniff", }); }); - it("declares utf-8 for HTML assets so non-ASCII content renders correctly", () => { - expect(assetResponseHeaders("/workspace/page.html")).toHaveProperty( - "Content-Type", - "text/html; charset=utf-8", - ); - expect(assetResponseHeaders("/workspace/PAGE.HTM")).toHaveProperty( - "Content-Type", - "text/html; charset=utf-8", - ); + it("serves HTML assets as utf-8 inside a sandboxed origin", () => { + for (const path of ["/workspace/page.html", "/workspace/PAGE.HTM", "/tmp/report.html"]) { + expect(assetResponseHeaders(path)).toMatchObject({ + "Content-Type": "text/html; charset=utf-8", + "Content-Security-Policy": "sandbox allow-scripts allow-forms allow-popups allow-modals", + }); + } }); it("downloads uploaded documents without executing their content", () => { diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 8b7c2ad3a61b60ed8755b7ce1b2c964e2636a2b9..4d5865335a392f599cc90b6f3b8a73c07f30067c 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -51,6 +51,10 @@ const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"]; const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; +// HTML previews are agent output, not the app. The sandbox gives the document an +// opaque origin: scripts run, but same-origin cookies, storage, and API calls are +// out of reach. Relative sibling assets still load through their signed URLs. +const HTML_CONTENT_SECURITY_POLICY = "sandbox allow-scripts allow-forms allow-popups allow-modals"; // Types a browser may render as a document if a proxy strips the disposition // header. Downloads of these fall back to octet-stream. @@ -105,7 +109,10 @@ export function assetResponseHeaders( : inlineVideoMimeType !== undefined && isSafeInlineVideoMimeType(inlineVideoMimeType) ? { "Content-Type": inlineVideoMimeType } : lowerPath.endsWith(".html") || lowerPath.endsWith(".htm") - ? { "Content-Type": "text/html; charset=utf-8" } + ? { + "Content-Type": "text/html; charset=utf-8", + "Content-Security-Policy": HTML_CONTENT_SECURITY_POLICY, + } : {}), ...(!options?.download && lowerPath.endsWith(".svg") ? { "Content-Security-Policy": SVG_CONTENT_SECURITY_POLICY } diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts index cecffbc1993d0c585d1a8f3d6aa3ffdb7920b84e..17c7f0bb050ce390a6313a96c195eb7c884a04f3 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -1,3 +1,6 @@ +// @effect-diagnostics nodeBuiltinImport:off - FileSystem cannot create a FIFO. +import * as NodeChildProcess from "node:child_process"; + import * as NodeServices from "@effect/platform-node/NodeServices"; import { it, describe, expect } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -73,6 +76,53 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i }), ); + it.effect("reads host files outside the workspace root by absolute path", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const outsideDir = yield* makeTempDir; + yield* writeTextFile(outsideDir, "cleanup-report.md", "# Report\n"); + const absolutePath = path.join(outsideDir, "cleanup-report.md"); + + const result = yield* workspaceFileSystem.readFile({ + cwd, + relativePath: absolutePath, + }); + + expect(result).toEqual({ + relativePath: absolutePath, + contents: "# Report\n", + byteLength: 9, + truncated: false, + }); + }), + ); + + it.effect("rejects a FIFO without blocking on open", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const outsideDir = yield* makeTempDir; + const fifoPath = path.join(outsideDir, "pipe"); + yield* Effect.promise( + () => + new Promise((resolve, reject) => + NodeChildProcess.execFile("mkfifo", [fifoPath], (error) => + error ? reject(error) : resolve(), + ), + ), + ); + + const error = yield* workspaceFileSystem + .readFile({ cwd, relativePath: fifoPath }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspacePathNotFileError); + }), + ); + it.effect("rejects reads outside the workspace root", () => Effect.gen(function* () { const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; @@ -212,6 +262,22 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i }), ); + it.effect("rejects writes by absolute path", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const outsideDir = yield* makeTempDir; + const absolutePath = path.join(outsideDir, "cleanup-report.md"); + + const error = yield* workspaceFileSystem + .writeFile({ cwd, relativePath: absolutePath, contents: "# Edited\n" }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkspacePaths.WorkspacePathOutsideRootError); + }), + ); + it.effect("invalidates workspace entry search cache after writes", () => Effect.gen(function* () { const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts index e2dc9cbbb3904e934b0a67da7475977018dd1dcb..055ab51feaeb961c826badc13c17fd4af3d8dd96 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -3,10 +3,13 @@ * WorkspaceFileSystem - Effect service contract for workspace file mutations. * * Owns workspace-root-relative file read/write operations and their associated - * safety checks and cache invalidation hooks. + * safety checks and cache invalidation hooks. Reads also accept absolute host + * paths so clients can show files an agent left outside the workspace; writes + * never leave the root. * * @module WorkspaceFileSystem */ +import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; import type { @@ -104,7 +107,10 @@ export type WorkspaceFileSystemError = typeof WorkspaceFileSystemError.Type; export class WorkspaceFileSystem extends Context.Service< WorkspaceFileSystem, { - /** Read a UTF-8 text file relative to the workspace root. */ + /** + * Read a UTF-8 text file relative to the workspace root, or any host file by + * absolute path. + */ readonly readFile: ( input: ProjectReadFileInput, ) => Effect.Effect< @@ -132,9 +138,31 @@ export const make = Effect.gen(function* () { const workspacePaths = yield* WorkspacePaths.WorkspacePaths; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const readFile: WorkspaceFileSystem["Service"]["readFile"] = Effect.fn( - "WorkspaceFileSystem.readFile", - )(function* (input) { + /** + * Resolves the file a read targets. Workspace-relative paths must stay inside the + * root, symlinks included. An absolute path reads a host file in place, such as a + * report an agent wrote to a temp directory; it gets no root check. + */ + const resolveReadTarget = Effect.fn("WorkspaceFileSystem.resolveReadTarget")(function* ( + input: ProjectReadFileInput, + ) { + const requestedPath = input.relativePath.trim(); + if (path.isAbsolute(requestedPath)) { + const realTargetPath = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(requestedPath), + catch: (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: requestedPath, + operationPath: requestedPath, + operation: "realpath-target", + cause, + }), + }); + return { relativePath: requestedPath, realTargetPath }; + } + const target = yield* workspacePaths.resolveRelativePathWithinRoot({ workspaceRoot: input.cwd, relativePath: input.relativePath, @@ -177,10 +205,24 @@ export const make = Effect.gen(function* () { resolvedPath: realTargetPath, }); } + return { relativePath: target.relativePath, realTargetPath }; + }); + + const readFile: WorkspaceFileSystem["Service"]["readFile"] = Effect.fn( + "WorkspaceFileSystem.readFile", + )(function* (input) { + const target = yield* resolveReadTarget(input); + const realTargetPath = target.realTargetPath; return yield* Effect.acquireUseRelease( Effect.tryPromise({ - try: () => NodeFSP.open(realTargetPath, "r"), + // Non-blocking so a FIFO cannot hang the open; the stat below rejects + // it. Regular files ignore the flag. Windows lacks it. + try: () => + NodeFSP.open( + realTargetPath, + NodeFS.constants.O_RDONLY | (NodeFS.constants.O_NONBLOCK ?? 0), + ), catch: (cause) => new WorkspaceFileSystemOperationError({ workspaceRoot: input.cwd, diff --git a/apps/web/src/browser/openFileInPreview.ts b/apps/web/src/browser/openFileInPreview.ts index b89b87c92898146c5fdc9ec02b4ae69ece91be37..4e540a9a0963dae445b8557be473b520c07bfe3a 100644 --- a/apps/web/src/browser/openFileInPreview.ts +++ b/apps/web/src/browser/openFileInPreview.ts @@ -6,6 +6,7 @@ import type { PreviewSessionSnapshot, ScopedThreadRef, } from "@t3tools/contracts"; +import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; import { type AtomCommandResult, mapAtomCommandResult, @@ -52,9 +53,14 @@ export async function openUrlInPreview(input: { }); } +/** + * Opens a browser document in the integrated browser. Inside the workspace the + * page may load sibling assets; a file outside it is served on its own. + */ export async function openFileInPreview(input: { readonly threadRef: ScopedThreadRef; readonly filePath: string; + readonly workspaceRoot: string | undefined; readonly httpBaseUrl: string; readonly createAssetUrl: (input: { readonly environmentId: EnvironmentId; @@ -71,11 +77,13 @@ export async function openFileInPreview(input: { ), ); } + const insideWorkspace = + mediaFileReference(input.filePath, input.workspaceRoot).relativePath !== undefined; const assetResult = await input.createAssetUrl({ environmentId: input.threadRef.environmentId, input: { resource: { - _tag: "workspace-file", + _tag: insideWorkspace ? "workspace-file" : "media-file", threadId: input.threadRef.threadId, path: input.filePath, }, diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 6f7327b3ab3e4a1b0f8f29d5278f6b803e99029f..19496468c53c8381d446aea0d931ad54073ddb9c 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -164,7 +164,7 @@ import { } from "~/lib/openPullRequestLink"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { isPreviewSupportedInRuntime } from "../previewStateStore"; -import { resolvePathLinkTarget } from "../terminal-links"; +import { isAbsolutePath, resolvePathLinkTarget } from "../terminal-links"; import { isBrowserPreviewFile, openFileInPreview, @@ -188,6 +188,8 @@ interface ChatMarkdownProps { parseRawHtml?: boolean; /** Append a prompt that invokes a newly created artifact-template skill. */ onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined; + /** Directory that anchors relative links and images; defaults to `cwd`. Set + to the file's own directory when rendering a markdown file. */ imageBaseDir?: string | undefined; onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; extraRemarkPlugins?: NonNullable; @@ -1017,14 +1019,16 @@ interface MarkdownFileLinkProps { targetPath: string; iconPath: string; displayPath: string; - workspaceRelativePath: string | null; + /** What the files panel opens: workspace-relative inside the workspace, the + absolute host path outside it, null when the panel cannot show the file. */ + panelPath: string | null; line?: number | undefined; label: string; copyMarkdown: string; theme: "light" | "dark"; threadRef?: ScopedThreadRef | undefined; onOpen?: ((targetPath: string) => Promise>) | undefined; - onOpenInPanel: (workspaceRelativePath: string, line: number | undefined) => void; + onOpenInPanel: (panelPath: string, line: number | undefined) => void; openInEditorMenuLabel: string; onOpenInBrowser?: (() => Promise>) | undefined; onOpenMedia?: (() => void) | undefined; @@ -1603,7 +1607,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ targetPath, iconPath, displayPath, - workspaceRelativePath, + panelPath, line, label, copyMarkdown, @@ -1657,8 +1661,8 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ }, [onOpen, targetPath]); const handleOpenInFilePreview = useCallback(() => { - if (threadRef && workspaceRelativePath) { - onOpenInPanel(workspaceRelativePath, line); + if (threadRef && panelPath) { + onOpenInPanel(panelPath, line); return; } if (onOpenMedia) { @@ -1666,7 +1670,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ return; } handleOpenInEditor(); - }, [handleOpenInEditor, line, onOpenInPanel, onOpenMedia, threadRef, workspaceRelativePath]); + }, [handleOpenInEditor, line, onOpenInPanel, onOpenMedia, panelPath, threadRef]); const handleOpenInBrowser = useCallback(() => { if (!onOpenInBrowser) { @@ -1867,7 +1871,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ const canOpenInEditor = onOpen !== undefined; const canOpenInBrowser = onOpenInBrowser !== undefined; - const canOpenInPanel = threadRef !== undefined && Boolean(workspaceRelativePath); + const canOpenInPanel = threadRef !== undefined && Boolean(panelPath); const hasPrimaryAction = hasMarkdownFilePrimaryAction({ canOpenInEditor, canOpenInBrowser, @@ -1954,7 +1958,7 @@ function areMarkdownFileLinkPropsEqual( previous.targetPath === next.targetPath && previous.iconPath === next.iconPath && previous.displayPath === next.displayPath && - previous.workspaceRelativePath === next.workspaceRelativePath && + previous.panelPath === next.panelPath && previous.line === next.line && previous.label === next.label && previous.copyMarkdown === next.copyMarkdown && @@ -2098,24 +2102,24 @@ function ChatMarkdown({ for (const href of extractMarkdownLinkHrefs(renderCodexFileCitationsAsMarkdown(text))) { const normalizedHref = normalizeMarkdownLinkHrefKey(href); if (metaByHref.has(normalizedHref)) continue; - const meta = resolveMarkdownFileLinkMeta(normalizedHref, cwd); + const meta = resolveMarkdownFileLinkMeta(normalizedHref, cwd, imageBaseDir ?? cwd); if (meta) { metaByHref.set(normalizedHref, meta); } } return metaByHref; - }, [cwd, text]); + }, [cwd, imageBaseDir, text]); const inlineCodeFileLinkMetaByText = useMemo(() => { const metaByText = new Map(); for (const span of extractInlineCodeSpans(text)) { if (metaByText.has(span)) continue; - const meta = resolveInlineCodeFileLinkMeta(span, cwd); + const meta = resolveInlineCodeFileLinkMeta(span, cwd, imageBaseDir ?? cwd); if (meta) { metaByText.set(span, meta); } } return metaByText; - }, [cwd, text]); + }, [cwd, imageBaseDir, text]); const fileLinkParentSuffixByPath = useMemo(() => { const filePaths = [ ...[...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath), @@ -2223,12 +2227,13 @@ function ChatMarkdown({ return openFileInPreview({ threadRef, filePath: path, + workspaceRoot: cwd, httpBaseUrl: preparedConnection.value.httpBaseUrl, createAssetUrl, openPreview, }); }, - [createAssetUrl, openPreview, preparedConnection, threadRef], + [createAssetUrl, cwd, openPreview, preparedConnection, threadRef], ); const findWorkspaceBasenameMatch = useCallback( async (workspaceRelativePath: string) => { @@ -2251,23 +2256,23 @@ function ChatMarkdown({ [cwd, environmentId, searchProjectEntries], ); // A bare filename resolves to the workspace root, which is rarely where the - // file is, so ask the index before opening. + // file is, so ask the index before opening. Absolute host paths open as-is. const openFileInPanel = useCallback( - (workspaceRelativePath: string, line: number | undefined) => { + (panelPath: string, line: number | undefined) => { if (!threadRef) return; // Claimed on every open so a synchronous one supersedes a lookup already // in flight. const isLatestLookup = claimWorkspaceBasenameLookup(); const openAt = (path: string) => useRightPanelStore.getState().openFile(threadRef, path, line); - if (!cwd || !needsWorkspaceBasenameLookup(workspaceRelativePath)) { - openAt(workspaceRelativePath); + if (!cwd || !needsWorkspaceBasenameLookup(panelPath)) { + openAt(panelPath); return; } void (async () => { - const match = await findWorkspaceBasenameMatch(workspaceRelativePath); + const match = await findWorkspaceBasenameMatch(panelPath); if (!isLatestLookup()) return; - openAt(match ?? workspaceRelativePath); + openAt(match ?? panelPath); })(); }, [cwd, findWorkspaceBasenameMatch, threadRef], @@ -2310,6 +2315,11 @@ function ChatMarkdown({ mediaMimeTypeFromExtension( fileLinkMeta.basename.slice(fileLinkMeta.basename.lastIndexOf(".")), ) !== null; + // Media outside the workspace keeps the expanded preview; other host + // files (a report in a temp dir) open read-only in the files panel. + const panelPath = + fileLinkMeta.workspaceRelativePath ?? + (!canPreviewMedia && isAbsolutePath(fileLinkMeta.filePath) ? fileLinkMeta.filePath : null); return ( & { threadRef: ScopedThreadRef; + readOnly: boolean; }) { const saveCoordinator = useFileSaveCoordinator({ environmentId, @@ -819,15 +821,19 @@ function RenderedMarkdownSurface({ cwd={cwd} relativePath={relativePath} threadRef={threadRef} - onTaskListChange={({ markerOffset, checked }) => { - const currentContents = - getOptimisticProjectFileQueryData(environmentId, cwd, relativePath)?.contents ?? - contents; - const nextContents = setMarkdownTaskChecked(currentContents, markerOffset, checked); - if (nextContents === currentContents) return; - setProjectFileQueryData(environmentId, cwd, relativePath, nextContents); - saveCoordinator.change(nextContents); - }} + onTaskListChange={ + readOnly + ? undefined + : ({ markerOffset, checked }) => { + const currentContents = + getOptimisticProjectFileQueryData(environmentId, cwd, relativePath)?.contents ?? + contents; + const nextContents = setMarkdownTaskChecked(currentContents, markerOffset, checked); + if (nextContents === currentContents) return; + setProjectFileQueryData(environmentId, cwd, relativePath, nextContents); + saveCoordinator.change(nextContents); + } + } /> ); @@ -872,6 +878,8 @@ export default function FilePreviewPanel({ const isVideo = relativePath !== null && isWorkspaceVideoPreviewPath(relativePath); const isImage = relativePath !== null && !isVideo && isWorkspaceImagePreviewPath(relativePath); const isMedia = isImage || isVideo; + // A file outside the workspace (an absolute path) is shown, never edited. + const isHostFile = relativePath !== null && isAbsolutePath(relativePath); const file = useProjectFileQuery(environmentId, cwd, relativePath, !isMedia); const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen); // Reading markdown rendered is a preference, not a property of one file. Keeping @@ -938,6 +946,7 @@ export default function FilePreviewPanel({ const result = await openFileInPreview({ threadRef, filePath: absolutePath, + workspaceRoot: cwd, httpBaseUrl: environmentHttpBaseUrl, createAssetUrl, openPreview, @@ -954,7 +963,7 @@ export default function FilePreviewPanel({ }), ); })(); - }, [absolutePath, createAssetUrl, environmentHttpBaseUrl, openPreview, threadRef]); + }, [absolutePath, createAssetUrl, cwd, environmentHttpBaseUrl, openPreview, threadRef]); return (
@@ -1130,9 +1139,10 @@ export default function FilePreviewPanel({ relativePath={relativePath} threadRef={threadRef} contents={file.data.contents} + readOnly={isHostFile} onPendingChange={onPendingChange} /> - ) : file.data.truncated ? ( + ) : file.data.truncated || isHostFile ? ( { }); it("normalizes repeated separators", () => { - expect(fileBreadcrumbs("workspace", "/src//index.ts").map((crumb) => crumb.label)).toEqual([ + expect(fileBreadcrumbs("workspace", "src//index.ts").map((crumb) => crumb.label)).toEqual([ "workspace", "src", "index.ts", ]); }); + + it("starts host paths outside the workspace at the filesystem root", () => { + expect(fileBreadcrumbs("t3code", "/tmp/t3-cleanup/report.md")).toEqual([ + { label: "tmp", path: "/tmp", kind: "directory" }, + { label: "t3-cleanup", path: "/tmp/t3-cleanup", kind: "directory" }, + { label: "report.md", path: "/tmp/t3-cleanup/report.md", kind: "file" }, + ]); + expect(fileBreadcrumbs("t3code", "C:\\Temp\\report.md")).toEqual([ + { label: "C:", path: "C:", kind: "directory" }, + { label: "Temp", path: "C:\\Temp", kind: "directory" }, + { label: "report.md", path: "C:\\Temp\\report.md", kind: "file" }, + ]); + expect(fileBreadcrumbs("t3code", "\\\\server\\share\\report.md").map((c) => c.path)).toEqual([ + "\\\\server", + "\\\\server\\share", + "\\\\server\\share\\report.md", + ]); + }); }); diff --git a/apps/web/src/components/files/filePath.ts b/apps/web/src/components/files/filePath.ts index 66ede58119b8942e61263fdff607a7dcb2f21a33..0598239cf7df821980e565b70409298d0c79281b 100644 --- a/apps/web/src/components/files/filePath.ts +++ b/apps/web/src/components/files/filePath.ts @@ -1,16 +1,27 @@ +import { isWindowsAbsolutePath } from "@t3tools/shared/path"; + +import { isAbsolutePath } from "~/terminal-links"; + export interface FileBreadcrumb { label: string; path: string; kind: "project" | "directory" | "file"; } +/** + * Crumbs for a workspace-relative path start at the project. An absolute host + * path is outside the workspace, so its crumbs start at the filesystem root. + */ export function fileBreadcrumbs(projectName: string, relativePath: string): FileBreadcrumb[] { - const parts = relativePath.split("/").filter(Boolean); + const hostPath = isAbsolutePath(relativePath); + const separator = isWindowsAbsolutePath(relativePath) ? "\\" : "/"; + const parts = relativePath.split(/[\\/]/).filter(Boolean); + const root = relativePath.startsWith("\\\\") ? "\\\\" : hostPath && separator === "/" ? "/" : ""; return [ - { label: projectName, path: "", kind: "project" }, + ...(hostPath ? [] : [{ label: projectName, path: "", kind: "project" as const }]), ...parts.map((part, index) => ({ label: part, - path: parts.slice(0, index + 1).join("/"), + path: root + parts.slice(0, index + 1).join(separator), kind: index === parts.length - 1 ? ("file" as const) : ("directory" as const), })), ]; diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index fc77f48c2ca65c4d2428f6630f9ea619d129a466..3b3bad7b25e3b62af62e621c1ee80707c3b8f3eb 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -275,6 +275,22 @@ describe("resolveMarkdownFileLinkTarget", () => { }); }); +describe("relative links inside a rendered host file", () => { + it("anchor to the file's directory while workspace membership follows cwd", () => { + const meta = resolveMarkdownFileLinkMeta("appendix.md", "/repo", "/tmp/report"); + expect(meta).toMatchObject({ + filePath: "/tmp/report/appendix.md", + workspaceRelativePath: null, + }); + const inline = resolveInlineCodeFileLinkMeta("Makefile:12", "/repo", "/tmp/report"); + expect(inline).toMatchObject({ filePath: "/tmp/report/Makefile", line: 12 }); + expect(resolveMarkdownFileLinkMeta("src/main.ts", "/repo", "/repo/docs")).toMatchObject({ + filePath: "/repo/docs/src/main.ts", + workspaceRelativePath: "docs/src/main.ts", + }); + }); +}); + describe("resolveInlineCodeFileLinkMeta", () => { it("links relative paths with file extensions", () => { expect( diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index 7ea965e71bbcdcbc68e6cf549417404ebe1c9fb6..d55c9e1cd16c2bb1d28dabfde595e3f410cb70b2 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -189,9 +189,15 @@ function hasExternalScheme(path: string): boolean { return !POSITION_ONLY_PATTERN.test(rest); } +/** + * `baseDir` anchors relative links; it defaults to the workspace root and is the + * file's own directory when rendering a markdown file. `cwd` stays the workspace + * root so the result still knows whether the target is inside it. + */ export function resolveMarkdownFileLinkTarget( href: string | undefined, cwd?: string, + baseDir: string | undefined = cwd, ): string | null { if (!href) return null; const rawHref = normalizeMarkdownLinkDestination(href); @@ -222,8 +228,8 @@ export function resolveMarkdownFileLinkTarget( return pathWithPosition; } - if (!cwd) return null; - return resolvePathLinkTarget(pathWithPosition, cwd); + if (!baseDir) return null; + return resolvePathLinkTarget(pathWithPosition, baseDir); } /** @@ -235,18 +241,19 @@ export function resolveMarkdownFileLinkTarget( export function resolveInlineCodeFileLinkMeta( codeText: string, cwd?: string, + baseDir: string | undefined = cwd, ): MarkdownFileLinkMeta | null { const candidate = inlineCodeFilePathCandidate(codeText); if (candidate === null) return null; - const resolved = resolveMarkdownFileLinkMeta(candidate, cwd); + const resolved = resolveMarkdownFileLinkMeta(candidate, cwd, baseDir); if (resolved) return resolved; // `Makefile:12` — conventional extensionless names fail the generic // markdown-link candidate patterns, but here the :line suffix already // marked the span as a file reference. - if (cwd && isConventionalFilePosition(candidate)) { - return buildFileLinkMetaFromTarget(resolvePathLinkTarget(candidate, cwd), cwd); + if (baseDir && isConventionalFilePosition(candidate)) { + return buildFileLinkMetaFromTarget(resolvePathLinkTarget(candidate, baseDir), cwd); } return null; } @@ -276,8 +283,9 @@ function workspaceRelativePath(path: string, workspaceRoot: string | undefined): export function resolveMarkdownFileLinkMeta( href: string | undefined, cwd?: string, + baseDir: string | undefined = cwd, ): MarkdownFileLinkMeta | null { - const targetPath = resolveMarkdownFileLinkTarget(href, cwd); + const targetPath = resolveMarkdownFileLinkTarget(href, cwd, baseDir); if (!targetPath) return null; return buildFileLinkMetaFromTarget(targetPath, cwd); } diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 27d5ded5d272eb83507bf8c63d60d43539238ba8..40038a2f0ebd144ad63df50adc67ef08a6cefb7c 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -41,6 +41,7 @@ export type RightPanelSurface = | { id: `file:${string}`; kind: "file"; + /** Workspace-relative, or absolute for a host file outside the workspace. */ relativePath: string; revealLine: number | null; revealRequestId: number; diff --git a/apps/web/src/terminal-links.ts b/apps/web/src/terminal-links.ts index 1351a2a6c568cb1c6568af9be819b90fea082209..be4258ba6b0f91a74da47728bcf94370481b5305 100644 --- a/apps/web/src/terminal-links.ts +++ b/apps/web/src/terminal-links.ts @@ -102,7 +102,7 @@ function isWindowsAbsolutePath(value: string): boolean { return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\"); } -function isAbsolutePath(value: string): boolean { +export function isAbsolutePath(value: string): boolean { return value.startsWith("/") || isWindowsAbsolutePath(value); } diff --git a/docs/internals/environment-auth.md b/docs/internals/environment-auth.md index ac54e60dfcbbe65f3b7459599c41e08e286669f9..771b57e14b3b550b8ea73761eb7398cebfaa3717 100644 --- a/docs/internals/environment-auth.md +++ b/docs/internals/environment-auth.md @@ -28,22 +28,31 @@ managed relay connectivity: The desktop bootstrap credential and command-line administrative bootstrap credentials additionally grant `access:read access:write relay:write`. +## Host file access + +Clients with `orchestration:read` can read files anywhere the environment's server account can +read, following the environment-wide authorization model rather than introducing per-project +filesystem permissions. `projects.readFile` accepts an absolute path and returns the text of that +host file; only workspace-relative paths pass its root check, and `projects.writeFile` never +accepts an absolute path. Clients use this to show files an agent wrote outside the workspace, such +as a report in a temp directory, read-only. + ## Media preview access Clients with `orchestration:read` can request a `media-file` URL through `assets.createUrl` for -supported images and videos anywhere the environment's server account can read. A thread ID -supplies the workspace for relative paths; absolute paths refer to the environment host, not the -client. This follows the environment-wide authorization model rather than introducing per-project -filesystem permissions. +supported images, videos, HTML, and PDF files anywhere the environment's server account can read. +A thread ID supplies the workspace for relative paths; absolute paths refer to the environment +host, not the client. [`AssetAccess.ts`](../../apps/server/src/assets/AssetAccess.ts) resolves symlinks, requires a regular file, and validates the resolved file's literal extension. It opens the file and signs its canonical path and device/inode identity for one hour. The token grants access to that exact file, not adjacent files or its containing directory. Serving rechecks the canonical path, media type, and opened descriptor's identity, then streams full or partial responses from that descriptor. Replacing a -file atomically requires a freshly signed URL; editing it in place does not. The existing workspace -boundary still applies to HTML, PDF, and other workspace previews. Uploaded attachments keep their -separate asset resource. +file atomically requires a freshly signed URL; editing it in place does not. Because the token names +one file, an HTML document served this way cannot load sibling assets; the directory-scoped +`workspace-file` resource remains the route for HTML inside the workspace. Uploaded attachments keep +their separate asset resource. Signed asset URLs are bearer credentials. Anyone who obtains a URL and can reach the environment can fetch that file until it expires. Clients should copy the authored reference, not the temporary diff --git a/docs/user/composer.md b/docs/user/composer.md index 5ac274381400542de8353eedc8551f8e4a953aea..19bef9e31ad5c7723cdb2f964492d7a36a0251aa 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -94,6 +94,15 @@ have a cached copy. Supported video formats and codecs depend on the browser or Bare paths in ordinary prose and paths inside code blocks stay text. Raw HTML `