From e32951ca6808f89b0f5c301d06e7daea86156dcd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 05:43:29 -0700 Subject: [PATCH 1/3] fix: preserve asset access failure causes Co-authored-by: codex --- apps/server/src/assets/AssetAccess.test.ts | 38 ++++- apps/server/src/assets/AssetAccess.ts | 179 ++++++++++++++++----- 2 files changed, 173 insertions(+), 44 deletions(-) diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 0cbe9176582..4fcddd0f8e6 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerConfig from "../config.ts"; @@ -85,7 +86,42 @@ describe("AssetAccess", () => { }, workspaceRoot: root, }).pipe(Effect.flip); - expect(error.message).toContain("relative to the project root"); + expect(error.message).toBe("Workspace file path must be relative to the project root."); + expect(error.cause).toBeInstanceOf(WorkspacePaths.WorkspacePathOutsideRootError); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("preserves non-missing canonical path failures when issuing asset URLs", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-permission-root-", + }); + const htmlPath = path.join(root, "report.html"); + yield* fileSystem.writeFileString(htmlPath, "

report

"); + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "realPath", + pathOrDescriptor: htmlPath, + }); + const failingFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + realPath: () => Effect.fail(cause), + }); + + const error = yield* issueAssetUrl({ + resource: { + _tag: "workspace-file", + threadId: ThreadId.make("thread-1"), + path: htmlPath, + }, + workspaceRoot: root, + }).pipe(Effect.provideService(FileSystem.FileSystem, failingFileSystem), Effect.flip); + + expect(error.message).toBe("Failed to inspect the workspace asset."); + expect(error.cause).toBe(cause); }).pipe(Effect.provide(testLayer)), ); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 873e9fc3d37..c4ba47c2ab8 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -11,6 +11,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import { @@ -97,33 +98,59 @@ function decodeRelativePath(value: string): string | null { } } -const failAccess = (message: string, cause?: unknown) => - new AssetAccessError({ message, ...(cause === undefined ? {} : { cause }) }); +const optionOnNotFound = ( + effect: Effect.Effect, +): Effect.Effect, PlatformError.PlatformError, R> => + effect.pipe( + Effect.map(Option.some), + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" ? Effect.succeed(Option.none()) : Effect.fail(error), + }), + ); const resolveCanonicalWorkspaceFile = Effect.fn("AssetAccess.resolveCanonicalWorkspaceFile")( function* (input: { readonly workspaceRoot: string; readonly relativePath: string }) { const fileSystem = yield* FileSystem.FileSystem; const workspacePaths = yield* WorkspacePaths.WorkspacePaths; - const resolved = yield* workspacePaths - .resolveRelativePathWithinRoot(input) - .pipe(Effect.orElseSucceed(() => null)); - if (!resolved) return null; + const resolved = yield* workspacePaths.resolveRelativePathWithinRoot(input).pipe( + Effect.map(Option.some), + Effect.catchTags({ + WorkspacePathOutsideRootError: () => Effect.succeed(Option.none()), + }), + ); + if (Option.isNone(resolved)) return null; const [canonicalRoot, canonicalFile] = yield* Effect.all([ - fileSystem.realPath(input.workspaceRoot).pipe(Effect.orElseSucceed(() => null)), - fileSystem.realPath(resolved.absolutePath).pipe(Effect.orElseSucceed(() => null)), + optionOnNotFound(fileSystem.realPath(input.workspaceRoot)), + optionOnNotFound(fileSystem.realPath(resolved.value.absolutePath)), ]); - if (!canonicalRoot || !canonicalFile) return null; + if (Option.isNone(canonicalRoot) || Option.isNone(canonicalFile)) return null; const path = yield* Path.Path; - const relative = path.relative(canonicalRoot, canonicalFile); + const relative = path.relative(canonicalRoot.value, canonicalFile.value); if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) return null; - const info = yield* fileSystem.stat(canonicalFile).pipe(Effect.orElseSucceed(() => null)); - return info?.type === "File" ? canonicalFile : null; + const info = yield* optionOnNotFound(fileSystem.stat(canonicalFile.value)); + return Option.isSome(info) && info.value.type === "File" ? canonicalFile.value : null; }, ); +const resolveCanonicalWorkspaceFileForRequest = (input: { + readonly workspaceRoot: string; + readonly relativePath: string; +}) => + resolveCanonicalWorkspaceFile(input).pipe( + Effect.tapError((cause) => + Effect.logError("Failed to resolve canonical asset path.", { + workspaceRoot: input.workspaceRoot, + relativePath: input.relativePath, + cause, + }), + ), + Effect.orElseSucceed(() => null), + ); + export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (input: { readonly resource: AssetResource; readonly workspaceRoot?: string; @@ -138,30 +165,60 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i switch (input.resource._tag) { case "workspace-file": { if (!input.workspaceRoot) { - return yield* failAccess("Workspace context was not found."); + return yield* new AssetAccessError({ message: "Workspace context was not found." }); } - const workspaceRoot = yield* workspacePaths - .normalizeWorkspaceRoot(input.workspaceRoot) - .pipe(Effect.mapError((cause) => failAccess(cause.message, cause))); + const workspaceRoot = yield* workspacePaths.normalizeWorkspaceRoot(input.workspaceRoot).pipe( + Effect.mapError( + (cause) => + new AssetAccessError({ + message: "Failed to normalize the workspace root.", + cause, + }), + ), + ); const relativePath = path.isAbsolute(input.resource.path) ? path.relative(workspaceRoot, input.resource.path) : input.resource.path; const resolved = yield* workspacePaths .resolveRelativePathWithinRoot({ workspaceRoot, relativePath }) - .pipe(Effect.mapError((cause) => failAccess(cause.message, cause))); + .pipe( + Effect.mapError( + (cause) => + new AssetAccessError({ + message: "Workspace file path must be relative to the project root.", + cause, + }), + ), + ); if (!isWorkspacePreviewEntryPath(resolved.relativePath)) { - return yield* failAccess("Only browser documents and images can be previewed."); + return yield* new AssetAccessError({ + message: "Only browser documents and images can be previewed.", + }); } const canonicalFile = yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath: resolved.relativePath, - }); + }).pipe( + Effect.mapError( + (cause) => + new AssetAccessError({ + message: "Failed to inspect the workspace asset.", + cause, + }), + ), + ); if (!canonicalFile) { - return yield* failAccess("Workspace asset was not found."); + return yield* new AssetAccessError({ message: "Workspace asset was not found." }); } - const canonicalWorkspaceRoot = yield* fileSystem - .realPath(workspaceRoot) - .pipe(Effect.mapError((cause) => failAccess("Failed to resolve workspace.", cause))); + const canonicalWorkspaceRoot = yield* fileSystem.realPath(workspaceRoot).pipe( + Effect.mapError( + (cause) => + new AssetAccessError({ + message: "Failed to resolve workspace.", + cause, + }), + ), + ); claims = isWorkspaceImagePreviewPath(resolved.relativePath) ? { version: 1, @@ -187,7 +244,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i attachmentId: input.resource.attachmentId, }); if (!attachmentPath) { - return yield* failAccess("Attachment was not found."); + return yield* new AssetAccessError({ message: "Attachment was not found." }); } claims = { version: 1, @@ -199,24 +256,44 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i break; } case "project-favicon": { - const workspaceRoot = yield* workspacePaths - .normalizeWorkspaceRoot(input.resource.cwd) - .pipe(Effect.mapError((cause) => failAccess(cause.message, cause))); + const workspaceRoot = yield* workspacePaths.normalizeWorkspaceRoot(input.resource.cwd).pipe( + Effect.mapError( + (cause) => + new AssetAccessError({ + message: "Failed to normalize the workspace root.", + cause, + }), + ), + ); const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; const faviconPath = yield* faviconResolver.resolvePath(workspaceRoot); const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null; if ( relativePath && - !(yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath })) + !(yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe( + Effect.mapError( + (cause) => + new AssetAccessError({ + message: "Failed to inspect the project favicon.", + cause, + }), + ), + )) ) { - return yield* failAccess("Project favicon was not found."); + return yield* new AssetAccessError({ message: "Project favicon was not found." }); } claims = { version: 1, kind: "project-favicon", - workspaceRoot: yield* fileSystem - .realPath(workspaceRoot) - .pipe(Effect.mapError((cause) => failAccess("Failed to resolve workspace.", cause))), + workspaceRoot: yield* fileSystem.realPath(workspaceRoot).pipe( + Effect.mapError( + (cause) => + new AssetAccessError({ + message: "Failed to resolve workspace.", + cause, + }), + ), + ), relativePath, expiresAt, }; @@ -226,9 +303,15 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i } const secretStore = yield* ServerSecretStore.ServerSecretStore; - const signingSecret = yield* secretStore - .getOrCreateRandom(SIGNING_SECRET_NAME, 32) - .pipe(Effect.mapError((cause) => failAccess(cause.message, cause))); + const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32).pipe( + Effect.mapError( + (cause) => + new AssetAccessError({ + message: "Failed to load the asset signing key.", + cause, + }), + ), + ); const encodedPayload = base64UrlEncode(encodeAssetClaims(claims)); const token = `${encodedPayload}.${signPayload(encodedPayload, signingSecret)}`; return { @@ -245,9 +328,10 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( if (!encodedPayload || !signature) return null; const secretStore = yield* ServerSecretStore.ServerSecretStore; - const signingSecret = yield* secretStore - .getOrCreateRandom(SIGNING_SECRET_NAME, 32) - .pipe(Effect.orElseSucceed(() => null)); + const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32).pipe( + Effect.tapError((cause) => Effect.logError("Failed to load the asset signing key.", { cause })), + Effect.orElseSucceed(() => null), + ); if (!signingSecret) return null; if (!timingSafeEqualBase64Url(signature, signPayload(encodedPayload, signingSecret))) return null; @@ -262,8 +346,17 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( }); if (!attachmentPath) return null; const fileSystem = yield* FileSystem.FileSystem; - const info = yield* fileSystem.stat(attachmentPath).pipe(Effect.orElseSucceed(() => null)); - return info?.type === "File" + const info = yield* optionOnNotFound(fileSystem.stat(attachmentPath)).pipe( + Effect.tapError((cause) => + Effect.logError("Failed to inspect attachment asset.", { + attachmentId: claims.attachmentId, + path: attachmentPath, + cause, + }), + ), + Effect.orElseSucceed(() => Option.none()), + ); + return Option.isSome(info) && info.value.type === "File" ? ({ kind: "file", path: attachmentPath } satisfies ResolvedAsset) : null; } @@ -272,7 +365,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( if (claims.relativePath === null) { return { kind: "project-favicon-fallback" } satisfies ResolvedAsset; } - const faviconPath = yield* resolveCanonicalWorkspaceFile({ + const faviconPath = yield* resolveCanonicalWorkspaceFileForRequest({ workspaceRoot: claims.workspaceRoot, relativePath: claims.relativePath, }); @@ -284,7 +377,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( const path = yield* Path.Path; if (claims.kind === "workspace-file-exact") { if (decodedPath !== path.basename(claims.relativePath)) return null; - const exactWorkspaceFile = yield* resolveCanonicalWorkspaceFile({ + const exactWorkspaceFile = yield* resolveCanonicalWorkspaceFileForRequest({ workspaceRoot: claims.workspaceRoot, relativePath: claims.relativePath, }); @@ -303,7 +396,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( } const joinedRelativePath = claims.baseRelativePath === "." ? decodedPath : path.join(claims.baseRelativePath, decodedPath); - const workspaceFile = yield* resolveCanonicalWorkspaceFile({ + const workspaceFile = yield* resolveCanonicalWorkspaceFileForRequest({ workspaceRoot: claims.workspaceRoot, relativePath: joinedRelativePath, }); From 09ead1326e6ca4949494e5295d0d191d255be28b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:30:51 -0700 Subject: [PATCH 2/3] [codex] Structure project favicon resolution failures (#3369) Co-authored-by: codex --- apps/server/src/assets/AssetAccess.test.ts | 49 +++++++ apps/server/src/assets/AssetAccess.ts | 54 +++++++- .../project/ProjectFaviconResolver.test.ts | 111 ++++++++++++++++ .../src/project/ProjectFaviconResolver.ts | 124 ++++++++++++++---- apps/server/src/ws.ts | 8 ++ packages/contracts/src/assets.ts | 18 +++ 6 files changed, 334 insertions(+), 30 deletions(-) diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 4fcddd0f8e6..7df2e3361c8 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -87,6 +87,14 @@ describe("AssetAccess", () => { workspaceRoot: root, }).pipe(Effect.flip); expect(error.message).toBe("Workspace file path must be relative to the project root."); + expect(error).toMatchObject({ + operation: "validate-workspace-path", + resource: { + _tag: "workspace-file", + threadId: "thread-1", + path: htmlPath, + }, + }); expect(error.cause).toBeInstanceOf(WorkspacePaths.WorkspacePathOutsideRootError); }).pipe(Effect.provide(testLayer)), ); @@ -121,6 +129,14 @@ describe("AssetAccess", () => { }).pipe(Effect.provideService(FileSystem.FileSystem, failingFileSystem), Effect.flip); expect(error.message).toBe("Failed to inspect the workspace asset."); + expect(error).toMatchObject({ + operation: "inspect-workspace-asset", + resource: { + _tag: "workspace-file", + threadId: "thread-1", + path: htmlPath, + }, + }); expect(error.cause).toBe(cause); }).pipe(Effect.provide(testLayer)), ); @@ -222,4 +238,37 @@ describe("AssetAccess", () => { ).toEqual({ kind: "project-favicon-fallback" }); }).pipe(Effect.provide(testLayer)), ); + + it.effect("preserves structured project favicon resolution causes", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-error-", + }); + const platformCause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "stat", + }); + const resolutionCause = new ProjectFaviconResolver.ProjectFaviconResolutionError({ + operation: "stat-candidate", + workspaceRoot: root, + relativePath: "favicon.svg", + cause: platformCause, + }); + const resolver = ProjectFaviconResolver.ProjectFaviconResolver.of({ + resolvePath: () => Effect.fail(resolutionCause), + }); + + const error = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root }, + }).pipe( + Effect.provideService(ProjectFaviconResolver.ProjectFaviconResolver, resolver), + Effect.flip, + ); + + expect(error.message).toBe("Failed to resolve project favicon."); + expect(error.cause).toBe(resolutionCause); + }).pipe(Effect.provide(testLayer)), + ); }); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index c4ba47c2ab8..f7be262b41a 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -165,12 +165,18 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i switch (input.resource._tag) { case "workspace-file": { if (!input.workspaceRoot) { - return yield* new AssetAccessError({ message: "Workspace context was not found." }); + return yield* new AssetAccessError({ + operation: "resolve-workspace-context", + resource: input.resource, + message: "Workspace context was not found.", + }); } const workspaceRoot = yield* workspacePaths.normalizeWorkspaceRoot(input.workspaceRoot).pipe( Effect.mapError( (cause) => new AssetAccessError({ + operation: "normalize-workspace-root", + resource: input.resource, message: "Failed to normalize the workspace root.", cause, }), @@ -185,6 +191,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i Effect.mapError( (cause) => new AssetAccessError({ + operation: "validate-workspace-path", + resource: input.resource, message: "Workspace file path must be relative to the project root.", cause, }), @@ -192,6 +200,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i ); if (!isWorkspacePreviewEntryPath(resolved.relativePath)) { return yield* new AssetAccessError({ + operation: "validate-preview-type", + resource: input.resource, message: "Only browser documents and images can be previewed.", }); } @@ -202,18 +212,26 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i Effect.mapError( (cause) => new AssetAccessError({ + operation: "inspect-workspace-asset", + resource: input.resource, message: "Failed to inspect the workspace asset.", cause, }), ), ); if (!canonicalFile) { - return yield* new AssetAccessError({ message: "Workspace asset was not found." }); + return yield* new AssetAccessError({ + operation: "locate-workspace-asset", + resource: input.resource, + message: "Workspace asset was not found.", + }); } const canonicalWorkspaceRoot = yield* fileSystem.realPath(workspaceRoot).pipe( Effect.mapError( (cause) => new AssetAccessError({ + operation: "resolve-workspace", + resource: input.resource, message: "Failed to resolve workspace.", cause, }), @@ -244,7 +262,11 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i attachmentId: input.resource.attachmentId, }); if (!attachmentPath) { - return yield* new AssetAccessError({ message: "Attachment was not found." }); + return yield* new AssetAccessError({ + operation: "locate-attachment", + resource: input.resource, + message: "Attachment was not found.", + }); } claims = { version: 1, @@ -260,13 +282,25 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i Effect.mapError( (cause) => new AssetAccessError({ + operation: "normalize-workspace-root", + resource: input.resource, message: "Failed to normalize the workspace root.", cause, }), ), ); const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; - const faviconPath = yield* faviconResolver.resolvePath(workspaceRoot); + const faviconPath = yield* faviconResolver.resolvePath(workspaceRoot).pipe( + Effect.mapError( + (cause) => + new AssetAccessError({ + operation: "resolve-project-favicon", + resource: input.resource, + message: "Failed to resolve project favicon.", + cause, + }), + ), + ); const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null; if ( relativePath && @@ -274,13 +308,19 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i Effect.mapError( (cause) => new AssetAccessError({ + operation: "inspect-project-favicon", + resource: input.resource, message: "Failed to inspect the project favicon.", cause, }), ), )) ) { - return yield* new AssetAccessError({ message: "Project favicon was not found." }); + return yield* new AssetAccessError({ + operation: "locate-project-favicon", + resource: input.resource, + message: "Project favicon was not found.", + }); } claims = { version: 1, @@ -289,6 +329,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i Effect.mapError( (cause) => new AssetAccessError({ + operation: "resolve-workspace", + resource: input.resource, message: "Failed to resolve workspace.", cause, }), @@ -307,6 +349,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i Effect.mapError( (cause) => new AssetAccessError({ + operation: "load-signing-key", + resource: input.resource, message: "Failed to load the asset signing key.", cause, }), diff --git a/apps/server/src/project/ProjectFaviconResolver.test.ts b/apps/server/src/project/ProjectFaviconResolver.test.ts index 37bda11e6aa..f012face906 100644 --- a/apps/server/src/project/ProjectFaviconResolver.test.ts +++ b/apps/server/src/project/ProjectFaviconResolver.test.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; @@ -34,6 +35,12 @@ const writeTextFile = Effect.fn("writeTextFile")(function* ( yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie); }); +const makeResolverWithFileSystem = (fileSystem: FileSystem.FileSystem) => + ProjectFaviconResolver.make.pipe( + Effect.provide(WorkspacePaths.layer), + Effect.provideService(FileSystem.FileSystem, fileSystem), + ); + it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { describe("resolvePath", () => { it.effect("prefers well-known favicon files", () => @@ -73,5 +80,109 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { expect(resolved).toBeNull(); }), ); + + it.effect("preserves workspace normalization context", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + const missingCwd = `${cwd}/missing`; + + const error = yield* resolver.resolvePath(missingCwd).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "ProjectFaviconResolutionError", + operation: "normalize-workspace", + workspaceRoot: missingCwd, + }); + expect(error.cause).toBeInstanceOf(WorkspacePaths.WorkspaceRootNotExistsError); + }), + ); + + it.effect("preserves non-missing candidate stat failures", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const faviconPath = path.join(cwd, "favicon.svg"); + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "stat", + pathOrDescriptor: faviconPath, + }); + const resolver = yield* makeResolverWithFileSystem( + FileSystem.FileSystem.of({ + ...fileSystem, + stat: (filePath) => + filePath === faviconPath ? Effect.fail(cause) : fileSystem.stat(filePath), + }), + ); + + const error = yield* resolver.resolvePath(cwd).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "ProjectFaviconResolutionError", + operation: "stat-candidate", + workspaceRoot: cwd, + relativePath: "favicon.svg", + absolutePath: faviconPath, + }); + expect(error.cause).toBe(cause); + }), + ); + + it.effect("preserves icon source read failures", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const sourcePath = path.join(cwd, "index.html"); + yield* writeTextFile(cwd, "index.html", ''); + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFileString", + pathOrDescriptor: sourcePath, + }); + const resolver = yield* makeResolverWithFileSystem( + FileSystem.FileSystem.of({ + ...fileSystem, + readFileString: (filePath, options) => + filePath === sourcePath + ? Effect.fail(cause) + : fileSystem.readFileString(filePath, options), + }), + ); + + const error = yield* resolver.resolvePath(cwd).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "ProjectFaviconResolutionError", + operation: "read-source", + workspaceRoot: cwd, + relativePath: "index.html", + absolutePath: sourcePath, + }); + expect(error.cause).toBe(cause); + }), + ); + + it.effect("rejects icon metadata paths outside the workspace", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "index.html", ''); + + const error = yield* resolver.resolvePath(cwd).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "ProjectFaviconResolutionError", + operation: "resolve-path", + workspaceRoot: cwd, + relativePath: "../secret.svg", + }); + expect(error.cause).toBeInstanceOf(WorkspacePaths.WorkspacePathOutsideRootError); + }), + ); }); }); diff --git a/apps/server/src/project/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts index 4c685a20f88..46af1680244 100644 --- a/apps/server/src/project/ProjectFaviconResolver.ts +++ b/apps/server/src/project/ProjectFaviconResolver.ts @@ -10,7 +10,10 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; @@ -56,6 +59,26 @@ const LINK_ICON_HTML_RE = const LINK_ICON_OBJ_RE = /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i; +export class ProjectFaviconResolutionError extends Schema.TaggedErrorClass()( + "ProjectFaviconResolutionError", + { + operation: Schema.Literals([ + "normalize-workspace", + "resolve-path", + "stat-candidate", + "read-source", + ]), + workspaceRoot: Schema.String, + relativePath: Schema.optional(Schema.String), + absolutePath: Schema.optional(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to resolve project favicon during ${this.operation} for workspace ${this.workspaceRoot}.`; + } +} + /** Service tag for project favicon resolution. */ export class ProjectFaviconResolver extends Context.Service< ProjectFaviconResolver, @@ -65,7 +88,9 @@ export class ProjectFaviconResolver extends Context.Service< * * Returns `null` when no candidate icon file can be found. */ - readonly resolvePath: (cwd: string) => Effect.Effect; + readonly resolvePath: ( + cwd: string, + ) => Effect.Effect; } >()("t3/project/ProjectFaviconResolver") {} @@ -77,6 +102,17 @@ function extractIconHref(source: string): string | null { return null; } +const optionOnNotFound = ( + effect: Effect.Effect, +): Effect.Effect, PlatformError.PlatformError, R> => + effect.pipe( + Effect.map(Option.some), + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" ? Effect.succeed(Option.none()) : Effect.fail(error), + }), + ); + export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -90,21 +126,37 @@ export const make = Effect.gen(function* () { const findExistingFile = Effect.fn("ProjectFaviconResolver.findExistingFile")(function* ( projectCwd: string, relativeCandidates: ReadonlyArray, - ): Effect.fn.Return { + ): Effect.fn.Return { for (const relativePath of relativeCandidates) { const candidate = yield* workspacePaths .resolveRelativePathWithinRoot({ workspaceRoot: projectCwd, relativePath, }) - .pipe(Effect.orElseSucceed(() => null)); - if (!candidate) { - continue; - } - const stats = yield* fileSystem - .stat(candidate.absolutePath) - .pipe(Effect.orElseSucceed(() => null)); - if (stats?.type === "File") { + .pipe( + Effect.mapError( + (cause) => + new ProjectFaviconResolutionError({ + operation: "resolve-path", + workspaceRoot: projectCwd, + relativePath, + cause, + }), + ), + ); + const stats = yield* optionOnNotFound(fileSystem.stat(candidate.absolutePath)).pipe( + Effect.mapError( + (cause) => + new ProjectFaviconResolutionError({ + operation: "stat-candidate", + workspaceRoot: projectCwd, + relativePath, + absolutePath: candidate.absolutePath, + cause, + }), + ), + ); + if (Option.isSome(stats) && stats.value.type === "File") { return candidate.absolutePath; } } @@ -114,12 +166,16 @@ export const make = Effect.gen(function* () { const resolvePath: ProjectFaviconResolver["Service"]["resolvePath"] = Effect.fn( "ProjectFaviconResolver.resolvePath", )(function* (cwd) { - const projectCwd = yield* workspacePaths - .normalizeWorkspaceRoot(cwd) - .pipe(Effect.orElseSucceed(() => null)); - if (!projectCwd) { - return null; - } + const projectCwd = yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe( + Effect.mapError( + (cause) => + new ProjectFaviconResolutionError({ + operation: "normalize-workspace", + workspaceRoot: cwd, + cause, + }), + ), + ); for (const candidate of FAVICON_CANDIDATES) { const existing = yield* findExistingFile(projectCwd, [candidate]); if (existing) { @@ -133,17 +189,35 @@ export const make = Effect.gen(function* () { workspaceRoot: projectCwd, relativePath: sourceFile, }) - .pipe(Effect.orElseSucceed(() => null)); - if (!sourcePath) { - continue; - } - const source = yield* fileSystem - .readFileString(sourcePath.absolutePath) - .pipe(Effect.orElseSucceed(() => null)); - if (!source) { + .pipe( + Effect.mapError( + (cause) => + new ProjectFaviconResolutionError({ + operation: "resolve-path", + workspaceRoot: projectCwd, + relativePath: sourceFile, + cause, + }), + ), + ); + const source = yield* optionOnNotFound( + fileSystem.readFileString(sourcePath.absolutePath), + ).pipe( + Effect.mapError( + (cause) => + new ProjectFaviconResolutionError({ + operation: "read-source", + workspaceRoot: projectCwd, + relativePath: sourceFile, + absolutePath: sourcePath.absolutePath, + cause, + }), + ), + ); + if (Option.isNone(source)) { continue; } - const href = extractIconHref(source); + const href = extractIconHref(source.value); if (!href) { continue; } diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 05e78de476c..7ebc432038c 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1414,6 +1414,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => Effect.mapError( (cause) => new AssetAccessError({ + operation: "resolve-workspace-context", + resource: input.resource, message: "Failed to resolve workspace context.", cause, }), @@ -1421,6 +1423,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => ); if (Option.isNone(thread)) { return yield* new AssetAccessError({ + operation: "resolve-workspace-context", + resource: input.resource, message: "Workspace context was not found.", }); } @@ -1430,6 +1434,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => Effect.mapError( (cause) => new AssetAccessError({ + operation: "resolve-workspace-context", + resource: input.resource, message: "Failed to resolve workspace context.", cause, }), @@ -1437,6 +1443,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => ); if (Option.isNone(project)) { return yield* new AssetAccessError({ + operation: "resolve-workspace-context", + resource: input.resource, message: "Workspace context was not found.", }); } diff --git a/packages/contracts/src/assets.ts b/packages/contracts/src/assets.ts index bd1ac0a53ec..fdfbe64246e 100644 --- a/packages/contracts/src/assets.ts +++ b/packages/contracts/src/assets.ts @@ -29,9 +29,27 @@ export const AssetCreateUrlResult = Schema.Struct({ }); export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type; +export const AssetAccessOperation = Schema.Literals([ + "resolve-workspace-context", + "normalize-workspace-root", + "validate-workspace-path", + "validate-preview-type", + "inspect-workspace-asset", + "locate-workspace-asset", + "resolve-workspace", + "locate-attachment", + "resolve-project-favicon", + "inspect-project-favicon", + "locate-project-favicon", + "load-signing-key", +]); +export type AssetAccessOperation = typeof AssetAccessOperation.Type; + export class AssetAccessError extends Schema.TaggedErrorClass()( "AssetAccessError", { + operation: AssetAccessOperation, + resource: AssetResource, message: TrimmedNonEmptyString, cause: Schema.optional(Schema.Defect()), }, From 639917fe4bba20523dfd68253c1855ddaf952e8a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 16:08:23 -0700 Subject: [PATCH 3/3] Keep favicon fallback after invalid metadata paths Co-authored-by: codex --- .../project/ProjectFaviconResolver.test.ts | 27 ++++++++++++------- .../src/project/ProjectFaviconResolver.ts | 25 ++++++++--------- 2 files changed, 31 insertions(+), 21 deletions(-) diff --git a/apps/server/src/project/ProjectFaviconResolver.test.ts b/apps/server/src/project/ProjectFaviconResolver.test.ts index f012face906..0b017b22e4e 100644 --- a/apps/server/src/project/ProjectFaviconResolver.test.ts +++ b/apps/server/src/project/ProjectFaviconResolver.test.ts @@ -167,21 +167,30 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { }), ); - it.effect("rejects icon metadata paths outside the workspace", () => + it.effect("skips icon metadata paths outside the workspace", () => Effect.gen(function* () { const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; const cwd = yield* makeTempDir; yield* writeTextFile(cwd, "index.html", ''); - const error = yield* resolver.resolvePath(cwd).pipe(Effect.flip); + const resolved = yield* resolver.resolvePath(cwd); - expect(error).toMatchObject({ - _tag: "ProjectFaviconResolutionError", - operation: "resolve-path", - workspaceRoot: cwd, - relativePath: "../secret.svg", - }); - expect(error.cause).toBeInstanceOf(WorkspacePaths.WorkspacePathOutsideRootError); + expect(resolved).toBeNull(); + }), + ); + + it.effect("continues to later sources after an outside-root icon href", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "index.html", ''); + yield* writeTextFile(cwd, "public/index.html", ''); + yield* writeTextFile(cwd, "public/brand/logo.svg", "brand"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("public/brand/logo.svg"); }), ); }); diff --git a/apps/server/src/project/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts index 46af1680244..e644df06ae6 100644 --- a/apps/server/src/project/ProjectFaviconResolver.ts +++ b/apps/server/src/project/ProjectFaviconResolver.ts @@ -134,30 +134,31 @@ export const make = Effect.gen(function* () { relativePath, }) .pipe( - Effect.mapError( - (cause) => - new ProjectFaviconResolutionError({ - operation: "resolve-path", - workspaceRoot: projectCwd, - relativePath, - cause, - }), - ), + Effect.map(Option.some), + Effect.catchTags({ + WorkspacePathOutsideRootError: () => + Effect.succeed( + Option.none<{ readonly absolutePath: string; readonly relativePath: string }>(), + ), + }), ); - const stats = yield* optionOnNotFound(fileSystem.stat(candidate.absolutePath)).pipe( + if (Option.isNone(candidate)) { + continue; + } + const stats = yield* optionOnNotFound(fileSystem.stat(candidate.value.absolutePath)).pipe( Effect.mapError( (cause) => new ProjectFaviconResolutionError({ operation: "stat-candidate", workspaceRoot: projectCwd, relativePath, - absolutePath: candidate.absolutePath, + absolutePath: candidate.value.absolutePath, cause, }), ), ); if (Option.isSome(stats) && stats.value.type === "File") { - return candidate.absolutePath; + return candidate.value.absolutePath; } } return null;