Skip to content

fix(cli): prefer OAuth session over HEYGEN_API_KEY - #7051

Merged
jrusso1020 merged 3 commits into
mainfrom
fix/cli-oauth-over-env-key
Nov 3, 2026
Merged

jrusso1020 merged 3 commits into
mainfrom
fix/cli-oauth-over-env-key

Conversation

@jrusso1020

Copy link
Copy Markdown
Collaborator

What

A signed-in hyperframes auth login session now wins over an API key in HEYGEN_API_KEY / HYPERFRAMES_API_KEY.

Order before: HEYGEN_API_KEY > HYPERFRAMES_API_KEY > HEYGEN_ACCESS_TOKEN > ~/.hyperframes/credentials (login, else saved key).

Order after:

  1. HEYGEN_ACCESS_TOKEN (a host app's injected OAuth token)
  2. The stored login, fresh or expired with a refresh token (refreshed before use)
  3. HEYGEN_API_KEY
  4. HYPERFRAMES_API_KEY
  5. The API key saved with auth login --api-key

The env key still comes first in two cases:

  • HEYGEN_API_URL or HEYGEN_API_BASE names a host other than HeyGen's. A host app's gateway takes the key it set, and an OAuth token must never be sent to it.
  • The login fails to refresh. The env key is used instead of an error, and for the rest of the process, so polling loops do not retry the refresh on every request.

An expired login with no env key still asks you to sign in, and still never switches to the saved API key.

Why

A Pro feature (hyperframes reverse start) refused a user on a paid plan as Free. Their login carried the plan, but a HEYGEN_API_KEY exported in the shell outranked it. An ambient env key should not silently replace the account you signed in with.

Related work

None.

How

All CLI paths already go through resolveCredential in packages/cli/src/auth/resolver.ts, so the order changes there once. The refresh sites fall back to the env key when a refresh fails:

  • refreshIfNeeded and forceRefreshCredentials in packages/cli/src/cloud/auth.ts (cloud render/get/list/delete, the 401 retry, reverse and other v3 jobs, audio analysis, publish)
  • withMediaAuthEnvironment in packages/cli/src/auth/media-environment.ts (media-use helpers)
  • auth status refreshes first, so it reports the credential commands actually use
  • publish keeps no fallback for a login it already checked mid-publish, so one publish never switches accounts halfway
  • The --update / --space error no longer says to unset the env key; signing in is enough now

The standalone media-use audio helper (skills/media-use/audio/scripts/lib/heygen.mjs) uses the same order. It cannot refresh, so an expired login gives way to an env key.

Explicit override: no command takes a per-command --api-key flag (--api-key exists only on auth login, where it saves a key), so there was nothing explicit to keep ahead of the login, and no new flag is added. To use an env key while signed in, run hyperframes auth logout. If a per-run switch turns out to be needed, it would be a flag on the HeyGen commands that picks the env key, added in its own PR.

Test plan

  • Unit tests added/updated
  • Manual testing performed
  • Documentation updated (if applicable)
  • Comments follow CONTRIBUTING.md "Comments": they say why, not what, and a bug fix says what the code must do and how to reproduce the bug

New tests:

  • resolver: login + env key -> login; expired login with refresh token + env key -> refreshable login; expired login without refresh + env key -> env key; foreign-client login + env key -> env key; broken store + env key -> env key; gateway URL or base -> env key; after a failed refresh -> env key; host token first; env key over saved key
  • cloud auth (new file): login + env key signs with the login; env only -> env key; expired login refreshes; failed refresh + env key -> env key with one refresh attempt; failed refresh without env key -> error; opt-out keeps the error; a 401 on a login that cannot refresh -> env key
  • media environment: login + env key passes the login; failed refresh + env key -> env key; no env key -> error; gateway keeps its key
  • media-use helper: live login beats env key; expired login and saved key give way to it

generate.test.ts now sets HEYGEN_API_URL to the host its mocks answer for. With an env key the resolver now reads the credentials file, and that real file read raced the test's fake timers.

"login + env key -> login" fails on main (resolves source: "env", type: "api_key").

Ran locally: bun run lint, oxfmt --check, CLI tsc --noEmit, the CLI unit suite (6373 passed), the media-use audio node tests, fallow audit, and the comment checks.

🤖 Generated with Claude Code

A signed-in `hyperframes auth login` session now wins over an API key in
the environment, so a paid plan on the login is used even when the shell
also exports HEYGEN_API_KEY.

New order: HEYGEN_ACCESS_TOKEN (host app), the stored login (refreshed
when expired), HEYGEN_API_KEY, HYPERFRAMES_API_KEY, the saved API key.
The env key still comes first when HEYGEN_API_URL or HEYGEN_API_BASE
names a host app's gateway, and takes over for the rest of the process
when the login cannot be refreshed. The media-use audio helper follows
the same order.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
@mintlify

mintlify Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated
hyperframes 🟢 Ready View Preview Nov 3, 2026, 12:50 AM

💡 Tip: Enable Automations to automatically generate PRs for you.

@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 267 (base branch 267), smooth 111 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (8)

Unstable (1)

  • seqfastkeys-none-px-r0-root-z100: tracking 193.74, pressJump 0.05, drop 0, reload 0, render 0.03, renderKey -, undo false, teleport false / tracking 0.06, pressJump 0.05, drop 0, reload 0, render 0.02, renderKey -, undo true, teleport true / tracking 0.07, pressJump 0.05, drop 0, reload 0, render 0.02, renderKey -, undo true, teleport true

@jrusso1020

Copy link
Copy Markdown
Collaborator Author

@miguel-heygen FYI: this is the fix for the env key winning over a fresh OAuth login that Terence hit in the reverse-any-video bug bash.

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: REQUEST CHANGES · 1 blocker · 0 important · 1 nit · head 2bea9f69

The new order and the gateway exception hold up. The blocker is the refresh-failure fallback (see inline): it switches on any refresh error and then stays switched for the whole process, so a single transient failure brings back the bug this PR fixes.

Scope
  • Read all of resolver.ts, cloud/auth.ts, media-environment.ts, publishProject's credential path, status.ts and heygen.mjs. Checked every resolveCredential/tryResolveCredential caller: all of them refresh before signing, so an expired-but-refreshable login never goes out unrefreshed.
  • Gateway: HFI's agent env (heygenAgentEnv.mjs) sets HEYGEN_API_URL to the loopback gateway and blanks HEYGEN_ACCESS_TOKEN, so its key still goes first. voiceFix and withMediaAuthEnvironment each set the token and delete the keys. No known host sets both a token and a key on a HeyGen host, so moving HEYGEN_ACCESS_TOKEN first changes nothing for them.
  • Not from this PR: with a gateway URL and no env key, the stored login still goes to the gateway through the cloud client, so the resolver comment saying it "must never receive an OAuth token" is only enforced for media.
  • Confirmed with a throwaway vitest at the head (not committed). CI is green.

Comment thread packages/cli/src/cloud/auth.ts Resolved
A 503, a network error or an overlapping refresh no longer switches the
process to HEYGEN_API_KEY. The fallback now needs a refused grant or an
expired login with no refresh token. A login another process stored since
is re-read first, and refreshes that overlap in one process share one
request. The media-use helper no longer treats HeyGen's own API base as a
gateway.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

@miguel-heygen miguel-heygen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent review at ede3c3d8e9765a067b581fe9f96531dce3dff993, additive to heygengenesis's refresh-failure finding. The new order preserves the stored login ahead of shell keys, and the current change repairs the 503/network and overlapping-refresh cases. I audited all 23 changed paths plus cloud, media, publish, status and audio credential consumers.

Blocker — malformed token replies still permanently select another credential: packages/cli/src/auth/resolver.ts:154–158 treats every REFRESH_FAILED as a refused grant. However, refreshOnce also calls parseTokenResponse on successful HTTP responses (packages/cli/src/auth/oauth.ts:400–401), and that parser throws the same code for {}, null, missing access_token or unsafe token fields (:542–557). With an expired stored login and HEYGEN_API_KEY set, HTTP 200 {} therefore returns the shell key and sets loginRefreshFailed=true; subsequent calls skip the login even if the endpoint recovers. A malformed response did not establish that the grant was refused, and this restores the wrong-account/billing selection the PR is fixing.

Distinguish actual grant rejection from token-response parsing/protocol errors before enabling sticky fallback. Add a malformed-200 → successful-refresh regression asserting the first call throws without selecting the key and the next call signs with the refreshed login. This finding follows the exact source/error path; no live login, token refresh or paid request was made.

Validation limit: current-head CI run 41555665808, attempt 1, was still in progress at 00:55:54 UTC. The fresh guard also still has checks in progress (CodeQL, Windows test shards and Flake gate); no failure conclusion was returned. No whole-job bodies or completed-suite result are claimed; no local tests were run. The blocker is independently established by the source error path.

— Magi (cutroom-dev)

Verdict: REQUEST CHANGES
Reasoning: Credential precedence and transient network handling improve, but a token parser error still permanently switches from the stored login to an environment API key without a refused grant.

Only a 400/401 from the token endpoint marks the grant as refused and
switches to the env key. A 200 whose body can't be parsed now throws and
leaves the login in place, so the next call refreshes again.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: APPROVE · 2 resolved, 0 open, 0 new · 2bea9f69 → 6605e03f

Magi's malformed-200 blocker is also fixed: only a 400/401 (refused) or LOGIN_EXPIRED switches to the env key, and there's a test for it. Required CI is green. The optional Comments ratchet is red: errors.ts share 2.2% → 3.0% from the new refused doc line.

@miguel-heygen miguel-heygen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at 6605e03f1cc4c0b6142908adc86775f5feb7146b: my malformed-success-response blocker in review5745627713 is resolved; I found no further code blocker.

packages/cli/src/auth/oauth.ts:393 explicitly marks only HTTP400/401 refresh refusals; packages/cli/src/auth/resolver.ts:154 preserves the login on malformed200, network and server errors, while retaining concurrent-login recovery. packages/cli/src/cloud/auth.test.ts:86 pins malformed200 followed by a healthy login refresh. I also traced the shared cloud/media/publish callers and retained gateway behavior; this agrees with heygengenesis's current source review.

Audited: the six-path repair since my prior23-path review, OAuth parsing/refusal classification, resolver and affected callers; unchanged code reuses that prior audit. Original hosted CI41557164722 tested this head through synthetic6aaa689 into eb35629. Both CLI shards passed (4009+3360 cases;23+89 skipped); the entire12-case cloud-auth file and26-case resolver file passed. Build, Lint, Typecheck, Format and required CLI smoke executed successfully. The final guard finds all 12 required contexts successful; GitHub still reports blocked. Empty combined commit statuses do not represent an additional executing job. No live login refresh or paid identity probe was performed.

Nit: packages/cli/src/auth/errors.ts:20 raises the optional Comments ratchet from2/91 to3/100 comment lines; original job125658753861 fails for that reason. Retain this failure rather than calling all checks green.

Verdict: APPROVE
Reasoning: Explicit refusal classification closes the sticky-env identity regression, and the original hosted regression file passes. The optional comment-density failure is not a correctness blocker.

— Magi (cutroom-dev)

@jrusso1020
jrusso1020 added this pull request to the merge queue Nov 3, 2026
Merged via the queue into main with commit d03bdea Nov 3, 2026
85 of 86 checks passed
@jrusso1020
jrusso1020 deleted the fix/cli-oauth-over-env-key branch November 3, 2026 02:08
@jrusso1020 jrusso1020 mentioned this pull request Nov 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants