From 467a7b5331515d890d3493b8f167e31612e7a755 Mon Sep 17 00:00:00 2001 From: chengyongru Date: Tue, 18 Aug 2026 17:50:16 +0800 Subject: [PATCH] fix(tui): refresh expired API credentials --- tui/src/app.test.ts | 79 +++++++++++++++++++++++++++++++++++++ tui/src/app.ts | 65 ++++++++++++++++++++++++++++-- tui/src/protocol.test.ts | 29 ++++++++++++++ tui/src/protocol.ts | 75 +++++++++++++++++++++++++---------- tui/src/runtime-controls.ts | 8 +++- 5 files changed, 230 insertions(+), 26 deletions(-) diff --git a/tui/src/app.test.ts b/tui/src/app.test.ts index 47c654789..2dd289b92 100644 --- a/tui/src/app.test.ts +++ b/tui/src/app.test.ts @@ -549,6 +549,85 @@ describe("NanobotTui layout", () => { } }) + test("refreshes expired API credentials before opening sessions", async () => { + setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" }) + const original = globalThis.fetch + let expired = false + let bootstrapRequests = 0 + const sessionAuthorizations: Array = [] + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input) + const authorization = new Headers(init?.headers).get("Authorization") + if (url.endsWith("/webui/bootstrap")) { + bootstrapRequests += 1 + return new Response(JSON.stringify({ + ws_url: "ws://nanobot.test/ws", + token: "fresh-websocket-token", + api_token: "fresh-api-token", + })) + } + if (authorization === "Bearer expired-api-token") { + if (expired) return new Response("Unauthorized", { status: 401 }) + } + if (url.endsWith("/api/sessions")) { + sessionAuthorizations.push(authorization) + return new Response(JSON.stringify({ + sessions: [{ key: "websocket:chat", title: "Current chat" }], + })) + } + if (url.endsWith("/api/commands")) { + return new Response(JSON.stringify({ commands: [] })) + } + if (url.endsWith("/api/settings")) { + return new Response(JSON.stringify({ model_presets: [] })) + } + if (url.endsWith("/api/workspaces")) { + return new Response(JSON.stringify({ controls: {} })) + } + if (url.includes("/api/settings/cli-apps")) { + return new Response(JSON.stringify({ apps: [] })) + } + if (url.endsWith("/api/settings/mcp-presets")) { + return new Response(JSON.stringify({ presets: [] })) + } + return new Response("{}") + }) as typeof fetch + const app = NanobotTui.mount( + setup.renderer, + { + ...options, + bootstrapUrl: "http://nanobot.test/webui/bootstrap", + bootstrapSecret: "bootstrap-secret", + apiUrl: "http://nanobot.test", + apiToken: "expired-api-token", + }, + client(), + new MockTreeSitterClient({ autoResolveTimeout: 0 }), + ) + app.accept({ event: "attached", chat_id: "chat" }) + const ui = app as unknown as { + ready: boolean + composer: TextareaRenderable + sessionMenu: { visible: boolean } + status: { plainText: string } + } + + try { + await waitUntil(() => ui.ready) + expired = true + ui.composer.setText("/sessions") + ui.composer.submit() + + await waitUntil(() => bootstrapRequests === 1) + await waitUntil(() => ui.sessionMenu.visible) + expect(bootstrapRequests).toBe(1) + expect(sessionAuthorizations.at(-1)).toBe("Bearer fresh-api-token") + expect(ui.status.plainText).not.toContain("HTTP 401") + } finally { + globalThis.fetch = original + } + }) + test("tracks canonical presets without overwriting a session override", async () => { setup = await createRenderer({ width: 96, height: 20, screenMode: "alternate-screen" }) const app = mount(setup) diff --git a/tui/src/app.ts b/tui/src/app.ts index 75d77c8a2..338f1c09d 100644 --- a/tui/src/app.ts +++ b/tui/src/app.ts @@ -26,8 +26,10 @@ import { fetchSessionContext, fetchSessions, fetchSlashCommands, + type ApiReauthenticator, type ConnectionStatus, type FileEditEvent, + type GatewayApiConnection, type HistoryMessage, type InboundEvent, type MentionCandidate, @@ -428,6 +430,8 @@ export class NanobotTui { private hostBlocked = false private hostWorkspace: string private hostBranch: string + private readonly apiReauthenticator: ApiReauthenticator | undefined + private apiRefreshPromise: Promise | null = null private constructor( renderer: CliRenderer, @@ -443,6 +447,9 @@ export class NanobotTui { this.modelPreset = options.modelPreset this.hostWorkspace = options.hostWorkspace || options.workspace this.hostBranch = options.branch || "" + this.apiReauthenticator = options.bootstrapUrl + ? (rejectedApiToken) => this.refreshApiConnection(rejectedApiToken) + : undefined this.sessionModelPreset = options.chatId ? undefined : null this.backgroundKnown = options.theme !== "auto" || renderer.themeMode !== null this.activeThemeMode = this.resolveThemeMode(renderer.themeMode) @@ -576,6 +583,7 @@ export class NanobotTui { modelPreset: this.modelPreset, workspace: options.workspace, access: options.access, + reauthenticateApi: this.apiReauthenticator, // Runtime settings are session state. Changing them during a turn is // safe and takes effect when the next provider call starts. available: () => this.ready, @@ -1123,7 +1131,13 @@ export class NanobotTui { } if (restoring || (!this.historyLoaded && this.options.chatId)) { this.historyLoaded = true - const history = await fetchHistory(this.options.apiUrl, this.options.apiToken, chatId) + const history = await fetchHistory( + this.options.apiUrl, + this.options.apiToken, + chatId, + undefined, + this.apiReauthenticator, + ) if (hydrationId !== this.hydrationId) return this.historyBeforeCursor = history.beforeCursor this.historyHasMore = history.hasMoreBefore @@ -1158,14 +1172,42 @@ export class NanobotTui { for (const event of events || []) this.accept(event) } - private useGatewayConnection(apiUrl: string, apiToken: string): void { + private updateGatewayApiConnection(apiUrl: string, apiToken: string): void { this.options.apiUrl = apiUrl this.options.apiToken = apiToken this.runtimeControls.useApiConnection(apiUrl, apiToken) + } + + private useGatewayConnection(apiUrl: string, apiToken: string): void { + this.updateGatewayApiConnection(apiUrl, apiToken) void this.loadCommands() void this.loadMentions() } + private async refreshApiConnection( + rejectedApiToken: string, + ): Promise { + if (this.options.apiToken && rejectedApiToken !== this.options.apiToken) { + return { apiUrl: this.options.apiUrl, apiToken: this.options.apiToken } + } + if (this.apiRefreshPromise) return this.apiRefreshPromise + const refresh = fetchGatewayConnection( + this.options.bootstrapUrl || "", + this.options.bootstrapSecret || "", + this.options.apiUrl, + `tui-${process.pid}`, + ).then((connection) => { + this.updateGatewayApiConnection(connection.apiUrl, connection.apiToken) + return connection + }) + this.apiRefreshPromise = refresh + try { + return await refresh + } finally { + if (this.apiRefreshPromise === refresh) this.apiRefreshPromise = null + } + } + private handleStatus(status: ConnectionStatus, detail?: string): void { if (status === "connected") { this.ready = false @@ -1790,6 +1832,7 @@ export class NanobotTui { discovered = await fetchSlashCommands( this.options.apiUrl, this.options.apiToken, + this.apiReauthenticator, ) } catch { // Local navigation remains available against older gateways. @@ -1828,6 +1871,7 @@ export class NanobotTui { this.mentionCandidates = await fetchMentionCandidates( this.options.apiUrl, this.options.apiToken, + this.apiReauthenticator, ) if (this.activeMentionQuery) this.syncComposerMenus() } catch { @@ -1864,6 +1908,8 @@ export class NanobotTui { this.options.apiUrl, this.options.apiToken, chatId, + undefined, + this.apiReauthenticator, ) if (chatId !== this.client.activeChatId) return const points = branchPoints(history.messages) @@ -1929,7 +1975,11 @@ export class NanobotTui { const loadId = ++this.sessionLoadId this.status.content = "Loading sessions…" try { - const sessions = await fetchSessions(this.options.apiUrl, this.options.apiToken) + const sessions = await fetchSessions( + this.options.apiUrl, + this.options.apiToken, + this.apiReauthenticator, + ) if (this.quitting || loadId !== this.sessionLoadId) return this.sessionLoading = false const current = sessions.find((session) => session.chatId === this.client.activeChatId) @@ -2130,6 +2180,7 @@ export class NanobotTui { this.options.apiUrl, this.options.apiToken, this.client.activeChatId, + this.apiReauthenticator, ) if (!context) { this.status.content = "Context unavailable · new session or older gateway" @@ -2150,7 +2201,11 @@ export class NanobotTui { if (!this.options.apiUrl || !this.options.apiToken) return const requestId = ++this.sessionMetadataId try { - const sessions = await fetchSessions(this.options.apiUrl, this.options.apiToken) + const sessions = await fetchSessions( + this.options.apiUrl, + this.options.apiToken, + this.apiReauthenticator, + ) if ( requestId !== this.sessionMetadataId || chatId !== this.client.activeChatId @@ -2172,6 +2227,7 @@ export class NanobotTui { this.options.apiUrl, this.options.apiToken, chatId, + this.apiReauthenticator, ) if (!context || chatId !== this.client.activeChatId) return this.contextTokens = context.estimatedSessionTokens @@ -2216,6 +2272,7 @@ export class NanobotTui { this.options.apiToken, chatId, this.historyBeforeCursor, + this.apiReauthenticator, ) if (hydrationId !== this.hydrationId || chatId !== this.client.activeChatId) return await this.transcript.prependHistory(history.messages) diff --git a/tui/src/protocol.test.ts b/tui/src/protocol.test.ts index fac03da4b..3489f285c 100644 --- a/tui/src/protocol.test.ts +++ b/tui/src/protocol.test.ts @@ -88,6 +88,35 @@ describe("gateway protocol", () => { } }) + test("retries an API request only once after reauthentication", async () => { + const original = globalThis.fetch + const authorizations: Array = [] + let reauthenticationRequests = 0 + globalThis.fetch = ((_input: string | URL | Request, init?: RequestInit) => { + authorizations.push(new Headers(init?.headers).get("Authorization")) + return Promise.resolve(new Response("Unauthorized", { status: 401 })) + }) as typeof fetch + + try { + await expect(fetchSlashCommands( + "http://nanobot.test", + "expired-api-token", + async (rejectedApiToken) => { + expect(rejectedApiToken).toBe("expired-api-token") + reauthenticationRequests += 1 + return { apiUrl: "http://nanobot.test", apiToken: "fresh-api-token" } + }, + )).rejects.toMatchObject({ message: "command request failed: HTTP 401" }) + expect(reauthenticationRequests).toBe(1) + expect(authorizations).toEqual([ + "Bearer expired-api-token", + "Bearer fresh-api-token", + ]) + } finally { + globalThis.fetch = original + } + }) + test("waits for bootstrap before opening the websocket", async () => { const original = globalThis.WebSocket let resolveConnection: ((value: { diff --git a/tui/src/protocol.ts b/tui/src/protocol.ts index 8329920aa..1849ba980 100644 --- a/tui/src/protocol.ts +++ b/tui/src/protocol.ts @@ -162,12 +162,19 @@ export interface ClientOptions { onStatus: (status: ConnectionStatus, detail?: string) => void } -export interface GatewayConnection { - wsUrl: string +export interface GatewayApiConnection { apiUrl: string apiToken: string } +export interface GatewayConnection extends GatewayApiConnection { + wsUrl: string +} + +export type ApiReauthenticator = ( + rejectedApiToken: string, +) => Promise + export class GatewayConnectionError extends Error { constructor(message: string, readonly retryable: boolean) { super(message) @@ -460,11 +467,26 @@ function decodeInboundEvent(value: unknown): InboundEvent | null | undefined { return value as InboundEvent } +async function fetchApi( + apiUrl: string, + apiToken: string, + path: string, + reauthenticate?: ApiReauthenticator, +): Promise { + const request = (connection: GatewayApiConnection) => fetch(`${connection.apiUrl}${path}`, { + headers: { Authorization: `Bearer ${connection.apiToken}` }, + }) + const response = await request({ apiUrl, apiToken }) + if (response.status !== 401 || !reauthenticate) return response + return request(await reauthenticate(apiToken)) +} + export async function fetchHistory( apiUrl: string, apiToken: string, chatId: string, beforeCursor?: string | null, + reauthenticate?: ApiReauthenticator, ): Promise { if (!apiUrl || !apiToken) { return { messages: [], hasMoreBefore: false, beforeCursor: null, userMessageOffset: 0 } @@ -472,9 +494,12 @@ export async function fetchHistory( const key = encodeURIComponent(`websocket:${chatId}`) const params = new URLSearchParams({ limit: "120", direction: "latest" }) if (beforeCursor) params.set("before", beforeCursor) - const response = await fetch(`${apiUrl}/api/sessions/${key}/webui-thread?${params}`, { - headers: { Authorization: `Bearer ${apiToken}` }, - }) + const response = await fetchApi( + apiUrl, + apiToken, + `/api/sessions/${key}/webui-thread?${params}`, + reauthenticate, + ) if (response.status === 404) { return { messages: [], hasMoreBefore: false, beforeCursor: null, userMessageOffset: 0 } } @@ -544,12 +569,16 @@ export async function fetchSessionContext( apiUrl: string, apiToken: string, chatId: string, + reauthenticate?: ApiReauthenticator, ): Promise { if (!apiUrl || !apiToken) return null const key = encodeURIComponent(`websocket:${chatId}`) - const response = await fetch(`${apiUrl}/api/sessions/${key}/context`, { - headers: { Authorization: `Bearer ${apiToken}` }, - }) + const response = await fetchApi( + apiUrl, + apiToken, + `/api/sessions/${key}/context`, + reauthenticate, + ) if (response.status === 404) return null if (!response.ok) throw new Error(`context request failed: HTTP ${response.status}`) const value = await response.json() as Record @@ -572,11 +601,10 @@ export async function fetchSessionContext( export async function fetchSlashCommands( apiUrl: string, apiToken: string, + reauthenticate?: ApiReauthenticator, ): Promise { if (!apiUrl || !apiToken) return [] - const response = await fetch(`${apiUrl}/api/commands`, { - headers: { Authorization: `Bearer ${apiToken}` }, - }) + const response = await fetchApi(apiUrl, apiToken, "/api/commands", reauthenticate) if (!response.ok) throw new Error(`command request failed: HTTP ${response.status}`) const payload = await response.json() as { commands?: unknown[] } return (payload.commands || []).flatMap((value) => { @@ -600,12 +628,12 @@ export async function fetchSlashCommands( export async function fetchRuntimeControls( apiUrl: string, apiToken: string, + reauthenticate?: ApiReauthenticator, ): Promise { if (!apiUrl || !apiToken) return { modelPresets: [], canUseFullAccess: false } - const headers = { Authorization: `Bearer ${apiToken}` } const [settingsResponse, workspacesResponse] = await Promise.all([ - fetch(`${apiUrl}/api/settings`, { headers }), - fetch(`${apiUrl}/api/workspaces`, { headers }).catch(() => null), + fetchApi(apiUrl, apiToken, "/api/settings", reauthenticate), + fetchApi(apiUrl, apiToken, "/api/workspaces", reauthenticate).catch(() => null), ]) if (!settingsResponse.ok) { throw new Error(`settings request failed: HTTP ${settingsResponse.status}`) @@ -631,12 +659,12 @@ export async function fetchRuntimeControls( export async function fetchSessions( apiUrl: string, apiToken: string, + reauthenticate?: ApiReauthenticator, ): Promise { if (!apiUrl || !apiToken) return [] - const headers = { Authorization: `Bearer ${apiToken}` } const [response, sidebarResponse] = await Promise.all([ - fetch(`${apiUrl}/api/sessions`, { headers }), - fetch(`${apiUrl}/api/webui/sidebar-state`, { headers }).catch(() => null), + fetchApi(apiUrl, apiToken, "/api/sessions", reauthenticate), + fetchApi(apiUrl, apiToken, "/api/webui/sidebar-state", reauthenticate).catch(() => null), ]) if (!response.ok) throw new Error(`session request failed: HTTP ${response.status}`) const payload = await response.json() as { sessions?: unknown[] } @@ -692,13 +720,18 @@ function sessionMentionName(session: SessionSummary): string { export async function fetchMentionCandidates( apiUrl: string, apiToken: string, + reauthenticate?: ApiReauthenticator, ): Promise { if (!apiUrl || !apiToken) return [] - const headers = { Authorization: `Bearer ${apiToken}` } const [sessions, appsResponse, mcpResponse] = await Promise.all([ - fetchSessions(apiUrl, apiToken), - fetch(`${apiUrl}/api/settings/cli-apps?installed_only=1`, { headers }).catch(() => null), - fetch(`${apiUrl}/api/settings/mcp-presets`, { headers }).catch(() => null), + fetchSessions(apiUrl, apiToken, reauthenticate), + fetchApi( + apiUrl, + apiToken, + "/api/settings/cli-apps?installed_only=1", + reauthenticate, + ).catch(() => null), + fetchApi(apiUrl, apiToken, "/api/settings/mcp-presets", reauthenticate).catch(() => null), ]) const used = new Set() const uniqueName = (raw: string) => { diff --git a/tui/src/runtime-controls.ts b/tui/src/runtime-controls.ts index 097365624..9b54fdc0a 100644 --- a/tui/src/runtime-controls.ts +++ b/tui/src/runtime-controls.ts @@ -7,6 +7,7 @@ import { import { fetchRuntimeControls, + type ApiReauthenticator, type WorkspaceScopePayload, } from "./protocol" import { PickerMenu, type PickerMenuTheme } from "./picker-menu" @@ -29,6 +30,7 @@ interface RuntimeControlsOptions { modelPreset: string workspace: string access: string + reauthenticateApi?: ApiReauthenticator available: () => boolean beforeOpen: () => void refreshScope: () => Promise @@ -202,7 +204,11 @@ export class RuntimeControls { return } this.controlsPromise = (async () => { - const controls = await fetchRuntimeControls(this.options.apiUrl, this.options.apiToken) + const controls = await fetchRuntimeControls( + this.options.apiUrl, + this.options.apiToken, + this.options.reauthenticateApi, + ) const presets = new Map(controls.modelPresets.map((preset) => [ preset.name.toLocaleLowerCase(), preset,