mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 00:03:01 +03:00
fix(tui): refresh expired API credentials
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,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,
|
||||
|
||||
Reference in New Issue
Block a user