Skip to content
Source
Sign inSign up

AssetAccess.ts

1,035 lines37.1 KB

1import type { AssetResource } from "@t3tools/contracts";
2import {
3 AssetAttachmentNotFoundError,
4 AssetSourceControlMediaUrlValidationError,
5 AssetPreviewTypeValidationError,
6 AssetProjectFaviconInspectionError,
7 AssetProjectFaviconNotFoundError,
8 AssetProjectFaviconResolutionError,
9 AssetSigningKeyLoadError,
10 AssetWorkspaceAssetInspectionError,
11 AssetWorkspaceAssetNotFoundError,
12 AssetWorkspaceContextNotFoundError,
13 AssetWorkspacePathValidationError,
14 AssetWorkspaceResolutionError,
15 AssetWorkspaceRootNormalizationError,
16 SOURCE_CONTROL_MEDIA_ASSET,
17 ThreadId,
18 NativeAppIconReference,
19 TurnItemId,
20} from "@t3tools/contracts";
21import {
22 hostPreviewMimeTypeFromExtension,
23 isWorkspaceAudioPreviewPath,
24 isWorkspacePreviewEntryPath,
25 isWorkspaceVideoPreviewPath,
26 WORKSPACE_BROWSER_PREVIEW_EXTENSIONS,
27} from "@t3tools/shared/filePreview";
28import {
29 IMAGE_DIMENSIONS_HEADER_BYTES,
30 readImageDimensions,
31 type ImageDimensions,
32} from "@t3tools/shared/imageDimensions";
33import { MCP_APP_MAX_HTML_BYTES } from "@t3tools/shared/mcpApp";
34import {
35 isProjectFaviconPath,
36 PROJECT_FAVICON_FALLBACK_MARKER,
37} from "@t3tools/shared/projectFavicon";
38import { isNetworkPath } from "@t3tools/shared/path";
39import { MAX_TOOL_OUTPUT_IMAGE_BASE64_LENGTH, toolOutputImages } from "@t3tools/shared/toolOutput";
40import * as Clock from "effect/Clock";
41import * as Digest from "effect/crypto/Digest";
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 Schema from "effect/Schema";
48import * as Stream from "effect/Stream";
49​
50import { base64UrlDecodeUtf8, base64UrlEncode } from "../auth/utils.ts";
51import * as AssetSigner from "./AssetSigner.ts";
52import { makeIsServerInternalPath } from "../auth/serverInternalPaths.ts";
53import { parseAttachmentFileExtension, resolveAttachmentPathById } from "../attachmentStore.ts";
54import * as ServerConfig from "../config.ts";
55import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
56import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
57import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
58import { isWithinRoot } from "../workspace/KnownWorkspaces.ts";
59import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
60import * as NativeAppIconResolver from "./NativeAppIconResolver.ts";
61import { sourceControlDefinitions } from "../sourceControl/sourceControlDefinitions.ts";
62import { extractIcnsPng } from "./icns.ts";
63import { openMediaFile, readMediaFileHeader, type OpenMediaFile } from "./MediaFile.ts";
64import * as HostProcess from "@t3tools/shared/HostProcess";
65​
66export const ASSET_ROUTE_PREFIX = "/api/assets";
67​
68const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000;
69const PROJECT_FAVICON_TOKEN_BUCKET_MS = 30 * 60 * 1000;
70const HTML_ATTACHMENT_TOKEN_BUCKET_MS = 30 * 60 * 1000;
71const PROJECT_FAVICON_VERSION_PREFIX = "v";
72const PROJECT_FAVICON_ICNS_MIN_SIZE = 96;
73const INLINE_VIDEO_MIME_TYPE_PATTERN = /^video\/[\w!#$&^.+-]+$/i;
74// Use server-assigned attachment extensions, never client MIME types.
75const INLINE_PREVIEW_MIME_TYPES: Record<string, string> = {
76 svg: "image/svg+xml",
77 pdf: "application/pdf",
78 html: "text/html",
79 htm: "text/html",
80};
81const inlinePreviewMimeTypeForExtension = (extension: string) =>
82 INLINE_PREVIEW_MIME_TYPES[extension] ??
83 hostPreviewMimeTypeFromExtension(`.${extension}`) ??
84 undefined;
85const PREVIEW_ASSET_EXTENSIONS = new Set([
86 ...WORKSPACE_BROWSER_PREVIEW_EXTENSIONS,
87 // Keep HTML dependency grants independent of newly supported file viewers.
88 ".avif",
89 ".gif",
90 ".ico",
91 ".jpeg",
92 ".jpg",
93 ".png",
94 ".svg",
95 ".webp",
96 ".css",
97 ".js",
98 ".mjs",
99 ".otf",
100 ".ttf",
101 ".woff",
102 ".woff2",
103]);
104​
105const AssetClaimsSchema = Schema.Union([
106 Schema.Struct({
107 version: Schema.Literal(1),
108 kind: Schema.Literal("workspace-file"),
109 workspaceRoot: Schema.String,
110 baseRelativePath: Schema.String,
111 expiresAt: Schema.Number,
112 }),
113 Schema.Struct({
114 version: Schema.Literal(1),
115 kind: Schema.Literal("workspace-file-exact"),
116 workspaceRoot: Schema.String,
117 relativePath: Schema.String,
118 expiresAt: Schema.Number,
119 }),
120 Schema.Struct({
121 version: Schema.Literal(1),
122 kind: Schema.Literal("media-file-exact"),
123 filePath: Schema.String,
124 download: Schema.optionalKey(Schema.Boolean),
125 device: Schema.String,
126 inode: Schema.String,
127 expiresAt: Schema.Number,
128 }),
129 Schema.Struct({
130 version: Schema.Literal(1),
131 kind: Schema.Literal("attachment"),
132 attachmentId: Schema.String,
133 download: Schema.optionalKey(Schema.Boolean),
134 fileName: Schema.optionalKey(Schema.String),
135 mimeType: Schema.optionalKey(Schema.String),
136 renderIntent: Schema.optionalKey(Schema.Literal("mcp-app")),
137 expiresAt: Schema.Number,
138 }),
139 Schema.Struct({
140 version: Schema.Literal(1),
141 kind: Schema.Literal("project-favicon"),
142 workspaceRoot: Schema.String,
143 relativePath: Schema.NullOr(Schema.String),
144 expiresAt: Schema.Number,
145 }),
146 Schema.Struct({
147 version: Schema.Literal(1),
148 kind: Schema.Literal("project-favicon-external"),
149 filePath: Schema.String,
150 expiresAt: Schema.Number,
151 }),
152 Schema.Struct({
153 version: Schema.Literal(1),
154 kind: Schema.Literal("native-app-icon"),
155 app: NativeAppIconReference,
156 expiresAt: Schema.Number,
157 }),
158 Schema.Struct({
159 version: Schema.Literal(1),
160 kind: Schema.Literal("tool-output-image"),
161 threadId: ThreadId,
162 itemId: TurnItemId,
163 index: Schema.Number,
164 expiresAt: Schema.Number,
165 }),
166 Schema.Struct({
167 version: Schema.Literal(1),
168 kind: Schema.Literal(SOURCE_CONTROL_MEDIA_ASSET),
169 /** A host's `fetchUrl`, narrowed at mint time; the signature is what keeps it there. */
170 url: Schema.String,
171 cwd: Schema.String,
172 repository: Schema.optionalKey(Schema.String),
173 expiresAt: Schema.Number,
174 }),
175]);
176type AssetClaims = typeof AssetClaimsSchema.Type;
177​
178const AssetClaimsJson = Schema.fromJsonString(AssetClaimsSchema);
179const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson);
180const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson);
181​
182export type ResolvedAsset =
183 | {
184 readonly kind: "file";
185 readonly path: string;
186 readonly download?: boolean;
187 readonly fileName?: string;
188 readonly mimeType?: string;
189 readonly file?: OpenMediaFile;
190 readonly renderIntent?: "mcp-app";
191 }
192 | {
193 readonly kind: "bytes";
194 readonly bytes: Uint8Array;
195 readonly mimeType: string;
196 }
197 | {
198 readonly kind: "source-control-media";
199 readonly url: string;
200 readonly cwd: string;
201 readonly repository?: string;
202 readonly expiresAt: number;
203 };
204​
205function decodeClaims(encodedPayload: string): AssetClaims | null {
206 try {
207 return Option.getOrNull(decodeAssetClaims(base64UrlDecodeUtf8(encodedPayload)));
208 } catch {
209 return null;
210 }
212​
213function decodeRelativePath(value: string): string | null {
214 try {
215 return decodeURIComponent(value);
216 } catch {
217 return null;
218 }
220​
221export const readMcpAppHtml = Effect.fn("AssetAccess.readMcpAppHtml")(function* (filePath: string) {
222 const fileSystem = yield* FileSystem.FileSystem;
223 // Allow space for stored CSP tags, but bound reads if the attachment grows after minting.
224 const limit = MCP_APP_MAX_HTML_BYTES + 128 * 1024;
225 const chunks = yield* Stream.runCollect(fileSystem.stream(filePath, { bytesToRead: limit + 1 }));
226 const size = chunks.reduce((total, chunk) => total + chunk.byteLength, 0);
227 if (size > limit) return null;
228 const bytes = new Uint8Array(size);
229 let position = 0;
230 for (const chunk of chunks) {
231 bytes.set(chunk, position);
232 position += chunk.byteLength;
233 }
234 return bytes;
235});
236​
237const readToolOutputImage = Effect.fn("AssetAccess.readToolOutputImage")(function* (input: {
238 readonly threadId: ThreadId;
239 readonly itemId: TurnItemId;
240 readonly index: number;
241}) {
242 const orchestrator = yield* Orchestrator.OrchestratorV2;
243 const item = yield* orchestrator.getTurnItem(input);
244 const image =
245 item?.type === "dynamic_tool" ? toolOutputImages(item.output)[input.index] : undefined;
246 return image?.data === undefined || image.data.length > MAX_TOOL_OUTPUT_IMAGE_BASE64_LENGTH
247 ? null
248 : { mimeType: image.mimeType, bytes: Buffer.from(image.data, "base64") };
249});
250​
251// Browsers cannot draw .icns; serve its embedded PNG.
252const projectFaviconAsset = Effect.fn("AssetAccess.projectFaviconAsset")(function* (
253 filePath: string,
254) {
255 if (!filePath.toLowerCase().endsWith(".icns")) {
256 return { kind: "file", path: filePath } satisfies ResolvedAsset;
257 }
258 const fileSystem = yield* FileSystem.FileSystem;
259 const bytes = yield* fileSystem.readFile(filePath).pipe(
260 Effect.tapError((cause) =>
261 Effect.logError("Failed to read project icon image.", { filePath, cause }),
262 ),
263 Effect.orElseSucceed(() => null),
264 );
265 const png = bytes ? extractIcnsPng(bytes, PROJECT_FAVICON_ICNS_MIN_SIZE) : null;
266 return png
267 ? ({ kind: "bytes", bytes: png, mimeType: "image/png" } satisfies ResolvedAsset)
268 : null;
269});
270​
271const resolveCanonicalFile = Effect.fn("AssetAccess.resolveCanonicalFile")(function* (
272 filePath: string,
273) {
274 const fileSystem = yield* FileSystem.FileSystem;
275 const canonicalFile = yield* ProjectFaviconResolver.optionOnNotFound(
276 fileSystem.realPath(filePath),
277 );
278 if (Option.isNone(canonicalFile)) return null;
279​
280 const info = yield* ProjectFaviconResolver.optionOnNotFound(fileSystem.stat(canonicalFile.value));
281 if (Option.isNone(info) || info.value.type !== "File") return null;
282 const isServerInternalPath = yield* makeIsServerInternalPath;
283 return (yield* isServerInternalPath(canonicalFile.value)) ? null : canonicalFile.value;
284});
285​
286const resolveCanonicalWorkspaceFile = Effect.fn("AssetAccess.resolveCanonicalWorkspaceFile")(
287 function* (input: { readonly workspaceRoot: string; readonly relativePath: string }) {
288 const fileSystem = yield* FileSystem.FileSystem;
289 const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
290 const resolved = yield* workspacePaths.resolveRelativePathWithinRoot(input).pipe(
291 Effect.asSome,
292 Effect.catchTags({
293 WorkspacePathOutsideRootError: () => Effect.succeedNone,
294 }),
295 );
296 if (Option.isNone(resolved)) return null;
297​
298 const [canonicalRoot, canonicalFile] = yield* Effect.all([
299 ProjectFaviconResolver.optionOnNotFound(fileSystem.realPath(input.workspaceRoot)),
300 ProjectFaviconResolver.optionOnNotFound(fileSystem.realPath(resolved.value.absolutePath)),
301 ]);
302 if (Option.isNone(canonicalRoot) || Option.isNone(canonicalFile)) return null;
303​
304 const path = yield* Path.Path;
305 const relative = path.relative(canonicalRoot.value, canonicalFile.value);
306 if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) return null;
307​
308 const info = yield* ProjectFaviconResolver.optionOnNotFound(
309 fileSystem.stat(canonicalFile.value),
310 );
311 if (Option.isNone(info) || info.value.type !== "File") return null;
312 const isServerInternalPath = yield* makeIsServerInternalPath;
313 return (yield* isServerInternalPath(canonicalFile.value)) ? null : canonicalFile.value;
314 },
315);
316​
317const resolveCanonicalWorkspaceFileForRequest = (input: {
318 readonly workspaceRoot: string;
319 readonly relativePath: string;
320}) =>
321 resolveCanonicalWorkspaceFile(input).pipe(
322 Effect.tapError((cause) =>
323 Effect.logError("Failed to resolve canonical asset path.", {
324 workspaceRoot: input.workspaceRoot,
325 relativePath: input.relativePath,
326 cause,
327 }),
328 ),
329 Effect.orElseSucceed(() => null),
330 );
331​
332const HEADER_IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
333​
334const readImageDimensionsFromOpenFile = (filePath: string, file: OpenMediaFile) =>
335 readMediaFileHeader(filePath, file, IMAGE_DIMENSIONS_HEADER_BYTES).pipe(
336 Effect.map(readImageDimensions),
337 Effect.orElseSucceed((): ImageDimensions | null => null),
338 );
339​
340// Non-blocking open prevents swapped-in FIFOs from hanging the request.
341const readImageDimensionsFromHeader = (filePath: string) =>
342 openMediaFile(filePath).pipe(
343 Effect.flatMap((file) =>
344 file === null ? Effect.succeed(null) : readImageDimensionsFromOpenFile(filePath, file),
345 ),
346 Effect.scoped,
347 Effect.orElseSucceed((): ImageDimensions | null => null),
348 );
349​
350const finalizeAbsoluteMediaFileAsset = Effect.fn("AssetAccess.finalizeAbsoluteMediaFileAsset")(
351 function* (input: {
352 readonly requestedPath: string;
353 readonly expectedCanonicalPath?: string;
354 readonly workspaceRoot: string | undefined;
355 readonly resource: AssetResource;
356 readonly expiresAt: number;
357 }) {
358 const path = yield* Path.Path;
359 // Resolving a network path makes Windows sign in to its host with the
360 // user's NTLM credentials, so only the thread's own project may be on a share.
361 // Windows reads a `\\?\` path as written, `..` included, so the lookup gets
362 // the resolved path this check approved.
363 const requestedPath = path.resolve(input.requestedPath);
364 if (
365 isNetworkPath(input.requestedPath) &&
366 (input.workspaceRoot === undefined ||
367 !isWithinRoot(path, requestedPath, path.resolve(input.workspaceRoot)))
368 ) {
369 return yield* new AssetWorkspaceAssetNotFoundError({
370 resource: input.resource,
371 checkedPath: input.requestedPath,
372 });
373 }
374 const canonicalFile = yield* resolveCanonicalFile(requestedPath).pipe(
375 Effect.mapError(
376 (cause) => new AssetWorkspaceAssetInspectionError({ resource: input.resource, cause }),
377 ),
378 );
379 // The workspace caller pins its containment-checked path; never follow a
380 // replacement symlink to a different file during this second resolution.
381 if (
382 !canonicalFile ||
383 (input.expectedCanonicalPath !== undefined && canonicalFile !== input.expectedCanonicalPath)
384 ) {
385 return yield* new AssetWorkspaceAssetNotFoundError({
386 resource: input.resource,
387 checkedPath: input.requestedPath,
388 });
389 }
390 const download =
391 input.resource._tag === "media-file" && input.resource.disposition === "attachment";
392 if (!download && hostPreviewMimeTypeFromExtension(path.extname(canonicalFile)) === null) {
393 return yield* new AssetPreviewTypeValidationError({ resource: input.resource });
394 }
395 const wantsDimensions = HEADER_IMAGE_EXTENSIONS.has(path.extname(canonicalFile).toLowerCase());
396 const opened = yield* openMediaFile(canonicalFile).pipe(
397 Effect.flatMap((file) =>
398 file === null
399 ? Effect.succeed(null)
400 : Effect.map(
401 wantsDimensions
402 ? readImageDimensionsFromOpenFile(canonicalFile, file)
403 : Effect.succeed(null),
404 (dimensions) => ({
405 identity: { device: file.info.dev.toString(), inode: file.info.ino.toString() },
406 fileRevision: `${file.info.dev}:${file.info.ino}:${file.info.size}:${file.info.mtimeNs}:${file.info.ctimeNs}`,
407 dimensions,
408 }),
409 ),
410 ),
411 Effect.scoped,
412 Effect.mapError(
413 (cause) => new AssetWorkspaceAssetInspectionError({ resource: input.resource, cause }),
414 ),
415 );
416 if (!opened) {
417 return yield* new AssetWorkspaceAssetNotFoundError({
418 resource: input.resource,
419 checkedPath: input.requestedPath,
420 });
421 }
422 return {
423 claims: {
424 version: 1 as const,
425 kind: "media-file-exact" as const,
426 filePath: canonicalFile,
427 ...(download ? { download: true } : {}),
428 ...opened.identity,
429 expiresAt: input.expiresAt,
430 },
431 fileName: path.basename(canonicalFile),
432 fileRevision: opened.fileRevision,
433 imageDimensions: opened.dimensions,
434 };
435 },
436);
437​
438const finalizeWorkspaceFileAsset = Effect.fn("AssetAccess.finalizeWorkspaceFileAsset")(
439 function* (input: {
440 readonly workspaceRoot: string;
441 readonly requestedPath: string;
442 readonly resource: AssetResource;
443 readonly expiresAt: number;
444 }) {
445 const path = yield* Path.Path;
446 const fileSystem = yield* FileSystem.FileSystem;
447 const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
448 const relativePath = path.isAbsolute(input.requestedPath)
449 ? path.relative(input.workspaceRoot, input.requestedPath)
450 : input.requestedPath;
451 const resolved = yield* workspacePaths
452 .resolveRelativePathWithinRoot({ workspaceRoot: input.workspaceRoot, relativePath })
453 .pipe(
454 Effect.mapError(
455 (cause) =>
456 new AssetWorkspacePathValidationError({
457 resource: input.resource,
458 cause,
459 }),
460 ),
461 );
462 const isDraftMedia =
463 input.resource._tag === "draft-workspace-file" &&
464 (isWorkspaceAudioPreviewPath(resolved.relativePath) ||
465 isWorkspaceVideoPreviewPath(resolved.relativePath));
466 if (!isDraftMedia && !isWorkspacePreviewEntryPath(resolved.relativePath)) {
467 return yield* new AssetPreviewTypeValidationError({
468 resource: input.resource,
469 });
470 }
471 const canonicalFile = yield* resolveCanonicalWorkspaceFile({
472 workspaceRoot: input.workspaceRoot,
473 relativePath: resolved.relativePath,
474 }).pipe(
475 Effect.mapError(
476 (cause) =>
477 new AssetWorkspaceAssetInspectionError({
478 resource: input.resource,
479 cause,
480 }),
481 ),
482 );
483 if (!canonicalFile) {
484 return yield* new AssetWorkspaceAssetNotFoundError({
485 resource: input.resource,
486 });
487 }
488 if (isDraftMedia) {
489 return yield* finalizeAbsoluteMediaFileAsset({
490 ...input,
491 requestedPath: canonicalFile,
492 expectedCanonicalPath: canonicalFile,
493 });
494 }
495 const canonicalWorkspaceRoot = yield* fileSystem.realPath(input.workspaceRoot).pipe(
496 Effect.mapError(
497 (cause) =>
498 new AssetWorkspaceResolutionError({
499 resource: input.resource,
500 cause,
501 }),
502 ),
503 );
504 const imageDimensions = HEADER_IMAGE_EXTENSIONS.has(
505 path.extname(resolved.relativePath).toLowerCase(),
506 )
507 ? yield* readImageDimensionsFromHeader(canonicalFile)
508 : null;
509 return {
510 claims: !/\.html?$/i.test(resolved.relativePath)
511 ? {
512 version: 1 as const,
513 kind: "workspace-file-exact" as const,
514 workspaceRoot: canonicalWorkspaceRoot,
515 relativePath: resolved.relativePath,
516 expiresAt: input.expiresAt,
517 }
518 : {
519 version: 1 as const,
520 kind: "workspace-file" as const,
521 workspaceRoot: canonicalWorkspaceRoot,
522 baseRelativePath: path.dirname(resolved.relativePath),
523 expiresAt: input.expiresAt,
524 },
525 fileName: path.basename(resolved.relativePath),
526 imageDimensions,
527 fileRevision: undefined,
528 };
529 },
530);
531​
532export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (input: {
533 readonly resource: AssetResource;
534 readonly workspaceRoot?: string;
535 readonly projectFaviconPath?: string;
536 /** Where a cloned project's checkout stands, while the clone tracker still knows it. */
537 readonly projectCheckout?: "pending" | "cloned";
538}) {
539 const fileSystem = yield* FileSystem.FileSystem;
540 const path = yield* Path.Path;
541 const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
542 let expiresAt = (yield* Clock.currentTimeMillis) + ASSET_TOKEN_TTL_MS;
543 let claims: AssetClaims;
544 let fileName: string;
545 let sourcePath: string | undefined;
546 let imageDimensions: ImageDimensions | null = null;
547 let fileRevision: string | undefined;
548​
549 switch (input.resource._tag) {
550 case "media-file": {
551 let requestedPath = expandHomePath(input.resource.path, yield* HostProcess.HomeDirectory);
552 if (!path.isAbsolute(requestedPath)) {
553 if (!input.workspaceRoot) {
554 return yield* new AssetWorkspaceContextNotFoundError({ resource: input.resource });
555 }
556 const workspaceRoot = yield* workspacePaths
557 .normalizeWorkspaceRoot(input.workspaceRoot)
558 .pipe(
559 Effect.mapError(
560 (cause) =>
561 new AssetWorkspaceRootNormalizationError({ resource: input.resource, cause }),
562 ),
563 );
564 requestedPath = path.resolve(workspaceRoot, requestedPath);
565 }
566 const finalized = yield* finalizeAbsoluteMediaFileAsset({
567 requestedPath,
568 workspaceRoot: input.workspaceRoot,
569 resource: input.resource,
570 expiresAt,
571 });
572 claims = finalized.claims;
573 fileName = finalized.fileName;
574 imageDimensions = finalized.imageDimensions;
575 fileRevision = finalized.fileRevision;
576 break;
577 }
578 case "workspace-file": {
579 if (!input.workspaceRoot) {
580 return yield* new AssetWorkspaceContextNotFoundError({
581 resource: input.resource,
582 });
583 }
584 const workspaceRoot = yield* workspacePaths.normalizeWorkspaceRoot(input.workspaceRoot).pipe(
585 Effect.mapError(
586 (cause) =>
587 new AssetWorkspaceRootNormalizationError({
588 resource: input.resource,
589 cause,
590 }),
591 ),
592 );
593 const finalized = yield* finalizeWorkspaceFileAsset({
594 workspaceRoot,
595 requestedPath: input.resource.path,
596 resource: input.resource,
597 expiresAt,
598 });
599 claims = finalized.claims;
600 fileName = finalized.fileName;
601 imageDimensions = finalized.imageDimensions;
602 break;
603 }
604 case "draft-workspace-file": {
605 const draftWorkspaceRoot = input.workspaceRoot ?? input.resource.cwd;
606 const hostPath = input.resource.path.startsWith("~/")
607 ? expandHomePath(input.resource.path, yield* HostProcess.HomeDirectory)
608 : input.resource.path;
609 if (path.isAbsolute(hostPath)) {
610 const finalized = yield* finalizeAbsoluteMediaFileAsset({
611 requestedPath: hostPath,
612 workspaceRoot: draftWorkspaceRoot,
613 resource: input.resource,
614 expiresAt,
615 });
616 claims = finalized.claims;
617 fileName = finalized.fileName;
618 imageDimensions = finalized.imageDimensions;
619 fileRevision = finalized.fileRevision;
620 break;
621 }
622 const workspaceRoot = yield* workspacePaths.normalizeWorkspaceRoot(draftWorkspaceRoot).pipe(
623 Effect.mapError(
624 (cause) =>
625 new AssetWorkspaceRootNormalizationError({
626 resource: input.resource,
627 cause,
628 }),
629 ),
630 );
631 const finalized = yield* finalizeWorkspaceFileAsset({
632 workspaceRoot,
633 requestedPath: input.resource.path,
634 resource: input.resource,
635 expiresAt,
636 });
637 claims = finalized.claims;
638 fileName = finalized.fileName;
639 imageDimensions = finalized.imageDimensions;
640 fileRevision = finalized.fileRevision;
641 break;
642 }
643 case "attachment": {
644 const config = yield* ServerConfig.ServerConfig;
645 const attachmentPath = resolveAttachmentPathById({
646 attachmentsDir: config.attachmentsDir,
647 attachmentId: input.resource.attachmentId,
648 });
649 if (!attachmentPath) {
650 return yield* new AssetAttachmentNotFoundError({
651 resource: input.resource,
652 });
653 }
654 const extension = parseAttachmentFileExtension(input.resource.attachmentId);
655 const isGenericFile = extension !== null;
656 const videoMimeType = input.resource.mimeType?.split(";", 1)[0]?.trim() ?? "";
657 const isVideo = INLINE_VIDEO_MIME_TYPE_PATTERN.test(videoMimeType);
658 const inlinePreviewMimeType =
659 input.resource.disposition === "inline" && extension !== null
660 ? inlinePreviewMimeTypeForExtension(extension)
661 : undefined;
662 if (!isGenericFile) {
663 imageDimensions = yield* fileSystem.realPath(attachmentPath).pipe(
664 Effect.flatMap(readImageDimensionsFromHeader),
665 Effect.orElseSucceed(() => null),
666 );
667 }
668 claims = {
669 version: 1,
670 kind: "attachment",
671 attachmentId: input.resource.attachmentId,
672 ...(isGenericFile && !isVideo && inlinePreviewMimeType === undefined
673 ? { download: true }
674 : {}),
675 ...(input.resource.fileName !== undefined ? { fileName: input.resource.fileName } : {}),
676 ...(inlinePreviewMimeType !== undefined
677 ? { mimeType: inlinePreviewMimeType }
678 : input.resource.mimeType !== undefined
679 ? { mimeType: isVideo ? videoMimeType : input.resource.mimeType }
680 : {}),
681 ...(input.resource.renderIntent === "mcp-app" && inlinePreviewMimeType === "text/html"
682 ? { renderIntent: "mcp-app" as const }
683 : {}),
684 expiresAt,
685 };
686 fileName = input.resource.fileName ?? path.basename(attachmentPath);
687 break;
688 }
689 case "project-favicon": {
690 const workspaceRoot = yield* workspacePaths.normalizeWorkspaceRoot(input.resource.cwd).pipe(
691 Effect.mapError(
692 (cause) =>
693 new AssetWorkspaceRootNormalizationError({
694 resource: input.resource,
695 cause,
696 }),
697 ),
698 );
699 const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
700 // A lookup in a half-cloned checkout would cache a miss that outlives the clone.
701 // Once it lands, a miss cached before (by enrichment) is stale too.
702 if (input.projectCheckout === "cloned") yield* faviconResolver.invalidate(workspaceRoot);
703 const faviconPath =
704 input.projectCheckout === "pending"
705 ? null
706 : yield* faviconResolver
707 .resolvePath(workspaceRoot, input.projectFaviconPath ?? undefined)
708 .pipe(
709 Effect.mapError(
710 (cause) =>
711 new AssetProjectFaviconResolutionError({
712 resource: input.resource,
713 cause,
714 }),
715 ),
716 );
717 const isExternalOverride =
718 faviconPath !== null &&
719 input.projectFaviconPath !== undefined &&
720 path.isAbsolute(input.projectFaviconPath) &&
721 path.normalize(faviconPath) === path.normalize(input.projectFaviconPath);
722 const relativePath =
723 faviconPath && !isExternalOverride ? path.relative(workspaceRoot, faviconPath) : null;
724 const sourceFaviconPath = isExternalOverride ? faviconPath : relativePath;
725 if (sourceFaviconPath && !isProjectFaviconPath(sourceFaviconPath)) {
726 return yield* new AssetPreviewTypeValidationError({ resource: input.resource });
727 }
728 sourcePath = sourceFaviconPath ?? undefined;
729 const canonicalFaviconPath = sourceFaviconPath
730 ? yield* (
731 isExternalOverride
732 ? resolveCanonicalFile(sourceFaviconPath)
733 : resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath: sourceFaviconPath })
734 ).pipe(
735 Effect.mapError(
736 (cause) =>
737 new AssetProjectFaviconInspectionError({
738 resource: input.resource,
739 cause,
740 }),
741 ),
742 )
743 : null;
744 if (sourceFaviconPath && !canonicalFaviconPath) {
745 return yield* new AssetProjectFaviconNotFoundError({
746 resource: input.resource,
747 });
748 }
749 claims =
750 isExternalOverride && canonicalFaviconPath
751 ? {
752 version: 1,
753 kind: "project-favicon-external",
754 filePath: canonicalFaviconPath,
755 expiresAt,
756 }
757 : {
758 version: 1,
759 kind: "project-favicon",
760 workspaceRoot: yield* fileSystem.realPath(workspaceRoot).pipe(
761 Effect.mapError(
762 (cause) =>
763 new AssetWorkspaceResolutionError({
764 resource: input.resource,
765 cause,
766 }),
767 ),
768 ),
769 relativePath,
770 expiresAt,
771 };
772 if (sourceFaviconPath && canonicalFaviconPath) {
773 const digest = yield* Digest.Digest;
774 const faviconBytes = yield* fileSystem.readFile(canonicalFaviconPath).pipe(
775 Effect.mapError(
776 (cause) =>
777 new AssetProjectFaviconInspectionError({
778 resource: input.resource,
779 cause,
780 }),
781 ),
782 );
783 const revision = yield* digest.digest(Digest.sha256, faviconBytes).pipe(
784 Effect.map(Hex.encode),
785 Effect.mapError(
786 (cause) =>
787 new AssetProjectFaviconInspectionError({
788 resource: input.resource,
789 cause,
790 }),
791 ),
792 );
793 fileName = `${PROJECT_FAVICON_VERSION_PREFIX}${revision}-${path.basename(sourceFaviconPath)}`;
794 } else {
795 fileName = PROJECT_FAVICON_FALLBACK_MARKER;
796 }
797 break;
798 }
799 case "tool-output-image": {
800 const image = yield* readToolOutputImage(input.resource).pipe(
801 Effect.mapError(
802 (cause) => new AssetWorkspaceAssetInspectionError({ resource: input.resource, cause }),
803 ),
804 );
805 if (image === null) {
806 return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource });
807 }
808 claims = {
809 version: 1,
810 kind: "tool-output-image",
811 threadId: input.resource.threadId,
812 itemId: input.resource.itemId,
813 index: input.resource.index,
814 expiresAt,
815 };
816 fileName = `image-${input.resource.index + 1}.${image.mimeType.slice("image/".length)}`;
817 imageDimensions = readImageDimensions(image.bytes);
818 break;
819 }
820 case "native-app-icon": {
821 claims = {
822 version: 1,
823 kind: "native-app-icon",
824 app: input.resource.app,
825 expiresAt,
826 };
827 fileName = "native-app-icon.png";
828 break;
829 }
830 case SOURCE_CONTROL_MEDIA_ASSET: {
831 const media = sourceControlDefinitions.mediaUrl(input.resource.url);
832 if (media === null) {
833 return yield* new AssetSourceControlMediaUrlValidationError({});
834 }
835 const { _tag, ...resource } = input.resource;
836 claims = {
837 version: 1,
838 kind: SOURCE_CONTROL_MEDIA_ASSET,
839 ...resource,
840 url: media.fetchUrl,
841 expiresAt,
842 };
843 fileName = media.fileName;
844 break;
845 }
846 }
847​
848 if (claims.kind === "project-favicon" || claims.kind === "project-favicon-external") {
849 const issuedAt = yield* Clock.currentTimeMillis;
850 expiresAt =
851 (Math.floor(issuedAt / PROJECT_FAVICON_TOKEN_BUCKET_MS) + 2) *
852 PROJECT_FAVICON_TOKEN_BUCKET_MS;
853 claims = { ...claims, expiresAt };
854 }
855 if (claims.kind === "attachment" && claims.mimeType === "text/html") {
856 // Stable URLs let immutable HTML reloads hit the cache; image retries need fresh URLs.
857 expiresAt =
858 Math.ceil(expiresAt / HTML_ATTACHMENT_TOKEN_BUCKET_MS) * HTML_ATTACHMENT_TOKEN_BUCKET_MS;
859 claims = { ...claims, expiresAt };
860 }
861 const encodedPayload = base64UrlEncode(encodeAssetClaims(claims));
862 const signer = yield* AssetSigner.AssetSigner;
863 const signature = yield* signer
864 .sign(encodedPayload)
865 .pipe(
866 Effect.mapError((cause) => new AssetSigningKeyLoadError({ resource: input.resource, cause })),
867 );
868 const token = `${encodedPayload}.${signature}`;
869 return {
870 relativeUrl: `${ASSET_ROUTE_PREFIX}/${token}/${encodeURIComponent(fileName)}`,
871 expiresAt,
872 ...(sourcePath !== undefined ? { sourcePath } : {}),
873 ...(imageDimensions !== null ? { imageDimensions } : {}),
874 ...(fileRevision !== undefined ? { fileRevision } : {}),
875 };
876});
877​
878export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
879 token: string,
880 relativePath: string,
881) {
882 const [encodedPayload, signature] = token.split(".");
883 if (!encodedPayload || !signature) return null;
884​
885 const signer = yield* AssetSigner.AssetSigner;
886 const verified = yield* signer.verify(encodedPayload, signature).pipe(
887 Effect.tapError((cause) => Effect.logError("Failed to load the asset signing key.", { cause })),
888 Effect.orElseSucceed(() => false),
889 );
890 if (!verified) return null;
891​
892 const claims = decodeClaims(encodedPayload);
893 if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) return null;
894​
895 if (claims.kind === "attachment") {
896 const config = yield* ServerConfig.ServerConfig;
897 const attachmentPath = resolveAttachmentPathById({
898 attachmentsDir: config.attachmentsDir,
899 attachmentId: claims.attachmentId,
900 });
901 if (!attachmentPath) return null;
902 const fileSystem = yield* FileSystem.FileSystem;
903 const info = yield* ProjectFaviconResolver.optionOnNotFound(
904 fileSystem.stat(attachmentPath),
905 ).pipe(
906 Effect.tapError((cause) =>
907 Effect.logError("Failed to inspect attachment asset.", {
908 attachmentId: claims.attachmentId,
909 path: attachmentPath,
910 cause,
911 }),
912 ),
913 Effect.orElseSucceed(() => Option.none()),
914 );
915 return Option.isSome(info) && info.value.type === "File"
916 ? ({
917 kind: "file",
918 path: attachmentPath,
919 ...(claims.download ? { download: true } : {}),
920 ...(claims.fileName !== undefined ? { fileName: claims.fileName } : {}),
921 ...(claims.mimeType !== undefined ? { mimeType: claims.mimeType } : {}),
922 ...(claims.renderIntent === "mcp-app" ? { renderIntent: "mcp-app" as const } : {}),
923 } satisfies ResolvedAsset)
924 : null;
925 }
926​
927 if (claims.kind === "project-favicon") {
928 if (claims.relativePath === null) return null;
929 const faviconPath = yield* resolveCanonicalWorkspaceFileForRequest({
930 workspaceRoot: claims.workspaceRoot,
931 relativePath: claims.relativePath,
932 });
933 return faviconPath ? yield* projectFaviconAsset(faviconPath) : null;
934 }
935​
936 if (claims.kind === "project-favicon-external") {
937 const faviconPath = yield* resolveCanonicalFile(claims.filePath).pipe(
938 Effect.tapError((cause) =>
939 Effect.logError("Failed to resolve canonical asset path.", {
940 filePath: claims.filePath,
941 cause,
942 }),
943 ),
944 Effect.orElseSucceed(() => null),
945 );
946 return faviconPath === claims.filePath ? yield* projectFaviconAsset(faviconPath) : null;
947 }
948​
949 if (claims.kind === SOURCE_CONTROL_MEDIA_ASSET) {
950 const { version: _version, ...media } = claims;
951 return { ...media, kind: "source-control-media" } satisfies ResolvedAsset;
952 }
953​
954 if (claims.kind === "tool-output-image") {
955 const image = yield* readToolOutputImage(claims).pipe(
956 Effect.tapError((cause) =>
957 Effect.logError("Failed to read tool output image.", {
958 threadId: claims.threadId,
959 itemId: claims.itemId,
960 cause,
961 }),
962 ),
963 Effect.orElseSucceed(() => null),
964 );
965 return image ? ({ kind: "bytes", ...image } satisfies ResolvedAsset) : null;
966 }
967​
968 if (claims.kind === "native-app-icon") {
969 const nativeAppIconResolver = yield* NativeAppIconResolver.NativeAppIconResolver;
970 const iconPath = yield* nativeAppIconResolver.resolve(claims.app);
971 return iconPath ? ({ kind: "file", path: iconPath } satisfies ResolvedAsset) : null;
972 }
973​
974 const decodedPath = decodeRelativePath(relativePath);
975 if (decodedPath === null) return null;
976 const path = yield* Path.Path;
977 if (claims.kind === "media-file-exact") {
978 if (decodedPath !== path.basename(claims.filePath)) return null;
979 const canonicalFile = yield* resolveCanonicalFile(claims.filePath).pipe(
980 Effect.tapError((cause) =>
981 Effect.logError("Failed to resolve canonical media path.", {
982 filePath: claims.filePath,
983 cause,
984 }),
985 ),
986 Effect.orElseSucceed(() => null),
987 );
988 if (canonicalFile !== claims.filePath) return null;
989 const mimeType = claims.download
990 ? "application/octet-stream"
991 : hostPreviewMimeTypeFromExtension(path.extname(canonicalFile));
992 if (!mimeType) return null;
993 const file = yield* openMediaFile(canonicalFile, claims).pipe(
994 Effect.tapError((cause) =>
995 Effect.logError("Failed to open canonical media file.", { filePath: canonicalFile, cause }),
996 ),
997 Effect.orElseSucceed(() => null),
998 );
999 return file
1000 ? ({
1001 kind: "file",
1002 path: canonicalFile,
1003 mimeType,
1004 file,
1005 ...(claims.download ? { download: true, fileName: path.basename(canonicalFile) } : {}),
1006 } satisfies ResolvedAsset)
1007 : null;
1009 if (claims.kind === "workspace-file-exact") {
1010 if (decodedPath !== path.basename(claims.relativePath)) return null;
1011 const exactWorkspaceFile = yield* resolveCanonicalWorkspaceFileForRequest({
1012 workspaceRoot: claims.workspaceRoot,
1013 relativePath: claims.relativePath,
1014 });
1015 return exactWorkspaceFile
1016 ? ({ kind: "file", path: exactWorkspaceFile } satisfies ResolvedAsset)
1017 : null;
1019 const segments = decodedPath.split(/[\\/]/);
1020 if (
1021 decodedPath.length === 0 ||
1022 decodedPath.includes("\0") ||
1023 segments.some((segment) => segment === "." || segment === ".." || segment.startsWith(".")) ||
1024 !PREVIEW_ASSET_EXTENSIONS.has(path.extname(decodedPath).toLowerCase())
1025 ) {
1026 return null;
1028 const joinedRelativePath =
1029 claims.baseRelativePath === "." ? decodedPath : path.join(claims.baseRelativePath, decodedPath);
1030 const workspaceFile = yield* resolveCanonicalWorkspaceFileForRequest({
1031 workspaceRoot: claims.workspaceRoot,
1032 relativePath: joinedRelativePath,
1033 });
1034 return workspaceFile ? ({ kind: "file", path: workspaceFile } satisfies ResolvedAsset) : null;
1035});