fix(tui): refresh expired API credentials

This commit is contained in:
chengyongru
2026-08-18 18:19:33 +08:00
committed by chengyongru
parent d47efcc352
commit 467a7b5331
5 changed files with 230 additions and 26 deletions
+79
View File
@@ -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<string | null> = []
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)
+61 -4
View File
@@ -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<GatewayApiConnection> | 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<GatewayApiConnection> {
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)
+29
View File
@@ -88,6 +88,35 @@ describe("gateway protocol", () => {
}
})
test("retries an API request only once after reauthentication", async () => {
const original = globalThis.fetch
const authorizations: Array<string | null> = []
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: {
+54 -21
View File
@@ -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<GatewayApiConnection>
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<Response> {
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<HistorySnapshot> {
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<SessionContextSnapshot | null> {
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<string, unknown>
@@ -572,11 +601,10 @@ export async function fetchSessionContext(
export async function fetchSlashCommands(
apiUrl: string,
apiToken: string,
reauthenticate?: ApiReauthenticator,
): Promise<SlashCommand[]> {
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<RuntimeControls> {
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<SessionSummary[]> {
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<MentionCandidate[]> {
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<string>()
const uniqueName = (raw: string) => {
+7 -1
View File
@@ -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<void>
@@ -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,