{"record":{"id":"77f61755be243668","repo":"NousResearch/hermes-agent","slug":"stored-token-set-missing-accesstoken","errorCode":null,"errorMessage":"Stored token set missing accessToken","messagePattern":"Stored token set missing accessToken","errorType":"validation","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"apps/desktop/electron/native-oauth.ts","lineNumber":207,"sourceCode":"    refreshToken: String(body?.refresh_token || ''),\n    expiresAt: Number.isFinite(expiresAt) ? expiresAt : 0,\n    provider: String(body?.provider || ''),\n    userId: String(body?.user_id || '')\n  }\n}\n\n/**\n * Validate a token set loaded from the encrypted local store.\n *\n * The stored representation is already normalized as NativeTokenSet and\n * therefore uses camelCase. Gateway token responses use snake_case and\n * remain handled separately by parseTokenResponse().\n */\nexport function parseStoredTokenSet(body: any): NativeTokenSet {\n  const accessToken = String(body?.accessToken || '')\n\n  if (!accessToken) {\n    throw new Error('Stored token set missing accessToken')\n  }\n\n  const expiresAt = Number(body?.expiresAt)\n\n  return {\n    accessToken,\n    refreshToken: String(body?.refreshToken || ''),\n    expiresAt: Number.isFinite(expiresAt) ? expiresAt : 0,\n    provider: String(body?.provider || ''),\n    userId: String(body?.userId || '')\n  }\n}\n\n/**\n * True when a stored token set is at/near expiry and should be refreshed\n * before use. `skewSeconds` refreshes slightly early to avoid a race where\n * the token expires in flight (mirrors the server's 60s cookie floor).\n */","sourceCodeStart":189,"sourceCodeEnd":225,"githubUrl":"https://github.com/NousResearch/hermes-agent/blob/c896c09c42910c584c4c7d2325b58c14713ea42c/apps/desktop/electron/native-oauth.ts#L189-L225","documentation":"Thrown by parseStoredTokenSet() when validating a token set loaded from the encrypted local store in the Hermes desktop app. The stored representation is normalized as NativeTokenSet (camelCase), and this check enforces a non-empty accessToken before the set is used. A missing accessToken means the persisted credentials are unusable for authenticated gateway calls.","triggerScenarios":"Calling parseStoredTokenSet(body) where String(body?.accessToken || '') is empty — accessToken undefined, null, or ''. Happens when the store entry was written from a raw snake_case gateway response (access_token) instead of the normalized camelCase set, was partially decrypted/corrupted, or holds an explicitly cleared record.","commonSituations":"Upgrading the desktop app across a token-store format change; a truncated or corrupted store file after a crash mid-write; a decrypt helper returning an empty object; any writer persisting parseTokenResponse() output directly instead of a NativeTokenSet.","solutions":["Treat the error as 'signed out': clear the stored entry for that baseUrl and re-run the native OAuth flow to mint fresh tokens.","Audit every writer of the store: gateway responses are snake_case and must be normalized (camelCase) before persisting — parseStoredTokenSet deliberately does not accept them.","If the store file is corrupted, delete it and let the app re-authenticate.","Add a read-time migration: if body.accessToken is empty but body.access_token exists, convert before calling parseStoredTokenSet."],"exampleFix":"// before\nconst tokens = parseStoredTokenSet(JSON.parse(io.decrypt(store[baseUrl])))\n\n// after\nconst raw = JSON.parse(io.decrypt(store[baseUrl]) ?? '{}')\n// migrate legacy snake_case payloads before parsing\nif (!raw.accessToken && raw.access_token) {\n  raw.accessToken = raw.access_token\n  raw.refreshToken = raw.refresh_token\n  raw.expiresAt = raw.expires_at\n}\nconst tokens = parseStoredTokenSet(raw)","handlingStrategy":"validation","validationCode":"function hasStoredAccessToken(body: any): boolean {\n  return Boolean(body && typeof body.accessToken === 'string' && body.accessToken.length > 0)\n}\n\n// before parsing\nif (!hasStoredAccessToken(raw)) {\n  // treat as signed out: clear the stale entry and re-authenticate instead of parsing\n  clearStoredEntry(baseUrl)\n  return startOAuthFlow()\n}","typeGuard":"function isStoredTokenSetLike(body: any): body is { accessToken: string } {\n  return typeof body?.accessToken === 'string' && body.accessToken.length > 0\n}","tryCatchPattern":"try {\n  const tokens = parseStoredTokenSet(raw)\n} catch (e) {\n  if (e instanceof Error && e.message === 'Stored token set missing accessToken') {\n    // recoverable: drop the stale entry, do not surface as a crash\n    clearStoredEntry(baseUrl)\n    return null\n  }\n  throw e\n}","preventionTips":["Always normalize gateway responses (snake_case, handled by parseTokenResponse) to NativeTokenSet (camelCase) before persisting.","Delete the store entry on explicit sign-out so parseStoredTokenSet never runs against an empty record.","Add a store-format version field and migrate old entries on read."],"tags":["auth","oauth","token-storage","desktop"],"backgroundTag":null,"analyzedSha":"c896c09c42910c584c4c7d2325b58c14713ea42c","analyzedAt":"2026-08-14T17:18:01.089Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}