Skip to content

fix(settings): pasting a provider API key no longer blacks out the window - #97

Closed
BIackFIame wants to merge 2 commits into
howdeploy:mainfrom
BIackFIame:fix/long-key-paste
Closed

BIackFIame wants to merge 2 commits into
howdeploy:mainfrom
BIackFIame:fix/long-key-paste

Conversation

@BIackFIame

Copy link
Copy Markdown
Contributor

Based on main (20f6855). Independent of #96; the two merge cleanly, and the packaged test build carries both.

Symptom

In Settings → Agents → Provider API keys, pasting a MiniMax key (a long sk-cp-… / sk-api-… string) turned the whole window black, and it stayed black until the app was restarted.

Root cause

The key field's change handler read the event inside a functional state updater:

onChange={(event) => setDrafts((current) => ({ ...current, [secretId]: event.currentTarget.value }))}

React only runs an updater while the handler is still executing when the component has no update pending. When one is pending (a paste over a character already in the field, or a second keystroke before the render), React runs the updater later, during render. By then the event has finished dispatching and currentTarget is null. The render throws TypeError: Cannot read properties of null (reading 'value'), and React 19 unmounts the whole root because nothing catches the error. #root is left empty.

The renderer process keeps running, so render-process-gone never fires and the main process's crash reload never runs. That is why the window stayed black.

The key's length and shape are not the cause. A 120-character key pasted over a pending edit breaks the same way. Nothing in the renderer validates, masks or runs a regex over the key, and the main-process redaction registry is linear: registering a 100 000-character key and masking 200 KB takes under 15 ms.

Repro (before the fix)

Hidden-window harness (keychain stubbed, --use-mock-keychain, throwaway user data), running the packaged build of main + #96. It opens Settings, focuses the MiniMax field and pastes a fake key with CDP Input.insertText:

  • A 120-character fake key into the empty field works.
  • A 200-character fake key pasted over it leaves #root with 0 children and empty body text. The renderer console shows the TypeError from the provider-secret__input onChange updater. The renderer PID doesn't change, render-process-gone doesn't fire, and nothing recovers.

Fix

  1. ProviderSecretsSettings: read event.currentTarget.value while the handler runs, then pass the value to the updater. An AST scan over src/renderer found no other state updater that reads an event.
  2. Uncaught render errors no longer leave the window black. The React root now sets onUncaughtError:
    • The first such error is logged with its component stack, and the application surface reloads in place. Sessions live in the main process and survive the reload, as they do after a renderer crash.
    • A second error within 30 s shows a static page with a Reload button instead of reloading in a loop. The same happens when session storage can't record the reload time.
    • The page's text is in Russian and English.

Tests

  • tests/provider-secrets-settings.test.mjs
    • Bundles the real component against a React stand-in whose updaters run after dispatch, the ordering React uses when an update is pending.
    • Pastes fake sk-cp-/sk-api- keys of 120, 200, 250, 500, 2 000 and 10 000 characters, plus one with a trailing newline and one with a trailing space, each over a pending edit.
    • Checks that the field holds the key and Save is enabled, all within a 1 s bound.
    • Fails before the fix with the same TypeError.
    • A line-level guard keeps event reads out of state updaters in the renderer.
  • tests/uncaught-error-recovery.test.mjs: the first error reloads, a second within the cooldown shows the page, an error after the cooldown reloads again, a clock that moved backwards doesn't block the reload, no storage shows the page, and the root routes errors to the recovery.
  • Full suite 1162/1163 passing, 1 skipped (--test-concurrency=2, fake HOME), on this branch and on this branch merged with fix(startup): load the application surface only after the startup page settled #96. Typecheck, electron-vite build, audit:secrets and test:even pass.

Checked in the packaged app

Built with npm run package from main + #96 + this branch. The harness loaded the app.asar contents, which match out/ byte for byte, with the window hidden, the keychain stubbed and a throwaway user data directory:

  • Pasting fake keys of 120 to 10 000 characters, and ones with a trailing newline or space, keeps the window rendered. The field holds the full key and Save is enabled.
  • An injected render error reloads to a usable Settings screen. A second one within 30 s shows the recovery page.
  • forcefullyCrashRenderer() still recovers through render-process-gone. There is a new renderer PID, and the MiniMax field works afterwards.

Pasting a MiniMax API key into Settings -> Agents -> Provider API keys
turned the window black. The key field's onChange read
event.currentTarget.value inside the setDrafts((current) => ...) updater.
React runs that updater later, during render, whenever an earlier update
of the component is still pending (a paste over a character already in
the field, the second keystroke of fast typing). By then the event has
finished dispatching and currentTarget is null, so the render threw
"Cannot read properties of null (reading 'value')" and React unmounted
the whole root. The renderer process stayed alive with an empty #root.
Key length and shape are not the cause: a 120-character key pasted over
a pending edit breaks the same way.

The handler now reads the value while the event dispatches and passes
it to the updater. No other renderer updater reads an event (checked
with an AST scan over src/renderer).

Tests: tests/provider-secrets-settings.test.mjs bundles the real
component against a React stand-in whose updaters run after dispatch
and pastes fake sk-cp-/sk-api- keys of 120 to 10 000 characters, with a
trailing newline and space, over a pending edit (fails before this
change with the same TypeError); a line-level guard keeps event reads
out of state updaters in the renderer.
An error thrown while React renders unmounts the whole root, but the
renderer process lives on, so render-process-gone never fires and the
main process's crash reload never runs: the window stays black until
the app is restarted. That is what the broken provider key field did.

The React root now handles onUncaughtError. The first such error logs
it with its component stack and reloads the application surface in
place; sessions live in the main process and survive the reload, as
after a renderer crash. A second one within 30 s (or with no session
storage to remember the reload) shows a static page with a Reload
button instead of reloading in a loop.

Tests: tests/uncaught-error-recovery.test.mjs (reload, cooldown, clock
change, no storage, root wiring). Checked in a hidden app: an injected
render error reloads to a usable Settings screen, a second one within
the cooldown shows the recovery page; forcefullyCrashRenderer still
reloads through render-process-gone.
@BIackFIame

Copy link
Copy Markdown
Contributor Author

Combined into #100 together with the other post-merge fixes, so they can be reviewed in one place. The commits are unchanged.

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.

1 participant