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

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

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

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

apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+9-2
......@@ -60,6 +60,7 @@ import { WorkspaceFileVideoPreview } from "./WorkspaceFileVideoPreview";
6060import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview";
6161import {
6262 basename,
63 isAbsolutePath,
6364 isMarkdownPreviewFile,
6465 isSvgImagePreviewFile,
6566 isVideoPreviewFile,
......@@ -782,8 +783,14 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
782783 );
783784 }
784785
785 const parentDir = relativePath.split("/").slice(0, -1).join("/");
786 const headerSubtitle = [projectName, parentDir].filter(Boolean).join(" · ");
786 const parentDir = relativePath.slice(
787 0,
788 Math.max(relativePath.lastIndexOf("/"), relativePath.lastIndexOf("\\"), 0),
789 );
790 // A host file outside the workspace is not under the project name.
791 const headerSubtitle = isAbsolutePath(relativePath)
792 ? parentDir
793 : [projectName, parentDir].filter(Boolean).join(" · ");
787794
788795 return (
789796 <ReviewHighlighterProvider>
apps/mobile/src/features/files/filePath.test.ts+14-1
......@@ -1,6 +1,19 @@
11import { describe, expect, it } from "vite-plus/test";
22
3import { isSvgImagePreviewFile, resolveWorkspaceRelativeFilePath } from "./filePath";
3import {
4 fileRoutePathSegments,
5 isSvgImagePreviewFile,
6 resolveWorkspaceRelativeFilePath,
7} from "./filePath";
8
9describe("fileRoutePathSegments", () => {
10 it("round-trips workspace-relative and host paths through the route", () => {
11 expect(fileRoutePathSegments("src/main.ts")).toEqual(["src", "main.ts"]);
12 expect(fileRoutePathSegments("/tmp/t3-cleanup/report.md").join("/")).toBe(
13 "/tmp/t3-cleanup/report.md",
14 );
15 });
16});
417
518describe("resolveWorkspaceRelativeFilePath", () => {
619 it("keeps normalized workspace-relative paths", () => {
apps/mobile/src/features/files/filePath.ts+8-1
......@@ -10,10 +10,17 @@ function isWindowsAbsolutePath(value: string): boolean {
1010 return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\");
1111}
1212
13function isAbsolutePath(value: string): boolean {
13/** A file route holding an absolute path shows a host file outside the workspace. */
14export function isAbsolutePath(value: string): boolean {
1415 return value.startsWith("/") || isWindowsAbsolutePath(value);
1516}
1617
18/** Route segments that `normalizeRoutePath` joins back into the same path, root included. */
19export function fileRoutePathSegments(path: string): string[] {
20 const segments = path.split("/").filter((segment) => segment.length > 0);
21 return path.startsWith("/") ? ["", ...segments] : segments;
22}
23
1724function isWindowsPathStyle(value: string): boolean {
1825 return isWindowsAbsolutePath(value) || /^[A-Za-z]:\\/.test(value);
1926}
apps/mobile/src/features/files/workspaceFileAssetUrl.ts+10-4
......@@ -2,7 +2,7 @@ import type { AssetResource, EnvironmentId, ThreadId } from "@t3tools/contracts"
22import { useMemo } from "react";
33
44import { useAssetUrlState, useRefreshAssetUrl } from "../../state/assets";
5import { isVideoPreviewFile, resolveWorkspaceFilePath } from "./filePath";
5import { isAbsolutePath, isVideoPreviewFile, resolveWorkspaceFilePath } from "./filePath";
66
77export function useWorkspaceFileAssetUrlState(props: {
88 readonly cwd: string | null;
......@@ -18,16 +18,22 @@ export function useWorkspaceFileAssetUrlState(props: {
1818 [props.cwd, props.relativePath],
1919 );
2020
21 // Videos stream from an exact-file URL, and so does anything outside the
22 // workspace, where no workspace-scoped URL can exist.
23 const relativePath = props.relativePath;
2124 const resource = useMemo<AssetResource | null>(
2225 () =>
23 absolutePath !== null && props.threadId !== null
26 absolutePath !== null && relativePath !== null && props.threadId !== null
2427 ? {
25 _tag: isVideoPreviewFile(absolutePath) ? "media-file" : "workspace-file",
28 _tag:
29 isVideoPreviewFile(absolutePath) || isAbsolutePath(relativePath)
30 ? "media-file"
31 : "workspace-file",
2632 threadId: props.threadId,
2733 path: absolutePath,
2834 }
2935 : null,
30 [absolutePath, props.threadId],
36 [absolutePath, relativePath, props.threadId],
3137 );
3238 const state = useAssetUrlState(props.environmentId, resource);
3339 const refresh = useRefreshAssetUrl(props.environmentId, resource);
apps/mobile/src/features/threads/ThreadFeed.tsx+36-2
......@@ -166,7 +166,12 @@ import {
166166import { useAtomQueryRunner } from "../../state/use-atom-query-runner";
167167import { usePreparedConnection } from "../../state/session";
168168import * as Option from "effect/Option";
169import { resolveWorkspaceRelativeFilePath } from "../files/filePath";
169import {
170 basename,
171 fileRoutePathSegments,
172 isAbsolutePath,
173 resolveWorkspaceRelativeFilePath,
174} from "../files/filePath";
170175import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize";
171176
172177const WIDE_MARKDOWN_BLOCK_OPTIONS = {
......@@ -2068,7 +2073,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
20682073 navigation.navigate("ThreadFile", {
20692074 environmentId: String(props.environmentId),
20702075 threadId: String(props.threadId),
2071 path: relativePath.split("/").filter((segment) => segment.length > 0),
2076 path: fileRoutePathSegments(relativePath),
20722077 ...(presentation.line ? { line: String(presentation.line) } : {}),
20732078 });
20742079 return;
......@@ -2090,6 +2095,35 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
20902095 return;
20912096 }
20922097
2098 // A host file outside the workspace, such as a report an agent wrote to
2099 // a temp directory, opens read-only in the file screen.
2100 if (presentation.kind === "file" && isAbsolutePath(presentation.path)) {
2101 void Haptics.selectionAsync();
2102 if (isPdfFile({ name: presentation.path })) {
2103 setExpandedFile(
2104 (current) =>
2105 current ?? {
2106 kind: "pdf",
2107 name: basename(presentation.path),
2108 environmentId: props.environmentId,
2109 resource: {
2110 _tag: "media-file",
2111 threadId: props.threadId,
2112 path: presentation.path,
2113 },
2114 },
2115 );
2116 return;
2117 }
2118 navigation.navigate("ThreadFile", {
2119 environmentId: String(props.environmentId),
2120 threadId: String(props.threadId),
2121 path: fileRoutePathSegments(presentation.path),
2122 ...(presentation.line ? { line: String(presentation.line) } : {}),
2123 });
2124 return;
2125 }
2126
20932127 if (presentation.kind !== "file" && presentation.href) {
20942128 if (/^https?:\/\//i.test(presentation.href) && isPdfFile({ name: presentation.href })) {
20952129 setExpandedFile(
apps/server/src/assets/AssetAccess.test.ts+6-4
......@@ -44,7 +44,7 @@ const testLayer = Layer.mergeAll(
4444).pipe(Layer.provideMerge(NodeServices.layer));
4545
4646describe("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", () =>
4848 Effect.gen(function* () {
4949 const fs = yield* FileSystem.FileSystem;
5050 const path = yield* Path.Path;
......@@ -54,6 +54,8 @@ describe("AssetAccess", () => {
5454 ["screenshot.png", "image/png"],
5555 ["recording.mp4", "video/mp4"],
5656 ["recording.webm", "video/webm"],
57 ["report.html", "text/html"],
58 ["report.pdf", "application/pdf"],
5759 ] as const) {
5860 const filePath = path.join(outside, name);
5961 yield* fs.writeFileString(filePath, "media");
......@@ -104,12 +106,12 @@ describe("AssetAccess", () => {
104106 }).pipe(Effect.provide(testLayer)),
105107 );
106108
107 it.effect("rejects non-media files, disguised targets, and directories", () =>
109 it.effect("rejects non-previewable files, disguised targets, and directories", () =>
108110 Effect.gen(function* () {
109111 const fs = yield* FileSystem.FileSystem;
110112 const path = yield* Path.Path;
111113 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"]) {
113115 const filePath = path.join(root, name);
114116 yield* fs.writeFileString(filePath, "not media");
115117 const error = yield* issueAssetUrl({
......@@ -118,7 +120,7 @@ describe("AssetAccess", () => {
118120 expect(error).toBeInstanceOf(AssetPreviewTypeValidationError);
119121 }
120122 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);
122124 const disguisedError = yield* issueAssetUrl({
123125 resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: disguisedPath },
124126 }).pipe(Effect.flip);
apps/server/src/assets/AssetAccess.ts+3-3
......@@ -14,9 +14,9 @@ import {
1414 AssetWorkspaceRootNormalizationError,
1515} from "@t3tools/contracts";
1616import {
17 hostPreviewMimeTypeFromExtension,
1718 isWorkspaceImagePreviewPath,
1819 isWorkspacePreviewEntryPath,
19 mediaMimeTypeFromExtension,
2020 WORKSPACE_BROWSER_PREVIEW_EXTENSIONS,
2121 WORKSPACE_IMAGE_PREVIEW_EXTENSIONS,
2222} from "@t3tools/shared/filePreview";
......@@ -246,7 +246,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
246246 if (!canonicalFile) {
247247 return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource });
248248 }
249 if (mediaMimeTypeFromExtension(path.extname(canonicalFile)) === null) {
249 if (hostPreviewMimeTypeFromExtension(path.extname(canonicalFile)) === null) {
250250 return yield* new AssetPreviewTypeValidationError({ resource: input.resource });
251251 }
252252 const identity = yield* openMediaFile(canonicalFile).pipe(
......@@ -598,7 +598,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
598598 Effect.orElseSucceed(() => null),
599599 );
600600 if (canonicalFile !== claims.filePath) return null;
601 const mimeType = mediaMimeTypeFromExtension(path.extname(canonicalFile));
601 const mimeType = hostPreviewMimeTypeFromExtension(path.extname(canonicalFile));
602602 if (!mimeType) return null;
603603 const file = yield* openMediaFile(canonicalFile, claims).pipe(
604604 Effect.tapError((cause) =>
apps/server/src/http.test.ts+7-9
......@@ -324,15 +324,13 @@ describe("assetResponseHeaders", () => {
324324 "X-Content-Type-Options": "nosniff",
325325 });
326326 });
327 it("declares utf-8 for HTML assets so non-ASCII content renders correctly", () => {
328 expect(assetResponseHeaders("/workspace/page.html")).toHaveProperty(
329 "Content-Type",
330 "text/html; charset=utf-8",
331 );
332 expect(assetResponseHeaders("/workspace/PAGE.HTM")).toHaveProperty(
333 "Content-Type",
334 "text/html; charset=utf-8",
335 );
327 it("serves HTML assets as utf-8 inside a sandboxed origin", () => {
328 for (const path of ["/workspace/page.html", "/workspace/PAGE.HTM", "/tmp/report.html"]) {
329 expect(assetResponseHeaders(path)).toMatchObject({
330 "Content-Type": "text/html; charset=utf-8",
331 "Content-Security-Policy": "sandbox allow-scripts allow-forms allow-popups allow-modals",
332 });
333 }
336334 });
337335
338336 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";
5151const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
5252const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"];
5353const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
54// HTML previews are agent output, not the app. The sandbox gives the document an
55// opaque origin: scripts run, but same-origin cookies, storage, and API calls are
56// out of reach. Relative sibling assets still load through their signed URLs.
57const HTML_CONTENT_SECURITY_POLICY = "sandbox allow-scripts allow-forms allow-popups allow-modals";
5458
5559// Types a browser may render as a document if a proxy strips the disposition
5660// header. Downloads of these fall back to octet-stream.
......@@ -105,7 +109,10 @@ export function assetResponseHeaders(
105109 : inlineVideoMimeType !== undefined && isSafeInlineVideoMimeType(inlineVideoMimeType)
106110 ? { "Content-Type": inlineVideoMimeType }
107111 : 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 }
109116 : {}),
110117 ...(!options?.download && lowerPath.endsWith(".svg")
111118 ? { "Content-Security-Policy": SVG_CONTENT_SECURITY_POLICY }
apps/server/src/workspace/WorkspaceFileSystem.test.ts+66
......@@ -1,3 +1,6 @@
1// @effect-diagnostics nodeBuiltinImport:off - FileSystem cannot create a FIFO.
2import * as NodeChildProcess from "node:child_process";
3
14import * as NodeServices from "@effect/platform-node/NodeServices";
25import { it, describe, expect } from "@effect/vitest";
36import * as Effect from "effect/Effect";
......@@ -73,6 +76,53 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i
7376 }),
7477 );
7578
79 it.effect("reads host files outside the workspace root by absolute path", () =>
80 Effect.gen(function* () {
81 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
82 const path = yield* Path.Path;
83 const cwd = yield* makeTempDir;
84 const outsideDir = yield* makeTempDir;
85 yield* writeTextFile(outsideDir, "cleanup-report.md", "# Report\n");
86 const absolutePath = path.join(outsideDir, "cleanup-report.md");
87
88 const result = yield* workspaceFileSystem.readFile({
89 cwd,
90 relativePath: absolutePath,
91 });
92
93 expect(result).toEqual({
94 relativePath: absolutePath,
95 contents: "# Report\n",
96 byteLength: 9,
97 truncated: false,
98 });
99 }),
100 );
101
102 it.effect("rejects a FIFO without blocking on open", () =>
103 Effect.gen(function* () {
104 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
105 const path = yield* Path.Path;
106 const cwd = yield* makeTempDir;
107 const outsideDir = yield* makeTempDir;
108 const fifoPath = path.join(outsideDir, "pipe");
109 yield* Effect.promise(
110 () =>
111 new Promise<void>((resolve, reject) =>
112 NodeChildProcess.execFile("mkfifo", [fifoPath], (error) =>
113 error ? reject(error) : resolve(),
114 ),
115 ),
116 );
117
118 const error = yield* workspaceFileSystem
119 .readFile({ cwd, relativePath: fifoPath })
120 .pipe(Effect.flip);
121
122 expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspacePathNotFileError);
123 }),
124 );
125
76126 it.effect("rejects reads outside the workspace root", () =>
77127 Effect.gen(function* () {
78128 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
......@@ -212,6 +262,22 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i
212262 }),
213263 );
214264
265 it.effect("rejects writes by absolute path", () =>
266 Effect.gen(function* () {
267 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
268 const path = yield* Path.Path;
269 const cwd = yield* makeTempDir;
270 const outsideDir = yield* makeTempDir;
271 const absolutePath = path.join(outsideDir, "cleanup-report.md");
272
273 const error = yield* workspaceFileSystem
274 .writeFile({ cwd, relativePath: absolutePath, contents: "# Edited\n" })
275 .pipe(Effect.flip);
276
277 expect(error).toBeInstanceOf(WorkspacePaths.WorkspacePathOutsideRootError);
278 }),
279 );
280
215281 it.effect("invalidates workspace entry search cache after writes", () =>
216282 Effect.gen(function* () {
217283 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
apps/server/src/workspace/WorkspaceFileSystem.ts+48-6
......@@ -3,10 +3,13 @@
33 * WorkspaceFileSystem - Effect service contract for workspace file mutations.
44 *
55 * 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.
79 *
810 * @module WorkspaceFileSystem
911 */
12import * as NodeFS from "node:fs";
1013import * as NodeFSP from "node:fs/promises";
1114
1215import type {
......@@ -104,7 +107,10 @@ export type WorkspaceFileSystemError = typeof WorkspaceFileSystemError.Type;
104107export class WorkspaceFileSystem extends Context.Service<
105108 WorkspaceFileSystem,
106109 {
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 */
108114 readonly readFile: (
109115 input: ProjectReadFileInput,
110116 ) => Effect.Effect<
......@@ -132,9 +138,31 @@ export const make = Effect.gen(function* () {
132138 const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
133139 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
134140
135 const readFile: WorkspaceFileSystem["Service"]["readFile"] = Effect.fn(
136 "WorkspaceFileSystem.readFile",
137 )(function* (input) {
141 /**
142 * Resolves the file a read targets. Workspace-relative paths must stay inside the
143 * root, symlinks included. An absolute path reads a host file in place, such as a
144 * report an agent wrote to a temp directory; it gets no root check.
145 */
146 const resolveReadTarget = Effect.fn("WorkspaceFileSystem.resolveReadTarget")(function* (
147 input: ProjectReadFileInput,
148 ) {
149 const requestedPath = input.relativePath.trim();
150 if (path.isAbsolute(requestedPath)) {
151 const realTargetPath = yield* Effect.tryPromise({
152 try: () => NodeFSP.realpath(requestedPath),
153 catch: (cause) =>
154 new WorkspaceFileSystemOperationError({
155 workspaceRoot: input.cwd,
156 relativePath: input.relativePath,
157 resolvedPath: requestedPath,
158 operationPath: requestedPath,
159 operation: "realpath-target",
160 cause,
161 }),
162 });
163 return { relativePath: requestedPath, realTargetPath };
164 }
165
138166 const target = yield* workspacePaths.resolveRelativePathWithinRoot({
139167 workspaceRoot: input.cwd,
140168 relativePath: input.relativePath,
......@@ -177,10 +205,24 @@ export const make = Effect.gen(function* () {
177205 resolvedPath: realTargetPath,
178206 });
179207 }
208 return { relativePath: target.relativePath, realTargetPath };
209 });
210
211 const readFile: WorkspaceFileSystem["Service"]["readFile"] = Effect.fn(
212 "WorkspaceFileSystem.readFile",
213 )(function* (input) {
214 const target = yield* resolveReadTarget(input);
215 const realTargetPath = target.realTargetPath;
180216
181217 return yield* Effect.acquireUseRelease(
182218 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 ),
184226 catch: (cause) =>
185227 new WorkspaceFileSystemOperationError({
186228 workspaceRoot: input.cwd,
apps/web/src/browser/openFileInPreview.ts+9-1
......@@ -6,6 +6,7 @@ import type {
66 PreviewSessionSnapshot,
77 ScopedThreadRef,
88} from "@t3tools/contracts";
9import { mediaFileReference } from "@t3tools/client-runtime/media-reference";
910import {
1011 type AtomCommandResult,
1112 mapAtomCommandResult,
......@@ -52,9 +53,14 @@ export async function openUrlInPreview<E>(input: {
5253 });
5354}
5455
56/**
57 * Opens a browser document in the integrated browser. Inside the workspace the
58 * page may load sibling assets; a file outside it is served on its own.
59 */
5560export async function openFileInPreview<AssetError, PreviewError>(input: {
5661 readonly threadRef: ScopedThreadRef;
5762 readonly filePath: string;
63 readonly workspaceRoot: string | undefined;
5864 readonly httpBaseUrl: string;
5965 readonly createAssetUrl: (input: {
6066 readonly environmentId: EnvironmentId;
......@@ -71,11 +77,13 @@ export async function openFileInPreview<AssetError, PreviewError>(input: {
7177 ),
7278 );
7379 }
80 const insideWorkspace =
81 mediaFileReference(input.filePath, input.workspaceRoot).relativePath !== undefined;
7482 const assetResult = await input.createAssetUrl({
7583 environmentId: input.threadRef.environmentId,
7684 input: {
7785 resource: {
78 _tag: "workspace-file",
86 _tag: insideWorkspace ? "workspace-file" : "media-file",
7987 threadId: input.threadRef.threadId,
8088 path: input.filePath,
8189 },
apps/web/src/components/ChatMarkdown.tsx+33-23
......@@ -164,7 +164,7 @@ import {
164164} from "~/lib/openPullRequestLink";
165165import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
166166import { isPreviewSupportedInRuntime } from "../previewStateStore";
167import { resolvePathLinkTarget } from "../terminal-links";
167import { isAbsolutePath, resolvePathLinkTarget } from "../terminal-links";
168168import {
169169 isBrowserPreviewFile,
170170 openFileInPreview,
......@@ -188,6 +188,8 @@ interface ChatMarkdownProps {
188188 parseRawHtml?: boolean;
189189 /** Append a prompt that invokes a newly created artifact-template skill. */
190190 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. */
191193 imageBaseDir?: string | undefined;
192194 onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined;
193195 extraRemarkPlugins?: NonNullable<ReactMarkdownOptions["remarkPlugins"]>;
......@@ -1017,14 +1019,16 @@ interface MarkdownFileLinkProps {
10171019 targetPath: string;
10181020 iconPath: string;
10191021 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;
10211025 line?: number | undefined;
10221026 label: string;
10231027 copyMarkdown: string;
10241028 theme: "light" | "dark";
10251029 threadRef?: ScopedThreadRef | undefined;
10261030 onOpen?: ((targetPath: string) => Promise<AtomCommandResult<unknown, unknown>>) | undefined;
1027 onOpenInPanel: (workspaceRelativePath: string, line: number | undefined) => void;
1031 onOpenInPanel: (panelPath: string, line: number | undefined) => void;
10281032 openInEditorMenuLabel: string;
10291033 onOpenInBrowser?: (() => Promise<AtomCommandResult<unknown, unknown>>) | undefined;
10301034 onOpenMedia?: (() => void) | undefined;
......@@ -1603,7 +1607,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
16031607 targetPath,
16041608 iconPath,
16051609 displayPath,
1606 workspaceRelativePath,
1610 panelPath,
16071611 line,
16081612 label,
16091613 copyMarkdown,
......@@ -1657,8 +1661,8 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
16571661 }, [onOpen, targetPath]);
16581662
16591663 const handleOpenInFilePreview = useCallback(() => {
1660 if (threadRef && workspaceRelativePath) {
1661 onOpenInPanel(workspaceRelativePath, line);
1664 if (threadRef && panelPath) {
1665 onOpenInPanel(panelPath, line);
16621666 return;
16631667 }
16641668 if (onOpenMedia) {
......@@ -1666,7 +1670,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
16661670 return;
16671671 }
16681672 handleOpenInEditor();
1669 }, [handleOpenInEditor, line, onOpenInPanel, onOpenMedia, threadRef, workspaceRelativePath]);
1673 }, [handleOpenInEditor, line, onOpenInPanel, onOpenMedia, panelPath, threadRef]);
16701674
16711675 const handleOpenInBrowser = useCallback(() => {
16721676 if (!onOpenInBrowser) {
......@@ -1867,7 +1871,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
18671871
18681872 const canOpenInEditor = onOpen !== undefined;
18691873 const canOpenInBrowser = onOpenInBrowser !== undefined;
1870 const canOpenInPanel = threadRef !== undefined && Boolean(workspaceRelativePath);
1874 const canOpenInPanel = threadRef !== undefined && Boolean(panelPath);
18711875 const hasPrimaryAction = hasMarkdownFilePrimaryAction({
18721876 canOpenInEditor,
18731877 canOpenInBrowser,
......@@ -1954,7 +1958,7 @@ function areMarkdownFileLinkPropsEqual(
19541958 previous.targetPath === next.targetPath &&
19551959 previous.iconPath === next.iconPath &&
19561960 previous.displayPath === next.displayPath &&
1957 previous.workspaceRelativePath === next.workspaceRelativePath &&
1961 previous.panelPath === next.panelPath &&
19581962 previous.line === next.line &&
19591963 previous.label === next.label &&
19601964 previous.copyMarkdown === next.copyMarkdown &&
......@@ -2098,24 +2102,24 @@ function ChatMarkdown({
20982102 for (const href of extractMarkdownLinkHrefs(renderCodexFileCitationsAsMarkdown(text))) {
20992103 const normalizedHref = normalizeMarkdownLinkHrefKey(href);
21002104 if (metaByHref.has(normalizedHref)) continue;
2101 const meta = resolveMarkdownFileLinkMeta(normalizedHref, cwd);
2105 const meta = resolveMarkdownFileLinkMeta(normalizedHref, cwd, imageBaseDir ?? cwd);
21022106 if (meta) {
21032107 metaByHref.set(normalizedHref, meta);
21042108 }
21052109 }
21062110 return metaByHref;
2107 }, [cwd, text]);
2111 }, [cwd, imageBaseDir, text]);
21082112 const inlineCodeFileLinkMetaByText = useMemo(() => {
21092113 const metaByText = new Map<string, MarkdownFileLinkMeta>();
21102114 for (const span of extractInlineCodeSpans(text)) {
21112115 if (metaByText.has(span)) continue;
2112 const meta = resolveInlineCodeFileLinkMeta(span, cwd);
2116 const meta = resolveInlineCodeFileLinkMeta(span, cwd, imageBaseDir ?? cwd);
21132117 if (meta) {
21142118 metaByText.set(span, meta);
21152119 }
21162120 }
21172121 return metaByText;
2118 }, [cwd, text]);
2122 }, [cwd, imageBaseDir, text]);
21192123 const fileLinkParentSuffixByPath = useMemo(() => {
21202124 const filePaths = [
21212125 ...[...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath),
......@@ -2223,12 +2227,13 @@ function ChatMarkdown({
22232227 return openFileInPreview({
22242228 threadRef,
22252229 filePath: path,
2230 workspaceRoot: cwd,
22262231 httpBaseUrl: preparedConnection.value.httpBaseUrl,
22272232 createAssetUrl,
22282233 openPreview,
22292234 });
22302235 },
2231 [createAssetUrl, openPreview, preparedConnection, threadRef],
2236 [createAssetUrl, cwd, openPreview, preparedConnection, threadRef],
22322237 );
22332238 const findWorkspaceBasenameMatch = useCallback(
22342239 async (workspaceRelativePath: string) => {
......@@ -2251,23 +2256,23 @@ function ChatMarkdown({
22512256 [cwd, environmentId, searchProjectEntries],
22522257 );
22532258 // 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.
22552260 const openFileInPanel = useCallback(
2256 (workspaceRelativePath: string, line: number | undefined) => {
2261 (panelPath: string, line: number | undefined) => {
22572262 if (!threadRef) return;
22582263 // Claimed on every open so a synchronous one supersedes a lookup already
22592264 // in flight.
22602265 const isLatestLookup = claimWorkspaceBasenameLookup();
22612266 const openAt = (path: string) =>
22622267 useRightPanelStore.getState().openFile(threadRef, path, line);
2263 if (!cwd || !needsWorkspaceBasenameLookup(workspaceRelativePath)) {
2264 openAt(workspaceRelativePath);
2268 if (!cwd || !needsWorkspaceBasenameLookup(panelPath)) {
2269 openAt(panelPath);
22652270 return;
22662271 }
22672272 void (async () => {
2268 const match = await findWorkspaceBasenameMatch(workspaceRelativePath);
2273 const match = await findWorkspaceBasenameMatch(panelPath);
22692274 if (!isLatestLookup()) return;
2270 openAt(match ?? workspaceRelativePath);
2275 openAt(match ?? panelPath);
22712276 })();
22722277 },
22732278 [cwd, findWorkspaceBasenameMatch, threadRef],
......@@ -2310,6 +2315,11 @@ function ChatMarkdown({
23102315 mediaMimeTypeFromExtension(
23112316 fileLinkMeta.basename.slice(fileLinkMeta.basename.lastIndexOf(".")),
23122317 ) !== null;
2318 // Media outside the workspace keeps the expanded preview; other host
2319 // files (a report in a temp dir) open read-only in the files panel.
2320 const panelPath =
2321 fileLinkMeta.workspaceRelativePath ??
2322 (!canPreviewMedia && isAbsolutePath(fileLinkMeta.filePath) ? fileLinkMeta.filePath : null);
23132323
23142324 return (
23152325 <MarkdownFileLink
......@@ -2317,7 +2327,7 @@ function ChatMarkdown({
23172327 targetPath={fileLinkMeta.targetPath}
23182328 iconPath={fileLinkMeta.filePath}
23192329 displayPath={fileLinkMeta.displayPath}
2320 workspaceRelativePath={fileLinkMeta.workspaceRelativePath}
2330 panelPath={panelPath}
23212331 line={fileLinkMeta.line}
23222332 label={labelParts.join(" · ")}
23232333 copyMarkdown={copyMarkdown}
......@@ -2434,7 +2444,7 @@ function ChatMarkdown({
24342444 const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : "";
24352445 const fileLinkMeta = normalizedHref
24362446 ? (markdownFileLinkMetaByHref.get(normalizedHref) ??
2437 resolveMarkdownFileLinkMeta(normalizedHref, cwd))
2447 resolveMarkdownFileLinkMeta(normalizedHref, cwd, imageBaseDir ?? cwd))
24382448 : null;
24392449 if (!fileLinkMeta) {
24402450 const faviconHost = resolveExternalWebLinkHost(href);
......@@ -2580,7 +2590,7 @@ function ChatMarkdown({
25802590 const codeText = nodeToPlainText(children);
25812591 const fileLinkMeta =
25822592 inlineCodeFileLinkMetaByText.get(codeText.trim()) ??
2583 resolveInlineCodeFileLinkMeta(codeText, cwd);
2593 resolveInlineCodeFileLinkMeta(codeText, cwd, imageBaseDir ?? cwd);
25842594 if (fileLinkMeta) {
25852595 return fileLinkChip(
25862596 fileLinkMeta,
apps/web/src/components/RightPanelTabs.tsx+3-1
......@@ -498,7 +498,9 @@ function surfaceTitle(
498498 case "files":
499499 return "Files";
500500 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 );
502504 case "terminal":
503505 return (
504506 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
3434import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting";
3535import { cn } from "~/lib/utils";
3636import { isPreviewSupportedInRuntime } from "~/previewStateStore";
37import { resolvePathLinkTarget } from "~/terminal-links";
37import { isAbsolutePath, resolvePathLinkTarget } from "~/terminal-links";
3838import { ScrollArea } from "~/components/ui/scroll-area";
3939import { Toggle } from "~/components/ui/toggle";
4040import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
......@@ -793,6 +793,7 @@ function RenderedMarkdownSurface({
793793 relativePath,
794794 contents,
795795 threadRef,
796 readOnly,
796797 onPendingChange,
797798}: Omit<
798799 EditableFileSurfaceProps,
......@@ -804,6 +805,7 @@ function RenderedMarkdownSurface({
804805 | "onPostRender"
805806> & {
806807 threadRef: ScopedThreadRef;
808 readOnly: boolean;
807809}) {
808810 const saveCoordinator = useFileSaveCoordinator({
809811 environmentId,
......@@ -819,15 +821,19 @@ function RenderedMarkdownSurface({
819821 cwd={cwd}
820822 relativePath={relativePath}
821823 threadRef={threadRef}
822 onTaskListChange={({ markerOffset, checked }) => {
823 const currentContents =
824 getOptimisticProjectFileQueryData(environmentId, cwd, relativePath)?.contents ??
825 contents;
826 const nextContents = setMarkdownTaskChecked(currentContents, markerOffset, checked);
827 if (nextContents === currentContents) return;
828 setProjectFileQueryData(environmentId, cwd, relativePath, nextContents);
829 saveCoordinator.change(nextContents);
830 }}
824 onTaskListChange={
825 readOnly
826 ? undefined
827 : ({ markerOffset, checked }) => {
828 const currentContents =
829 getOptimisticProjectFileQueryData(environmentId, cwd, relativePath)?.contents ??
830 contents;
831 const nextContents = setMarkdownTaskChecked(currentContents, markerOffset, checked);
832 if (nextContents === currentContents) return;
833 setProjectFileQueryData(environmentId, cwd, relativePath, nextContents);
834 saveCoordinator.change(nextContents);
835 }
836 }
831837 />
832838 </ScrollArea>
833839 );
......@@ -872,6 +878,8 @@ export default function FilePreviewPanel({
872878 const isVideo = relativePath !== null && isWorkspaceVideoPreviewPath(relativePath);
873879 const isImage = relativePath !== null && !isVideo && isWorkspaceImagePreviewPath(relativePath);
874880 const isMedia = isImage || isVideo;
881 // A file outside the workspace (an absolute path) is shown, never edited.
882 const isHostFile = relativePath !== null && isAbsolutePath(relativePath);
875883 const file = useProjectFileQuery(environmentId, cwd, relativePath, !isMedia);
876884 const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen);
877885 // Reading markdown rendered is a preference, not a property of one file. Keeping
......@@ -938,6 +946,7 @@ export default function FilePreviewPanel({
938946 const result = await openFileInPreview({
939947 threadRef,
940948 filePath: absolutePath,
949 workspaceRoot: cwd,
941950 httpBaseUrl: environmentHttpBaseUrl,
942951 createAssetUrl,
943952 openPreview,
......@@ -954,7 +963,7 @@ export default function FilePreviewPanel({
954963 }),
955964 );
956965 })();
957 }, [absolutePath, createAssetUrl, environmentHttpBaseUrl, openPreview, threadRef]);
966 }, [absolutePath, createAssetUrl, cwd, environmentHttpBaseUrl, openPreview, threadRef]);
958967
959968 return (
960969 <div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
......@@ -1130,9 +1139,10 @@ export default function FilePreviewPanel({
11301139 relativePath={relativePath}
11311140 threadRef={threadRef}
11321141 contents={file.data.contents}
1142 readOnly={isHostFile}
11331143 onPendingChange={onPendingChange}
11341144 />
1135 ) : file.data.truncated ? (
1145 ) : file.data.truncated || isHostFile ? (
11361146 <Virtualizer
11371147 key={`${relativePath}:${resolvedTheme}:${file.data.byteLength}`}
11381148 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", () => {
1414 });
1515
1616 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([
1818 "workspace",
1919 "src",
2020 "index.ts",
2121 ]);
2222 });
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 });
2341});
apps/web/src/components/files/filePath.ts+14-3
......@@ -1,16 +1,27 @@
1import { isWindowsAbsolutePath } from "@t3tools/shared/path";
2
3import { isAbsolutePath } from "~/terminal-links";
4
15export interface FileBreadcrumb {
26 label: string;
37 path: string;
48 kind: "project" | "directory" | "file";
59}
610
11/**
12 * Crumbs for a workspace-relative path start at the project. An absolute host
13 * path is outside the workspace, so its crumbs start at the filesystem root.
14 */
715export 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 === "/" ? "/" : "";
920 return [
10 { label: projectName, path: "", kind: "project" },
21 ...(hostPath ? [] : [{ label: projectName, path: "", kind: "project" as const }]),
1122 ...parts.map((part, index) => ({
1223 label: part,
13 path: parts.slice(0, index + 1).join("/"),
24 path: root + parts.slice(0, index + 1).join(separator),
1425 kind: index === parts.length - 1 ? ("file" as const) : ("directory" as const),
1526 })),
1627 ];
apps/web/src/markdown-links.test.ts+16
......@@ -275,6 +275,22 @@ describe("resolveMarkdownFileLinkTarget", () => {
275275 });
276276});
277277
278describe("relative links inside a rendered host file", () => {
279 it("anchor to the file's directory while workspace membership follows cwd", () => {
280 const meta = resolveMarkdownFileLinkMeta("appendix.md", "/repo", "/tmp/report");
281 expect(meta).toMatchObject({
282 filePath: "/tmp/report/appendix.md",
283 workspaceRelativePath: null,
284 });
285 const inline = resolveInlineCodeFileLinkMeta("Makefile:12", "/repo", "/tmp/report");
286 expect(inline).toMatchObject({ filePath: "/tmp/report/Makefile", line: 12 });
287 expect(resolveMarkdownFileLinkMeta("src/main.ts", "/repo", "/repo/docs")).toMatchObject({
288 filePath: "/repo/docs/src/main.ts",
289 workspaceRelativePath: "docs/src/main.ts",
290 });
291 });
292});
293
278294describe("resolveInlineCodeFileLinkMeta", () => {
279295 it("links relative paths with file extensions", () => {
280296 expect(
apps/web/src/markdown-links.ts+14-6
......@@ -189,9 +189,15 @@ function hasExternalScheme(path: string): boolean {
189189 return !POSITION_ONLY_PATTERN.test(rest);
190190}
191191
192/**
193 * `baseDir` anchors relative links; it defaults to the workspace root and is the
194 * file's own directory when rendering a markdown file. `cwd` stays the workspace
195 * root so the result still knows whether the target is inside it.
196 */
192197export function resolveMarkdownFileLinkTarget(
193198 href: string | undefined,
194199 cwd?: string,
200 baseDir: string | undefined = cwd,
195201): string | null {
196202 if (!href) return null;
197203 const rawHref = normalizeMarkdownLinkDestination(href);
......@@ -222,8 +228,8 @@ export function resolveMarkdownFileLinkTarget(
222228 return pathWithPosition;
223229 }
224230
225 if (!cwd) return null;
226 return resolvePathLinkTarget(pathWithPosition, cwd);
231 if (!baseDir) return null;
232 return resolvePathLinkTarget(pathWithPosition, baseDir);
227233}
228234
229235/**
......@@ -235,18 +241,19 @@ export function resolveMarkdownFileLinkTarget(
235241export function resolveInlineCodeFileLinkMeta(
236242 codeText: string,
237243 cwd?: string,
244 baseDir: string | undefined = cwd,
238245): MarkdownFileLinkMeta | null {
239246 const candidate = inlineCodeFilePathCandidate(codeText);
240247 if (candidate === null) return null;
241248
242 const resolved = resolveMarkdownFileLinkMeta(candidate, cwd);
249 const resolved = resolveMarkdownFileLinkMeta(candidate, cwd, baseDir);
243250 if (resolved) return resolved;
244251
245252 // `Makefile:12` — conventional extensionless names fail the generic
246253 // markdown-link candidate patterns, but here the :line suffix already
247254 // marked the span as a file reference.
248 if (cwd && isConventionalFilePosition(candidate)) {
249 return buildFileLinkMetaFromTarget(resolvePathLinkTarget(candidate, cwd), cwd);
255 if (baseDir && isConventionalFilePosition(candidate)) {
256 return buildFileLinkMetaFromTarget(resolvePathLinkTarget(candidate, baseDir), cwd);
250257 }
251258 return null;
252259}
......@@ -276,8 +283,9 @@ function workspaceRelativePath(path: string, workspaceRoot: string | undefined):
276283export function resolveMarkdownFileLinkMeta(
277284 href: string | undefined,
278285 cwd?: string,
286 baseDir: string | undefined = cwd,
279287): MarkdownFileLinkMeta | null {
280 const targetPath = resolveMarkdownFileLinkTarget(href, cwd);
288 const targetPath = resolveMarkdownFileLinkTarget(href, cwd, baseDir);
281289 if (!targetPath) return null;
282290 return buildFileLinkMetaFromTarget(targetPath, cwd);
283291}
apps/web/src/rightPanelStore.ts+1
......@@ -41,6 +41,7 @@ export type RightPanelSurface =
4141 | {
4242 id: `file:${string}`;
4343 kind: "file";
44 /** Workspace-relative, or absolute for a host file outside the workspace. */
4445 relativePath: string;
4546 revealLine: number | null;
4647 revealRequestId: number;
apps/web/src/terminal-links.ts+1-1
......@@ -102,7 +102,7 @@ function isWindowsAbsolutePath(value: string): boolean {
102102 return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\");
103103}
104104
105function isAbsolutePath(value: string): boolean {
105export function isAbsolutePath(value: string): boolean {
106106 return value.startsWith("/") || isWindowsAbsolutePath(value);
107107}
108108
docs/internals/environment-auth.md+16-7
......@@ -28,22 +28,31 @@ managed relay connectivity:
2828The desktop bootstrap credential and command-line administrative bootstrap
2929credentials additionally grant `access:read access:write relay:write`.
3030
31## Host file access
32
33Clients with `orchestration:read` can read files anywhere the environment's server account can
34read, following the environment-wide authorization model rather than introducing per-project
35filesystem permissions. `projects.readFile` accepts an absolute path and returns the text of that
36host file; only workspace-relative paths pass its root check, and `projects.writeFile` never
37accepts an absolute path. Clients use this to show files an agent wrote outside the workspace, such
38as a report in a temp directory, read-only.
39
3140## Media preview access
3241
3342Clients with `orchestration:read` can request a `media-file` URL through `assets.createUrl` for
34supported images and videos anywhere the environment's server account can read. A thread ID
35supplies the workspace for relative paths; absolute paths refer to the environment host, not the
36client. This follows the environment-wide authorization model rather than introducing per-project
37filesystem permissions.
43supported images, videos, HTML, and PDF files anywhere the environment's server account can read.
44A thread ID supplies the workspace for relative paths; absolute paths refer to the environment
45host, not the client.
3846
3947[`AssetAccess.ts`](../../apps/server/src/assets/AssetAccess.ts) resolves symlinks, requires a regular
4048file, and validates the resolved file's literal extension. It opens the file and signs its canonical
4149path and device/inode identity for one hour. The token grants access to that exact file, not adjacent
4250files or its containing directory. Serving rechecks the canonical path, media type, and opened
4351descriptor's identity, then streams full or partial responses from that descriptor. Replacing a
44file atomically requires a freshly signed URL; editing it in place does not. The existing workspace
45boundary still applies to HTML, PDF, and other workspace previews. Uploaded attachments keep their
46separate asset resource.
52file atomically requires a freshly signed URL; editing it in place does not. Because the token names
53one file, an HTML document served this way cannot load sibling assets; the directory-scoped
54`workspace-file` resource remains the route for HTML inside the workspace. Uploaded attachments keep
55their separate asset resource.
4756
4857Signed asset URLs are bearer credentials. Anyone who obtains a URL and can reach the environment
4958can 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
9494Bare paths in ordinary prose and paths inside code blocks stay text. Raw HTML `<video>` tags
9595are not supported; use the Markdown embed syntax above.
9696
97## Files outside the workspace
98
99When an agent links to a file it wrote outside the workspace, such as a Markdown report in
100`/tmp`, select the link to open it in the file viewer. The viewer shows the file read-only, with
101rendered Markdown available as usual; it cannot edit files outside the workspace. HTML and PDF
102files outside the workspace open the same way as ones inside it, including the integrated browser
103where it is available. Because such a file is served on its own, an HTML page outside the workspace
104cannot load scripts, styles, or images from files beside it.
105
97106## Changing projects
98107
99108On 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([
1515 threadId: ThreadId,
1616 path: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),
1717 }),
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.
1821 Schema.TaggedStruct("media-file", {
1922 threadId: ThreadId,
2023 path: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),
......@@ -157,7 +160,7 @@ export class AssetPreviewTypeValidationError extends Schema.TaggedErrorClass<Ass
157160) {
158161 override get message(): string {
159162 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."
161164 : "Only browser documents and images can be previewed.";
162165 }
163166}
packages/contracts/src/project.ts+2
......@@ -193,6 +193,8 @@ export class ProjectListEntriesError extends Schema.TaggedErrorClass<ProjectList
193193
194194export const ProjectReadFileInput = Schema.Struct({
195195 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.
196198 relativePath: TrimmedNonEmptyString.check(Schema.isMaxLength(PROJECT_READ_FILE_PATH_MAX_LENGTH)),
197199});
198200export type ProjectReadFileInput = typeof ProjectReadFileInput.Type;
packages/shared/src/filePreview.ts+16
......@@ -24,6 +24,12 @@ const IMAGE_MIME_TYPE_BY_EXTENSION = new Map([
2424 [".webp", "image/webp"],
2525]);
2626
27const BROWSER_MIME_TYPE_BY_EXTENSION = new Map([
28 [".htm", "text/html"],
29 [".html", "text/html"],
30 [".pdf", "application/pdf"],
31]);
32
2733/** Classifies a literal filesystem extension, without URL decoding or suffix removal. */
2834export function mediaMimeTypeFromExtension(extension: string): string | null {
2935 if (!/^\.[a-z0-9]+$/i.test(extension)) return null;
......@@ -33,6 +39,16 @@ export function mediaMimeTypeFromExtension(extension: string): string | null {
3339 );
3440}
3541
42/** Files the server serves in place from anywhere on its host: media plus browser documents. */
43export function hostPreviewMimeTypeFromExtension(extension: string): string | null {
44 if (!/^\.[a-z0-9]+$/i.test(extension)) return null;
45 return (
46 mediaMimeTypeFromExtension(extension) ??
47 BROWSER_MIME_TYPE_BY_EXTENSION.get(extension.toLowerCase()) ??
48 null
49 );
50}
51
3652/** Classifies an authored media path or URL. Filesystem validation uses the literal extension. */
3753export function mediaMimeType(path: string): string | null {
3854 const trimmed = path.trim();