diff --git a/src/main/rate-limits/grok-auth.test.ts b/src/main/rate-limits/grok-auth.test.ts index 793e3e7a694..7bf8f9e62b3 100644 --- a/src/main/rate-limits/grok-auth.test.ts +++ b/src/main/rate-limits/grok-auth.test.ts @@ -45,4 +45,112 @@ describe('readGrokAuthSession', () => { error: 'Grok auth file is invalid' }) }) + + it.each(['https://auth.x.ai', 'https://auth.x.ai::client'])( + 'prefers the %s issuer entry over an earlier alternate issuer', + async (preferredIssuer) => { + vi.doMock('node:fs', () => ({ + existsSync: vi.fn(() => true), + readFileSync: vi.fn(() => + JSON.stringify({ + 'https://stale.example.com::client': { + key: 'stale-token', + user_id: 'stale-user', + email: 'stale@example.com', + expires_at: '2099-01-01T00:00:00.000Z' + }, + [preferredIssuer]: { + key: 'live-token', + user_id: 'live-user', + email: 'live@example.com', + team_id: 'team-1', + expires_at: '2099-06-01T00:00:00.000Z', + oidc_client_id: 'client-1' + } + }) + ) + })) + const { readGrokAuthSession } = await import('./grok-auth') + + expect(readGrokAuthSession()).toEqual({ + status: 'ok', + session: { + accessToken: 'live-token', + userId: 'live-user', + email: 'live@example.com', + teamId: 'team-1', + expiresAtMs: Date.parse('2099-06-01T00:00:00.000Z'), + oidcClientId: 'client-1' + } + }) + } + ) + + it('falls back to the first tokenized entry when no auth.x.ai key exists', async () => { + vi.doMock('node:fs', () => ({ + existsSync: vi.fn(() => true), + readFileSync: vi.fn(() => + JSON.stringify({ + 'https://alternate.example.com::client': { + key: 'alt-token', + user_id: 'alt-user', + email: 'alt@example.com', + expires_at: '2099-01-01T00:00:00.000Z' + } + }) + ) + })) + const { readGrokAuthSession } = await import('./grok-auth') + + expect(readGrokAuthSession()).toEqual({ + status: 'ok', + session: { + accessToken: 'alt-token', + userId: 'alt-user', + email: 'alt@example.com', + teamId: null, + expiresAtMs: Date.parse('2099-01-01T00:00:00.000Z'), + oidcClientId: null + } + }) + }) + + it('skips an expired auth.x.ai client entry when a fresh one follows it', async () => { + vi.doMock('node:fs', () => ({ + existsSync: vi.fn(() => true), + readFileSync: vi.fn(() => + JSON.stringify({ + 'https://auth.x.ai::old-client': { + key: 'expired-token', + expires_at: '2020-01-01T00:00:00.000Z' + }, + 'https://auth.x.ai::current-client': { + key: 'fresh-token', + expires_at: '2099-01-01T00:00:00.000Z' + } + }) + ) + })) + const { readGrokAuthSession } = await import('./grok-auth') + + expect(readGrokAuthSession()).toMatchObject({ + status: 'ok', + session: { accessToken: 'fresh-token' } + }) + }) + + it('does not resurrect an alternate issuer when an auth.x.ai entry is tokenless', async () => { + vi.doMock('node:fs', () => ({ + existsSync: vi.fn(() => true), + readFileSync: vi.fn(() => + JSON.stringify({ + 'https://alternate.example.com::client': { key: 'stale-token' }, + 'https://auth.x.ai::client': { user_id: 'signed-out-user' } + }) + ) + })) + const { readGrokAuthSession } = await import('./grok-auth') + + expect(readGrokAuthSession()).toEqual({ status: 'missing' }) + }) }) diff --git a/src/main/rate-limits/grok-auth.ts b/src/main/rate-limits/grok-auth.ts index 10ba629ff34..e055c022975 100644 --- a/src/main/rate-limits/grok-auth.ts +++ b/src/main/rate-limits/grok-auth.ts @@ -29,6 +29,8 @@ type GrokAuthEntry = { oidc_client_id?: string } +type TokenizedGrokAuthEntry = GrokAuthEntry & { key: string } + export type GrokAuthReadResult = | { status: 'missing' } | { status: 'error'; error: string } @@ -43,7 +45,7 @@ function getGrokAuthReadError(err: unknown): string { return 'Unable to read Grok auth file' } -function parseAuthEntry(value: unknown): GrokAuthEntry | null { +function parseAuthEntry(value: unknown): TokenizedGrokAuthEntry | null { if (typeof value !== 'object' || value === null) { return null } @@ -51,7 +53,7 @@ function parseAuthEntry(value: unknown): GrokAuthEntry | null { if (typeof entry.key !== 'string' || entry.key.length === 0) { return null } - return entry + return entry as TokenizedGrokAuthEntry } function parseExpiresAtMs(iso: string | undefined): number | null { @@ -62,6 +64,24 @@ function parseExpiresAtMs(iso: string | undefined): number | null { return Number.isFinite(ms) ? ms : null } +// Why: stale alternate issuers can precede the default xAI OAuth session in auth.json. +const PREFERRED_GROK_AUTH_ISSUER = 'https://auth.x.ai' + +function sessionFromAuthEntry(authEntry: TokenizedGrokAuthEntry): GrokAuthSession { + return { + accessToken: authEntry.key, + userId: typeof authEntry.user_id === 'string' ? authEntry.user_id : null, + email: typeof authEntry.email === 'string' ? authEntry.email : null, + teamId: typeof authEntry.team_id === 'string' ? authEntry.team_id : null, + expiresAtMs: parseExpiresAtMs(authEntry.expires_at), + oidcClientId: typeof authEntry.oidc_client_id === 'string' ? authEntry.oidc_client_id : null + } +} + +function isPreferredGrokAuthKey(key: string): boolean { + return key === PREFERRED_GROK_AUTH_ISSUER || key.startsWith(`${PREFERRED_GROK_AUTH_ISSUER}::`) +} + export function readGrokAuthSession(): GrokAuthReadResult { const path = getGrokAuthPath() if (!existsSync(path)) { @@ -72,23 +92,32 @@ export function readGrokAuthSession(): GrokAuthReadResult { if (typeof parsed !== 'object' || parsed === null) { return { status: 'error', error: 'Grok auth file is invalid' } } - for (const entry of Object.values(parsed)) { + let preferredKeySeen = false + let expiredPreferred: GrokAuthSession | null = null + let fallback: GrokAuthSession | null = null + for (const [key, entry] of Object.entries(parsed as Record)) { + const isPreferred = isPreferredGrokAuthKey(key) + preferredKeySeen ||= isPreferred const authEntry = parseAuthEntry(entry) - if (!authEntry?.key) { + if (!authEntry) { continue } - return { - status: 'ok', - session: { - accessToken: authEntry.key, - userId: typeof authEntry.user_id === 'string' ? authEntry.user_id : null, - email: typeof authEntry.email === 'string' ? authEntry.email : null, - teamId: typeof authEntry.team_id === 'string' ? authEntry.team_id : null, - expiresAtMs: parseExpiresAtMs(authEntry.expires_at), - oidcClientId: - typeof authEntry.oidc_client_id === 'string' ? authEntry.oidc_client_id : null + const session = sessionFromAuthEntry(authEntry) + if (isPreferred) { + if (isGrokAccessTokenFresh(session)) { + return { status: 'ok', session } } + expiredPreferred ??= session + continue } + if (!fallback) { + fallback = session + } + } + // Why: alternate issuers are compatibility fallbacks only when no default entry exists. + const selectedSession = expiredPreferred ?? (preferredKeySeen ? null : fallback) + if (selectedSession) { + return { status: 'ok', session: selectedSession } } // Why: a token-less file (e.g. after grok logout) means signed out, not a // failure — 'error' would keep a status-bar alert visible for that user. diff --git a/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.test.ts b/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.test.ts index efdac51caef..185cc6460e6 100644 --- a/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.test.ts +++ b/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.test.ts @@ -1,4 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import en from '@/i18n/locales/en.json' +import es from '@/i18n/locales/es.json' +import ja from '@/i18n/locales/ja.json' +import ko from '@/i18n/locales/ko.json' +import zh from '@/i18n/locales/zh.json' import { OSC52_CLIPBOARD_SETTING_ID } from './osc52-clipboard-setting-anchor' import type * as Osc52ClipboardBlockedToastModule from './osc52-clipboard-blocked-toast' @@ -41,6 +46,7 @@ describe('showOsc52ClipboardBlockedToast', () => { showOsc52ClipboardBlockedToast() + expect(toastInfoMock.mock.calls[0]?.[1]?.description).toContain('Grok') const options = toastInfoMock.mock.calls[0]?.[1] expect(options).toMatchObject({ action: { @@ -67,4 +73,14 @@ describe('showOsc52ClipboardBlockedToast', () => { expect(toastInfoMock).toHaveBeenCalledTimes(1) }) + + it('mentions Grok in every supported locale', () => { + const locales = [en, es, ja, ko, zh] + + for (const locale of locales) { + expect( + locale.auto.components.terminal.pane.osc52.clipboard.blocked.toast['7cf51f74fd'] + ).toContain('Grok') + } + }) }) diff --git a/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.ts b/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.ts index 127f40f7a99..fb0b5d73a60 100644 --- a/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.ts +++ b/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.ts @@ -19,7 +19,7 @@ export function showOsc52ClipboardBlockedToast(): void { { description: translate( 'auto.components.terminal.pane.osc52.clipboard.blocked.toast.7cf51f74fd', - 'Enable TUI clipboard writes in Terminal settings to copy from SSH, tmux, Neovim, or fzf.' + 'Enable TUI clipboard writes in Terminal settings to copy from SSH, tmux, Neovim, fzf, or Grok.' ), duration: 12_000, action: { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index c89567567d0..6030a8a47a8 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2581,7 +2581,7 @@ "blocked": { "toast": { "97c98f1afe": "Open Setting", - "7cf51f74fd": "Enable TUI clipboard writes in Terminal settings to copy from SSH, tmux, Neovim, or fzf.", + "7cf51f74fd": "Enable TUI clipboard writes in Terminal settings to copy from SSH, tmux, Neovim, fzf, or Grok.", "89eaa3e80b": "Terminal clipboard write blocked" } } diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 11cf5339d6b..ee5e4ebc003 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -2581,7 +2581,7 @@ "blocked": { "toast": { "97c98f1afe": "Abrir ajustes", - "7cf51f74fd": "Habilita las escrituras al portapapeles TUI en los ajustes de Terminal para copiar desde SSH, tmux, Neovim o fzf.", + "7cf51f74fd": "Habilita las escrituras al portapapeles TUI en los ajustes de Terminal para copiar desde SSH, tmux, Neovim, fzf o Grok.", "89eaa3e80b": "Escritura al portapapeles del terminal bloqueada" } } diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 8cc54d3ac1f..adf70d56d71 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -2581,7 +2581,7 @@ "blocked": { "toast": { "97c98f1afe": "設定を開く", - "7cf51f74fd": "SSH、tmux、Neovim、または fzf からコピーするには、Terminal 設定で TUI クリップボードへの書き込みを有効にします。", + "7cf51f74fd": "SSH、tmux、Neovim、fzf、または Grok からコピーするには、Terminal 設定で TUI クリップボードへの書き込みを有効にします。", "89eaa3e80b": "Terminal のクリップボードへの書き込みがブロックされました" } } diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index e66b07cc5e0..503002ecfa6 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -2581,7 +2581,7 @@ "blocked": { "toast": { "97c98f1afe": "설정 열기", - "7cf51f74fd": "SSH, tmux, Neovim 또는 fzf에서 복사하려면 Terminal 설정에서 TUI 클립보드 쓰기를 활성화하세요.", + "7cf51f74fd": "SSH, tmux, Neovim, fzf 또는 Grok에서 복사하려면 Terminal 설정에서 TUI 클립보드 쓰기를 활성화하세요.", "89eaa3e80b": "Terminal 클립보드 쓰기가 차단되었습니다." } } diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index e0d28d35be6..e39655c1850 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -2581,7 +2581,7 @@ "blocked": { "toast": { "97c98f1afe": "打开设置", - "7cf51f74fd": "在终端设置中启用 TUI 剪贴板写入,以从 SSH、tmux、Neovim 或 fzf 进行复制。", + "7cf51f74fd": "在终端设置中启用 TUI 剪贴板写入,以从 SSH、tmux、Neovim、fzf 或 Grok 进行复制。", "89eaa3e80b": "终端剪贴板写入被阻止" } }