fix(desktop): restore native OAuth tokens after restart - #71524
Conversation
There was a problem hiding this comment.
Pull request overview
This PR fixes Hermes Desktop “signed out after restart” behavior by correctly parsing the encrypted, on-disk native OAuth token set (camelCase NativeTokenSet) instead of treating it like a gateway token response (snake_case), while preserving the existing gateway-response parsing path.
Changes:
- Added
parseStoredTokenSet()to validate/normalize the persisted camelCase token shape without changingparseTokenResponse(). - Switched desktop token restore (
_loadNativeTokens) to useparseStoredTokenSet()and log failures instead of silently discarding them. - Added regression tests covering the encrypted on-disk camelCase token format and rejecting the gateway snake_case shape.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| apps/desktop/electron/native-oauth.ts | Adds a dedicated parser for persisted (camelCase) token sets to avoid mis-parsing stored tokens as gateway responses. |
| apps/desktop/electron/native-oauth.test.ts | Adds regression tests for parseStoredTokenSet() and its expected/invalid input shapes. |
| apps/desktop/electron/main.ts | Restores tokens via parseStoredTokenSet() and logs load failures for easier diagnosis. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
first-party corroboration for this, from a Hermes Cloud + Desktop pairing on macOS. symptom matches exactly: connection mode set to Hermes Cloud, still signed in, and every fresh start (after an update, after quit and reopen, after a machine restart) fails to reach the cloud agent. the only recovery is signing out of Hermes Cloud and signing back in, and it is needed every single time. the connection default applies across profiles, so all of them lose the backend at once. what the desktop log shows, in order: the cached remote backend fails its liveness probe and gets dropped, then a long run of "Could not reach the remote Hermes gateway while refreshing its WebSocket ticket", then the error flips once to "your remote gateway session has expired" and after that permanently to the OAuth not-signed-in state. it never re-probes once the gateway is reachable again. one detail that supports the logging change here as much as the parser change: at the moment the state flips, the log carries no cause. caveat on my own evidence: the capture window starts 2026-07-17, so it predates #68250 and the early phases in it are that PR's territory, not this one. the terminal state is the part that matches what you describe. I have not verified the fix, only the symptom and the swallowed-error path. |
|
@notwitcheer Thanks a lot for sharing this — that is very useful corroboration. Your macOS symptoms appear to match the same Desktop-side failure I observed on Windows: the encrypted native OAuth token set exists on disk, but is not restored correctly after a fresh application start. One important clarification: this Desktop PR is one half of the complete fix I validated in my self-hosted environment.
The Desktop PR can still be tested independently on macOS and should fix the immediate token-restoration problem. However, reliable long-term token renewal may also depend on the server-side fix, or on equivalent protection already being deployed by Hermes Cloud. Since you are using Hermes Cloud, you would only need to build the Desktop branch locally; the server-side deployment would need to be handled by the Nous Research maintainers. I have only built and validated the packaged application on Windows, but the modified Electron/TypeScript code is cross-platform. A macOS test would therefore be extremely valuable. The most useful scenarios would be:
The expected result is that the existing session is restored and renewed without requiring a manual sign-out/sign-in. I would be happy to help you inspect the macOS logs or walk through building the branch. Please make sure that any shared logs do not contain access or refresh token values. |
|
I have now independently built and tested this PR on macOS 15.7.4 using an Intel x86_64 Hackintosh. The result was successful:
I tested it against my self-hosted gateway, which also has the companion server-side PR #71548 applied. Therefore, this validates the complete Desktop + Gateway fix in my environment. You can build the Desktop PR directly on your Mac with the following process: # Install Hermes first, if it is not already installed
curl -fsSL https://hermes-agent.nousresearch.com/install.sh |
bash -s -- --skip-setup --skip-browser
source ~/.zshrc
cd ~/.hermes/hermes-agent
# Fetch and switch to this PR
git fetch --no-tags origin pull/71524/head:pr-71524
git switch pr-71524
# Install dependencies and run the OAuth tests
npm install
cd apps/desktop
npx vitest run --project electron electron/native-oauth.test.ts
# Build the macOS application from this PR
cd ~/.hermes/hermes-agent
CSC_IDENTITY_AUTO_DISCOVERY=false \
hermes desktop --build-only --force-build
# Launch the locally built application
./apps/desktop/release/mac/Hermes.app/Contents/MacOS/HermesThe build is unsigned, so macOS may display a Gatekeeper warning depending on your local security settings. The important test scenarios are:
Since you use Hermes Cloud, the server-side portion would need to be deployed by the Nous Research maintainers, or they would need to confirm that equivalent refresh-request protection is already present there. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
apps/desktop/electron/main.ts:5952
- The new stored-token load failure log assumes the caught value is an Error (
(error as Error).message). In JS/TS,throwcan be any value; if it’s a string/object,.messagewill be undefined and the log loses the useful details. Prefer the same defensive formatting used elsewhere in this file (e.g.err?.message || err).
} catch (error) {
rememberLog(
`[native-oauth] failed to load stored tokens for ${baseUrl}: ${(error as Error).message}`
)
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
apps/desktop/electron/main.ts:5945
- When
decryptDesktopSecret(secret)fails (returns an empty string),_loadNativeTokenscurrently returnsnullwithout logging or clearing the corrupt entry. This makes token-load failures harder to diagnose and can cause the same unreadable blob to be retried on every startup. Consider logging this case and deleting the stored blob so the user can re-auth cleanly without repeated silent failures.
if (!plaintext) {
return null
}
const tokens = parseStoredTokenSet(JSON.parse(plaintext))
|
Thanks for isolating the storage-vs-gateway response boundary; the current-main defect is real. Problems
Suggested changes
Automated hermes-sweeper review. |
24076cc to
d553b6e
Compare
|
@teknium1 Thanks for the review — addressed in I added a small Electron-free persistence seam that is now used by the real production path in The new regression exercises the complete sequence:
The test explicitly proves that the stored camelCase payload is rejected by the old snake_case-only Additional coverage verifies:
Validation completed:
The broader Desktop typecheck still encounters unrelated pre-existing renderer dependency errors, while the Electron and E2E configurations covering this change both pass. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
apps/desktop/electron/native-token-store.ts:83
- persistNativeTokenSet() logs write failures using
(error as Error).message, which becomesundefinedfor non-Error throws. This can hide the real failure reason; mirror the defensive formatting already used in loadNativeTokenSet().
try {
io.writeStoreText(JSON.stringify(store))
} catch (error) {
io.rememberLog?.(`[native-oauth] failed to persist tokens: ${(error as Error).message}`)
}
apps/desktop/electron/native-token-store.ts:60
- readStore() treats any parsed "object" (including arrays) as a valid store. If the file ever contains "[]" (or another non-record object), writes will silently fail because JSON.stringify on an array drops non-index properties, so tokens won't persist even though no error is thrown. Guard against arrays so the store is always a plain key/value map.
This issue also appears on line 79 of the same file.
function readStore(io: NativeTokenStoreIo): Record<string, any> {
try {
const parsed = JSON.parse(io.readStoreText())
return parsed && typeof parsed === 'object' ? parsed : {}
} catch {
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
apps/desktop/electron/native-token-store.ts:82
io.encrypt()is typed to returnStoredTokenSecret | null, but the result is assigned tostore[baseUrl]without checking. If an injectedencryptimplementation returnsnull(rather than throwing), this will overwrite any previously-stored secret withnull, and subsequent launches will treat the user as signed out with no way to recover the prior refresh token.
// Encrypt the whole set as one blob so the refresh token never lands in
// plaintext on disk. Deliberately outside the try below: an unusable
// keychain is an authoritative write failure and must surface to the
// caller, not be logged away as if the tokens were saved.
store[baseUrl] = io.encrypt(JSON.stringify(tokens))
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
apps/desktop/electron/native-token-store.ts:134
- The new failure logs include the full
baseUrl.normalizeRemoteBaseUrl()does not strip URL userinfo (username/password), so a user-entered URL likehttps://user:pass@hostwould get those credentials echoed into logs when decryption/loading fails. Please redact userinfo before logging the URL (and keep the unredacted value for the store key).
if (!plaintext) {
// A keychain that is merely locked/unavailable right now must not cost
// the user their refresh token — leave the entry for the next attempt.
io.rememberLog?.(`[native-oauth] failed to decrypt stored tokens for ${baseUrl}; keeping stored entry for retry`)
austinpickett
left a comment
There was a problem hiding this comment.
LGTM.
Confirmed the reload-parser bug on current main: parseStoredTokenSet is absent and _loadNativeTokens() feeds the decrypted camelCase set to the snake_case parseTokenResponse(), which throws and gets swallowed into a signed-out state.
The branch is 50 commits behind, so the tip-to-tip diff makes streamThrottle/battery look reverted. It isn't. A 3-way merge onto current main is clean and both features survive alongside the fix.
Verified on the merged tree:
- Electron typecheck passes (
tsconfig.electron.json). - 43/43 targeted tests pass (
native-token-store24 +native-oauth19), including the encrypt/store/decrypt/load round trip through the injected helper.
The persisted camelCase vs gateway snake_case split is the right boundary, and keeping the store module electron-free matches how native-oauth.ts is already structured.
Summary
Root cause
The desktop persists an already-normalized
NativeTokenSetusing camelCase fields:accessTokenrefreshTokenexpiresAtuserIdOn restart, the stored object was passed to
parseTokenResponse(), which expects the gateway response format using snake_case fields such asaccess_token.This caused
parseTokenResponse()to throwGateway token response missing access_token. The exception was silently caught,_loadNativeTokens()returnednull, and the desktop reported that the user was not signed in after every restart.Fix
Add a dedicated
parseStoredTokenSet()parser for the normalized camelCase storage format while keepingparseTokenResponse()unchanged for gateway responses.Validation
npm --prefix apps/desktop run test:desktop:platforms -- electron/native-oauth.test.tsnpm exec -- tsc -p tsconfig.electron.json --noEmitnpm run buildnpm run builder -- --dir