diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 3f59f7b4f..8482762ca 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "hyperframes", "description": "HyperFrames by HeyGen. Write HTML, render video. Compositions, GSAP and runtime adapter animations, captions, voiceovers, audio-reactive visuals, and website capture for HyperFrames.", - "version": "0.7.92", + "version": "0.7.94", "author": { "name": "HeyGen", "email": "hyperframes@heygen.com", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 93e55ce85..42a3692ce 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "hyperframes", "description": "Write HTML, render video. Compositions, Tailwind v4 styles, GSAP and runtime adapter animations, captions, voiceovers, audio-reactive visuals, and website capture for HyperFrames.", - "version": "0.7.92", + "version": "0.7.94", "author": { "name": "HeyGen", "email": "hyperframes@heygen.com", diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index b1f7ea797..a8a964806 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -3,7 +3,7 @@ "name": "hyperframes", "displayName": "HyperFrames by HeyGen", "description": "Write HTML, render video. Compositions, Tailwind v4 styles, GSAP and runtime adapter animations, captions, voiceovers, audio-reactive visuals, and website capture for HyperFrames.", - "version": "0.7.92", + "version": "0.7.94", "author": { "name": "HeyGen", "email": "hyperframes@heygen.com" diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index b3f221dbe..fd1cc03ff 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -162,6 +162,17 @@ "produceDrawElementFrame", ], }, + // External-conflict persistence is the #2990 stack boundary. The coordinator + // consumes these exports in child PR #2991; keep the primitive independently reviewable. + { + "file": "packages/studio/src/utils/externalConflictStorage.ts", + "exports": [ + "persistExternalConflictSnapshot", + "persistExternalFailureSnapshot", + "loadExternalConflictSnapshot", + "deleteExternalConflictSnapshot", + ], + }, // CLI command files: every command exports a const `examples` per the // convention documented in CLAUDE.md. This is a namespace barrel, not a // collision. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e79a49359..e19b5ce0c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,7 +129,7 @@ jobs: - run: corepack enable - run: corepack prepare pnpm@10.17.1 --activate - uses: ./.github/actions/prepare-ffmpeg-bin - - run: bun install --frozen-lockfile + - run: bash scripts/ci/install-workspace-dependencies.sh - run: bun run build - run: bun run verify:packed-manifests @@ -148,7 +148,7 @@ jobs: with: node-version: 22 - uses: ./.github/actions/prepare-ffmpeg-bin - - run: bun install --frozen-lockfile + - run: bash scripts/ci/install-workspace-dependencies.sh - run: bun run lint # `fallow audit` runs dead-code + complexity + duplication analysis scoped to @@ -182,7 +182,7 @@ jobs: with: node-version: 22 - uses: ./.github/actions/prepare-ffmpeg-bin - - run: bun install --frozen-lockfile + - run: bash scripts/ci/install-workspace-dependencies.sh - name: Run fallow audit id: audit # `bun install` above made `bunx fallow` resolve from node_modules, so @@ -238,7 +238,7 @@ jobs: with: node-version: 22 - uses: ./.github/actions/prepare-ffmpeg-bin - - run: bun install --frozen-lockfile + - run: bash scripts/ci/install-workspace-dependencies.sh - run: bun run format:check typecheck: @@ -256,7 +256,7 @@ jobs: with: node-version: 22 - uses: ./.github/actions/prepare-ffmpeg-bin - - run: bun install --frozen-lockfile + - run: bash scripts/ci/install-workspace-dependencies.sh - run: bun run build - run: bun run --filter '*' typecheck @@ -283,7 +283,7 @@ jobs: with: node-version: 22 - uses: ./.github/actions/prepare-ffmpeg-bin - - run: bun install --frozen-lockfile + - run: bash scripts/ci/install-workspace-dependencies.sh - run: bun run test:scripts - run: bun run --filter '@hyperframes/{parsers,lint,studio-server}' build - run: bun run --cwd packages/core build @@ -314,7 +314,7 @@ jobs: sudo apt-get update -qq sudo apt-get install -y --no-install-recommends ffmpeg - uses: ./.github/actions/prepare-ffmpeg-bin - - run: bun install --frozen-lockfile + - run: bash scripts/ci/install-workspace-dependencies.sh - run: bun run --filter '@hyperframes/{parsers,lint,studio-server}' build - run: bun run --cwd packages/core build - run: bun run --filter @hyperframes/engine build @@ -422,7 +422,7 @@ jobs: node-version: 22 - name: Install dependencies if: runner.os != 'Windows' - run: bun install --frozen-lockfile --ignore-scripts + run: bash scripts/ci/install-workspace-dependencies.sh --ignore-scripts - name: Install dependencies if: runner.os == 'Windows' run: bun install --frozen-lockfile --ignore-scripts --linker=hoisted @@ -437,7 +437,7 @@ jobs: steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - - run: bun install --frozen-lockfile + - run: bash scripts/ci/install-workspace-dependencies.sh # Build workspace deps so the sdk's @hyperframes/parsers + core subpath # imports resolve via the "node" export condition (dist) under vitest. - run: bun run --filter '@hyperframes/parsers' build @@ -459,7 +459,7 @@ jobs: with: node-version: 22 - uses: ./.github/actions/prepare-ffmpeg-bin - - run: bun install --frozen-lockfile + - run: bash scripts/ci/install-workspace-dependencies.sh # Runtime coverage now imports core modules that consume workspace # subpaths. Build their dist exports before Vitest resolves them. - run: bun run --filter '@hyperframes/{parsers,lint,studio-server}' build @@ -480,7 +480,7 @@ jobs: with: node-version: 22 - uses: ./.github/actions/prepare-ffmpeg-bin - - run: bun install --frozen-lockfile + - run: bash scripts/ci/install-workspace-dependencies.sh # Build workspace deps so the studio vite.config.ts (loaded by Node) can # resolve @hyperframes/core and @hyperframes/studio-server via the "node" # export condition (dist). @@ -523,7 +523,7 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 - - run: bun install --frozen-lockfile + - run: bash scripts/ci/install-workspace-dependencies.sh # Same reason as studio-load-smoke: vite.config.ts is loaded by Node and # resolves the workspace packages through their "node" export condition. - run: bun run --filter '@hyperframes/{parsers,lint,studio-server}' build @@ -634,7 +634,7 @@ jobs: with: node-version: 22 - uses: ./.github/actions/prepare-ffmpeg-bin - - run: bun install --frozen-lockfile + - run: bash scripts/ci/install-workspace-dependencies.sh - run: bun run build # Pack the CLI as a tarball (simulates what `npm publish` produces) @@ -711,7 +711,7 @@ jobs: sudo apt-get install -y ffmpeg - uses: ./.github/actions/prepare-ffmpeg-bin - name: Install dependencies - run: bun install --frozen-lockfile + run: bash scripts/ci/install-workspace-dependencies.sh - name: Build monorepo run: bun run build diff --git a/bun.lock b/bun.lock index 85a5cbd6f..af5e10cce 100644 --- a/bun.lock +++ b/bun.lock @@ -343,6 +343,7 @@ "@types/react-dom": "19", "@vitejs/plugin-react": "^4.0.0", "autoprefixer": "^10.4.0", + "fake-indexeddb": "^6.2.5", "postcss": "^8.4.0", "puppeteer-core": "^25.2.1", "tailwindcss": "^3.4.0", @@ -1487,6 +1488,8 @@ "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + "fake-indexeddb": ["fake-indexeddb@6.2.5", "", {}, "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w=="], + "fallow": ["fallow@2.75.0", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "@fallow-cli/darwin-arm64": "2.75.0", "@fallow-cli/darwin-x64": "2.75.0", "@fallow-cli/linux-arm64-gnu": "2.75.0", "@fallow-cli/linux-arm64-musl": "2.75.0", "@fallow-cli/linux-x64-gnu": "2.75.0", "@fallow-cli/linux-x64-musl": "2.75.0", "@fallow-cli/win32-arm64-msvc": "2.75.0", "@fallow-cli/win32-x64-msvc": "2.75.0" }, "bin": { "fallow": "bin/fallow", "fallow-lsp": "bin/fallow-lsp", "fallow-mcp": "bin/fallow-mcp" } }, "sha512-0/2cquNI/cDLP/LzcCbkwI4hMzkX4tE0VY3/69n3PBBeqFpbM2oai+2Cb0sB8dXB8MDUGPVoPJjDW5GiUo7a1A=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 43609791d..577446906 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -8,6 +8,76 @@ Recent HyperFrames releases, including user-facing features, fixes, and migratio {/* New release entries are prepended by `bun run changelog:draft --write`. */} + +The CLI now supports explicit RFC 8628 device authorization for attended SSH +and remote-terminal sessions, so users can sign in without copying API keys or +exposing a loopback callback. The flow is fail-closed, validates minted tokens +before atomic persistence, and leaves the existing browser login unchanged. + +## Features + +- **CLI:** Add device authorization login ([b9233525b](https://github.com/heygen-com/hyperframes/commit/b9233525b2e02ec9329445ab2602ebd557033daf), [#2836](https://github.com/heygen-com/hyperframes/pull/2836)) + +[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.93...v0.7.94). + + + +Studio now preserves and recovers external file edits instead of silently +overwriting them, while logical timeline navigation makes keyboard control more +predictable. The engine and producer also harden remote downloads, ffprobe +invocation, telemetry path redaction, GPU verification, and cross-frame-rate +BeginFrame timing. + +## Features + +- **Studio:** Model logical timeline navigation ([0f1333c80](https://github.com/heygen-com/hyperframes/commit/0f1333c80d3c18eddf8af69110168cc910d14fec), [#2712](https://github.com/heygen-com/hyperframes/pull/2712)) +- **Studio:** Add external conflict recovery UI ([8a0dccfc7](https://github.com/heygen-com/hyperframes/commit/8a0dccfc72224d993efdb0ae8d95fc0e020327d4), [#2992](https://github.com/heygen-com/hyperframes/pull/2992)) +- **Studio:** Coordinate external file changes ([a99caad58](https://github.com/heygen-com/hyperframes/commit/a99caad581496f2ad8e132b750ced73be8cf3e51), [#2991](https://github.com/heygen-com/hyperframes/pull/2991)) +- **Studio:** Preserve external file conflicts ([b30a23402](https://github.com/heygen-com/hyperframes/commit/b30a23402e2db19b43cb0283e1553048746fb8bb), [#2990](https://github.com/heygen-com/hyperframes/pull/2990)) + +## Fixes + +- **Engine:** Keep BeginFrame time monotonic across frame rates ([b390b71bd](https://github.com/heygen-com/hyperframes/commit/b390b71bde982ffbf55c9ec93f1491d99011bfc3), [#3011](https://github.com/heygen-com/hyperframes/pull/3011)) +- **Engine:** Validate remote download integrity ([bbdfee116](https://github.com/heygen-com/hyperframes/commit/bbdfee1166c45e348ef76d2e384a0ad2243ac847), [#2938](https://github.com/heygen-com/hyperframes/pull/2938)) +- **Studio:** Reconcile external edits before reload ([ebdd1893c](https://github.com/heygen-com/hyperframes/commit/ebdd1893c4a0d86b984fb627fed0283a981d2713), [#2993](https://github.com/heygen-com/hyperframes/pull/2993)) +- **Studio:** Drain pending edits before reload ([471313854](https://github.com/heygen-com/hyperframes/commit/47131385445c0f8ee4203f8d6ff4f5600d8c8b2e), [#2989](https://github.com/heygen-com/hyperframes/pull/2989)) +- **Studio:** Make inspector commits transactional ([552419c52](https://github.com/heygen-com/hyperframes/commit/552419c52d16fa5a5e8d15a9263bc5f41e596360), [#2987](https://github.com/heygen-com/hyperframes/pull/2987)) +- **Engine:** Distinguish probe failure from a genuinely absent GPU ([f69c4a0e3](https://github.com/heygen-com/hyperframes/commit/f69c4a0e3aa8feb1110b52d538f8ca127481c95d)) +- **Studio:** Respect GSAP transform ownership ([cf45c9845](https://github.com/heygen-com/hyperframes/commit/cf45c984549bc41ed4debc99d5283d2a60108cbb), [#2986](https://github.com/heygen-com/hyperframes/pull/2986)) +- **Parsers:** Preserve safe GSAP helper defaults ([532f06158](https://github.com/heygen-com/hyperframes/commit/532f06158b3a930b85a289ab0608d720311cd5a1), [#2985](https://github.com/heygen-com/hyperframes/pull/2985)) +- **Engine:** Warn once per process about unverified hardware GPU ([6703ea7e0](https://github.com/heygen-com/hyperframes/commit/6703ea7e04701b490c5fb563db4f532fbc8aec89)) +- **Engine:** Verify explicit browserGpuMode=hardware instead of trusting it ([131780fe9](https://github.com/heygen-com/hyperframes/commit/131780fe9627ee7fb17cb871ef6a144a6ef02735)) +- **Core,producer,skills:** Unicode paths, non-Error rejections, shell callers ([1664fe6ad](https://github.com/heygen-com/hyperframes/commit/1664fe6ad773c76e0dc7314e7f2eb439cd3687d6)) +- **Producer:** Drop the ReDoS-prone literal-argv regex for a linear scan ([6c5403f7c](https://github.com/heygen-com/hyperframes/commit/6c5403f7cd5e5b21f301b88d6d9f84451751ba15)) +- **Skills,producer:** Terminate ffprobe options in shipped skill scripts ([255cf9291](https://github.com/heygen-com/hyperframes/commit/255cf929150977fa57dc1cadde2e9cf626f5c20c)) +- **Core,producer:** Redact bare relative paths and the known input path ([e79ab3ab3](https://github.com/heygen-com/hyperframes/commit/e79ab3ab31a5f789f8c998430f2b58cb95973f07)) +- **Core:** Redact any path in telemetry, not an allowlist of roots ([d04569e37](https://github.com/heygen-com/hyperframes/commit/d04569e37f1c63967ce7373557ded4e4f150e7de)) +- **Producer,studio Server:** Finish the ffprobe argv sweep, pin the contract ([d0dbf11ef](https://github.com/heygen-com/hyperframes/commit/d0dbf11ef53dd7ca535ed40a10e8f2dad4f12c4d)) +- **Cli,core,lint,producer:** Terminate ffprobe options at every call site ([47564ab94](https://github.com/heygen-com/hyperframes/commit/47564ab94cc4bac5de6715169939353c211af689)) + +## Docs & Examples + +- **Studio:** Document timeline keyboard navigation ([7bf425b7a](https://github.com/heygen-com/hyperframes/commit/7bf425b7a90c5a3c8154c66025261ff9e52c8c72), [#3031](https://github.com/heygen-com/hyperframes/pull/3031)) + +## Internal + +- **Studio:** Relax large fixture timeout ([bce2140ff](https://github.com/heygen-com/hyperframes/commit/bce2140ff208e16f83ee0854bca8baf85ad7216e), [#3040](https://github.com/heygen-com/hyperframes/pull/3040)) +- **Studio:** Extract live timeline clock ([19b8a1f3c](https://github.com/heygen-com/hyperframes/commit/19b8a1f3c5a55c30b5aa367c9054ca1b31552536), [#2711](https://github.com/heygen-com/hyperframes/pull/2711)) +- **Studio:** Pin text-field Backspace routing ([cde5bae4c](https://github.com/heygen-com/hyperframes/commit/cde5bae4c5a6afa167faf827ab1073e66964cd35), [#2988](https://github.com/heygen-com/hyperframes/pull/2988)) +- **Producer:** Treat an all-literal probe argv as taking no input ([c81f68b59](https://github.com/heygen-com/hyperframes/commit/c81f68b59225d0acd34acbc419e408125fa51e36)) +- **Producer:** Discover ffprobe callers and pin terminator position ([91a7cb1f5](https://github.com/heygen-com/hyperframes/commit/91a7cb1f5b7f9a4bb539384fb50645a8feab3f29)) + +[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.92...v0.7.93). + + "Run `hyperframes auth login` to re-authenticate.", ); +export const ErrDeviceAuthFailed = (detail: string) => + new AuthError( + "DEVICE_AUTH_FAILED", + `Device authorization failed: ${detail}`, + "Run `hyperframes auth login --device` to start a new code.", + ); + export function isAuthError(err: unknown): err is AuthError { return err instanceof AuthError; } diff --git a/packages/cli/src/auth/index.ts b/packages/cli/src/auth/index.ts index 710297b9c..9d9562b1c 100644 --- a/packages/cli/src/auth/index.ts +++ b/packages/cli/src/auth/index.ts @@ -33,7 +33,9 @@ export type { UserInfo } from "./client.js"; export { assertOAuthConfiguredOrExit, + persistVerifiedOAuthSession, refreshTokens, revokeTokens, startAuthorizationCodeFlow, + startDeviceAuthorizationFlow, } from "./oauth.js"; diff --git a/packages/cli/src/auth/oauth.test.ts b/packages/cli/src/auth/oauth.test.ts index c280daf45..fcc9ac515 100644 --- a/packages/cli/src/auth/oauth.test.ts +++ b/packages/cli/src/auth/oauth.test.ts @@ -4,10 +4,13 @@ import { setupTempAuthEnv } from "./_test-utils.js"; import { isAuthError } from "./errors.js"; import { parseTokenResponse, + persistFreshOAuth, + persistVerifiedOAuthSession, refreshTokens, resolveClientId, revokeTokens, startAuthorizationCodeFlow, + startDeviceAuthorizationFlow, } from "./oauth.js"; import { readStore, writeStore } from "./store.js"; @@ -26,6 +29,45 @@ vi.mock("./browser.js", () => ({ openBrowser: vi.fn(async () => ({ opened: true })), })); +function tokenFetch(body: Record): typeof fetch { + return (async () => + new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + })) as unknown as typeof fetch; +} + +function deviceAuthorizationResponse(overrides: Record = {}): Response { + return new Response( + JSON.stringify({ + device_code: "secret-device-code", + user_code: "ABCD-2345", + verification_uri: "https://app.heygen.com/oauth/device", + expires_in: 600, + interval: 5, + ...overrides, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); +} + +function queuedFetch( + responses: Response[], + onRequest?: (url: string | URL | Request, init?: RequestInit) => void, +): typeof fetch { + return (async (url: string | URL | Request, init?: RequestInit) => { + onRequest?.(url, init); + const next = responses.shift(); + if (!next) throw new Error("unexpected fetch"); + return next; + }) as typeof fetch; +} + +async function loginWithTokens(body: Record) { + await startAuthorizationCodeFlow({ fetchImpl: tokenFetch(body) }); + return (await readStore()).credentials; +} + describe("auth/oauth", () => { let fixture: Awaited>; @@ -70,7 +112,10 @@ describe("auth/oauth", () => { }); it("accepts expires_in as a string (some servers serialize as string)", () => { - const tokens = parseTokenResponse({ access_token: "at", expires_in: "1800" }); + const tokens = parseTokenResponse({ + access_token: "at", + expires_in: "1800", + }); expect(tokens.expires_at).toBeDefined(); }); @@ -103,7 +148,10 @@ describe("auth/oauth", () => { it("clamps non-positive expires_in to avoid an immediate-refresh loop", () => { const zero = parseTokenResponse({ access_token: "at", expires_in: 0 }); - const negative = parseTokenResponse({ access_token: "at", expires_in: -100 }); + const negative = parseTokenResponse({ + access_token: "at", + expires_in: -100, + }); // both should resolve to a future time expect(new Date(zero.expires_at!).getTime()).toBeGreaterThan(Date.now() + 25 * 1000); expect(new Date(negative.expires_at!).getTime()).toBeGreaterThan(Date.now() + 25 * 1000); @@ -158,11 +206,7 @@ describe("auth/oauth", () => { expires_at: "2026-01-01T00:00:00Z", }, }); - const fetchImpl = (async () => - new Response(JSON.stringify({ access_token: "new_at", expires_in: 3600 }), { - status: 200, - headers: { "content-type": "application/json" }, - })) as unknown as typeof fetch; + const fetchImpl = tokenFetch({ access_token: "new_at", expires_in: 3600 }); await refreshTokens("keep_me_rt", { fetchImpl }); const { credentials } = await readStore(); expect(credentials.oauth?.access_token).toBe("new_at"); @@ -172,11 +216,7 @@ describe("auth/oauth", () => { it("preserves an existing api_key when persisting refreshed oauth", async () => { await writeStore({ api_key: "hg_keep" }); - const fetchImpl = (async () => - new Response(JSON.stringify({ access_token: "new_at", expires_in: 60 }), { - status: 200, - headers: { "content-type": "application/json" }, - })) as unknown as typeof fetch; + const fetchImpl = tokenFetch({ access_token: "new_at", expires_in: 60 }); await refreshTokens("old_rt", { fetchImpl }); const { credentials } = await readStore(); expect(credentials.api_key).toBe("hg_keep"); @@ -202,11 +242,7 @@ describe("auth/oauth", () => { }), { mode: 0o600 }, ); - const fetchImpl = (async () => - new Response(JSON.stringify({ access_token: "new_at", expires_in: 3600 }), { - status: 200, - headers: { "content-type": "application/json" }, - })) as unknown as typeof fetch; + const fetchImpl = tokenFetch({ access_token: "new_at", expires_in: 3600 }); await refreshTokens("keep_me_rt", { fetchImpl }); const onDisk = JSON.parse(await fs.readFile(path, "utf8")); @@ -217,7 +253,9 @@ describe("auth/oauth", () => { it("throws REFRESH_FAILED on 400/401", async () => { const fetchImpl = (async () => - new Response("invalid_grant", { status: 400 })) as unknown as typeof fetch; + new Response("invalid_grant", { + status: 400, + })) as unknown as typeof fetch; await expect(refreshTokens("bad_rt", { fetchImpl })).rejects.toSatisfy((err) => { return isAuthError(err) && (err as { code: string }).code === "REFRESH_FAILED"; }); @@ -261,7 +299,10 @@ describe("auth/oauth", () => { capturedBody = init?.body as string; return new Response("", { status: 200 }); }) as unknown as typeof fetch; - await revokeTokens("tok", { fetchImpl, token_type_hint: "refresh_token" }); + await revokeTokens("tok", { + fetchImpl, + token_type_hint: "refresh_token", + }); expect(capturedBody).toContain("token_type_hint=refresh_token"); }); @@ -296,24 +337,19 @@ describe("auth/oauth", () => { }); describe("startAuthorizationCodeFlow persistence", () => { - function tokenFetch(body: Record): typeof fetch { - return (async () => - new Response(JSON.stringify(body), { - status: 200, - headers: { "content-type": "application/json" }, - })) as unknown as typeof fetch; - } - it("overwrites the OAuth block on fresh login (no inherited refresh_token)", async () => { // Pre-seed a prior session whose refresh_token must NOT leak into // the new login when the new response omits one. await writeStore({ - oauth: { access_token: "old_at", refresh_token: "OLD_rt_should_not_survive" }, + oauth: { + access_token: "old_at", + refresh_token: "OLD_rt_should_not_survive", + }, + }); + const credentials = await loginWithTokens({ + access_token: "new_at", + expires_in: 3600, }); - const fetchImpl = tokenFetch({ access_token: "new_at", expires_in: 3600 }); - await startAuthorizationCodeFlow({ fetchImpl }); - - const { credentials } = await readStore(); expect(credentials.oauth?.access_token).toBe("new_at"); // Fresh login is a clean session — the old refresh_token is gone. expect(credentials.oauth?.refresh_token).toBeUndefined(); @@ -321,10 +357,10 @@ describe("auth/oauth", () => { it("preserves a co-located api_key across fresh login", async () => { await writeStore({ api_key: "hg_keep_me" }); - const fetchImpl = tokenFetch({ access_token: "new_at", refresh_token: "new_rt" }); - await startAuthorizationCodeFlow({ fetchImpl }); - - const { credentials } = await readStore(); + const credentials = await loginWithTokens({ + access_token: "new_at", + refresh_token: "new_rt", + }); expect(credentials.api_key).toBe("hg_keep_me"); expect(credentials.oauth?.access_token).toBe("new_at"); expect(credentials.oauth?.refresh_token).toBe("new_rt"); @@ -344,16 +380,335 @@ describe("auth/oauth", () => { }), { mode: 0o600 }, ); - const fetchImpl = tokenFetch({ access_token: "new_at", expires_in: 3600 }); - await startAuthorizationCodeFlow({ fetchImpl }); - - const { credentials } = await readStore(); + const credentials = await loginWithTokens({ + access_token: "new_at", + expires_in: 3600, + }); expect(credentials.oauth?.access_token).toBe("new_at"); - expect(credentials.user).toEqual({ email: "jane@example.com", username: "jdoe" }); + expect(credentials.user).toEqual({ + email: "jane@example.com", + username: "jdoe", + }); // The unknown key is on a hidden slot — assert via the raw file. const onDisk = JSON.parse(await fs.readFile(path, "utf8")); expect(onDisk.future_field).toEqual({ keep: true }); }); }); + + describe("startDeviceAuthorizationFlow", () => { + it("atomically replaces OAuth and identity while preserving foreign fields", async () => { + const path = (await import("./paths.js")).credentialPath(); + await fs.writeFile( + path, + JSON.stringify({ + api_key: "hg_keep", + oauth: { access_token: "old-at", refresh_token: "old-rt" }, + user: { + email: "old@example.com", + username: "old-user", + future_user_field: "keep-user", + }, + future_root_field: { keep: true }, + }), + { mode: 0o600 }, + ); + + await persistVerifiedOAuthSession( + { access_token: "device-at", refresh_token: "device-rt" }, + { email: "new@example.com" }, + ); + + const onDisk = JSON.parse(await fs.readFile(path, "utf8")); + expect(onDisk.api_key).toBe("hg_keep"); + expect(onDisk.oauth).toMatchObject({ + access_token: "device-at", + refresh_token: "device-rt", + }); + expect(onDisk.user).toEqual({ + email: "new@example.com", + future_user_field: "keep-user", + }); + expect(onDisk.future_root_field).toEqual({ keep: true }); + }); + + it("recovers a corrupt credential file before installing a verified session", async () => { + const path = (await import("./paths.js")).credentialPath(); + await fs.writeFile(path, "{not-json", { mode: 0o600 }); + + await persistVerifiedOAuthSession( + { access_token: "device-at", refresh_token: "device-rt" }, + { email: "new@example.com" }, + ); + + const onDisk = JSON.parse(await fs.readFile(path, "utf8")); + expect(onDisk.oauth).toMatchObject({ + access_token: "device-at", + refresh_token: "device-rt", + }); + expect(onDisk.user).toEqual({ email: "new@example.com" }); + }); + + it("polls pending and slow_down responses without persisting before identity verification", async () => { + const requests: Array<{ url: string; body: URLSearchParams }> = []; + const responses = [ + new Response( + JSON.stringify({ + device_code: "secret-device-code", + user_code: "ABCD-2345", + verification_uri: "https://app.heygen.com/oauth/device", + expires_in: 600, + interval: 5, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + new Response(JSON.stringify({ error: "authorization_pending" }), { + status: 400, + headers: { "content-type": "application/json" }, + }), + new Response(JSON.stringify({ error: "slow_down" }), { + status: 400, + headers: { "content-type": "application/json" }, + }), + new Response( + JSON.stringify({ + access_token: "device-at", + refresh_token: "device-rt", + token_type: "Bearer", + expires_in: 3600, + scope: "openid profile email", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ]; + const fetchImpl = queuedFetch(responses, (url, init) => { + requests.push({ + url: String(url), + body: new URLSearchParams(String(init?.body ?? "")), + }); + }); + const sleeps: number[] = []; + const challenges: Array<{ userCode: string; verificationUri: string }> = []; + + const tokens = await startDeviceAuthorizationFlow({ + fetchImpl, + sleepImpl: async (ms) => { + sleeps.push(ms); + }, + now: () => 1_000, + onChallenge: (challenge) => { + challenges.push(challenge); + }, + }); + + expect(tokens.access_token).toBe("device-at"); + expect(challenges).toEqual([ + { + userCode: "ABCD-2345", + verificationUri: "https://app.heygen.com/oauth/device", + }, + ]); + expect(sleeps).toEqual([5_000, 5_000, 10_000]); + expect(requests[0]?.body.get("client_id")).toBe(resolveClientId()); + expect(requests[1]?.body.get("device_code")).toBe("secret-device-code"); + expect(requests[1]?.body.get("grant_type")).toBe( + "urn:ietf:params:oauth:grant-type:device_code", + ); + expect((await readStore()).source).toBe("absent"); + + await persistFreshOAuth(tokens); + expect((await readStore()).credentials.oauth?.access_token).toBe("device-at"); + }); + + it("uses the RFC default interval and presents a safe complete verification URL", async () => { + const responses = [ + deviceAuthorizationResponse({ + interval: undefined, + verification_uri_complete: "https://app.heygen.com/oauth/device?user_code=ABCD-2345", + }), + new Response(JSON.stringify({ access_token: "device-at" }), { status: 200 }), + ]; + const fetchImpl = queuedFetch(responses); + const sleeps: number[] = []; + const challenges: Array<{ + userCode: string; + verificationUri: string; + verificationUriComplete?: string; + }> = []; + + await startDeviceAuthorizationFlow({ + fetchImpl, + sleepImpl: async (ms) => { + sleeps.push(ms); + }, + now: () => 1_000, + onChallenge: (challenge) => { + challenges.push(challenge); + }, + }); + + expect(sleeps).toEqual([5_000]); + expect(challenges).toEqual([ + { + userCode: "ABCD-2345", + verificationUri: "https://app.heygen.com/oauth/device", + verificationUriComplete: "https://app.heygen.com/oauth/device?user_code=ABCD-2345", + }, + ]); + }); + + it("treats HTTP 429 without an OAuth body as slow_down and honors Retry-After", async () => { + const responses = [ + deviceAuthorizationResponse(), + new Response("rate limited", { status: 429, headers: { "retry-after": "20" } }), + new Response(JSON.stringify({ access_token: "device-at" }), { status: 200 }), + ]; + const fetchImpl = queuedFetch(responses); + const sleeps: number[] = []; + + await startDeviceAuthorizationFlow({ + fetchImpl, + sleepImpl: async (ms) => { + sleeps.push(ms); + }, + now: () => 1_000, + }); + + expect(sleeps).toEqual([5_000, 20_000]); + }); + + it("canonicalizes an internationalized verification host before displaying it", async () => { + const unicodeUri = "https://rаypal.example/oauth/device"; + const responses = [ + deviceAuthorizationResponse({ verification_uri: unicodeUri }), + new Response(JSON.stringify({ access_token: "device-at" }), { status: 200 }), + ]; + const fetchImpl = queuedFetch(responses); + const challenges: Array<{ verificationUri: string }> = []; + + await startDeviceAuthorizationFlow({ + fetchImpl, + sleepImpl: async () => {}, + now: () => 1_000, + onChallenge: (challenge) => { + challenges.push(challenge); + }, + }); + + expect(challenges[0]?.verificationUri).toBe(new URL(unicodeUri).href); + expect(challenges[0]?.verificationUri).not.toBe(unicodeUri); + }); + + it("times out while reading a slow authorization response body", async () => { + const fetchImpl = (async (_url: string | URL | Request, init?: RequestInit) => { + const body = new ReadableStream({ + start(controller) { + init?.signal?.addEventListener("abort", () => controller.error(new Error("aborted")), { + once: true, + }); + }, + }); + return new Response(body, { status: 200 }); + }) as typeof fetch; + + await expect( + startDeviceAuthorizationFlow({ fetchImpl, requestTimeoutMs: 5 }), + ).rejects.toThrow(/request timed out/); + }); + + it("times out a stalled token poll", async () => { + let call = 0; + const fetchImpl = (async (_url: string | URL | Request, init?: RequestInit) => { + call += 1; + if (call === 1) { + return deviceAuthorizationResponse({ interval: undefined }); + } + return await new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(new Error("aborted")), { + once: true, + }); + }); + }) as typeof fetch; + + await expect( + startDeviceAuthorizationFlow({ + fetchImpl, + sleepImpl: async () => {}, + now: () => 1_000, + requestTimeoutMs: 5, + }), + ).rejects.toThrow(/request timed out/); + }); + + it.each(["access_denied", "expired_token"])( + "fails with a bounded error for %s without echoing the device code", + async (oauthError) => { + const fetchImpl = (async (_url: string | URL | Request, init?: RequestInit) => { + const body = new URLSearchParams(String(init?.body ?? "")); + if (body.has("scope")) { + return new Response( + JSON.stringify({ + device_code: "never-log-this-device-code", + user_code: "ABCD-2345", + verification_uri: "https://app.heygen.com/oauth/device", + expires_in: 600, + interval: 5, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + return new Response( + JSON.stringify({ + error: oauthError, + error_description: "echo never-log-this-device-code", + }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + }) as typeof fetch; + + await expect( + startDeviceAuthorizationFlow({ + fetchImpl, + sleepImpl: async () => {}, + now: () => 1_000, + }), + ).rejects.not.toThrow(/never-log-this-device-code/); + expect((await readStore()).source).toBe("absent"); + }, + ); + + it.each([ + ["credential-bearing URL", "https://user:pass@app.heygen.com/oauth/device", 600, 5], + ["control-character URL", "https://app.heygen.com/oauth/\u001b[31m", 600, 5], + ["prefix-parsed expiry", "https://app.heygen.com/oauth/device", "600seconds", 5], + ["prefix-parsed interval", "https://app.heygen.com/oauth/device", 600, "5seconds"], + ])("rejects an unsafe or malformed %s response", async (_name, uri, expiresIn, interval) => { + const fetchImpl = (async () => + new Response( + JSON.stringify({ + device_code: "never-log-this-device-code", + user_code: "ABCD-2345", + verification_uri: uri, + expires_in: expiresIn, + interval, + }), + { status: 200, headers: { "content-type": "application/json" } }, + )) as typeof fetch; + + await expect(startDeviceAuthorizationFlow({ fetchImpl })).rejects.toThrow( + /Device authorization failed/, + ); + }); + + it("rejects an oversized device response without exposing its body", async () => { + const marker = "never-log-this-device-code"; + const fetchImpl = (async () => + new Response(JSON.stringify({ padding: marker.repeat(8_000) }), { + status: 200, + headers: { "content-type": "application/json" }, + })) as typeof fetch; + + await expect(startDeviceAuthorizationFlow({ fetchImpl })).rejects.not.toThrow(marker); + }); + }); }); diff --git a/packages/cli/src/auth/oauth.ts b/packages/cli/src/auth/oauth.ts index 6cb4a3648..a1c0a2047 100644 --- a/packages/cli/src/auth/oauth.ts +++ b/packages/cli/src/auth/oauth.ts @@ -34,7 +34,13 @@ import { failCommand } from "../utils/commandResult.js"; * Public client — no `client_secret`. */ -import { ErrApi, ErrOAuthNotConfigured, ErrRefreshFailed, isAuthError } from "./errors.js"; +import { + ErrApi, + ErrDeviceAuthFailed, + ErrOAuthNotConfigured, + ErrRefreshFailed, + isAuthError, +} from "./errors.js"; import { generatePkcePair, generateState } from "./pkce.js"; import { startLoopback } from "./loopback.js"; import { openBrowser } from "./browser.js"; @@ -45,6 +51,7 @@ import { writeStore, type Credentials, type OAuthTokens, + type StoredUserInfo, } from "./store.js"; import { c } from "../ui/colors.js"; @@ -65,6 +72,13 @@ const DEFAULT_SCOPES = "openid profile email"; const DEFAULT_AUTHORIZE_URL = "https://app.heygen.com/oauth/authorize"; const DEFAULT_TOKEN_URL = "https://api2.heygen.com/v1/oauth/token"; const DEFAULT_REVOKE_URL = "https://api2.heygen.com/v1/oauth/revoke"; +const DEFAULT_DEVICE_AUTHORIZATION_URL = "https://api2.heygen.com/v1/oauth/device_authorization"; +const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"; +const MAX_DEVICE_FLOW_SECONDS = 30 * 60; +const MIN_DEVICE_POLL_SECONDS = 5; +const MAX_DEVICE_POLL_SECONDS = 60; +const MAX_DEVICE_RESPONSE_BYTES = 64 * 1024; +const DEVICE_REQUEST_TIMEOUT_MS = 15_000; function authorizeEndpoint(): string { return process.env["HYPERFRAMES_OAUTH_AUTHORIZE_URL"] || DEFAULT_AUTHORIZE_URL; @@ -75,6 +89,9 @@ function tokenEndpoint(): string { function revokeEndpoint(): string { return process.env["HYPERFRAMES_OAUTH_REVOKE_URL"] || DEFAULT_REVOKE_URL; } +function deviceAuthorizationEndpoint(): string { + return process.env["HYPERFRAMES_OAUTH_DEVICE_URL"] || DEFAULT_DEVICE_AUTHORIZATION_URL; +} export interface AuthorizeFlowOptions { /** Override scopes (default `openid profile email`). */ @@ -95,6 +112,27 @@ export interface RefreshOptions { fetchImpl?: typeof fetch; } +export interface DeviceAuthorizationChallenge { + userCode: string; + verificationUri: string; + verificationUriComplete?: string; +} + +export interface DeviceAuthorizationFlowOptions { + /** Override scopes (default `openid profile email`). */ + scope?: string; + /** Inject a custom fetch (used by tests). */ + fetchImpl?: typeof fetch; + /** Inject polling sleep (used by tests). */ + sleepImpl?: (ms: number) => Promise; + /** Inject a monotonic-enough clock in epoch milliseconds (used by tests). */ + now?: () => number; + /** Bound each authorization-server request, including its response body (default 15s). */ + requestTimeoutMs?: number; + /** Present the user code and verification URI without exposing device_code. */ + onChallenge?: (challenge: DeviceAuthorizationChallenge) => void | Promise; +} + /** Read the client_id, throwing `ErrOAuthNotConfigured` when unset. */ export function resolveClientId(): string { const override = process.env["HYPERFRAMES_OAUTH_CLIENT_ID"]; @@ -171,6 +209,177 @@ export async function startAuthorizationCodeFlow( return { tokens }; } +/** + * RFC 8628 attended device flow. This function deliberately returns an + * unpersisted token set: the command must verify `/v3/users/me` first and only + * then call `persistFreshOAuth`. That ordering prevents a token for the wrong + * account/resource from ever becoming the active shared credential. + */ +export async function startDeviceAuthorizationFlow( + opts: DeviceAuthorizationFlowOptions = {}, +): Promise { + const runtime: DeviceFlowRuntime = { + clientId: resolveClientId(), + fetchImpl: opts.fetchImpl ?? fetch, + sleepImpl: + opts.sleepImpl ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))), + now: opts.now ?? Date.now, + requestTimeoutMs: opts.requestTimeoutMs ?? DEVICE_REQUEST_TIMEOUT_MS, + }; + const issuance = await requestDeviceAuthorization(runtime, opts.scope ?? DEFAULT_SCOPES); + await opts.onChallenge?.({ + userCode: issuance.userCode, + verificationUri: issuance.verificationUri, + ...(issuance.verificationUriComplete + ? { verificationUriComplete: issuance.verificationUriComplete } + : {}), + }); + return await pollDeviceToken(runtime, issuance); +} + +interface DeviceFlowRuntime { + clientId: string; + fetchImpl: typeof fetch; + sleepImpl: (ms: number) => Promise; + now: () => number; + requestTimeoutMs: number; +} + +async function requestDeviceAuthorization( + runtime: DeviceFlowRuntime, + scope: string, +): Promise { + return await withDeviceRequestTimeout( + runtime, + "could not reach the authorization server", + async (signal) => { + const response = await runtime.fetchImpl(deviceAuthorizationEndpoint(), { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + accept: "application/json", + }, + body: new URLSearchParams({ client_id: runtime.clientId, scope }).toString(), + signal, + }); + if (!response.ok) { + throw ErrDeviceAuthFailed(`authorization server returned HTTP ${response.status}`); + } + return parseDeviceAuthorizationResponse(await readJsonOrDeviceError(response)); + }, + ); +} + +async function pollDeviceToken( + runtime: DeviceFlowRuntime, + issuance: ParsedDeviceAuthorization, +): Promise { + const deadline = runtime.now() + Math.min(issuance.expiresIn, MAX_DEVICE_FLOW_SECONDS) * 1000; + let intervalSeconds = issuance.interval; + while (runtime.now() < deadline) { + const remainingMs = deadline - runtime.now(); + if (remainingMs <= 0) break; + await runtime.sleepImpl(Math.min(intervalSeconds * 1000, remainingMs)); + + const result = await requestDeviceToken(runtime, issuance.deviceCode); + if (result.tokens) return result.tokens; + if (result.slowDown) { + intervalSeconds = Math.min( + Math.max(intervalSeconds + 5, result.retryAfterSeconds ?? 0), + MAX_DEVICE_POLL_SECONDS, + ); + } + } + throw ErrDeviceAuthFailed("the code expired"); +} + +async function requestDeviceToken( + runtime: DeviceFlowRuntime, + deviceCode: string, +): Promise { + return await withDeviceRequestTimeout( + runtime, + "lost contact with the authorization server", + async (signal) => { + const response = await runtime.fetchImpl(tokenEndpoint(), { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: DEVICE_CODE_GRANT_TYPE, + device_code: deviceCode, + client_id: runtime.clientId, + }).toString(), + signal, + }); + return await evaluateDevicePollResponse(response, runtime.now()); + }, + ); +} + +interface DevicePollResult { + tokens?: OAuthTokens; + slowDown?: boolean; + retryAfterSeconds?: number; +} + +async function evaluateDevicePollResponse( + response: Response, + nowMs: number, +): Promise { + if (response.ok) { + return { tokens: parseTokenResponse(await readJsonOrDeviceError(response)) }; + } + + const error = await readDeviceOAuthError(response); + switch (error) { + case "authorization_pending": + return {}; + case "slow_down": + return { slowDown: true, retryAfterSeconds: retryAfterSeconds(response, nowMs) }; + case "access_denied": + throw ErrDeviceAuthFailed("access was denied"); + case "expired_token": + throw ErrDeviceAuthFailed("the code expired"); + default: + if (response.status === 429) { + return { slowDown: true, retryAfterSeconds: retryAfterSeconds(response, nowMs) }; + } + throw ErrDeviceAuthFailed(`authorization server returned HTTP ${response.status}`); + } +} + +async function withDeviceRequestTimeout( + runtime: DeviceFlowRuntime, + networkError: string, + operation: (signal: AbortSignal) => Promise, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), runtime.requestTimeoutMs); + try { + return await operation(controller.signal); + } catch (err) { + if (controller.signal.aborted) { + throw ErrDeviceAuthFailed("authorization server request timed out"); + } + if (isAuthError(err)) throw err; + throw ErrDeviceAuthFailed(networkError); + } finally { + clearTimeout(timer); + } +} + +function retryAfterSeconds(response: Response, nowMs: number): number | undefined { + const value = response.headers.get("retry-after")?.trim(); + if (!value) return undefined; + if (/^\d+$/.test(value)) return Math.min(Number(value), MAX_DEVICE_POLL_SECONDS); + const retryAt = Date.parse(value); + if (!Number.isFinite(retryAt)) return undefined; + return Math.min(Math.max(Math.ceil((retryAt - nowMs) / 1000), 0), MAX_DEVICE_POLL_SECONDS); +} + export async function refreshTokens( refresh_token: string, opts: RefreshOptions = {}, @@ -422,6 +631,201 @@ async function persistOAuth( await writeStore({ ...existing, oauth }); } +/** Persist a verified fresh OAuth login while preserving cross-CLI fields. */ +export async function persistFreshOAuth(tokens: OAuthTokens): Promise { + await persistOAuth(tokens, { preserveMissing: false }); +} + +/** + * Atomically install a verified device session and its identity metadata. + * + * This is intentionally one credential-file rename: if the write fails, the + * previous credential remains intact and the caller can revoke the freshly + * minted tokens without leaving a half-installed session or stale identity. + */ +export async function persistVerifiedOAuthSession( + tokens: OAuthTokens, + user: StoredUserInfo, +): Promise { + let credentials: Credentials = {}; + try { + ({ credentials } = await readStore()); + } catch { + // Match the other fresh-login path: a corrupt prior file must not prevent + // installing a newly verified session. + credentials = {}; + } + const next: Credentials = { + ...credentials, + oauth: { ...tokens }, + }; + if (user.email || user.first_name || user.last_name || user.username) { + // Preserve only unknown/foreign user fields from the existing record. + // Assigning every known field (including undefined) prevents identity + // fields from the previous account surviving when the new response omits + // them; serializeUser skips undefined values. + next.user = { + ...credentials.user, + email: user.email, + first_name: user.first_name, + last_name: user.last_name, + username: user.username, + }; + } else { + delete next.user; + } + await writeStore(next); +} + +interface ParsedDeviceAuthorization { + deviceCode: string; + userCode: string; + verificationUri: string; + verificationUriComplete?: string; + expiresIn: number; + interval: number; +} + +function parseDeviceAuthorizationResponse(payload: unknown): ParsedDeviceAuthorization { + const data = requireDeviceAuthorizationRecord(payload); + const deviceCode = stringField(data, "device_code"); + const userCode = stringField(data, "user_code"); + const verificationUri = requiredSafeVerificationUri(data, "verification_uri"); + const verificationUriComplete = optionalSafeVerificationUri(data, "verification_uri_complete"); + const expiresIn = strictNumericField(data, "expires_in"); + const interval = strictNumericField(data, "interval"); + requireSafeDeviceCode(deviceCode); + requireSafeDeviceCode(userCode); + const timing = normalizeDeviceAuthorizationTiming(data, expiresIn, interval); + return { + deviceCode, + userCode, + verificationUri, + ...(verificationUriComplete ? { verificationUriComplete } : {}), + ...timing, + }; +} + +function requireSafeDeviceCode(value: string | undefined): asserts value is string { + if (!value || !isHeaderSafe(value)) { + throw ErrDeviceAuthFailed("authorization server returned an invalid response"); + } +} + +function normalizeDeviceAuthorizationTiming( + data: Record, + expiresIn: number | undefined, + interval: number | undefined, +): Pick { + if ( + !isPositiveNumber(expiresIn) || + (data["interval"] !== undefined && !isPositiveNumber(interval)) + ) { + throw ErrDeviceAuthFailed("authorization server returned invalid timing values"); + } + return { + expiresIn, + interval: Math.min( + Math.max(Math.ceil(interval ?? MIN_DEVICE_POLL_SECONDS), MIN_DEVICE_POLL_SECONDS), + MAX_DEVICE_POLL_SECONDS, + ), + }; +} + +function requiredSafeVerificationUri(data: Record, key: string): string { + const value = normalizeSafeVerificationUri(stringField(data, key)); + if (!value) throw ErrDeviceAuthFailed("authorization server returned an unsafe verification URL"); + return value; +} + +function optionalSafeVerificationUri( + data: Record, + key: string, +): string | undefined { + if (data[key] === undefined) return undefined; + return requiredSafeVerificationUri(data, key); +} + +function requireDeviceAuthorizationRecord(payload: unknown): Record { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + throw ErrDeviceAuthFailed("authorization server returned an invalid response"); + } + return payload as Record; +} + +function isPositiveNumber(value: number | undefined): value is number { + return value !== undefined && value > 0; +} + +function normalizeSafeVerificationUri(value: string | undefined): string | undefined { + if (!value || !isHeaderSafe(value)) return undefined; + try { + const url = new URL(value); + if (url.username || url.password) return undefined; + const allowed = + url.protocol === "https:" || + (url.protocol === "http:" && ["127.0.0.1", "localhost"].includes(url.hostname)); + return allowed ? url.href : undefined; + } catch { + return undefined; + } +} + +async function readJsonOrDeviceError(res: Response): Promise { + return await readBoundedDeviceJson(res); +} + +async function readDeviceOAuthError(res: Response): Promise { + try { + const payload = await readBoundedDeviceJson(res); + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined; + const error = (payload as Record)["error"]; + return typeof error === "string" ? error : undefined; + } catch { + return undefined; + } +} + +function strictNumericField(obj: Record, key: string): number | undefined { + const value = obj[key]; + if (typeof value === "number") return Number.isFinite(value) ? value : undefined; + if (typeof value !== "string" || value.trim() === "") return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +async function readBoundedDeviceJson(res: Response): Promise { + if (!res.body) throw ErrDeviceAuthFailed("authorization server returned no data"); + const reader = res.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + total += value.byteLength; + if (total > MAX_DEVICE_RESPONSE_BYTES) { + await reader.cancel(); + throw ErrDeviceAuthFailed("authorization server response was too large"); + } + chunks.push(value); + } + const body = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return JSON.parse(new TextDecoder().decode(body)); + } catch (err) { + if (isAuthError(err)) throw err; + throw ErrDeviceAuthFailed("authorization server returned non-JSON data"); + } finally { + reader.releaseLock(); + } +} + async function readJsonOrThrow(res: Response): Promise { try { return await res.json(); diff --git a/packages/cli/src/commands/auth.ts b/packages/cli/src/commands/auth.ts index f6beeecd2..9c202b536 100644 --- a/packages/cli/src/commands/auth.ts +++ b/packages/cli/src/commands/auth.ts @@ -17,6 +17,7 @@ import { c } from "../ui/colors.js"; export const examples: Example[] = [ ["Sign in via browser (OAuth)", "hyperframes auth login"], + ["Sign in from SSH/headless terminal", "hyperframes auth login --device"], ["Save an API key (interactive)", "hyperframes auth login --api-key"], ["Save an API key from stdin", "echo $HEYGEN_API_KEY | hyperframes auth login --api-key"], ["Check who you're signed in as", "hyperframes auth status"], @@ -31,7 +32,7 @@ Manage HeyGen credentials. Credentials live in ${c.accent("~/.heygen/credentials")} and are shared with heygen-cli. ${c.bold("SUBCOMMANDS:")} - ${c.accent("login")} ${c.dim("Sign in via browser (default) or --api-key for a long-lived key.")} + ${c.accent("login")} ${c.dim("Sign in via browser, --device for SSH, or --api-key for a long-lived key.")} ${c.accent("status")} ${c.dim("Show the active credential's source, type, and identity.")} ${c.accent("refresh")} ${c.dim("Force-refresh the OAuth access token.")} ${c.accent("logout")} ${c.dim("Remove the stored credential (--keep-api-key for OAuth-only).")} @@ -42,6 +43,7 @@ ${c.bold("ENV VARS:")} ${c.accent("HEYGEN_API_URL")} Override the API base URL (default https://api.heygen.com). ${c.accent("HEYGEN_CONFIG_DIR")} Override the credentials directory (default ~/.heygen). ${c.accent("HYPERFRAMES_OAUTH_CLIENT_ID")} Override the OAuth client_id (for dev/test). + ${c.accent("HYPERFRAMES_OAUTH_DEVICE_URL")} Override the RFC 8628 device endpoint (for dev/test). `; export default defineCommand({ diff --git a/packages/cli/src/commands/auth/login.test.ts b/packages/cli/src/commands/auth/login.test.ts index 72c64f3c8..9a5d666de 100644 --- a/packages/cli/src/commands/auth/login.test.ts +++ b/packages/cli/src/commands/auth/login.test.ts @@ -18,20 +18,6 @@ const verifyState = vi.hoisted( }, ); -vi.mock("../../auth/index.js", async (orig) => { - const actual = await orig(); - class MockAuthClient { - async getCurrentUser(): Promise> { - if (verifyState.reject) { - const { ErrUnauthenticated: rej } = await import("../../auth/errors.js"); - throw rej("invalid key"); - } - return verifyState.user; - } - } - return { ...actual, AuthClient: MockAuthClient }; -}); - // Spy on the telemetry the login flow emits, so we can assert the identity is // attributed on success. login.ts imports these via a dynamic import of // telemetry/index.js; the mock intercepts it. @@ -43,16 +29,90 @@ const telemetry = vi.hoisted(() => ({ })); vi.mock("../../telemetry/index.js", () => telemetry); -describe("auth login --api-key rollback", () => { +const deviceChallenge = vi.hoisted(() => ({ + verificationUriComplete: undefined as string | undefined, +})); + +const deviceAuth = vi.hoisted(() => ({ + start: vi.fn(async (options?: { onChallenge?: (value: unknown) => void }) => { + options?.onChallenge?.({ + verificationUri: "https://app.heygen.com/oauth/device", + ...(deviceChallenge.verificationUriComplete + ? { verificationUriComplete: deviceChallenge.verificationUriComplete } + : {}), + userCode: "ABCD-2345", + }); + return { + access_token: "device-at", + refresh_token: "device-rt", + token_type: "Bearer", + }; + }), + persist: vi.fn(async () => {}), + revoke: vi.fn(async () => {}), +})); + +vi.mock("../../auth/index.js", async (orig) => { + const actual = await orig(); + class MockAuthClient { + async getCurrentUser(): Promise> { + if (verifyState.reject) { + const { ErrUnauthenticated: rej } = await import("../../auth/errors.js"); + throw rej("invalid token"); + } + return verifyState.user; + } + } + return { + ...actual, + AuthClient: MockAuthClient, + startDeviceAuthorizationFlow: deviceAuth.start, + persistVerifiedOAuthSession: deviceAuth.persist, + revokeTokens: deviceAuth.revoke, + }; +}); + +describe("auth login", () => { let dir: string; let envFixture: EnvFixture; + let runtimeEnv: Record; + let stdinTTYDescriptor: PropertyDescriptor | undefined; + let stdoutTTYDescriptor: PropertyDescriptor | undefined; beforeEach(async () => { + runtimeEnv = Object.fromEntries( + [ + "CI", + "SSH_CONNECTION", + "SSH_CLIENT", + "SSH_TTY", + "BROWSER", + "HF_NO_BROWSER", + "CODESPACES", + "GITHUB_CODESPACES", + "REMOTE_CONTAINERS", + "GITPOD_WORKSPACE_ID", + "container", + ].map((key) => [key, process.env[key]]), + ); + for (const key of Object.keys(runtimeEnv)) delete process.env[key]; + stdinTTYDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); + stdoutTTYDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: true, + }); + Object.defineProperty(process.stdout, "isTTY", { + configurable: true, + value: true, + }); envFixture = await setupTempAuthEnv("hf-login-"); dir = envFixture.dir; verifyState.reject = false; verifyState.user = { email: "alice@example.com" }; + deviceChallenge.verificationUriComplete = undefined; for (const fn of Object.values(telemetry)) fn.mockClear(); + for (const fn of Object.values(deviceAuth)) fn.mockClear(); vi.spyOn(console, "log").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => {}); }); @@ -60,16 +120,28 @@ describe("auth login --api-key rollback", () => { afterEach(async () => { vi.restoreAllMocks(); await envFixture.restore(); + for (const [key, value] of Object.entries(runtimeEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + if (stdinTTYDescriptor) Object.defineProperty(process.stdin, "isTTY", stdinTTYDescriptor); + else delete (process.stdin as { isTTY?: boolean }).isTTY; + if (stdoutTTYDescriptor) Object.defineProperty(process.stdout, "isTTY", stdoutTTYDescriptor); + else delete (process.stdout as { isTTY?: boolean }).isTTY; }); - async function runLogin(apiKey: string): Promise { + async function runCommand(args: Record): Promise { const cmd = (await import("./login.js")).default; // citty command run only reads `args` here. await (cmd.run as (ctx: { args: Record }) => Promise)({ - args: { "api-key": apiKey }, + args, }); } + async function runLogin(apiKey: string): Promise { + await runCommand({ "api-key": apiKey }); + } + it("removes the rejected key on a failed FIRST login (no prior credential)", async () => { verifyState.reject = true; await expect(runLogin("hg_badkey123")).rejects.toThrow(CliRuntimeError); @@ -128,7 +200,10 @@ describe("auth login --api-key rollback", () => { }); it("rollback on a rejected key restores the previous user block too", async () => { - await writeStore({ api_key: "hg_prev", user: { email: "prev@example.com" } }); + await writeStore({ + api_key: "hg_prev", + user: { email: "prev@example.com" }, + }); verifyState.reject = true; await expect(runLogin("hg_badnewkey")).rejects.toThrow(CliRuntimeError); @@ -198,4 +273,90 @@ describe("auth login --api-key rollback", () => { expect(onDisk.user).toEqual({ email: "jane@example.com" }); expect(onDisk.future_field).toEqual({ x: 1 }); }); + + it("requires the explicit --device flag in a remote terminal", async () => { + process.env["SSH_CONNECTION"] = "192.0.2.1 1234 192.0.2.2 22"; + await expect(runCommand({})).rejects.toThrow(/Invalid command usage/); + expect(deviceAuth.start).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("hyperframes auth login --device"), + ); + }); + + it.each([ + "CODESPACES", + "GITHUB_CODESPACES", + "REMOTE_CONTAINERS", + "GITPOD_WORKSPACE_ID", + "container", + ])("requires --device in the %s remote environment", async (name) => { + process.env[name] = "true"; + await expect(runCommand({})).rejects.toThrow(/Invalid command usage/); + expect(deviceAuth.start).not.toHaveBeenCalled(); + }); + + it("opens verification_uri_complete without asking the user to re-enter the code", async () => { + deviceChallenge.verificationUriComplete = + "https://app.heygen.com/oauth/device?user_code=ABCD-2345"; + + await runCommand({ device: true }); + + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining("https://app.heygen.com/oauth/device?user_code=ABCD-2345"), + ); + expect(console.log).not.toHaveBeenCalledWith(expect.stringContaining("Enter code")); + }); + + it("verifies the device token before persisting it", async () => { + verifyState.user = { email: "device@example.com" }; + await runCommand({ device: true }); + + expect(deviceAuth.start).toHaveBeenCalledOnce(); + expect(deviceAuth.persist).toHaveBeenCalledWith( + expect.objectContaining({ access_token: "device-at" }), + expect.objectContaining({ email: "device@example.com" }), + ); + expect(deviceAuth.revoke).not.toHaveBeenCalled(); + expect(telemetry.trackAuthLoginCompleted).toHaveBeenCalledWith("device", "device@example.com"); + }); + + it("revokes and never persists a device token that identity verification rejects", async () => { + verifyState.reject = true; + await expect(runCommand({ device: true })).rejects.toThrow(CliRuntimeError); + + expect(deviceAuth.persist).not.toHaveBeenCalled(); + expect(deviceAuth.revoke).toHaveBeenCalledTimes(2); + expect(deviceAuth.revoke).toHaveBeenNthCalledWith( + 1, + "device-at", + expect.objectContaining({ token_type_hint: "access_token" }), + ); + expect(deviceAuth.revoke).toHaveBeenNthCalledWith( + 2, + "device-rt", + expect.objectContaining({ token_type_hint: "refresh_token" }), + ); + expect(telemetry.trackAuthLoginFailed).toHaveBeenCalledWith("device", "rejected"); + }); + + it("refuses device authorization in CI", async () => { + process.env["CI"] = "true"; + await expect(runCommand({ device: true })).rejects.toThrow(/Invalid command usage/); + expect(deviceAuth.start).not.toHaveBeenCalled(); + }); + + it("does not treat CI=false as an unattended environment", async () => { + process.env["CI"] = "false"; + await runCommand({ device: true }); + expect(deviceAuth.start).toHaveBeenCalledOnce(); + }); + + it("refuses device authorization when either terminal stream is not a TTY", async () => { + Object.defineProperty(process.stdout, "isTTY", { + configurable: true, + value: undefined, + }); + await expect(runCommand({ device: true })).rejects.toThrow(/Invalid command usage/); + expect(deviceAuth.start).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 3115e4e26..e116d4aed 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -1,4 +1,4 @@ -import { failCommand } from "../../utils/commandResult.js"; +import { failCommand, failUsage } from "../../utils/commandResult.js"; /** * `hyperframes auth login` — sign in to HeyGen. * @@ -35,8 +35,11 @@ import { isUserInfoEmpty, readStore, refreshTokens, + revokeTokens, saveUserInfo, + persistVerifiedOAuthSession, startAuthorizationCodeFlow, + startDeviceAuthorizationFlow, tryResolveCredential, userDisplayName, writeStore, @@ -63,18 +66,149 @@ export default defineCommand({ type: "string", description: "API key value, or pass `--api-key` with no value to read from stdin / prompt.", }, + device: { + type: "boolean", + description: "Use an attended device code (for SSH/headless terminals; never for CI).", + }, }, // fallow-ignore-next-line complexity async run({ args }) { const inlineKey = args["api-key"]; + if (inlineKey !== undefined && args.device) { + console.error(c.error("Choose either --device or --api-key, not both.")); + failUsage(); + } if (inlineKey !== undefined) { await runApiKeyLogin(inlineKey); return; } + if (args.device) { + await runDeviceLogin(); + return; + } + if (isRemoteOrHeadless()) { + console.error( + c.error( + "Browser callback login is unavailable in this remote/headless terminal. Run `hyperframes auth login --device`.", + ), + ); + failUsage(); + } await runOAuthLogin(); }, }); +function isRemoteOrHeadless(): boolean { + const remoteEnvironment = [ + "CODESPACES", + "GITHUB_CODESPACES", + "REMOTE_CONTAINERS", + "GITPOD_WORKSPACE_ID", + "container", + ].some(envFlagEnabled); + return Boolean( + process.env["SSH_CONNECTION"] || + process.env["SSH_CLIENT"] || + process.env["SSH_TTY"] || + process.env["BROWSER"] === "none" || + process.env["HF_NO_BROWSER"] === "1" || + remoteEnvironment || + process.stdout.isTTY !== true, + ); +} + +function envFlagEnabled(name: string): boolean { + const value = process.env[name]?.trim().toLowerCase(); + return Boolean(value && value !== "0" && value !== "false" && value !== "no"); +} + +function assertAttendedDeviceFlow(): void { + if (envFlagEnabled("CI") || process.stdin.isTTY !== true || process.stdout.isTTY !== true) { + console.error( + c.error( + "`--device` requires an attended terminal and is disabled in CI. Use an API key or workload credential for automation.", + ), + ); + failUsage(); + } +} + +async function runDeviceLogin(): Promise { + assertAttendedDeviceFlow(); + assertOAuthConfiguredOrExit(); + const { trackAuthLoginStarted, trackAuthLoginCompleted, trackAuthLoginFailed, identifyUser } = + await import("../../telemetry/index.js"); + trackAuthLoginStarted("device"); + + let tokens; + try { + tokens = await startDeviceAuthorizationFlow({ + onChallenge: ({ verificationUri, verificationUriComplete, userCode }) => { + console.log(`Open ${c.accent(verificationUriComplete ?? verificationUri)} in a browser.`); + if (!verificationUriComplete) console.log(`Enter code ${c.bold(userCode)}.`); + console.log(c.dim("Waiting for approval…")); + }, + }); + } catch (err) { + const message = (err as Error).message || "Device authorization failed."; + trackAuthLoginFailed("device", /expired/i.test(message) ? "flow_timeout" : "flow_error"); + console.error(c.error(message)); + failCommand(); + } + + const credential = { + type: "oauth" as const, + access_token: tokens.access_token, + ...(tokens.refresh_token ? { refresh_token: tokens.refresh_token } : {}), + source: "file_json" as const, + refreshable: false, + }; + let user: UserInfo; + try { + user = await new AuthClient().getCurrentUser(credential); + } catch (err) { + await revokeDeviceTokens(tokens); + trackAuthLoginFailed("device", "rejected"); + console.error( + c.error( + `HeyGen could not verify the approved device session; no credential was saved. ${(err as Error).message}`, + ), + ); + failCommand(); + } + + try { + await persistVerifiedOAuthSession(tokens, toStoredUserInfo(user)); + } catch (err) { + await revokeDeviceTokens(tokens); + trackAuthLoginFailed("device", "flow_error"); + console.error( + c.error( + `Could not save the verified device session; it was revoked. ${(err as Error).message}`, + ), + ); + failCommand(); + } + + const id = identityKey(user); + if (id) identifyUser(id); + trackAuthLoginCompleted("device", id); + const identity = userDisplayName(toStoredUserInfo(user)) ?? "(unknown user)"; + console.log(c.success(`✓ Signed in as ${identity}.`)); +} + +async function revokeDeviceTokens(tokens: { + access_token: string; + refresh_token?: string; +}): Promise { + await revokeTokens(tokens.access_token, { token_type_hint: "access_token" }); + if (tokens.refresh_token) { + await revokeTokens(tokens.refresh_token, { + token_type_hint: "refresh_token", + }); + } +} + // fallow-ignore-next-line complexity async function runOAuthLogin(): Promise { assertOAuthConfiguredOrExit(); @@ -286,7 +420,11 @@ async function rollback(previous: Credentials): Promise { async function verifyAndReport(key: string): Promise { const client = new AuthClient(); try { - const user = await client.getCurrentUser({ type: "api_key", key, source: "file_json" }); + const user = await client.getCurrentUser({ + type: "api_key", + key, + source: "file_json", + }); // Persist the friendly-display block next to the now-verified api_key // so `auth status` can show a recognizable identity. Best-effort. await persistUserInfo(user); diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index a7316ea31..8d6ebf1e7 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -399,7 +399,8 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { await import("../../../producer/src/services/deterministicFonts.js"); const { prepareAnimatedGifInputs } = await import("../../../producer/src/services/animatedGifPrep.js"); - const { downloadToTemp } = await import("../../../producer/src/utils/urlDownloader.js"); + const { downloadToTemp, writeUrlDownloadTelemetry } = + await import("../../../producer/src/utils/urlDownloader.js"); const gifOutputDir = join(project.dir, ".hyperframes", "prepared-assets", "gif"); const gifDownloadDir = join(project.dir, ".hyperframes", "prepared-assets", "downloads"); const prepared = await prepareAnimatedGifInputs(html, { @@ -408,7 +409,11 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { outputDir: gifOutputDir, outputSrcPrefix: ".hyperframes/prepared-assets/gif", cacheDir: gifOutputDir, - sourceAssets: await downloadRemoteGifImageSources(html, gifDownloadDir, downloadToTemp), + sourceAssets: await downloadRemoteGifImageSources(html, gifDownloadDir, (url, destDir) => + downloadToTemp(url, destDir, undefined, undefined, undefined, { + onTelemetry: writeUrlDownloadTelemetry, + }), + ), }); return injectDeterministicFontFaces(prepared.html); }, diff --git a/packages/cli/src/telemetry/events.ts b/packages/cli/src/telemetry/events.ts index ad7bd9e1b..6b8874a78 100644 --- a/packages/cli/src/telemetry/events.ts +++ b/packages/cli/src/telemetry/events.ts @@ -507,7 +507,8 @@ export function trackBrowserInstall(): void { // dashboards — a completed sign-in, a browser flow the user abandoned, and a // rejected key all look identical (i.e. absent). These three events close that // gap so the sign-in funnel is measurable like the render funnel already is. -// `method` is "oauth" (the default browser PKCE flow) or "api_key". No token, +// `method` is "oauth" (the default browser PKCE flow), "device" (attended +// RFC 8628 flow), or "api_key". No token, // key, identity, email, or free text is ever attached — only the method and a // low-cardinality outcome/reason. // @@ -516,7 +517,7 @@ export function trackBrowserInstall(): void { // today (events attribute to the install's anonymousId), but pre-plumbing it // makes attributing a completed sign-in to a resolved identity later a one-line // change at the callsite rather than a signature sweep. -export type AuthLoginMethod = "oauth" | "api_key"; +export type AuthLoginMethod = "oauth" | "device" | "api_key"; export type AuthLoginFailureReason = | "flow_error" // OAuth authorization/exchange threw a real error | "flow_timeout" // OAuth callback wait elapsed (user closed the tab / walked away) diff --git a/packages/core/package.json b/packages/core/package.json index 80fddd708..9d1c8bec9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@hyperframes/core", - "version": "0.7.92", + "version": "0.7.94", "description": "", "repository": { "type": "git", diff --git a/packages/engine/package.json b/packages/engine/package.json index 4e5c19297..334acebdb 100644 --- a/packages/engine/package.json +++ b/packages/engine/package.json @@ -1,6 +1,6 @@ { "name": "@hyperframes/engine", - "version": "0.7.92", + "version": "0.7.94", "description": "Seekable web page to video rendering engine (Puppeteer + FFmpeg)", "repository": { "type": "git", diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 7a1630659..2217bde2c 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -120,6 +120,7 @@ export { getCapturePerfSummary, percentileOf, prepareCaptureSessionForReuse, + deriveBeginFrameProbeTimeTicks, type CaptureSession, isTransientBrowserError, isMemoryExhaustionError, @@ -273,7 +274,18 @@ export { type KeyframeAnalysis, } from "./utils/ffprobe.js"; -export { assertPublicHttpsUrl, downloadToTemp, isHttpUrl } from "./utils/urlDownloader.js"; +export { + assertPublicHttpsUrl, + downloadToTemp, + fetchPublicHttpsText, + isHttpUrl, + safeDownloadUrlIdentity, + writeUrlDownloadTelemetry, + type SafeDownloadUrlIdentity, + type UrlDownloadOptions, + type UrlDownloadTelemetry, + type PublicHttpsTextOptions, +} from "./utils/urlDownloader.js"; export { runFfmpeg, formatFfmpegError, diff --git a/packages/engine/src/services/audioMixer.test.ts b/packages/engine/src/services/audioMixer.test.ts index bc49a149e..36531822d 100644 --- a/packages/engine/src/services/audioMixer.test.ts +++ b/packages/engine/src/services/audioMixer.test.ts @@ -52,6 +52,7 @@ describe("processCompositionAudio", () => { const tempDirs: string[] = []; afterEach(() => { + vi.unstubAllGlobals(); runFfmpegMock.mockClear(); extractAudioMetadataMock.mockReset(); extractAudioMetadataMock.mockResolvedValue({ @@ -66,6 +67,44 @@ describe("processCompositionAudio", () => { } }); + it("classifies an HTML-as-200 audio source as deterministic user input", async () => { + const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-")); + const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-")); + tempDirs.push(baseDir, workDir); + const fetchMock = vi + .fn() + .mockResolvedValue(new Response("denied")); + vi.stubGlobal("fetch", fetchMock); + + const result = await processCompositionAudio( + [ + { + id: "remote-voice", + src: "https://cdn.example/voice", + start: 0, + end: 2, + mediaStart: 0, + layer: 0, + volume: 1, + type: "audio", + }, + ], + baseDir, + workDir, + join(baseDir, "out.m4a"), + 2, + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(result.failures).toEqual([ + expect.objectContaining({ + stage: "download", + owner: "user", + retryable: false, + }), + ]); + }); + it.each([ { message: "AbortError: ffprobe operation aborted", diff --git a/packages/engine/src/services/audioMixer.ts b/packages/engine/src/services/audioMixer.ts index 1b063f3fd..a3b3ad060 100644 --- a/packages/engine/src/services/audioMixer.ts +++ b/packages/engine/src/services/audioMixer.ts @@ -9,7 +9,12 @@ import { closeSync, existsSync, mkdirSync, mkdtempSync, openSync, rmSync, writeF import { join, dirname } from "path"; import { parseHTML } from "linkedom"; import { extractAudioMetadata } from "../utils/ffprobe.js"; -import { downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js"; +import { + downloadToTemp, + isHttpUrl, + UrlDownloadError, + writeUrlDownloadTelemetry, +} from "../utils/urlDownloader.js"; import { DEFAULT_CONFIG, type EngineConfig } from "../config.js"; import { formatFfmpegError, runFfmpeg, type RunFfmpegResult } from "../utils/runFfmpeg.js"; import { unwrapTemplate } from "../utils/htmlTemplate.js"; @@ -241,16 +246,23 @@ function probeFailure(message: string, elementId: string): AudioProcessingFailur }; } -function downloadFailure(message: string, elementId: string): AudioProcessingFailure { +function downloadFailure(error: unknown, elementId: string): AudioProcessingFailure { + const message = error instanceof Error ? error.message : String(error); const invalidSource = - /(?:invalid URL|only HTTPS|private\/reserved|HTTP (?:400|401|403|404|405|410|422)\b)/i.test( - message, - ); + error instanceof UrlDownloadError + ? error.kind === "http_not_found" || + error.kind === "http_rejected" || + error.kind === "invalid_payload" || + error.kind === "cancelled" + : /(?:invalid URL|only HTTPS|private\/reserved|HTTP (?:400|401|403|404|405|410|422)\b)/i.test( + message, + ); + const retryable = error instanceof UrlDownloadError ? error.retryable : !invalidSource; return { stage: "download", reason: "download_failed", owner: invalidSource ? "user" : "system", - retryable: !invalidSource, + retryable, elementId, detail: boundedDetail(`Download failed for audio element ${elementId}: ${message}`), }; @@ -712,11 +724,11 @@ export async function processCompositionAudio( if (isHttpUrl(srcPath)) { try { - srcPath = await downloadToTemp(srcPath, workDir); + srcPath = await downloadToTemp(srcPath, workDir, undefined, signal, undefined, { + onTelemetry: writeUrlDownloadTelemetry, + }); } catch (err: unknown) { - failures.push( - downloadFailure(err instanceof Error ? err.message : String(err), element.id), - ); + failures.push(downloadFailure(err, element.id)); return; } } diff --git a/packages/engine/src/services/browserManager.test.ts b/packages/engine/src/services/browserManager.test.ts index db6e7e468..dfa068e5d 100644 --- a/packages/engine/src/services/browserManager.test.ts +++ b/packages/engine/src/services/browserManager.test.ts @@ -241,11 +241,68 @@ describe("resolveBrowserGpuMode", () => { expect(mode).toBe("software"); }); - it("passes 'hardware' through unchanged without probing", async () => { + it("passes 'hardware' through unchanged", async () => { + setMockWebGlProbe({ hasWebGL: true, vendor: "NVIDIA", renderer: "NVIDIA GeForce RTX 3070" }); const mode = await resolveBrowserGpuMode("hardware"); expect(mode).toBe("hardware"); }); + it("warns when explicit 'hardware' probes to software, but still honours it", async () => { + // heygen-com/hyperframes#2967: `--browser-gpu` inside a container with no + // GPU passthrough rendered 19186 frames on CPU with no diagnostic. + setMockWebGlProbe({ + hasWebGL: true, + vendor: "Google Inc. (Google)", + renderer: "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device))", + }); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const mode = await resolveBrowserGpuMode("hardware", { platform: "linux" }); + expect(mode).toBe("hardware"); + const warning = warn.mock.calls.map((call) => String(call[0])).join("\n"); + expect(warning).toContain("browserGpuMode=hardware was requested"); + expect(warning).toContain("--gpus all"); + // Once per process, not once per worker — the render path resolves the + // mode for the probe browser plus every parallel worker. + await resolveBrowserGpuMode("hardware", { platform: "linux" }); + await resolveBrowserGpuMode("hardware", { platform: "linux" }); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it("stays quiet when explicit 'hardware' probes to hardware", async () => { + setMockWebGlProbe({ hasWebGL: true, vendor: "NVIDIA", renderer: "NVIDIA GeForce RTX 3070" }); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + expect(await resolveBrowserGpuMode("hardware")).toBe("hardware"); + expect(warn).not.toHaveBeenCalled(); + }); + + it("gives non-linux hosts the generic remediation, not the Docker one", async () => { + setMockWebGlProbe({ + hasWebGL: true, + vendor: "Google Inc. (Google)", + renderer: "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device))", + }); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + expect(await resolveBrowserGpuMode("hardware", { platform: "darwin" })).toBe("hardware"); + const warning = String(warn.mock.calls[0]?.[0]); + expect(warning).toContain("host exposes a GPU"); + expect(warning).not.toContain("--gpus all"); + }); + + it("does not blame the GPU when the probe itself failed to launch", async () => { + // A probe that could not run is NO evidence about the GPU. Sending this + // operator to `--gpus all` would hide a broken Chrome install behind a + // phantom passthrough problem. + _setPuppeteerForTests({ + launch: vi.fn().mockRejectedValue(new Error("spawn ENOENT /bad/chrome")), + } as unknown as PuppeteerNode); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + expect(await resolveBrowserGpuMode("hardware", { platform: "linux" })).toBe("hardware"); + const warning = String(warn.mock.calls[0]?.[0]); + expect(warning).toContain("GPU probe could not run"); + expect(warning).toContain("hyperframes doctor"); + expect(warning).not.toContain("--gpus all"); + }); + it("falls back to 'software' when the probe browser cannot launch", async () => { // No chromePath, env unset, and (in the test env) no system Chrome to find // → puppeteer.launch will throw → caller catches → software fallback. @@ -272,31 +329,45 @@ describe("resolveBrowserGpuMode", () => { expect(second).toBe("software"); // Reset and re-probe to confirm the test-only reset works. _resetAutoBrowserGpuModeCacheForTests(); - const third = await resolveBrowserGpuMode("hardware"); + setMockWebGlProbe({ hasWebGL: true, vendor: "NVIDIA", renderer: "NVIDIA GeForce RTX 3070" }); + const third = await resolveBrowserGpuMode("auto"); expect(third).toBe("hardware"); }); - it("deduplicates concurrent auto-mode probes by caching the in-flight Promise", async () => { + it("deduplicates concurrent probes so only one Chrome launches", async () => { // Parallel coordinator fires N workers via Promise.all — without Promise- // level caching, a `--workers 4` render against a no-GPU host would launch - // 4 simultaneous probe Chromes. Verify all concurrent callers get the - // exact same Promise reference (proving the probe runs once, not N times). - const p1 = resolveBrowserGpuMode("auto", { - chromePath: "/definitely/not/a/real/chrome/binary", - browserTimeout: 2000, + // 4 simultaneous probe Chromes. Assert the launch count directly rather + // than Promise identity: `"auto"` and `"hardware"` now each adapt the + // shared cached Promise via `.then`, so identity is no longer the + // invariant — "the probe browser starts exactly once" is. + const { launch } = setMockWebGlProbe({ + hasWebGL: true, + vendor: "Google Inc. (Google)", + renderer: "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device))", }); - const p2 = resolveBrowserGpuMode("auto", { - chromePath: "/definitely/not/a/real/chrome/binary", - browserTimeout: 2000, - }); - const p3 = resolveBrowserGpuMode("auto", { - chromePath: "/definitely/not/a/real/chrome/binary", - browserTimeout: 2000, - }); - expect(p1).toBe(p2); - expect(p2).toBe(p3); - const results = await Promise.all([p1, p2, p3]); + const results = await Promise.all([ + resolveBrowserGpuMode("auto", { browserTimeout: 2000 }), + resolveBrowserGpuMode("auto", { browserTimeout: 2000 }), + resolveBrowserGpuMode("auto", { browserTimeout: 2000 }), + ]); expect(results).toEqual(["software", "software", "software"]); + expect(launch).toHaveBeenCalledTimes(1); + }); + + it("shares the one probe across mixed 'auto' and 'hardware' callers", async () => { + const { launch } = setMockWebGlProbe({ + hasWebGL: true, + vendor: "NVIDIA", + renderer: "NVIDIA GeForce RTX 3070", + }); + const results = await Promise.all([ + resolveBrowserGpuMode("auto"), + resolveBrowserGpuMode("hardware"), + resolveBrowserGpuMode("auto"), + ]); + expect(results).toEqual(["hardware", "hardware", "hardware"]); + expect(launch).toHaveBeenCalledTimes(1); }); it.each([ diff --git a/packages/engine/src/services/browserManager.ts b/packages/engine/src/services/browserManager.ts index 762626eb3..899c3498b 100644 --- a/packages/engine/src/services/browserManager.ts +++ b/packages/engine/src/services/browserManager.ts @@ -422,7 +422,25 @@ export const _probeBeginFrameSupportForTests = probeBeginFrameSupport; export const _closeBrowserAfterFailedProbeForTests = closeBrowserAfterFailedProbe; /** - * Cached *in-flight or resolved* probe Promise for `resolveBrowserGpuMode("auto", ...)`. + * Outcome of the one-shot WebGL probe. + * + * `cause` distinguishes the two ways a probe lands on `"software"`, because + * they need OPPOSITE remediation: + * - `"no-gpu"` — the probe ran and Chrome reported a software renderer + * (SwiftShader / llvmpipe). Remediation: GPU passthrough. + * - `"probe-error"` — the probe itself failed (Chrome couldn't launch, bad + * executable path, sandbox denied). We have NO evidence + * about the GPU either way; telling the operator to fix GPU + * passthrough would send them chasing the wrong problem. + */ +interface GpuProbeOutcome { + mode: "software" | "hardware"; + cause?: "no-gpu" | "probe-error"; +} + +/** + * Cached *in-flight or resolved* probe Promise, shared by BOTH the `"auto"` + * and explicit `"hardware"` entry points of `resolveBrowserGpuMode`. * * Caching the Promise (rather than the resolved value) deduplicates concurrent * callers — the parallel coordinator runs N workers via `Promise.all`, so a @@ -430,14 +448,13 @@ export const _closeBrowserAfterFailedProbeForTests = closeBrowserAfterFailedProb * simultaneous probe Chromes. The first call assigns the Promise and every * other concurrent caller awaits the same one, paying the ~240 ms probe cost * exactly once per process lifetime. - * - * Exported for tests; production callers go through `resolveBrowserGpuMode`. */ -let _autoBrowserGpuModeCache: Promise<"software" | "hardware"> | undefined; +let _autoBrowserGpuModeCache: Promise | undefined; /** Test-only: reset the cached probe result. */ export function _resetAutoBrowserGpuModeCacheForTests(): void { _autoBrowserGpuModeCache = undefined; + _unverifiedHardwareGpuWarned = false; } async function getPuppeteerOrNull(): Promise { @@ -478,7 +495,7 @@ async function probeAutoBrowserGpuMode(options: { chromePath?: string; browserTimeout?: number; platform?: NodeJS.Platform; -}): Promise<"software" | "hardware"> { +}): Promise { const platform = options.platform ?? process.platform; const browserTimeout = options.browserTimeout ?? DEFAULT_CONFIG.browserTimeout; const executablePath = options.chromePath ?? resolveHeadlessShellPath({}); @@ -486,7 +503,7 @@ async function probeAutoBrowserGpuMode(options: { if (ppt === null) { logResolvedBrowserGpuMode("software", "puppeteer unavailable"); - return "software"; + return { mode: "software", cause: "probe-error" }; } try { @@ -497,24 +514,35 @@ async function probeAutoBrowserGpuMode(options: { }); const resolved = resolveWebGlProbeMode(info); logResolvedBrowserGpuMode(resolved, describeWebGlProbe(info)); - return resolved; + return resolved === "hardware" ? { mode: "hardware" } : { mode: "software", cause: "no-gpu" }; } catch (err) { logResolvedBrowserGpuMode("software", formatProbeFailure(err)); - return "software"; + return { mode: "software", cause: "probe-error" }; } } /** * Resolve `browserGpuMode` to a concrete `"software" | "hardware"` answer. * - * For `"software"` / `"hardware"` this is a pure pass-through. For `"auto"` - * it launches a tiny Chrome with the platform's hardware GPU args, runs a - * one-shot WebGL availability probe, and falls back to `"software"` if - * hardware-mode WebGL is unavailable. The Promise is cached for the process - * lifetime, so concurrent callers (parallel workers) share the same probe. + * For `"software"` this is a pure pass-through. For `"auto"` it launches a + * tiny Chrome with the platform's hardware GPU args, runs a one-shot WebGL + * availability probe, and falls back to `"software"` if hardware-mode WebGL + * is unavailable. The Promise is cached for the process lifetime, so + * concurrent callers (parallel workers) share the same probe. * - * Any failure (Chrome launch error, navigation timeout, missing canvas API, - * etc.) is treated as a `"software"` fallback. The render path with + * `"hardware"` (an explicit `--browser-gpu` / `PRODUCER_BROWSER_GPU_MODE= + * hardware`) is honoured verbatim — the operator asked for it — but runs the + * SAME probe to VERIFY it, because Chrome's hardware GL args are advisory: + * with no usable GPU in the sandbox (no `/dev/dri`, no NVIDIA container + * runtime, missing EGL/driver libraries) Chrome silently falls back to + * software WebGL and the render just runs at CPU speed. Without this check + * the only trace is a buried `Automatic fallback to software WebGL` browser + * warning — heygen-com/hyperframes#2967 rendered 19186 frames on CPU while + * `--browser-gpu` was set and nothing said so. The probe result never + * changes the returned mode; it only makes the fallback loud. + * + * Any probe failure (Chrome launch error, navigation timeout, missing canvas + * API, etc.) is treated as a `"software"` result. The render path with * SwiftShader always works, so a misclassification toward software is the * safe failure mode; misclassifying toward hardware would error on the real * render. @@ -527,21 +555,84 @@ export function resolveBrowserGpuMode( platform?: NodeJS.Platform; } = {}, ): Promise<"software" | "hardware"> { - if (mode !== "auto") return Promise.resolve(mode); - if (_autoBrowserGpuModeCache) return _autoBrowserGpuModeCache; + if (mode === "software") return Promise.resolve(mode); - _autoBrowserGpuModeCache = probeAutoBrowserGpuMode(options); - return _autoBrowserGpuModeCache; + _autoBrowserGpuModeCache ??= probeAutoBrowserGpuMode(options); + if (mode === "auto") return _autoBrowserGpuModeCache.then((probed) => probed.mode); + + return _autoBrowserGpuModeCache.then((probed) => { + // Warn once per cache lifetime, not once per caller: `createCaptureSession` + // resolves the mode for the probe browser AND every parallel worker, so + // an un-deduplicated warning prints N+1 times and buries itself. + if (probed.mode === "software" && !_unverifiedHardwareGpuWarned) { + _unverifiedHardwareGpuWarned = true; + console.warn( + buildUnverifiedHardwareGpuWarning(options.platform ?? process.platform, probed.cause), + ); + } + return "hardware"; + }); } /** - * Single observability surface for the auto-detect outcome. Logged exactly + * Latch for the explicit-hardware-probed-to-software warning: fires once per + * cache lifetime (re-armed by `_resetAutoBrowserGpuModeCacheForTests`). + */ +let _unverifiedHardwareGpuWarned = false; + +/** + * Warning text for "you asked for hardware GPU and we could not confirm it". + * + * Splits on `cause` because the two failure shapes need opposite remediation. + * A probe that RAN and saw SwiftShader is a GPU-passthrough problem. A probe + * that could not run tells us nothing about the GPU — pointing that operator + * at `--gpus all` would send them chasing a phantom while their Chrome + * install is the actual fault. + */ +function buildUnverifiedHardwareGpuWarning( + platform: NodeJS.Platform | string, + cause: GpuProbeOutcome["cause"], +): string { + if (cause === "probe-error") { + return ( + "[hyperframes] browserGpuMode=hardware was requested, but the GPU probe could not run, " + + "so hardware acceleration is UNVERIFIED — if Chrome falls back to software WebGL the " + + "capture will run at CPU speed. Honouring the explicit request anyway.\n" + + " This is a probe failure, not evidence of a missing GPU: see the " + + "`browserGpuMode probe → software (probe failed ...)` line above for the underlying " + + "error, which usually means Chrome could not launch (bad HYPERFRAMES_BROWSER_PATH, " + + "missing shared libraries, or a denied sandbox) rather than a GPU problem.\n" + + " Run `hyperframes doctor` to check the Chrome install." + ); + } + const remediation = + platform === "linux" + ? "Inside Docker, the container needs GPU passthrough: `--gpus all` with the NVIDIA " + + "Container Toolkit installed, or `--device /dev/dri` for Mesa/AMD/Intel. The image " + + "also needs the matching userspace driver + libEGL. Verify with " + + "`hyperframes render --browser-gpu` and watch for this warning disappearing." + : "Check that the host exposes a GPU to this process and that the graphics drivers are " + + "installed."; + return ( + "[hyperframes] browserGpuMode=hardware was requested, but the WebGL probe found no " + + "hardware GPU — Chrome will silently fall back to software WebGL and the capture will " + + "run at CPU speed. Honouring the explicit request anyway.\n" + + ` ${remediation}\n` + + " Pass --no-browser-gpu to select deterministic SwiftShader instead of waiting on a " + + "hardware path that is not there." + ); +} + +/** + * Single observability surface for the GPU probe outcome. Logged exactly * once per process (the probe runs once); without this line, a regression * to "always software even with a GPU present" would be invisible in - * production. Goes to stderr to stay out of stdout pipelines. + * production. Goes to stderr to stay out of stdout pipelines. Says "probe" + * rather than "auto" because explicit `browserGpuMode=hardware` runs the + * same probe to verify itself. */ function logResolvedBrowserGpuMode(resolved: "hardware" | "software", reason: string): void { - console.error(`[hyperframes] browserGpuMode auto → ${resolved} (${reason})`); + console.error(`[hyperframes] browserGpuMode probe → ${resolved} (${reason})`); } function createBrowserLaunchFingerprint( diff --git a/packages/engine/src/services/frameCapture-warmupTicks.test.ts b/packages/engine/src/services/frameCapture-warmupTicks.test.ts index 24e572342..f38839ca6 100644 --- a/packages/engine/src/services/frameCapture-warmupTicks.test.ts +++ b/packages/engine/src/services/frameCapture-warmupTicks.test.ts @@ -12,7 +12,10 @@ import { describe, expect, it } from "vitest"; import { LOCKED_WARMUP_TICKS, + deriveBeginFrameTimelineTicks, + deriveBeginFrameTimeTicks, driveWarmupTicks, + prepareBeginFrameTimeline, warmupFrameTimeTicks, type WarmupTickState, } from "./frameCapture.js"; @@ -172,3 +175,81 @@ describe("driveWarmupTicks — locked", () => { expect(warmupFrameTimeTicks(state, 33)).toBe(LOCKED_WARMUP_TICKS * 33); }); }); + +describe("deriveBeginFrameTimeTicks", () => { + const warmupIntervalMs = 33; + const state: WarmupTickState = { + running: false, + ticks: LOCKED_WARMUP_TICKS, + }; + const expectMonotonicTimeline = ( + warmupState: WarmupTickState, + captureIntervalMs: number, + ): void => { + const timeline = deriveBeginFrameTimelineTicks( + warmupState, + warmupIntervalMs, + captureIntervalMs, + ); + const lastWarmupTick = (warmupState.ticks - 1) * warmupIntervalMs; + + expect(timeline.commit).toBeGreaterThan(lastWarmupTick); + expect(timeline.probe).toBeGreaterThan(timeline.commit); + expect(timeline.capture).toBeGreaterThan(timeline.probe); + }; + + it.each([60, 120, 240, 60_000 / 1001])( + "keeps warmup, commit, probe, and capture monotonic at %ifps", + (fps) => { + expectMonotonicTimeline(state, 1000 / fps); + }, + ); + + it.each([24, 30, 30_000 / 1001, 31, 32])( + "preserves the legacy capture baseline when it is already monotonic at %ifps", + (fps) => { + const captureIntervalMs = 1000 / fps; + + expect(deriveBeginFrameTimeTicks(state, warmupIntervalMs, captureIntervalMs)).toBeCloseTo( + (LOCKED_WARMUP_TICKS + 10) * captureIntervalMs, + ); + }, + ); + + it("keeps an unlocked warmup timeline monotonic", () => { + const unlockedState: WarmupTickState = { running: false, ticks: 7 }; + expectMonotonicTimeline(unlockedState, 1000 / 60); + }); + + it("raises the capture baseline only when the warmup clock is ahead", () => { + const captureIntervalMs = 1000 / 60; + + expect(deriveBeginFrameTimeTicks(state, warmupIntervalMs, captureIntervalMs)).toBeCloseTo( + LOCKED_WARMUP_TICKS * warmupIntervalMs + 10 * captureIntervalMs, + ); + }); + + it("raises the baseline just above the safe legacy boundary", () => { + const captureIntervalMs = 1000 / 33; + + expect(deriveBeginFrameTimeTicks(state, warmupIntervalMs, captureIntervalMs)).toBeCloseTo( + LOCKED_WARMUP_TICKS * warmupIntervalMs + 10 * captureIntervalMs, + ); + }); + + it("wires the canonical capture and commit ticks into session initialization", () => { + const session = { + beginFrameIntervalMs: 1000 / 60, + beginFrameTimeTicks: 0, + }; + const prepared = prepareBeginFrameTimeline(session, state, warmupIntervalMs); + + expect(session.beginFrameTimeTicks).toBe(prepared.timeline.capture); + expect(prepared.commitParams).toEqual({ + frameTimeTicks: prepared.timeline.commit, + interval: session.beginFrameIntervalMs, + noDisplayUpdates: false, + }); + expect(prepared.timeline.commit).toBeGreaterThan((LOCKED_WARMUP_TICKS - 1) * warmupIntervalMs); + }); +}); diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index 7fdb61f28..d7d1c59c0 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -632,6 +632,101 @@ export function warmupFrameTimeTicks(state: WarmupTickState, intervalMs: number) return state.ticks * intervalMs; } +const BEGIN_FRAME_CAPTURE_HEADROOM_INTERVALS = 10; +const BEGIN_FRAME_COMMIT_LEAD_INTERVALS = 6; +const BEGIN_FRAME_PROBE_LEAD_INTERVALS = 5; + +export interface BeginFrameTimelineTicks { + capture: number; + commit: number; + probe: number; +} + +export interface PreparedBeginFrameTimeline { + commitParams: { + frameTimeTicks: number; + interval: number; + noDisplayUpdates: false; + }; + timeline: BeginFrameTimelineTicks; +} + +/** + * Place frame zero after the warmup clock while retaining capture-rate-sized + * headroom for the visual commit and liveness probe ticks that precede it. + * + * The warmup and capture intervals can differ (warmup currently runs at a + * fixed 33ms). Basing both clocks on the capture interval would move time + * backwards whenever the output frame rate is faster than the warmup rate. + */ +export function deriveBeginFrameTimeTicks( + state: WarmupTickState, + warmupIntervalMs: number, + captureIntervalMs: number, +): number { + const legacyCaptureTimeTicks = + (state.ticks + BEGIN_FRAME_CAPTURE_HEADROOM_INTERVALS) * captureIntervalMs; + const legacyCommitTimeTicks = deriveBeginFrameCommitTimeTicks( + legacyCaptureTimeTicks, + captureIntervalMs, + ); + const lastWarmupTimeTicks = Math.max(0, state.ticks - 1) * warmupIntervalMs; + if (legacyCommitTimeTicks > lastWarmupTimeTicks) return legacyCaptureTimeTicks; + + const monotonicCaptureTimeTicks = + warmupFrameTimeTicks(state, warmupIntervalMs) + + BEGIN_FRAME_CAPTURE_HEADROOM_INTERVALS * captureIntervalMs; + return monotonicCaptureTimeTicks; +} + +function deriveBeginFrameCommitTimeTicks( + captureTimeTicks: number, + captureIntervalMs: number, +): number { + return captureTimeTicks - BEGIN_FRAME_COMMIT_LEAD_INTERVALS * captureIntervalMs; +} + +export function deriveBeginFrameProbeTimeTicks( + captureTimeTicks: number, + captureIntervalMs: number, +): number { + return Math.max(0, captureTimeTicks - BEGIN_FRAME_PROBE_LEAD_INTERVALS * captureIntervalMs); +} + +export function deriveBeginFrameTimelineTicks( + state: WarmupTickState, + warmupIntervalMs: number, + captureIntervalMs: number, +): BeginFrameTimelineTicks { + const capture = deriveBeginFrameTimeTicks(state, warmupIntervalMs, captureIntervalMs); + return { + capture, + commit: deriveBeginFrameCommitTimeTicks(capture, captureIntervalMs), + probe: deriveBeginFrameProbeTimeTicks(capture, captureIntervalMs), + }; +} + +export function prepareBeginFrameTimeline( + session: Pick, + state: WarmupTickState, + warmupIntervalMs: number, +): PreparedBeginFrameTimeline { + const timeline = deriveBeginFrameTimelineTicks( + state, + warmupIntervalMs, + session.beginFrameIntervalMs, + ); + session.beginFrameTimeTicks = timeline.capture; + return { + timeline, + commitParams: { + frameTimeTicks: timeline.commit, + interval: session.beginFrameIntervalMs, + noDisplayUpdates: false, + }, + }; +} + export async function driveWarmupTicks( options: WarmupTickOptions, state: WarmupTickState, @@ -2277,10 +2372,16 @@ export async function initializeSession(session: CaptureSession): Promise warmupState.running = false; await warmupLoopPromise.catch(() => {}); - // Set base frame time ticks past warmup range. Locked mode pins to the - // constant so chunk workers on different hosts compute the same baseline. - const baseTickCount = lockWarmupTicks ? LOCKED_WARMUP_TICKS : warmupState.ticks; - session.beginFrameTimeTicks = (baseTickCount + 10) * session.beginFrameIntervalMs; + // Preserve the legacy baseline when it is already safe. Otherwise continue + // from the clock actually used by warmup, then reserve capture-rate headroom + // for the commit and probe ticks below. Locked mode still produces an + // identical timeline on every host because its driver ends at exactly + // LOCKED_WARMUP_TICKS. + const preparedBeginFrameTimeline = prepareBeginFrameTimeline( + session, + warmupState, + warmupIntervalMs, + ); // drawElement or transparent-background init — runs after page is fully ready. // IMPORTANT: must stay after beginFrameTimeTicks is set above. The per-frame @@ -2318,11 +2419,7 @@ export async function initializeSession(session: CaptureSession): Promise // `-6·interval` the order stays warmup < commit < probe < capture. await ensureRenderFrameSiblings(page); const commitCdp = await getCdpSession(page); - await commitCdp.send("HeadlessExperimental.beginFrame", { - frameTimeTicks: session.beginFrameTimeTicks - 6 * session.beginFrameIntervalMs, - interval: session.beginFrameIntervalMs, - noDisplayUpdates: false, - }); + await commitCdp.send("HeadlessExperimental.beginFrame", preparedBeginFrameTimeline.commitParams); session.isInitialized = true; } diff --git a/packages/engine/src/services/videoFrameExtractor.errorClassification.test.ts b/packages/engine/src/services/videoFrameExtractor.errorClassification.test.ts index 6afa2f8fa..6873c484a 100644 --- a/packages/engine/src/services/videoFrameExtractor.errorClassification.test.ts +++ b/packages/engine/src/services/videoFrameExtractor.errorClassification.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { classifyFfmpegSpawnError } from "./videoFrameExtractor.js"; +import { UrlDownloadError } from "../utils/urlDownloader.js"; +import { classifyFfmpegSpawnError, classifyVideoExtractionError } from "./videoFrameExtractor.js"; describe("classifyFfmpegSpawnError", () => { it.each(["ENOENT", "EACCES", "ENOEXEC", "UNKNOWN"])( @@ -18,3 +19,21 @@ describe("classifyFfmpegSpawnError", () => { }); }); }); + +describe("classifyVideoExtractionError download integrity", () => { + it("keeps deterministic HTML payloads non-retryable and user-owned as invalid media", () => { + const classified = classifyVideoExtractionError( + new UrlDownloadError("invalid_payload", false, "HTML payload"), + ); + expect(classified).toMatchObject({ kind: "invalid_media", retryable: false }); + }); + + it.each(["range_protocol", "length_mismatch", "hash_mismatch"] as const)( + "keeps %s retryable after the downloader's one clean refetch is exhausted", + (kind) => { + expect( + classifyVideoExtractionError(new UrlDownloadError(kind, true, "integrity failure")), + ).toMatchObject({ kind: "download_transient", retryable: true }); + }, + ); +}); diff --git a/packages/engine/src/services/videoFrameExtractor.ts b/packages/engine/src/services/videoFrameExtractor.ts index 501485aa1..1ecc62d4e 100644 --- a/packages/engine/src/services/videoFrameExtractor.ts +++ b/packages/engine/src/services/videoFrameExtractor.ts @@ -28,7 +28,12 @@ import { isHdrColorSpace as isHdrColorSpaceUtil, type HdrTransfer, } from "../utils/hdr.js"; -import { downloadToTemp, isHttpUrl, UrlDownloadError } from "../utils/urlDownloader.js"; +import { + downloadToTemp, + isHttpUrl, + UrlDownloadError, + writeUrlDownloadTelemetry, +} from "../utils/urlDownloader.js"; import { runFfmpeg } from "../utils/runFfmpeg.js"; import { DEFAULT_CONFIG, type EngineConfig } from "../config.js"; import { unwrapTemplate } from "../utils/htmlTemplate.js"; @@ -342,6 +347,14 @@ export function classifyVideoExtractionError(error: unknown): VideoSourceExtract diagnostic, ); } + if (error.kind === "invalid_payload") { + return new VideoSourceExtractionError( + "invalid_media", + false, + "Video source download returned a non-media payload", + diagnostic, + ); + } if (error.retryable) { return new VideoSourceExtractionError( "download_transient", @@ -1424,8 +1437,13 @@ export async function extractAllVideoFrames( if (isHttpUrl(videoPath)) { const downloadDir = join(options.outputDir, "_downloads"); mkdirSync(downloadDir, { recursive: true }); - videoPath = await downloadToTemp(videoPath, downloadDir, undefined, signal, () => - recordTransientRetries(1), + videoPath = await downloadToTemp( + videoPath, + downloadDir, + undefined, + signal, + () => recordTransientRetries(1), + { onTelemetry: writeUrlDownloadTelemetry }, ); } diff --git a/packages/engine/src/utils/urlDownloader.test.ts b/packages/engine/src/utils/urlDownloader.test.ts index 8ef85317a..908765c18 100644 --- a/packages/engine/src/utils/urlDownloader.test.ts +++ b/packages/engine/src/utils/urlDownloader.test.ts @@ -2,16 +2,89 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { existsSync, + mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, + utimesSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { assertPublicHttpsUrl, downloadToTemp, UrlDownloadError } from "./urlDownloader.js"; +import { createHash } from "node:crypto"; +import { + assertPublicHttpsUrl, + downloadToTemp, + fetchPublicHttpsText, + UrlDownloadError, +} from "./urlDownloader.js"; + +const fsRaceControls = vi.hoisted(() => ({ + deleteBeforeLstatPath: undefined as string | undefined, + deleteInjectedWinnerBeforeLstatPath: undefined as string | undefined, + injectRaceAtLinkPath: undefined as string | undefined, + deleteBeforeReadPath: undefined as string | undefined, + replaceStaleLockAfterObservationPath: undefined as string | undefined, + replaceLockOnReleasePath: undefined as string | undefined, +})); + +vi.mock("fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + lstatSync: ((...args: unknown[]) => { + const path = String(args[0]); + if (path === fsRaceControls.deleteBeforeLstatPath) { + fsRaceControls.deleteBeforeLstatPath = undefined; + actual.rmSync(path, { force: true }); + } + if (path === fsRaceControls.deleteInjectedWinnerBeforeLstatPath && actual.existsSync(path)) { + fsRaceControls.deleteInjectedWinnerBeforeLstatPath = undefined; + actual.rmSync(path, { force: true }); + } + return Reflect.apply(actual.lstatSync, actual, args); + }) as typeof actual.lstatSync, + readdirSync: ((...args: unknown[]) => { + const path = String(args[0]); + const observed = Reflect.apply(actual.readdirSync, actual, args); + if (path === fsRaceControls.replaceStaleLockAfterObservationPath) { + fsRaceControls.replaceStaleLockAfterObservationPath = undefined; + actual.rmSync(path, { recursive: true, force: true }); + actual.mkdirSync(path); + actual.mkdirSync(join(path, ".hf-owner-successor")); + } + return observed; + }) as typeof actual.readdirSync, + rmdirSync: ((...args: unknown[]) => { + const path = String(args[0]); + if (path === fsRaceControls.replaceLockOnReleasePath) { + fsRaceControls.replaceLockOnReleasePath = undefined; + actual.rmSync(path, { recursive: true, force: true }); + actual.mkdirSync(path); + actual.mkdirSync(join(path, ".hf-owner-successor")); + } + return Reflect.apply(actual.rmdirSync, actual, args); + }) as typeof actual.rmdirSync, + linkSync: ((...args: unknown[]) => { + const destination = String(args[1]); + if (destination === fsRaceControls.injectRaceAtLinkPath) { + fsRaceControls.injectRaceAtLinkPath = undefined; + actual.writeFileSync(destination, "concurrent-winner"); + } + return Reflect.apply(actual.linkSync, actual, args); + }) as typeof actual.linkSync, + createReadStream: ((...args: unknown[]) => { + const path = String(args[0]); + if (path === fsRaceControls.deleteBeforeReadPath) { + fsRaceControls.deleteBeforeReadPath = undefined; + actual.rmSync(path, { force: true }); + } + return Reflect.apply(actual.createReadStream, actual, args); + }) as typeof actual.createReadStream, + }; +}); const tempDirs: string[] = []; @@ -23,12 +96,30 @@ function makeTempDir(): string { function temporaryDownloadEntries(dir: string): string[] { return readdirSync(dir).filter( - (name) => name.includes(".partial-") || name.startsWith(".hf-download-"), + (name) => + name.includes(".partial-") || name.startsWith(".hf-download-") || name.endsWith(".hf-lock"), ); } +function isoBmffMediaBytes(marker: string): Buffer { + const ftyp = Buffer.alloc(24); + ftyp.writeUInt32BE(24, 0); + ftyp.write("ftyp", 4, 4, "ascii"); + ftyp.write("isom", 8, 4, "ascii"); + ftyp.writeUInt32BE(0, 12); + ftyp.write("isom", 16, 4, "ascii"); + ftyp.write("mp42", 20, 4, "ascii"); + return Buffer.concat([ftyp, Buffer.from(marker)]); +} + afterEach(() => { vi.unstubAllGlobals(); + fsRaceControls.deleteBeforeLstatPath = undefined; + fsRaceControls.deleteInjectedWinnerBeforeLstatPath = undefined; + fsRaceControls.injectRaceAtLinkPath = undefined; + fsRaceControls.deleteBeforeReadPath = undefined; + fsRaceControls.replaceStaleLockAfterObservationPath = undefined; + fsRaceControls.replaceLockOnReleasePath = undefined; for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }); } @@ -120,6 +211,78 @@ describe("assertPublicHttpsUrl — SSRF guard", () => { expect(() => assertPublicHttpsUrl("not-a-url")).toThrow("Invalid URL"); expect(() => assertPublicHttpsUrl("")).toThrow("Invalid URL"); }); + + it("never echoes a rejected signed URL in diagnostics", () => { + const signed = "http://127.0.0.1/private/customer.mp4?X-Amz-Signature=super-secret"; + let message = ""; + try { + assertPublicHttpsUrl(signed); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + expect(message).not.toContain("customer.mp4"); + expect(message).not.toContain("super-secret"); + }); +}); + +describe("fetchPublicHttpsText", () => { + it("validates every redirect before issuing the next request", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(null, { + status: 302, + headers: { location: "https://169.254.169.254/latest/meta-data/" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await expect( + fetchPublicHttpsText("https://styles.example/fonts.css", { maxBytes: 1024 }), + ).rejects.toMatchObject({ kind: "http_rejected", retryable: false }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("enforces the byte cap while consuming a chunked response", async () => { + let cancelled = false; + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("123456")); + controller.enqueue(new TextEncoder().encode("789012")); + }, + cancel() { + cancelled = true; + }, + }); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(body))); + + await expect( + fetchPublicHttpsText("https://styles.example/large.css", { maxBytes: 10 }), + ).rejects.toMatchObject({ kind: "length_mismatch", retryable: false }); + expect(cancelled).toBe(true); + }); + + it("keeps the timeout active while the response body is stalled", async () => { + const fetchMock = vi.fn().mockImplementation(async (_url: string, init: RequestInit) => { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("partial")); + init.signal?.addEventListener( + "abort", + () => controller.error(new DOMException("aborted", "AbortError")), + { once: true }, + ); + }, + }); + return new Response(body); + }); + vi.stubGlobal("fetch", fetchMock); + + await expect( + fetchPublicHttpsText("https://styles.example/stalled.css", { + maxBytes: 1024, + timeoutMs: 20, + }), + ).rejects.toMatchObject({ kind: "timeout", retryable: true }); + }); }); describe("downloadToTemp atomic publication and bounded retry", () => { @@ -141,7 +304,10 @@ describe("downloadToTemp atomic publication and bounded retry", () => { expect(fetchMock).toHaveBeenNthCalledWith( 2, "https://media.example/final.mp4", - expect.objectContaining({ redirect: "manual" }), + expect.objectContaining({ + redirect: "manual", + headers: { "accept-encoding": "identity" }, + }), ); expect(readFileSync(path, "utf8")).toBe("complete"); expect(temporaryDownloadEntries(dir)).toEqual([]); @@ -212,6 +378,598 @@ describe("downloadToTemp atomic publication and bounded retry", () => { expect(temporaryDownloadEntries(dir)).toEqual([]); }); + it("does not expose a signed URL supplied through hostile HTTP status text", async () => { + const signedUrl = + "https://cdn.example/private/customer-video?X-Amz-Signature=super-secret-signature"; + const fetchMock = vi + .fn() + .mockResolvedValue(new Response(null, { status: 503, statusText: signedUrl })); + vi.stubGlobal("fetch", fetchMock); + const dir = makeTempDir(); + + let message = ""; + try { + await downloadToTemp(signedUrl, dir, 1_000); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(message).toBe("HTTP 503"); + expect(message).not.toContain("customer-video"); + expect(message).not.toContain("super-secret-signature"); + }); + + it("rejects a truncated Content-Length response and cleanly refetches once", async () => { + const complete = isoBmffMediaBytes("complete"); + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response(complete.subarray(0, complete.length - 1), { + headers: { "content-length": String(complete.length) }, + }), + ) + .mockResolvedValueOnce( + new Response(complete, { headers: { "content-length": String(complete.length) } }), + ); + vi.stubGlobal("fetch", fetchMock); + const dir = makeTempDir(); + const onTransientRetry = vi.fn(); + + const path = await downloadToTemp( + "https://cdn.example/truncated.mp4", + dir, + 1_000, + undefined, + onTransientRetry, + {}, + ); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(onTransientRetry).toHaveBeenCalledWith( + expect.objectContaining({ kind: "length_mismatch", retryable: true }), + ); + expect(readFileSync(path)).toEqual(complete); + expect(temporaryDownloadEntries(dir)).toEqual([]); + }); + + it("does not publish after two repeated length mismatches", async () => { + const fetchMock = vi + .fn() + .mockImplementation(() => + Promise.resolve(new Response("short", { headers: { "content-length": "12" } })), + ); + vi.stubGlobal("fetch", fetchMock); + const dir = makeTempDir(); + + await expect( + downloadToTemp("https://cdn.example/always-short.mp4", dir, 1_000), + ).rejects.toMatchObject({ + kind: "length_mismatch", + retryable: true, + } satisfies Partial); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(readdirSync(dir).filter((name) => name.startsWith("download_"))).toEqual([]); + expect(temporaryDownloadEntries(dir)).toEqual([]); + }); + + it("rejects an unsolicited well-formed 206 and succeeds after one clean refetch", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response("part", { + status: 206, + headers: { "content-range": "bytes 0-3/8", "content-length": "4" }, + }), + ) + .mockResolvedValueOnce(new Response("complete", { headers: { "content-length": "8" } })); + vi.stubGlobal("fetch", fetchMock); + const dir = makeTempDir(); + + const path = await downloadToTemp("https://cdn.example/unsolicited-range.mp4", dir, 1_000); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(readFileSync(path, "utf8")).toBe("complete"); + expect(temporaryDownloadEntries(dir)).toEqual([]); + }); + + it("rejects malformed 206 responses after exactly one refetch", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response("part", { + status: 206, + headers: { "content-range": "bytes nonsense" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + const dir = makeTempDir(); + + await expect( + downloadToTemp("https://cdn.example/malformed-range.mp4", dir, 1_000), + ).rejects.toMatchObject({ + kind: "range_protocol", + retryable: true, + telemetry: expect.objectContaining({ rangeDisposition: "malformed_206" }), + } satisfies Partial); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(temporaryDownloadEntries(dir)).toEqual([]); + }); + + it("accepts a complete identity-encoded object with a noncompliant Content-Range on 200", async () => { + const onTelemetry = vi.fn(); + const fetchMock = vi.fn().mockResolvedValue( + new Response("complete", { + status: 200, + headers: { "content-range": "bytes 0-7/8", "content-length": "8" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + const dir = makeTempDir(); + + const path = await downloadToTemp( + "https://cdn.example/full-range-on-200.mp4", + dir, + 1_000, + undefined, + undefined, + { onTelemetry }, + ); + + expect(readFileSync(path, "utf8")).toBe("complete"); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(onTelemetry).toHaveBeenCalledWith( + expect.objectContaining({ outcome: "published", rangeDisposition: "full_object_200" }), + ); + }); + + it("rejects Content-Range on a 200 response without a matching declared length", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response("complete", { + status: 200, + headers: { "content-range": "bytes 0-7/8" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + const dir = makeTempDir(); + + await expect( + downloadToTemp("https://cdn.example/range-on-200.mp4", dir, 1_000), + ).rejects.toMatchObject({ + kind: "range_protocol", + retryable: true, + } satisfies Partial); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("rejects a full-object Content-Range when the streamed body is shorter than declared", async () => { + const fetchMock = vi.fn().mockImplementation(() => + Promise.resolve( + new Response("short", { + status: 200, + headers: { "content-range": "bytes 0-7/8", "content-length": "8" }, + }), + ), + ); + vi.stubGlobal("fetch", fetchMock); + const dir = makeTempDir(); + + await expect( + downloadToTemp("https://cdn.example/short-full-range-on-200.mp4", dir, 1_000), + ).rejects.toMatchObject({ + kind: "length_mismatch", + retryable: true, + telemetry: expect.objectContaining({ rangeDisposition: "full_object_200" }), + } satisfies Partial); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(temporaryDownloadEntries(dir)).toEqual([]); + }); + + it.each([ + ["partial object", "bytes 0-3/8", "4", undefined], + ["nonzero start", "bytes 1-7/8", "8", undefined], + ["wildcard total", "bytes 0-7/*", "8", undefined], + ["mismatched length", "bytes 0-7/8", "7", undefined], + ["unsafe integer", "bytes 0-9007199254740991/9007199254740992", "8", undefined], + ["encoded body", "bytes 0-7/8", "8", "gzip"], + ])("rejects a %s Content-Range on 200", async (_case, range, length, encoding) => { + const fetchMock = vi.fn().mockResolvedValue( + new Response("complete", { + status: 200, + headers: { + "content-range": range, + "content-length": length, + ...(encoding ? { "content-encoding": encoding } : {}), + }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + const dir = makeTempDir(); + + await expect( + downloadToTemp("https://cdn.example/invalid-range-on-200.mp4", dir, 1_000), + ).rejects.toMatchObject({ + kind: "range_protocol", + retryable: true, + telemetry: expect.objectContaining({ rangeDisposition: "content_range_on_200" }), + } satisfies Partial); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("retries a checksum mismatch once and publishes only matching bytes", async () => { + const corrupt = isoBmffMediaBytes("corrupt"); + const complete = isoBmffMediaBytes("complete"); + const expectedSha256 = createHash("sha256").update(complete).digest("hex"); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response(corrupt)) + .mockResolvedValueOnce(new Response(complete)); + vi.stubGlobal("fetch", fetchMock); + const dir = makeTempDir(); + + const path = await downloadToTemp( + "https://cdn.example/checksum.mp4", + dir, + 1_000, + undefined, + undefined, + { expectedSha256 }, + ); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(readFileSync(path)).toEqual(complete); + }); + + it("rejects a malformed caller checksum before fetching", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const dir = makeTempDir(); + + await expect( + downloadToTemp("https://cdn.example/checksum.mp4", dir, 1_000, undefined, undefined, { + expectedSha256: "not-a-sha256", + }), + ).rejects.toMatchObject({ kind: "hash_mismatch", retryable: false }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(readdirSync(dir)).toEqual([]); + }); + + it("locally refetches a caller checksum mismatch but keeps the final error non-retryable", async () => { + const bytes = isoBmffMediaBytes("always-wrong"); + const expectedSha256 = createHash("sha256").update("different").digest("hex"); + const serverSha256 = createHash("sha256").update(bytes).digest("base64"); + const contentMd5 = createHash("md5").update(bytes).digest("base64"); + const fetchMock = vi.fn().mockImplementation(() => + Promise.resolve( + new Response(bytes, { + headers: { "x-amz-checksum-sha256": serverSha256, "content-md5": contentMd5 }, + }), + ), + ); + const onTransientRetry = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const dir = makeTempDir(); + + await expect( + downloadToTemp( + "https://cdn.example/caller-checksum.mp4", + dir, + 1_000, + undefined, + onTransientRetry, + { expectedSha256 }, + ), + ).rejects.toMatchObject({ + kind: "hash_mismatch", + retryable: false, + locallyRetryable: true, + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(onTransientRetry).not.toHaveBeenCalled(); + }); + + it("prefers a matching caller SHA-256 over contradictory server checksums", async () => { + const bytes = isoBmffMediaBytes("caller-authoritative"); + const callerSha256 = createHash("sha256").update(bytes).digest("hex"); + const fetchMock = vi.fn().mockResolvedValue( + new Response(bytes, { + headers: { + "x-amz-checksum-sha256": Buffer.from("wrong-sha256").toString("base64"), + "content-md5": Buffer.from("wrong-md5").toString("base64"), + }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + const dir = makeTempDir(); + + const path = await downloadToTemp( + "https://cdn.example/caller-authoritative.mp4", + dir, + 1_000, + undefined, + undefined, + { expectedSha256: callerSha256 }, + ); + + expect(readFileSync(path)).toEqual(bytes); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("prefers a matching server SHA-256 over a contradictory Content-MD5", async () => { + const bytes = isoBmffMediaBytes("server-sha-authoritative"); + const serverSha256 = createHash("sha256").update(bytes).digest("base64"); + const fetchMock = vi.fn().mockResolvedValue( + new Response(bytes, { + headers: { + "x-amz-checksum-sha256": serverSha256, + "content-md5": Buffer.from("wrong-md5").toString("base64"), + }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + const dir = makeTempDir(); + + const path = await downloadToTemp("https://cdn.example/server-sha.mp4", dir, 1_000); + + expect(readFileSync(path)).toEqual(bytes); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("accepts a matching Content-MD5 when no SHA-256 is available", async () => { + const bytes = isoBmffMediaBytes("legacy-md5"); + const contentMd5 = createHash("md5").update(bytes).digest("base64"); + const fetchMock = vi + .fn() + .mockResolvedValue(new Response(bytes, { headers: { "content-md5": contentMd5 } })); + vi.stubGlobal("fetch", fetchMock); + const dir = makeTempDir(); + + const path = await downloadToTemp("https://cdn.example/legacy-md5.mp4", dir, 1_000); + + expect(readFileSync(path)).toEqual(bytes); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("keeps repeated Content-MD5 mismatches retryable upstream", async () => { + const bytes = isoBmffMediaBytes("legacy-md5-mismatch"); + const fetchMock = vi.fn().mockImplementation(() => + Promise.resolve( + new Response(bytes, { + headers: { "content-md5": Buffer.from("wrong-md5").toString("base64") }, + }), + ), + ); + vi.stubGlobal("fetch", fetchMock); + const dir = makeTempDir(); + + await expect( + downloadToTemp("https://cdn.example/legacy-md5-mismatch.mp4", dir, 1_000), + ).rejects.toMatchObject({ kind: "hash_mismatch", retryable: true }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("keeps repeated server checksum mismatches retryable upstream", async () => { + const bytes = isoBmffMediaBytes("server-corrupt"); + const fetchMock = vi.fn().mockImplementation(() => + Promise.resolve( + new Response(bytes, { + headers: { digest: `sha-256=${Buffer.from("wrong-checksum").toString("base64")}` }, + }), + ), + ); + vi.stubGlobal("fetch", fetchMock); + const dir = makeTempDir(); + + await expect( + downloadToTemp("https://cdn.example/server-checksum.mp4", dir, 1_000), + ).rejects.toMatchObject({ kind: "hash_mismatch", retryable: true }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("ignores S3 composite checksums that are not full-object SHA-256", async () => { + const mediaBytes = isoBmffMediaBytes("multipart-object"); + const fetchMock = vi.fn().mockResolvedValue( + new Response(mediaBytes, { + headers: { + "x-amz-checksum-sha256": Buffer.from("not-a-full-object-checksum").toString("base64"), + "x-amz-checksum-type": "COMPOSITE", + }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + const dir = makeTempDir(); + + const path = await downloadToTemp( + "https://cdn.example/multipart.mp4", + dir, + 1_000, + undefined, + undefined, + {}, + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(readFileSync(path)).toEqual(mediaBytes); + }); + + it("rejects an HTML-as-200 media payload without retrying", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(new Response(" denied")); + vi.stubGlobal("fetch", fetchMock); + const dir = makeTempDir(); + + await expect( + downloadToTemp("https://cdn.example/html-error.mp4", dir, 1_000, undefined, undefined, {}), + ).rejects.toMatchObject({ + kind: "invalid_payload", + retryable: false, + } satisfies Partial); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(temporaryDownloadEntries(dir)).toEqual([]); + }); + + 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); + const dir = makeTempDir(); + + await expect( + downloadToTemp("https://cdn.example/bom-error.mp4", dir, 1_000), + ).rejects.toMatchObject({ kind: "invalid_payload", retryable: false }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(readdirSync(dir).filter((name) => name.startsWith("download_"))).toEqual([]); + }); + + it("invalidates a cached HTML error document before localizing a font", async () => { + const url = "https://cdn.example/font.woff2"; + const dir = makeTempDir(); + const cacheName = `download_${createHash("md5").update(url).digest("hex").slice(0, 12)}.woff2`; + const cachePath = join(dir, cacheName); + writeFileSync(cachePath, "expired"); + const fontBytes = Buffer.from("wOF2valid-font-payload"); + const fetchMock = vi.fn().mockResolvedValue(new Response(fontBytes)); + vi.stubGlobal("fetch", fetchMock); + + const path = await downloadToTemp(url, dir, 1_000); + + expect(path).toBe(cachePath); + expect(readFileSync(path)).toEqual(fontBytes); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(temporaryDownloadEntries(dir)).toEqual([]); + }); + + it("rejects a JSON error document served as video without retrying", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response('{"message":"access denied"}', { + headers: { "content-type": "video/mp4" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + const dir = makeTempDir(); + + await expect( + downloadToTemp( + "https://cdn.example/wrong-signature.mp4", + dir, + 1_000, + undefined, + undefined, + {}, + ), + ).rejects.toMatchObject({ kind: "invalid_payload", retryable: false }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(readdirSync(dir).filter((name) => name.startsWith("download_"))).toEqual([]); + }); + + it("accepts valid media bytes through a wrong-MIME redirect", async () => { + const mediaBytes = isoBmffMediaBytes("wrong-mime"); + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response(null, { + status: 302, + headers: { location: "https://media.example/asset" }, + }), + ) + .mockResolvedValueOnce( + new Response(mediaBytes, { headers: { "content-type": "text/html" } }), + ); + vi.stubGlobal("fetch", fetchMock); + const dir = makeTempDir(); + + const path = await downloadToTemp( + "https://cdn.example/wrong-mime", + dir, + 1_000, + undefined, + undefined, + {}, + ); + + expect(readFileSync(path)).toEqual(mediaBytes); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("accepts extensionless media and emits safe complete integrity telemetry", async () => { + const mediaBytes = isoBmffMediaBytes("extensionless"); + const etag = '"customer-secret-etag"'; + const fetchMock = vi.fn().mockResolvedValue( + new Response(mediaBytes, { + headers: { "content-length": String(mediaBytes.length), etag }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + const dir = makeTempDir(); + const events: unknown[] = []; + const signedUrl = + "https://cdn.example/private/customer-video?X-Amz-Signature=super-secret-signature"; + + const path = await downloadToTemp(signedUrl, dir, 1_000, undefined, undefined, { + onTelemetry: (event) => events.push(event), + }); + + expect(readFileSync(path)).toEqual(mediaBytes); + expect(events).toEqual([ + expect.objectContaining({ + initialHost: "cdn.example", + finalHost: "cdn.example", + attempt: 1, + outcome: "published", + status: 200, + expectedBytes: mediaBytes.length, + receivedBytes: mediaBytes.length, + localSize: mediaBytes.length, + localSha256: createHash("sha256").update(mediaBytes).digest("hex"), + etagFingerprint: createHash("sha256").update(etag).digest("hex"), + }), + ]); + const serialized = JSON.stringify(events); + expect(serialized).not.toContain("customer-video"); + expect(serialized).not.toContain("super-secret-signature"); + expect(serialized).not.toContain("customer-secret-etag"); + }); + + it.each([ + ["aiff", Buffer.from("FORM\0\0\0\0AIFF")], + ["caf", Buffer.from("caff\0\x01\0\0")], + ["amr", Buffer.from("#!AMR\n")], + ["flv", Buffer.from("FLV\x01\x05")], + ])( + "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); + const dir = makeTempDir(); + + const path = await downloadToTemp(`https://cdn.example/extensionless-${kind}`, dir, 1_000); + + expect(readFileSync(path)).toEqual(mediaBytes); + expect(fetchMock).toHaveBeenCalledTimes(1); + }, + ); + + it("redacts signed URLs embedded in underlying fetch failures", async () => { + const signedUrl = + "https://cdn.example/private/customer-video?X-Amz-Signature=super-secret-signature"; + const fetchMock = vi + .fn() + .mockRejectedValue(new TypeError(`fetch failed while requesting ${signedUrl}`)); + vi.stubGlobal("fetch", fetchMock); + const dir = makeTempDir(); + + let message = ""; + try { + await downloadToTemp(signedUrl, dir, 1_000); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(message).toContain("transient network error"); + expect(message).not.toContain("customer-video"); + expect(message).not.toContain("super-secret-signature"); + }); + it("cancels a streaming HTTP error body before retrying", async () => { let errorBodyCancelled = false; const errorBody = new ReadableStream({ @@ -393,6 +1151,24 @@ describe("downloadToTemp atomic publication and bounded retry", () => { expect(temporaryDownloadEntries(dir)).toEqual([]); }); + it("refetches when the cache path disappears before its first inspection", async () => { + const url = "https://cdn.example/first-lstat-race.mp4"; + const dir = makeTempDir(); + const cacheName = `download_${createHash("md5").update(url).digest("hex").slice(0, 12)}.mp4`; + const cachePath = join(dir, cacheName); + writeFileSync(cachePath, "vanishing-cache-entry"); + fsRaceControls.deleteBeforeLstatPath = cachePath; + const fetchMock = vi.fn().mockResolvedValue(new Response("complete")); + vi.stubGlobal("fetch", fetchMock); + + const path = await downloadToTemp(url, dir, 1_000); + + expect(path).toBe(cachePath); + expect(readFileSync(path, "utf8")).toBe("complete"); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(temporaryDownloadEntries(dir)).toEqual([]); + }); + 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); @@ -447,6 +1223,206 @@ describe("downloadToTemp atomic publication and bounded retry", () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); + it("waits for a cross-process cache-path lock before inspecting the final path", async () => { + const url = "https://cdn.example/locked.mp4"; + const dir = makeTempDir(); + const cacheName = `download_${createHash("md5").update(url).digest("hex").slice(0, 12)}.mp4`; + const lockPath = join(dir, `${cacheName}.hf-lock`); + mkdirSync(lockPath); + const fetchMock = vi.fn().mockResolvedValue(new Response("complete")); + vi.stubGlobal("fetch", fetchMock); + + const pending = downloadToTemp(url, dir, 1_000); + await new Promise((resolve) => setTimeout(resolve, 25)); + expect(fetchMock).not.toHaveBeenCalled(); + rmSync(lockPath, { recursive: true, force: true }); + + const path = await pending; + expect(readFileSync(path, "utf8")).toBe("complete"); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(temporaryDownloadEntries(dir)).toEqual([]); + }); + + it("recovers a stale cross-process cache-path lock", async () => { + const url = "https://cdn.example/stale-lock.mp4"; + const dir = makeTempDir(); + const cacheName = `download_${createHash("md5").update(url).digest("hex").slice(0, 12)}.mp4`; + const lockPath = join(dir, `${cacheName}.hf-lock`); + mkdirSync(lockPath); + mkdirSync(join(lockPath, ".hf-owner-stale")); + 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); + + const path = await downloadToTemp(url, dir, 1_000); + + expect(readFileSync(path, "utf8")).toBe("complete"); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(existsSync(lockPath)).toBe(false); + expect(temporaryDownloadEntries(dir)).toEqual([]); + }); + + it("does not mix a stale lstat with a successor owner read", async () => { + const url = "https://cdn.example/stale-lock-successor.mp4"; + const dir = makeTempDir(); + const cacheName = `download_${createHash("md5").update(url).digest("hex").slice(0, 12)}.mp4`; + const lockPath = join(dir, `${cacheName}.hf-lock`); + mkdirSync(lockPath); + mkdirSync(join(lockPath, ".hf-owner-stale")); + const staleTime = new Date(Date.now() - 6 * 60_000); + utimesSync(lockPath, staleTime, staleTime); + fsRaceControls.replaceStaleLockAfterObservationPath = lockPath; + const fetchMock = vi.fn().mockResolvedValue(new Response("complete")); + vi.stubGlobal("fetch", fetchMock); + + const pending = downloadToTemp(url, dir, 1_000); + await new Promise((resolve) => setTimeout(resolve, 25)); + + expect(existsSync(lockPath)).toBe(true); + expect(fetchMock).not.toHaveBeenCalled(); + rmSync(lockPath, { recursive: true, force: true }); + + const path = await pending; + expect(readFileSync(path, "utf8")).toBe("complete"); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(temporaryDownloadEntries(dir)).toEqual([]); + }); + + it("does not release a successor cache lock created at the same path", async () => { + const url = "https://cdn.example/successor-lock.mp4"; + const dir = makeTempDir(); + const cacheName = `download_${createHash("md5").update(url).digest("hex").slice(0, 12)}.mp4`; + const cachePath = join(dir, cacheName); + const lockPath = `${cachePath}.hf-lock`; + writeFileSync(cachePath, "cached"); + fsRaceControls.replaceLockOnReleasePath = lockPath; + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const path = await downloadToTemp(url, dir, 1_000); + + expect(path).toBe(cachePath); + expect(fetchMock).not.toHaveBeenCalled(); + expect(existsSync(lockPath)).toBe(true); + rmSync(lockPath, { recursive: true, force: true }); + }); + + it("keeps the replacement path intact across cross-signal stale-cache callers", async () => { + const url = "https://cdn.example/stale-concurrent.woff2"; + const dir = makeTempDir(); + const cacheName = `download_${createHash("md5").update(url).digest("hex").slice(0, 12)}.woff2`; + writeFileSync(join(dir, cacheName), "expired"); + const firstController = new AbortController(); + const secondController = new AbortController(); + const body = Buffer.from("wOF2replacement"); + const fetchMock = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + setTimeout(() => resolve(new Response(body)), 10); + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const [first, second] = await Promise.all([ + downloadToTemp(url, dir, 1_000, firstController.signal), + downloadToTemp(url, dir, 1_000, secondController.signal), + ]); + + expect(first).toBe(second); + expect(existsSync(first)).toBe(true); + expect(readFileSync(first)).toEqual(body); + expect(temporaryDownloadEntries(dir)).toEqual([]); + }); + + it("never overwrites an artifact returned by a concurrent checksum scope", async () => { + const firstBytes = isoBmffMediaBytes("first-version"); + const secondBytes = isoBmffMediaBytes("second-version"); + const firstSha = createHash("sha256").update(firstBytes).digest("hex"); + const secondSha = createHash("sha256").update(secondBytes).digest("hex"); + const fetchMock = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => + setTimeout(() => resolve(new Response(firstBytes)), 20), + ), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => + setTimeout(() => resolve(new Response(secondBytes)), 5), + ), + ); + vi.stubGlobal("fetch", fetchMock); + const dir = makeTempDir(); + const url = "https://cdn.example/versioned.mp4"; + + const [firstPath, secondPath] = await Promise.all([ + downloadToTemp(url, dir, 1_000, undefined, undefined, { + expectedSha256: firstSha, + }), + downloadToTemp(url, dir, 1_000, undefined, undefined, { + expectedSha256: secondSha, + }), + ]); + + expect(firstPath).not.toBe(secondPath); + expect(readFileSync(firstPath)).toEqual(firstBytes); + expect(readFileSync(secondPath)).toEqual(secondBytes); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("locally refetches when a concurrent race winner disappears before validation", async () => { + const url = "https://cdn.example/vanishing-race-winner.mp4"; + const dir = makeTempDir(); + const cacheName = `download_${createHash("md5").update(url).digest("hex").slice(0, 12)}.mp4`; + const cachePath = join(dir, cacheName); + fsRaceControls.injectRaceAtLinkPath = cachePath; + fsRaceControls.deleteBeforeReadPath = cachePath; + const onTelemetry = vi.fn(); + const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(new Response("complete"))); + vi.stubGlobal("fetch", fetchMock); + + const path = await downloadToTemp(url, dir, 1_000, undefined, undefined, { onTelemetry }); + + expect(path).toBe(cachePath); + expect(readFileSync(path, "utf8")).toBe("complete"); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(onTelemetry).toHaveBeenCalledWith( + expect.objectContaining({ outcome: "attempt_failed", failureKind: "filesystem" }), + ); + expect(onTelemetry).toHaveBeenCalledWith( + expect.objectContaining({ outcome: "retrying", failureKind: "filesystem" }), + ); + expect(temporaryDownloadEntries(dir)).toEqual([]); + }); + + it("locally refetches when an EEXIST race winner disappears before its first lstat", async () => { + const url = "https://cdn.example/vanishing-race-winner-before-lstat.mp4"; + const dir = makeTempDir(); + const cacheName = `download_${createHash("md5").update(url).digest("hex").slice(0, 12)}.mp4`; + const cachePath = join(dir, cacheName); + fsRaceControls.injectRaceAtLinkPath = cachePath; + fsRaceControls.deleteInjectedWinnerBeforeLstatPath = cachePath; + const onTelemetry = vi.fn(); + const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(new Response("complete"))); + vi.stubGlobal("fetch", fetchMock); + + const path = await downloadToTemp(url, dir, 1_000, undefined, undefined, { onTelemetry }); + + expect(path).toBe(cachePath); + expect(readFileSync(path, "utf8")).toBe("complete"); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(onTelemetry).toHaveBeenCalledWith( + expect.objectContaining({ outcome: "attempt_failed", failureKind: "filesystem" }), + ); + expect(onTelemetry).toHaveBeenCalledWith( + expect.objectContaining({ outcome: "retrying", failureKind: "filesystem" }), + ); + expect(temporaryDownloadEntries(dir)).toEqual([]); + }); + it("does not let one caller cancellation abort another caller", async () => { const firstController = new AbortController(); const secondController = new AbortController(); @@ -545,7 +1521,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { } satisfies Partial); const path = await first; expect(readFileSync(path, "utf8")).toBe("complete"); - expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenCalledTimes(1); expect(temporaryDownloadEntries(dir)).toEqual([]); }); }); diff --git a/packages/engine/src/utils/urlDownloader.ts b/packages/engine/src/utils/urlDownloader.ts index 7e9234537..3ba5fe650 100644 --- a/packages/engine/src/utils/urlDownloader.ts +++ b/packages/engine/src/utils/urlDownloader.ts @@ -1,20 +1,25 @@ import { closeSync, + createReadStream, createWriteStream, existsSync, fsyncSync, + linkSync, mkdtempSync, mkdirSync, lstatSync, openSync, - renameSync, + readdirSync, + rmdirSync, rmSync, statSync, + type Stats, + unlinkSync, } from "fs"; -import { createHash } from "crypto"; +import { createHash, randomUUID } from "crypto"; import { BlockList, isIP } from "node:net"; import { dirname, extname, join } from "path"; -import { Readable } from "stream"; +import { Readable, Transform } from "stream"; import { pipeline } from "stream/promises"; const inFlightDownloads = new Map>(); @@ -40,22 +45,104 @@ export type UrlDownloadFailureKind = | "http_transient" | "network" | "empty_body" + | "range_protocol" + | "length_mismatch" + | "hash_mismatch" + | "invalid_payload" | "filesystem"; +export type UrlDownloadTelemetryOutcome = + | "attempt_failed" + | "cache_hit" + | "published" + | "race_reused" + | "retrying"; + +export interface UrlDownloadTelemetry { + urlFingerprint: string; + initialHost?: string; + finalHost?: string; + attempt: number; + outcome: UrlDownloadTelemetryOutcome; + status?: number; + expectedBytes?: number; + receivedBytes?: number; + rangeDisposition?: + | "none" + | "malformed_206" + | "unsolicited_206" + | "content_range_on_200" + | "full_object_200"; + etagFingerprint?: string; + etagWeak?: boolean; + localSize?: number; + localSha256?: string; + failureKind?: UrlDownloadFailureKind; +} + +export interface UrlDownloadOptions { + /** Optional strong checksum supplied by a trusted caller. */ + expectedSha256?: string; + onTelemetry?: (event: UrlDownloadTelemetry) => void; +} + +export interface PublicHttpsTextOptions { + /** Maximum decoded response bytes retained in memory. */ + maxBytes: number; + timeoutMs?: number; + signal?: AbortSignal; +} + +export interface SafeDownloadUrlIdentity { + urlFingerprint: string; + host?: string; +} + +/** Query-free, non-reversible URL identity suitable for logs and metrics. */ +export function safeDownloadUrlIdentity(url: string): SafeDownloadUrlIdentity { + let canonical = url; + let host: string | undefined; + try { + const parsed = new URL(url); + canonical = `${parsed.origin}${parsed.pathname}`; + host = parsed.hostname.toLowerCase(); + } catch { + // Invalid input is still fingerprinted; never echo it in diagnostics. + } + return { + urlFingerprint: createHash("sha256").update(canonical).digest("hex"), + host, + }; +} + +/** Default safe structured sink for engine media call sites without a logger. */ +export function writeUrlDownloadTelemetry(event: UrlDownloadTelemetry): void { + try { + process.stderr.write(`[hyperframes:download] ${JSON.stringify(event)}\n`); + } catch { + // Observability must never change download correctness. + } +} + export class UrlDownloadError extends Error { constructor( readonly kind: UrlDownloadFailureKind, readonly retryable: boolean, message: string, readonly status?: number, + readonly telemetry?: Partial, + /** A bounded in-call refetch can be safe even when upstream retry is not. */ + readonly locallyRetryable: boolean = retryable, ) { super(message); this.name = "UrlDownloadError"; } } -function classifyHttpFailure(status: number, statusText: string): UrlDownloadError { - const message = `HTTP ${status}: ${statusText}`; +function classifyHttpFailure(status: number): UrlDownloadError { + // Response.statusText is remote-controlled and some CDNs/proxies echo the + // signed request URL into it. The numeric status is sufficient and bounded. + const message = `HTTP ${status}`; if (status === 404 || status === 410) { return new UrlDownloadError("http_not_found", false, message, status); } @@ -67,20 +154,27 @@ function classifyHttpFailure(status: number, statusText: string): UrlDownloadErr function classifyDownloadFailure(error: unknown): UrlDownloadError { if (error instanceof UrlDownloadError) return error; - const message = error instanceof Error ? error.message : String(error); let current: unknown = error; // Undici often wraps a mid-body socket failure as `TypeError: terminated` // with the actionable `UND_ERR_*` code on `cause`. for (let depth = 0; current && depth < 4; depth += 1) { if (isRetryableNetworkCause(current)) { - return new UrlDownloadError("network", true, `Download failed: ${message}`); + return new UrlDownloadError( + "network", + true, + "Download failed due to a transient network error", + ); } current = typeof current === "object" && current !== null && "cause" in current ? current.cause : undefined; } - return new UrlDownloadError("filesystem", false, `Download failed: ${message}`); + return new UrlDownloadError( + "filesystem", + false, + "Download failed while writing the local artifact", + ); } const RETRYABLE_NETWORK_CODES = new Set(["ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "EAI_AGAIN"]); @@ -152,35 +246,202 @@ export function assertPublicHttpsUrl(url: string): void { try { parsed = new URL(url); } catch { - throw new Error(`[URLDownloader] Invalid URL: ${url}`); + throw new Error("[URLDownloader] Invalid URL"); } if (parsed.protocol !== "https:") { - throw new Error( - `[URLDownloader] Only HTTPS URLs are permitted in compositions (got ${parsed.protocol}): ${url}`, - ); + throw new Error(`[URLDownloader] Only HTTPS URLs are permitted in compositions`); } if (isBlockedHost(parsed.hostname)) { - throw new Error( - `[URLDownloader] URL targets a private/reserved address and is not permitted: ${url}`, - ); + throw new Error("[URLDownloader] URL targets a private/reserved address and is not permitted"); } } -function getFilenameFromUrl(url: string): string { - const hash = createHash("md5").update(url).digest("hex").slice(0, 12); +function getFilenameFromUrl(url: string, validationScope: string): string { + const physicalIdentity = validationScope === "" ? url : `${url}\0${validationScope}`; + const hash = createHash("md5").update(physicalIdentity).digest("hex").slice(0, 12); const urlObj = new URL(url); const ext = extname(urlObj.pathname) || ".mp4"; return `download_${hash}${ext}`; } -function hasCompleteFile(path: string): boolean { - if (!existsSync(path)) return false; - const entry = lstatSync(path); - if (entry.isFile() && entry.size > 0) return true; - // Old versions could leave an empty file behind. Never trust that stale - // cache entry—or a symlink/special entry planted at the final path. - rmSync(path, { recursive: entry.isDirectory(), force: true }); - return false; +function sameFileIdentity(left: Stats, right: Stats): boolean { + return left.dev === right.dev && left.ino === right.ino; +} + +const CACHE_LOCK_POLL_MS = 10; +const CACHE_LOCK_STALE_MS = 5 * 60_000; +const CACHE_LOCK_RECLAIM_NAME = ".hf-reclaim"; +const CACHE_LOCK_OWNER_PREFIX = ".hf-owner-"; + +interface CacheLockObservation { + stats: Stats; + owner?: string; +} + +function sameCacheLockStatGeneration(left: Stats, right: Stats): boolean { + return ( + sameFileIdentity(left, right) && + left.mtimeMs === right.mtimeMs && + left.ctimeMs === right.ctimeMs && + left.birthtimeMs === right.birthtimeMs + ); +} + +function observeCachePathLock(lockPath: string): CacheLockObservation { + for (let pass = 0; pass < 3; pass += 1) { + const before = lstatSync(lockPath); + const owner = readdirSync(lockPath).find((name) => name.startsWith(CACHE_LOCK_OWNER_PREFIX)); + const after = lstatSync(lockPath); + if (sameCacheLockStatGeneration(before, after)) return { stats: after, owner }; + } + throw new UrlDownloadError("filesystem", true, "Cache lock changed repeatedly during inspection"); +} + +function sameCachePathLock(left: CacheLockObservation, right: CacheLockObservation): boolean { + if (left.owner !== undefined || right.owner !== undefined) { + return left.owner !== undefined && left.owner === right.owner; + } + // Backward-compatible fallback for lock directories created by an older + // process before ownership markers were introduced. + return sameFileIdentity(left.stats, right.stats); +} + +function removeCacheLockDirectoryIfEmpty(lockPath: string): void { + try { + rmdirSync(lockPath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT" && code !== "ENOTEMPTY" && code !== "EEXIST") throw error; + } +} + +function releaseOwnedCachePathLock(lockPath: string, owner: string): void { + try { + // Consuming the unique owner marker elects exactly one releaser. The + // non-recursive rmdir below cannot delete a successor with its own marker. + rmdirSync(join(lockPath, owner)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; + } + removeCacheLockDirectoryIfEmpty(lockPath); +} + +async function waitForCacheLock(signal?: AbortSignal): Promise { + if (signal?.aborted) { + throw new UrlDownloadError("cancelled", false, "Download cancelled"); + } + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, CACHE_LOCK_POLL_MS); + const onAbort = (): void => { + clearTimeout(timeout); + signal?.removeEventListener("abort", onAbort); + reject(new UrlDownloadError("cancelled", false, "Download cancelled")); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +// The lock loop keeps filesystem races, stale-lock recovery, cancellation, and timeout together. +// fallow-ignore-next-line complexity +async function acquireCachePathLock( + localPath: string, + timeoutMs: number, + signal?: AbortSignal, +): Promise<() => void> { + const lockPath = `${localPath}.hf-lock`; + const startedAt = Date.now(); + for (;;) { + if (signal?.aborted) { + throw new UrlDownloadError("cancelled", false, "Download cancelled"); + } + let createdLock = false; + try { + mkdirSync(lockPath); + createdLock = true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + // Another process owns the path; inspect it below. + } + if (createdLock) { + const owner = `${CACHE_LOCK_OWNER_PREFIX}${randomUUID()}`; + try { + mkdirSync(join(lockPath, owner)); + const entries = readdirSync(lockPath); + if (entries.length === 1 && entries[0] === owner) { + return () => releaseOwnedCachePathLock(lockPath, owner); + } + // The empty directory was replaced or another creator reached it + // before our marker. Consume only our marker and retry ownership. + rmdirSync(join(lockPath, owner)); + removeCacheLockDirectoryIfEmpty(lockPath); + continue; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; + throw error; + } + } + + let observedLock: CacheLockObservation; + try { + observedLock = observeCachePathLock(lockPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; + throw error; + } + if (Date.now() - observedLock.stats.mtimeMs > CACHE_LOCK_STALE_MS) { + if (observedLock.owner) { + // Removing the exact unique marker is an atomic ownership claim. + // A competing releaser/reclaimer gets ENOENT and must re-observe. + try { + rmdirSync(join(lockPath, observedLock.owner)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; + throw error; + } + removeCacheLockDirectoryIfEmpty(lockPath); + continue; + } + + // Compatibility path for stale marker-less locks from an older process. + const reclaimPath = join(lockPath, CACHE_LOCK_RECLAIM_NAME); + try { + // A marker inside the observed lock serializes competing stale + // reclaimers without introducing another independently stale lock. + mkdirSync(reclaimPath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "EEXIST" || code === "ENOENT") continue; + throw error; + } + try { + const currentLock = observeCachePathLock(lockPath); + if (sameCachePathLock(currentLock, observedLock)) { + rmdirSync(reclaimPath); + removeCacheLockDirectoryIfEmpty(lockPath); + } else { + // The stale lock was replaced after our observation. Remove only + // our marker from the successor and leave its ownership intact. + rmdirSync(reclaimPath); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + continue; + } + + if (Date.now() - startedAt >= timeoutMs) { + throw new UrlDownloadError( + "timeout", + true, + `Download cache lock timeout after ${timeoutMs / 1000}s`, + ); + } + await waitForCacheLock(signal); + } } const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); @@ -228,7 +489,7 @@ function resolveRedirectUrl(response: Response, currentUrl: string, redirects: n async function fetchWithValidatedRedirects( initialUrl: string, controller: AbortController, -): Promise { +): Promise<{ response: Response; finalUrl: string }> { let currentUrl = initialUrl; for (let redirects = 0; ; redirects += 1) { assertAllowedDownloadUrl(currentUrl, redirects > 0); @@ -239,20 +500,292 @@ async function fetchWithValidatedRedirects( const response = await fetch(currentUrl, { signal: controller.signal, redirect: "manual", + headers: { "accept-encoding": "identity" }, }); - if (!REDIRECT_STATUSES.has(response.status)) return response; + if (!REDIRECT_STATUSES.has(response.status)) return { response, finalUrl: currentUrl }; await cancelResponseBody(response); currentUrl = resolveRedirectUrl(response, currentUrl, redirects); } } +/** Fetch bounded UTF-8 text while applying the downloader's redirect and SSRF policy to every hop. */ +// fallow-ignore-next-line complexity +export async function fetchPublicHttpsText( + url: string, + options: PublicHttpsTextOptions, +): Promise { + const timeoutMs = options.timeoutMs ?? 15_000; + if (!Number.isSafeInteger(options.maxBytes) || options.maxBytes <= 0) { + throw new RangeError("maxBytes must be a positive safe integer"); + } + assertPublicHttpsUrl(url); + + const controller = new AbortController(); + let timedOut = false; + let callerAborted = options.signal?.aborted ?? false; + const onCallerAbort = (): void => { + callerAborted = true; + controller.abort(); + }; + options.signal?.addEventListener("abort", onCallerAbort, { once: true }); + const timeoutId = setTimeout(() => { + timedOut = true; + controller.abort(); + }, timeoutMs); + + try { + if (callerAborted) { + throw new UrlDownloadError("cancelled", false, "Text fetch cancelled"); + } + const { response } = await fetchWithValidatedRedirects(url, controller); + if (!response.ok) { + await cancelResponseBody(response); + throw classifyHttpFailure(response.status); + } + if (!response.body) return ""; + + let declaredLength: number | undefined; + try { + declaredLength = parseDeclaredLength(response); + } catch (error) { + await cancelResponseBody(response); + throw error; + } + const contentEncoding = response.headers.get("content-encoding")?.trim().toLowerCase(); + const expectedBytes = + !contentEncoding || contentEncoding === "identity" ? declaredLength : undefined; + if (declaredLength !== undefined && declaredLength > options.maxBytes) { + await cancelResponseBody(response); + throw new UrlDownloadError( + "length_mismatch", + false, + "Text response exceeded the configured byte limit", + response.status, + ); + } + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let receivedBytes = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + receivedBytes += value.byteLength; + if (receivedBytes > options.maxBytes) { + await reader.cancel(); + throw new UrlDownloadError( + "length_mismatch", + false, + "Text response exceeded the configured byte limit", + response.status, + { receivedBytes }, + ); + } + chunks.push(value); + } + if (expectedBytes !== undefined && receivedBytes !== expectedBytes) { + throw new UrlDownloadError( + "length_mismatch", + true, + "Text response byte count did not match its declared length", + response.status, + { expectedBytes, receivedBytes }, + ); + } + return new TextDecoder().decode(Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)))); + } catch (error) { + if (callerAborted) { + throw new UrlDownloadError("cancelled", false, "Text fetch cancelled"); + } + if (timedOut) { + throw new UrlDownloadError("timeout", true, `Text fetch timeout after ${timeoutMs / 1000}s`); + } + throw classifyDownloadFailure(error); + } finally { + clearTimeout(timeoutId); + options.signal?.removeEventListener("abort", onCallerAbort); + controller.abort(); + } +} + +interface PartialIntegrity { + finalHost?: string; + status: number; + expectedBytes?: number; + receivedBytes: number; + rangeDisposition: UrlDownloadTelemetry["rangeDisposition"]; + etagFingerprint?: string; + etagWeak?: boolean; + localSize: number; + localSha256: string; +} + +function parseDeclaredLength(response: Response): number | undefined { + const raw = response.headers.get("content-length"); + if (raw === null) return undefined; + if (!/^\d+$/.test(raw.trim())) { + throw new UrlDownloadError( + "length_mismatch", + true, + "Download response Content-Length is malformed", + response.status, + { status: response.status }, + ); + } + const value = Number(raw); + if (!Number.isSafeInteger(value) || value < 0) { + throw new UrlDownloadError( + "length_mismatch", + true, + "Download response Content-Length is out of range", + response.status, + { status: response.status }, + ); + } + return value; +} + +// Keep every Content-Range invariant in one parser so malformed and unsolicited +// partial responses cannot drift into different retry classifications. +// fallow-ignore-next-line complexity +function classifyRangeDisposition(response: Response): UrlDownloadTelemetry["rangeDisposition"] { + const contentRange = response.headers.get("content-range"); + if (response.status === 206) { + const match = contentRange?.match(/^bytes (\d+)-(\d+)\/(\d+|\*)$/i); + if (!match) return "malformed_206"; + const start = Number(match[1]); + const end = Number(match[2]); + const total = match[3] === "*" ? undefined : Number(match[3]); + if ( + !Number.isSafeInteger(start) || + !Number.isSafeInteger(end) || + start < 0 || + end < start || + (total !== undefined && (!Number.isSafeInteger(total) || total <= end)) + ) { + return "malformed_206"; + } + return "unsolicited_206"; + } + if (response.status !== 200 || contentRange === null) return "none"; + + const match = contentRange.match(/^bytes (\d+)-(\d+)\/(\d+)$/i); + const contentLength = response.headers.get("content-length")?.trim(); + const contentEncoding = response.headers.get("content-encoding")?.trim().toLowerCase(); + if (!match || !contentLength || !/^\d+$/.test(contentLength)) { + return "content_range_on_200"; + } + const start = Number(match[1]); + const end = Number(match[2]); + const total = Number(match[3]); + const declaredLength = Number(contentLength); + return Number.isSafeInteger(start) && + Number.isSafeInteger(end) && + Number.isSafeInteger(total) && + Number.isSafeInteger(declaredLength) && + start === 0 && + total > 0 && + end === total - 1 && + declaredLength === total && + (!contentEncoding || contentEncoding === "identity") + ? "full_object_200" + : "content_range_on_200"; +} + +function looksLikeRemoteErrorDocument(prefix: Buffer): boolean { + const text = prefix + .toString("utf8") + .replace(/^\uFEFF?\s*/, "") + .toLowerCase(); + return ( + text.startsWith(", +): UrlDownloadError { + return new UrlDownloadError( + "hash_mismatch", + source === "server", + "Download payload checksum did not match", + status, + telemetry, + true, + ); +} + +// Response protocol, streamed byte accounting, hashes, and payload validation +// share one lifecycle so no validation can happen after publication. +// fallow-ignore-next-line complexity async function fetchToPartial( url: string, partialPath: string, controller: AbortController, -): Promise { - const response = await fetchWithValidatedRedirects(url, controller); + options: UrlDownloadOptions, +): Promise { + const { response, finalUrl } = await fetchWithValidatedRedirects(url, controller); + const finalIdentity = safeDownloadUrlIdentity(finalUrl); + const rangeDisposition = classifyRangeDisposition(response); + if (rangeDisposition !== "none" && rangeDisposition !== "full_object_200") { + await cancelResponseBody(response); + throw new UrlDownloadError( + "range_protocol", + true, + rangeDisposition === "malformed_206" + ? "Download received a malformed unsolicited partial response" + : "Download received an unsolicited partial response", + response.status, + { + finalHost: finalIdentity.host, + status: response.status, + rangeDisposition, + }, + ); + } if (!response.ok) { // Do not leave a streaming error response holding an Undici connection // while the bounded retry starts. @@ -261,22 +794,148 @@ async function fetchToPartial( } catch { // The HTTP status remains the useful failure if teardown also fails. } - throw classifyHttpFailure(response.status, response.statusText); + const classified = classifyHttpFailure(response.status); + throw new UrlDownloadError( + classified.kind, + classified.retryable, + classified.message, + classified.status, + { finalHost: finalIdentity.host, status: response.status, rangeDisposition }, + ); } if (!response.body) { - throw new UrlDownloadError("empty_body", true, "Download response body is empty"); + throw new UrlDownloadError( + "empty_body", + true, + "Download response body is empty", + response.status, + { + finalHost: finalIdentity.host, + status: response.status, + }, + ); } - const fileStream = createWriteStream(partialPath, { flags: "wx" }); + let declaredLength: number | undefined; + try { + declaredLength = parseDeclaredLength(response); + } catch (error) { + await cancelResponseBody(response); + if (error instanceof UrlDownloadError) { + throw new UrlDownloadError(error.kind, error.retryable, error.message, error.status, { + ...error.telemetry, + finalHost: finalIdentity.host, + rangeDisposition, + }); + } + throw error; + } + const contentEncoding = response.headers.get("content-encoding")?.trim().toLowerCase(); + const expectedBytes = + !contentEncoding || contentEncoding === "identity" ? declaredLength : undefined; + + let receivedBytes = 0; + const sha256 = createHash("sha256"); + const md5 = createHash("md5"); + const prefixChunks: Buffer[] = []; + let prefixBytes = 0; + const inspector = new Transform({ + transform(chunk: Buffer, _encoding, callback) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + receivedBytes += bytes.length; + sha256.update(bytes); + md5.update(bytes); + if (prefixBytes < 1024) { + const remaining = 1024 - prefixBytes; + const sample = bytes.subarray(0, remaining); + prefixChunks.push(sample); + prefixBytes += sample.length; + } + callback(null, bytes); + }, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any const readableStream = Readable.fromWeb(response.body as any); - await pipeline(readableStream, fileStream); - if (statSync(partialPath).size === 0) { - throw new UrlDownloadError("empty_body", true, "Download response body contained zero bytes"); + const fileStream = createWriteStream(partialPath, { flags: "wx" }); + await pipeline(readableStream, inspector, fileStream); + const localSize = statSync(partialPath).size; + const sha256Bytes = sha256.digest(); + const localSha256 = sha256Bytes.toString("hex"); + const md5Base64 = md5.digest("base64"); + const telemetry = { + finalHost: finalIdentity.host, + status: response.status, + expectedBytes, + receivedBytes, + rangeDisposition, + localSize, + localSha256, + } satisfies Partial; + if (receivedBytes === 0 || localSize === 0) { + throw new UrlDownloadError( + "empty_body", + true, + "Download response body contained zero bytes", + response.status, + telemetry, + ); } + if ( + localSize !== receivedBytes || + (expectedBytes !== undefined && receivedBytes !== expectedBytes) + ) { + throw new UrlDownloadError( + "length_mismatch", + true, + "Download response byte count did not match its declared length", + response.status, + telemetry, + ); + } + const prefix = Buffer.concat(prefixChunks); + if (looksLikeRemoteErrorDocument(prefix)) { + throw new UrlDownloadError( + "invalid_payload", + false, + "Download returned an HTML or JSON error document", + response.status, + telemetry, + ); + } + + const expectedSha256 = expectedResponseSha256(response, options.expectedSha256); + const checksumMatches = + expectedSha256 === null || + (expectedSha256.encoding === "base64" + ? sha256Bytes.toString("base64") === expectedSha256.value + : localSha256 === expectedSha256.value); + const contentMd5 = response.headers.get("content-md5")?.trim(); + if (!checksumMatches) { + throw checksumMismatchError(expectedSha256?.source ?? "server", response.status, telemetry); + } + if (expectedSha256 === null && contentMd5 && md5Base64 !== contentMd5) { + throw checksumMismatchError("server", response.status, telemetry); + } + + const etag = response.headers.get("etag")?.trim(); + return { + finalHost: finalIdentity.host, + status: response.status, + expectedBytes, + receivedBytes, + rangeDisposition, + etagFingerprint: etag ? createHash("sha256").update(etag).digest("hex") : undefined, + etagWeak: etag ? /^W\//i.test(etag) : undefined, + localSize, + localSha256, + }; } -function syncAndPublishPartial(partialPath: string, localPath: string): void { +function syncAndPublishPartial( + partialPath: string, + localPath: string, +): Extract { // Windows rejects fsync on a read-only handle (EPERM); the partial is ours // and writable, so r+ preserves the same flush semantics cross-platform. const fd = openSync(partialPath, "r+"); @@ -286,20 +945,48 @@ function syncAndPublishPartial(partialPath: string, localPath: string): void { closeSync(fd); } - // Different cancellation scopes intentionally do not share a physical - // request. If another complete attempt won the final-path race, reuse it. - if (hasCompleteFile(localPath)) return; + // A hard-link publish is atomic and no-clobber: unlike rename(), it cannot + // replace a path that another successful caller has already returned. try { - renameSync(partialPath, localPath); + linkSync(partialPath, localPath); + unlinkSync(partialPath); + return "published"; } catch (error) { - if (!hasCompleteFile(localPath)) throw error; + const code = (error as NodeJS.ErrnoException).code; + if (code !== "EEXIST") throw error; + let winner: Stats; + try { + winner = lstatSync(localPath); + } catch (inspectionError) { + if ((inspectionError as NodeJS.ErrnoException).code !== "ENOENT") throw inspectionError; + throw new UrlDownloadError( + "filesystem", + true, + "Concurrent cache artifact disappeared before validation", + ); + } + if (!winner.isFile() || winner.size === 0) throw error; + return "race_reused"; } } +function emitDownloadTelemetry(options: UrlDownloadOptions, event: UrlDownloadTelemetry): void { + try { + options.onTelemetry?.(event); + } catch { + // Metrics/logging callbacks cannot affect publication or retry policy. + } +} + +// Attempt-scoped cancellation, cleanup, publication, and telemetry deliberately +// remain under one try/finally so every exit removes the unique partial directory. +// fallow-ignore-next-line complexity async function runDownloadAttempt( url: string, localPath: string, timeoutMs: number, + attempt: number, + options: UrlDownloadOptions, signal?: AbortSignal, ): Promise { // A private, unguessable directory prevents symlink planting and keeps the @@ -318,22 +1005,92 @@ async function runDownloadAttempt( timedOut = true; controller.abort(); }, timeoutMs); + const identity = safeDownloadUrlIdentity(url); try { if (callerAborted) { throw new UrlDownloadError("cancelled", false, "Download cancelled"); } - await fetchToPartial(url, partialPath, controller); - syncAndPublishPartial(partialPath, localPath); + const integrity = await fetchToPartial(url, partialPath, controller, options); + let outcome = syncAndPublishPartial(partialPath, localPath); + let publishedIntegrity = integrity; + if (outcome === "race_reused") { + let inspection: Awaited>; + try { + inspection = await inspectExistingFile(localPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + throw new UrlDownloadError( + "filesystem", + true, + "Concurrent cache artifact disappeared before validation", + integrity.status, + integrity, + ); + } + publishedIntegrity = { + ...integrity, + localSize: inspection.localSize, + localSha256: inspection.localSha256, + }; + if (!localInspectionMatchesOptions(inspection, options)) { + if (looksLikeRemoteErrorDocument(inspection.prefix)) { + throw new UrlDownloadError( + "invalid_payload", + false, + "Concurrent download published an HTML or JSON error document", + integrity.status, + publishedIntegrity, + ); + } + throw checksumMismatchError("caller", integrity.status, publishedIntegrity); + } + } + emitDownloadTelemetry(options, { + urlFingerprint: identity.urlFingerprint, + initialHost: identity.host, + attempt, + outcome, + ...publishedIntegrity, + }); return localPath; } catch (error) { if (callerAborted) { - throw new UrlDownloadError("cancelled", false, "Download cancelled"); + const classified = new UrlDownloadError("cancelled", false, "Download cancelled"); + emitDownloadTelemetry(options, { + urlFingerprint: identity.urlFingerprint, + initialHost: identity.host, + attempt, + outcome: "attempt_failed", + failureKind: classified.kind, + }); + throw classified; } if (timedOut) { - throw new UrlDownloadError("timeout", true, `Download timeout after ${timeoutMs / 1000}s`); + const classified = new UrlDownloadError( + "timeout", + true, + `Download timeout after ${timeoutMs / 1000}s`, + ); + emitDownloadTelemetry(options, { + urlFingerprint: identity.urlFingerprint, + initialHost: identity.host, + attempt, + outcome: "attempt_failed", + failureKind: classified.kind, + }); + throw classified; } - throw classifyDownloadFailure(error); + const classified = classifyDownloadFailure(error); + emitDownloadTelemetry(options, { + urlFingerprint: identity.urlFingerprint, + initialHost: identity.host, + attempt, + outcome: "attempt_failed", + failureKind: classified.kind, + ...classified.telemetry, + }); + throw classified; } finally { clearTimeout(timeoutId); signal?.removeEventListener("abort", onCallerAbort); @@ -348,36 +1105,161 @@ async function downloadWithRetry( timeoutMs: number, signal?: AbortSignal, onTransientRetry?: (error: UrlDownloadError) => void, + options: UrlDownloadOptions = {}, ): Promise { const maxTransientRetries = 1; for (let attempt = 0; ; attempt += 1) { try { - return await runDownloadAttempt(url, localPath, timeoutMs, signal); + return await runDownloadAttempt(url, localPath, timeoutMs, attempt + 1, options, signal); } catch (error) { const classified = classifyDownloadFailure(error); - if (!classified.retryable || attempt >= maxTransientRetries) throw classified; - onTransientRetry?.(classified); + if (!classified.locallyRetryable || attempt >= maxTransientRetries) throw classified; + if (classified.retryable) onTransientRetry?.(classified); + const identity = safeDownloadUrlIdentity(url); + emitDownloadTelemetry(options, { + urlFingerprint: identity.urlFingerprint, + initialHost: identity.host, + attempt: attempt + 1, + outcome: "retrying", + failureKind: classified.kind, + ...classified.telemetry, + }); } } } +async function inspectExistingFile(path: string): Promise<{ + localSize: number; + localSha256: string; + prefix: Buffer; +}> { + const sha256 = createHash("sha256"); + const prefixChunks: Buffer[] = []; + let prefixBytes = 0; + let localSize = 0; + for await (const chunk of createReadStream(path)) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + localSize += bytes.length; + sha256.update(bytes); + if (prefixBytes < 1024) { + const sample = bytes.subarray(0, 1024 - prefixBytes); + prefixChunks.push(sample); + prefixBytes += sample.length; + } + } + return { + localSize, + localSha256: sha256.digest("hex"), + prefix: Buffer.concat(prefixChunks), + }; +} + +function localInspectionMatchesOptions( + inspection: { localSize: number; localSha256: string; prefix: Buffer }, + options: UrlDownloadOptions, +): boolean { + const expectedSha256 = options.expectedSha256?.trim().toLowerCase(); + return ( + inspection.localSize > 0 && + !looksLikeRemoteErrorDocument(inspection.prefix) && + (!expectedSha256 || inspection.localSha256 === expectedSha256) + ); +} + +function sameCacheEntry(before: Stats, after: Stats): boolean { + return ( + sameFileIdentity(before, after) && + before.size === after.size && + before.mtimeMs === after.mtimeMs + ); +} + +// Cache identity checks must remain adjacent to invalidation to avoid widening the TOCTOU window. +// fallow-ignore-next-line complexity +async function reuseOrInvalidateCachedFile( + url: string, + localPath: string, + timeoutMs: number, + signal: AbortSignal | undefined, + options: UrlDownloadOptions, +): Promise { + const releaseLock = await acquireCachePathLock(localPath, timeoutMs, signal); + try { + // Re-inspect when a mixed-version process changes the path while it is open. + for (let pass = 0; pass < 3; pass += 1) { + let before: Stats; + try { + before = lstatSync(localPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; + throw error; + } + if (!before.isFile() || before.size === 0) { + rmSync(localPath, { recursive: before.isDirectory(), force: true }); + return false; + } + + let inspection: Awaited>; + try { + inspection = await inspectExistingFile(localPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; + throw error; + } + let after: Stats; + try { + after = lstatSync(localPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; + throw error; + } + if (!sameCacheEntry(before, after)) continue; + + if (localInspectionMatchesOptions(inspection, options)) { + const identity = safeDownloadUrlIdentity(url); + emitDownloadTelemetry(options, { + urlFingerprint: identity.urlFingerprint, + initialHost: identity.host, + attempt: 0, + outcome: "cache_hit", + receivedBytes: inspection.localSize, + localSize: inspection.localSize, + localSha256: inspection.localSha256, + rangeDisposition: "none", + }); + return true; + } + + rmSync(localPath, { force: true }); + return false; + } + return false; + } finally { + releaseLock(); + } +} + export async function downloadToTemp( url: string, destDir: string, timeoutMs: number = 300000, signal?: AbortSignal, onTransientRetry?: (error: UrlDownloadError) => void, + options: UrlDownloadOptions = {}, ): Promise { // Reject non-HTTPS URLs and private/reserved address ranges before // touching the cache or filesystem — customer-supplied compositions must // not be able to trigger outbound fetches to internal infrastructure. assertPublicHttpsUrl(url); + const expectedSha256 = normalizeCallerSha256(options.expectedSha256); + const normalizedOptions = { ...options, expectedSha256 }; const cacheKey = `${url}\0${destDir}`; // The physical request may be shared only by callers with the same // cancellation scope and deadline. Otherwise the first caller's abort or // timeout would incorrectly own every waiter. - const inFlightKey = `${cacheKey}\0${timeoutMs}\0${signalScopeKey(signal)}`; + const validationScope = expectedSha256 ?? ""; + const inFlightKey = `${cacheKey}\0${timeoutMs}\0${signalScopeKey(signal)}\0${validationScope}`; const inFlight = inFlightDownloads.get(inFlightKey); if (inFlight) { return inFlight; @@ -387,12 +1269,38 @@ export async function downloadToTemp( mkdirSync(destDir, { recursive: true }); } - const filename = getFilenameFromUrl(url); + const filename = getFilenameFromUrl(url, validationScope); const localPath = join(destDir, filename); - if (hasCompleteFile(localPath)) return localPath; - - const downloadPromise = downloadWithRetry(url, localPath, timeoutMs, signal, onTransientRetry); + // Register before the first asynchronous cache inspection so same-scope + // callers cannot both race through stale-entry invalidation. + const downloadPromise = (async () => { + const cacheStartedAt = Date.now(); + const reused = await reuseOrInvalidateCachedFile( + url, + localPath, + timeoutMs, + signal, + normalizedOptions, + ); + const remainingTimeoutMs = timeoutMs - (Date.now() - cacheStartedAt); + if (remainingTimeoutMs <= 0) { + throw new UrlDownloadError( + "timeout", + true, + `Download cache inspection timeout after ${timeoutMs / 1000}s`, + ); + } + if (reused) return localPath; + return downloadWithRetry( + url, + localPath, + remainingTimeoutMs, + signal, + onTransientRetry, + normalizedOptions, + ); + })(); const trackedDownload = downloadPromise.finally(() => { inFlightDownloads.delete(inFlightKey); }); diff --git a/packages/gcp-cloud-run/package.json b/packages/gcp-cloud-run/package.json index f192d94ba..2566ead23 100644 --- a/packages/gcp-cloud-run/package.json +++ b/packages/gcp-cloud-run/package.json @@ -1,6 +1,6 @@ { "name": "@hyperframes/gcp-cloud-run", - "version": "0.7.92", + "version": "0.7.94", "description": "Google Cloud Run + Workflows adapter for HyperFrames distributed rendering — request handler, client-side SDK, and Terraform module.", "repository": { "type": "git", diff --git a/packages/lint/package.json b/packages/lint/package.json index 9ee95aabc..b9247d9c4 100644 --- a/packages/lint/package.json +++ b/packages/lint/package.json @@ -1,6 +1,6 @@ { "name": "@hyperframes/lint", - "version": "0.7.92", + "version": "0.7.94", "repository": { "type": "git", "url": "https://github.com/heygen-com/hyperframes", diff --git a/packages/parsers/package.json b/packages/parsers/package.json index 6ece98a24..f606b45a1 100644 --- a/packages/parsers/package.json +++ b/packages/parsers/package.json @@ -1,6 +1,6 @@ { "name": "@hyperframes/parsers", - "version": "0.7.92", + "version": "0.7.94", "repository": { "type": "git", "url": "https://github.com/heygen-com/hyperframes", diff --git a/packages/player/package.json b/packages/player/package.json index 5233cb09b..4e33ce6a9 100644 --- a/packages/player/package.json +++ b/packages/player/package.json @@ -1,6 +1,6 @@ { "name": "@hyperframes/player", - "version": "0.7.92", + "version": "0.7.94", "description": "Embeddable web component for HyperFrames compositions", "repository": { "type": "git", diff --git a/packages/producer/package.json b/packages/producer/package.json index a2c678dff..10f6a5bae 100644 --- a/packages/producer/package.json +++ b/packages/producer/package.json @@ -1,6 +1,6 @@ { "name": "@hyperframes/producer", - "version": "0.7.92", + "version": "0.7.94", "description": "HTML-to-video rendering engine using Chrome's BeginFrame API", "repository": { "type": "git", diff --git a/packages/producer/src/services/distributed/renderChunk.ts b/packages/producer/src/services/distributed/renderChunk.ts index 684cd87f3..fe99e98b8 100644 --- a/packages/producer/src/services/distributed/renderChunk.ts +++ b/packages/producer/src/services/distributed/renderChunk.ts @@ -51,6 +51,7 @@ import { createCaptureSession, createFrameLookupTable, createVideoFrameInjector, + deriveBeginFrameProbeTimeTicks, type EngineConfig, type ExtractedFrames, type FrameLookupTable, @@ -316,7 +317,10 @@ export async function beginFrameSessionNeedsScreenshotFallback( Number(process.env.PRODUCER_BEGINFRAME_PROBE_TIMEOUT_MS) > 0 ? Number(process.env.PRODUCER_BEGINFRAME_PROBE_TIMEOUT_MS) : 30_000; - const probeTick = Math.max(0, session.beginFrameTimeTicks - 5 * session.beginFrameIntervalMs); + const probeTick = deriveBeginFrameProbeTimeTicks( + session.beginFrameTimeTicks, + session.beginFrameIntervalMs, + ); return !(await probe(session.page, timeoutMs, probeTick, session.beginFrameIntervalMs)); } diff --git a/packages/producer/src/services/htmlCompiler.test.ts b/packages/producer/src/services/htmlCompiler.test.ts index a69be7e02..6fda36d7f 100644 --- a/packages/producer/src/services/htmlCompiler.test.ts +++ b/packages/producer/src/services/htmlCompiler.test.ts @@ -85,6 +85,22 @@ describe("discoverMediaFromBrowser", () => { }); }); +function validTestMediaResponse(): Response { + const bytes = new Uint8Array([ + 0, 0, 0, 24, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d, 0, 0, 0, 0, 0x69, 0x73, 0x6f, 0x6d, + 0x6d, 0x70, 0x34, 0x32, + ]); + return new Response(bytes, { status: 200 }); +} + +function validTestImageResponse(): Response { + const png = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", + "base64", + ); + return new Response(png, { status: 200 }); +} + describe("injectSdkPositionEditsRenderScript", () => { it("injects before when SDK position-edit markers are present", () => { const html = @@ -1424,7 +1440,7 @@ describe("localizeRemoteMediaSources", () => { it("rewrites remote