diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 6a3787fba7..869531152a 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -437,6 +437,8 @@ // as a direct dep to guarantee installation even when transitive // resolution fails (corrupted cache, dedup edge cases). "debug", + // The engine loads it at runtime; tsup keeps it external in the CLI bundle. + "undici", // @puppeteer/browsers' optional peer: its zip fallback when no system `unzip` exists. "yauzl", // Loaded through the optional package loader for Gemini vision captioning. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 071c327f59..0cb4fadc6e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -841,6 +841,8 @@ jobs: - name: Install dependencies if: runner.os == 'Windows' run: bun install --frozen-lockfile --ignore-scripts --linker=hoisted + - run: bun run --filter '@hyperframes/{parsers,lint,studio-server}' build + - run: bun run --cwd packages/core build - run: bun run --cwd packages/cli test src/utils/npxCommand.test.ts src/commands/skills.test.ts sdk-tests: diff --git a/bun.lock b/bun.lock index a4d1b4f44e..46a60d321e 100644 --- a/bun.lock +++ b/bun.lock @@ -69,6 +69,7 @@ "hyperframes-localize-fonts": "./bin/hyperframes-localize-fonts.mjs", }, "dependencies": { + "undici": "7.25.0", "@hono/node-server": "^2.0.5", "@puppeteer/browsers": "^3.2.2", "acorn": "^8.17.0", @@ -165,6 +166,7 @@ "name": "@hyperframes/engine", "version": "0.8.210", "dependencies": { + "undici": "7.25.0", "@hono/node-server": "^2.0.5", "@hyperframes/core": "workspace:^", "@hyperframes/parsers": "workspace:^", diff --git a/packages/cli/package.json b/packages/cli/package.json index 59e1eee240..c56fec9a8c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -70,6 +70,7 @@ "open": "^10.0.0", "postcss": "^8.5.8", "puppeteer-core": "^25.10.0", + "undici": "7.25.0", "yauzl": "^3.4.0" }, "devDependencies": { diff --git a/packages/cli/src/utils/hostAnswers.ts b/packages/cli/src/utils/hostAnswers.ts index fd5262d9b0..bcbfa87a8f 100644 --- a/packages/cli/src/utils/hostAnswers.ts +++ b/packages/cli/src/utils/hostAnswers.ts @@ -1,13 +1,8 @@ import { promises as dns } from "node:dns"; +import { fetchGoesThroughEnvProxy } from "@hyperframes/core/env-proxy"; const PROBE_TIMEOUT_MS = 1000; -function fetchGoesThroughEnvProxy(): boolean { - if (!(process.env["HTTPS_PROXY"] ?? process.env["https_proxy"])?.trim()) return false; - const flags = [...process.execArgv, ...(process.env["NODE_OPTIONS"]?.split(/\s+/u) ?? [])]; - return process.env["NODE_USE_ENV_PROXY"] === "1" || flags.includes("--use-env-proxy"); -} - /** * Whether DNS answers for `host` via c-ares, one short try per nameserver. The system lookup * `fetch` makes cannot be aborted and holds even `process.exit`, so background requests ask this first. diff --git a/packages/cli/src/utils/publishProject.ts b/packages/cli/src/utils/publishProject.ts index 72facfad6e..e6b8b9cd48 100644 --- a/packages/cli/src/utils/publishProject.ts +++ b/packages/cli/src/utils/publishProject.ts @@ -1,3 +1,4 @@ +import { envProxyEnabled } from "@hyperframes/core/env-proxy"; import { basename, dirname, join, posix, relative, resolve } from "node:path"; import { closeSync, @@ -235,11 +236,7 @@ function proxySupportHint(): string { const proxyConfigured = ["HTTPS_PROXY", "HTTP_PROXY", "https_proxy", "http_proxy"].some((key) => Boolean(process.env[key]?.trim()), ); - const proxyEnabled = - process.env["NODE_USE_ENV_PROXY"] === "1" || - process.execArgv.includes("--use-env-proxy") || - process.env["NODE_OPTIONS"]?.split(/\s+/u).includes("--use-env-proxy") === true; - if (!proxyConfigured || proxyEnabled) return ""; + if (!proxyConfigured || envProxyEnabled()) return ""; return ( ". Proxy variables are set but ignored by Node fetch; if this network requires them, retry with " + "NODE_USE_ENV_PROXY=1 (Node 22.21+)" diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index 16981ef6e9..68c701678d 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -44,6 +44,7 @@ var __dirname = __hf_dirname(__filename);`, }, external: [ "puppeteer-core", + "undici", "puppeteer", "@puppeteer/browsers", // Native module — its platform binary (@img/sharp--) must be diff --git a/packages/core/package-subpaths.json b/packages/core/package-subpaths.json index 91e5d2a2d0..d901470b2a 100644 --- a/packages/core/package-subpaths.json +++ b/packages/core/package-subpaths.json @@ -74,6 +74,12 @@ "types": "./dist/removeTree.d.ts", "environments": ["bun", "node"] }, + "./env-proxy": { + "source": "./src/envProxy.ts", + "runtime": "./dist/envProxy.js", + "types": "./dist/envProxy.d.ts", + "environments": ["bun", "node"] + }, "./rename-retry": { "source": "./src/renameWithRetry.ts", "runtime": "./dist/renameWithRetry.js", diff --git a/packages/core/package.json b/packages/core/package.json index 80dab86d9c..f4c51f8241 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -86,6 +86,12 @@ "import": "./src/removeTree.ts", "types": "./src/removeTree.ts" }, + "./env-proxy": { + "bun": "./src/envProxy.ts", + "node": "./dist/envProxy.js", + "import": "./src/envProxy.ts", + "types": "./src/envProxy.ts" + }, "./rename-retry": { "bun": "./src/renameWithRetry.ts", "node": "./dist/renameWithRetry.js", @@ -660,6 +666,10 @@ "import": "./dist/removeTree.js", "types": "./dist/removeTree.d.ts" }, + "./env-proxy": { + "import": "./dist/envProxy.js", + "types": "./dist/envProxy.d.ts" + }, "./rename-retry": { "import": "./dist/renameWithRetry.js", "types": "./dist/renameWithRetry.d.ts" diff --git a/packages/core/src/envProxy.test.ts b/packages/core/src/envProxy.test.ts new file mode 100644 index 0000000000..e06968a576 --- /dev/null +++ b/packages/core/src/envProxy.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { envProxyEnabled, fetchGoesThroughEnvProxy } from "./envProxy.js"; + +describe("env proxy", () => { + it("needs a proxy variable and Node started with env-proxy support", () => { + const proxy = { HTTPS_PROXY: "http://proxy.test:3128" }; + expect(fetchGoesThroughEnvProxy({ ...proxy, NODE_USE_ENV_PROXY: "1" }, [])).toBe(true); + expect(fetchGoesThroughEnvProxy(proxy, ["--use-env-proxy"])).toBe(true); + expect( + fetchGoesThroughEnvProxy({ ...proxy, NODE_OPTIONS: "--no-warnings --use-env-proxy" }, []), + ).toBe(true); + expect( + fetchGoesThroughEnvProxy({ https_proxy: " http://proxy.test ", NODE_USE_ENV_PROXY: "1" }, []), + ).toBe(true); + expect(fetchGoesThroughEnvProxy(proxy, [])).toBe(false); + expect(fetchGoesThroughEnvProxy({ NODE_USE_ENV_PROXY: "1" }, [])).toBe(false); + expect(fetchGoesThroughEnvProxy({ HTTPS_PROXY: " ", NODE_USE_ENV_PROXY: "1" }, [])).toBe(false); + }); + + it("reports env-proxy support separately from the variables", () => { + expect(envProxyEnabled({ NODE_USE_ENV_PROXY: "1" }, [])).toBe(true); + expect(envProxyEnabled({}, [])).toBe(false); + }); +}); diff --git a/packages/core/src/envProxy.ts b/packages/core/src/envProxy.ts new file mode 100644 index 0000000000..9f3b677b28 --- /dev/null +++ b/packages/core/src/envProxy.ts @@ -0,0 +1,18 @@ +/** Whether `fetch` follows the proxy variables (`NODE_USE_ENV_PROXY=1` or `--use-env-proxy`). */ +export function envProxyEnabled( + env: NodeJS.ProcessEnv = process.env, + execArgv: readonly string[] = process.execArgv, +): boolean { + const flags = [...execArgv, ...(env["NODE_OPTIONS"]?.split(/\s+/u) ?? [])]; + return env["NODE_USE_ENV_PROXY"] === "1" || flags.includes("--use-env-proxy"); +} + +/** Whether `fetch` reaches the network through `HTTPS_PROXY`, which then does the name resolution. */ +export function fetchGoesThroughEnvProxy( + env: NodeJS.ProcessEnv = process.env, + execArgv: readonly string[] = process.execArgv, +): boolean { + return ( + Boolean((env["HTTPS_PROXY"] ?? env["https_proxy"])?.trim()) && envProxyEnabled(env, execArgv) + ); +} diff --git a/packages/engine/package.json b/packages/engine/package.json index 2549a9c9c9..43a27ad609 100644 --- a/packages/engine/package.json +++ b/packages/engine/package.json @@ -100,7 +100,8 @@ "hono": "^4.6.0", "linkedom": "^0.18.12", "puppeteer": "~25.11.0", - "puppeteer-core": "^25.11.0" + "puppeteer-core": "^25.11.0", + "undici": "7.25.0" }, "devDependencies": { "@types/node": "^25.0.10", diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 6949db251a..f818279faf 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -379,6 +379,7 @@ export { } from "./utils/notMediaPayload.js"; export { + _setDownloadFetchForTests, assertPublicHttpsUrl, isBlockedNetworkHost, downloadToTemp, diff --git a/packages/engine/src/services/audioMixer.test.ts b/packages/engine/src/services/audioMixer.test.ts index 1c01a17b66..8cb1902ef2 100644 --- a/packages/engine/src/services/audioMixer.test.ts +++ b/packages/engine/src/services/audioMixer.test.ts @@ -83,7 +83,11 @@ vi.mock("../utils/ffprobe.js", async (importOriginal) => { }); import { RATE_RANGE } from "@hyperframes/core/audio-automation"; +import { _setDownloadFetchForTests } from "../utils/urlDownloader.js"; import { AudioFxRenderError } from "./audioFxRender.js"; + +const stubFetch = (replacement: unknown): void => + _setDownloadFetchForTests(replacement as Parameters[0]); import { MAX_MIX_INPUTS, parseAudioElements, processCompositionAudio } from "./audioMixer.js"; import { encodeAac } from "./aacEncode.js"; @@ -156,6 +160,7 @@ describe("processCompositionAudio", () => { afterEach(() => { vi.unstubAllGlobals(); + _setDownloadFetchForTests(); runFfmpegMock.mockReset(); extractAudioMetadataMock.mockReset(); extractAudioMetadataMock.mockResolvedValue({ @@ -230,7 +235,7 @@ describe("processCompositionAudio", () => { const fetchMock = vi .fn() .mockResolvedValue(new Response("denied")); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const result = await processCompositionAudio( [ @@ -1914,7 +1919,7 @@ describe("processCompositionAudio", () => { const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-")); tempDirs.push(baseDir, workDir); const fetchMock = vi.fn().mockRejectedValue(new Error("downloaded")); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const url = "https://bucket.s3.amazonaws.com/cas/take.mov?X-Amz-Signature=s"; const result = await processCompositionAudio( diff --git a/packages/engine/src/services/videoFrameExtractor.errorClassification.test.ts b/packages/engine/src/services/videoFrameExtractor.errorClassification.test.ts index 7a70945c94..d325d66569 100644 --- a/packages/engine/src/services/videoFrameExtractor.errorClassification.test.ts +++ b/packages/engine/src/services/videoFrameExtractor.errorClassification.test.ts @@ -2,7 +2,14 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { safeDownloadUrlIdentity, UrlDownloadError } from "../utils/urlDownloader.js"; +import { + _setDownloadFetchForTests, + safeDownloadUrlIdentity, + UrlDownloadError, +} from "../utils/urlDownloader.js"; + +const stubFetch = (replacement: unknown): void => + _setDownloadFetchForTests(replacement as Parameters[0]); import { classifyFfmpegSpawnError, classifyVideoExtractionError, @@ -13,6 +20,7 @@ const tempDirs: string[] = []; afterEach(() => { vi.unstubAllGlobals(); + _setDownloadFetchForTests(); for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); }); @@ -62,8 +70,7 @@ describe("extractAllVideoFrames download failure metadata", () => { it("threads a query-free fingerprint, allowlisted host, and zero used retries", async () => { const source = "https://media.customer-cdn.example/private/clip.mp4?X-Amz-Signature=super-secret#fragment"; - vi.stubGlobal( - "fetch", + stubFetch( vi.fn().mockResolvedValue(new Response("denied")), ); @@ -97,7 +104,7 @@ describe("extractAllVideoFrames download failure metadata", () => { it("collapses an exhausted HTTP retry to status class and normalized host", async () => { const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 503 })); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const result = await extractAllVideoFrames( [ diff --git a/packages/engine/src/utils/urlDownloader.progress.test.ts b/packages/engine/src/utils/urlDownloader.progress.test.ts index a8e6619a36..8de7604967 100644 --- a/packages/engine/src/utils/urlDownloader.progress.test.ts +++ b/packages/engine/src/utils/urlDownloader.progress.test.ts @@ -2,7 +2,10 @@ import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { downloadToTemp } from "./urlDownloader.js"; +import { _setDownloadFetchForTests, downloadToTemp } from "./urlDownloader.js"; + +const stubFetch = (replacement: unknown): void => + _setDownloadFetchForTests(replacement as Parameters[0]); const dirs: string[] = []; @@ -12,7 +15,7 @@ beforeEach(() => { afterEach(() => { vi.useRealTimers(); - vi.unstubAllGlobals(); + _setDownloadFetchForTests(); for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); }); @@ -77,7 +80,7 @@ const fetchCalls = (fetchMock: { mock: { calls: unknown[] } }, count: number) => /** Starts a download whose every attempt gets a fresh `response()`, once the first fetch is made. */ async function startDownload(response: () => Response) { const fetchMock = vi.fn(async () => response()); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const outcome: { path?: string; error?: unknown } = {}; void downloadToTemp("https://cdn.example/media.mp4", tempDir()).then( (path) => (outcome.path = path), diff --git a/packages/engine/src/utils/urlDownloader.publicAddress.test.ts b/packages/engine/src/utils/urlDownloader.publicAddress.test.ts new file mode 100644 index 0000000000..32e02af5f5 --- /dev/null +++ b/packages/engine/src/utils/urlDownloader.publicAddress.test.ts @@ -0,0 +1,68 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const answers = vi.hoisted(() => ({ list: [] as Array<{ address: string; family: number }> })); +vi.mock("node:dns", () => { + const lookup = ( + _host: string, + _options: unknown, + callback: (error: null, addresses: typeof answers.list) => void, + ) => callback(null, answers.list); + return { default: { lookup }, lookup }; +}); + +import { downloadToTemp, fetchPublicHttpsText, UrlDownloadError } from "./urlDownloader.js"; + +const dirs: string[] = []; +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe("downloads refuse hosts that resolve to private addresses", () => { + it.each([ + ["a private IPv4 answer", [{ address: "10.1.2.3", family: 4 }]], + [ + "a link-local answer among public ones", + [ + { address: "93.184.216.34", family: 4 }, + { address: "169.254.169.254", family: 4 }, + ], + ], + ["a loopback IPv6 answer", [{ address: "::1", family: 6 }]], + ])("downloadToTemp rejects %s without retrying", async (_, list) => { + answers.list = list; + const dir = mkdtempSync(join(tmpdir(), "hf-public-address-")); + dirs.push(dir); + const error = await downloadToTemp("https://assets.example.test/a.png", dir).catch((e) => e); + expect(error).toBeInstanceOf(UrlDownloadError); + expect(error).toMatchObject({ kind: "http_rejected", retryable: false }); + }); + + it("fetchPublicHttpsText rejects the same way", async () => { + answers.list = [{ address: "192.168.0.7", family: 4 }]; + await expect( + fetchPublicHttpsText("https://assets.example.test/x.html", { maxBytes: 1024 }), + ).rejects.toMatchObject({ + kind: "http_rejected", + }); + }); + + it("keeps Node's own fetch when it goes through an env proxy, which resolves the name", async () => { + answers.list = [{ address: "10.1.2.3", family: 4 }]; + vi.stubEnv("HTTPS_PROXY", "http://proxy.test:3128"); + vi.stubEnv("NODE_USE_ENV_PROXY", "1"); + const viaProxy = vi.fn(async () => new Response("")); + vi.stubGlobal("fetch", viaProxy); + try { + await expect( + fetchPublicHttpsText("https://assets.example.test/x.html", { maxBytes: 1024 }), + ).resolves.toBe(""); + expect(viaProxy).toHaveBeenCalledOnce(); + } finally { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + } + }); +}); diff --git a/packages/engine/src/utils/urlDownloader.test.ts b/packages/engine/src/utils/urlDownloader.test.ts index 18f0d600f9..737145b1e5 100644 --- a/packages/engine/src/utils/urlDownloader.test.ts +++ b/packages/engine/src/utils/urlDownloader.test.ts @@ -15,12 +15,16 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { createHash } from "node:crypto"; import { + _setDownloadFetchForTests, assertPublicHttpsUrl, downloadToTemp, fetchPublicHttpsText, UrlDownloadError, } from "./urlDownloader.js"; +const stubFetch = (replacement: unknown): void => + _setDownloadFetchForTests(replacement as Parameters[0]); + const fsRaceControls = vi.hoisted(() => ({ deleteBeforeLstatPath: undefined as string | undefined, deleteInjectedWinnerBeforeLstatPath: undefined as string | undefined, @@ -148,6 +152,7 @@ afterEach(() => { heldDeadlines.clear(); vi.useRealTimers(); vi.unstubAllGlobals(); + _setDownloadFetchForTests(); fsRaceControls.deleteBeforeLstatPath = undefined; fsRaceControls.deleteInjectedWinnerBeforeLstatPath = undefined; fsRaceControls.injectRaceAtLinkPath = undefined; @@ -280,7 +285,7 @@ describe("fetchPublicHttpsText", () => { headers: { location: "https://169.254.169.254/latest/meta-data/" }, }), ); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); await expect( fetchPublicHttpsText("https://styles.example/fonts.css", { maxBytes: 1024 }), @@ -299,7 +304,7 @@ describe("fetchPublicHttpsText", () => { cancelled = true; }, }); - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(body))); + stubFetch(vi.fn().mockResolvedValue(new Response(body))); await expect( fetchPublicHttpsText("https://styles.example/large.css", { maxBytes: 10 }), @@ -321,7 +326,7 @@ describe("fetchPublicHttpsText", () => { }); return new Response(body); }); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); await expect( fetchPublicHttpsText("https://styles.example/stalled.css", { @@ -334,8 +339,7 @@ describe("fetchPublicHttpsText", () => { describe("downloadToTemp atomic publication and bounded retry", () => { it("does not race a real wall-clock deadline on a slow attempt", async () => { - vi.stubGlobal( - "fetch", + stubFetch( vi.fn().mockImplementation( (_url: string, init: RequestInit) => new Promise((resolve, reject) => { @@ -360,7 +364,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { }), ) .mockResolvedValueOnce(new Response("complete")); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); const path = await downloadToTemp("https://cdn.example/redirect.mp4", dir, 1_000); @@ -384,7 +388,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { headers: { location: "http://169.254.169.254/latest/meta-data/" }, }), ); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); await expect( @@ -404,7 +408,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { headers: { location: "https://[::ffff:169.254.169.254]/latest/meta-data/" }, }), ); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); await expect( @@ -421,7 +425,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { .fn() .mockResolvedValueOnce(new Response(null, { status: 503, statusText: "Service Unavailable" })) .mockResolvedValueOnce(new Response("complete")); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); const onTransientRetry = vi.fn(); @@ -448,7 +452,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { const fetchMock = vi .fn() .mockResolvedValue(new Response(null, { status: 503, statusText: signedUrl })); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); let message = ""; @@ -476,7 +480,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { .mockResolvedValueOnce( new Response(complete, { headers: { "content-length": String(complete.length) } }), ); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); const onTransientRetry = vi.fn(); @@ -503,7 +507,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { .mockImplementation(() => Promise.resolve(new Response("short", { headers: { "content-length": "12" } })), ); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); await expect( @@ -528,7 +532,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { }), ) .mockResolvedValueOnce(new Response("complete", { headers: { "content-length": "8" } })); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); const path = await downloadToTemp("https://cdn.example/unsolicited-range.mp4", dir, 1_000); @@ -545,7 +549,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { headers: { "content-range": "bytes nonsense" }, }), ); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); await expect( @@ -567,7 +571,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { headers: { "content-range": "bytes 0-7/8", "content-length": "8" }, }), ); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); const path = await downloadToTemp( @@ -593,7 +597,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { headers: { "content-range": "bytes 0-7/8" }, }), ); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); await expect( @@ -614,7 +618,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { }), ), ); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); await expect( @@ -646,7 +650,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { }, }), ); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); await expect( @@ -667,7 +671,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { .fn() .mockResolvedValueOnce(new Response(corrupt)) .mockResolvedValueOnce(new Response(complete)); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); const path = await downloadToTemp( @@ -685,7 +689,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { it("rejects a malformed caller checksum before fetching", async () => { const fetchMock = vi.fn(); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); await expect( @@ -710,7 +714,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { ), ); const onTransientRetry = vi.fn(); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); await expect( @@ -742,7 +746,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { }, }), ); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); const path = await downloadToTemp( @@ -769,7 +773,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { }, }), ); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); const path = await downloadToTemp("https://cdn.example/server-sha.mp4", dir, 1_000); @@ -784,7 +788,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { const fetchMock = vi .fn() .mockResolvedValue(new Response(bytes, { headers: { "content-md5": contentMd5 } })); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); const path = await downloadToTemp("https://cdn.example/legacy-md5.mp4", dir, 1_000); @@ -802,7 +806,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { }), ), ); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); await expect( @@ -820,7 +824,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { }), ), ); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); await expect( @@ -839,7 +843,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { }, }), ); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); const path = await downloadToTemp( @@ -859,7 +863,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { const fetchMock = vi .fn() .mockResolvedValue(new Response(" denied")); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); await expect( @@ -876,7 +880,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { it("rejects a BOM-prefixed remote error document without retrying", async () => { const payload = Buffer.from("\uFEFF denied"); const fetchMock = vi.fn().mockResolvedValue(new Response(payload)); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); await expect( @@ -894,7 +898,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { writeFileSync(cachePath, "expired"); const fontBytes = Buffer.from("wOF2valid-font-payload"); const fetchMock = vi.fn().mockResolvedValue(new Response(fontBytes)); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const path = await downloadToTemp(url, dir, 1_000); @@ -910,7 +914,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { headers: { "content-type": "video/mp4" }, }), ); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); await expect( @@ -940,7 +944,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { .mockResolvedValueOnce( new Response(mediaBytes, { headers: { "content-type": "text/html" } }), ); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); const path = await downloadToTemp( @@ -964,7 +968,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { headers: { "content-length": String(mediaBytes.length), etag }, }), ); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); const events: unknown[] = []; const signedUrl = @@ -1004,7 +1008,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { "does not reject valid %s inputs via a duplicate format allowlist", async (kind, mediaBytes) => { const fetchMock = vi.fn().mockResolvedValue(new Response(mediaBytes)); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); const path = await downloadToTemp(`https://cdn.example/extensionless-${kind}`, dir, 1_000); @@ -1020,7 +1024,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { const fetchMock = vi .fn() .mockRejectedValue(new TypeError(`fetch failed while requesting ${signedUrl}`)); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); let message = ""; @@ -1043,7 +1047,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { .mockRejectedValue( Object.assign(new Error(`no space left writing ${signedUrl}`), { code: "ENOSPC" }), ); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); await expect(downloadToTemp(signedUrl, makeTempDir(), 1_000)).rejects.toMatchObject({ kind: "filesystem", @@ -1055,7 +1059,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { const signedUrl = "https://cdn.example/private/clip.mp4?X-Amz-Signature=super-secret-signature"; const hostile = Object.assign(new Error("boom"), { code: signedUrl }); hostile.name = signedUrl; - vi.stubGlobal("fetch", vi.fn().mockRejectedValue(hostile)); + stubFetch(vi.fn().mockRejectedValue(hostile)); await expect(downloadToTemp(signedUrl, makeTempDir(), 1_000)).rejects.toMatchObject({ message: "Download failed while writing the local artifact", @@ -1078,7 +1082,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { new Response(errorBody, { status: 503, statusText: "Service Unavailable" }), ) .mockResolvedValueOnce(new Response("complete")); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); const path = await downloadToTemp("https://cdn.example/streaming-503.mp4", dir, 1_000); @@ -1093,7 +1097,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { const fetchMock = vi .fn() .mockResolvedValue(new Response(null, { status: 404, statusText: "Not Found" })); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); await expect( @@ -1111,7 +1115,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { const fetchMock = vi .fn() .mockResolvedValue(new Response(null, { status: 503, statusText: "Service Unavailable" })); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); await expect( @@ -1137,7 +1141,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { .fn() .mockResolvedValueOnce(new Response(resetBody)) .mockResolvedValueOnce(new Response("complete")); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); const path = await downloadToTemp("https://cdn.example/reset-once.mp4", dir, 1_000); @@ -1161,7 +1165,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { .fn() .mockResolvedValueOnce(new Response(disconnectedBody)) .mockResolvedValueOnce(new Response("complete")); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); const path = await downloadToTemp("https://cdn.example/undici-reset-once.mp4", dir, 1_000); @@ -1188,7 +1192,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { return new Response(stalledBody); }) .mockResolvedValueOnce(new Response("complete")); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); const pending = downloadToTemp("https://cdn.example/stalled-body.mp4", dir, 1_000); @@ -1215,7 +1219,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { return new Response(stalledBody); }) .mockResolvedValueOnce(new Response("complete")); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); const pending = downloadToTemp("https://cdn.example/unresponsive-body.mp4", dir, 1_000); @@ -1243,7 +1247,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { return new Response(truncatedBody); }) .mockResolvedValueOnce(new Response("complete")); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); const pending = downloadToTemp("https://cdn.example/aborted-chunked-body.mp4", dir, 1_000); @@ -1261,7 +1265,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { .fn() .mockResolvedValueOnce(new Response("")) .mockResolvedValueOnce(new Response("complete")); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); const path = await downloadToTemp("https://cdn.example/empty-once.mp4", dir, 1_000); @@ -1276,7 +1280,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { .fn() .mockResolvedValueOnce(new Response(null)) .mockResolvedValueOnce(new Response("complete")); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); const path = await downloadToTemp("https://cdn.example/null-body-once.mp4", dir, 1_000); @@ -1288,7 +1292,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { it("removes a stale zero-byte final file before downloading", async () => { const fetchMock = vi.fn().mockResolvedValue(new Response("complete")); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); const stalePath = join(dir, "download_eda0de5dc5a3.mp4"); writeFileSync(stalePath, ""); @@ -1309,7 +1313,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { writeFileSync(cachePath, "vanishing-cache-entry"); fsRaceControls.deleteBeforeLstatPath = cachePath; const fetchMock = vi.fn().mockResolvedValue(new Response("complete")); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const path = await downloadToTemp(url, dir, 1_000); @@ -1321,7 +1325,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { it("does not trust a nonempty symlink at the final cache path", async () => { const fetchMock = vi.fn().mockResolvedValue(new Response("downloaded")); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); const target = join(dir, "attacker-controlled.mp4"); const cachePath = join(dir, "download_eda0de5dc5a3.mp4"); @@ -1338,7 +1342,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { it("does not retry caller cancellation", async () => { const fetchMock = vi.fn(); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); const controller = new AbortController(); controller.abort(); @@ -1360,7 +1364,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { setTimeout(() => resolve(new Response("complete")), 10); }), ); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); const [first, second] = await Promise.all([ @@ -1380,7 +1384,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { const lockPath = join(dir, `${cacheName}.hf-lock`); mkdirSync(lockPath); const fetchMock = vi.fn().mockResolvedValue(new Response("complete")); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const pending = downloadToTemp(url, dir, 1_000); await new Promise((resolve) => setTimeout(resolve, 25)); @@ -1403,7 +1407,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { const staleTime = new Date(Date.now() - 6 * 60_000); utimesSync(lockPath, staleTime, staleTime); const fetchMock = vi.fn().mockResolvedValue(new Response("complete")); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const path = await downloadToTemp(url, dir, 1_000); @@ -1424,7 +1428,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { utimesSync(lockPath, staleTime, staleTime); fsRaceControls.replaceStaleLockAfterObservationPath = lockPath; const fetchMock = vi.fn().mockResolvedValue(new Response("complete")); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const pending = downloadToTemp(url, dir, 1_000); await new Promise((resolve) => setTimeout(resolve, 25)); @@ -1448,7 +1452,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { writeFileSync(cachePath, "cached"); fsRaceControls.replaceLockOnReleasePath = lockPath; const fetchMock = vi.fn(); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const path = await downloadToTemp(url, dir, 1_000); @@ -1472,7 +1476,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { setTimeout(() => resolve(new Response(body)), 10); }), ); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const [first, second] = await Promise.all([ downloadToTemp(url, dir, 1_000, firstController.signal), @@ -1504,7 +1508,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { setTimeout(() => resolve(new Response(secondBytes)), 5), ), ); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); const url = "https://cdn.example/versioned.mp4"; @@ -1532,7 +1536,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { fsRaceControls.deleteBeforeReadPath = cachePath; const onTelemetry = vi.fn(); const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(new Response("complete"))); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const path = await downloadToTemp(url, dir, 1_000, undefined, undefined, { onTelemetry }); @@ -1557,7 +1561,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { fsRaceControls.deleteInjectedWinnerBeforeLstatPath = cachePath; const onTelemetry = vi.fn(); const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(new Response("complete"))); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const path = await downloadToTemp(url, dir, 1_000, undefined, undefined, { onTelemetry }); @@ -1597,7 +1601,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { setTimeout(() => resolve(new Response("complete")), 10); }), ); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); const first = downloadToTemp( @@ -1651,7 +1655,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { }); return new Response(body); }); - vi.stubGlobal("fetch", fetchMock); + stubFetch(fetchMock); const dir = makeTempDir(); const first = downloadToTemp( diff --git a/packages/engine/src/utils/urlDownloader.ts b/packages/engine/src/utils/urlDownloader.ts index f03dd3450a..f45f00fc48 100644 --- a/packages/engine/src/utils/urlDownloader.ts +++ b/packages/engine/src/utils/urlDownloader.ts @@ -17,7 +17,9 @@ import { unlinkSync, } from "fs"; import { createHash, randomUUID } from "crypto"; -import { BlockList, isIP } from "node:net"; +import dns from "node:dns"; +import { fetchGoesThroughEnvProxy } from "@hyperframes/core/env-proxy"; +import { BlockList, isIP, type LookupFunction } from "node:net"; import { dirname, extname, join } from "path"; import { Readable, Transform } from "stream"; import { pipeline } from "stream/promises"; @@ -467,6 +469,57 @@ async function acquireCachePathLock( const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); const MAX_REDIRECTS = 5; +const NON_PUBLIC_ADDRESS_CODE = "HF_NON_PUBLIC_ADDRESS"; + +/** Resolves a host and refuses to connect when any answer is a non-public address. */ +const publicOnlyLookup: LookupFunction = (hostname, options, callback) => { + dns.lookup(hostname, { ...options, all: true }, (error, answers) => { + if (error) return callback(error, "", 0); + if ( + answers.length === 0 || + answers.some((a) => isBlockedNetworkHost(a.address.split("%")[0]!)) + ) { + const refused = Object.assign(new Error("host resolves to a non-public address"), { + code: NON_PUBLIC_ADDRESS_CODE, + }); + return callback(refused, "", 0); + } + if (options.all) return callback(null, answers as never); + return callback(null, answers[0]!.address, answers[0]!.family); + }); +}; + +type DownloadFetch = (url: string, init: RequestInit) => Promise; + +let undiciTransport: Promise | undefined; +const fetchPublic: DownloadFetch = async (url, init) => { + // Behind an env proxy the proxy resolves the name; the URL and redirect checks are the guard then. + if (fetchGoesThroughEnvProxy()) return fetch(url, init); + undiciTransport ??= import("undici").then(({ Agent, fetch: undiciFetch }) => { + const dispatcher = new Agent({ connect: { lookup: publicOnlyLookup } }); + return (target, options) => + undiciFetch(target, { ...(options as object), dispatcher }) as unknown as Promise; + }); + return (await undiciTransport)(url, init); +}; + +let downloadFetch: DownloadFetch = fetchPublic; + +/** Test seam: replaces the transport every download goes through; no argument restores it. */ +export function _setDownloadFetchForTests(replacement?: DownloadFetch): void { + downloadFetch = replacement ?? fetchPublic; +} + +function refusedNonPublicAddress(error: unknown): boolean { + for (let depth = 0; error && depth < 4; depth += 1) { + if (typeof error === "object" && "code" in error && error.code === NON_PUBLIC_ADDRESS_CODE) { + return true; + } + error = typeof error === "object" && "cause" in error ? error.cause : undefined; + } + return false; +} + function assertAllowedDownloadUrl(url: string, redirect: boolean): void { try { assertPublicHttpsUrl(url); @@ -517,10 +570,19 @@ async function fetchWithValidatedRedirects( // lgtm[js/file-access-to-http] — every redirect hop is fetched manually // only after the HTTPS/private-host guard above; automatic redirect // following is disabled so an allowed host cannot bounce into IMDS. - const response = await fetch(currentUrl, { + const response = await downloadFetch(currentUrl, { signal: controller.signal, redirect: "manual", headers: { "accept-encoding": "identity" }, + }).catch((error: unknown) => { + if (!refusedNonPublicAddress(error)) throw error; + throw new UrlDownloadError( + "http_rejected", + false, + redirects > 0 + ? "Download redirect target is not permitted" + : "Download URL is not permitted", + ); }); if (!REDIRECT_STATUSES.has(response.status)) return { response, finalUrl: currentUrl }; diff --git a/packages/producer/src/server.test.ts b/packages/producer/src/server.test.ts index 96aa334499..870211417c 100644 --- a/packages/producer/src/server.test.ts +++ b/packages/producer/src/server.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; +import { _setDownloadFetchForTests } from "@hyperframes/engine"; import { parseRenderOptions, prepareRenderBody } from "./server.js"; describe("parseRenderOptions — variables", () => { @@ -191,14 +192,13 @@ describe("prepareRenderBody — validation", () => { }); describe("prepareRenderBody — previewUrl", () => { - const realFetch = globalThis.fetch; const stubFetch = (impl: () => Promise) => { const spy = mock(impl); - globalThis.fetch = spy as unknown as typeof fetch; + _setDownloadFetchForTests(spy as unknown as Parameters[0]); return spy; }; afterEach(() => { - globalThis.fetch = realFetch; + _setDownloadFetchForTests(); }); it.each([ diff --git a/packages/producer/src/services/distributed/plan.test.ts b/packages/producer/src/services/distributed/plan.test.ts index a8022dfa41..786d5eb517 100644 --- a/packages/producer/src/services/distributed/plan.test.ts +++ b/packages/producer/src/services/distributed/plan.test.ts @@ -30,6 +30,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { applyConcreteGpuScreenshotClamp, + _setDownloadFetchForTests, buildChromeArgs, FFMPEG_PATH_ENV, getFfmpegBinary, @@ -707,12 +708,11 @@ describe("plan() — golden planDir + planHash determinism", () => { `, ); - const originalFetch = globalThis.fetch; let fetchCalls = 0; - globalThis.fetch = (async () => { + _setDownloadFetchForTests((async () => { fetchCalls += 1; return new Response(null, { status: 404, statusText: "Not Found" }); - }) as typeof fetch; + }) as never); let caught: unknown; try { @@ -724,7 +724,7 @@ describe("plan() — golden planDir + planHash determinism", () => { } catch (err) { caught = err; } finally { - globalThis.fetch = originalFetch; + _setDownloadFetchForTests(); } expect(fetchCalls).toBeGreaterThan(0); diff --git a/packages/producer/src/services/htmlCompiler.test.ts b/packages/producer/src/services/htmlCompiler.test.ts index d2fb9777dd..7c81bfeba7 100644 --- a/packages/producer/src/services/htmlCompiler.test.ts +++ b/packages/producer/src/services/htmlCompiler.test.ts @@ -9,7 +9,7 @@ import { parseHTML } from "linkedom"; import { interpolateVolumeGain } from "@hyperframes/core/media-volume-envelope"; import { redactTelemetryString } from "@hyperframes/core"; import { defaultLogger } from "../logger.js"; -import { NotMediaPayloadError } from "@hyperframes/engine"; +import { NotMediaPayloadError, _setDownloadFetchForTests } from "@hyperframes/engine"; import { collectExternalAssets, compileForRender, @@ -1945,11 +1945,16 @@ describe("crossorigin attribute stripping", () => { // avoid invoking ffprobe / the full compileForRender pipeline. fetch is patched // in-process for success cases; the unit lane's refused network covers fallback. +// Downloads go through the engine's fetch seam, not the global fetch. +const stubDownloadFetch = { + set fetch(impl: unknown) { + _setDownloadFetchForTests(impl as never); + }, +}; + describe("localizeRemoteMediaSources", () => { it("rewrites remote