From c17377e2064c032065dfae3f3eec6251f098e9d9 Mon Sep 17 00:00:00 2001 From: maria Date: Wed, 16 Sep 2026 18:08:41 -0300 Subject: [PATCH 1/3] fix(web): show private repository media in pull request tabs (#11706) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) (cherry picked from commit 32e8b2584556c0c55ce0d1d8f72b506cb771d60d) --- apps/server/src/assets/AssetAccess.test.ts | 117 ++++++++++- apps/server/src/assets/AssetAccess.ts | 59 +++++- .../src/assets/GitHubMediaFetch.test.ts | 155 ++++++++++++++ apps/server/src/assets/GitHubMediaFetch.ts | 191 ++++++++++++++++++ apps/server/src/http.ts | 12 ++ apps/server/src/server.test.ts | 3 +- apps/server/src/server.ts | 4 +- apps/server/src/ws.ts | 2 + apps/web/src/components/ChatMarkdown.tsx | 102 ++++++++-- .../pullRequest/PullRequestMarkdown.tsx | 58 +++++- packages/contracts/src/assets.ts | 17 ++ packages/shared/package.json | 4 + packages/shared/src/githubMedia.ts | 70 +++++++ 13 files changed, 765 insertions(+), 29 deletions(-) create mode 100644 apps/server/src/assets/GitHubMediaFetch.test.ts create mode 100644 apps/server/src/assets/GitHubMediaFetch.ts create mode 100644 packages/shared/src/githubMedia.ts diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 32385fb5f8..6bebceac77 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -3,7 +3,7 @@ import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; import * as NodeFSP from "node:fs/promises"; -import { AssetPreviewTypeValidationError, ThreadId } from "@t3tools/contracts"; +import { AssetAccessError, AssetPreviewTypeValidationError, ThreadId } from "@t3tools/contracts"; import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; import { describe, expect, it } from "@effect/vitest"; import * as Crypto from "effect/Crypto"; @@ -12,8 +12,10 @@ 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 Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; -import { HttpServerResponse } from "effect/unstable/http"; +import { HttpClient, HttpClientResponse, HttpServerResponse } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { vi } from "vite-plus/test"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; @@ -25,6 +27,8 @@ import { assetFileResponse } from "../http.ts"; import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts"; import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; import { openMediaFile } from "./MediaFile.ts"; +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import { githubMediaResponse } from "./GitHubMediaFetch.ts"; vi.mock("node:fs/promises", async (importOriginal) => { const actual = await importOriginal(); @@ -47,6 +51,53 @@ const testLayer = Layer.mergeAll( ).pipe(Layer.provideMerge(NodeServices.layer)); describe("AssetAccess", () => { + it.effect("uses the active media credential after login, account switch and logout", () => { + let lookups = 0; + const authorizations: Array = []; + return Effect.gen(function* () { + const asset = { + url: "https://raw.githubusercontent.com/owner/repo/main/shot.png", + cwd: "/repo", + expiresAt: Number.MAX_SAFE_INTEGER, + }; + expect((yield* githubMediaResponse(asset, {})).status).toBe(404); + expect((yield* githubMediaResponse(asset, {})).status).toBe(200); + expect((yield* githubMediaResponse(asset, {})).status).toBe(200); + expect((yield* githubMediaResponse(asset, {})).status).toBe(404); + expect(lookups).toBe(4); + expect(authorizations).toEqual([undefined, "Bearer signed-in", "Bearer switched", undefined]); + }).pipe( + Effect.provide( + Layer.mock(GitHubCli.GitHubCli)({ + execute: () => + Effect.sync(() => ({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: ["", "signed-in", "switched", ""][lookups++] ?? "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + })), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + authorizations.push(request.headers.authorization); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(null, { + status: request.headers.authorization ? 200 : 404, + headers: { "content-type": "image/png" }, + }), + ), + ); + }), + ), + Effect.scoped, + ); + }); + it.effect("issues exact URLs for media and browser documents outside the workspace", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -258,7 +309,7 @@ describe("AssetAccess", () => { suffix.slice(0, separator), suffix.slice(separator + 1), ); - if (!asset) throw new Error("Expected a resolved media file"); + if (asset?.kind !== "file") throw new Error("Expected a resolved media file"); yield* fs.rename(filePath, savedPath); yield* fs.symlink(secretPath, filePath); @@ -447,7 +498,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?.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"); @@ -463,7 +514,7 @@ describe("AssetAccess", () => { renewedSuffix.slice(0, renewedSeparator), renewedSuffix.slice(renewedSeparator + 1), ); - if (!renewedAsset) throw new Error("Expected the replacement media file"); + if (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); @@ -1198,4 +1249,60 @@ describe("AssetAccess", () => { expect(error.cause).toBe(resolutionCause); }).pipe(Effect.provide(testLayer)), ); + + it.effect("serves GitHub-hosted pull request media through the repository's credential", () => + Effect.gen(function* () { + const resolve = (relativeUrl: string) => { + const suffix = relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + return resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)); + }; + const issue = (url: string) => + issueAssetUrl({ resource: { _tag: "github-media", cwd: "/repo", url } }); + + const attachment = yield* issue( + "https://github.com/user-attachments/assets/1a1842fb-6383-492f-873c-57aa0033fa6c", + ); + expect(attachment.relativeUrl.endsWith("/1a1842fb-6383-492f-873c-57aa0033fa6c")).toBe(true); + expect(yield* resolve(attachment.relativeUrl)).toEqual({ + kind: "github-media", + url: "https://github.com/user-attachments/assets/1a1842fb-6383-492f-873c-57aa0033fa6c", + cwd: "/repo", + // The signed URL's own expiry, which is how long a client may keep the bytes. + expiresAt: attachment.expiresAt, + }); + + // A `blob` link addresses the page; only the raw host answers a credential with bytes. + const committed = yield* issue("https://github.com/owner/repo/blob/main/docs/shot.png"); + expect(yield* resolve(committed.relativeUrl)).toMatchObject({ + url: "https://raw.githubusercontent.com/owner/repo/main/docs/shot.png", + }); + + // The pre-`user-attachments` form, Git LFS bytes, and a name no `decodeURIComponent` + // accepts all arrive from real bodies. + const legacy = yield* issue("https://github.com/owner/repo/assets/45952064/1a1842fb"); + expect(yield* resolve(legacy.relativeUrl)).toMatchObject({ + url: "https://github.com/owner/repo/assets/45952064/1a1842fb", + }); + const lfs = yield* issue("https://media.githubusercontent.com/media/owner/repo/main/a.mp4"); + expect(yield* resolve(lfs.relativeUrl)).toMatchObject({ + url: "https://media.githubusercontent.com/media/owner/repo/main/a.mp4", + }); + const awkward = yield* issue("https://raw.githubusercontent.com/o/r/main/100%.png"); + expect(awkward.relativeUrl.endsWith("/100%25.png")).toBe(true); + + for (const url of [ + "https://example.com/shot.png", + "https://example.com/shot.png?token=private-media-token", + "http://github.com/user-attachments/assets/1a1842fb", + "https://github.com/owner/repo/pull/1", + "https://github.com/owner/repo/blob/main/", + ]) { + const error = yield* issue(url).pipe(Effect.flip); + expect(error._tag).toBe("AssetGitHubMediaUrlValidationError"); + const encoded = yield* Schema.encodeEffect(Schema.fromJsonString(AssetAccessError))(error); + expect(encoded).not.toContain(url); + } + }).pipe(Effect.provide(testLayer)), + ); }); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 73317395db..6612e1cf59 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -1,6 +1,7 @@ import type { AssetResource } from "@t3tools/contracts"; import { AssetAttachmentNotFoundError, + AssetGitHubMediaUrlValidationError, AssetPreviewTypeValidationError, AssetProjectFaviconInspectionError, AssetProjectFaviconNotFoundError, @@ -27,6 +28,7 @@ import { readImageDimensions, type ImageDimensions, } from "@t3tools/shared/imageDimensions"; +import { githubMediaFetchUrl, githubMediaFileName } from "@t3tools/shared/githubMedia"; import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; import * as Clock from "effect/Clock"; import * as Crypto from "effect/Crypto"; @@ -135,6 +137,14 @@ const AssetClaimsSchema = Schema.Union([ app: ToolActivityNativeAppReference, expiresAt: Schema.Number, }), + Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("github-media"), + /** Already narrowed to a GitHub media host at mint time; the signature is what keeps it there. */ + url: Schema.String, + cwd: Schema.String, + expiresAt: Schema.Number, + }), ]); type AssetClaims = typeof AssetClaimsSchema.Type; @@ -142,14 +152,23 @@ 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-media"; + readonly url: string; + readonly cwd: string; + /** When the signed URL that granted this stops working, which bounds how long a client + may keep the bytes it fetched with it. */ + readonly expiresAt: number; + }; function decodeClaims(encodedPayload: string): AssetClaims | null { try { @@ -674,6 +693,21 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i fileName = "native-app-icon.png"; break; } + case "github-media": { + const fetchUrl = githubMediaFetchUrl(input.resource.url); + if (fetchUrl === null) { + return yield* new AssetGitHubMediaUrlValidationError({}); + } + claims = { + version: 1, + kind: "github-media", + url: fetchUrl, + cwd: input.resource.cwd, + expiresAt, + }; + fileName = githubMediaFileName(fetchUrl); + break; + } } const secretStore = yield* ServerSecretStore.ServerSecretStore; @@ -776,6 +810,15 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( : null; } + if (claims.kind === "github-media") { + return { + kind: "github-media", + url: claims.url, + cwd: claims.cwd, + expiresAt: claims.expiresAt, + } satisfies ResolvedAsset; + } + if (claims.kind === "native-app-icon") { const nativeAppIconResolver = yield* NativeAppIconResolver.NativeAppIconResolver; const iconPath = yield* nativeAppIconResolver.resolve(claims.app); diff --git a/apps/server/src/assets/GitHubMediaFetch.test.ts b/apps/server/src/assets/GitHubMediaFetch.test.ts new file mode 100644 index 0000000000..bc9893750a --- /dev/null +++ b/apps/server/src/assets/GitHubMediaFetch.test.ts @@ -0,0 +1,155 @@ +import { expect, it } from "@effect/vitest"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HttpClient, HttpClientResponse, HttpServerResponse } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import { githubMediaResponse } from "./GitHubMediaFetch.ts"; + +const github = Layer.mock(GitHubCli.GitHubCli)({ + execute: (input) => { + expect(input.env?.GH_DEBUG).toBe(""); + return Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "private-token", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }); + }, +}); +const asset = { + url: "https://github.com/user-attachments/assets/abc-123", + cwd: "/repo", + expiresAt: 60_000, +}; + +it.effect("strips credentials on signed CDN redirects and streams video ranges", () => { + const requests: Array<{ url: string; auth: string | undefined; range: string | undefined }> = []; + return Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + const response = yield* githubMediaResponse( + { ...asset, expiresAt: now + 60_000 }, + { range: "bytes=2-4", cookie: "private-cookie", authorization: "client-token" }, + ); + expect(response.status).toBe(206); + expect(response.headers["content-range"]).toBe("bytes 2-4/10"); + expect(response.headers["cache-control"]).toBe("private, max-age=60"); + expect(yield* Effect.promise(() => HttpServerResponse.toWeb(response).text())).toBe("abc"); + expect(requests).toEqual([ + { url: asset.url, auth: "Bearer private-token", range: "bytes=2-4" }, + { + url: "https://private-user-images.githubusercontent.com/image?signature=opaque", + auth: undefined, + range: "bytes=2-4", + }, + ]); + }).pipe( + Effect.provide(github), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + expect(request.headers.cookie).toBeUndefined(); + requests.push({ + url: request.url, + auth: request.headers.authorization, + range: request.headers.range, + }); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + requests.length === 1 + ? new Response(null, { + status: 302, + headers: { + location: + "https://private-user-images.githubusercontent.com/image?signature=opaque", + }, + }) + : new Response("abc", { + status: 206, + headers: { + "content-type": "video/mp4", + "content-range": "bytes 2-4/10", + "content-length": "3", + }, + }), + ), + ); + }), + ), + Effect.scoped, + ); +}); + +for (const location of [ + "https://127.0.0.1/private", + "https://internal.example/private", + "http://raw.githubusercontent.com/x", + "https://raw.githubusercontent.com:8443/x", + "https://user:password@raw.githubusercontent.com/x", +]) { + it.effect(`refuses an unsafe media redirect: ${location}`, () => { + let requests = 0; + return githubMediaResponse(asset, {}).pipe( + Effect.tap((response) => + Effect.sync(() => { + expect(response.status).toBe(502); + expect(requests).toBe(1); + }), + ), + Effect.provide(github), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + requests++; + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(null, { status: 302, headers: { location } }), + ), + ); + }), + ), + Effect.scoped, + ); + }); +} + +for (const scenario of [ + { type: "text/html", status: 200, expected: 415 }, + { type: "image/svg+xml", status: 200, expected: 200 }, + { type: "text/html", status: 404, expected: 404 }, + { type: "text/html", status: 503, expected: 502 }, +]) { + it.effect(`handles media content type ${scenario.type} and status ${scenario.status}`, () => + githubMediaResponse(asset, {}).pipe( + Effect.tap((response) => + Effect.sync(() => { + expect(response.status).toBe(scenario.expected); + expect(response.headers["x-content-type-options"]).toBe("nosniff"); + if (scenario.type === "image/svg+xml") + expect(response.headers["content-security-policy"]).toContain("sandbox"); + else expect(response.headers["content-length"]).toBeUndefined(); + }), + ), + Effect.provide(github), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(null, { + status: scenario.status, + headers: { "content-type": scenario.type, "content-length": "200" }, + }), + ), + ), + ), + ), + Effect.scoped, + ), + ); +} diff --git a/apps/server/src/assets/GitHubMediaFetch.ts b/apps/server/src/assets/GitHubMediaFetch.ts new file mode 100644 index 0000000000..7a715eb491 --- /dev/null +++ b/apps/server/src/assets/GitHubMediaFetch.ts @@ -0,0 +1,191 @@ +import Mime from "@effect/platform-node/Mime"; +import { githubMediaFileName } from "@t3tools/shared/githubMedia"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, + HttpServerResponse, + type HttpClientResponse, +} from "effect/unstable/http"; + +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; + +/** + * Exactly the hosts the credential is for. Everything a redirect leads to — the presigned + * object stores GitHub hands assets off to — authorizes with its own signature, and some of + * them reject a request that also carries a bearer token. + */ +const CREDENTIALED_HOSTS = new Set([ + "github.com", + "www.github.com", + "raw.githubusercontent.com", + "media.githubusercontent.com", +]); +const isCredentialedHost = (url: string) => { + try { + return CREDENTIALED_HOSTS.has(new URL(url).hostname.toLowerCase()); + } catch { + return false; + } +}; + +/** GitHub answers an asset request with a 302 to a signed object URL that needs no credential. */ +const MAX_REDIRECTS = 3; +/** Following the redirect here, rather than in `fetch`, is what keeps the token on GitHub. */ +const MANUAL_REDIRECT: RequestInit = { redirect: "manual" }; +/** Passed through so a seek in a long video costs one upstream range request, not a full download. */ +const FORWARDED_REQUEST_HEADERS = ["range", "if-range"] as const; +const FORWARDED_RESPONSE_HEADERS = [ + "content-type", + "content-length", + "content-range", + "accept-ranges", + "etag", + "last-modified", +] as const; +/** A pull request embeds pictures and recordings. Anything else is not served from our origin. */ +const MEDIA_CONTENT_TYPE_PATTERN = /^(?:image|video|audio)\/[\w!#$&^.+-]+$/i; +const SVG_CONTENT_TYPE = "image/svg+xml"; +// An SVG is a document: same policy the asset route gives a workspace SVG, so one embedded in a +// body cannot run script against this origin. +const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; + +const githubToken = Effect.fn("GitHubMediaFetch.githubToken")(function* (input: { + readonly cwd: string; + readonly host: string; +}) { + // Resolve the active credential for each request: an account switch or logout must not + // reuse a token retained by a previous media request. + const github = yield* GitHubCli.GitHubCli; + // No credential is a normal state: a public asset still loads, and a private one fails the way + // it does in a browser that is not signed in. + const token = yield* github + .execute({ + cwd: input.cwd, + args: ["auth", "token", "--hostname", input.host], + env: { GH_DEBUG: "" }, + }) + .pipe( + Effect.map((output) => output.stdout.trim()), + Effect.orElseSucceed(() => ""), + ); + // A login or recovered CLI failure must take effect on the next media request. + if (token.length === 0) return null; + return Redacted.make(token); +}); + +/** + * Follows GitHub's redirect to the signed object itself, and never carries the credential off + * GitHub: the object URL authorizes with its own signature, and the store it lives in has no + * business seeing a token. + */ +const fetchFollowingRedirects = Effect.fn("GitHubMediaFetch.fetchFollowingRedirects")(function* ( + url: string, + headers: Record, + token: Redacted.Redacted | null, +) { + const httpClient = HttpClient.withScope(yield* HttpClient.HttpClient); + let target = url; + for (let hop = 0; ; hop += 1) { + // The credential rides only on a request to GitHub itself. A redirect leads to a signed + // object URL that authorizes on its own, and the store it lives in has no business seeing + // a token — deciding that from the target, not from the hop count, is what makes it so. + const authorization = + token !== null && isCredentialedHost(target) ? `Bearer ${Redacted.value(token)}` : null; + const response: HttpClientResponse.HttpClientResponse = yield* httpClient + .execute( + HttpClientRequest.get(target).pipe( + HttpClientRequest.setHeaders({ + ...headers, + // The bytes are streamed straight through, so never let an encoding layer in. + "accept-encoding": "identity", + ...(authorization === null ? {} : { authorization }), + }), + ), + ) + .pipe(Effect.provideService(FetchHttpClient.RequestInit, MANUAL_REDIRECT)); + const location = response.headers.location; + if (response.status < 300 || response.status >= 400) return response; + // A chain this long is not GitHub answering with bytes, and its body is not the media. + if (!location || hop >= MAX_REDIRECTS) return null; + const next = new URL(location, target); + const host = next.hostname.toLowerCase(); + const knownHost = + CREDENTIALED_HOSTS.has(host) || + host.endsWith(".githubusercontent.com") || + /^github-production-(?:user-asset|repository-file|release-asset)-[a-z0-9-]+\.s3\.amazonaws\.com$/.test( + host, + ); + if ( + next.protocol !== "https:" || + next.port !== "" || + next.username || + next.password || + !knownHost + ) + return null; + target = next.toString(); + } +}); + +/** + * Serves media a pull request body points at on GitHub through the `gh` credential, which is the + * only thing that distinguishes a readable private attachment from a 404. + */ +export const githubMediaResponse = Effect.fn("GitHubMediaFetch.githubMediaResponse")(function* ( + asset: { readonly url: string; readonly cwd: string; readonly expiresAt: number }, + requestHeaders: Record, +) { + // Both media hosts are served by github.com's account, which is the host `gh` stores it under. + const token = yield* githubToken({ cwd: asset.cwd, host: "github.com" }); + const forwarded: Record = {}; + for (const name of FORWARDED_REQUEST_HEADERS) { + const value = requestHeaders[name]; + if (value !== undefined) forwarded[name] = value; + } + const response = yield* fetchFollowingRedirects(asset.url, forwarded, token); + // An upload GitHub hosts never changes under its URL, so the only thing a cached copy must + // not outlive is the signed URL that granted it — which is the same bound the URL itself has. + const remainingSeconds = Math.floor((asset.expiresAt - (yield* Clock.currentTimeMillis)) / 1000); + const headers: Record = { + "cache-control": + remainingSeconds > 0 ? `private, max-age=${remainingSeconds}` : "private, no-store", + "x-content-type-options": "nosniff", + }; + if (response === null) return HttpServerResponse.empty({ status: 502, headers }); + // An upstream refusal is the client's answer, not this server's fault; only a broken hop is. + // It carries none of the upstream entity headers: a `content-length` with no body behind it + // holds the connection open until the browser gives up on it. + if (response.status >= 400) { + return HttpServerResponse.empty({ + status: response.status >= 500 ? 502 : response.status, + headers, + }); + } + // Only pictures and recordings leave this origin, and never on GitHub's word alone: the raw + // host labels every committed binary `application/octet-stream`, so the name decides those. + const upstreamType = + response.headers["content-type"]?.split(";", 1)[0]?.trim().toLowerCase() ?? ""; + const contentType = MEDIA_CONTENT_TYPE_PATTERN.test(upstreamType) + ? upstreamType + : (Mime.getType(githubMediaFileName(asset.url))?.toLowerCase() ?? ""); + if (!MEDIA_CONTENT_TYPE_PATTERN.test(contentType)) { + return HttpServerResponse.empty({ status: 415, headers }); + } + for (const name of FORWARDED_RESPONSE_HEADERS) { + const value = response.headers[name]; + if (value !== undefined) headers[name] = value; + } + headers["content-type"] = contentType; + if (contentType === SVG_CONTENT_TYPE) { + headers["content-security-policy"] = SVG_CONTENT_SECURITY_POLICY; + } + return HttpServerResponse.stream(response.stream, { + status: response.status, + headers, + contentType, + }); +}); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 1da31ee016..62e47fb8be 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -29,6 +29,7 @@ import { OtlpTracer, OtlpSerialization } from "effect/unstable/observability"; import * as ServerConfig from "./config.ts"; import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts"; +import { githubMediaResponse } from "./assets/GitHubMediaFetch.ts"; import { statMediaFile, streamMediaFile, type OpenMediaFile } from "./assets/MediaFile.ts"; import { ATTACHMENT_UPLOAD_ROUTE_PREFIX, @@ -396,6 +397,17 @@ export const assetRouteLayer = HttpRouter.add( if (!asset) { return HttpServerResponse.text("Not Found", { status: 404 }); } + if (asset.kind === "github-media") { + return yield* githubMediaResponse(asset, request.headers).pipe( + Effect.tapError(() => Effect.logWarning("Failed to fetch GitHub media.")), + Effect.orElseSucceed(() => + HttpServerResponse.empty({ + status: 502, + headers: { "cache-control": "private, no-store", "x-content-type-options": "nosniff" }, + }), + ), + ); + } return yield* assetFileResponse( asset, request.method === "GET" ? request.headers.range : undefined, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 247b2da4cb..435769a838 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -174,6 +174,7 @@ import * as VcsDriver from "./vcs/VcsDriver.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; +import * as GitHubCli from "./sourceControl/GitHubCli.ts"; import * as VcsProcess from "./vcs/VcsProcess.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import * as ReviewService from "./review/ReviewService.ts"; @@ -1237,7 +1238,7 @@ const buildAppUnderTest = (options?: { Layer.provideMerge(ServerSecretStore.layer), Layer.provide(workspaceAndProjectServicesLayer), Layer.provideMerge(FetchHttpClient.layer), - Layer.provide(VcsProcess.layer), + Layer.provide(GitHubCli.layer.pipe(Layer.provideMerge(VcsProcess.layer))), Layer.provide(layerConfig), ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 5f5952164c..a044d6dec4 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -487,8 +487,10 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // Core Services Layer.provideMerge(ServerSettingsLayerLive), Layer.provideMerge(CheckpointingLayerLive), + // `GitHubCli` is the registry's own instance, exposed because the asset route fetches + // GitHub-hosted pull request media with the repository's credential. Layer.provideMerge( - Layer.mergeAll(SourceControlProviderRegistryLayerLive, PullRequestServiceLive), + Layer.mergeAll(SourceControlProviderRegistryLayerLive, PullRequestServiceLive, GitHubCli.layer), ), Layer.provideMerge(GitLayerLive), Layer.provideMerge(VcsLayerLive), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index e03aeb85f1..d840552497 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -3359,6 +3359,8 @@ const makeWsRpcLayer = ( if ( input.resource._tag === "attachment" || input.resource._tag === "native-app-icon" || + // GitHub media carries its repository context. + input.resource._tag === "github-media" || (input.resource._tag === "media-file" && path.isAbsolute(input.resource.path) && isDriveOrPosixAbsolutePath(input.resource.path)) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 8544041f7c..e84d392720 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -35,6 +35,7 @@ import type { ServerProviderSkill, ThreadPullRequestKey, } from "@t3tools/contracts"; +import { githubMediaFetchUrl } from "@t3tools/shared/githubMedia"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -217,6 +218,9 @@ interface ChatMarkdownProps { extraRemarkPlugins?: NonNullable; /** Renders a `t3-context://` link as a chip; without it the link shows its label as text. */ renderContextReference?: ((reference: ChatMarkdownContextReference) => ReactNode) | undefined; + /** Loads GitHub-hosted media through `cwd`'s GitHub credential, which a private repository's + uploads need; without it those images and videos load unauthenticated and 404. */ + githubMedia?: boolean | undefined; } export interface ChatMarkdownContextReference { @@ -1610,7 +1614,7 @@ 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-media" } >; readonly kind?: "image" | "video"; readonly alt: string; @@ -1620,6 +1624,18 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props /** Caps the image height while keeping its aspect ratio. */ readonly maxHeightRem?: number | undefined; readonly style?: CSSProperties | undefined; + readonly className?: string | undefined; + /** Sanitized authored attributes (`id`, `align`, …) that fragment links and layout rely on. */ + readonly imageProps?: + | Omit, "src" | "alt" | "className" | "style"> + | undefined; + /** Where the media also lives on the web, for the failure state's escape hatch. */ + readonly originalUrl?: string | undefined; + /** The workspace media frame, on by default; off for media that keeps the author's own box. */ + readonly framed?: boolean | undefined; + /** Loaded instead of the failure state when no URL can be signed, such as against a server + too old to know this resource. Only safe when the client can reach it directly. */ + readonly fallbackSrc?: string | undefined; readonly workspaceRoot?: string | undefined; readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; }) { @@ -1632,9 +1648,19 @@ 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 src = assetUrl._tag === "Success" ? assetUrl.url + (props.srcFragment ?? "") : null; + const reference = path + ? mediaFileReference(path, props.workspaceRoot) + : props.originalUrl + ? mediaUrlReference(props.originalUrl) + : undefined; + const relativePath = reference?.kind === "file" ? reference.relativePath : undefined; + const fallbackSrc = assetUrl._tag === "Failure" ? props.fallbackSrc : undefined; + const src = + assetUrl._tag === "Success" + ? assetUrl.url + (props.srcFragment ?? "") + : fallbackSrc === undefined + ? null + : fallbackSrc + (props.srcFragment ?? ""); // 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. @@ -1651,9 +1677,11 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props kind: props.kind ?? "image", name: props.alt || (props.kind ?? "image"), src, - asset: { environmentId: props.environmentId, resource }, + ...(fallbackSrc === undefined + ? { asset: { environmentId: props.environmentId, resource } } + : {}), ...(reference ? { reference } : {}), - ...(relativePath && resource._tag !== "attachment" + ...(relativePath && (resource._tag === "media-file" || resource._tag === "workspace-file") ? { onOpenFile: () => useRightPanelStore @@ -1671,9 +1699,10 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props ); @@ -2262,6 +2296,7 @@ function useChatMarkdownState({ imageBaseDir, onImageExpand, renderContextReference, + githubMedia = false, }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); const [localMediaPreview, setLocalMediaPreview] = useState(null); @@ -2668,6 +2703,7 @@ function useChatMarkdownState({ environmentId, expandMedia, fileLinkChip, + githubMedia, renderContextReference, imageBaseDir, inlineCodeFileLinkMetaByText, @@ -2696,6 +2732,7 @@ function useChatMarkdownState({ environmentId, expandMedia, fileLinkChip, + githubMedia, renderContextReference, imageBaseDir, inlineCodeFileLinkMetaByText, @@ -3092,10 +3129,16 @@ const CHAT_MARKDOWN_COMPONENTS = { ); }, - img: function MarkdownImg({ node, title, src, alt, ...props }) { - const { expandMedia, cwd, imageBaseDir, threadRef, renderContextReference } = use( - ChatMarkdownRendererContext, - ); + img: function MarkdownImage({ node, title, src, alt, ...props }) { + const { + expandMedia, + cwd, + environmentId, + githubMedia, + imageBaseDir, + threadRef, + renderContextReference, + } = use(ChatMarkdownRendererContext); const imageExpand = use(MarkdownLinkContext) ? undefined : expandMedia; const contextReference = typeof src === "string" ? parseComposerContextHref(src) : null; if (contextReference) { @@ -3121,6 +3164,39 @@ const CHAT_MARKDOWN_COMPONENTS = { const authoredSizeStyle = authoredImageSizeStyle(width, height); const imageSource = classifyMarkdownImageSource(classifiedSrc, imageBaseDir ?? cwd); const kind = mediaKindFromPath(classifiedSrc) ?? "image"; + const directUri = imageSource._tag === "Direct" ? imageSource.uri : null; + const githubMediaUrl = + directUri === null ? null : githubMediaFetchUrl(resolveProtocolRelativeMediaUrl(directUri)); + if ( + githubMedia && + cwd !== undefined && + environmentId !== null && + directUri !== null && + githubMediaUrl !== null + ) { + return ( + + ); + } if (imageSource._tag === "Direct") { const mediaSrc = resolveProtocolRelativeMediaUrl(imageSource.uri); const originalUrl = diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx index 9a4b63044a..6ffb54569e 100644 --- a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx +++ b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx @@ -1,8 +1,11 @@ import { ExternalLinkIcon, PaperclipIcon } from "lucide-react"; -import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; +import { markdownImageSourceFragment } from "@t3tools/client-runtime/markdown-images"; +import { githubMediaFetchUrl } from "@t3tools/shared/githubMedia"; +import type { AssetResource, EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; import { createContext, useContext, useMemo } from "react"; import type { Options as ReactMarkdownOptions } from "react-markdown"; +import { useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls"; import { cn } from "~/lib/utils"; import { PULL_REQUESTS_PANEL_REF } from "~/rightPanelStore"; @@ -15,6 +18,46 @@ export const PullRequestMarkdownContext = createContext<{ threadRef: ScopedThreadRef | null; } | null>(null); +/** + * A video GitHub hosts for the repository. It plays through a signed asset URL the server + * fetches with the repository's GitHub credential, which is what a private repository's + * uploads need; the URL is re-signed on retry, so a stale one recovers without a reload. + */ +function PullRequestGitHubVideo({ + environmentId, + cwd, + url, + fetchUrl, +}: { + environmentId: EnvironmentId; + cwd: string; + /** What the body authored, which is what "Open original" should reach. */ + url: string; + /** The canonical GitHub media URL: a `blob` link addresses the page, not the bytes. */ + fetchUrl: string; +}) { + const resource = useMemo( + () => ({ _tag: "github-media", cwd, url: fetchUrl }), + [cwd, fetchUrl], + ); + const assetUrl = useAssetUrlState(environmentId, resource); + const refreshAssetUrl = useAssetUrlRefresh(environmentId, resource); + // A server too old to sign this resource, or one with no route to GitHub, still leaves a + // public repository's video playing exactly as it did before. + const src = + assetUrl._tag === "Success" ? assetUrl.url : assetUrl._tag === "Failure" ? fetchUrl : null; + return ( + + ); +} + /** Renders PR uploads inline, with retry and an original link when video playback fails. */ export function PullRequestMarkdown({ text, @@ -57,6 +100,19 @@ export function PullRequestMarkdown({ pullRequestPanelRef={resolvedThreadRef ?? PULL_REQUESTS_PANEL_REF} environmentId={environmentId} extraRemarkPlugins={extraRemarkPlugins} + githubMedia + /> + ); + } + const githubMediaUrl = segment.media === "video" ? githubMediaFetchUrl(segment.url) : null; + if (githubMediaUrl !== null) { + return ( + ); } diff --git a/packages/contracts/src/assets.ts b/packages/contracts/src/assets.ts index 6447aab225..cd5d175730 100644 --- a/packages/contracts/src/assets.ts +++ b/packages/contracts/src/assets.ts @@ -50,6 +50,13 @@ export const AssetResource = Schema.Union([ Schema.TaggedStruct("native-app-icon", { app: ToolActivityNativeAppReference, }), + // An upload a pull request body points at on GitHub. A private repository serves these only + // to a request that carries a credential, which the client has none of, so the server fetches + // them with the `gh` credential the repository at `cwd` authenticates with. + Schema.TaggedStruct("github-media", { + cwd: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)), + url: TrimmedNonEmptyString.check(Schema.isMaxLength(2048)), + }), ]); export type AssetResource = typeof AssetResource.Type; @@ -285,6 +292,15 @@ export class AssetSigningKeyLoadError extends Schema.TaggedError()( + "AssetGitHubMediaUrlValidationError", + {}, +) { + override get message(): string { + return "Only media hosted by GitHub can be fetched with a GitHub credential."; + } +} + export const AssetAccessError = Schema.Union([ AssetWorkspaceContextNotFoundError, AssetWorkspaceContextResolutionError, @@ -298,6 +314,7 @@ export const AssetAccessError = Schema.Union([ AssetProjectFaviconResolutionError, AssetProjectFaviconInspectionError, AssetProjectFaviconNotFoundError, + AssetGitHubMediaUrlValidationError, AssetSigningKeyLoadError, ]); export type AssetAccessError = typeof AssetAccessError.Type; diff --git a/packages/shared/package.json b/packages/shared/package.json index 2b113f0636..4aa98c2eef 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -43,6 +43,10 @@ "types": "./src/git.ts", "import": "./src/git.ts" }, + "./githubMedia": { + "types": "./src/githubMedia.ts", + "import": "./src/githubMedia.ts" + }, "./sourceControl": { "types": "./src/sourceControl.ts", "import": "./src/sourceControl.ts" diff --git a/packages/shared/src/githubMedia.ts b/packages/shared/src/githubMedia.ts new file mode 100644 index 0000000000..4226db8250 --- /dev/null +++ b/packages/shared/src/githubMedia.ts @@ -0,0 +1,70 @@ +/** + * Media a pull request body points at on GitHub's own hosts. In a private repository GitHub + * answers an unauthenticated request for one with 404 — a screenshot dropped into a description + * becomes `github.com/user-attachments/assets/`, and a file committed alongside the code + * becomes a `raw.githubusercontent.com` path — so the renderer, which carries no GitHub session, + * draws a broken image where the reviewer expects the evidence. The server can fetch these with + * the `gh` credential and hand the bytes back. + * + * Only hosts where a credential is what decides the answer belong here. `objects.githubusercontent.com` + * and the `private-user-images` links GitHub's own HTML carries are already signed and load on their + * own, and a token does nothing for them once that signature expires. + */ + +const RAW_HOST = "raw.githubusercontent.com"; +/** Git LFS pointers resolve here, which is where an LFS-tracked screenshot's bytes live. */ +const LFS_HOST = "media.githubusercontent.com"; +const ATTACHMENT_PATH_PATTERN = /^\/user-attachments\/assets\/[\w-]+$/u; +/** What GitHub wrote into a body before `user-attachments`; older descriptions still carry it. */ +const LEGACY_ATTACHMENT_PATH_PATTERN = /^\/[^/]+\/[^/]+\/assets\/\d+\/[\w-]+$/u; +/** `blob` and `raw` both address file bytes; `raw` is the one the token is honoured on. */ +const REPOSITORY_FILE_PATTERN = /^\/([^/]+)\/([^/]+)\/(?:raw|blob)\/(.*[^/])$/u; + +/** Port, userinfo, and fragment say nothing about which bytes GitHub will serve. */ +function canonicalUrl(host: string, url: URL): string { + return `https://${host}${url.pathname}${url.search}`; +} + +/** + * The URL to fetch with a GitHub credential for `source`, or null when the source is not + * GitHub-hosted media — those keep loading directly, exactly as they do today. + */ +export function githubMediaFetchUrl(source: string): string | null { + let url: URL; + try { + url = new URL(source); + } catch { + return null; + } + if (url.protocol !== "https:") return null; + const host = url.hostname.toLowerCase(); + if (host === RAW_HOST || host === LFS_HOST) return canonicalUrl(host, url); + if (host !== "github.com" && host !== "www.github.com") return null; + if ( + ATTACHMENT_PATH_PATTERN.test(url.pathname) || + LEGACY_ATTACHMENT_PATH_PATTERN.test(url.pathname) + ) { + return `https://github.com${url.pathname}`; + } + const repositoryFile = REPOSITORY_FILE_PATTERN.exec(url.pathname); + // `?raw=true` is how the web UI spells "the bytes, not the page"; the raw host needs no query. + return repositoryFile + ? `https://${RAW_HOST}/${repositoryFile[1]}/${repositoryFile[2]}/${repositoryFile[3]}` + : null; +} + +/** + * Last path segment, for the signed URL's display name. A percent sequence GitHub accepts but + * `decodeURIComponent` rejects is left encoded rather than failing the whole asset. + */ +export function githubMediaFileName(fetchUrl: string): string { + const segment = new URL(fetchUrl).pathname.split("/").pop() ?? ""; + let decoded: string; + try { + decoded = decodeURIComponent(segment); + } catch { + decoded = segment; + } + const name = decoded.replace(/[\p{Cc}\\/]/gu, ""); + return name.length > 0 ? name : "github-media"; +} From eee255eb6c0f66acfa048cdc58aa561ef71d15e0 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Fri, 18 Sep 2026 09:17:04 -0600 Subject: [PATCH 2/3] fix(media): reject malformed redirects and document private access --- apps/server/src/assets/GitHubMediaFetch.test.ts | 1 + apps/server/src/assets/GitHubMediaFetch.ts | 3 ++- docs/user/source-control.md | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/server/src/assets/GitHubMediaFetch.test.ts b/apps/server/src/assets/GitHubMediaFetch.test.ts index bc9893750a..0b63f7ebc9 100644 --- a/apps/server/src/assets/GitHubMediaFetch.test.ts +++ b/apps/server/src/assets/GitHubMediaFetch.test.ts @@ -88,6 +88,7 @@ for (const location of [ "https://internal.example/private", "http://raw.githubusercontent.com/x", "https://raw.githubusercontent.com:8443/x", + "https://[", "https://user:password@raw.githubusercontent.com/x", ]) { it.effect(`refuses an unsafe media redirect: ${location}`, () => { diff --git a/apps/server/src/assets/GitHubMediaFetch.ts b/apps/server/src/assets/GitHubMediaFetch.ts index 7a715eb491..c292bd1b77 100644 --- a/apps/server/src/assets/GitHubMediaFetch.ts +++ b/apps/server/src/assets/GitHubMediaFetch.ts @@ -111,7 +111,8 @@ const fetchFollowingRedirects = Effect.fn("GitHubMediaFetch.fetchFollowingRedire if (response.status < 300 || response.status >= 400) return response; // A chain this long is not GitHub answering with bytes, and its body is not the media. if (!location || hop >= MAX_REDIRECTS) return null; - const next = new URL(location, target); + const next = URL.parse(location, target); + if (next === null) return null; const host = next.hostname.toLowerCase(); const knownHost = CREDENTIALED_HOSTS.has(host) || diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 5f831a6606..4dceb462e4 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -89,6 +89,8 @@ merge. Reviews open as tabs in the right panel, and your filters, search, and so you return. Command-click (Control-click on Windows and Linux) a pull request number in the sidebar to open it in your browser instead. GitLab calls these merge requests. +Images and videos hosted on GitHub can load from private pull requests using the GitHub account signed in on the repository’s server. For a remote environment, run `gh auth login` on that machine with an account that can read the repository. GitHub Enterprise media is not supported yet. + Press Command+Enter (Control+Enter on Windows and Linux) to submit a pull request comment. Long comments start as previews. Bot reports and resolved or dismissed discussions are grouped separately; expand a group and load older comments to read its history. From 9bcbf71707ee9c5ed78feaf7b33276c905d087b1 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Fri, 18 Sep 2026 09:18:36 -0600 Subject: [PATCH 3/3] docs: record authenticated PR media adoption --- .agents/upstream-review.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.agents/upstream-review.md b/.agents/upstream-review.md index 184c54c12b..320a0e60bd 100644 --- a/.agents/upstream-review.md +++ b/.agents/upstream-review.md @@ -200,3 +200,4 @@ The review cursor stays unchanged; unrelated sources in this range remain unclas | Provider account usage and discovery / `6d1d549441be84f19696ab59ed7e2fbf305280d4` | `2db675aeffd9cb1e8b5ad76ddd018433b45b02e9` (#11485), `7931227977ca3e6f3354a63467caa634ab79796f` (#11811), `2a264adc6f6c65f98d4273acaa1af567eca83f2a` (#11345), `8b1ea4dd2465f3cf3cfa02d6878beaa1ec22e71a` (#11741) | First three adopted: scan all configured account homes with canonical deduplication and Pylon-relative Claude home semantics; avoid subscription-triggered provider refreshes; diagnose the configured Codex executable. WSL source already covered: Pylon retains Node-based staged and mounted runtimes, whose existing preflight discovers Node and forwards PATH. Standalone-runtime prerequisite #11511 remains outside this disposition. Added regression tests; no unused probe port. Cursor unchanged. | Cycle [#611](https://github.com/pylon-code/pylon/issues/611), [PR #613](https://github.com/pylon-code/pylon/pull/613); separate Antigravity adversarial reviews, 374 focused tests, scoped checks and browser before/after evidence. Real WSL unavailable on this macOS host. | | PR cache reuse and quota efficiency / `6d1d549441be84f19696ab59ed7e2fbf305280d4` | `f4600d77dd7c2fa9f10e8f4500882e427fcc7e26` (#11888), `d612d12b8bb278af97b0029f8d48b2403a5f9ee6` (#12168) | Adopted applicable behavior: scoped cache revisions, canonical detail reuse, deduplicated refreshes and bounded quota probes. Preserve Pylon environment fences and observed GraphQL consumption. Exclude account-router-specific hooks and Forgejo tests: those dependencies are absent in Pylon; their independent source decisions remain open. No cross-environment credential routing introduced. | Cycle [#625](https://github.com/pylon-code/pylon/issues/625), [PR #626](https://github.com/pylon-code/pylon/pull/626); direct adversarial self-review, focused backend/client regressions and scoped types/lint. | +| Authenticated GitHub PR media / `6d1d549441be84f19696ab59ed7e2fbf305280d4` | `32e8b2584556c0c55ce0d1d8f72b506cb771d60d` (#11706) | Adopted with Pylon path/context guards retained. Signed assets use the active server GitHub CLI account per request; bounded GitHub/CDN redirects strip credentials off credentialed hosts, stream ranges, constrain MIME and sandbox SVG. Older servers retain public fallback. GitHub Enterprise media unsupported. | Cycle [#625](https://github.com/pylon-code/pylon/issues/625), [PR #629](https://github.com/pylon-code/pylon/pull/629); direct adversarial self-review, credential/redirect/range regressions, scoped checks and browser evidence. |