1import type { AssetResource } from "@t3tools/contracts";
2import {
3 AssetAttachmentNotFoundError,
4 AssetGitHubMediaUrlValidationError,
5 AssetPreviewTypeValidationError,
6 AssetProjectFaviconInspectionError,
7 AssetProjectFaviconNotFoundError,
8 AssetProjectFaviconResolutionError,
9 AssetSigningKeyLoadError,
10 AssetWorkspaceAssetInspectionError,
11 AssetWorkspaceAssetNotFoundError,
12 AssetWorkspaceContextNotFoundError,
13 AssetWorkspacePathValidationError,
14 AssetWorkspaceResolutionError,
15 AssetWorkspaceRootNormalizationError,
16 ThreadId,
17 ToolActivityNativeAppReference,
18 TurnItemId,
19} from "@t3tools/contracts";
20import {
21 audioMimeTypeFromExtension,
22 hostPreviewMimeTypeFromExtension,
23 isWorkspaceImagePreviewPath,
24 isWorkspacePreviewEntryPath,
25 WORKSPACE_BROWSER_PREVIEW_EXTENSIONS,
26 WORKSPACE_IMAGE_PREVIEW_EXTENSIONS,
27} from "@t3tools/shared/filePreview";
28import {
29 IMAGE_DIMENSIONS_HEADER_BYTES,
30 readImageDimensions,
31 type ImageDimensions,
32} from "@t3tools/shared/imageDimensions";
33import { githubMediaFetchUrl, githubMediaFileName } from "@t3tools/shared/githubMedia";
34import { MCP_APP_MAX_HTML_BYTES } from "@t3tools/shared/mcpApp";
35import {
36 isProjectFaviconPath,
37 PROJECT_FAVICON_FALLBACK_MARKER,
38} from "@t3tools/shared/projectFavicon";
39import { MAX_TOOL_OUTPUT_IMAGE_BASE64_LENGTH, toolOutputImages } from "@t3tools/shared/toolOutput";
40import * as Clock from "effect/Clock";
41import * as Crypto from "effect/Crypto";
42import * as Effect from "effect/Effect";
43import * as Hex from "effect/encoding/Hex";
44import * as FileSystem from "effect/FileSystem";
45import * as Option from "effect/Option";
46import * as Path from "effect/Path";
47import * as PlatformError from "effect/PlatformError";
48import * as Schema from "effect/Schema";
49import * as Stream from "effect/Stream";
50
51import {
52 base64UrlDecodeUtf8,
53 base64UrlEncode,
54 signPayload,
55 timingSafeEqualBase64Url,
56} from "../auth/utils.ts";
57import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
58import {
59 attachmentPathsById,
60 parseAttachmentFileExtension,
61 resolveAttachmentPathById,
62} from "../attachmentStore.ts";
63import * as ServerConfig from "../config.ts";
64import * as TranscriptArchive from "../archive/TranscriptArchive.ts";
65import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
66import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
67import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
68import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
69import * as NativeAppIconResolver from "./NativeAppIconResolver.ts";
70import { extractIcnsPng } from "./icns.ts";
71import { openMediaFile, readMediaFileHeader, type OpenMediaFile } from "./MediaFile.ts";
72import * as HostProcess from "@t3tools/shared/HostProcess";
73
74export const ASSET_ROUTE_PREFIX = "/api/assets";
75
76const SIGNING_SECRET_NAME = "asset-access-signing-key";
77const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000;
78const PROJECT_FAVICON_TOKEN_BUCKET_MS = 30 * 60 * 1000;
79const PROJECT_FAVICON_VERSION_PREFIX = "v";
80// Clients draw project icons from a 96px thumbnail at most.
81const PROJECT_FAVICON_ICNS_MIN_SIZE = 96;
82const INLINE_VIDEO_MIME_TYPE_PATTERN = /^video\/[\w!#$&^.+-]+$/i;
83// Extensions a document viewer or audio player may request inline. The extension comes from
84// the attachment id the server assigned, never from the client's mime type.
85const INLINE_PREVIEW_MIME_TYPES: Record<string, string> = {
86 pdf: "application/pdf",
87 html: "text/html",
88 htm: "text/html",
89};
90const inlinePreviewMimeTypeForExtension = (extension: string) =>
91 INLINE_PREVIEW_MIME_TYPES[extension] ?? audioMimeTypeFromExtension(`.${extension}`) ?? undefined;
92const PREVIEW_ASSET_EXTENSIONS = new Set([
93 ...WORKSPACE_BROWSER_PREVIEW_EXTENSIONS,
94 ...WORKSPACE_IMAGE_PREVIEW_EXTENSIONS,
95 ".css",
96 ".js",
97 ".mjs",
98 ".otf",
99 ".ttf",
100 ".woff",
101 ".woff2",
102]);
103
104const ArchivedFileClaims = Schema.Struct({
105 version: Schema.Literal(1),
106 kind: Schema.Literal("archived-file"),
107 path: Schema.String,
108 location: Schema.String,
109 hash: Schema.String,
110 fileName: Schema.String,
111 mimeType: Schema.String,
112 download: Schema.Boolean,
113 renderIntent: Schema.optionalKey(Schema.Literal("mcp-app")),
114 expiresAt: Schema.Number,
115});
116const AssetClaimsSchema = Schema.Union([
117 ArchivedFileClaims,
118 Schema.Struct({
119 version: Schema.Literal(1),
120 kind: Schema.Literal("workspace-file"),
121 workspaceRoot: Schema.String,
122 baseRelativePath: Schema.String,
123 expiresAt: Schema.Number,
124 }),
125 Schema.Struct({
126 version: Schema.Literal(1),
127 kind: Schema.Literal("workspace-file-exact"),
128 workspaceRoot: Schema.String,
129 relativePath: Schema.String,
130 expiresAt: Schema.Number,
131 }),
132 Schema.Struct({
133 version: Schema.Literal(1),
134 kind: Schema.Literal("media-file-exact"),
135 filePath: Schema.String,
136 device: Schema.String,
137 inode: Schema.String,
138 expiresAt: Schema.Number,
139 }),
140 Schema.Struct({
141 version: Schema.Literal(1),
142 kind: Schema.Literal("attachment"),
143 attachmentId: Schema.String,
144 /** Decided at mint time. Absent tokens (from before this field) serve
145 inline, which is only ever the image case. */
146 download: Schema.optionalKey(Schema.Boolean),
147 /** Display name and mime the caller supplied at mint time; drive the
148 download filename and Content-Type. */
149 fileName: Schema.optionalKey(Schema.String),
150 mimeType: Schema.optionalKey(Schema.String),
151 renderIntent: Schema.optionalKey(Schema.Literal("mcp-app")),
152 expiresAt: Schema.Number,
153 }),
154 Schema.Struct({
155 version: Schema.Literal(1),
156 kind: Schema.Literal("project-favicon"),
157 workspaceRoot: Schema.String,
158 relativePath: Schema.NullOr(Schema.String),
159 expiresAt: Schema.Number,
160 }),
161 Schema.Struct({
162 version: Schema.Literal(1),
163 kind: Schema.Literal("project-favicon-external"),
164 filePath: Schema.String,
165 expiresAt: Schema.Number,
166 }),
167 Schema.Struct({
168 version: Schema.Literal(1),
169 kind: Schema.Literal("native-app-icon"),
170 app: ToolActivityNativeAppReference,
171 expiresAt: Schema.Number,
172 }),
173 Schema.Struct({
174 version: Schema.Literal(1),
175 kind: Schema.Literal("tool-output-image"),
176 threadId: ThreadId,
177 itemId: TurnItemId,
178 index: Schema.Number,
179 expiresAt: Schema.Number,
180 }),
181 Schema.Struct({
182 version: Schema.Literal(1),
183 kind: Schema.Literal("github-media"),
184 /** Already narrowed to a GitHub media host at mint time; the signature is what keeps it there. */
185 url: Schema.String,
186 cwd: Schema.String,
187 expiresAt: Schema.Number,
188 }),
189]);
190type AssetClaims = typeof AssetClaimsSchema.Type;
191
192const AssetClaimsJson = Schema.fromJsonString(AssetClaimsSchema);
193const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson);
194const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson);
195
196export type ResolvedAsset =
197 | ({ readonly kind: "archived-file" } & Omit<typeof ArchivedFileClaims.Type, "kind" | "version">)
198 | {
199 readonly kind: "file";
200 readonly path: string;
201 readonly download?: boolean;
202 readonly fileName?: string;
203 readonly mimeType?: string;
204 readonly file?: OpenMediaFile;
205 readonly renderIntent?: "mcp-app";
206 }
207 | {
208 readonly kind: "bytes";
209 readonly bytes: Uint8Array;
210 readonly mimeType: string;
211 }
212 | {
213 readonly kind: "github-media";
214 readonly url: string;
215 readonly cwd: string;
216 /** When the signed URL that granted this stops working, which bounds how long a client
217 may keep the bytes it fetched with it. */
218 readonly expiresAt: number;
219 };
220
221function decodeClaims(encodedPayload: string): AssetClaims | null {
222 try {
223 return Option.getOrNull(decodeAssetClaims(base64UrlDecodeUtf8(encodedPayload)));
224 } catch {
225 return null;
226 }
227}
228
229function decodeRelativePath(value: string): string | null {
230 try {
231 return decodeURIComponent(value);
232 } catch {
233 return null;
234 }
235}
236
237const optionOnNotFound = <A, R>(
238 effect: Effect.Effect<A, PlatformError.PlatformError, R>,
239): Effect.Effect<Option.Option<A>, PlatformError.PlatformError, R> =>
240 effect.pipe(
241 Effect.asSome,
242 Effect.catchTags({
243 PlatformError: (error) =>
244 error.reason._tag === "NotFound" ? Effect.succeed(Option.none<A>()) : Effect.fail(error),
245 }),
246 );
247
248/** Reads a stored MCP HTML snapshot; null means it exceeds the preview limit. */
249export const readMcpAppHtml = Effect.fn("AssetAccess.readMcpAppHtml")(function* (filePath: string) {
250 const fileSystem = yield* FileSystem.FileSystem;
251 // Stored snapshots also contain a bounded CSP meta tag (up to 32 domains
252 // per directive). Bound the read even if the attachment grows after minting.
253 const limit = MCP_APP_MAX_HTML_BYTES + 128 * 1024;
254 const chunks = yield* Stream.runCollect(fileSystem.stream(filePath, { bytesToRead: limit + 1 }));
255 const size = chunks.reduce((total, chunk) => total + chunk.byteLength, 0);
256 if (size > limit) return null;
257 const bytes = new Uint8Array(size);
258 let position = 0;
259 for (const chunk of chunks) {
260 bytes.set(chunk, position);
261 position += chunk.byteLength;
262 }
263 return bytes;
264});
265
266/**
267 * Decodes one image a tool returned inline; null when the stored item has no
268 * such image, or it is larger than a provider turn accepts.
269 */
270const readToolOutputImage = Effect.fn("AssetAccess.readToolOutputImage")(function* (input: {
271 readonly threadId: ThreadId;
272 readonly itemId: TurnItemId;
273 readonly index: number;
274}) {
275 const orchestrator = yield* Orchestrator.OrchestratorV2;
276 const item = yield* orchestrator.getTurnItem(input);
277 const image =
278 item?.type === "dynamic_tool" ? toolOutputImages(item.output)[input.index] : undefined;
279 return image?.data === undefined || image.data.length > MAX_TOOL_OUTPUT_IMAGE_BASE64_LENGTH
280 ? null
281 : { mimeType: image.mimeType, bytes: Buffer.from(image.data, "base64") };
282});
283
284/** Browsers display the embedded PNG in an `.icns` image. */
285const imagePreviewAsset = Effect.fn("AssetAccess.imagePreviewAsset")(function* (
286 filePath: string,
287 file?: OpenMediaFile,
288) {
289 if (!filePath.toLowerCase().endsWith(".icns")) {
290 return { kind: "file", path: filePath } satisfies ResolvedAsset;
291 }
292 const fileSystem = yield* FileSystem.FileSystem;
293 const bytes = yield* Effect.gen(function* () {
294 return file
295 ? yield* readMediaFileHeader(filePath, file, Number(file.info.size))
296 : yield* fileSystem.readFile(filePath);
297 }).pipe(
298 Effect.tapError((cause) =>
299 Effect.logError("Failed to read project icon image.", { filePath, cause }),
300 ),
301 Effect.orElseSucceed(() => null),
302 );
303 const png = bytes ? extractIcnsPng(bytes, PROJECT_FAVICON_ICNS_MIN_SIZE) : null;
304 return png
305 ? ({ kind: "bytes", bytes: png, mimeType: "image/png" } satisfies ResolvedAsset)
306 : null;
307});
308
309const resolveCanonicalFile = Effect.fn("AssetAccess.resolveCanonicalFile")(function* (
310 filePath: string,
311) {
312 const fileSystem = yield* FileSystem.FileSystem;
313 const canonicalFile = yield* optionOnNotFound(fileSystem.realPath(filePath));
314 if (Option.isNone(canonicalFile)) return null;
315
316 const info = yield* optionOnNotFound(fileSystem.stat(canonicalFile.value));
317 return Option.isSome(info) && info.value.type === "File" ? canonicalFile.value : null;
318});
319
320const resolveCanonicalWorkspaceFile = Effect.fn("AssetAccess.resolveCanonicalWorkspaceFile")(
321 function* (input: { readonly workspaceRoot: string; readonly relativePath: string }) {
322 const fileSystem = yield* FileSystem.FileSystem;
323 const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
324 const resolved = yield* workspacePaths.resolveRelativePathWithinRoot(input).pipe(
325 Effect.asSome,
326 Effect.catchTags({
327 WorkspacePathOutsideRootError: () => Effect.succeedNone,
328 }),
329 );
330 if (Option.isNone(resolved)) return null;
331
332 const [canonicalRoot, canonicalFile] = yield* Effect.all([
333 optionOnNotFound(fileSystem.realPath(input.workspaceRoot)),
334 optionOnNotFound(fileSystem.realPath(resolved.value.absolutePath)),
335 ]);
336 if (Option.isNone(canonicalRoot) || Option.isNone(canonicalFile)) return null;
337
338 const path = yield* Path.Path;
339 const relative = path.relative(canonicalRoot.value, canonicalFile.value);
340 if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) return null;
341
342 const info = yield* optionOnNotFound(fileSystem.stat(canonicalFile.value));
343 return Option.isSome(info) && info.value.type === "File" ? canonicalFile.value : null;
344 },
345);
346
347const resolveCanonicalWorkspaceFileForRequest = (input: {
348 readonly workspaceRoot: string;
349 readonly relativePath: string;
350}) =>
351 resolveCanonicalWorkspaceFile(input).pipe(
352 Effect.tapError((cause) =>
353 Effect.logError("Failed to resolve canonical asset path.", {
354 workspaceRoot: input.workspaceRoot,
355 relativePath: input.relativePath,
356 cause,
357 }),
358 ),
359 Effect.orElseSucceed(() => null),
360 );
361
362/**
363 * Reads pixel dimensions from an image's header so clients can reserve the
364 * exact box before the bytes arrive. Best effort: an unreadable or unsupported
365 * file just leaves the field out, and the client measures after decode. Only
366 * formats the parser understands are opened; SVG and the rest are skipped.
367 */
368const HEADER_IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
369
370/** From the identity-checked, non-blocking handle the caller already holds. */
371const readImageDimensionsFromOpenFile = (filePath: string, file: OpenMediaFile) =>
372 readMediaFileHeader(filePath, file, IMAGE_DIMENSIONS_HEADER_BYTES).pipe(
373 Effect.map(readImageDimensions),
374 Effect.orElseSucceed((): ImageDimensions | null => null),
375 );
376
377/**
378 * Opens through `openMediaFile` so a path swapped for a FIFO cannot block the
379 * request; a regular open would wait for a writer that never comes.
380 */
381const readImageDimensionsFromHeader = (filePath: string) =>
382 openMediaFile(filePath).pipe(
383 Effect.flatMap((file) =>
384 file === null ? Effect.succeed(null) : readImageDimensionsFromOpenFile(filePath, file),
385 ),
386 Effect.scoped,
387 Effect.orElseSucceed((): ImageDimensions | null => null),
388 );
389
390const finalizeAbsoluteMediaFileAsset = Effect.fn("AssetAccess.finalizeAbsoluteMediaFileAsset")(
391 function* (input: {
392 readonly requestedPath: string;
393 readonly resource: AssetResource;
394 readonly expiresAt: number;
395 }) {
396 const path = yield* Path.Path;
397 const canonicalFile = yield* resolveCanonicalFile(input.requestedPath).pipe(
398 Effect.mapError(
399 (cause) => new AssetWorkspaceAssetInspectionError({ resource: input.resource, cause }),
400 ),
401 );
402 if (!canonicalFile) {
403 return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource });
404 }
405 if (
406 hostPreviewMimeTypeFromExtension(path.extname(canonicalFile)) === null &&
407 !canonicalFile.toLowerCase().endsWith(".icns")
408 ) {
409 return yield* new AssetPreviewTypeValidationError({ resource: input.resource });
410 }
411 const wantsDimensions = HEADER_IMAGE_EXTENSIONS.has(path.extname(canonicalFile).toLowerCase());
412 const opened = yield* openMediaFile(canonicalFile).pipe(
413 Effect.flatMap((file) =>
414 file === null
415 ? Effect.succeed(null)
416 : Effect.map(
417 wantsDimensions
418 ? readImageDimensionsFromOpenFile(canonicalFile, file)
419 : Effect.succeed(null),
420 (dimensions) => ({
421 identity: { device: file.info.dev.toString(), inode: file.info.ino.toString() },
422 dimensions,
423 }),
424 ),
425 ),
426 Effect.scoped,
427 Effect.mapError(
428 (cause) => new AssetWorkspaceAssetInspectionError({ resource: input.resource, cause }),
429 ),
430 );
431 if (!opened) {
432 return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource });
433 }
434 return {
435 claims: {
436 version: 1 as const,
437 kind: "media-file-exact" as const,
438 filePath: canonicalFile,
439 ...opened.identity,
440 expiresAt: input.expiresAt,
441 },
442 fileName: path.basename(canonicalFile),
443 imageDimensions: opened.dimensions,
444 };
445 },
446);
447
448const finalizeWorkspaceFileAsset = Effect.fn("AssetAccess.finalizeWorkspaceFileAsset")(
449 function* (input: {
450 readonly workspaceRoot: string;
451 readonly requestedPath: string;
452 readonly resource: AssetResource;
453 readonly expiresAt: number;
454 }) {
455 const path = yield* Path.Path;
456 const fileSystem = yield* FileSystem.FileSystem;
457 const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
458 const relativePath = path.isAbsolute(input.requestedPath)
459 ? path.relative(input.workspaceRoot, input.requestedPath)
460 : input.requestedPath;
461 const resolved = yield* workspacePaths
462 .resolveRelativePathWithinRoot({ workspaceRoot: input.workspaceRoot, relativePath })
463 .pipe(
464 Effect.mapError(
465 (cause) =>
466 new AssetWorkspacePathValidationError({
467 resource: input.resource,
468 cause,
469 }),
470 ),
471 );
472 const isImage =
473 isWorkspaceImagePreviewPath(resolved.relativePath) ||
474 resolved.relativePath.toLowerCase().endsWith(".icns");
475 if (!isWorkspacePreviewEntryPath(resolved.relativePath) && !isImage) {
476 return yield* new AssetPreviewTypeValidationError({
477 resource: input.resource,
478 });
479 }
480 const canonicalFile = yield* resolveCanonicalWorkspaceFile({
481 workspaceRoot: input.workspaceRoot,
482 relativePath: resolved.relativePath,
483 }).pipe(
484 Effect.mapError(
485 (cause) =>
486 new AssetWorkspaceAssetInspectionError({
487 resource: input.resource,
488 cause,
489 }),
490 ),
491 );
492 if (!canonicalFile) {
493 return yield* new AssetWorkspaceAssetNotFoundError({
494 resource: input.resource,
495 });
496 }
497 const canonicalWorkspaceRoot = yield* fileSystem.realPath(input.workspaceRoot).pipe(
498 Effect.mapError(
499 (cause) =>
500 new AssetWorkspaceResolutionError({
501 resource: input.resource,
502 cause,
503 }),
504 ),
505 );
506 const imageDimensions = HEADER_IMAGE_EXTENSIONS.has(
507 path.extname(resolved.relativePath).toLowerCase(),
508 )
509 ? yield* readImageDimensionsFromHeader(canonicalFile)
510 : null;
511 return {
512 claims: isImage
513 ? {
514 version: 1 as const,
515 kind: "workspace-file-exact" as const,
516 workspaceRoot: canonicalWorkspaceRoot,
517 relativePath: resolved.relativePath,
518 expiresAt: input.expiresAt,
519 }
520 : {
521 version: 1 as const,
522 kind: "workspace-file" as const,
523 workspaceRoot: canonicalWorkspaceRoot,
524 baseRelativePath: path.dirname(resolved.relativePath),
525 expiresAt: input.expiresAt,
526 },
527 fileName: path.basename(resolved.relativePath),
528 imageDimensions,
529 };
530 },
531);
532
533const archivedFile = Effect.fn("AssetAccess.archivedFile")(function* (
534 paths: ReadonlyArray<string>,
535 options: {
536 readonly expiresAt: number;
537 readonly fileName?: string | undefined;
538 readonly mimeType?: string | undefined;
539 readonly download?: boolean | undefined;
540 readonly renderIntent?: "mcp-app" | undefined;
541 },
542) {
543 const archive = yield* TranscriptArchive.TranscriptArchive;
544 const path = yield* Path.Path;
545 const found = yield* archive.asset(paths);
546 if (Option.isNone(found)) return null;
547 const fileName = options.fileName ?? path.basename(found.value.path);
548 return {
549 ...found.value,
550 version: 1 as const,
551 kind: "archived-file" as const,
552 fileName,
553 mimeType:
554 options.mimeType ??
555 hostPreviewMimeTypeFromExtension(path.extname(fileName)) ??
556 "application/octet-stream",
557 download: options.download ?? false,
558 ...(options.renderIntent ? { renderIntent: options.renderIntent } : {}),
559 expiresAt: options.expiresAt,
560 };
561});
562
563const issueClaimUrl = Effect.fn("AssetAccess.issueClaimUrl")(function* (
564 resource: AssetResource,
565 initialClaims: AssetClaims,
566 fileName: string,
567 sourcePath: string | undefined,
568 imageDimensions: ImageDimensions | null,
569) {
570 let claims = initialClaims;
571 let expiresAt = claims.expiresAt;
572 const secretStore = yield* ServerSecretStore.ServerSecretStore;
573 const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32).pipe(
574 Effect.mapError(
575 (cause) =>
576 new AssetSigningKeyLoadError({
577 resource,
578 cause,
579 }),
580 ),
581 );
582 if (claims.kind === "project-favicon" || claims.kind === "project-favicon-external") {
583 const issuedAt = yield* Clock.currentTimeMillis;
584 expiresAt =
585 (Math.floor(issuedAt / PROJECT_FAVICON_TOKEN_BUCKET_MS) + 2) *
586 PROJECT_FAVICON_TOKEN_BUCKET_MS;
587 claims = { ...claims, expiresAt };
588 }
589 const encodedPayload = base64UrlEncode(encodeAssetClaims(claims));
590 const token = `${encodedPayload}.${signPayload(encodedPayload, signingSecret)}`;
591 return {
592 relativeUrl: `${ASSET_ROUTE_PREFIX}/${token}/${encodeURIComponent(fileName)}`,
593 expiresAt,
594 ...(sourcePath !== undefined ? { sourcePath } : {}),
595 ...(imageDimensions !== null ? { imageDimensions } : {}),
596 };
597});
598
599export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (input: {
600 readonly resource: AssetResource;
601 readonly workspaceRoot?: string;
602 readonly projectFaviconPath?: string;
603 /** The project's clone has not landed, so its icon is reported missing without a lookup. */
604 readonly projectCheckoutPending?: boolean;
605}) {
606 const fileSystem = yield* FileSystem.FileSystem;
607 const path = yield* Path.Path;
608 const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
609 let expiresAt = (yield* Clock.currentTimeMillis) + ASSET_TOKEN_TTL_MS;
610 let claims: AssetClaims;
611 let fileName: string;
612 let sourcePath: string | undefined;
613 let imageDimensions: ImageDimensions | null = null;
614
615 const resource = input.resource;
616 let archivePaths: ReadonlyArray<string> = [];
617 if (resource._tag === "attachment") {
618 const input = {
619 attachmentsDir: (yield* ServerConfig.ServerConfig).attachmentsDir,
620 attachmentId: resource.attachmentId,
621 };
622 if (!resolveAttachmentPathById(input)) archivePaths = attachmentPathsById(input);
623 } else if (resource._tag === "media-file" || resource._tag === "workspace-file") {
624 const expanded = expandHomePath(resource.path, yield* HostProcess.HomeDirectory);
625 if (path.isAbsolute(expanded)) archivePaths = [expanded];
626 else if (input.workspaceRoot) archivePaths = [path.resolve(input.workspaceRoot, expanded)];
627 }
628 const present =
629 resource._tag === "attachment"
630 ? []
631 : yield* Effect.forEach(archivePaths, (file) =>
632 fileSystem
633 .exists(file)
634 .pipe(
635 Effect.mapError(
636 (cause) => new AssetWorkspaceAssetInspectionError({ resource, cause }),
637 ),
638 ),
639 );
640 const archived =
641 archivePaths.length > 0 && !present.some(Boolean)
642 ? yield* (yield* TranscriptArchive.TranscriptArchive)
643 .asset(archivePaths)
644 .pipe(
645 Effect.mapError((cause) => new AssetWorkspaceAssetInspectionError({ resource, cause })),
646 )
647 : Option.none();
648 if (Option.isSome(archived)) {
649 const archivedPath = archived.value.path;
650 fileName =
651 resource._tag === "attachment"
652 ? (resource.fileName ?? path.basename(archivedPath))
653 : path.basename(archivedPath);
654 const mimeType =
655 (resource._tag === "attachment" ? resource.mimeType : undefined) ??
656 hostPreviewMimeTypeFromExtension(path.extname(fileName)) ??
657 "application/octet-stream";
658 claims = {
659 version: 1,
660 kind: "archived-file",
661 path: archivedPath,
662 location: archived.value.location,
663 hash: archived.value.hash,
664 fileName,
665 mimeType,
666 download:
667 resource._tag === "attachment" &&
668 resource.disposition !== "inline" &&
669 !/^(image|video)\//u.test(mimeType),
670 ...(resource._tag === "attachment" && resource.renderIntent === "mcp-app"
671 ? { renderIntent: "mcp-app" as const }
672 : {}),
673 expiresAt,
674 };
675 return yield* issueClaimUrl(resource, claims, fileName, archivedPath, null);
676 }
677 switch (input.resource._tag) {
678 case "media-file": {
679 let requestedPath = expandHomePath(input.resource.path, yield* HostProcess.HomeDirectory);
680 if (!path.isAbsolute(requestedPath)) {
681 if (!input.workspaceRoot) {
682 return yield* new AssetWorkspaceContextNotFoundError({ resource: input.resource });
683 }
684 const workspaceRoot = yield* workspacePaths
685 .normalizeWorkspaceRoot(input.workspaceRoot)
686 .pipe(
687 Effect.mapError(
688 (cause) =>
689 new AssetWorkspaceRootNormalizationError({ resource: input.resource, cause }),
690 ),
691 );
692 requestedPath = path.resolve(workspaceRoot, requestedPath);
693 }
694 const finalized = yield* finalizeAbsoluteMediaFileAsset({
695 requestedPath,
696 resource: input.resource,
697 expiresAt,
698 });
699 claims = finalized.claims;
700 fileName = finalized.fileName;
701 imageDimensions = finalized.imageDimensions;
702 break;
703 }
704 case "workspace-file": {
705 if (!input.workspaceRoot) {
706 return yield* new AssetWorkspaceContextNotFoundError({
707 resource: input.resource,
708 });
709 }
710 const workspaceRoot = yield* workspacePaths.normalizeWorkspaceRoot(input.workspaceRoot).pipe(
711 Effect.mapError(
712 (cause) =>
713 new AssetWorkspaceRootNormalizationError({
714 resource: input.resource,
715 cause,
716 }),
717 ),
718 );
719 const finalized = yield* finalizeWorkspaceFileAsset({
720 workspaceRoot,
721 requestedPath: input.resource.path,
722 resource: input.resource,
723 expiresAt,
724 });
725 claims = finalized.claims;
726 fileName = finalized.fileName;
727 imageDimensions = finalized.imageDimensions;
728 break;
729 }
730 case "draft-workspace-file": {
731 // The draft names its workspace root in the resource itself; an explicit
732 // root only overrides it.
733 const draftWorkspaceRoot = input.workspaceRoot ?? input.resource.cwd;
734 if (path.isAbsolute(input.resource.path)) {
735 // An absolute draft path serves exactly like an absolute media path.
736 const finalized = yield* finalizeAbsoluteMediaFileAsset({
737 requestedPath: input.resource.path,
738 resource: input.resource,
739 expiresAt,
740 });
741 claims = finalized.claims;
742 fileName = finalized.fileName;
743 imageDimensions = finalized.imageDimensions;
744 break;
745 }
746 const workspaceRoot = yield* workspacePaths.normalizeWorkspaceRoot(draftWorkspaceRoot).pipe(
747 Effect.mapError(
748 (cause) =>
749 new AssetWorkspaceRootNormalizationError({
750 resource: input.resource,
751 cause,
752 }),
753 ),
754 );
755 const finalized = yield* finalizeWorkspaceFileAsset({
756 workspaceRoot,
757 requestedPath: input.resource.path,
758 resource: input.resource,
759 expiresAt,
760 });
761 claims = finalized.claims;
762 fileName = finalized.fileName;
763 imageDimensions = finalized.imageDimensions;
764 break;
765 }
766 case "attachment": {
767 const config = yield* ServerConfig.ServerConfig;
768 const attachmentPath = resolveAttachmentPathById({
769 attachmentsDir: config.attachmentsDir,
770 attachmentId: input.resource.attachmentId,
771 });
772 if (!attachmentPath) {
773 return yield* new AssetAttachmentNotFoundError({
774 resource: input.resource,
775 });
776 }
777 // Generic files carry their extension inside the attachment id (that
778 // shape resolves the on-disk path); images do not. Videos and images
779 // render inline. Other generic files download unless a viewer requests
780 // a supported document or audio format inline.
781 const extension = parseAttachmentFileExtension(input.resource.attachmentId);
782 const isGenericFile = extension !== null;
783 const videoMimeType = input.resource.mimeType?.split(";", 1)[0]?.trim() ?? "";
784 const isVideo = INLINE_VIDEO_MIME_TYPE_PATTERN.test(videoMimeType);
785 const inlinePreviewMimeType =
786 input.resource.disposition === "inline" && extension !== null
787 ? inlinePreviewMimeTypeForExtension(extension)
788 : undefined;
789 if (!isGenericFile) {
790 imageDimensions = yield* readImageDimensionsFromHeader(attachmentPath);
791 }
792 claims = {
793 version: 1,
794 kind: "attachment",
795 attachmentId: input.resource.attachmentId,
796 ...(isGenericFile && !isVideo && inlinePreviewMimeType === undefined
797 ? { download: true }
798 : {}),
799 ...(input.resource.fileName !== undefined ? { fileName: input.resource.fileName } : {}),
800 ...(inlinePreviewMimeType !== undefined
801 ? { mimeType: inlinePreviewMimeType }
802 : input.resource.mimeType !== undefined
803 ? { mimeType: isVideo ? videoMimeType : input.resource.mimeType }
804 : {}),
805 ...(input.resource.renderIntent === "mcp-app" && inlinePreviewMimeType === "text/html"
806 ? { renderIntent: "mcp-app" as const }
807 : {}),
808 expiresAt,
809 };
810 fileName = input.resource.fileName ?? path.basename(attachmentPath);
811 break;
812 }
813 case "project-favicon": {
814 const workspaceRoot = yield* workspacePaths.normalizeWorkspaceRoot(input.resource.cwd).pipe(
815 Effect.mapError(
816 (cause) =>
817 new AssetWorkspaceRootNormalizationError({
818 resource: input.resource,
819 cause,
820 }),
821 ),
822 );
823 const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
824 // A lookup in a half-cloned checkout would cache a miss that outlives the clone.
825 const faviconPath = input.projectCheckoutPending
826 ? null
827 : yield* faviconResolver
828 .resolvePath(workspaceRoot, input.projectFaviconPath ?? undefined)
829 .pipe(
830 Effect.mapError(
831 (cause) =>
832 new AssetProjectFaviconResolutionError({
833 resource: input.resource,
834 cause,
835 }),
836 ),
837 );
838 const isExternalOverride =
839 faviconPath !== null &&
840 input.projectFaviconPath !== undefined &&
841 path.isAbsolute(input.projectFaviconPath) &&
842 path.normalize(faviconPath) === path.normalize(input.projectFaviconPath);
843 const relativePath =
844 faviconPath && !isExternalOverride ? path.relative(workspaceRoot, faviconPath) : null;
845 const sourceFaviconPath = isExternalOverride ? faviconPath : relativePath;
846 if (sourceFaviconPath && !isProjectFaviconPath(sourceFaviconPath)) {
847 return yield* new AssetPreviewTypeValidationError({ resource: input.resource });
848 }
849 sourcePath = sourceFaviconPath ?? undefined;
850 const canonicalFaviconPath = sourceFaviconPath
851 ? yield* (
852 isExternalOverride
853 ? resolveCanonicalFile(sourceFaviconPath)
854 : resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath: sourceFaviconPath })
855 ).pipe(
856 Effect.mapError(
857 (cause) =>
858 new AssetProjectFaviconInspectionError({
859 resource: input.resource,
860 cause,
861 }),
862 ),
863 )
864 : null;
865 if (sourceFaviconPath && !canonicalFaviconPath) {
866 return yield* new AssetProjectFaviconNotFoundError({
867 resource: input.resource,
868 });
869 }
870 claims =
871 isExternalOverride && canonicalFaviconPath
872 ? {
873 version: 1,
874 kind: "project-favicon-external",
875 filePath: canonicalFaviconPath,
876 expiresAt,
877 }
878 : {
879 version: 1,
880 kind: "project-favicon",
881 workspaceRoot: yield* fileSystem.realPath(workspaceRoot).pipe(
882 Effect.mapError(
883 (cause) =>
884 new AssetWorkspaceResolutionError({
885 resource: input.resource,
886 cause,
887 }),
888 ),
889 ),
890 relativePath,
891 expiresAt,
892 };
893 if (sourceFaviconPath && canonicalFaviconPath) {
894 const crypto = yield* Crypto.Crypto;
895 const faviconBytes = yield* fileSystem.readFile(canonicalFaviconPath).pipe(
896 Effect.mapError(
897 (cause) =>
898 new AssetProjectFaviconInspectionError({
899 resource: input.resource,
900 cause,
901 }),
902 ),
903 );
904 const revision = yield* crypto.digest("SHA-256", faviconBytes).pipe(
905 Effect.map(Hex.encode),
906 Effect.mapError(
907 (cause) =>
908 new AssetProjectFaviconInspectionError({
909 resource: input.resource,
910 cause,
911 }),
912 ),
913 );
914 fileName = `${PROJECT_FAVICON_VERSION_PREFIX}${revision}-${path.basename(sourceFaviconPath)}`;
915 } else {
916 fileName = PROJECT_FAVICON_FALLBACK_MARKER;
917 }
918 break;
919 }
920 case "tool-output-image": {
921 const image = yield* readToolOutputImage(input.resource).pipe(
922 Effect.mapError(
923 (cause) => new AssetWorkspaceAssetInspectionError({ resource: input.resource, cause }),
924 ),
925 );
926 if (image === null) {
927 return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource });
928 }
929 claims = {
930 version: 1,
931 kind: "tool-output-image",
932 threadId: input.resource.threadId,
933 itemId: input.resource.itemId,
934 index: input.resource.index,
935 expiresAt,
936 };
937 // The allowed types are all `image/<extension>`.
938 fileName = `image-${input.resource.index + 1}.${image.mimeType.slice("image/".length)}`;
939 imageDimensions = readImageDimensions(image.bytes);
940 break;
941 }
942 case "native-app-icon": {
943 claims = {
944 version: 1,
945 kind: "native-app-icon",
946 app: input.resource.app,
947 expiresAt,
948 };
949 fileName = "native-app-icon.png";
950 break;
951 }
952 case "github-media": {
953 const fetchUrl = githubMediaFetchUrl(input.resource.url);
954 if (fetchUrl === null) {
955 return yield* new AssetGitHubMediaUrlValidationError({});
956 }
957 claims = {
958 version: 1,
959 kind: "github-media",
960 url: fetchUrl,
961 cwd: input.resource.cwd,
962 expiresAt,
963 };
964 fileName = githubMediaFileName(fetchUrl);
965 break;
966 }
967 }
968
969 return yield* issueClaimUrl(input.resource, claims, fileName, sourcePath, imageDimensions);
970});
971
972export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
973 token: string,
974 relativePath: string,
975) {
976 const [encodedPayload, signature] = token.split(".");
977 if (!encodedPayload || !signature) return null;
978
979 const secretStore = yield* ServerSecretStore.ServerSecretStore;
980 const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32).pipe(
981 Effect.tapError((cause) => Effect.logError("Failed to load the asset signing key.", { cause })),
982 Effect.orElseSucceed(() => null),
983 );
984 if (!signingSecret) return null;
985 if (!timingSafeEqualBase64Url(signature, signPayload(encodedPayload, signingSecret))) return null;
986
987 const claims = decodeClaims(encodedPayload);
988 if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) return null;
989
990 if (claims.kind === "archived-file") {
991 if (decodeRelativePath(relativePath) !== claims.fileName) return null;
992 return { ...claims, kind: "archived-file" } satisfies ResolvedAsset;
993 }
994
995 if (claims.kind === "attachment") {
996 const config = yield* ServerConfig.ServerConfig;
997 const attachmentPath = resolveAttachmentPathById({
998 attachmentsDir: config.attachmentsDir,
999 attachmentId: claims.attachmentId,
1000 });
1001 if (!attachmentPath)
1002 return yield* archivedFile(
1003 attachmentPathsById({
1004 attachmentsDir: config.attachmentsDir,
1005 attachmentId: claims.attachmentId,
1006 }),
1007 claims,
1008 ).pipe(Effect.orElseSucceed(() => null));
1009 const fileSystem = yield* FileSystem.FileSystem;
1010 const info = yield* optionOnNotFound(fileSystem.stat(attachmentPath)).pipe(
1011 Effect.tapError((cause) =>
1012 Effect.logError("Failed to inspect attachment asset.", {
1013 attachmentId: claims.attachmentId,
1014 path: attachmentPath,
1015 cause,
1016 }),
1017 ),
1018 Effect.orElseSucceed(() => Option.none()),
1019 );
1020 return Option.isSome(info) && info.value.type === "File"
1021 ? ({
1022 kind: "file",
1023 path: attachmentPath,
1024 ...(claims.download ? { download: true } : {}),
1025 ...(claims.fileName !== undefined ? { fileName: claims.fileName } : {}),
1026 ...(claims.mimeType !== undefined ? { mimeType: claims.mimeType } : {}),
1027 ...(claims.renderIntent === "mcp-app" ? { renderIntent: "mcp-app" as const } : {}),
1028 } satisfies ResolvedAsset)
1029 : null;
1030 }
1031
1032 if (claims.kind === "project-favicon") {
1033 if (claims.relativePath === null) return null;
1034 const faviconPath = yield* resolveCanonicalWorkspaceFileForRequest({
1035 workspaceRoot: claims.workspaceRoot,
1036 relativePath: claims.relativePath,
1037 });
1038 return faviconPath ? yield* imagePreviewAsset(faviconPath) : null;
1039 }
1040
1041 if (claims.kind === "project-favicon-external") {
1042 const faviconPath = yield* resolveCanonicalFile(claims.filePath).pipe(
1043 Effect.tapError((cause) =>
1044 Effect.logError("Failed to resolve canonical asset path.", {
1045 filePath: claims.filePath,
1046 cause,
1047 }),
1048 ),
1049 Effect.orElseSucceed(() => null),
1050 );
1051 return faviconPath === claims.filePath ? yield* imagePreviewAsset(faviconPath) : null;
1052 }
1053
1054 if (claims.kind === "github-media") {
1055 return {
1056 kind: "github-media",
1057 url: claims.url,
1058 cwd: claims.cwd,
1059 expiresAt: claims.expiresAt,
1060 } satisfies ResolvedAsset;
1061 }
1062
1063 if (claims.kind === "tool-output-image") {
1064 const image = yield* readToolOutputImage(claims).pipe(
1065 Effect.tapError((cause) =>
1066 Effect.logError("Failed to read tool output image.", {
1067 threadId: claims.threadId,
1068 itemId: claims.itemId,
1069 cause,
1070 }),
1071 ),
1072 Effect.orElseSucceed(() => null),
1073 );
1074 return image ? ({ kind: "bytes", ...image } satisfies ResolvedAsset) : null;
1075 }
1076
1077 if (claims.kind === "native-app-icon") {
1078 const nativeAppIconResolver = yield* NativeAppIconResolver.NativeAppIconResolver;
1079 const iconPath = yield* nativeAppIconResolver.resolve(claims.app);
1080 return iconPath ? ({ kind: "file", path: iconPath } satisfies ResolvedAsset) : null;
1081 }
1082
1083 const decodedPath = decodeRelativePath(relativePath);
1084 if (decodedPath === null) return null;
1085 const path = yield* Path.Path;
1086 if (claims.kind === "media-file-exact") {
1087 if (decodedPath !== path.basename(claims.filePath)) return null;
1088 const canonicalFile = yield* resolveCanonicalFile(claims.filePath).pipe(
1089 Effect.tapError((cause) =>
1090 Effect.logError("Failed to resolve canonical media path.", {
1091 filePath: claims.filePath,
1092 cause,
1093 }),
1094 ),
1095 Effect.orElseSucceed(() => null),
1096 );
1097 if (canonicalFile === null)
1098 return yield* archivedFile([claims.filePath], { expiresAt: claims.expiresAt }).pipe(
1099 Effect.orElseSucceed(() => null),
1100 );
1101 if (canonicalFile !== claims.filePath) return null;
1102 const isIcns = canonicalFile.toLowerCase().endsWith(".icns");
1103 const mimeType = isIcns
1104 ? "image/png"
1105 : hostPreviewMimeTypeFromExtension(path.extname(canonicalFile));
1106 if (!mimeType) return null;
1107 const file = yield* openMediaFile(canonicalFile, claims).pipe(
1108 Effect.tapError((cause) =>
1109 Effect.logError("Failed to open canonical media file.", { filePath: canonicalFile, cause }),
1110 ),
1111 Effect.orElseSucceed(() => null),
1112 );
1113 if (!file) return null;
1114 return isIcns
1115 ? yield* imagePreviewAsset(canonicalFile, file)
1116 : ({ kind: "file", path: canonicalFile, mimeType, file } satisfies ResolvedAsset);
1117 }
1118 if (claims.kind === "workspace-file-exact") {
1119 if (decodedPath !== path.basename(claims.relativePath)) return null;
1120 const exactWorkspaceFile = yield* resolveCanonicalWorkspaceFileForRequest({
1121 workspaceRoot: claims.workspaceRoot,
1122 relativePath: claims.relativePath,
1123 });
1124 return exactWorkspaceFile
1125 ? yield* imagePreviewAsset(exactWorkspaceFile)
1126 : yield* archivedFile([path.resolve(claims.workspaceRoot, claims.relativePath)], {
1127 expiresAt: claims.expiresAt,
1128 }).pipe(Effect.orElseSucceed(() => null));
1129 }
1130 const segments = decodedPath.split(/[\\/]/);
1131 if (
1132 decodedPath.length === 0 ||
1133 decodedPath.includes("\0") ||
1134 segments.some((segment) => segment === "." || segment === ".." || segment.startsWith(".")) ||
1135 !PREVIEW_ASSET_EXTENSIONS.has(path.extname(decodedPath).toLowerCase())
1136 ) {
1137 return null;
1138 }
1139 const joinedRelativePath =
1140 claims.baseRelativePath === "." ? decodedPath : path.join(claims.baseRelativePath, decodedPath);
1141 const workspaceFile = yield* resolveCanonicalWorkspaceFileForRequest({
1142 workspaceRoot: claims.workspaceRoot,
1143 relativePath: joinedRelativePath,
1144 });
1145 return workspaceFile
1146 ? ({ kind: "file", path: workspaceFile } satisfies ResolvedAsset)
1147 : yield* archivedFile([path.resolve(claims.workspaceRoot, joinedRelativePath)], {
1148 expiresAt: claims.expiresAt,
1149 }).pipe(Effect.orElseSucceed(() => null));
1150});