From ff85facae25189d40cf80a5a48653de6397e2dcc Mon Sep 17 00:00:00 2001 From: flamboh Date: Fri, 4 Sep 2026 02:17:29 -0700 Subject: [PATCH 1/8] fix(web): match GitHub image colors in pull requests --- apps/server/src/assets/AssetAccess.test.ts | 24 ++- apps/server/src/assets/AssetAccess.ts | 44 ++++- .../src/assets/GitHubUserAttachment.test.ts | 142 ++++++++++++++ .../server/src/assets/GitHubUserAttachment.ts | 184 ++++++++++++++++++ apps/server/src/http.ts | 25 +++ apps/server/src/ws.ts | 3 +- apps/web/src/components/ChatMarkdown.tsx | 37 +++- .../pullRequest/PullRequestMarkdown.tsx | 14 +- packages/contracts/src/assets.test.ts | 22 ++- packages/contracts/src/assets.ts | 11 ++ 10 files changed, 486 insertions(+), 20 deletions(-) create mode 100644 apps/server/src/assets/GitHubUserAttachment.test.ts create mode 100644 apps/server/src/assets/GitHubUserAttachment.ts diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index b83b8684432c..ed55108ac098 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -47,6 +47,22 @@ const testLayer = Layer.mergeAll( ).pipe(Layer.provideMerge(NodeServices.layer)); describe("AssetAccess", () => { + it.effect("issues signed URLs for canonical GitHub user attachments", () => + Effect.gen(function* () { + const url = "https://github.com/user-attachments/assets/f1d65268-4213-47a5-864d-5067e8bf5918"; + const result = yield* issueAssetUrl({ + resource: { _tag: "github-user-attachment", url }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + + expect(yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1))).toEqual({ + kind: "github-user-attachment", + url, + }); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("issues exact URLs for media and browser documents outside the workspace", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -228,7 +244,7 @@ describe("AssetAccess", () => { suffix.slice(0, separator), suffix.slice(separator + 1), ); - if (!asset) throw new Error("Expected a resolved media file"); + if (!asset || asset.kind !== "file") throw new Error("Expected a resolved media file"); yield* fs.rename(filePath, savedPath); yield* fs.symlink(secretPath, filePath); @@ -391,7 +407,7 @@ describe("AssetAccess", () => { const name = suffix.slice(separator + 1); yield* fs.writeFileString(filePath, "in-place edit"); const edited = yield* resolveAsset(token, name); - if (!edited) throw new Error("Expected the edited media file"); + if (!edited || edited.kind !== "file") throw new Error("Expected the edited media file"); const editedResponse = HttpServerResponse.toWeb(yield* assetFileResponse(edited)); expect(yield* Effect.promise(() => editedResponse.text())).toBe("in-place edit"); @@ -407,7 +423,9 @@ describe("AssetAccess", () => { renewedSuffix.slice(0, renewedSeparator), renewedSuffix.slice(renewedSeparator + 1), ); - if (!renewedAsset) throw new Error("Expected the replacement media file"); + if (!renewedAsset || renewedAsset.kind !== "file") { + throw new Error("Expected the replacement media file"); + } const renewedResponse = HttpServerResponse.toWeb(yield* assetFileResponse(renewedAsset)); expect(yield* Effect.promise(() => renewedResponse.text())).toBe("replacement"); yield* fs.remove(filePath); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 956c4ac44211..b79a231effe7 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -1,5 +1,5 @@ -import type { AssetResource } from "@t3tools/contracts"; import { + type AssetResource, AssetAttachmentNotFoundError, AssetPreviewTypeValidationError, AssetProjectFaviconInspectionError, @@ -12,6 +12,7 @@ import { AssetWorkspacePathValidationError, AssetWorkspaceResolutionError, AssetWorkspaceRootNormalizationError, + GitHubUserAttachmentUrl, ToolActivityNativeAppReference, } from "@t3tools/contracts"; import { @@ -133,6 +134,12 @@ const AssetClaimsSchema = Schema.Union([ app: ToolActivityNativeAppReference, expiresAt: Schema.Number, }), + Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("github-user-attachment"), + url: GitHubUserAttachmentUrl, + expiresAt: Schema.Number, + }), ]); type AssetClaims = typeof AssetClaimsSchema.Type; @@ -140,14 +147,19 @@ const AssetClaimsJson = Schema.fromJsonString(AssetClaimsSchema); const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson); const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson); -export type ResolvedAsset = { - readonly kind: "file"; - readonly path: string; - readonly download?: boolean; - readonly fileName?: string; - readonly mimeType?: string; - readonly file?: OpenMediaFile; -}; +export type ResolvedAsset = + | { + readonly kind: "file"; + readonly path: string; + readonly download?: boolean; + readonly fileName?: string; + readonly mimeType?: string; + readonly file?: OpenMediaFile; + } + | { + readonly kind: "github-user-attachment"; + readonly url: string; + }; function decodeClaims(encodedPayload: string): AssetClaims | null { try { @@ -577,6 +589,16 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i fileName = "native-app-icon.png"; break; } + case "github-user-attachment": { + claims = { + version: 1, + kind: "github-user-attachment", + url: input.resource.url, + expiresAt, + }; + fileName = "github-user-attachment"; + break; + } } const secretStore = yield* ServerSecretStore.ServerSecretStore; @@ -683,6 +705,10 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( return iconPath ? ({ kind: "file", path: iconPath } satisfies ResolvedAsset) : null; } + if (claims.kind === "github-user-attachment") { + return { kind: "github-user-attachment", url: claims.url } satisfies ResolvedAsset; + } + const decodedPath = decodeRelativePath(relativePath); if (decodedPath === null) return null; const path = yield* Path.Path; diff --git a/apps/server/src/assets/GitHubUserAttachment.test.ts b/apps/server/src/assets/GitHubUserAttachment.test.ts new file mode 100644 index 000000000000..9e88fbb44e2f --- /dev/null +++ b/apps/server/src/assets/GitHubUserAttachment.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { FetchHttpClient } from "effect/unstable/http"; +import { vi } from "vite-plus/test"; + +import { loadGitHubUserAttachment, stripBt709ColorMetadata } from "./GitHubUserAttachment.ts"; + +const PNG_SIGNATURE = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]); +const EMPTY_IHDR = Array.from({ length: 13 }, () => 0); +const EMPTY_CHRM = Array.from({ length: 32 }, () => 0); + +function concatBytes(parts: ReadonlyArray): Uint8Array { + const bytes = new Uint8Array(parts.reduce((length, part) => length + part.length, 0)); + let offset = 0; + for (const part of parts) { + bytes.set(part, offset); + offset += part.length; + } + return bytes; +} + +function pngChunk(type: string, data: ReadonlyArray): Uint8Array { + const chunk = new Uint8Array(12 + data.length); + new DataView(chunk.buffer).setUint32(0, data.length, false); + for (let index = 0; index < type.length; index += 1) { + chunk[4 + index] = type.charCodeAt(index); + } + chunk.set(data, 8); + return chunk; +} + +function pngWithCicp(cicp: ReadonlyArray): Uint8Array { + return concatBytes([ + PNG_SIGNATURE, + pngChunk("IHDR", EMPTY_IHDR), + pngChunk("cICP", cicp), + pngChunk("cHRM", EMPTY_CHRM), + pngChunk("gAMA", [0, 0, 177, 143]), + pngChunk("IDAT", [4, 5, 6]), + pngChunk("IEND", []), + ]); +} + +function chunkTypes(bytes: Uint8Array): string[] { + const types: string[] = []; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let offset = PNG_SIGNATURE.length; + while (offset + 12 <= bytes.length) { + const length = view.getUint32(offset, false); + types.push(String.fromCharCode(...bytes.subarray(offset + 4, offset + 8))); + offset += 12 + length; + } + return types; +} + +describe("GitHub user attachments", () => { + it("drops conflicting BT.709 color metadata without changing image data", () => { + const source = pngWithCicp([1, 1, 0, 1]); + const normalized = stripBt709ColorMetadata(source); + + expect(chunkTypes(normalized)).toEqual(["IHDR", "IDAT", "IEND"]); + expect(normalized).toEqual( + concatBytes([ + PNG_SIGNATURE, + pngChunk("IHDR", EMPTY_IHDR), + pngChunk("IDAT", [4, 5, 6]), + pngChunk("IEND", []), + ]), + ); + }); + + it("leaves other cICP profiles and non-PNG data untouched", () => { + const displayP3 = pngWithCicp([12, 13, 0, 1]); + const jpeg = Uint8Array.from([255, 216, 255, 224]); + + expect(stripBt709ColorMetadata(displayP3)).toBe(displayP3); + expect(stripBt709ColorMetadata(jpeg)).toBe(jpeg); + }); + + it.effect("follows only GitHub's attachment host and normalizes PNG responses", () => + Effect.gen(function* () { + const sourceUrl = + "https://github.com/user-attachments/assets/f1d65268-4213-47a5-864d-5067e8bf5918"; + const redirectedUrl = + "https://github-production-user-asset-6210df.s3.amazonaws.com/asset?signature=redacted"; + const source = pngWithCicp([1, 1, 0, 1]); + const fetchMock = Object.assign( + vi.fn(async (input: string | URL | Request, _init?: RequestInit) => { + const url = String(input); + if (url === sourceUrl) { + return new Response(null, { status: 302, headers: { location: redirectedUrl } }); + } + return new Response(source, { + status: 200, + headers: { + "content-length": String(source.length), + "content-type": "image/png", + }, + }); + }), + { preconnect: vi.fn() }, + ); + + const result = yield* loadGitHubUserAttachment(sourceUrl).pipe( + Effect.provide(FetchHttpClient.layer), + Effect.provideService(FetchHttpClient.Fetch, fetchMock), + ); + + expect(chunkTypes(result.bytes)).not.toContain("cICP"); + expect(fetchMock.mock.calls.map(([input]) => String(input))).toEqual([ + sourceUrl, + redirectedUrl, + ]); + expect(fetchMock.mock.calls.every(([, init]) => init?.redirect === "manual")).toBe(true); + }), + ); + + it.effect("rejects redirects outside GitHub's attachment bucket", () => + Effect.gen(function* () { + const fetchMock = Object.assign( + vi.fn( + async () => + new Response(null, { + status: 302, + headers: { location: "http://127.0.0.1/private" }, + }), + ), + { preconnect: vi.fn() }, + ); + const error = yield* loadGitHubUserAttachment( + "https://github.com/user-attachments/assets/f1d65268-4213-47a5-864d-5067e8bf5918", + ).pipe( + Effect.provide(FetchHttpClient.layer), + Effect.provideService(FetchHttpClient.Fetch, fetchMock), + Effect.flip, + ); + + expect(error.reason).toBe("redirect"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }), + ); +}); diff --git a/apps/server/src/assets/GitHubUserAttachment.ts b/apps/server/src/assets/GitHubUserAttachment.ts new file mode 100644 index 000000000000..444259558e6b --- /dev/null +++ b/apps/server/src/assets/GitHubUserAttachment.ts @@ -0,0 +1,184 @@ +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Stream from "effect/Stream"; +import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http"; + +const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10] as const; +const BT709_FULL_RANGE_CICP = [1, 1, 0, 1] as const; +const PNG_COLOR_PROFILE_CHUNKS = new Set(["cICP", "cHRM", "gAMA", "iCCP", "sRGB"]); +const MAX_GITHUB_USER_ATTACHMENT_BYTES = 25 * 1024 * 1024; +const GITHUB_ASSET_REDIRECT_HOST_PATTERN = + /^github-production-user-asset-[a-z0-9]+\.s3\.amazonaws\.com$/i; +const SUPPORTED_IMAGE_CONTENT_TYPES = new Set([ + "image/avif", + "image/gif", + "image/jpeg", + "image/png", + "image/webp", +]); + +export class GitHubUserAttachmentFetchError extends Data.TaggedError( + "GitHubUserAttachmentFetchError", +)<{ + readonly reason: "content-type" | "redirect" | "response-too-large" | "status" | "transport"; + readonly status?: number; +}> { + override get message(): string { + return "The GitHub user attachment could not be loaded."; + } +} + +function bytesEqualAt(bytes: Uint8Array, offset: number, expected: ReadonlyArray): boolean { + return expected.every((byte, index) => bytes[offset + index] === byte); +} + +/** + * Chromium's newer PNG decoder color-manages cICP and legacy gAMA/cHRM metadata that GitHub's + * rendering effectively ignores. macOS screenshots can contain both descriptions and render + * much darker in Chromium as a result. When that exact BT.709 declaration is present, remove + * the color-description chunks so the original compressed pixels are interpreted as sRGB. + */ +export function stripBt709ColorMetadata(bytes: Uint8Array): Uint8Array { + if (bytes.length < PNG_SIGNATURE.length || !bytesEqualAt(bytes, 0, PNG_SIGNATURE)) return bytes; + + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const chunks: Array<{ + readonly start: number; + readonly end: number; + readonly type: string; + readonly dataOffset: number; + readonly dataLength: number; + }> = []; + let offset: number = PNG_SIGNATURE.length; + while (offset + 12 <= bytes.length) { + const dataLength = view.getUint32(offset, false); + const dataOffset = offset + 8; + const chunkEnd = dataOffset + dataLength + 4; + if (chunkEnd > bytes.length) return bytes; + + chunks.push({ + start: offset, + end: chunkEnd, + type: String.fromCharCode(...bytes.subarray(offset + 4, offset + 8)), + dataOffset, + dataLength, + }); + offset = chunkEnd; + } + + if (offset !== bytes.length) return bytes; + const hasBt709Cicp = chunks.some( + (chunk) => + chunk.type === "cICP" && + chunk.dataLength === BT709_FULL_RANGE_CICP.length && + bytesEqualAt(bytes, chunk.dataOffset, BT709_FULL_RANGE_CICP), + ); + if (!hasBt709Cicp) return bytes; + + const removedChunks = chunks.filter((chunk) => PNG_COLOR_PROFILE_CHUNKS.has(chunk.type)); + const removedByteCount = removedChunks.reduce((sum, chunk) => sum + chunk.end - chunk.start, 0); + const normalized = new Uint8Array(bytes.length - removedByteCount); + let sourceOffset = 0; + let destinationOffset = 0; + for (const chunk of removedChunks) { + normalized.set(bytes.subarray(sourceOffset, chunk.start), destinationOffset); + destinationOffset += chunk.start - sourceOffset; + sourceOffset = chunk.end; + } + normalized.set(bytes.subarray(sourceOffset), destinationOffset); + return normalized; +} + +function trustedRedirectUrl(location: string, sourceUrl: string): string | null { + try { + const url = new URL(location, sourceUrl); + return url.protocol === "https:" && + url.username === "" && + url.password === "" && + url.port === "" && + GITHUB_ASSET_REDIRECT_HOST_PATTERN.test(url.hostname) + ? url.toString() + : null; + } catch { + return null; + } +} + +const executeWithoutRedirects = ( + effect: Effect.Effect, +) => effect.pipe(Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" })); + +const readLimitedBody = Effect.fn("GitHubUserAttachment.readLimitedBody")(function* ( + response: HttpClientResponse.HttpClientResponse, +) { + const declaredLength = Number(response.headers["content-length"]); + if (Number.isFinite(declaredLength) && declaredLength > MAX_GITHUB_USER_ATTACHMENT_BYTES) { + return yield* new GitHubUserAttachmentFetchError({ reason: "response-too-large" }); + } + + const chunks: Uint8Array[] = []; + let byteLength = 0; + yield* response.stream.pipe( + Stream.runForEach((chunk) => { + byteLength += chunk.length; + if (byteLength > MAX_GITHUB_USER_ATTACHMENT_BYTES) { + return Effect.fail(new GitHubUserAttachmentFetchError({ reason: "response-too-large" })); + } + chunks.push(chunk); + return Effect.void; + }), + Effect.mapError((error) => + error instanceof GitHubUserAttachmentFetchError + ? error + : new GitHubUserAttachmentFetchError({ reason: "transport" }), + ), + ); + + const bytes = new Uint8Array(byteLength); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.length; + } + return bytes; +}); + +export const loadGitHubUserAttachment = Effect.fn("GitHubUserAttachment.loadGitHubUserAttachment")( + function* (sourceUrl: string) { + const httpClient = yield* HttpClient.HttpClient; + const initialResponse = yield* executeWithoutRedirects(httpClient.get(sourceUrl)).pipe( + Effect.mapError(() => new GitHubUserAttachmentFetchError({ reason: "transport" })), + ); + const response = + initialResponse.status >= 300 && initialResponse.status < 400 + ? yield* Effect.gen(function* () { + const redirectUrl = initialResponse.headers.location + ? trustedRedirectUrl(initialResponse.headers.location, sourceUrl) + : null; + if (redirectUrl === null) { + return yield* new GitHubUserAttachmentFetchError({ reason: "redirect" }); + } + return yield* executeWithoutRedirects(httpClient.get(redirectUrl)).pipe( + Effect.mapError(() => new GitHubUserAttachmentFetchError({ reason: "transport" })), + ); + }) + : initialResponse; + + if (response.status < 200 || response.status >= 300) { + return yield* new GitHubUserAttachmentFetchError({ + reason: "status", + status: response.status, + }); + } + const contentType = response.headers["content-type"]?.split(";", 1)[0]?.trim().toLowerCase(); + if (contentType === undefined || !SUPPORTED_IMAGE_CONTENT_TYPES.has(contentType)) { + return yield* new GitHubUserAttachmentFetchError({ reason: "content-type" }); + } + + const bytes = yield* readLimitedBody(response); + return { + bytes: contentType === "image/png" ? stripBt709ColorMetadata(bytes) : bytes, + contentType, + }; + }, +); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 290b73b48514..78488e9de9bf 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -31,6 +31,7 @@ import { OtlpTracer } from "effect/unstable/observability"; import * as ServerConfig from "./config.ts"; import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts"; import { statMediaFile, streamMediaFile, type OpenMediaFile } from "./assets/MediaFile.ts"; +import { loadGitHubUserAttachment } from "./assets/GitHubUserAttachment.ts"; import { ATTACHMENT_UPLOAD_ROUTE_PREFIX, storeAttachmentUpload, @@ -388,6 +389,30 @@ export const assetRouteLayer = HttpRouter.add( if (!asset) { return HttpServerResponse.text("Not Found", { status: 404 }); } + if (asset.kind === "github-user-attachment") { + return yield* loadGitHubUserAttachment(asset.url).pipe( + Effect.map(({ bytes, contentType }) => + HttpServerResponse.uint8Array(bytes, { + status: 200, + contentType, + headers: { + "Cache-Control": "private, max-age=3600", + "X-Content-Type-Options": "nosniff", + }, + }), + ), + Effect.tapError((error) => + Effect.logWarning("Failed to load GitHub user attachment", { + sourceUrl: asset.url, + reason: error.reason, + ...(error.status === undefined ? {} : { status: error.status }), + }), + ), + Effect.orElseSucceed(() => + HttpServerResponse.text("GitHub user attachment unavailable", { status: 502 }), + ), + ); + } return yield* assetFileResponse( asset, request.method === "GET" ? request.headers.range : undefined, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 5e4ca0a18db9..e1c244bcb7ce 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2458,7 +2458,8 @@ const makeWsRpcLayer = ( if ( input.resource._tag === "attachment" || input.resource._tag === "native-app-icon" || - (input.resource._tag === "media-file" && path.isAbsolute(input.resource.path)) + (input.resource._tag === "media-file" && path.isAbsolute(input.resource.path)) || + input.resource._tag === "github-user-attachment" ) { return yield* issueAssetUrl({ resource: input.resource }); } diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 192abaf385f7..2654f9d6a88e 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -205,6 +205,9 @@ interface ChatMarkdownProps { imageBaseDir?: string | undefined; onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; extraRemarkPlugins?: NonNullable; + resolveDirectImageAsset?: ( + url: string, + ) => Extract | null; } export function canUseMarkdownFileShellActions( @@ -1525,7 +1528,9 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props readonly environmentId: EnvironmentId; readonly resource: Extract< AssetResource, - { readonly _tag: "attachment" | "workspace-file" | "media-file" } + { + readonly _tag: "attachment" | "workspace-file" | "media-file" | "github-user-attachment"; + } >; readonly kind?: "image" | "video"; readonly alt: string; @@ -1548,8 +1553,13 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props : resource._tag === "workspace-file" && props.workspaceRoot ? `${props.workspaceRoot.replace(/[\\/]+$/, "")}/${resource.path}` : undefined; - const reference = path ? mediaFileReference(path, props.workspaceRoot) : undefined; - const relativePath = reference?.relativePath; + const reference = + resource._tag === "github-user-attachment" + ? mediaUrlReference(resource.url) + : path + ? mediaFileReference(path, props.workspaceRoot) + : undefined; + const relativePath = reference?.kind === "file" ? reference.relativePath : undefined; const src = assetUrl._tag === "Success" ? assetUrl.url + (props.srcFragment ?? "") : null; // The server reads the pixel size from the file header, so the slot can be // the image's final box instead of a 16:9 guess. An authored size wins; a @@ -1569,7 +1579,7 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props src, asset: { environmentId: props.environmentId, resource }, ...(reference ? { reference } : {}), - ...(relativePath && resource._tag !== "attachment" + ...(relativePath && (resource._tag === "workspace-file" || resource._tag === "media-file") ? { onOpenFile: () => useRightPanelStore @@ -2162,6 +2172,7 @@ function useChatMarkdownState({ onUseArtifactTemplate, imageBaseDir, onImageExpand, + resolveDirectImageAsset, }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); const [localMediaPreview, setLocalMediaPreview] = useState(null); @@ -2564,6 +2575,7 @@ function useChatMarkdownState({ openMarkdownMedia, projects, linkedThreadPullRequestFor, + resolveDirectImageAsset, resolveThreadPullRequest, resolvedTheme, serverConfig, @@ -2591,6 +2603,7 @@ function useChatMarkdownState({ openMarkdownMedia, projects, linkedThreadPullRequestFor, + resolveDirectImageAsset, resolveThreadPullRequest, resolvedTheme, serverConfig, @@ -2970,7 +2983,8 @@ const CHAT_MARKDOWN_COMPONENTS = { ); }, img: function MarkdownImage({ node, title, src, alt, ...props }) { - const { expandMedia, cwd, imageBaseDir, threadRef } = use(ChatMarkdownRendererContext); + const { environmentId, expandMedia, cwd, imageBaseDir, resolveDirectImageAsset, threadRef } = + use(ChatMarkdownRendererContext); const imageExpand = use(MarkdownLinkContext) ? undefined : expandMedia; const localSrc = node?.properties?.dataLocalSrc; const markdownTitle = node?.properties?.dataMarkdownTitle; @@ -2998,6 +3012,19 @@ const CHAT_MARKDOWN_COMPONENTS = { src: mediaSrc, ...(reference ? { reference } : {}), }; + const directImageAsset = kind === "image" ? resolveDirectImageAsset?.(mediaSrc) : undefined; + if (directImageAsset && environmentId) { + return ( + + ); + } if (kind === "video") { return ( (null); +function resolveGitHubUserAttachmentAsset( + url: string, +): Extract | null { + return isGitHubUserAttachmentUrl(url) ? { _tag: "github-user-attachment", url } : null; +} + /** Renders PR uploads inline, with retry and an original link when video playback fails. */ export function PullRequestMarkdown({ text, @@ -51,6 +62,7 @@ export function PullRequestMarkdown({ pullRequestPanelRef={resolvedThreadRef ?? PULL_REQUESTS_PANEL_REF} environmentId={environmentId} extraRemarkPlugins={extraRemarkPlugins} + resolveDirectImageAsset={resolveGitHubUserAttachmentAsset} /> ); } diff --git a/packages/contracts/src/assets.test.ts b/packages/contracts/src/assets.test.ts index c47c53b2a84e..25669bfca7a8 100644 --- a/packages/contracts/src/assets.test.ts +++ b/packages/contracts/src/assets.test.ts @@ -1,7 +1,7 @@ import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; -import { AttachmentCreateUploadUrlInput } from "./assets.ts"; +import { AttachmentCreateUploadUrlInput, isGitHubUserAttachmentUrl } from "./assets.ts"; import { PROVIDER_SEND_TURN_MAX_FILE_BYTES, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, @@ -58,3 +58,23 @@ describe("AttachmentCreateUploadUrlInput", () => { ).toBe(false); }); }); + +describe("GitHubUserAttachmentUrl", () => { + it("accepts only canonical GitHub user attachment URLs", () => { + expect( + isGitHubUserAttachmentUrl( + "https://github.com/user-attachments/assets/f1d65268-4213-47a5-864d-5067e8bf5918", + ), + ).toBe(true); + expect( + isGitHubUserAttachmentUrl( + "https://githubproxy.fjygbaifeng.eu.org.evil.test/user-attachments/assets/f1d65268-4213-47a5-864d-5067e8bf5918", + ), + ).toBe(false); + expect( + isGitHubUserAttachmentUrl( + "https://github.com/user-attachments/assets/f1d65268-4213-47a5-864d-5067e8bf5918?redirect=http://127.0.0.1", + ), + ).toBe(false); + }); +}); diff --git a/packages/contracts/src/assets.ts b/packages/contracts/src/assets.ts index 26638832ca05..4ad0335dc312 100644 --- a/packages/contracts/src/assets.ts +++ b/packages/contracts/src/assets.ts @@ -10,6 +10,14 @@ import { import { ToolActivityNativeAppReference } from "./providerRuntime.ts"; const ASSET_PATH_MAX_LENGTH = 1024; +const GITHUB_USER_ATTACHMENT_URL_PATTERN = + /^https:\/\/github\.com\/user-attachments\/assets\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export const GitHubUserAttachmentUrl = TrimmedNonEmptyString.check( + Schema.isMaxLength(256), + Schema.isPattern(GITHUB_USER_ATTACHMENT_URL_PATTERN), +); +export const isGitHubUserAttachmentUrl = Schema.is(GitHubUserAttachmentUrl); export const AssetResource = Schema.Union([ Schema.TaggedStruct("workspace-file", { @@ -44,6 +52,9 @@ export const AssetResource = Schema.Union([ Schema.TaggedStruct("native-app-icon", { app: ToolActivityNativeAppReference, }), + Schema.TaggedStruct("github-user-attachment", { + url: GitHubUserAttachmentUrl, + }), ]); export type AssetResource = typeof AssetResource.Type; From a1a57489f69b9c6fae4dc4c0a76f9f121fd66ebd Mon Sep 17 00:00:00 2001 From: flamboh Date: Fri, 4 Sep 2026 08:33:58 -0700 Subject: [PATCH 2/8] fix(web): make GitHub image normalization best-effort --- .../src/assets/GitHubUserAttachment.test.ts | 113 +++++++++++++----- .../server/src/assets/GitHubUserAttachment.ts | 66 +++++++--- apps/web/src/components/ChatMarkdown.tsx | 25 +++- .../ChatMarkdown.workspace-images.test.tsx | 24 ++++ 4 files changed, 177 insertions(+), 51 deletions(-) diff --git a/apps/server/src/assets/GitHubUserAttachment.test.ts b/apps/server/src/assets/GitHubUserAttachment.test.ts index 9e88fbb44e2f..9390859e306a 100644 --- a/apps/server/src/assets/GitHubUserAttachment.test.ts +++ b/apps/server/src/assets/GitHubUserAttachment.test.ts @@ -3,11 +3,14 @@ import * as Effect from "effect/Effect"; import { FetchHttpClient } from "effect/unstable/http"; import { vi } from "vite-plus/test"; -import { loadGitHubUserAttachment, stripBt709ColorMetadata } from "./GitHubUserAttachment.ts"; +import { loadGitHubUserAttachment } from "./GitHubUserAttachment.ts"; const PNG_SIGNATURE = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]); const EMPTY_IHDR = Array.from({ length: 13 }, () => 0); -const EMPTY_CHRM = Array.from({ length: 32 }, () => 0); +const SRGB_CHRM = [ + 0, 0, 122, 38, 0, 0, 128, 132, 0, 0, 250, 0, 0, 0, 128, 232, 0, 0, 117, 48, 0, 0, 234, 96, 0, 0, + 58, 152, 0, 0, 23, 112, +]; function concatBytes(parts: ReadonlyArray): Uint8Array { const bytes = new Uint8Array(parts.reduce((length, part) => length + part.length, 0)); @@ -29,18 +32,46 @@ function pngChunk(type: string, data: ReadonlyArray): Uint8Array { return chunk; } -function pngWithCicp(cicp: ReadonlyArray): Uint8Array { +function pngWithCicp( + cicp: ReadonlyArray, + options: { readonly includeSrgbFallback?: boolean } = {}, +): Uint8Array { + const colorFallback = + options.includeSrgbFallback === false + ? [] + : [pngChunk("cHRM", SRGB_CHRM), pngChunk("gAMA", [0, 0, 177, 143])]; return concatBytes([ PNG_SIGNATURE, pngChunk("IHDR", EMPTY_IHDR), pngChunk("cICP", cicp), - pngChunk("cHRM", EMPTY_CHRM), - pngChunk("gAMA", [0, 0, 177, 143]), + ...colorFallback, pngChunk("IDAT", [4, 5, 6]), pngChunk("IEND", []), ]); } +function loadDirectImage(bytes: Uint8Array) { + const fetchMock = Object.assign( + vi.fn( + async () => + new Response(bytes, { + status: 200, + headers: { + "content-length": String(bytes.length), + "content-type": "image/png", + }, + }), + ), + { preconnect: vi.fn() }, + ); + return loadGitHubUserAttachment( + "https://github.com/user-attachments/assets/f1d65268-4213-47a5-864d-5067e8bf5918", + ).pipe( + Effect.provide(FetchHttpClient.layer), + Effect.provideService(FetchHttpClient.Fetch, fetchMock), + ); +} + function chunkTypes(bytes: Uint8Array): string[] { const types: string[] = []; const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); @@ -54,28 +85,34 @@ function chunkTypes(bytes: Uint8Array): string[] { } describe("GitHub user attachments", () => { - it("drops conflicting BT.709 color metadata without changing image data", () => { - const source = pngWithCicp([1, 1, 0, 1]); - const normalized = stripBt709ColorMetadata(source); - - expect(chunkTypes(normalized)).toEqual(["IHDR", "IDAT", "IEND"]); - expect(normalized).toEqual( - concatBytes([ - PNG_SIGNATURE, - pngChunk("IHDR", EMPTY_IHDR), - pngChunk("IDAT", [4, 5, 6]), - pngChunk("IEND", []), - ]), - ); - }); - - it("leaves other cICP profiles and non-PNG data untouched", () => { - const displayP3 = pngWithCicp([12, 13, 0, 1]); - const jpeg = Uint8Array.from([255, 216, 255, 224]); - - expect(stripBt709ColorMetadata(displayP3)).toBe(displayP3); - expect(stripBt709ColorMetadata(jpeg)).toBe(jpeg); - }); + it.effect("drops only the conflicting cICP chunk without changing image data", () => + Effect.gen(function* () { + const source = pngWithCicp([1, 1, 0, 1]); + const { bytes: normalized } = yield* loadDirectImage(source); + + expect(chunkTypes(normalized)).toEqual(["IHDR", "cHRM", "gAMA", "IDAT", "IEND"]); + expect(normalized).toEqual( + concatBytes([ + PNG_SIGNATURE, + pngChunk("IHDR", EMPTY_IHDR), + pngChunk("cHRM", SRGB_CHRM), + pngChunk("gAMA", [0, 0, 177, 143]), + pngChunk("IDAT", [4, 5, 6]), + pngChunk("IEND", []), + ]), + ); + }), + ); + + it.effect("leaves valid BT.709 and other color profiles untouched", () => + Effect.gen(function* () { + const bt709 = pngWithCicp([1, 1, 0, 1], { includeSrgbFallback: false }); + const displayP3 = pngWithCicp([12, 13, 0, 1]); + + expect((yield* loadDirectImage(bt709)).bytes).toEqual(bt709); + expect((yield* loadDirectImage(displayP3)).bytes).toEqual(displayP3); + }), + ); it.effect("follows only GitHub's attachment host and normalizes PNG responses", () => Effect.gen(function* () { @@ -107,6 +144,7 @@ describe("GitHub user attachments", () => { ); expect(chunkTypes(result.bytes)).not.toContain("cICP"); + expect(chunkTypes(result.bytes)).toEqual(["IHDR", "cHRM", "gAMA", "IDAT", "IEND"]); expect(fetchMock.mock.calls.map(([input]) => String(input))).toEqual([ sourceUrl, redirectedUrl, @@ -139,4 +177,25 @@ describe("GitHub user attachments", () => { expect(fetchMock).toHaveBeenCalledTimes(1); }), ); + + it.effect("preserves transport failures as the error cause", () => + Effect.gen(function* () { + const fetchMock = Object.assign( + vi.fn(async () => Promise.reject(new Error("offline"))), + { + preconnect: vi.fn(), + }, + ); + const error = yield* loadGitHubUserAttachment( + "https://github.com/user-attachments/assets/f1d65268-4213-47a5-864d-5067e8bf5918", + ).pipe( + Effect.provide(FetchHttpClient.layer), + Effect.provideService(FetchHttpClient.Fetch, fetchMock), + Effect.flip, + ); + + expect(error.reason).toBe("transport"); + expect(error.cause).toBeDefined(); + }), + ); }); diff --git a/apps/server/src/assets/GitHubUserAttachment.ts b/apps/server/src/assets/GitHubUserAttachment.ts index 444259558e6b..5d8302c6e367 100644 --- a/apps/server/src/assets/GitHubUserAttachment.ts +++ b/apps/server/src/assets/GitHubUserAttachment.ts @@ -1,11 +1,15 @@ -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http"; const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10] as const; const BT709_FULL_RANGE_CICP = [1, 1, 0, 1] as const; -const PNG_COLOR_PROFILE_CHUNKS = new Set(["cICP", "cHRM", "gAMA", "iCCP", "sRGB"]); +const SRGB_GAMMA = [0, 0, 177, 143] as const; +const SRGB_CHROMATICITIES = [ + 0, 0, 122, 38, 0, 0, 128, 132, 0, 0, 250, 0, 0, 0, 128, 232, 0, 0, 117, 48, 0, 0, 234, 96, 0, 0, + 58, 152, 0, 0, 23, 112, +] as const; const MAX_GITHUB_USER_ATTACHMENT_BYTES = 25 * 1024 * 1024; const GITHUB_ASSET_REDIRECT_HOST_PATTERN = /^github-production-user-asset-[a-z0-9]+\.s3\.amazonaws\.com$/i; @@ -17,28 +21,36 @@ const SUPPORTED_IMAGE_CONTENT_TYPES = new Set([ "image/webp", ]); -export class GitHubUserAttachmentFetchError extends Data.TaggedError( +export class GitHubUserAttachmentFetchError extends Schema.TaggedErrorClass()( "GitHubUserAttachmentFetchError", -)<{ - readonly reason: "content-type" | "redirect" | "response-too-large" | "status" | "transport"; - readonly status?: number; -}> { + { + reason: Schema.Literals([ + "content-type", + "redirect", + "response-too-large", + "status", + "transport", + ]), + status: Schema.optional(Schema.Number), + cause: Schema.optional(Schema.Defect()), + }, +) { override get message(): string { return "The GitHub user attachment could not be loaded."; } } +const isGitHubUserAttachmentFetchError = Schema.is(GitHubUserAttachmentFetchError); function bytesEqualAt(bytes: Uint8Array, offset: number, expected: ReadonlyArray): boolean { return expected.every((byte, index) => bytes[offset + index] === byte); } /** - * Chromium's newer PNG decoder color-manages cICP and legacy gAMA/cHRM metadata that GitHub's - * rendering effectively ignores. macOS screenshots can contain both descriptions and render - * much darker in Chromium as a result. When that exact BT.709 declaration is present, remove - * the color-description chunks so the original compressed pixels are interpreted as sRGB. + * Newer Chromium versions honor cICP ahead of legacy PNG color metadata. Some macOS screenshots + * describe the same pixels as full-range BT.709 in cICP and as sRGB in gAMA/cHRM. Remove only the + * conflicting cICP chunk from that exact combination so decoders use the existing sRGB metadata. */ -export function stripBt709ColorMetadata(bytes: Uint8Array): Uint8Array { +function stripConflictingBt709Cicp(bytes: Uint8Array): Uint8Array { if (bytes.length < PNG_SIGNATURE.length || !bytesEqualAt(bytes, 0, PNG_SIGNATURE)) return bytes; const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); @@ -73,9 +85,21 @@ export function stripBt709ColorMetadata(bytes: Uint8Array): Uint8Array { chunk.dataLength === BT709_FULL_RANGE_CICP.length && bytesEqualAt(bytes, chunk.dataOffset, BT709_FULL_RANGE_CICP), ); - if (!hasBt709Cicp) return bytes; + const hasSrgbGamma = chunks.some( + (chunk) => + chunk.type === "gAMA" && + chunk.dataLength === SRGB_GAMMA.length && + bytesEqualAt(bytes, chunk.dataOffset, SRGB_GAMMA), + ); + const hasSrgbChromaticities = chunks.some( + (chunk) => + chunk.type === "cHRM" && + chunk.dataLength === SRGB_CHROMATICITIES.length && + bytesEqualAt(bytes, chunk.dataOffset, SRGB_CHROMATICITIES), + ); + if (!hasBt709Cicp || !hasSrgbGamma || !hasSrgbChromaticities) return bytes; - const removedChunks = chunks.filter((chunk) => PNG_COLOR_PROFILE_CHUNKS.has(chunk.type)); + const removedChunks = chunks.filter((chunk) => chunk.type === "cICP"); const removedByteCount = removedChunks.reduce((sum, chunk) => sum + chunk.end - chunk.start, 0); const normalized = new Uint8Array(bytes.length - removedByteCount); let sourceOffset = 0; @@ -128,9 +152,9 @@ const readLimitedBody = Effect.fn("GitHubUserAttachment.readLimitedBody")(functi return Effect.void; }), Effect.mapError((error) => - error instanceof GitHubUserAttachmentFetchError + isGitHubUserAttachmentFetchError(error) ? error - : new GitHubUserAttachmentFetchError({ reason: "transport" }), + : new GitHubUserAttachmentFetchError({ reason: "transport", cause: error }), ), ); @@ -147,7 +171,9 @@ export const loadGitHubUserAttachment = Effect.fn("GitHubUserAttachment.loadGitH function* (sourceUrl: string) { const httpClient = yield* HttpClient.HttpClient; const initialResponse = yield* executeWithoutRedirects(httpClient.get(sourceUrl)).pipe( - Effect.mapError(() => new GitHubUserAttachmentFetchError({ reason: "transport" })), + Effect.mapError( + (cause) => new GitHubUserAttachmentFetchError({ reason: "transport", cause }), + ), ); const response = initialResponse.status >= 300 && initialResponse.status < 400 @@ -159,7 +185,9 @@ export const loadGitHubUserAttachment = Effect.fn("GitHubUserAttachment.loadGitH return yield* new GitHubUserAttachmentFetchError({ reason: "redirect" }); } return yield* executeWithoutRedirects(httpClient.get(redirectUrl)).pipe( - Effect.mapError(() => new GitHubUserAttachmentFetchError({ reason: "transport" })), + Effect.mapError( + (cause) => new GitHubUserAttachmentFetchError({ reason: "transport", cause }), + ), ); }) : initialResponse; @@ -177,7 +205,7 @@ export const loadGitHubUserAttachment = Effect.fn("GitHubUserAttachment.loadGitH const bytes = yield* readLimitedBody(response); return { - bytes: contentType === "image/png" ? stripBt709ColorMetadata(bytes) : bytes, + bytes: contentType === "image/png" ? stripConflictingBt709Cicp(bytes) : bytes, contentType, }; }, diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 2654f9d6a88e..fc7a1ef629b6 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1369,6 +1369,7 @@ function ChatMarkdownImage(props: { /** Null while the URL is being resolved; the last decoded image stays up. */ readonly src: string | null; readonly sourceFailed?: boolean | undefined; + readonly onSourceError?: ((src: string) => void) | undefined; readonly alt: string; readonly copyMarkdown: string | undefined; readonly standalone: boolean; @@ -1408,6 +1409,7 @@ function ChatMarkdownImage(props: { setFailedSrc(null); }, onError: () => { + props.onSourceError?.(loadingSrc); setFailedSrc(loadingSrc); setLoadedSrc(null); }, @@ -1523,7 +1525,7 @@ function ChatMarkdownVideo(props: { ); } -/** Environment-hosted media loads through an exact-file signed asset URL. */ +/** Media served by an environment loads through a signed asset URL. */ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props: { readonly environmentId: EnvironmentId; readonly resource: Extract< @@ -1546,6 +1548,8 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props }) { const assetUrl = useAssetUrlState(props.environmentId, props.resource); const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, props.resource); + const [failedAssetSrc, setFailedAssetSrc] = useState(null); + const [failedFallbackSrc, setFailedFallbackSrc] = useState(null); const resource = props.resource; const path = resource._tag === "media-file" @@ -1560,7 +1564,13 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props ? mediaFileReference(path, props.workspaceRoot) : undefined; const relativePath = reference?.kind === "file" ? reference.relativePath : undefined; - const src = assetUrl._tag === "Success" ? assetUrl.url + (props.srcFragment ?? "") : null; + const assetSrc = assetUrl._tag === "Success" ? assetUrl.url + (props.srcFragment ?? "") : null; + const fallbackSrc = resource._tag === "github-user-attachment" ? resource.url : null; + const assetSourceFailed = + assetUrl._tag === "Failure" || (assetSrc !== null && failedAssetSrc === assetSrc); + const usesFallback = assetSourceFailed && fallbackSrc !== null; + const src = usesFallback ? fallbackSrc : assetSrc; + const fallbackSourceFailed = usesFallback && failedFallbackSrc === fallbackSrc; // The server reads the pixel size from the file header, so the slot can be // the image's final box instead of a 16:9 guess. An authored size wins; a // caller's height cap shrinks the box while keeping the ratio. @@ -1577,7 +1587,7 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props kind: props.kind ?? "image", name: props.alt || (props.kind ?? "image"), src, - asset: { environmentId: props.environmentId, resource }, + ...(usesFallback ? {} : { asset: { environmentId: props.environmentId, resource } }), ...(reference ? { reference } : {}), ...(relativePath && (resource._tag === "workspace-file" || resource._tag === "media-file") ? { @@ -1596,7 +1606,7 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props return ( { + if (usesFallback) setFailedFallbackSrc(failedSrc); + else setFailedAssetSrc(failedSrc); + }} onImageExpand={props.onImageExpand} /> ); diff --git a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx index 344eca250ce9..2f5a7d9a9c5c 100644 --- a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx +++ b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx @@ -404,4 +404,28 @@ describe("ChatMarkdown workspace images", () => { expect(html).toContain("max-w-[min(100%,30rem)]"); expect(html).not.toContain("Image unavailable"); }); + + it.each([ + ["success", "https://signed.test/workspace-image.svg"], + ["failure", "https://github.com/user-attachments/assets/f1d65268-4213-47a5-864d-5067e8bf5918"], + ] as const)("uses the GitHub attachment %s source", (assetState, expectedSrc) => { + const sourceUrl = + "https://github.com/user-attachments/assets/f1d65268-4213-47a5-864d-5067e8bf5918"; + testState.assetState = assetState; + + const html = renderToStaticMarkup( + + url === sourceUrl ? { _tag: "github-user-attachment", url } : null + } + />, + ); + + expect(testState.resources).toEqual([{ _tag: "github-user-attachment", url: sourceUrl }]); + expect(html).toContain(`src="${expectedSrc}"`); + expect(html).not.toContain("Image unavailable"); + }); }); From 2fd45bc9d79080ad0a36b198f1c99b5c09a76f32 Mon Sep 17 00:00:00 2001 From: flamboh Date: Fri, 4 Sep 2026 08:55:21 -0700 Subject: [PATCH 3/8] fix(server): bound GitHub attachment buffering --- .../src/assets/GitHubUserAttachment.test.ts | 1 + .../server/src/assets/GitHubUserAttachment.ts | 39 ++++++++++++------- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/apps/server/src/assets/GitHubUserAttachment.test.ts b/apps/server/src/assets/GitHubUserAttachment.test.ts index 9390859e306a..bbe56eafba48 100644 --- a/apps/server/src/assets/GitHubUserAttachment.test.ts +++ b/apps/server/src/assets/GitHubUserAttachment.test.ts @@ -90,6 +90,7 @@ describe("GitHub user attachments", () => { const source = pngWithCicp([1, 1, 0, 1]); const { bytes: normalized } = yield* loadDirectImage(source); + expect(normalized.buffer.byteLength).toBe(source.byteLength); expect(chunkTypes(normalized)).toEqual(["IHDR", "cHRM", "gAMA", "IDAT", "IEND"]); expect(normalized).toEqual( concatBytes([ diff --git a/apps/server/src/assets/GitHubUserAttachment.ts b/apps/server/src/assets/GitHubUserAttachment.ts index 5d8302c6e367..a47b8b00e693 100644 --- a/apps/server/src/assets/GitHubUserAttachment.ts +++ b/apps/server/src/assets/GitHubUserAttachment.ts @@ -10,6 +10,7 @@ const SRGB_CHROMATICITIES = [ 0, 0, 122, 38, 0, 0, 128, 132, 0, 0, 250, 0, 0, 0, 128, 232, 0, 0, 117, 48, 0, 0, 234, 96, 0, 0, 58, 152, 0, 0, 23, 112, ] as const; +const INITIAL_GITHUB_USER_ATTACHMENT_BUFFER_BYTES = 64 * 1024; const MAX_GITHUB_USER_ATTACHMENT_BYTES = 25 * 1024 * 1024; const GITHUB_ASSET_REDIRECT_HOST_PATTERN = /^github-production-user-asset-[a-z0-9]+\.s3\.amazonaws\.com$/i; @@ -101,16 +102,16 @@ function stripConflictingBt709Cicp(bytes: Uint8Array): Uint8Array { const removedChunks = chunks.filter((chunk) => chunk.type === "cICP"); const removedByteCount = removedChunks.reduce((sum, chunk) => sum + chunk.end - chunk.start, 0); - const normalized = new Uint8Array(bytes.length - removedByteCount); + const normalizedLength = bytes.length - removedByteCount; let sourceOffset = 0; let destinationOffset = 0; for (const chunk of removedChunks) { - normalized.set(bytes.subarray(sourceOffset, chunk.start), destinationOffset); + bytes.copyWithin(destinationOffset, sourceOffset, chunk.start); destinationOffset += chunk.start - sourceOffset; sourceOffset = chunk.end; } - normalized.set(bytes.subarray(sourceOffset), destinationOffset); - return normalized; + bytes.copyWithin(destinationOffset, sourceOffset); + return bytes.subarray(0, normalizedLength); } function trustedRedirectUrl(location: string, sourceUrl: string): string | null { @@ -140,15 +141,29 @@ const readLimitedBody = Effect.fn("GitHubUserAttachment.readLimitedBody")(functi return yield* new GitHubUserAttachmentFetchError({ reason: "response-too-large" }); } - const chunks: Uint8Array[] = []; + const initialCapacity = + Number.isSafeInteger(declaredLength) && declaredLength >= 0 + ? declaredLength + : INITIAL_GITHUB_USER_ATTACHMENT_BUFFER_BYTES; + let bytes = new Uint8Array(initialCapacity); let byteLength = 0; yield* response.stream.pipe( Stream.runForEach((chunk) => { - byteLength += chunk.length; - if (byteLength > MAX_GITHUB_USER_ATTACHMENT_BYTES) { + const nextByteLength = byteLength + chunk.length; + if (nextByteLength > MAX_GITHUB_USER_ATTACHMENT_BYTES) { return Effect.fail(new GitHubUserAttachmentFetchError({ reason: "response-too-large" })); } - chunks.push(chunk); + if (nextByteLength > bytes.length) { + const nextCapacity = Math.min( + MAX_GITHUB_USER_ATTACHMENT_BYTES, + Math.max(nextByteLength, bytes.length * 2, INITIAL_GITHUB_USER_ATTACHMENT_BUFFER_BYTES), + ); + const grown = new Uint8Array(nextCapacity); + grown.set(bytes.subarray(0, byteLength)); + bytes = grown; + } + bytes.set(chunk, byteLength); + byteLength = nextByteLength; return Effect.void; }), Effect.mapError((error) => @@ -158,13 +173,7 @@ const readLimitedBody = Effect.fn("GitHubUserAttachment.readLimitedBody")(functi ), ); - const bytes = new Uint8Array(byteLength); - let offset = 0; - for (const chunk of chunks) { - bytes.set(chunk, offset); - offset += chunk.length; - } - return bytes; + return bytes.subarray(0, byteLength); }); export const loadGitHubUserAttachment = Effect.fn("GitHubUserAttachment.loadGitHubUserAttachment")( From a92c5143446e871ce4bcf84377a982ee3ccf96ee Mon Sep 17 00:00:00 2001 From: flamboh Date: Fri, 4 Sep 2026 09:44:50 -0700 Subject: [PATCH 4/8] fix(server): time out GitHub attachment requests --- .../src/assets/GitHubUserAttachment.test.ts | 82 +++++++++++++++++++ .../server/src/assets/GitHubUserAttachment.ts | 35 ++++---- 2 files changed, 101 insertions(+), 16 deletions(-) diff --git a/apps/server/src/assets/GitHubUserAttachment.test.ts b/apps/server/src/assets/GitHubUserAttachment.test.ts index bbe56eafba48..ea037fa33a86 100644 --- a/apps/server/src/assets/GitHubUserAttachment.test.ts +++ b/apps/server/src/assets/GitHubUserAttachment.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as TestClock from "effect/testing/TestClock"; import { FetchHttpClient } from "effect/unstable/http"; import { vi } from "vite-plus/test"; @@ -199,4 +201,84 @@ describe("GitHub user attachments", () => { expect(error.cause).toBeDefined(); }), ); + + it.effect("times out stalled initial and redirected requests", () => + Effect.gen(function* () { + for (const redirectBeforeStall of [false, true]) { + const sourceUrl = + "https://github.com/user-attachments/assets/f1d65268-4213-47a5-864d-5067e8bf5918"; + const redirectedUrl = + "https://github-production-user-asset-6210df.s3.amazonaws.com/asset?signature=redacted"; + const fetchMock = Object.assign( + vi.fn((input: string | URL | Request, init?: RequestInit): Promise => { + if (redirectBeforeStall && String(input) === sourceUrl) { + return Promise.resolve( + new Response(null, { status: 302, headers: { location: redirectedUrl } }), + ); + } + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { + once: true, + }); + }); + }), + { preconnect: vi.fn() }, + ); + const errorFiber = yield* loadGitHubUserAttachment(sourceUrl).pipe( + Effect.provide(FetchHttpClient.layer), + Effect.provideService(FetchHttpClient.Fetch, fetchMock), + Effect.flip, + Effect.forkChild, + ); + + yield* Effect.yieldNow; + yield* TestClock.adjust("10 seconds"); + const error = yield* Fiber.join(errorFiber); + + expect(error.reason).toBe("transport"); + expect(error.cause).toBeDefined(); + expect(fetchMock).toHaveBeenCalledTimes(redirectBeforeStall ? 2 : 1); + } + }), + ); + + it.effect("times out a stalled response body", () => + Effect.gen(function* () { + let bodyCancelled = false; + const fetchMock = Object.assign( + vi.fn(async () => + Promise.resolve( + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(PNG_SIGNATURE); + }, + cancel() { + bodyCancelled = true; + }, + }), + { status: 200, headers: { "content-type": "image/png" } }, + ), + ), + ), + { preconnect: vi.fn() }, + ); + const errorFiber = yield* loadGitHubUserAttachment( + "https://github.com/user-attachments/assets/f1d65268-4213-47a5-864d-5067e8bf5918", + ).pipe( + Effect.provide(FetchHttpClient.layer), + Effect.provideService(FetchHttpClient.Fetch, fetchMock), + Effect.flip, + Effect.forkChild, + ); + + yield* Effect.yieldNow; + yield* TestClock.adjust("30 seconds"); + const error = yield* Fiber.join(errorFiber); + + expect(error.reason).toBe("transport"); + expect(error.cause).toBeDefined(); + expect(bodyCancelled).toBe(true); + }), + ); }); diff --git a/apps/server/src/assets/GitHubUserAttachment.ts b/apps/server/src/assets/GitHubUserAttachment.ts index a47b8b00e693..a3865e525090 100644 --- a/apps/server/src/assets/GitHubUserAttachment.ts +++ b/apps/server/src/assets/GitHubUserAttachment.ts @@ -1,3 +1,4 @@ +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; @@ -12,6 +13,8 @@ const SRGB_CHROMATICITIES = [ ] as const; const INITIAL_GITHUB_USER_ATTACHMENT_BUFFER_BYTES = 64 * 1024; const MAX_GITHUB_USER_ATTACHMENT_BYTES = 25 * 1024 * 1024; +const GITHUB_USER_ATTACHMENT_REQUEST_TIMEOUT = Duration.seconds(10); +const GITHUB_USER_ATTACHMENT_BODY_TIMEOUT = Duration.seconds(30); const GITHUB_ASSET_REDIRECT_HOST_PATTERN = /^github-production-user-asset-[a-z0-9]+\.s3\.amazonaws\.com$/i; const SUPPORTED_IMAGE_CONTENT_TYPES = new Set([ @@ -42,6 +45,12 @@ export class GitHubUserAttachmentFetchError extends Schema.TaggedErrorClass): boolean { return expected.every((byte, index) => bytes[offset + index] === byte); } @@ -131,7 +140,12 @@ function trustedRedirectUrl(location: string, sourceUrl: string): string | null const executeWithoutRedirects = ( effect: Effect.Effect, -) => effect.pipe(Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" })); +) => + effect.pipe( + Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }), + Effect.timeout(GITHUB_USER_ATTACHMENT_REQUEST_TIMEOUT), + Effect.mapError(transportFailure), + ); const readLimitedBody = Effect.fn("GitHubUserAttachment.readLimitedBody")(function* ( response: HttpClientResponse.HttpClientResponse, @@ -166,11 +180,8 @@ const readLimitedBody = Effect.fn("GitHubUserAttachment.readLimitedBody")(functi byteLength = nextByteLength; return Effect.void; }), - Effect.mapError((error) => - isGitHubUserAttachmentFetchError(error) - ? error - : new GitHubUserAttachmentFetchError({ reason: "transport", cause: error }), - ), + Effect.timeout(GITHUB_USER_ATTACHMENT_BODY_TIMEOUT), + Effect.mapError(transportFailure), ); return bytes.subarray(0, byteLength); @@ -179,11 +190,7 @@ const readLimitedBody = Effect.fn("GitHubUserAttachment.readLimitedBody")(functi export const loadGitHubUserAttachment = Effect.fn("GitHubUserAttachment.loadGitHubUserAttachment")( function* (sourceUrl: string) { const httpClient = yield* HttpClient.HttpClient; - const initialResponse = yield* executeWithoutRedirects(httpClient.get(sourceUrl)).pipe( - Effect.mapError( - (cause) => new GitHubUserAttachmentFetchError({ reason: "transport", cause }), - ), - ); + const initialResponse = yield* executeWithoutRedirects(httpClient.get(sourceUrl)); const response = initialResponse.status >= 300 && initialResponse.status < 400 ? yield* Effect.gen(function* () { @@ -193,11 +200,7 @@ export const loadGitHubUserAttachment = Effect.fn("GitHubUserAttachment.loadGitH if (redirectUrl === null) { return yield* new GitHubUserAttachmentFetchError({ reason: "redirect" }); } - return yield* executeWithoutRedirects(httpClient.get(redirectUrl)).pipe( - Effect.mapError( - (cause) => new GitHubUserAttachmentFetchError({ reason: "transport", cause }), - ), - ); + return yield* executeWithoutRedirects(httpClient.get(redirectUrl)); }) : initialResponse; From 88d4004977f243cac53c8a43e687b75a601df796 Mon Sep 17 00:00:00 2001 From: flamboh Date: Fri, 4 Sep 2026 15:46:10 -0700 Subject: [PATCH 5/8] refactor(web): simplify GitHub image normalization --- .../src/assets/GitHubUserAttachment.test.ts | 10 +++ .../server/src/assets/GitHubUserAttachment.ts | 73 +++++++------------ apps/web/src/components/ChatMarkdown.tsx | 38 +++++----- .../ChatMarkdown.workspace-images.test.tsx | 24 ------ .../pullRequest/PullRequestMarkdown.tsx | 15 +--- 5 files changed, 59 insertions(+), 101 deletions(-) diff --git a/apps/server/src/assets/GitHubUserAttachment.test.ts b/apps/server/src/assets/GitHubUserAttachment.test.ts index ea037fa33a86..4436dd9dea1d 100644 --- a/apps/server/src/assets/GitHubUserAttachment.test.ts +++ b/apps/server/src/assets/GitHubUserAttachment.test.ts @@ -117,6 +117,16 @@ describe("GitHub user attachments", () => { }), ); + it.effect("leaves truncated and duplicate-profile PNGs untouched", () => + Effect.gen(function* () { + const source = pngWithCicp([1, 1, 0, 1]); + const duplicate = concatBytes([source, pngChunk("cICP", [1, 1, 0, 1])]); + for (const bytes of [source.subarray(0, source.length - 1), duplicate]) { + expect((yield* loadDirectImage(bytes)).bytes).toEqual(bytes); + } + }), + ); + it.effect("follows only GitHub's attachment host and normalizes PNG responses", () => Effect.gen(function* () { const sourceUrl = diff --git a/apps/server/src/assets/GitHubUserAttachment.ts b/apps/server/src/assets/GitHubUserAttachment.ts index a3865e525090..fdde315a3bcd 100644 --- a/apps/server/src/assets/GitHubUserAttachment.ts +++ b/apps/server/src/assets/GitHubUserAttachment.ts @@ -64,13 +64,9 @@ function stripConflictingBt709Cicp(bytes: Uint8Array): Uint8Array { if (bytes.length < PNG_SIGNATURE.length || !bytesEqualAt(bytes, 0, PNG_SIGNATURE)) return bytes; const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - const chunks: Array<{ - readonly start: number; - readonly end: number; - readonly type: string; - readonly dataOffset: number; - readonly dataLength: number; - }> = []; + let cicpOffset = -1; + let hasSrgbGamma = false; + let hasSrgbChromaticities = false; let offset: number = PNG_SIGNATURE.length; while (offset + 12 <= bytes.length) { const dataLength = view.getUint32(offset, false); @@ -78,49 +74,32 @@ function stripConflictingBt709Cicp(bytes: Uint8Array): Uint8Array { const chunkEnd = dataOffset + dataLength + 4; if (chunkEnd > bytes.length) return bytes; - chunks.push({ - start: offset, - end: chunkEnd, - type: String.fromCharCode(...bytes.subarray(offset + 4, offset + 8)), - dataOffset, - dataLength, - }); + const type = String.fromCharCode(...bytes.subarray(offset + 4, offset + 8)); + if (type === "cICP") { + // PNG permits one cICP chunk. Leave duplicate or other profiles untouched. + if ( + cicpOffset !== -1 || + dataLength !== BT709_FULL_RANGE_CICP.length || + !bytesEqualAt(bytes, dataOffset, BT709_FULL_RANGE_CICP) + ) + return bytes; + cicpOffset = offset; + } else if (type === "gAMA") { + hasSrgbGamma ||= + dataLength === SRGB_GAMMA.length && bytesEqualAt(bytes, dataOffset, SRGB_GAMMA); + } else if (type === "cHRM") { + hasSrgbChromaticities ||= + dataLength === SRGB_CHROMATICITIES.length && + bytesEqualAt(bytes, dataOffset, SRGB_CHROMATICITIES); + } offset = chunkEnd; } - if (offset !== bytes.length) return bytes; - const hasBt709Cicp = chunks.some( - (chunk) => - chunk.type === "cICP" && - chunk.dataLength === BT709_FULL_RANGE_CICP.length && - bytesEqualAt(bytes, chunk.dataOffset, BT709_FULL_RANGE_CICP), - ); - const hasSrgbGamma = chunks.some( - (chunk) => - chunk.type === "gAMA" && - chunk.dataLength === SRGB_GAMMA.length && - bytesEqualAt(bytes, chunk.dataOffset, SRGB_GAMMA), - ); - const hasSrgbChromaticities = chunks.some( - (chunk) => - chunk.type === "cHRM" && - chunk.dataLength === SRGB_CHROMATICITIES.length && - bytesEqualAt(bytes, chunk.dataOffset, SRGB_CHROMATICITIES), - ); - if (!hasBt709Cicp || !hasSrgbGamma || !hasSrgbChromaticities) return bytes; - - const removedChunks = chunks.filter((chunk) => chunk.type === "cICP"); - const removedByteCount = removedChunks.reduce((sum, chunk) => sum + chunk.end - chunk.start, 0); - const normalizedLength = bytes.length - removedByteCount; - let sourceOffset = 0; - let destinationOffset = 0; - for (const chunk of removedChunks) { - bytes.copyWithin(destinationOffset, sourceOffset, chunk.start); - destinationOffset += chunk.start - sourceOffset; - sourceOffset = chunk.end; - } - bytes.copyWithin(destinationOffset, sourceOffset); - return bytes.subarray(0, normalizedLength); + if (offset !== bytes.length || cicpOffset === -1 || !hasSrgbGamma || !hasSrgbChromaticities) + return bytes; + const chunkLength = 12 + BT709_FULL_RANGE_CICP.length; + bytes.copyWithin(cicpOffset, cicpOffset + chunkLength); + return bytes.subarray(0, bytes.length - chunkLength); } function trustedRedirectUrl(location: string, sourceUrl: string): string | null { diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index fc7a1ef629b6..1ec34d7ae92d 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -22,12 +22,13 @@ import { WrapTextIcon, type LucideIcon, } from "lucide-react"; -import type { - AssetResource, - EnvironmentId, - ScopedThreadRef, - ServerProviderSkill, - ThreadPullRequestKey, +import { + isGitHubUserAttachmentUrl, + type AssetResource, + type EnvironmentId, + type ScopedThreadRef, + type ServerProviderSkill, + type ThreadPullRequestKey, } from "@t3tools/contracts"; import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; import { @@ -205,9 +206,7 @@ interface ChatMarkdownProps { imageBaseDir?: string | undefined; onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; extraRemarkPlugins?: NonNullable; - resolveDirectImageAsset?: ( - url: string, - ) => Extract | null; + normalizeGitHubImages?: boolean; } export function canUseMarkdownFileShellActions( @@ -2187,7 +2186,7 @@ function useChatMarkdownState({ onUseArtifactTemplate, imageBaseDir, onImageExpand, - resolveDirectImageAsset, + normalizeGitHubImages, }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); const [localMediaPreview, setLocalMediaPreview] = useState(null); @@ -2590,7 +2589,7 @@ function useChatMarkdownState({ openMarkdownMedia, projects, linkedThreadPullRequestFor, - resolveDirectImageAsset, + normalizeGitHubImages, resolveThreadPullRequest, resolvedTheme, serverConfig, @@ -2618,7 +2617,7 @@ function useChatMarkdownState({ openMarkdownMedia, projects, linkedThreadPullRequestFor, - resolveDirectImageAsset, + normalizeGitHubImages, resolveThreadPullRequest, resolvedTheme, serverConfig, @@ -2998,8 +2997,9 @@ const CHAT_MARKDOWN_COMPONENTS = { ); }, img: function MarkdownImage({ node, title, src, alt, ...props }) { - const { environmentId, expandMedia, cwd, imageBaseDir, resolveDirectImageAsset, threadRef } = - use(ChatMarkdownRendererContext); + const { environmentId, expandMedia, cwd, imageBaseDir, normalizeGitHubImages, threadRef } = use( + ChatMarkdownRendererContext, + ); const imageExpand = use(MarkdownLinkContext) ? undefined : expandMedia; const localSrc = node?.properties?.dataLocalSrc; const markdownTitle = node?.properties?.dataMarkdownTitle; @@ -3027,12 +3027,16 @@ const CHAT_MARKDOWN_COMPONENTS = { src: mediaSrc, ...(reference ? { reference } : {}), }; - const directImageAsset = kind === "image" ? resolveDirectImageAsset?.(mediaSrc) : undefined; - if (directImageAsset && environmentId) { + if ( + normalizeGitHubImages && + kind === "image" && + environmentId && + isGitHubUserAttachmentUrl(mediaSrc) + ) { return ( { expect(html).toContain("max-w-[min(100%,30rem)]"); expect(html).not.toContain("Image unavailable"); }); - - it.each([ - ["success", "https://signed.test/workspace-image.svg"], - ["failure", "https://github.com/user-attachments/assets/f1d65268-4213-47a5-864d-5067e8bf5918"], - ] as const)("uses the GitHub attachment %s source", (assetState, expectedSrc) => { - const sourceUrl = - "https://github.com/user-attachments/assets/f1d65268-4213-47a5-864d-5067e8bf5918"; - testState.assetState = assetState; - - const html = renderToStaticMarkup( - - url === sourceUrl ? { _tag: "github-user-attachment", url } : null - } - />, - ); - - expect(testState.resources).toEqual([{ _tag: "github-user-attachment", url: sourceUrl }]); - expect(html).toContain(`src="${expectedSrc}"`); - expect(html).not.toContain("Image unavailable"); - }); }); diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx index dd16c4d1ff57..c31a0331212d 100644 --- a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx +++ b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx @@ -1,10 +1,5 @@ import { ExternalLinkIcon, PaperclipIcon } from "lucide-react"; -import { - isGitHubUserAttachmentUrl, - type AssetResource, - type EnvironmentId, - type ScopedThreadRef, -} from "@t3tools/contracts"; +import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; import { createContext, useContext, useMemo } from "react"; import type { Options as ReactMarkdownOptions } from "react-markdown"; @@ -20,12 +15,6 @@ export const PullRequestMarkdownContext = createContext<{ threadRef: ScopedThreadRef | null; } | null>(null); -function resolveGitHubUserAttachmentAsset( - url: string, -): Extract | null { - return isGitHubUserAttachmentUrl(url) ? { _tag: "github-user-attachment", url } : null; -} - /** Renders PR uploads inline, with retry and an original link when video playback fails. */ export function PullRequestMarkdown({ text, @@ -62,7 +51,7 @@ export function PullRequestMarkdown({ pullRequestPanelRef={resolvedThreadRef ?? PULL_REQUESTS_PANEL_REF} environmentId={environmentId} extraRemarkPlugins={extraRemarkPlugins} - resolveDirectImageAsset={resolveGitHubUserAttachmentAsset} + normalizeGitHubImages /> ); } From 004201b60eed13fe80c86a6e6c193504c90f6a87 Mon Sep 17 00:00:00 2001 From: flamboh Date: Sat, 5 Sep 2026 02:09:26 -0700 Subject: [PATCH 6/8] fix(web): preserve image loading behavior after rebase --- .../ChatMarkdown.github-images.test.tsx | 48 +++++++++++++++++++ apps/web/src/components/ChatMarkdown.tsx | 1 + 2 files changed, 49 insertions(+) create mode 100644 apps/web/src/components/ChatMarkdown.github-images.test.tsx diff --git a/apps/web/src/components/ChatMarkdown.github-images.test.tsx b/apps/web/src/components/ChatMarkdown.github-images.test.tsx new file mode 100644 index 000000000000..fd6a956b0106 --- /dev/null +++ b/apps/web/src/components/ChatMarkdown.github-images.test.tsx @@ -0,0 +1,48 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import type { ReactNode } from "react"; +import { act, create } from "react-test-renderer"; +import { describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("../assets/assetUrls", () => ({ + useAssetUrlRefresh: () => vi.fn(), + useAssetUrlState: () => ({ _tag: "Success", url: "https://signed.test/workspace-image.svg" }), +})); + +vi.mock("./media/MediaActions", () => ({ + MediaActions: ({ children }: { children: ReactNode }) => children, +})); +import { ChatMarkdownAssetImage } from "./ChatMarkdown"; + +describe("GitHub image fallback", () => { + it("keeps the loading slot through a proxy failure, then displays the original image", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const url = "https://github.com/user-attachments/assets/433c6edc-fad7-4259-9323-be4b9968488e"; + let renderer!: ReturnType; + await act(async () => { + renderer = create( + , + ); + }); + try { + expect(renderer.root.findByType("img").props.src).toBe( + "https://signed.test/workspace-image.svg", + ); + await act(async () => renderer.root.findByType("img").props.onError()); + expect(renderer.root.findByType("img").props.src).toBe(url); + expect(renderer.root.findByProps({ "aria-label": "Loading image" })).toBeDefined(); + await act(async () => renderer.root.findByType("img").props.onLoad()); + expect(renderer.root.findByType("img").props.src).toBe(url); + expect(renderer.root.findAllByProps({ "aria-label": "Loading image" })).toHaveLength(0); + await act(async () => renderer.root.findByType("img").props.onError()); + expect(renderer.root.findAllByType("img")).toHaveLength(0); + expect(renderer.root.findByProps({ role: "alert" })).toBeDefined(); + } finally { + await act(async () => renderer.unmount()); + vi.unstubAllGlobals(); + } + }); +}); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 1ec34d7ae92d..dd2754b79b55 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -3037,6 +3037,7 @@ const CHAT_MARKDOWN_COMPONENTS = { Date: Sat, 5 Sep 2026 02:13:28 -0700 Subject: [PATCH 7/8] fix(web): preserve authored attributes on GitHub images --- apps/web/src/components/ChatMarkdown.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index dd2754b79b55..15e4d87cd216 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1542,6 +1542,7 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props /** Caps the box height in rem while keeping the image's ratio; 30 by default. */ readonly maxHeightRem?: number | undefined; readonly style?: CSSProperties | undefined; + readonly imageProps?: ComponentProps["imageProps"]; readonly workspaceRoot?: string | undefined; readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; }) { @@ -1627,6 +1628,7 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props className={CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME} style={style} actionsSource={actionsSource} + imageProps={props.imageProps} originalUrl={fallbackSrc ?? undefined} onSourceError={(failedSrc) => { if (usesFallback) setFailedFallbackSrc(failedSrc); @@ -3037,6 +3039,7 @@ const CHAT_MARKDOWN_COMPONENTS = { Date: Tue, 8 Sep 2026 07:38:17 +0000 Subject: [PATCH 8/8] fix(server): use current Effect tagged error API --- apps/server/src/assets/GitHubUserAttachment.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/assets/GitHubUserAttachment.ts b/apps/server/src/assets/GitHubUserAttachment.ts index fdde315a3bcd..c7b6617d9094 100644 --- a/apps/server/src/assets/GitHubUserAttachment.ts +++ b/apps/server/src/assets/GitHubUserAttachment.ts @@ -25,7 +25,7 @@ const SUPPORTED_IMAGE_CONTENT_TYPES = new Set([ "image/webp", ]); -export class GitHubUserAttachmentFetchError extends Schema.TaggedErrorClass()( +export class GitHubUserAttachmentFetchError extends Schema.TaggedError()( "GitHubUserAttachmentFetchError", { reason: Schema.Literals([