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 () => {
|
test("tracks canonical presets without overwriting a session override", async () => {
|
||||||
setup = await createRenderer({ width: 96, height: 20, screenMode: "alternate-screen" })
|
setup = await createRenderer({ width: 96, height: 20, screenMode: "alternate-screen" })
|
||||||
const app = mount(setup)
|
const app = mount(setup)
|
||||||
|
|||||||
+61
-4
@@ -26,8 +26,10 @@ import {
|
|||||||
fetchSessionContext,
|
fetchSessionContext,
|
||||||
fetchSessions,
|
fetchSessions,
|
||||||
fetchSlashCommands,
|
fetchSlashCommands,
|
||||||
|
type ApiReauthenticator,
|
||||||
type ConnectionStatus,
|
type ConnectionStatus,
|
||||||
type FileEditEvent,
|
type FileEditEvent,
|
||||||
|
type GatewayApiConnection,
|
||||||
type HistoryMessage,
|
type HistoryMessage,
|
||||||
type InboundEvent,
|
type InboundEvent,
|
||||||
type MentionCandidate,
|
type MentionCandidate,
|
||||||
@@ -428,6 +430,8 @@ export class NanobotTui {
|
|||||||
private hostBlocked = false
|
private hostBlocked = false
|
||||||
private hostWorkspace: string
|
private hostWorkspace: string
|
||||||
private hostBranch: string
|
private hostBranch: string
|
||||||
|
private readonly apiReauthenticator: ApiReauthenticator | undefined
|
||||||
|
private apiRefreshPromise: Promise<GatewayApiConnection> | null = null
|
||||||
|
|
||||||
private constructor(
|
private constructor(
|
||||||
renderer: CliRenderer,
|
renderer: CliRenderer,
|
||||||
@@ -443,6 +447,9 @@ export class NanobotTui {
|
|||||||
this.modelPreset = options.modelPreset
|
this.modelPreset = options.modelPreset
|
||||||
this.hostWorkspace = options.hostWorkspace || options.workspace
|
this.hostWorkspace = options.hostWorkspace || options.workspace
|
||||||
this.hostBranch = options.branch || ""
|
this.hostBranch = options.branch || ""
|
||||||
|
this.apiReauthenticator = options.bootstrapUrl
|
||||||
|
? (rejectedApiToken) => this.refreshApiConnection(rejectedApiToken)
|
||||||
|
: undefined
|
||||||
this.sessionModelPreset = options.chatId ? undefined : null
|
this.sessionModelPreset = options.chatId ? undefined : null
|
||||||
this.backgroundKnown = options.theme !== "auto" || renderer.themeMode !== null
|
this.backgroundKnown = options.theme !== "auto" || renderer.themeMode !== null
|
||||||
this.activeThemeMode = this.resolveThemeMode(renderer.themeMode)
|
this.activeThemeMode = this.resolveThemeMode(renderer.themeMode)
|
||||||
@@ -576,6 +583,7 @@ export class NanobotTui {
|
|||||||
modelPreset: this.modelPreset,
|
modelPreset: this.modelPreset,
|
||||||
workspace: options.workspace,
|
workspace: options.workspace,
|
||||||
access: options.access,
|
access: options.access,
|
||||||
|
reauthenticateApi: this.apiReauthenticator,
|
||||||
// Runtime settings are session state. Changing them during a turn is
|
// Runtime settings are session state. Changing them during a turn is
|
||||||
// safe and takes effect when the next provider call starts.
|
// safe and takes effect when the next provider call starts.
|
||||||
available: () => this.ready,
|
available: () => this.ready,
|
||||||
@@ -1123,7 +1131,13 @@ export class NanobotTui {
|
|||||||
}
|
}
|
||||||
if (restoring || (!this.historyLoaded && this.options.chatId)) {
|
if (restoring || (!this.historyLoaded && this.options.chatId)) {
|
||||||
this.historyLoaded = true
|
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
|
if (hydrationId !== this.hydrationId) return
|
||||||
this.historyBeforeCursor = history.beforeCursor
|
this.historyBeforeCursor = history.beforeCursor
|
||||||
this.historyHasMore = history.hasMoreBefore
|
this.historyHasMore = history.hasMoreBefore
|
||||||
@@ -1158,14 +1172,42 @@ export class NanobotTui {
|
|||||||
for (const event of events || []) this.accept(event)
|
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.apiUrl = apiUrl
|
||||||
this.options.apiToken = apiToken
|
this.options.apiToken = apiToken
|
||||||
this.runtimeControls.useApiConnection(apiUrl, apiToken)
|
this.runtimeControls.useApiConnection(apiUrl, apiToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
private useGatewayConnection(apiUrl: string, apiToken: string): void {
|
||||||
|
this.updateGatewayApiConnection(apiUrl, apiToken)
|
||||||
void this.loadCommands()
|
void this.loadCommands()
|
||||||
void this.loadMentions()
|
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 {
|
private handleStatus(status: ConnectionStatus, detail?: string): void {
|
||||||
if (status === "connected") {
|
if (status === "connected") {
|
||||||
this.ready = false
|
this.ready = false
|
||||||
@@ -1790,6 +1832,7 @@ export class NanobotTui {
|
|||||||
discovered = await fetchSlashCommands(
|
discovered = await fetchSlashCommands(
|
||||||
this.options.apiUrl,
|
this.options.apiUrl,
|
||||||
this.options.apiToken,
|
this.options.apiToken,
|
||||||
|
this.apiReauthenticator,
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
// Local navigation remains available against older gateways.
|
// Local navigation remains available against older gateways.
|
||||||
@@ -1828,6 +1871,7 @@ export class NanobotTui {
|
|||||||
this.mentionCandidates = await fetchMentionCandidates(
|
this.mentionCandidates = await fetchMentionCandidates(
|
||||||
this.options.apiUrl,
|
this.options.apiUrl,
|
||||||
this.options.apiToken,
|
this.options.apiToken,
|
||||||
|
this.apiReauthenticator,
|
||||||
)
|
)
|
||||||
if (this.activeMentionQuery) this.syncComposerMenus()
|
if (this.activeMentionQuery) this.syncComposerMenus()
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1864,6 +1908,8 @@ export class NanobotTui {
|
|||||||
this.options.apiUrl,
|
this.options.apiUrl,
|
||||||
this.options.apiToken,
|
this.options.apiToken,
|
||||||
chatId,
|
chatId,
|
||||||
|
undefined,
|
||||||
|
this.apiReauthenticator,
|
||||||
)
|
)
|
||||||
if (chatId !== this.client.activeChatId) return
|
if (chatId !== this.client.activeChatId) return
|
||||||
const points = branchPoints(history.messages)
|
const points = branchPoints(history.messages)
|
||||||
@@ -1929,7 +1975,11 @@ export class NanobotTui {
|
|||||||
const loadId = ++this.sessionLoadId
|
const loadId = ++this.sessionLoadId
|
||||||
this.status.content = "Loading sessions…"
|
this.status.content = "Loading sessions…"
|
||||||
try {
|
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
|
if (this.quitting || loadId !== this.sessionLoadId) return
|
||||||
this.sessionLoading = false
|
this.sessionLoading = false
|
||||||
const current = sessions.find((session) => session.chatId === this.client.activeChatId)
|
const current = sessions.find((session) => session.chatId === this.client.activeChatId)
|
||||||
@@ -2130,6 +2180,7 @@ export class NanobotTui {
|
|||||||
this.options.apiUrl,
|
this.options.apiUrl,
|
||||||
this.options.apiToken,
|
this.options.apiToken,
|
||||||
this.client.activeChatId,
|
this.client.activeChatId,
|
||||||
|
this.apiReauthenticator,
|
||||||
)
|
)
|
||||||
if (!context) {
|
if (!context) {
|
||||||
this.status.content = "Context unavailable · new session or older gateway"
|
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
|
if (!this.options.apiUrl || !this.options.apiToken) return
|
||||||
const requestId = ++this.sessionMetadataId
|
const requestId = ++this.sessionMetadataId
|
||||||
try {
|
try {
|
||||||
const sessions = await fetchSessions(this.options.apiUrl, this.options.apiToken)
|
const sessions = await fetchSessions(
|
||||||
|
this.options.apiUrl,
|
||||||
|
this.options.apiToken,
|
||||||
|
this.apiReauthenticator,
|
||||||
|
)
|
||||||
if (
|
if (
|
||||||
requestId !== this.sessionMetadataId
|
requestId !== this.sessionMetadataId
|
||||||
|| chatId !== this.client.activeChatId
|
|| chatId !== this.client.activeChatId
|
||||||
@@ -2172,6 +2227,7 @@ export class NanobotTui {
|
|||||||
this.options.apiUrl,
|
this.options.apiUrl,
|
||||||
this.options.apiToken,
|
this.options.apiToken,
|
||||||
chatId,
|
chatId,
|
||||||
|
this.apiReauthenticator,
|
||||||
)
|
)
|
||||||
if (!context || chatId !== this.client.activeChatId) return
|
if (!context || chatId !== this.client.activeChatId) return
|
||||||
this.contextTokens = context.estimatedSessionTokens
|
this.contextTokens = context.estimatedSessionTokens
|
||||||
@@ -2216,6 +2272,7 @@ export class NanobotTui {
|
|||||||
this.options.apiToken,
|
this.options.apiToken,
|
||||||
chatId,
|
chatId,
|
||||||
this.historyBeforeCursor,
|
this.historyBeforeCursor,
|
||||||
|
this.apiReauthenticator,
|
||||||
)
|
)
|
||||||
if (hydrationId !== this.hydrationId || chatId !== this.client.activeChatId) return
|
if (hydrationId !== this.hydrationId || chatId !== this.client.activeChatId) return
|
||||||
await this.transcript.prependHistory(history.messages)
|
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 () => {
|
test("waits for bootstrap before opening the websocket", async () => {
|
||||||
const original = globalThis.WebSocket
|
const original = globalThis.WebSocket
|
||||||
let resolveConnection: ((value: {
|
let resolveConnection: ((value: {
|
||||||
|
|||||||
+54
-21
@@ -162,12 +162,19 @@ export interface ClientOptions {
|
|||||||
onStatus: (status: ConnectionStatus, detail?: string) => void
|
onStatus: (status: ConnectionStatus, detail?: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GatewayConnection {
|
export interface GatewayApiConnection {
|
||||||
wsUrl: string
|
|
||||||
apiUrl: string
|
apiUrl: string
|
||||||
apiToken: string
|
apiToken: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GatewayConnection extends GatewayApiConnection {
|
||||||
|
wsUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiReauthenticator = (
|
||||||
|
rejectedApiToken: string,
|
||||||
|
) => Promise<GatewayApiConnection>
|
||||||
|
|
||||||
export class GatewayConnectionError extends Error {
|
export class GatewayConnectionError extends Error {
|
||||||
constructor(message: string, readonly retryable: boolean) {
|
constructor(message: string, readonly retryable: boolean) {
|
||||||
super(message)
|
super(message)
|
||||||
@@ -460,11 +467,26 @@ function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
|
|||||||
return value as InboundEvent
|
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(
|
export async function fetchHistory(
|
||||||
apiUrl: string,
|
apiUrl: string,
|
||||||
apiToken: string,
|
apiToken: string,
|
||||||
chatId: string,
|
chatId: string,
|
||||||
beforeCursor?: string | null,
|
beforeCursor?: string | null,
|
||||||
|
reauthenticate?: ApiReauthenticator,
|
||||||
): Promise<HistorySnapshot> {
|
): Promise<HistorySnapshot> {
|
||||||
if (!apiUrl || !apiToken) {
|
if (!apiUrl || !apiToken) {
|
||||||
return { messages: [], hasMoreBefore: false, beforeCursor: null, userMessageOffset: 0 }
|
return { messages: [], hasMoreBefore: false, beforeCursor: null, userMessageOffset: 0 }
|
||||||
@@ -472,9 +494,12 @@ export async function fetchHistory(
|
|||||||
const key = encodeURIComponent(`websocket:${chatId}`)
|
const key = encodeURIComponent(`websocket:${chatId}`)
|
||||||
const params = new URLSearchParams({ limit: "120", direction: "latest" })
|
const params = new URLSearchParams({ limit: "120", direction: "latest" })
|
||||||
if (beforeCursor) params.set("before", beforeCursor)
|
if (beforeCursor) params.set("before", beforeCursor)
|
||||||
const response = await fetch(`${apiUrl}/api/sessions/${key}/webui-thread?${params}`, {
|
const response = await fetchApi(
|
||||||
headers: { Authorization: `Bearer ${apiToken}` },
|
apiUrl,
|
||||||
})
|
apiToken,
|
||||||
|
`/api/sessions/${key}/webui-thread?${params}`,
|
||||||
|
reauthenticate,
|
||||||
|
)
|
||||||
if (response.status === 404) {
|
if (response.status === 404) {
|
||||||
return { messages: [], hasMoreBefore: false, beforeCursor: null, userMessageOffset: 0 }
|
return { messages: [], hasMoreBefore: false, beforeCursor: null, userMessageOffset: 0 }
|
||||||
}
|
}
|
||||||
@@ -544,12 +569,16 @@ export async function fetchSessionContext(
|
|||||||
apiUrl: string,
|
apiUrl: string,
|
||||||
apiToken: string,
|
apiToken: string,
|
||||||
chatId: string,
|
chatId: string,
|
||||||
|
reauthenticate?: ApiReauthenticator,
|
||||||
): Promise<SessionContextSnapshot | null> {
|
): Promise<SessionContextSnapshot | null> {
|
||||||
if (!apiUrl || !apiToken) return null
|
if (!apiUrl || !apiToken) return null
|
||||||
const key = encodeURIComponent(`websocket:${chatId}`)
|
const key = encodeURIComponent(`websocket:${chatId}`)
|
||||||
const response = await fetch(`${apiUrl}/api/sessions/${key}/context`, {
|
const response = await fetchApi(
|
||||||
headers: { Authorization: `Bearer ${apiToken}` },
|
apiUrl,
|
||||||
})
|
apiToken,
|
||||||
|
`/api/sessions/${key}/context`,
|
||||||
|
reauthenticate,
|
||||||
|
)
|
||||||
if (response.status === 404) return null
|
if (response.status === 404) return null
|
||||||
if (!response.ok) throw new Error(`context request failed: HTTP ${response.status}`)
|
if (!response.ok) throw new Error(`context request failed: HTTP ${response.status}`)
|
||||||
const value = await response.json() as Record<string, unknown>
|
const value = await response.json() as Record<string, unknown>
|
||||||
@@ -572,11 +601,10 @@ export async function fetchSessionContext(
|
|||||||
export async function fetchSlashCommands(
|
export async function fetchSlashCommands(
|
||||||
apiUrl: string,
|
apiUrl: string,
|
||||||
apiToken: string,
|
apiToken: string,
|
||||||
|
reauthenticate?: ApiReauthenticator,
|
||||||
): Promise<SlashCommand[]> {
|
): Promise<SlashCommand[]> {
|
||||||
if (!apiUrl || !apiToken) return []
|
if (!apiUrl || !apiToken) return []
|
||||||
const response = await fetch(`${apiUrl}/api/commands`, {
|
const response = await fetchApi(apiUrl, apiToken, "/api/commands", reauthenticate)
|
||||||
headers: { Authorization: `Bearer ${apiToken}` },
|
|
||||||
})
|
|
||||||
if (!response.ok) throw new Error(`command request failed: HTTP ${response.status}`)
|
if (!response.ok) throw new Error(`command request failed: HTTP ${response.status}`)
|
||||||
const payload = await response.json() as { commands?: unknown[] }
|
const payload = await response.json() as { commands?: unknown[] }
|
||||||
return (payload.commands || []).flatMap((value) => {
|
return (payload.commands || []).flatMap((value) => {
|
||||||
@@ -600,12 +628,12 @@ export async function fetchSlashCommands(
|
|||||||
export async function fetchRuntimeControls(
|
export async function fetchRuntimeControls(
|
||||||
apiUrl: string,
|
apiUrl: string,
|
||||||
apiToken: string,
|
apiToken: string,
|
||||||
|
reauthenticate?: ApiReauthenticator,
|
||||||
): Promise<RuntimeControls> {
|
): Promise<RuntimeControls> {
|
||||||
if (!apiUrl || !apiToken) return { modelPresets: [], canUseFullAccess: false }
|
if (!apiUrl || !apiToken) return { modelPresets: [], canUseFullAccess: false }
|
||||||
const headers = { Authorization: `Bearer ${apiToken}` }
|
|
||||||
const [settingsResponse, workspacesResponse] = await Promise.all([
|
const [settingsResponse, workspacesResponse] = await Promise.all([
|
||||||
fetch(`${apiUrl}/api/settings`, { headers }),
|
fetchApi(apiUrl, apiToken, "/api/settings", reauthenticate),
|
||||||
fetch(`${apiUrl}/api/workspaces`, { headers }).catch(() => null),
|
fetchApi(apiUrl, apiToken, "/api/workspaces", reauthenticate).catch(() => null),
|
||||||
])
|
])
|
||||||
if (!settingsResponse.ok) {
|
if (!settingsResponse.ok) {
|
||||||
throw new Error(`settings request failed: HTTP ${settingsResponse.status}`)
|
throw new Error(`settings request failed: HTTP ${settingsResponse.status}`)
|
||||||
@@ -631,12 +659,12 @@ export async function fetchRuntimeControls(
|
|||||||
export async function fetchSessions(
|
export async function fetchSessions(
|
||||||
apiUrl: string,
|
apiUrl: string,
|
||||||
apiToken: string,
|
apiToken: string,
|
||||||
|
reauthenticate?: ApiReauthenticator,
|
||||||
): Promise<SessionSummary[]> {
|
): Promise<SessionSummary[]> {
|
||||||
if (!apiUrl || !apiToken) return []
|
if (!apiUrl || !apiToken) return []
|
||||||
const headers = { Authorization: `Bearer ${apiToken}` }
|
|
||||||
const [response, sidebarResponse] = await Promise.all([
|
const [response, sidebarResponse] = await Promise.all([
|
||||||
fetch(`${apiUrl}/api/sessions`, { headers }),
|
fetchApi(apiUrl, apiToken, "/api/sessions", reauthenticate),
|
||||||
fetch(`${apiUrl}/api/webui/sidebar-state`, { headers }).catch(() => null),
|
fetchApi(apiUrl, apiToken, "/api/webui/sidebar-state", reauthenticate).catch(() => null),
|
||||||
])
|
])
|
||||||
if (!response.ok) throw new Error(`session request failed: HTTP ${response.status}`)
|
if (!response.ok) throw new Error(`session request failed: HTTP ${response.status}`)
|
||||||
const payload = await response.json() as { sessions?: unknown[] }
|
const payload = await response.json() as { sessions?: unknown[] }
|
||||||
@@ -692,13 +720,18 @@ function sessionMentionName(session: SessionSummary): string {
|
|||||||
export async function fetchMentionCandidates(
|
export async function fetchMentionCandidates(
|
||||||
apiUrl: string,
|
apiUrl: string,
|
||||||
apiToken: string,
|
apiToken: string,
|
||||||
|
reauthenticate?: ApiReauthenticator,
|
||||||
): Promise<MentionCandidate[]> {
|
): Promise<MentionCandidate[]> {
|
||||||
if (!apiUrl || !apiToken) return []
|
if (!apiUrl || !apiToken) return []
|
||||||
const headers = { Authorization: `Bearer ${apiToken}` }
|
|
||||||
const [sessions, appsResponse, mcpResponse] = await Promise.all([
|
const [sessions, appsResponse, mcpResponse] = await Promise.all([
|
||||||
fetchSessions(apiUrl, apiToken),
|
fetchSessions(apiUrl, apiToken, reauthenticate),
|
||||||
fetch(`${apiUrl}/api/settings/cli-apps?installed_only=1`, { headers }).catch(() => null),
|
fetchApi(
|
||||||
fetch(`${apiUrl}/api/settings/mcp-presets`, { headers }).catch(() => null),
|
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 used = new Set<string>()
|
||||||
const uniqueName = (raw: string) => {
|
const uniqueName = (raw: string) => {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
fetchRuntimeControls,
|
fetchRuntimeControls,
|
||||||
|
type ApiReauthenticator,
|
||||||
type WorkspaceScopePayload,
|
type WorkspaceScopePayload,
|
||||||
} from "./protocol"
|
} from "./protocol"
|
||||||
import { PickerMenu, type PickerMenuTheme } from "./picker-menu"
|
import { PickerMenu, type PickerMenuTheme } from "./picker-menu"
|
||||||
@@ -29,6 +30,7 @@ interface RuntimeControlsOptions {
|
|||||||
modelPreset: string
|
modelPreset: string
|
||||||
workspace: string
|
workspace: string
|
||||||
access: string
|
access: string
|
||||||
|
reauthenticateApi?: ApiReauthenticator
|
||||||
available: () => boolean
|
available: () => boolean
|
||||||
beforeOpen: () => void
|
beforeOpen: () => void
|
||||||
refreshScope: () => Promise<void>
|
refreshScope: () => Promise<void>
|
||||||
@@ -202,7 +204,11 @@ export class RuntimeControls {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.controlsPromise = (async () => {
|
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) => [
|
const presets = new Map(controls.modelPresets.map((preset) => [
|
||||||
preset.name.toLocaleLowerCase(),
|
preset.name.toLocaleLowerCase(),
|
||||||
preset,
|
preset,
|
||||||
|
|||||||
Reference in New Issue
Block a user