Skip to content

Commit 55158de

Browse files
James Mtendamemacursoragent
andcommitted
fix(zoo-gateway): sign-out clears stale profile tokens, simplify model fetch
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 13eac4a commit 55158de

7 files changed

Lines changed: 78 additions & 43 deletions

File tree

src/api/providers/__tests__/zoo-gateway.spec.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,21 @@ vitest.mock("../fetchers/modelCache", () => ({
5757
}))
5858

5959
const mockGetCachedZooCodeToken = vitest.hoisted(() => vitest.fn<() => string | undefined>(() => undefined))
60+
const mockSessionCleared = vitest.hoisted(() => ({ value: false }))
6061

6162
vitest.mock("../../../services/zoo-code-auth", () => ({
6263
getZooCodeBaseUrl: vitest.fn(() => "https://www.zoocode.dev"),
63-
getCachedZooCodeToken: mockGetCachedZooCodeToken,
64-
clearZooCodeToken: vitest.fn(async () => undefined),
64+
getCachedZooCodeToken: () => mockGetCachedZooCodeToken() ?? "",
65+
resolveZooGatewaySessionToken: (profileToken?: string) => {
66+
const cached = mockGetCachedZooCodeToken()
67+
if (cached) return cached
68+
if (mockSessionCleared.value) return undefined
69+
return profileToken
70+
},
71+
clearZooCodeToken: vitest.fn(async () => {
72+
mockSessionCleared.value = true
73+
mockGetCachedZooCodeToken.mockReturnValue(undefined)
74+
}),
6575
}))
6676

6777
vitest.mock("../../transform/caching/vercel-ai-gateway", () => ({
@@ -93,6 +103,7 @@ describe("ZooGatewayHandler", () => {
93103

94104
beforeEach(() => {
95105
vitest.clearAllMocks()
106+
mockSessionCleared.value = false
96107
mockGetCachedZooCodeToken.mockReturnValue(undefined)
97108
mockCreate.mockClear()
98109
showErrorMessage.mockReset()
@@ -195,6 +206,13 @@ describe("ZooGatewayHandler", () => {
195206
})
196207

197208
describe("createMessage", () => {
209+
it("requires authentication at request time when no session token is available", async () => {
210+
const handler = new ZooGatewayHandler({})
211+
await expect(drainCreateMessage(handler)).rejects.toThrow(
212+
"Zoo Gateway requires authentication. Please sign in to Zoo Code first.",
213+
)
214+
})
215+
198216
beforeEach(() => {
199217
mockCreate.mockImplementation(async () => ({
200218
[Symbol.asyncIterator]: async function* () {

src/api/providers/fetchers/__tests__/zoo-gateway.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ vitest.mock("axios")
88
vitest.mock("../../../../services/zoo-code-auth", () => ({
99
getCachedZooCodeToken: vitest.fn(() => ""),
1010
getZooCodeBaseUrl: vitest.fn(() => "https://example.test"),
11+
resolveZooGatewaySessionToken: vitest.fn((profileToken?: string) => profileToken || undefined),
1112
}))
1213
const mockedAxios = axios as any
1314

src/api/providers/fetchers/zoo-gateway.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import axios from "axios"
33
import type { ModelInfo } from "@roo-code/types"
44

55
import type { ApiHandlerOptions } from "../../../shared/api"
6-
import { getCachedZooCodeToken, getZooCodeBaseUrl } from "../../../services/zoo-code-auth"
6+
import { getZooCodeBaseUrl, resolveZooGatewaySessionToken } from "../../../services/zoo-code-auth"
77

88
import {
99
type VercelAiGatewayModel,
@@ -24,9 +24,7 @@ export async function getZooGatewayModels(options?: ApiHandlerOptions): Promise<
2424
const models: Record<string, ModelInfo> = {}
2525
const baseURL = options?.zooGatewayBaseUrl ?? `${getZooCodeBaseUrl()}/api/gateway/v1`
2626

27-
// Build headers - Zoo Gateway requires authentication via the zoo_ext_ session token.
28-
// Fall back to the secret-storage cache when the profile hasn't been seeded yet.
29-
const sessionToken = options?.zooSessionToken || getCachedZooCodeToken()
27+
const sessionToken = resolveZooGatewaySessionToken(options?.zooSessionToken)
3028
const headers: Record<string, string> = {}
3129
if (sessionToken) {
3230
headers["Authorization"] = `Bearer ${sessionToken}`

src/api/providers/zoo-gateway.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
} from "@roo-code/types"
1111

1212
import { ApiHandlerOptions } from "../../shared/api"
13-
import { clearZooCodeToken, getCachedZooCodeToken, getZooCodeBaseUrl } from "../../services/zoo-code-auth"
13+
import { clearZooCodeToken, getZooCodeBaseUrl, resolveZooGatewaySessionToken } from "../../services/zoo-code-auth"
1414
import { Package } from "../../shared/package"
1515
import { t } from "../../i18n"
1616

@@ -101,9 +101,7 @@ export class ZooGatewayHandler extends RouterProvider implements SingleCompletio
101101
constructor(options: ApiHandlerOptions) {
102102
const baseURL = options.zooGatewayBaseUrl ?? `${getZooCodeBaseUrl()}/api/gateway/v1`
103103

104-
// Prefer the secret-storage cache so a 401 clear takes effect immediately; fall back
105-
// to the profile-persisted token when the user is signed in but seeding hasn't run yet.
106-
const sessionToken = getCachedZooCodeToken() || options.zooSessionToken
104+
const sessionToken = resolveZooGatewaySessionToken(options.zooSessionToken)
107105

108106
// Merge Zoo-specific enrichment headers into openAiHeaders so they flow through
109107
// the parent's single OpenAI client. We avoid reassigning `this.client` (which
@@ -128,8 +126,7 @@ export class ZooGatewayHandler extends RouterProvider implements SingleCompletio
128126
}
129127

130128
private ensureAuthenticated(): void {
131-
const sessionToken = getCachedZooCodeToken() || this.options.zooSessionToken
132-
if (!sessionToken) {
129+
if (!resolveZooGatewaySessionToken(this.options.zooSessionToken)) {
133130
throw new Error(ZOO_GATEWAY_AUTH_ERROR)
134131
}
135132
}
@@ -217,12 +214,14 @@ export class ZooGatewayHandler extends RouterProvider implements SingleCompletio
217214
}
218215
}
219216
} catch (error) {
220-
void surfaceGatewayApiError(error).catch((surfaceError) => {
217+
try {
218+
await surfaceGatewayApiError(error)
219+
} catch (surfaceError) {
221220
console.error(
222221
"Failed to surface Zoo Gateway error:",
223222
surfaceError instanceof Error ? surfaceError.message : surfaceError,
224223
)
225-
})
224+
}
226225
throw error
227226
}
228227
}
@@ -248,12 +247,14 @@ export class ZooGatewayHandler extends RouterProvider implements SingleCompletio
248247
const response = await this.client.chat.completions.create(requestOptions)
249248
return response.choices[0]?.message.content || ""
250249
} catch (error) {
251-
void surfaceGatewayApiError(error).catch((surfaceError) => {
250+
try {
251+
await surfaceGatewayApiError(error)
252+
} catch (surfaceError) {
252253
console.error(
253254
"Failed to surface Zoo Gateway error:",
254255
surfaceError instanceof Error ? surfaceError.message : surfaceError,
255256
)
256-
})
257+
}
257258
if (error instanceof Error) {
258259
throw new Error(`Zoo Gateway completion error: ${error.message}`)
259260
}

src/core/webview/webviewMessageHandler.ts

Lines changed: 1 addition & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -946,31 +946,6 @@ export const webviewMessageHandler = async (
946946
}
947947
}
948948

949-
// For zoo-gateway, the token may be stored in a separate zoo-gateway profile
950-
// (not the currently active profile). Look it up so the model list populates
951-
// even when zoo-gateway isn't the active provider.
952-
let zooGatewayToken = apiConfiguration.zooSessionToken
953-
let zooGatewayBaseUrl = apiConfiguration.zooGatewayBaseUrl
954-
955-
if (!zooGatewayToken) {
956-
try {
957-
const allProfiles = await provider.providerSettingsManager.listConfig()
958-
const zooGatewayProfiles = allProfiles.filter((p) => p.apiProvider === "zoo-gateway")
959-
for (const profileMeta of zooGatewayProfiles) {
960-
const fullProfile = await provider.providerSettingsManager.getProfile({
961-
name: profileMeta.name,
962-
})
963-
if (fullProfile.zooSessionToken) {
964-
zooGatewayToken = fullProfile.zooSessionToken
965-
zooGatewayBaseUrl = fullProfile.zooGatewayBaseUrl ?? zooGatewayBaseUrl
966-
break
967-
}
968-
}
969-
} catch (error) {
970-
console.debug("Failed to look up zoo-gateway profile for model fetch:", error)
971-
}
972-
}
973-
974949
// Base candidates (only those handled by this aggregate fetcher)
975950
const candidates: { key: RouterName; options: GetModelsOptions }[] = [
976951
{ key: "openrouter", options: { provider: "openrouter" } },
@@ -994,8 +969,7 @@ export const webviewMessageHandler = async (
994969
key: "zoo-gateway",
995970
options: {
996971
provider: "zoo-gateway",
997-
apiKey: zooGatewayToken,
998-
baseUrl: zooGatewayBaseUrl,
972+
baseUrl: apiConfiguration.zooGatewayBaseUrl,
999973
},
1000974
},
1001975
]

src/services/__tests__/zoo-code-auth.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
getZooCodeBaseUrl,
1313
handleAuthCallback,
1414
initZooCodeAuth,
15+
resolveZooGatewaySessionToken,
1516
setZooCodeToken,
1617
setZooCodeUserInfo,
1718
verifyZooCodeToken,
@@ -432,6 +433,29 @@ describe("zoo-code-auth", () => {
432433
})
433434
})
434435

436+
describe("resolveZooGatewaySessionToken", () => {
437+
it("prefers the cached token over a profile token", async () => {
438+
await initZooCodeAuth(mockContext)
439+
await setZooCodeToken("zoo_ext_cached")
440+
441+
expect(resolveZooGatewaySessionToken("zoo_ext_profile")).toBe("zoo_ext_cached")
442+
})
443+
444+
it("ignores profile tokens after an explicit sign-out clear", async () => {
445+
await initZooCodeAuth(mockContext)
446+
await setZooCodeToken("zoo_ext_cached")
447+
await clearZooCodeToken()
448+
449+
expect(resolveZooGatewaySessionToken("zoo_ext_stale_profile")).toBeUndefined()
450+
})
451+
452+
it("falls back to the profile token when the cache is empty and not cleared", async () => {
453+
await initZooCodeAuth(mockContext)
454+
455+
expect(resolveZooGatewaySessionToken("zoo_ext_profile")).toBe("zoo_ext_profile")
456+
})
457+
})
458+
435459
describe("disconnectZooCode", () => {
436460
it("revokes the current token and clears cached auth state", async () => {
437461
await initZooCodeAuth(mockContext)

src/services/zoo-code-auth.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ let secretStorage: vscode.SecretStorage | undefined
1111

1212
// In-memory cache for synchronous access in ZooCodeHandler hot path
1313
let _cachedToken: string | undefined = undefined
14+
let _sessionCleared = false
1415
let _cachedUserName: string | undefined = undefined
1516
let _cachedUserEmail: string | undefined = undefined
1617
let _cachedUserImage: string | undefined = undefined
@@ -28,6 +29,7 @@ export async function initZooCodeAuth(context: vscode.ExtensionContext): Promise
2829

2930
// Pre-load the token and user info into memory on init so ZooCodeHandler can access them synchronously
3031
_cachedToken = await secretStorage.get(ZOO_CODE_TOKEN_KEY)
32+
_sessionCleared = false
3133
_cachedUserName = await secretStorage.get(ZOO_CODE_USER_NAME_KEY)
3234
_cachedUserEmail = await secretStorage.get(ZOO_CODE_USER_EMAIL_KEY)
3335
_cachedUserImage = await secretStorage.get(ZOO_CODE_USER_IMAGE_KEY)
@@ -85,6 +87,21 @@ export function getCachedZooCodeToken(): string {
8587
return _cachedToken ?? ""
8688
}
8789

90+
/**
91+
* Resolves the Zoo Gateway session token for API calls.
92+
* Secret-storage cache wins over profile-persisted tokens; after an explicit sign-out
93+
* or 401 clear, profile tokens are ignored so stale credentials cannot be reused.
94+
*/
95+
export function resolveZooGatewaySessionToken(profileToken?: string): string | undefined {
96+
if (_cachedToken) {
97+
return _cachedToken
98+
}
99+
if (_sessionCleared) {
100+
return undefined
101+
}
102+
return profileToken || undefined
103+
}
104+
88105
export function getCachedZooCodeUserInfo(): { name?: string; email?: string; image?: string } {
89106
return {
90107
name: _cachedUserName,
@@ -153,6 +170,7 @@ export async function setZooCodeToken(token: string): Promise<void> {
153170
if (!secretStorage) return
154171
await secretStorage.store(ZOO_CODE_TOKEN_KEY, token)
155172
_cachedToken = token
173+
_sessionCleared = false
156174
// Reset subscription status when token is set
157175
_cachedSubscriptionStatus = "unknown"
158176
_lastSubscriptionCheck = 0
@@ -204,6 +222,7 @@ export async function clearZooCodeToken(): Promise<void> {
204222
if (!secretStorage) return
205223
await secretStorage.delete(ZOO_CODE_TOKEN_KEY)
206224
_cachedToken = undefined
225+
_sessionCleared = true
207226
_cachedSubscriptionStatus = "unknown"
208227
_lastSubscriptionCheck = 0
209228
}

0 commit comments

Comments
 (0)