authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 17:35:07-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 17:35:07-07:00
log0eba548282b96ee420ad0b1a3f5d9d4487376b90
treed831a177719d88b2ce1fd2c161b870ad90f7199c
parent68c0dd3b7095cae054a9db5e48157e89f65c0664
signature Signed by PGP key B5690EEEBB952194

[codex] Model asset access failures with distinct errors (#3448)

Co-authored-by: codex <codex@users.noreply.github.com>

4 files changed, 200 insertions(+), 79 deletions(-)

apps/server/src/assets/AssetAccess.test.ts+3-2
...@@ -88,7 +88,7 @@ describe("AssetAccess", () => {...@@ -88,7 +88,7 @@ describe("AssetAccess", () => {
88 }).pipe(Effect.flip);88 }).pipe(Effect.flip);
89 expect(error.message).toBe("Workspace file path must be relative to the project root.");89 expect(error.message).toBe("Workspace file path must be relative to the project root.");
90 expect(error).toMatchObject({90 expect(error).toMatchObject({
91 operation: "validate-workspace-path",91 _tag: "AssetWorkspacePathValidationError",
92 resource: {92 resource: {
93 _tag: "workspace-file",93 _tag: "workspace-file",
94 threadId: "thread-1",94 threadId: "thread-1",
...@@ -130,7 +130,7 @@ describe("AssetAccess", () => {...@@ -130,7 +130,7 @@ describe("AssetAccess", () => {
130130
131 expect(error.message).toBe("Failed to inspect the workspace asset.");131 expect(error.message).toBe("Failed to inspect the workspace asset.");
132 expect(error).toMatchObject({132 expect(error).toMatchObject({
133 operation: "inspect-workspace-asset",133 _tag: "AssetWorkspaceAssetInspectionError",
134 resource: {134 resource: {
135 _tag: "workspace-file",135 _tag: "workspace-file",
136 threadId: "thread-1",136 threadId: "thread-1",
...@@ -268,6 +268,7 @@ describe("AssetAccess", () => {...@@ -268,6 +268,7 @@ describe("AssetAccess", () => {
268 );268 );
269269
270 expect(error.message).toBe("Failed to resolve project favicon.");270 expect(error.message).toBe("Failed to resolve project favicon.");
271 expect(error._tag).toBe("AssetProjectFaviconResolutionError");
271 expect(error.cause).toBe(resolutionCause);272 expect(error.cause).toBe(resolutionCause);
272 }).pipe(Effect.provide(testLayer)),273 }).pipe(Effect.provide(testLayer)),
273 );274 );
apps/server/src/assets/AssetAccess.ts+28-43
...@@ -1,5 +1,18 @@...@@ -1,5 +1,18 @@
1import type { AssetResource } from "@t3tools/contracts";1import type { AssetResource } from "@t3tools/contracts";
2import { AssetAccessError } from "@t3tools/contracts";2import {
3 AssetAttachmentNotFoundError,
4 AssetPreviewTypeValidationError,
5 AssetProjectFaviconInspectionError,
6 AssetProjectFaviconNotFoundError,
7 AssetProjectFaviconResolutionError,
8 AssetSigningKeyLoadError,
9 AssetWorkspaceAssetInspectionError,
10 AssetWorkspaceAssetNotFoundError,
11 AssetWorkspaceContextNotFoundError,
12 AssetWorkspacePathValidationError,
13 AssetWorkspaceResolutionError,
14 AssetWorkspaceRootNormalizationError,
15} from "@t3tools/contracts";
3import {16import {
4 isWorkspaceImagePreviewPath,17 isWorkspaceImagePreviewPath,
5 isWorkspacePreviewEntryPath,18 isWorkspacePreviewEntryPath,
...@@ -165,19 +178,15 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i...@@ -165,19 +178,15 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
165 switch (input.resource._tag) {178 switch (input.resource._tag) {
166 case "workspace-file": {179 case "workspace-file": {
167 if (!input.workspaceRoot) {180 if (!input.workspaceRoot) {
168 return yield* new AssetAccessError({181 return yield* new AssetWorkspaceContextNotFoundError({
169 operation: "resolve-workspace-context",
170 resource: input.resource,182 resource: input.resource,
171 message: "Workspace context was not found.",
172 });183 });
173 }184 }
174 const workspaceRoot = yield* workspacePaths.normalizeWorkspaceRoot(input.workspaceRoot).pipe(185 const workspaceRoot = yield* workspacePaths.normalizeWorkspaceRoot(input.workspaceRoot).pipe(
175 Effect.mapError(186 Effect.mapError(
176 (cause) =>187 (cause) =>
177 new AssetAccessError({188 new AssetWorkspaceRootNormalizationError({
178 operation: "normalize-workspace-root",
179 resource: input.resource,189 resource: input.resource,
180 message: "Failed to normalize the workspace root.",
181 cause,190 cause,
182 }),191 }),
183 ),192 ),
...@@ -190,19 +199,15 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i...@@ -190,19 +199,15 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
190 .pipe(199 .pipe(
191 Effect.mapError(200 Effect.mapError(
192 (cause) =>201 (cause) =>
193 new AssetAccessError({202 new AssetWorkspacePathValidationError({
194 operation: "validate-workspace-path",
195 resource: input.resource,203 resource: input.resource,
196 message: "Workspace file path must be relative to the project root.",
197 cause,204 cause,
198 }),205 }),
199 ),206 ),
200 );207 );
201 if (!isWorkspacePreviewEntryPath(resolved.relativePath)) {208 if (!isWorkspacePreviewEntryPath(resolved.relativePath)) {
202 return yield* new AssetAccessError({209 return yield* new AssetPreviewTypeValidationError({
203 operation: "validate-preview-type",
204 resource: input.resource,210 resource: input.resource,
205 message: "Only browser documents and images can be previewed.",
206 });211 });
207 }212 }
208 const canonicalFile = yield* resolveCanonicalWorkspaceFile({213 const canonicalFile = yield* resolveCanonicalWorkspaceFile({
...@@ -211,28 +216,22 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i...@@ -211,28 +216,22 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
211 }).pipe(216 }).pipe(
212 Effect.mapError(217 Effect.mapError(
213 (cause) =>218 (cause) =>
214 new AssetAccessError({219 new AssetWorkspaceAssetInspectionError({
215 operation: "inspect-workspace-asset",
216 resource: input.resource,220 resource: input.resource,
217 message: "Failed to inspect the workspace asset.",
218 cause,221 cause,
219 }),222 }),
220 ),223 ),
221 );224 );
222 if (!canonicalFile) {225 if (!canonicalFile) {
223 return yield* new AssetAccessError({226 return yield* new AssetWorkspaceAssetNotFoundError({
224 operation: "locate-workspace-asset",
225 resource: input.resource,227 resource: input.resource,
226 message: "Workspace asset was not found.",
227 });228 });
228 }229 }
229 const canonicalWorkspaceRoot = yield* fileSystem.realPath(workspaceRoot).pipe(230 const canonicalWorkspaceRoot = yield* fileSystem.realPath(workspaceRoot).pipe(
230 Effect.mapError(231 Effect.mapError(
231 (cause) =>232 (cause) =>
232 new AssetAccessError({233 new AssetWorkspaceResolutionError({
233 operation: "resolve-workspace",
234 resource: input.resource,234 resource: input.resource,
235 message: "Failed to resolve workspace.",
236 cause,235 cause,
237 }),236 }),
238 ),237 ),
...@@ -262,10 +261,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i...@@ -262,10 +261,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
262 attachmentId: input.resource.attachmentId,261 attachmentId: input.resource.attachmentId,
263 });262 });
264 if (!attachmentPath) {263 if (!attachmentPath) {
265 return yield* new AssetAccessError({264 return yield* new AssetAttachmentNotFoundError({
266 operation: "locate-attachment",
267 resource: input.resource,265 resource: input.resource,
268 message: "Attachment was not found.",
269 });266 });
270 }267 }
271 claims = {268 claims = {
...@@ -281,10 +278,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i...@@ -281,10 +278,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
281 const workspaceRoot = yield* workspacePaths.normalizeWorkspaceRoot(input.resource.cwd).pipe(278 const workspaceRoot = yield* workspacePaths.normalizeWorkspaceRoot(input.resource.cwd).pipe(
282 Effect.mapError(279 Effect.mapError(
283 (cause) =>280 (cause) =>
284 new AssetAccessError({281 new AssetWorkspaceRootNormalizationError({
285 operation: "normalize-workspace-root",
286 resource: input.resource,282 resource: input.resource,
287 message: "Failed to normalize the workspace root.",
288 cause,283 cause,
289 }),284 }),
290 ),285 ),
...@@ -293,10 +288,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i...@@ -293,10 +288,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
293 const faviconPath = yield* faviconResolver.resolvePath(workspaceRoot).pipe(288 const faviconPath = yield* faviconResolver.resolvePath(workspaceRoot).pipe(
294 Effect.mapError(289 Effect.mapError(
295 (cause) =>290 (cause) =>
296 new AssetAccessError({291 new AssetProjectFaviconResolutionError({
297 operation: "resolve-project-favicon",
298 resource: input.resource,292 resource: input.resource,
299 message: "Failed to resolve project favicon.",
300 cause,293 cause,
301 }),294 }),
302 ),295 ),
...@@ -307,19 +300,15 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i...@@ -307,19 +300,15 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
307 !(yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe(300 !(yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe(
308 Effect.mapError(301 Effect.mapError(
309 (cause) =>302 (cause) =>
310 new AssetAccessError({303 new AssetProjectFaviconInspectionError({
311 operation: "inspect-project-favicon",
312 resource: input.resource,304 resource: input.resource,
313 message: "Failed to inspect the project favicon.",
314 cause,305 cause,
315 }),306 }),
316 ),307 ),
317 ))308 ))
318 ) {309 ) {
319 return yield* new AssetAccessError({310 return yield* new AssetProjectFaviconNotFoundError({
320 operation: "locate-project-favicon",
321 resource: input.resource,311 resource: input.resource,
322 message: "Project favicon was not found.",
323 });312 });
324 }313 }
325 claims = {314 claims = {
...@@ -328,10 +317,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i...@@ -328,10 +317,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
328 workspaceRoot: yield* fileSystem.realPath(workspaceRoot).pipe(317 workspaceRoot: yield* fileSystem.realPath(workspaceRoot).pipe(
329 Effect.mapError(318 Effect.mapError(
330 (cause) =>319 (cause) =>
331 new AssetAccessError({320 new AssetWorkspaceResolutionError({
332 operation: "resolve-workspace",
333 resource: input.resource,321 resource: input.resource,
334 message: "Failed to resolve workspace.",
335 cause,322 cause,
336 }),323 }),
337 ),324 ),
...@@ -348,10 +335,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i...@@ -348,10 +335,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
348 const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32).pipe(335 const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32).pipe(
349 Effect.mapError(336 Effect.mapError(
350 (cause) =>337 (cause) =>
351 new AssetAccessError({338 new AssetSigningKeyLoadError({
352 operation: "load-signing-key",
353 resource: input.resource,339 resource: input.resource,
354 message: "Failed to load the asset signing key.",
355 cause,340 cause,
356 }),341 }),
357 ),342 ),
apps/server/src/ws.ts+6-13
...@@ -46,7 +46,8 @@ import {...@@ -46,7 +46,8 @@ import {
46 OrchestrationReplayEventsError,46 OrchestrationReplayEventsError,
47 type FilesystemBrowseFailure,47 type FilesystemBrowseFailure,
48 FilesystemBrowseError,48 FilesystemBrowseError,
49 AssetAccessError,49 AssetWorkspaceContextNotFoundError,
50 AssetWorkspaceContextResolutionError,
50 EnvironmentAuthorizationError,51 EnvironmentAuthorizationError,
51 ThreadId,52 ThreadId,
52 type TerminalAttachStreamEvent,53 type TerminalAttachStreamEvent,
...@@ -1413,19 +1414,15 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>...@@ -1413,19 +1414,15 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
1413 .pipe(1414 .pipe(
1414 Effect.mapError(1415 Effect.mapError(
1415 (cause) =>1416 (cause) =>
1416 new AssetAccessError({1417 new AssetWorkspaceContextResolutionError({
1417 operation: "resolve-workspace-context",
1418 resource: input.resource,1418 resource: input.resource,
1419 message: "Failed to resolve workspace context.",
1420 cause,1419 cause,
1421 }),1420 }),
1422 ),1421 ),
1423 );1422 );
1424 if (Option.isNone(thread)) {1423 if (Option.isNone(thread)) {
1425 return yield* new AssetAccessError({1424 return yield* new AssetWorkspaceContextNotFoundError({
1426 operation: "resolve-workspace-context",
1427 resource: input.resource,1425 resource: input.resource,
1428 message: "Workspace context was not found.",
1429 });1426 });
1430 }1427 }
1431 const project = yield* projectionSnapshotQuery1428 const project = yield* projectionSnapshotQuery
...@@ -1433,19 +1430,15 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>...@@ -1433,19 +1430,15 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
1433 .pipe(1430 .pipe(
1434 Effect.mapError(1431 Effect.mapError(
1435 (cause) =>1432 (cause) =>
1436 new AssetAccessError({1433 new AssetWorkspaceContextResolutionError({
1437 operation: "resolve-workspace-context",
1438 resource: input.resource,1434 resource: input.resource,
1439 message: "Failed to resolve workspace context.",
1440 cause,1435 cause,
1441 }),1436 }),
1442 ),1437 ),
1443 );1438 );
1444 if (Option.isNone(project)) {1439 if (Option.isNone(project)) {
1445 return yield* new AssetAccessError({1440 return yield* new AssetWorkspaceContextNotFoundError({
1446 operation: "resolve-workspace-context",
1447 resource: input.resource,1441 resource: input.resource,
1448 message: "Workspace context was not found.",
1449 });1442 });
1450 }1443 }
1451 return yield* issueAssetUrl({1444 return yield* issueAssetUrl({
packages/contracts/src/assets.ts+163-21
...@@ -29,28 +29,170 @@ export const AssetCreateUrlResult = Schema.Struct({...@@ -29,28 +29,170 @@ export const AssetCreateUrlResult = Schema.Struct({
29});29});
30export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type;30export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type;
3131
32export const AssetAccessOperation = Schema.Literals([32export class AssetWorkspaceContextNotFoundError extends Schema.TaggedErrorClass<AssetWorkspaceContextNotFoundError>()(
33 "resolve-workspace-context",33 "AssetWorkspaceContextNotFoundError",
34 "normalize-workspace-root",34 {
35 "validate-workspace-path",35 resource: AssetResource,
36 "validate-preview-type",36 },
37 "inspect-workspace-asset",37) {
38 "locate-workspace-asset",38 override get message(): string {
39 "resolve-workspace",39 return "Workspace context was not found.";
40 "locate-attachment",40 }
41 "resolve-project-favicon",41}
42 "inspect-project-favicon",42
43 "locate-project-favicon",43export class AssetWorkspaceContextResolutionError extends Schema.TaggedErrorClass<AssetWorkspaceContextResolutionError>()(
44 "load-signing-key",44 "AssetWorkspaceContextResolutionError",
45]);45 {
46export type AssetAccessOperation = typeof AssetAccessOperation.Type;46 resource: AssetResource,
47 cause: Schema.Defect(),
48 },
49) {
50 override get message(): string {
51 return "Failed to resolve workspace context.";
52 }
53}
54
55export class AssetWorkspaceRootNormalizationError extends Schema.TaggedErrorClass<AssetWorkspaceRootNormalizationError>()(
56 "AssetWorkspaceRootNormalizationError",
57 {
58 resource: AssetResource,
59 cause: Schema.Defect(),
60 },
61) {
62 override get message(): string {
63 return "Failed to normalize the workspace root.";
64 }
65}
66
67export class AssetWorkspacePathValidationError extends Schema.TaggedErrorClass<AssetWorkspacePathValidationError>()(
68 "AssetWorkspacePathValidationError",
69 {
70 resource: AssetResource,
71 cause: Schema.Defect(),
72 },
73) {
74 override get message(): string {
75 return "Workspace file path must be relative to the project root.";
76 }
77}
78
79export class AssetPreviewTypeValidationError extends Schema.TaggedErrorClass<AssetPreviewTypeValidationError>()(
80 "AssetPreviewTypeValidationError",
81 {
82 resource: AssetResource,
83 },
84) {
85 override get message(): string {
86 return "Only browser documents and images can be previewed.";
87 }
88}
89
90export class AssetWorkspaceAssetInspectionError extends Schema.TaggedErrorClass<AssetWorkspaceAssetInspectionError>()(
91 "AssetWorkspaceAssetInspectionError",
92 {
93 resource: AssetResource,
94 cause: Schema.Defect(),
95 },
96) {
97 override get message(): string {
98 return "Failed to inspect the workspace asset.";
99 }
100}
101
102export class AssetWorkspaceAssetNotFoundError extends Schema.TaggedErrorClass<AssetWorkspaceAssetNotFoundError>()(
103 "AssetWorkspaceAssetNotFoundError",
104 {
105 resource: AssetResource,
106 },
107) {
108 override get message(): string {
109 return "Workspace asset was not found.";
110 }
111}
112
113export class AssetWorkspaceResolutionError extends Schema.TaggedErrorClass<AssetWorkspaceResolutionError>()(
114 "AssetWorkspaceResolutionError",
115 {
116 resource: AssetResource,
117 cause: Schema.Defect(),
118 },
119) {
120 override get message(): string {
121 return "Failed to resolve workspace.";
122 }
123}
47124
48export class AssetAccessError extends Schema.TaggedErrorClass<AssetAccessError>()(125export class AssetAttachmentNotFoundError extends Schema.TaggedErrorClass<AssetAttachmentNotFoundError>()(
49 "AssetAccessError",126 "AssetAttachmentNotFoundError",
50 {127 {
51 operation: AssetAccessOperation,
52 resource: AssetResource,128 resource: AssetResource,
53 message: TrimmedNonEmptyString,
54 cause: Schema.optional(Schema.Defect()),
55 },129 },
56) {}130) {
131 override get message(): string {
132 return "Attachment was not found.";
133 }
134}
135
136export class AssetProjectFaviconResolutionError extends Schema.TaggedErrorClass<AssetProjectFaviconResolutionError>()(
137 "AssetProjectFaviconResolutionError",
138 {
139 resource: AssetResource,
140 cause: Schema.Defect(),
141 },
142) {
143 override get message(): string {
144 return "Failed to resolve project favicon.";
145 }
146}
147
148export class AssetProjectFaviconInspectionError extends Schema.TaggedErrorClass<AssetProjectFaviconInspectionError>()(
149 "AssetProjectFaviconInspectionError",
150 {
151 resource: AssetResource,
152 cause: Schema.Defect(),
153 },
154) {
155 override get message(): string {
156 return "Failed to inspect the project favicon.";
157 }
158}
159
160export class AssetProjectFaviconNotFoundError extends Schema.TaggedErrorClass<AssetProjectFaviconNotFoundError>()(
161 "AssetProjectFaviconNotFoundError",
162 {
163 resource: AssetResource,
164 },
165) {
166 override get message(): string {
167 return "Project favicon was not found.";
168 }
169}
170
171export class AssetSigningKeyLoadError extends Schema.TaggedErrorClass<AssetSigningKeyLoadError>()(
172 "AssetSigningKeyLoadError",
173 {
174 resource: AssetResource,
175 cause: Schema.Defect(),
176 },
177) {
178 override get message(): string {
179 return "Failed to load the asset signing key.";
180 }
181}
182
183export const AssetAccessError = Schema.Union([
184 AssetWorkspaceContextNotFoundError,
185 AssetWorkspaceContextResolutionError,
186 AssetWorkspaceRootNormalizationError,
187 AssetWorkspacePathValidationError,
188 AssetPreviewTypeValidationError,
189 AssetWorkspaceAssetInspectionError,
190 AssetWorkspaceAssetNotFoundError,
191 AssetWorkspaceResolutionError,
192 AssetAttachmentNotFoundError,
193 AssetProjectFaviconResolutionError,
194 AssetProjectFaviconInspectionError,
195 AssetProjectFaviconNotFoundError,
196 AssetSigningKeyLoadError,
197]);
198export type AssetAccessError = typeof AssetAccessError.Type;