Skip to content

fix(web): persist server access token across tabs and browser restarts - #1567

Merged
wbxl2000 merged 6 commits into
mainfrom
fix/web-token-persist-legacy-cwd
Jul 12, 2026
Merged

fix(web): persist server access token across tabs and browser restarts#1567
wbxl2000 merged 6 commits into
mainfrom
fix/web-token-persist-legacy-cwd

Conversation

@wbxl2000

@wbxl2000 wbxl2000 commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator

Related Issue

No linked issue — reported as direct user feedback ("access token 隔一阵就需要重新输入"). This PR is split out from #1554, which bundled four fixes; of those, the workspace-path entry already landed via #1556 and the mid-turn resync fix was dropped in #1558 (to be reworked on the v2 kernel). The legacy session-recovery parts were held back for now.

Problem

The web UI stored the server bearer credential in sessionStorage, which is tab-scoped and cleared when the tab closes. Users had to re-enter the token for every new tab, after closing the browser, and after mobile browsers evicted background tabs. The server token itself is persistent (<KIMI_CODE_HOME>/server.token survives restarts and is only rotated explicitly); only the browser-side storage was short-lived.

What changed

Persist the credential in localStorage with a fixed seven-day expiry, so it survives tab close and browser restarts without being retained indefinitely. Details:

  • The seven-day window starts when the credential is written and is not renewed by reads; a page kept open past the deadline also stops using it.
  • Legacy sessionStorage values are migrated into the expiring format once. Failed migrations cannot silently grant a fresh seven-day window on every reload.
  • Expiry clears the matching stored value without deleting a newer value written by another tab.
  • A 401 (for example after kimi server rotate-token) clears the rejected credential and shows the token dialog. A different fresh token written by another tab is preserved.
  • The same storage slot may contain the persistent server token or a manually entered KIMI_CODE_PASSWORD, so browser-profile retention is capped at seven days for both.
  • Tests cover reload persistence, the exact expiry boundary, no renewal on reads, legacy migration, fragment intake and URL scrubbing, 401 and cross-tab clearing, and storage failures.

Checklist

  • I have read the CONTRIBUTING document.
  • I have linked a related issue, or explained the problem above.
  • I have added tests that prove my feature works.
  • Ran gen-changesets skill, or this PR needs no changeset.
  • Ran gen-docs skill, or this PR needs no doc update.

The web UI kept the server bearer credential in sessionStorage, which is
tab-scoped and cleared on tab close, so users had to re-enter the token
for every new tab and after every mobile tab eviction. Mirror it to
localStorage instead: the token already lives on disk at
<KIMI_CODE_HOME>/server.token and rides in the launch URL fragment, so
browser-profile persistence does not materially widen exposure for this
local tool. Existing sessionStorage copies are migrated on first boot,
and a 401 (e.g. after `kimi server rotate-token`) still clears the
stored credential.
@changeset-bot

changeset-bot Bot commented Jul 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2abac60

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@moonshot-ai/kimi-code Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Jul 12, 2026

Copy link
Copy Markdown
pnpm dlx https://pkg.pr.new/@moonshot-ai/kimi-code@2abac60
npx https://pkg.pr.new/@moonshot-ai/kimi-code@2abac60

commit: 2abac60

@wbxl2000
wbxl2000 force-pushed the fix/web-token-persist-legacy-cwd branch from 82a420f to c7b388b Compare July 12, 2026 07:03
@wbxl2000 wbxl2000 changed the title fix: persist web server token and restore vanished sessions fix(web): persist server access token across tabs and browser restarts Jul 12, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 82a420f940

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

// membership source Cmd+K search uses — so no session stays invisible.
const globalSessions = await listAllSessionsGlobal().catch(() => [] as AppSession[]);
const knownIds = new Set(sessions.map((s) => s.id));
const missed = globalSessions.filter((s) => !knownIds.has(s.id));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve pagination when recovering missed sessions

Because knownIds only contains the first pages loaded per workspace, this filter treats every older paginated session as “missed.” For any workspace with more than the initial page, listAllSessionsGlobal() now drains the full history at startup and sessions.push(...missed) inserts all remaining sessions while hasMore and cursors still describe only the first page, defeating the load-more path and producing states like a “load more” button with nothing actually remaining. Please restrict recovery to sessions whose workspace mapping is genuinely absent/inconsistent, or mark the affected pagination state as fully loaded when intentionally adding all sessions.

Useful? React with 👍 / 👎.

export function clearCredential(): void {
memory = undefined;
try {
globalThis.localStorage?.removeItem(STORAGE_KEY);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid clearing a fresh shared token from stale tabs

Now that the credential is persisted in shared localStorage, this unconditional removal lets any tab with a stale in-memory token erase a newer valid token written by another tab. For example, after token rotation one tab can store the fresh fragment token, then an older tab’s delayed 401 calls markAuthRequired()clearCredential() and removes the fresh persisted value, causing subsequent reloads/new tabs to prompt again even though a valid token had been saved. Please only clear the persisted value if it still matches the rejected credential, or otherwise avoid cross-tab deletion of a newer token.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 2537c1f: clearCredential now only removes the persisted value when it still matches the credential this tab was using, so a stale tab can no longer erase a newer token another tab stored after rotation. Added cross-tab tests for the stale-tab, matching-credential, and no-credential cases.

wbxl2000 added 2 commits July 12, 2026 15:11
localStorage is shared across tabs, so the unconditional removal let a
tab holding a stale in-memory credential erase a newer token another
tab had just persisted (e.g. after `kimi server rotate-token` one tab
stores the fresh fragment token, then an older tab's delayed 401 wiped
it). Only clear the persisted copy when it still matches the rejected
credential this tab was using.
Drop return-await on the dynamic import, brace the void arrow in the
toThrow assertion, and remove a redundant String() conversion — CI runs
oxlint --type-aware, which flags these where the plain local run does
not.
@wbxl2000

Copy link
Copy Markdown
Collaborator Author

@codex

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 127f4d42ef

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +89 to +93
globalThis.localStorage?.setItem(STORAGE_KEY, value);
// Drop any legacy sessionStorage copy so the two stores cannot diverge.
globalThis.sessionStorage?.removeItem(STORAGE_KEY);
} catch {
// sessionStorage may be unavailable (private mode) — memory still works.
// Storage may be unavailable (private mode) — memory still works.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve a reloadable fallback when localStorage is blocked

When localStorage.setItem throws but sessionStorage is still usable (for example, blocked/quota-limited local storage), this catch leaves the token only in module memory. ServerAuthDialog.submit() immediately calls window.location.reload() after setCredential, so that in-memory credential is lost and the user lands back on the token prompt even though the old sessionStorage path would have survived the reload. Please either fall back to sessionStorage for this case or avoid reloading when persistence fails.

Useful? React with 👍 / 👎.

@wbxl2000

Copy link
Copy Markdown
Collaborator Author

@codex

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 62b0ff7257

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +187 to +189
if (memory.expiresAt <= Date.now()) {
clearExpiredCredential(memory);
return undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Close active WebSockets when the token expires

When a browser tab keeps its WebSocket open past memory.expiresAt, this check is never reached for that socket: the WS client reads the token only once in connect() and later subscribe / terminal_input frames reuse the already-authenticated connection. That means a long-lived tab can keep using server access past the seven-day cap until the socket happens to reconnect; force a reconnect/close or auth-required transition when the in-memory expiry passes.

Useful? React with 👍 / 👎.

Comment on lines +221 to +223
persistCredential(stored);
// Drop any legacy sessionStorage copy so the two stores cannot diverge.
globalThis.sessionStorage?.removeItem(STORAGE_KEY);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clear the legacy session token even if localStorage fails

If localStorage.setItem throws here (for example quota/private-mode storage) the try exits before the legacy sessionStorage value is removed. In a tab that still has an old session-scoped credential, submitting a fresh token after rotation will work only until reload, then initServerAuth() can read and migrate the stale session value again and re-prompt; run the session cleanup in a separate best-effort block even when local persistence fails.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 01b4c1f: the legacy sessionStorage cleanup in setCredential now runs in its own best-effort try block, so it executes even when localStorage.setItem throws (private mode / quota). Added a test covering the blocked-localStorage path: the credential stays memory-only, but the stale session copy is removed and cannot be re-migrated on reload.

setCredential ran persistCredential and the legacy sessionStorage
cleanup in one try, so a localStorage.setItem failure (private mode,
quota) skipped the cleanup: a stale session-scoped credential left
behind was re-migrated on the next reload and 401'd into another token
prompt. Split the session cleanup into its own best-effort block.
@wbxl2000

Copy link
Copy Markdown
Collaborator Author

@codex

1 similar comment
@wbxl2000

Copy link
Copy Markdown
Collaborator Author

@codex

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 01b4c1fc0b

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +187 to +189
if (memory.expiresAt <= Date.now()) {
clearExpiredCredential(memory);
return undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Notify the app when an in-memory credential expires

When a tab stays open past the 7-day TTL, this branch drops the token but never invokes the auth-required listeners, so App.vue keeps authRequired false. In the case where the next auth use is only a WebSocket reconnect (for example after a daemon restart or network drop), DaemonEventSocket.connect() calls getCredential(), opens an unauthenticated upgrade, and the WS path reports a generic connection error/retry rather than showing the token dialog; the user is stuck disconnected until some separate REST call happens to 401. Expiry should drive the same prompt path as a rejected credential, or WS auth failures need to call it.

Useful? React with 👍 / 👎.

@wbxl2000
wbxl2000 merged commit f901b9e into main Jul 12, 2026
10 checks passed
@wbxl2000
wbxl2000 deleted the fix/web-token-persist-legacy-cwd branch July 12, 2026 10:36
@github-actions github-actions Bot mentioned this pull request Jul 12, 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.

1 participant