1// @effect-diagnostics nodeBuiltinImport:off - tests inject swaps at the native open boundary.
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform";
4import * as NodeFSP from "node:fs/promises";
5import {
6 AssetAccessError,
7 AssetPreviewTypeValidationError,
8 ThreadId,
9 TurnItemId,
10} from "@t3tools/contracts";
11import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon";
12import { MCP_APP_MAX_HTML_BYTES } from "@t3tools/shared/mcpApp";
13import { describe, expect, it } from "@effect/vitest";
14import * as Crypto from "effect/Crypto";
15import * as DateTime from "effect/DateTime";
16import * as Effect from "effect/Effect";
17import * as FileSystem from "effect/FileSystem";
18import * as Layer from "effect/Layer";
19import * as Redacted from "effect/Redacted";
20import * as Path from "effect/Path";
21import * as PlatformError from "effect/PlatformError";
22import * as Schema from "effect/Schema";
23import * as TestClock from "effect/testing/TestClock";
24import { HttpClient, HttpClientResponse, HttpServerResponse } from "effect/http";
25import { vi } from "vite-plus/test";
26
27import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
28import * as ServerConfig from "../config.ts";
29import * as TranscriptArchive from "../archive/TranscriptArchive.ts";
30import * as Option from "effect/Option";
31import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
32import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
33import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts";
34import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
35import { assetFileResponse } from "../http.ts";
36import { ASSET_ROUTE_PREFIX, issueAssetUrl, readMcpAppHtml, resolveAsset } from "./AssetAccess.ts";
37import * as NativeAppIconResolver from "./NativeAppIconResolver.ts";
38import { openMediaFile } from "./MediaFile.ts";
39import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
40import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials";
41import { githubMediaResponse } from "./GitHubMediaFetch.ts";
42import * as HostProcess from "@t3tools/shared/HostProcess";
43
44vi.mock("node:fs/promises", async (importOriginal) => {
45 const actual = await importOriginal<typeof NodeFSP>();
46 return { ...actual, open: vi.fn(actual.open), realpath: vi.fn(actual.realpath) };
47});
48
49const layerConfig = ServerConfig.ServerConfig.layerTest(process.cwd(), {
50 prefix: "t3-asset-access-test-",
51});
52// A PNG header is enough for the dimension read: signature, then IHDR width and height.
53const screenshotPng = new Uint8Array(24);
54screenshotPng.set([
55 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52,
56]);
57new DataView(screenshotPng.buffer).setUint32(16, 390);
58new DataView(screenshotPng.buffer).setUint32(20, 844);
59const screenshotIcns = new Uint8Array(16 + screenshotPng.length);
60screenshotIcns.set(new TextEncoder().encode("icns"));
61new DataView(screenshotIcns.buffer).setUint32(4, screenshotIcns.length);
62screenshotIcns.set(new TextEncoder().encode("ic07"), 8);
63new DataView(screenshotIcns.buffer).setUint32(12, 8 + screenshotPng.length);
64screenshotIcns.set(screenshotPng, 16);
65const screenshotItem = {
66 id: TurnItemId.make("tool-screenshot"),
67 type: "dynamic_tool" as const,
68 threadId: ThreadId.make("thread-1"),
69 runId: null,
70 nodeId: null,
71 providerThreadId: null,
72 providerTurnId: null,
73 nativeItemRef: null,
74 parentItemId: null,
75 ordinal: 1,
76 status: "completed" as const,
77 title: null,
78 toolName: "mcp__t3-code__device_screenshot",
79 input: { deviceId: "phone" },
80 output: {
81 content: [
82 {
83 type: "image",
84 source: {
85 type: "base64",
86 media_type: "image/png",
87 data: Buffer.from(screenshotPng).toString("base64"),
88 },
89 },
90 ],
91 },
92 startedAt: DateTime.makeUnsafe("2026-10-05T00:00:00.000Z"),
93 completedAt: DateTime.makeUnsafe("2026-10-05T00:00:01.000Z"),
94 updatedAt: DateTime.makeUnsafe("2026-10-05T00:00:01.000Z"),
95};
96
97const oversizedScreenshotItem = {
98 ...screenshotItem,
99 id: TurnItemId.make("tool-screenshot-oversized"),
100 output: {
101 content: [
102 {
103 type: "image",
104 source: { type: "base64", media_type: "image/png", data: "A".repeat(14 * 1024 * 1024) },
105 },
106 ],
107 },
108};
109
110const layerTest = Layer.mergeAll(
111 Layer.mock(TranscriptArchive.TranscriptArchive)({
112 enabled: Effect.succeed(false),
113 asset: () => Effect.succeed(Option.none()),
114 }),
115 NodeHttpPlatform.layer,
116 Layer.mock(Orchestrator.OrchestratorV2)({
117 getTurnItem: ({ itemId }) =>
118 Effect.succeed(
119 itemId === screenshotItem.id
120 ? screenshotItem
121 : itemId === oversizedScreenshotItem.id
122 ? oversizedScreenshotItem
123 : null,
124 ),
125 }),
126 layerConfig,
127 WorkspacePaths.layer,
128 ProjectFaviconResolver.layer.pipe(
129 Layer.provide(WorkspacePaths.layer),
130 Layer.provide(T3ProjectFileLoader.layer),
131 ),
132 NativeAppIconResolver.layer.pipe(Layer.provide(layerConfig)),
133 ServerSecretStore.layer.pipe(Layer.provide(layerConfig)),
134).pipe(Layer.provideMerge(NodeServices.layer));
135
136describe("AssetAccess", () => {
137 describe("readMcpAppHtml", () => {
138 it.effect("accepts snapshots at the stored HTML byte limit", () =>
139 Effect.gen(function* () {
140 const fileSystem = yield* FileSystem.FileSystem;
141 const path = yield* Path.Path;
142 const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-mcp-html-" });
143 const file = path.join(directory, "app.html");
144 const limit = MCP_APP_MAX_HTML_BYTES + 128 * 1024;
145 yield* fileSystem.writeFile(file, new Uint8Array(limit).fill(120));
146
147 const bytes = yield* readMcpAppHtml(file);
148 expect(bytes?.byteLength).toBe(limit);
149 expect(bytes?.every((byte) => byte === 120)).toBe(true);
150 }).pipe(Effect.provide(NodeServices.layer)),
151 );
152
153 it.effect("rejects snapshots one byte past the limit and much larger files", () =>
154 Effect.gen(function* () {
155 const fileSystem = yield* FileSystem.FileSystem;
156 const path = yield* Path.Path;
157 const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-mcp-html-" });
158 const file = path.join(directory, "app.html");
159 const limit = MCP_APP_MAX_HTML_BYTES + 128 * 1024;
160 for (const size of [limit + 1, limit * 2]) {
161 yield* fileSystem.writeFile(file, new Uint8Array(size).fill(120));
162 expect(yield* readMcpAppHtml(file)).toBeNull();
163 }
164 }).pipe(Effect.provide(NodeServices.layer)),
165 );
166
167 it.effect("preserves UTF-8 bytes across stream chunks and accepts empty snapshots", () =>
168 Effect.gen(function* () {
169 const fileSystem = yield* FileSystem.FileSystem;
170 const path = yield* Path.Path;
171 const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-mcp-html-" });
172 const file = path.join(directory, "app.html");
173 for (const html of ["", "<body>" + "☃".repeat(100_000) + "</body>"]) {
174 const expected = new TextEncoder().encode(html);
175 yield* fileSystem.writeFile(file, expected);
176 const bytes = yield* readMcpAppHtml(file);
177 expect(bytes?.byteLength).toBe(expected.byteLength);
178 expect(bytes && new TextDecoder().decode(bytes)).toBe(html);
179 }
180 }).pipe(Effect.provide(NodeServices.layer)),
181 );
182
183 it.effect("preserves filesystem failures instead of reporting oversized HTML", () =>
184 Effect.gen(function* () {
185 const fileSystem = yield* FileSystem.FileSystem;
186 const path = yield* Path.Path;
187 const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-mcp-html-" });
188 const error = yield* readMcpAppHtml(path.join(directory, "missing.html")).pipe(Effect.flip);
189 expect(error._tag).toBe("PlatformError");
190 expect(error.reason._tag).toBe("NotFound");
191 }).pipe(Effect.provide(NodeServices.layer)),
192 );
193 });
194
195 it.effect("loads private media immediately after login with the GitHub credential", () => {
196 let lookups = 0;
197 const authorizations: Array<string | undefined> = [];
198 return Effect.gen(function* () {
199 const asset = {
200 url: "https://raw.githubusercontent.com/owner/repo/main/shot.png",
201 cwd: "/repo",
202 expiresAt: Number.MAX_SAFE_INTEGER,
203 };
204 expect((yield* githubMediaResponse(asset, {})).status).toBe(404);
205 expect((yield* githubMediaResponse(asset, {})).status).toBe(200);
206 expect((yield* githubMediaResponse(asset, {})).status).toBe(200);
207 // Caching the token is GitHubCredentials' job; this asks it every time.
208 expect(lookups).toBe(3);
209 expect(authorizations).toEqual([undefined, "Bearer signed-in", "Bearer signed-in"]);
210 }).pipe(
211 Effect.provide(
212 Layer.mock(GitHubCredentials.GitHubCredentials)({
213 get: (host) =>
214 ++lookups === 1
215 ? Effect.fail(new GitHubCredentials.GitHubNotSignedInError({ host }))
216 : Effect.succeed({
217 host,
218 token: Redacted.make("signed-in"),
219 source: "gh" as const,
220 fingerprint: "fingerprint",
221 }),
222 }),
223 ),
224 Effect.provideService(
225 HttpClient.HttpClient,
226 HttpClient.make((request) => {
227 authorizations.push(request.headers.authorization);
228 return Effect.succeed(
229 HttpClientResponse.fromWeb(
230 request,
231 new Response(null, {
232 status: request.headers.authorization ? 200 : 404,
233 headers: { "content-type": "image/png" },
234 }),
235 ),
236 );
237 }),
238 ),
239 Effect.scoped,
240 );
241 });
242
243 it.effect("serves an image a tool returned inline from the stored item", () =>
244 Effect.gen(function* () {
245 const resource = {
246 _tag: "tool-output-image" as const,
247 threadId: screenshotItem.threadId,
248 itemId: screenshotItem.id,
249 index: 0,
250 };
251 const result = yield* issueAssetUrl({ resource });
252 expect(result.imageDimensions).toEqual({ width: 390, height: 844 });
253 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
254 const separator = suffix.indexOf("/");
255 expect(yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1))).toEqual({
256 kind: "bytes",
257 mimeType: "image/png",
258 bytes: Buffer.from(screenshotPng),
259 });
260 const missing = yield* issueAssetUrl({ resource: { ...resource, index: 1 } }).pipe(
261 Effect.flip,
262 );
263 expect(missing._tag).toBe("AssetWorkspaceAssetNotFoundError");
264 // Larger than a provider turn accepts, so it is never decoded.
265 const oversized = yield* issueAssetUrl({
266 resource: { ...resource, itemId: oversizedScreenshotItem.id },
267 }).pipe(Effect.flip);
268 expect(oversized._tag).toBe("AssetWorkspaceAssetNotFoundError");
269 }).pipe(Effect.provide(layerTest)),
270 );
271
272 it.effect("issues exact URLs for media and browser documents outside the workspace", () =>
273 Effect.gen(function* () {
274 const fs = yield* FileSystem.FileSystem;
275 const path = yield* Path.Path;
276 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-root-" });
277 const outside = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-outside-" });
278 for (const [name, mimeType] of [
279 ["screenshot.png", "image/png"],
280 ["recording.mp4", "video/mp4"],
281 ["recording.webm", "video/webm"],
282 ["report.html", "text/html"],
283 ["report.pdf", "application/pdf"],
284 ] as const) {
285 const filePath = path.join(outside, name);
286 yield* fs.writeFileString(filePath, "media");
287 const canonicalFile = yield* fs.realPath(filePath);
288 const result = yield* issueAssetUrl({
289 resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath },
290 workspaceRoot: root,
291 });
292 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
293 const separator = suffix.indexOf("/");
294 const token = suffix.slice(0, separator);
295 expect(yield* resolveAsset(token, suffix.slice(separator + 1))).toMatchObject({
296 kind: "file",
297 path: canonicalFile,
298 mimeType,
299 });
300 yield* fs.writeFileString(path.join(outside, "sibling.png"), "private sibling");
301 expect(yield* resolveAsset(token, "sibling.png")).toBeNull();
302 expect(yield* resolveAsset(token, `../${name}`)).toBeNull();
303 expect(yield* resolveAsset(`${token}tampered`, name)).toBeNull();
304 }
305 }).pipe(Effect.provide(layerTest)),
306 );
307
308 it.effect("reports pixel dimensions from an image header and nothing for other files", () =>
309 Effect.gen(function* () {
310 const fs = yield* FileSystem.FileSystem;
311 const path = yield* Path.Path;
312 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-dimensions-" });
313 const png = Uint8Array.from([
314 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52, 0, 0,
315 0x06, 0x40, 0, 0, 0x03, 0x84,
316 ]);
317 yield* fs.writeFile(path.join(root, "shot.png"), png);
318 yield* fs.writeFileString(path.join(root, "clip.mp4"), "video");
319 yield* fs.writeFileString(path.join(root, "broken.png"), "not a png");
320 const issue = (name: string) =>
321 issueAssetUrl({
322 resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: name },
323 workspaceRoot: root,
324 });
325
326 expect((yield* issue("shot.png")).imageDimensions).toEqual({ width: 1600, height: 900 });
327 expect((yield* issue("clip.mp4")).imageDimensions).toBeUndefined();
328 expect((yield* issue("broken.png")).imageDimensions).toBeUndefined();
329 }).pipe(Effect.provide(layerTest)),
330 );
331
332 it.effect("resolves relative media paths from the thread workspace, including outside it", () =>
333 Effect.gen(function* () {
334 const fs = yield* FileSystem.FileSystem;
335 const path = yield* Path.Path;
336 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-relative-" });
337 const root = path.join(directory, "workspace");
338 yield* fs.makeDirectory(root);
339 for (const relativePath of ["screenshot.png", "../recording.mp4"]) {
340 const filePath = path.resolve(root, relativePath);
341 yield* fs.writeFileString(filePath, "media");
342 const result = yield* issueAssetUrl({
343 resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: relativePath },
344 workspaceRoot: root,
345 });
346 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
347 const separator = suffix.indexOf("/");
348 expect(
349 yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)),
350 ).toMatchObject({
351 kind: "file",
352 path: yield* fs.realPath(filePath),
353 });
354 }
355 }).pipe(Effect.provide(layerTest)),
356 );
357
358 it.effect("resolves home-relative media paths independently of the workspace", () =>
359 Effect.gen(function* () {
360 const fs = yield* FileSystem.FileSystem;
361 const path = yield* Path.Path;
362 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-home-" });
363 const home = path.join(directory, "var", "home", "alice");
364 const filePath = path.join(home, "Downloads", "repro.mp4");
365 yield* fs.makeDirectory(path.dirname(filePath), { recursive: true });
366 yield* fs.writeFileString(filePath, "recording bytes");
367 const canonicalFile = yield* fs.realPath(filePath);
368 yield* Effect.gen(function* () {
369 for (const workspaceRoot of [
370 path.join(home, "project"),
371 path.join(directory, "srv", "project"),
372 undefined,
373 ]) {
374 if (workspaceRoot) yield* fs.makeDirectory(workspaceRoot, { recursive: true });
375 for (const requestedPath of ["~/Downloads/repro.mp4", "~\\Downloads/repro.mp4"]) {
376 const result = yield* issueAssetUrl({
377 resource: {
378 _tag: "media-file",
379 threadId: ThreadId.make("thread-1"),
380 path: requestedPath,
381 },
382 ...(workspaceRoot ? { workspaceRoot } : {}),
383 });
384 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
385 const separator = suffix.indexOf("/");
386 const asset = yield* resolveAsset(
387 suffix.slice(0, separator),
388 suffix.slice(separator + 1),
389 );
390 expect(asset).toMatchObject({
391 kind: "file",
392 path: canonicalFile,
393 mimeType: "video/mp4",
394 });
395 if (asset?.kind !== "file") throw new Error("Expected a resolved home media file");
396 const response = HttpServerResponse.toWeb(yield* assetFileResponse(asset));
397 expect(yield* Effect.promise(() => response.text())).toBe("recording bytes");
398 }
399 }
400 }).pipe(Effect.provideService(HostProcess.HomeDirectory, home));
401 }).pipe(Effect.provide(layerTest)),
402 );
403
404 it.effect.skipIf(!symlinksSupported)(
405 "rejects non-previewable files, disguised targets, and directories",
406 () =>
407 Effect.gen(function* () {
408 const fs = yield* FileSystem.FileSystem;
409 const path = yield* Path.Path;
410 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-validation-" });
411 for (const name of ["report.md", "secret.txt", "secret.%70ng", "secret.png#private.txt"]) {
412 const filePath = path.join(root, name);
413 yield* fs.writeFileString(filePath, "not media");
414 const error = yield* issueAssetUrl({
415 resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath },
416 }).pipe(Effect.flip);
417 expect(error).toBeInstanceOf(AssetPreviewTypeValidationError);
418 }
419 const disguisedPath = path.join(root, "disguised.png");
420 yield* fs.symlink(path.join(root, "secret.txt"), disguisedPath);
421 const disguisedError = yield* issueAssetUrl({
422 resource: {
423 _tag: "media-file",
424 threadId: ThreadId.make("thread-1"),
425 path: disguisedPath,
426 },
427 }).pipe(Effect.flip);
428 expect(disguisedError).toBeInstanceOf(AssetPreviewTypeValidationError);
429 const directoryPath = path.join(root, "directory.png");
430 yield* fs.makeDirectory(directoryPath);
431 const directoryError = yield* issueAssetUrl({
432 resource: {
433 _tag: "media-file",
434 threadId: ThreadId.make("thread-1"),
435 path: directoryPath,
436 },
437 }).pipe(Effect.flip);
438 expect(directoryError._tag).toBe("AssetWorkspaceAssetNotFoundError");
439 }).pipe(Effect.provide(layerTest)),
440 );
441
442 it.effect.skipIf(!symlinksSupported)(
443 "binds media URLs to the canonical target and rejects symlink substitution",
444 () =>
445 Effect.gen(function* () {
446 const fs = yield* FileSystem.FileSystem;
447 const path = yield* Path.Path;
448 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-symlink-" });
449 const filePath = path.join(root, "actual.svg");
450 const aliasPath = path.join(root, "alias.png");
451 const replacementPath = path.join(root, "other.svg");
452 yield* fs.writeFileString(filePath, "<svg/>");
453 yield* fs.writeFileString(replacementPath, "<svg>private</svg>");
454 yield* fs.symlink(filePath, aliasPath);
455 const canonicalFile = yield* fs.realPath(filePath);
456 const result = yield* issueAssetUrl({
457 resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: aliasPath },
458 });
459 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
460 const separator = suffix.indexOf("/");
461 const token = suffix.slice(0, separator);
462 const name = suffix.slice(separator + 1);
463 const expected = { kind: "file", path: canonicalFile, mimeType: "image/svg+xml" };
464 expect(yield* resolveAsset(token, name)).toMatchObject(expected);
465 yield* fs.remove(aliasPath);
466 yield* fs.symlink(replacementPath, aliasPath);
467 expect(yield* resolveAsset(token, name)).toMatchObject(expected);
468 yield* fs.remove(filePath);
469 yield* fs.symlink(replacementPath, filePath);
470 expect(yield* resolveAsset(token, name)).toBeNull();
471 }).pipe(Effect.provide(layerTest)),
472 );
473
474 it.effect.skipIf(!symlinksSupported)(
475 "keeps full and partial responses bound to the file opened during resolution",
476 () =>
477 Effect.gen(function* () {
478 const fs = yield* FileSystem.FileSystem;
479 const path = yield* Path.Path;
480 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-open-file-" });
481 const filePath = path.join(root, "recording.mp4");
482 const savedPath = path.join(root, "saved.mp4");
483 const secretPath = path.join(root, "secret.txt");
484 yield* fs.writeFileString(filePath, "0123456789");
485 yield* fs.writeFileString(secretPath, "private information");
486 const result = yield* issueAssetUrl({
487 resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath },
488 });
489 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
490 const separator = suffix.indexOf("/");
491 for (const [range, expected, status] of [
492 [undefined, "0123456789", 200],
493 ["bytes=2-5", "2345", 206],
494 ] as const) {
495 const asset = yield* resolveAsset(
496 suffix.slice(0, separator),
497 suffix.slice(separator + 1),
498 );
499 if (asset?.kind !== "file") throw new Error("Expected a resolved media file");
500
501 yield* fs.rename(filePath, savedPath);
502 yield* fs.symlink(secretPath, filePath);
503 const response = HttpServerResponse.toWeb(yield* assetFileResponse(asset, range));
504 expect(response.status).toBe(status);
505 expect(response.headers.get("content-length")).toBe(String(expected.length));
506 expect(yield* Effect.promise(() => response.text())).toBe(expected);
507 yield* fs.remove(filePath);
508 yield* fs.rename(savedPath, filePath);
509 }
510 }).pipe(Effect.provide(layerTest)),
511 );
512
513 it.effect.skipIf(!symlinksSupported)(
514 "rejects a symlink swapped in after canonical validation but before open",
515 () =>
516 Effect.gen(function* () {
517 const fs = yield* FileSystem.FileSystem;
518 const path = yield* Path.Path;
519 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-open-race-" });
520 const filePath = path.join(root, "recording.mp4");
521 const secretPath = path.join(root, "secret.txt");
522 yield* fs.writeFileString(filePath, "video");
523 yield* fs.writeFileString(secretPath, "secret");
524 const canonicalPath = yield* fs.realPath(filePath);
525 const result = yield* issueAssetUrl({
526 resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath },
527 });
528 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
529 const separator = suffix.indexOf("/");
530 const swappingFileSystem = FileSystem.FileSystem.of({
531 ...fs,
532 stat: Effect.fn(function* (requestedPath) {
533 const info = yield* fs.stat(requestedPath);
534 if (requestedPath === canonicalPath) {
535 yield* fs.remove(filePath);
536 yield* fs.symlink(secretPath, filePath);
537 }
538 return info;
539 }),
540 });
541 expect(
542 yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)).pipe(
543 Effect.provideService(FileSystem.FileSystem, swappingFileSystem),
544 ),
545 ).toBeNull();
546 }).pipe(Effect.provide(layerTest)),
547 );
548
549 it.effect.skipIf(!symlinksSupported)(
550 "closes a descriptor rejected when its path changes during open",
551 () =>
552 Effect.gen(function* () {
553 const fs = yield* FileSystem.FileSystem;
554 const path = yield* Path.Path;
555 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-open-rejected-" });
556 const filePath = path.join(root, "recording.mp4");
557 const secretPath = path.join(root, "secret.txt");
558 yield* fs.writeFileString(filePath, "video");
559 yield* fs.writeFileString(secretPath, "secret");
560 const canonicalPath = yield* fs.realPath(filePath);
561 const originalOpen = (yield* Effect.promise(() =>
562 vi.importActual<typeof NodeFSP>("node:fs/promises"),
563 )).open;
564 let opened: NodeFSP.FileHandle | undefined;
565 const openSpy = vi.mocked(NodeFSP.open).mockImplementation(async (target, flags, mode) => {
566 const handle = await originalOpen(target, flags, mode);
567 if (target === canonicalPath) {
568 opened = handle;
569 await NodeFSP.unlink(filePath);
570 await NodeFSP.symlink(secretPath, filePath);
571 }
572 return handle;
573 });
574 yield* Effect.addFinalizer(() =>
575 Effect.sync(() => openSpy.mockImplementation(originalOpen)),
576 );
577 expect(yield* openMediaFile(canonicalPath)).toBeNull();
578 expect(opened).toBeDefined();
579 expect(opened?.fd).toBe(-1);
580 }).pipe(Effect.provide(layerTest)),
581 );
582
583 it.effect.skipIf(!symlinksSupported)(
584 "rejects an ancestor symlink race even when canonical path rechecks would pass",
585 () =>
586 Effect.gen(function* () {
587 const fs = yield* FileSystem.FileSystem;
588 const path = yield* Path.Path;
589 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-parent-race-" });
590 const publicDirectory = path.join(root, "public");
591 const privateDirectory = path.join(root, "private");
592 yield* fs.makeDirectory(publicDirectory);
593 yield* fs.makeDirectory(privateDirectory);
594 const filePath = path.join(publicDirectory, "recording.mp4");
595 yield* fs.writeFileString(filePath, "public video");
596 yield* fs.writeFileString(path.join(privateDirectory, "recording.mp4"), "private video");
597 const canonicalPath = yield* fs.realPath(filePath);
598 const result = yield* issueAssetUrl({
599 resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath },
600 });
601 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
602 const separator = suffix.indexOf("/");
603 const native = yield* Effect.promise(() =>
604 vi.importActual<typeof NodeFSP>("node:fs/promises"),
605 );
606 const savedDirectory = path.join(root, "saved");
607 const realpathSpy = vi.mocked(NodeFSP.realpath).mockImplementationOnce(async () => {
608 // A pathname-only guard can see the original parents during realpath,
609 // but the private file during both lstat calls and open.
610 await native.unlink(publicDirectory);
611 await native.rename(savedDirectory, publicDirectory);
612 const canonical = await native.realpath(canonicalPath);
613 await native.rename(publicDirectory, savedDirectory);
614 await native.symlink(privateDirectory, publicDirectory, "junction");
615 return canonical;
616 });
617 yield* Effect.addFinalizer(() =>
618 Effect.sync(() => realpathSpy.mockReset().mockImplementation(native.realpath)),
619 );
620 const swappingFileSystem = FileSystem.FileSystem.of({
621 ...fs,
622 realPath: Effect.fn(function* (requestedPath) {
623 const canonical = yield* fs.realPath(requestedPath);
624 if (requestedPath === canonicalPath) {
625 yield* fs.rename(publicDirectory, savedDirectory);
626 yield* Effect.promise(() =>
627 NodeFSP.symlink(privateDirectory, publicDirectory, "junction"),
628 );
629 }
630 return canonical;
631 }),
632 });
633 expect(
634 yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)).pipe(
635 Effect.provideService(FileSystem.FileSystem, swappingFileSystem),
636 ),
637 ).toBeNull();
638 }).pipe(Effect.provide(layerTest)),
639 );
640
641 it.effect("keeps in-place edits readable but requires a new URL after atomic replacement", () =>
642 Effect.gen(function* () {
643 const fs = yield* FileSystem.FileSystem;
644 const path = yield* Path.Path;
645 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-replacement-" });
646 const filePath = path.join(root, "recording.mp4");
647 yield* fs.writeFileString(filePath, "original");
648 const input = {
649 resource: {
650 _tag: "media-file" as const,
651 threadId: ThreadId.make("thread-1"),
652 path: filePath,
653 },
654 };
655 const original = yield* issueAssetUrl(input);
656 const suffix = original.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
657 const separator = suffix.indexOf("/");
658 const token = suffix.slice(0, separator);
659 const name = suffix.slice(separator + 1);
660 yield* fs.writeFileString(filePath, "in-place edit");
661 const edited = yield* resolveAsset(token, name);
662 if (edited?.kind !== "file") throw new Error("Expected the edited media file");
663 const editedResponse = HttpServerResponse.toWeb(yield* assetFileResponse(edited));
664 expect(yield* Effect.promise(() => editedResponse.text())).toBe("in-place edit");
665
666 const replacement = path.join(root, "replacement.mp4");
667 yield* fs.writeFileString(replacement, "replacement");
668 yield* fs.rename(replacement, filePath);
669 expect(yield* resolveAsset(token, name)).toBeNull();
670
671 const renewed = yield* issueAssetUrl(input);
672 const renewedSuffix = renewed.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
673 const renewedSeparator = renewedSuffix.indexOf("/");
674 const renewedAsset = yield* resolveAsset(
675 renewedSuffix.slice(0, renewedSeparator),
676 renewedSuffix.slice(renewedSeparator + 1),
677 );
678 if (renewedAsset?.kind !== "file") throw new Error("Expected the replacement media file");
679 const renewedResponse = HttpServerResponse.toWeb(yield* assetFileResponse(renewedAsset));
680 expect(yield* Effect.promise(() => renewedResponse.text())).toBe("replacement");
681 yield* fs.remove(filePath);
682 expect(
683 yield* resolveAsset(
684 renewedSuffix.slice(0, renewedSeparator),
685 renewedSuffix.slice(renewedSeparator + 1),
686 ),
687 ).toBeNull();
688 }).pipe(Effect.provide(layerTest)),
689 );
690
691 it.effect("issues workspace URLs that resolve the entry file and sibling assets", () =>
692 Effect.gen(function* () {
693 const fileSystem = yield* FileSystem.FileSystem;
694 const path = yield* Path.Path;
695 const root = yield* fileSystem.makeTempDirectoryScoped({
696 prefix: "t3-asset-workspace-",
697 });
698 const htmlPath = path.join(root, "report.html");
699 const cssPath = path.join(root, "report.css");
700 yield* fileSystem.writeFileString(htmlPath, '<link rel="stylesheet" href="report.css">');
701 yield* fileSystem.writeFileString(cssPath, "body { color: red; }");
702 yield* fileSystem.writeFileString(path.join(root, ".env"), "SECRET=value");
703 const canonicalHtmlPath = yield* fileSystem.realPath(htmlPath);
704 const canonicalCssPath = yield* fileSystem.realPath(cssPath);
705
706 const result = yield* issueAssetUrl({
707 resource: {
708 _tag: "workspace-file",
709 threadId: ThreadId.make("thread-1"),
710 path: htmlPath,
711 },
712 workspaceRoot: root,
713 });
714 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
715 const separatorIndex = suffix.indexOf("/");
716 const token = suffix.slice(0, separatorIndex);
717
718 expect(yield* resolveAsset(token, "report.html")).toEqual({
719 kind: "file",
720 path: canonicalHtmlPath,
721 });
722 expect(yield* resolveAsset(token, "report.css")).toEqual({
723 kind: "file",
724 path: canonicalCssPath,
725 });
726 expect(yield* resolveAsset(token, "../secret.txt")).toBeNull();
727 expect(yield* resolveAsset(token, ".env")).toBeNull();
728 expect(yield* resolveAsset(`${token}tampered`, "report.html")).toBeNull();
729 }).pipe(Effect.provide(layerTest)),
730 );
731
732 it.effect("rejects workspace files outside the authorized root", () =>
733 Effect.gen(function* () {
734 const fileSystem = yield* FileSystem.FileSystem;
735 const path = yield* Path.Path;
736 const root = yield* fileSystem.makeTempDirectoryScoped({
737 prefix: "t3-asset-root-",
738 });
739 const outside = yield* fileSystem.makeTempDirectoryScoped({
740 prefix: "t3-asset-outside-",
741 });
742 const htmlPath = path.join(outside, "report.html");
743 yield* fileSystem.writeFileString(htmlPath, "<p>outside</p>");
744
745 const error = yield* issueAssetUrl({
746 resource: {
747 _tag: "workspace-file",
748 threadId: ThreadId.make("thread-1"),
749 path: htmlPath,
750 },
751 workspaceRoot: root,
752 }).pipe(Effect.flip);
753 expect(error.message).toBe("Workspace file path must be relative to the project root.");
754 expect(error).toMatchObject({
755 _tag: "AssetWorkspacePathValidationError",
756 resource: {
757 _tag: "workspace-file",
758 threadId: "thread-1",
759 path: htmlPath,
760 },
761 });
762 expect(error.cause).toBeInstanceOf(WorkspacePaths.WorkspacePathOutsideRootError);
763 }).pipe(Effect.provide(layerTest)),
764 );
765
766 it.effect("issues draft workspace URLs without a thread", () =>
767 Effect.gen(function* () {
768 const fileSystem = yield* FileSystem.FileSystem;
769 const path = yield* Path.Path;
770 const root = yield* fileSystem.makeTempDirectoryScoped({
771 prefix: "t3-asset-draft-",
772 });
773 const htmlPath = path.join(root, "report.html");
774 const cssPath = path.join(root, "report.css");
775 yield* fileSystem.writeFileString(htmlPath, '<link rel="stylesheet" href="report.css">');
776 yield* fileSystem.writeFileString(cssPath, "body { color: red; }");
777 const canonicalHtmlPath = yield* fileSystem.realPath(htmlPath);
778 const canonicalCssPath = yield* fileSystem.realPath(cssPath);
779
780 const result = yield* issueAssetUrl({
781 resource: { _tag: "draft-workspace-file", cwd: root, path: "report.html" },
782 workspaceRoot: root,
783 });
784 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
785 const separatorIndex = suffix.indexOf("/");
786 const token = suffix.slice(0, separatorIndex);
787
788 expect(yield* resolveAsset(token, "report.html")).toEqual({
789 kind: "file",
790 path: canonicalHtmlPath,
791 });
792 expect(yield* resolveAsset(token, "report.css")).toEqual({
793 kind: "file",
794 path: canonicalCssPath,
795 });
796 expect(yield* resolveAsset(token, "../secret.txt")).toBeNull();
797 }).pipe(Effect.provide(layerTest)),
798 );
799
800 it.effect("serves absolute draft media files exactly, wherever they live", () =>
801 Effect.gen(function* () {
802 const fileSystem = yield* FileSystem.FileSystem;
803 const path = yield* Path.Path;
804 const root = yield* fileSystem.makeTempDirectoryScoped({
805 prefix: "t3-asset-draft-root-",
806 });
807 const outside = yield* fileSystem.makeTempDirectoryScoped({
808 prefix: "t3-asset-draft-outside-",
809 });
810 const clipPath = path.join(outside, "clip.mp4");
811 yield* fileSystem.writeFileString(clipPath, "video");
812 const canonicalClipPath = yield* fileSystem.realPath(clipPath);
813
814 const result = yield* issueAssetUrl({
815 resource: { _tag: "draft-workspace-file", cwd: root, path: clipPath },
816 workspaceRoot: root,
817 });
818 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
819 const separatorIndex = suffix.indexOf("/");
820 const token = suffix.slice(0, separatorIndex);
821
822 expect(yield* resolveAsset(token, "clip.mp4")).toMatchObject({
823 kind: "file",
824 path: canonicalClipPath,
825 mimeType: "video/mp4",
826 });
827 expect(yield* resolveAsset(token, "other.mp4")).toBeNull();
828 }).pipe(Effect.provide(layerTest)),
829 );
830
831 it.effect("previews ICNS candidates without selecting a project favicon", () =>
832 Effect.gen(function* () {
833 const fileSystem = yield* FileSystem.FileSystem;
834 const path = yield* Path.Path;
835 const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-icon-candidates-" });
836 const external = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-icon-external-" });
837 yield* fileSystem.writeFileString(path.join(root, "favicon.svg"), "<svg>saved</svg>");
838
839 for (const requestedPath of ["Candidate.icns", path.join(external, "Candidate.icns")]) {
840 const filePath = path.isAbsolute(requestedPath)
841 ? requestedPath
842 : path.join(root, requestedPath);
843 yield* fileSystem.writeFile(filePath, screenshotIcns);
844 const result = yield* issueAssetUrl({
845 resource: { _tag: "draft-workspace-file", cwd: root, path: requestedPath },
846 });
847 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
848 const token = suffix.slice(0, suffix.indexOf("/"));
849 const canonicalPath = yield* fileSystem.realPath(filePath);
850 const asset = yield* resolveAsset(token, "Candidate.icns").pipe(
851 Effect.provideService(
852 FileSystem.FileSystem,
853 FileSystem.FileSystem.of({
854 ...fileSystem,
855 readFile: (requested) =>
856 path.isAbsolute(requestedPath) && requested === canonicalPath
857 ? Effect.succeed(new Uint8Array())
858 : fileSystem.readFile(requested),
859 }),
860 ),
861 );
862
863 expect(asset?.kind === "bytes" ? asset.mimeType : null).toBe("image/png");
864 expect(asset?.kind === "bytes" ? Uint8Array.from(asset.bytes) : null).toEqual(
865 screenshotPng,
866 );
867 expect(yield* resolveAsset(token, "favicon.svg")).toBeNull();
868 expect(yield* resolveAsset(token, "../Candidate.icns")).toBeNull();
869
870 if (path.isAbsolute(requestedPath)) {
871 const replacement = path.join(external, "replacement.icns");
872 yield* fileSystem.writeFile(replacement, screenshotIcns);
873 yield* fileSystem.rename(replacement, filePath);
874 expect(yield* resolveAsset(token, "Candidate.icns")).toBeNull();
875 }
876 }
877 const saved = yield* issueAssetUrl({
878 resource: { _tag: "project-favicon", cwd: root, path: "Candidate.icns" },
879 });
880 expect(saved.sourcePath).toBe("favicon.svg");
881 }).pipe(Effect.provide(layerTest)),
882 );
883
884 it.effect.skipIf(!symlinksSupported)("revalidates ICNS candidate workspace confinement", () =>
885 Effect.gen(function* () {
886 const fileSystem = yield* FileSystem.FileSystem;
887 const path = yield* Path.Path;
888 const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-icon-contained-" });
889 const outside = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-icon-private-" });
890 const filePath = path.join(root, "Candidate.icns");
891 const secret = path.join(outside, "Private.icns");
892 yield* fileSystem.writeFile(filePath, screenshotPng);
893 yield* fileSystem.writeFile(secret, screenshotPng);
894 const result = yield* issueAssetUrl({
895 resource: { _tag: "draft-workspace-file", cwd: root, path: "Candidate.icns" },
896 });
897 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
898 const token = suffix.slice(0, suffix.indexOf("/"));
899 yield* fileSystem.remove(filePath);
900 yield* fileSystem.symlink(secret, filePath);
901 expect(yield* resolveAsset(token, "Candidate.icns")).toBeNull();
902 const error = yield* issueAssetUrl({
903 resource: { _tag: "draft-workspace-file", cwd: root, path: "Candidate.icns" },
904 }).pipe(Effect.flip);
905 expect(error._tag).toBe("AssetWorkspaceAssetNotFoundError");
906 }).pipe(Effect.provide(layerTest)),
907 );
908
909 it.effect("falls back to the resource cwd for relative draft paths", () =>
910 Effect.gen(function* () {
911 const fileSystem = yield* FileSystem.FileSystem;
912 const path = yield* Path.Path;
913 const root = yield* fileSystem.makeTempDirectoryScoped({
914 prefix: "t3-asset-draft-fallback-",
915 });
916 const htmlPath = path.join(root, "report.html");
917 yield* fileSystem.writeFileString(htmlPath, "<p>draft</p>");
918 const canonicalHtmlPath = yield* fileSystem.realPath(htmlPath);
919
920 const result = yield* issueAssetUrl({
921 resource: { _tag: "draft-workspace-file", cwd: root, path: "report.html" },
922 });
923 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
924 const separatorIndex = suffix.indexOf("/");
925 const token = suffix.slice(0, separatorIndex);
926
927 expect(yield* resolveAsset(token, "report.html")).toEqual({
928 kind: "file",
929 path: canonicalHtmlPath,
930 });
931 }).pipe(Effect.provide(layerTest)),
932 );
933
934 it.effect("preserves non-missing canonical path failures when issuing asset URLs", () =>
935 Effect.gen(function* () {
936 const fileSystem = yield* FileSystem.FileSystem;
937 const path = yield* Path.Path;
938 const root = yield* fileSystem.makeTempDirectoryScoped({
939 prefix: "t3-asset-permission-root-",
940 });
941 const htmlPath = path.join(root, "report.html");
942 yield* fileSystem.writeFileString(htmlPath, "<p>report</p>");
943 const cause = PlatformError.systemError({
944 _tag: "PermissionDenied",
945 module: "FileSystem",
946 method: "realPath",
947 pathOrDescriptor: htmlPath,
948 });
949 const failingFileSystem = FileSystem.FileSystem.of({
950 ...fileSystem,
951 realPath: () => Effect.fail(cause),
952 });
953
954 const error = yield* issueAssetUrl({
955 resource: {
956 _tag: "workspace-file",
957 threadId: ThreadId.make("thread-1"),
958 path: htmlPath,
959 },
960 workspaceRoot: root,
961 }).pipe(Effect.provideService(FileSystem.FileSystem, failingFileSystem), Effect.flip);
962
963 expect(error.message).toBe("Failed to inspect the workspace asset.");
964 expect(error).toMatchObject({
965 _tag: "AssetWorkspaceAssetInspectionError",
966 resource: {
967 _tag: "workspace-file",
968 threadId: "thread-1",
969 path: htmlPath,
970 },
971 });
972 expect(error.cause).toBe(cause);
973 }).pipe(Effect.provide(layerTest)),
974 );
975
976 it.effect("issues exact workspace URLs for image previews", () =>
977 Effect.gen(function* () {
978 const fileSystem = yield* FileSystem.FileSystem;
979 const path = yield* Path.Path;
980 const root = yield* fileSystem.makeTempDirectoryScoped({
981 prefix: "t3-asset-image-workspace-",
982 });
983 const assetsDirectory = path.join(root, "assets");
984 const imagePath = path.join(assetsDirectory, "icon.png");
985 const siblingPath = path.join(assetsDirectory, "other.png");
986 yield* fileSystem.makeDirectory(assetsDirectory, { recursive: true });
987 yield* fileSystem.writeFile(imagePath, new Uint8Array([137, 80, 78, 71]));
988 yield* fileSystem.writeFile(siblingPath, new Uint8Array([137, 80, 78, 71]));
989 const canonicalImagePath = yield* fileSystem.realPath(imagePath);
990
991 const result = yield* issueAssetUrl({
992 resource: {
993 _tag: "workspace-file",
994 threadId: ThreadId.make("thread-1"),
995 path: imagePath,
996 },
997 workspaceRoot: root,
998 });
999 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
1000 const separatorIndex = suffix.indexOf("/");
1001 const token = suffix.slice(0, separatorIndex);
1002
1003 expect(yield* resolveAsset(token, "icon.png")).toEqual({
1004 kind: "file",
1005 path: canonicalImagePath,
1006 });
1007 expect(yield* resolveAsset(token, "other.png")).toBeNull();
1008 expect(yield* resolveAsset(token, "../icon.png")).toBeNull();
1009 }).pipe(Effect.provide(layerTest)),
1010 );
1011
1012 it.effect("previews workspace files with literal fragment characters in their paths", () =>
1013 Effect.gen(function* () {
1014 const fileSystem = yield* FileSystem.FileSystem;
1015 const path = yield* Path.Path;
1016 const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-preview-literal-" });
1017 const directory = path.join(root, "assets#archive");
1018 yield* fileSystem.makeDirectory(directory);
1019
1020 for (const name of ["icon#v2.png", "report#draft.pdf"]) {
1021 const filePath = path.join(directory, name);
1022 yield* fileSystem.writeFileString(filePath, "preview fixture");
1023 const canonicalFile = yield* fileSystem.realPath(filePath);
1024 const result = yield* issueAssetUrl({
1025 resource: {
1026 _tag: "workspace-file",
1027 threadId: ThreadId.make("thread-1"),
1028 path: filePath,
1029 },
1030 workspaceRoot: root,
1031 });
1032 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
1033 const token = suffix.slice(0, suffix.indexOf("/"));
1034
1035 expect(yield* resolveAsset(token, name)).toEqual({ kind: "file", path: canonicalFile });
1036 if (name.endsWith(".png")) {
1037 expect(yield* resolveAsset(token, "other.png")).toBeNull();
1038 }
1039 }
1040
1041 const disguisedPath = path.join(root, "image.png#notes.txt");
1042 yield* fileSystem.writeFileString(disguisedPath, "not an image");
1043 const error = yield* issueAssetUrl({
1044 resource: {
1045 _tag: "workspace-file",
1046 threadId: ThreadId.make("thread-1"),
1047 path: disguisedPath,
1048 },
1049 workspaceRoot: root,
1050 }).pipe(Effect.flip);
1051 expect(error).toBeInstanceOf(AssetPreviewTypeValidationError);
1052 }).pipe(Effect.provide(layerTest)),
1053 );
1054
1055 it.effect("signs MCP render intent only for inline HTML attachments", () =>
1056 Effect.gen(function* () {
1057 const config = yield* ServerConfig.ServerConfig;
1058 const fs = yield* FileSystem.FileSystem;
1059 const path = yield* Path.Path;
1060 yield* fs.makeDirectory(config.attachmentsDir, { recursive: true });
1061 for (const extension of ["html", "pdf"]) {
1062 const attachmentId = `thread-1-00000000-0000-4000-8000-000000000031-${extension}`;
1063 yield* fs.writeFileString(
1064 path.join(config.attachmentsDir, `${attachmentId}.${extension}`),
1065 "capture",
1066 );
1067 for (const disposition of ["inline", "attachment"] as const) {
1068 const result = yield* issueAssetUrl({
1069 resource: {
1070 _tag: "attachment",
1071 attachmentId,
1072 disposition,
1073 renderIntent: "mcp-app",
1074 },
1075 });
1076 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
1077 const separator = suffix.indexOf("/");
1078 const asset = yield* resolveAsset(
1079 suffix.slice(0, separator),
1080 suffix.slice(separator + 1),
1081 );
1082 if (asset?.kind !== "file") throw new Error("Expected attachment file");
1083 expect("renderIntent" in asset ? asset.renderIntent : undefined).toBe(
1084 extension === "html" && disposition === "inline" ? "mcp-app" : undefined,
1085 );
1086 // Changing signed claims cannot opt an ordinary download into rendering.
1087 const token = suffix.slice(0, separator);
1088 const [payload, signature] = token.split(".");
1089 const claims = JSON.parse(Buffer.from(payload!, "base64url").toString("utf8"));
1090 claims.renderIntent = claims.renderIntent ? undefined : "mcp-app";
1091 const tampered = `${Buffer.from(JSON.stringify(claims)).toString("base64url")}.${signature}`;
1092 expect(yield* resolveAsset(tampered, suffix.slice(separator + 1))).toBeNull();
1093 }
1094 }
1095 }).pipe(Effect.provide(layerTest)),
1096 );
1097
1098 it.effect("issues exact attachment capabilities by attachment id", () =>
1099 Effect.gen(function* () {
1100 const config = yield* ServerConfig.ServerConfig;
1101 const fileSystem = yield* FileSystem.FileSystem;
1102 const path = yield* Path.Path;
1103 const attachmentId = "thread-1-00000000-0000-4000-8000-000000000001";
1104 const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.png`);
1105 yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true });
1106 yield* fileSystem.writeFile(attachmentPath, new Uint8Array([1, 2, 3]));
1107
1108 const result = yield* issueAssetUrl({
1109 resource: { _tag: "attachment", attachmentId },
1110 });
1111 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
1112 const separatorIndex = suffix.indexOf("/");
1113 const token = suffix.slice(0, separatorIndex);
1114
1115 expect(yield* resolveAsset(token, "ignored.png")).toEqual({
1116 kind: "file",
1117 path: attachmentPath,
1118 });
1119 }).pipe(Effect.provide(layerTest)),
1120 );
1121
1122 it.effect("serves video attachments inline", () =>
1123 Effect.gen(function* () {
1124 const config = yield* ServerConfig.ServerConfig;
1125 const fileSystem = yield* FileSystem.FileSystem;
1126 const path = yield* Path.Path;
1127 const attachmentId = "thread-1-00000000-0000-4000-8000-000000000001-mp4";
1128 const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.mp4`);
1129 yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true });
1130 yield* fileSystem.writeFile(attachmentPath, new Uint8Array([1, 2, 3]));
1131
1132 const result = yield* issueAssetUrl({
1133 resource: {
1134 _tag: "attachment",
1135 attachmentId,
1136 fileName: "demo.mp4",
1137 mimeType: 'video/mp4; codecs="avc1.42E01E"',
1138 },
1139 });
1140 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
1141 const separatorIndex = suffix.indexOf("/");
1142
1143 expect(
1144 yield* resolveAsset(suffix.slice(0, separatorIndex), suffix.slice(separatorIndex + 1)),
1145 ).toEqual({
1146 kind: "file",
1147 path: attachmentPath,
1148 fileName: "demo.mp4",
1149 mimeType: "video/mp4",
1150 });
1151 }).pipe(Effect.provide(layerTest)),
1152 );
1153 it.effect("issues signed native application icon capabilities", () =>
1154 Effect.gen(function* () {
1155 const result = yield* issueAssetUrl({
1156 resource: {
1157 _tag: "native-app-icon",
1158 app: { _tag: "app-id", appId: "com.example.Editor" },
1159 },
1160 });
1161
1162 expect(result.relativeUrl).toMatch(
1163 new RegExp(`^${ASSET_ROUTE_PREFIX}/[^/]+/native-app-icon\\.png$`, "u"),
1164 );
1165 expect(result.expiresAt).toBeGreaterThan(0);
1166 }).pipe(Effect.provide(layerTest)),
1167 );
1168
1169 it.effect("serves document attachments inline when a viewer requests it", () =>
1170 Effect.gen(function* () {
1171 const config = yield* ServerConfig.ServerConfig;
1172 const fileSystem = yield* FileSystem.FileSystem;
1173 const path = yield* Path.Path;
1174 const attachmentId = "thread-1-00000000-0000-4000-8000-000000000001-pdf";
1175 const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.pdf`);
1176 yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true });
1177 yield* fileSystem.writeFile(attachmentPath, new Uint8Array([1, 2, 3]));
1178
1179 const result = yield* issueAssetUrl({
1180 resource: {
1181 _tag: "attachment",
1182 attachmentId,
1183 fileName: "report.pdf",
1184 mimeType: "application/pdf",
1185 disposition: "inline",
1186 },
1187 });
1188 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
1189 const separatorIndex = suffix.indexOf("/");
1190
1191 expect(
1192 yield* resolveAsset(suffix.slice(0, separatorIndex), suffix.slice(separatorIndex + 1)),
1193 ).toEqual({
1194 kind: "file",
1195 path: attachmentPath,
1196 fileName: "report.pdf",
1197 mimeType: "application/pdf",
1198 });
1199 }).pipe(Effect.provide(layerTest)),
1200 );
1201
1202 it.effect("serves audio previews with their stored format and keeps saving explicit", () =>
1203 Effect.gen(function* () {
1204 const config = yield* ServerConfig.ServerConfig;
1205 const fileSystem = yield* FileSystem.FileSystem;
1206 const path = yield* Path.Path;
1207 const attachmentId = "thread-1-00000000-0000-4000-8000-000000000003-wav";
1208 const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.wav`);
1209 yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true });
1210 yield* fileSystem.writeFile(attachmentPath, new Uint8Array([1, 2, 3]));
1211 for (const disposition of ["inline", "attachment"] as const) {
1212 const result = yield* issueAssetUrl({
1213 resource: {
1214 _tag: "attachment",
1215 attachmentId,
1216 fileName: "recording.wav",
1217 mimeType: "application/octet-stream",
1218 disposition,
1219 },
1220 });
1221 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
1222 const separatorIndex = suffix.indexOf("/");
1223 expect(
1224 yield* resolveAsset(suffix.slice(0, separatorIndex), suffix.slice(separatorIndex + 1)),
1225 ).toEqual({
1226 kind: "file",
1227 path: attachmentPath,
1228 fileName: "recording.wav",
1229 mimeType: disposition === "inline" ? "audio/wav" : "application/octet-stream",
1230 ...(disposition === "attachment" ? { download: true } : {}),
1231 });
1232 }
1233 }).pipe(Effect.provide(layerTest)),
1234 );
1235
1236 it.effect("keeps inline requests for other attachment types as downloads", () =>
1237 Effect.gen(function* () {
1238 const config = yield* ServerConfig.ServerConfig;
1239 const fileSystem = yield* FileSystem.FileSystem;
1240 const path = yield* Path.Path;
1241 const attachmentId = "thread-1-00000000-0000-4000-8000-000000000002-zip";
1242 const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.zip`);
1243 yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true });
1244 yield* fileSystem.writeFile(attachmentPath, new Uint8Array([1, 2, 3]));
1245
1246 const result = yield* issueAssetUrl({
1247 resource: {
1248 _tag: "attachment",
1249 attachmentId,
1250 fileName: "archive.zip",
1251 mimeType: "text/html",
1252 disposition: "inline",
1253 },
1254 });
1255 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
1256 const separatorIndex = suffix.indexOf("/");
1257
1258 expect(
1259 yield* resolveAsset(suffix.slice(0, separatorIndex), suffix.slice(separatorIndex + 1)),
1260 ).toMatchObject({ kind: "file", path: attachmentPath, download: true });
1261 }).pipe(Effect.provide(layerTest)),
1262 );
1263 it.effect("issues project favicon capabilities with a signed fallback", () =>
1264 Effect.gen(function* () {
1265 const fileSystem = yield* FileSystem.FileSystem;
1266 const path = yield* Path.Path;
1267 const root = yield* fileSystem.makeTempDirectoryScoped({
1268 prefix: "t3-asset-favicon-",
1269 });
1270 const faviconPath = path.join(root, "favicon.svg");
1271 const initialFavicon = "<svg>a</svg>";
1272 const updatedFavicon = "<svg>b</svg>";
1273 expect(updatedFavicon).toHaveLength(initialFavicon.length);
1274 yield* fileSystem.writeFileString(faviconPath, initialFavicon);
1275 const canonicalFaviconPath = yield* fileSystem.realPath(faviconPath);
1276
1277 const faviconResult = yield* issueAssetUrl({
1278 resource: { _tag: "project-favicon", cwd: root },
1279 });
1280 expect(faviconResult.sourcePath).toBe("favicon.svg");
1281 expect(faviconResult.relativeUrl).toMatch(/\/v[0-9a-f]{64}-favicon\.svg$/);
1282 expect(
1283 yield* issueAssetUrl({
1284 resource: { _tag: "project-favicon", cwd: root },
1285 }),
1286 ).toEqual(faviconResult);
1287 const faviconSuffix = faviconResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
1288 const faviconSeparatorIndex = faviconSuffix.indexOf("/");
1289 expect(
1290 yield* resolveAsset(
1291 faviconSuffix.slice(0, faviconSeparatorIndex),
1292 faviconSuffix.slice(faviconSeparatorIndex + 1),
1293 ),
1294 ).toEqual({ kind: "file", path: canonicalFaviconPath });
1295
1296 yield* fileSystem.writeFileString(faviconPath, updatedFavicon);
1297 const updatedFaviconResult = yield* issueAssetUrl({
1298 resource: { _tag: "project-favicon", cwd: root },
1299 });
1300 expect(
1301 updatedFaviconResult.relativeUrl.slice(updatedFaviconResult.relativeUrl.lastIndexOf("/")),
1302 ).not.toBe(faviconResult.relativeUrl.slice(faviconResult.relativeUrl.lastIndexOf("/")));
1303
1304 yield* fileSystem.remove(faviconPath);
1305 const fallbackResult = yield* issueAssetUrl({
1306 resource: { _tag: "project-favicon", cwd: root },
1307 });
1308 expect(fallbackResult.relativeUrl.endsWith(`/${PROJECT_FAVICON_FALLBACK_MARKER}`)).toBe(true);
1309 expect(fallbackResult.sourcePath).toBeUndefined();
1310 const fallbackSuffix = fallbackResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
1311 const fallbackSeparatorIndex = fallbackSuffix.indexOf("/");
1312 expect(
1313 yield* resolveAsset(
1314 fallbackSuffix.slice(0, fallbackSeparatorIndex),
1315 fallbackSuffix.slice(fallbackSeparatorIndex + 1),
1316 ),
1317 ).toBeNull();
1318 }).pipe(Effect.provide(layerTest)),
1319 );
1320
1321 it.effect("issues project favicon capabilities for a saved override", () =>
1322 Effect.gen(function* () {
1323 const fileSystem = yield* FileSystem.FileSystem;
1324 const path = yield* Path.Path;
1325 const root = yield* fileSystem.makeTempDirectoryScoped({
1326 prefix: "t3-asset-favicon-override-",
1327 });
1328 yield* fileSystem.makeDirectory(path.join(root, "brand"));
1329 yield* fileSystem.writeFileString(path.join(root, "brand", "custom.svg"), "<svg />");
1330 yield* fileSystem.writeFileString(path.join(root, "favicon.svg"), "<svg>auto</svg>");
1331
1332 const result = yield* issueAssetUrl({
1333 resource: { _tag: "project-favicon", cwd: root },
1334 projectFaviconPath: "brand/custom.svg",
1335 });
1336
1337 expect(result.sourcePath).toBe(path.join("brand", "custom.svg"));
1338 expect(result.relativeUrl).toMatch(/\/v[0-9a-f]{64}-custom\.svg$/);
1339 }).pipe(Effect.provide(layerTest)),
1340 );
1341
1342 it.effect("issues an exact capability for a saved favicon outside the workspace", () =>
1343 Effect.gen(function* () {
1344 const fileSystem = yield* FileSystem.FileSystem;
1345 const path = yield* Path.Path;
1346 const root = yield* fileSystem.makeTempDirectoryScoped({
1347 prefix: "t3-asset-favicon-workspace-",
1348 });
1349 const pictures = yield* fileSystem.makeTempDirectoryScoped({
1350 prefix: "t3-asset-favicon-pictures-",
1351 });
1352 const externalPath = path.join(pictures, "custom.png");
1353 const siblingPath = path.join(pictures, "sibling.png");
1354 yield* fileSystem.writeFile(externalPath, new Uint8Array([1, 2, 3]));
1355 yield* fileSystem.writeFile(siblingPath, new Uint8Array([4, 5, 6]));
1356 const canonicalPath = yield* fileSystem.realPath(externalPath);
1357 const canonicalSiblingPath = yield* fileSystem.realPath(siblingPath);
1358
1359 const result = yield* issueAssetUrl({
1360 resource: { _tag: "project-favicon", cwd: root },
1361 projectFaviconPath: externalPath,
1362 });
1363 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
1364 const separatorIndex = suffix.indexOf("/");
1365
1366 expect(result.sourcePath).toBe(externalPath);
1367 expect(result.relativeUrl).toMatch(/\/v[0-9a-f]{64}-custom\.png$/);
1368 expect(
1369 yield* resolveAsset(suffix.slice(0, separatorIndex), suffix.slice(separatorIndex + 1)),
1370 ).toEqual({ kind: "file", path: canonicalPath });
1371 const tamperedSuffixResult = yield* resolveAsset(
1372 suffix.slice(0, separatorIndex),
1373 "sibling.png",
1374 );
1375 expect(tamperedSuffixResult).toEqual({ kind: "file", path: canonicalPath });
1376 expect(tamperedSuffixResult).not.toEqual({ kind: "file", path: canonicalSiblingPath });
1377 }).pipe(Effect.provide(layerTest)),
1378 );
1379
1380 it.effect("ignores a client favicon path hint", () =>
1381 Effect.gen(function* () {
1382 const fileSystem = yield* FileSystem.FileSystem;
1383 const path = yield* Path.Path;
1384 const root = yield* fileSystem.makeTempDirectoryScoped({
1385 prefix: "t3-asset-favicon-hint-",
1386 });
1387 yield* fileSystem.makeDirectory(path.join(root, "brand"));
1388 yield* fileSystem.writeFileString(path.join(root, "brand", "hint.svg"), "<svg>hint</svg>");
1389 yield* fileSystem.writeFileString(path.join(root, "brand", "saved.svg"), "<svg>saved</svg>");
1390
1391 const result = yield* issueAssetUrl({
1392 resource: { _tag: "project-favicon", cwd: root, path: "brand/hint.svg" },
1393 projectFaviconPath: "brand/saved.svg",
1394 });
1395
1396 expect(result.sourcePath).toBe(path.join("brand", "saved.svg"));
1397 expect(result.relativeUrl).toMatch(/\/v[0-9a-f]{64}-saved\.svg$/);
1398 }).pipe(Effect.provide(layerTest)),
1399 );
1400
1401 it.effect("keeps automatic favicon resolution separate from a saved override", () =>
1402 Effect.gen(function* () {
1403 const fileSystem = yield* FileSystem.FileSystem;
1404 const path = yield* Path.Path;
1405 const root = yield* fileSystem.makeTempDirectoryScoped({
1406 prefix: "t3-asset-favicon-automatic-",
1407 });
1408 yield* fileSystem.makeDirectory(path.join(root, "brand"));
1409 yield* fileSystem.writeFileString(path.join(root, "brand", "saved.svg"), "<svg>saved</svg>");
1410 yield* fileSystem.writeFileString(path.join(root, "favicon.svg"), "<svg>automatic</svg>");
1411
1412 const result = yield* issueAssetUrl({
1413 resource: { _tag: "project-favicon", cwd: root },
1414 });
1415
1416 expect(result.sourcePath).toBe("favicon.svg");
1417 expect(result.relativeUrl).toMatch(/\/v[0-9a-f]{64}-favicon\.svg$/);
1418 }).pipe(Effect.provide(layerTest)),
1419 );
1420
1421 it.effect("serves a native macOS app icon as its embedded PNG", () =>
1422 Effect.gen(function* () {
1423 const fileSystem = yield* FileSystem.FileSystem;
1424 const path = yield* Path.Path;
1425 const root = yield* fileSystem.makeTempDirectoryScoped({
1426 prefix: "t3-asset-favicon-icns-",
1427 });
1428 yield* fileSystem.makeDirectory(path.join(root, "Resources"));
1429 yield* fileSystem.writeFile(path.join(root, "Resources", "AppIcon.icns"), screenshotIcns);
1430
1431 const result = yield* issueAssetUrl({
1432 resource: { _tag: "project-favicon", cwd: root },
1433 });
1434 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
1435 const separatorIndex = suffix.indexOf("/");
1436
1437 expect(result.sourcePath).toBe(path.join("Resources", "AppIcon.icns"));
1438 const asset = yield* resolveAsset(
1439 suffix.slice(0, separatorIndex),
1440 suffix.slice(separatorIndex + 1),
1441 );
1442 expect(asset?.kind === "bytes" ? asset.mimeType : null).toBe("image/png");
1443 expect(asset?.kind === "bytes" ? Uint8Array.from(asset.bytes) : null).toEqual(screenshotPng);
1444 }).pipe(Effect.provide(layerTest)),
1445 );
1446
1447 it.effect("rejects a resolved project favicon with a non-image extension", () =>
1448 Effect.gen(function* () {
1449 const fileSystem = yield* FileSystem.FileSystem;
1450 const path = yield* Path.Path;
1451 const root = yield* fileSystem.makeTempDirectoryScoped({
1452 prefix: "t3-asset-favicon-type-",
1453 });
1454 yield* fileSystem.writeFileString(path.join(root, "secret.txt"), "not an image");
1455
1456 const error = yield* issueAssetUrl({
1457 resource: { _tag: "project-favicon", cwd: root },
1458 projectFaviconPath: "secret.txt",
1459 }).pipe(Effect.flip);
1460
1461 expect(error).toBeInstanceOf(AssetPreviewTypeValidationError);
1462 }).pipe(Effect.provide(layerTest)),
1463 );
1464
1465 it.effect("buckets project favicon expiry after content hashing", () =>
1466 Effect.gen(function* () {
1467 const crypto = yield* Crypto.Crypto;
1468 const fileSystem = yield* FileSystem.FileSystem;
1469 const path = yield* Path.Path;
1470 const root = yield* fileSystem.makeTempDirectoryScoped({
1471 prefix: "t3-asset-favicon-expiry-",
1472 });
1473 yield* fileSystem.writeFileString(path.join(root, "favicon.svg"), "<svg />");
1474
1475 const bucketMs = 30 * 60 * 1000;
1476 yield* TestClock.setTime(bucketMs - 1);
1477 const crossingCrypto = Crypto.make({
1478 randomBytes: (size) => new Uint8Array(size),
1479 digest: (algorithm, data) =>
1480 TestClock.adjust("2 millis").pipe(Effect.andThen(crypto.digest(algorithm, data))),
1481 });
1482 const result = yield* issueAssetUrl({
1483 resource: { _tag: "project-favicon", cwd: root },
1484 }).pipe(Effect.provideService(Crypto.Crypto, crossingCrypto));
1485
1486 expect(result.expiresAt).toBe(3 * bucketMs);
1487 }).pipe(Effect.provide(layerTest)),
1488 );
1489
1490 it.effect("preserves structured project favicon resolution causes", () =>
1491 Effect.gen(function* () {
1492 const fileSystem = yield* FileSystem.FileSystem;
1493 const root = yield* fileSystem.makeTempDirectoryScoped({
1494 prefix: "t3-asset-favicon-error-",
1495 });
1496 const platformCause = PlatformError.systemError({
1497 _tag: "PermissionDenied",
1498 module: "FileSystem",
1499 method: "stat",
1500 });
1501 const resolutionCause = new ProjectFaviconResolver.ProjectFaviconResolutionError({
1502 operation: "stat-candidate",
1503 workspaceRoot: root,
1504 relativePath: "favicon.svg",
1505 cause: platformCause,
1506 });
1507 const resolver = ProjectFaviconResolver.ProjectFaviconResolver.of({
1508 resolvePath: () => Effect.fail(resolutionCause),
1509 });
1510
1511 const error = yield* issueAssetUrl({
1512 resource: { _tag: "project-favicon", cwd: root },
1513 }).pipe(
1514 Effect.provideService(ProjectFaviconResolver.ProjectFaviconResolver, resolver),
1515 Effect.flip,
1516 );
1517
1518 expect(error.message).toBe("Failed to resolve project favicon.");
1519 expect(error._tag).toBe("AssetProjectFaviconResolutionError");
1520 expect(error.cause).toBe(resolutionCause);
1521 }).pipe(Effect.provide(layerTest)),
1522 );
1523
1524 it.effect("serves GitHub-hosted pull request media through the repository's credential", () =>
1525 Effect.gen(function* () {
1526 const resolve = (relativeUrl: string) => {
1527 const suffix = relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
1528 const separator = suffix.indexOf("/");
1529 return resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1));
1530 };
1531 const issue = (url: string) =>
1532 issueAssetUrl({ resource: { _tag: "github-media", cwd: "/repo", url } });
1533
1534 const attachment = yield* issue(
1535 "https://github.com/user-attachments/assets/1a1842fb-6383-492f-873c-57aa0033fa6c",
1536 );
1537 expect(attachment.relativeUrl.endsWith("/1a1842fb-6383-492f-873c-57aa0033fa6c")).toBe(true);
1538 expect(yield* resolve(attachment.relativeUrl)).toEqual({
1539 kind: "github-media",
1540 url: "https://github.com/user-attachments/assets/1a1842fb-6383-492f-873c-57aa0033fa6c",
1541 cwd: "/repo",
1542 // The signed URL's own expiry, which is how long a client may keep the bytes.
1543 expiresAt: attachment.expiresAt,
1544 });
1545
1546 // A `blob` link addresses the page; only the raw host answers a credential with bytes.
1547 const committed = yield* issue("https://github.com/owner/repo/blob/main/docs/shot.png");
1548 expect(yield* resolve(committed.relativeUrl)).toMatchObject({
1549 url: "https://raw.githubusercontent.com/owner/repo/main/docs/shot.png",
1550 });
1551
1552 // The pre-`user-attachments` form, Git LFS bytes, and a name no `decodeURIComponent`
1553 // accepts all arrive from real bodies.
1554 const legacy = yield* issue("https://github.com/owner/repo/assets/45952064/1a1842fb");
1555 expect(yield* resolve(legacy.relativeUrl)).toMatchObject({
1556 url: "https://github.com/owner/repo/assets/45952064/1a1842fb",
1557 });
1558 const lfs = yield* issue("https://media.githubusercontent.com/media/owner/repo/main/a.mp4");
1559 expect(yield* resolve(lfs.relativeUrl)).toMatchObject({
1560 url: "https://media.githubusercontent.com/media/owner/repo/main/a.mp4",
1561 });
1562 const awkward = yield* issue("https://raw.githubusercontent.com/o/r/main/100%.png");
1563 expect(awkward.relativeUrl.endsWith("/100%25.png")).toBe(true);
1564
1565 for (const url of [
1566 "https://example.com/shot.png",
1567 "https://example.com/shot.png?token=private-media-token",
1568 "http://github.com/user-attachments/assets/1a1842fb",
1569 "https://github.com/owner/repo/pull/1",
1570 "https://github.com/owner/repo/blob/main/",
1571 ]) {
1572 const error = yield* issue(url).pipe(Effect.flip);
1573 expect(error._tag).toBe("AssetGitHubMediaUrlValidationError");
1574 const encoded = yield* Schema.encodeEffect(Schema.fromJsonString(AssetAccessError))(error);
1575 expect(encoded).not.toContain(url);
1576 }
1577 }).pipe(Effect.provide(layerTest)),
1578 );
1579});