Skip to content

Commit 1326a97

Browse files
James Mtendamemacursoragent
andcommitted
feat(zoo-gateway): auth callback, profile token sync, and sign-out
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 8634510 commit 1326a97

6 files changed

Lines changed: 365 additions & 28 deletions

File tree

src/activate/__tests__/handleUri.spec.ts

Lines changed: 77 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,25 +6,32 @@ vi.mock("vscode", () => ({
66

77
import * as vscode from "vscode"
88

9-
const { mockGetVisibleInstance, mockHandleZooCodeAuthCallback, mockSetZooCodeUserInfo, mockVisibleProvider } =
10-
vi.hoisted(() => {
11-
const mockVisibleProvider = {
12-
handleOpenRouterCallback: vi.fn(),
13-
handleRequestyCallback: vi.fn(),
14-
handleZooCodeCallback: vi.fn(),
15-
} as any
16-
17-
return {
18-
mockGetVisibleInstance: vi.fn(() => mockVisibleProvider),
19-
mockHandleZooCodeAuthCallback: vi.fn(),
20-
mockSetZooCodeUserInfo: vi.fn(),
21-
mockVisibleProvider,
22-
}
23-
})
9+
const {
10+
mockGetVisibleInstance,
11+
mockGetAllInstances,
12+
mockHandleZooCodeAuthCallback,
13+
mockSetZooCodeUserInfo,
14+
mockVisibleProvider,
15+
} = vi.hoisted(() => {
16+
const mockVisibleProvider = {
17+
handleOpenRouterCallback: vi.fn(),
18+
handleRequestyCallback: vi.fn(),
19+
handleZooCodeCallback: vi.fn(),
20+
} as any
21+
22+
return {
23+
mockGetVisibleInstance: vi.fn(() => mockVisibleProvider),
24+
mockGetAllInstances: vi.fn(() => [mockVisibleProvider]),
25+
mockHandleZooCodeAuthCallback: vi.fn(),
26+
mockSetZooCodeUserInfo: vi.fn(),
27+
mockVisibleProvider,
28+
}
29+
})
2430

2531
vi.mock("../../core/webview/ClineProvider", () => ({
2632
ClineProvider: {
2733
getVisibleInstance: mockGetVisibleInstance,
34+
getAllInstances: mockGetAllInstances,
2835
},
2936
}))
3037

@@ -39,6 +46,7 @@ describe("handleUri", () => {
3946
beforeEach(() => {
4047
vi.clearAllMocks()
4148
mockGetVisibleInstance.mockReturnValue(mockVisibleProvider)
49+
mockGetAllInstances.mockReturnValue([mockVisibleProvider])
4250
})
4351

4452
it("ignores legacy cloud auth callback", async () => {
@@ -54,8 +62,9 @@ describe("handleUri", () => {
5462
)
5563
})
5664

57-
it("stores callback user info even when no webview is visible", async () => {
65+
it("stores callback user info even when no provider instances exist", async () => {
5866
mockGetVisibleInstance.mockReturnValue(null)
67+
mockGetAllInstances.mockReturnValue([])
5968
mockHandleZooCodeAuthCallback.mockResolvedValue(true)
6069

6170
await handleUri({
@@ -69,6 +78,7 @@ describe("handleUri", () => {
6978
email: "jane@example.com",
7079
image: "https://example.com/avatar.png",
7180
})
81+
// No provider instances exist, so handleZooCodeCallback should not be called
7282
expect(mockVisibleProvider.handleZooCodeCallback).not.toHaveBeenCalled()
7383
})
7484

@@ -116,4 +126,55 @@ describe("handleUri", () => {
116126
expect(mockSetZooCodeUserInfo).not.toHaveBeenCalled()
117127
expect(mockVisibleProvider.handleZooCodeCallback).not.toHaveBeenCalled()
118128
})
129+
130+
it("propagates the callback token to every ClineProvider instance, not just the visible one", async () => {
131+
// Regression: prior to multi-instance fan-out, hidden providers (sidebar collapsed,
132+
// secondary panels) never received the zooSessionToken, so their profile settings
133+
// stayed unauthenticated until reload.
134+
mockHandleZooCodeAuthCallback.mockResolvedValue(true)
135+
136+
const hiddenProvider = { handleZooCodeCallback: vi.fn() } as any
137+
const secondHidden = { handleZooCodeCallback: vi.fn() } as any
138+
mockGetAllInstances.mockReturnValue([mockVisibleProvider, hiddenProvider, secondHidden])
139+
140+
await handleUri({
141+
path: "/auth-callback",
142+
query: "token=zoo_ext_test_token",
143+
} as any)
144+
145+
expect(mockHandleZooCodeAuthCallback).toHaveBeenCalledWith("zoo_ext_test_token")
146+
expect(mockSetZooCodeUserInfo).toHaveBeenCalled()
147+
expect(mockVisibleProvider.handleZooCodeCallback).toHaveBeenCalledWith("zoo_ext_test_token")
148+
expect(hiddenProvider.handleZooCodeCallback).toHaveBeenCalledWith("zoo_ext_test_token")
149+
expect(secondHidden.handleZooCodeCallback).toHaveBeenCalledWith("zoo_ext_test_token")
150+
})
151+
152+
it("serializes callbacks across instances to avoid concurrent profile-store writes", async () => {
153+
// Regression: a previous implementation used Promise.all which fanned out concurrent
154+
// read-modify-write operations on the same provider settings store. Verify the
155+
// callbacks are invoked sequentially.
156+
mockHandleZooCodeAuthCallback.mockResolvedValue(true)
157+
158+
const order: string[] = []
159+
const makeProvider = (name: string) =>
160+
({
161+
handleZooCodeCallback: vi.fn(async () => {
162+
order.push(`${name}:start`)
163+
// Yield to the event loop so a concurrent call would interleave.
164+
await new Promise((resolve) => setTimeout(resolve, 0))
165+
order.push(`${name}:end`)
166+
}),
167+
}) as any
168+
169+
const a = makeProvider("a")
170+
const b = makeProvider("b")
171+
mockGetAllInstances.mockReturnValue([a, b])
172+
173+
await handleUri({
174+
path: "/auth-callback",
175+
query: "token=zoo_ext_test_token",
176+
} as any)
177+
178+
expect(order).toEqual(["a:start", "a:end", "b:start", "b:end"])
179+
})
119180
})

src/activate/handleUri.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,20 @@ export const handleUri = async (uri: vscode.Uri) => {
5050
email,
5151
image,
5252
})
53-
// Refresh webview state if a panel is currently open
54-
if (visibleProvider) {
55-
await visibleProvider.handleZooCodeCallback(token)
53+
// Write the token to all active provider instances regardless of visibility.
54+
// The profile settings write (handleZooCodeCallback) must run on any active
55+
// instance — not just the visible one — so the zoo-gateway zooSessionToken
56+
// is persisted even when the sidebar/panel is hidden at callback time.
57+
//
58+
// Run sequentially (NOT Promise.all): each ClineProvider's
59+
// handleZooCodeCallback does a read-modify-write on the same backing
60+
// provider settings store (listConfig → getProfile → saveConfig /
61+
// upsertProviderProfile). Fanning out concurrently across N instances
62+
// can interleave reads/writes and clobber updates. Serialization here
63+
// is cheap (at most a handful of instances) and avoids the race.
64+
const allInstances = ClineProvider.getAllInstances()
65+
for (const instance of allInstances) {
66+
await instance.handleZooCodeCallback(token)
5667
}
5768
}
5869
}

src/core/webview/ClineProvider.ts

Lines changed: 133 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,10 @@ export class ClineProvider
620620
return findLast(Array.from(this.activeInstances), (instance) => instance.view?.visible === true)
621621
}
622622

623+
public static getAllInstances(): ClineProvider[] {
624+
return Array.from(this.activeInstances)
625+
}
626+
623627
public static async getInstance(): Promise<ClineProvider | undefined> {
624628
let visibleProvider = ClineProvider.getVisibleInstance()
625629

@@ -851,6 +855,64 @@ export class ClineProvider
851855
if (!currentTask || currentTask.abandoned || currentTask.abort) {
852856
await this.removeClineFromStack()
853857
}
858+
859+
// Ensure zoo-gateway profile is seeded for users who signed in before this feature existed.
860+
// Without this, users with a valid cached token but no zoo-gateway profile would need to
861+
// re-authenticate to use Zoo Gateway. Fire-and-forget to avoid blocking webview init.
862+
void this.ensureZooGatewayProfileSeeded().catch((err) => {
863+
this.log(`[ensureZooGatewayProfileSeeded] Error: ${err instanceof Error ? err.message : String(err)}`)
864+
})
865+
}
866+
867+
/**
868+
* Seeds the zoo-gateway provider profile for users who have a cached auth token
869+
* but no profile (e.g., users who signed in before Zoo Gateway was added), or
870+
* who have an empty/imported profile without a token.
871+
* Called once per webview init; handleZooCodeCallback is idempotent so repeated calls are safe.
872+
*/
873+
private async ensureZooGatewayProfileSeeded(): Promise<void> {
874+
const { getCachedZooCodeToken } = await import("../../services/zoo-code-auth")
875+
const token = getCachedZooCodeToken()
876+
if (!token) return
877+
878+
// Check ALL zoo-gateway profiles — only skip seeding if every profile has the current token.
879+
// Using .find() would miss stale tokens in duplicate/renamed profiles since handleZooCodeCallback
880+
// uses .filter() and updates all of them — the early-return guard must match.
881+
const allProfiles = await this.providerSettingsManager.listConfig()
882+
const zooGatewayProfiles = allProfiles.filter((p) => p.apiProvider === "zoo-gateway")
883+
884+
if (zooGatewayProfiles.length === 0) {
885+
this.log("[ensureZooGatewayProfileSeeded] No zoo-gateway profile found, creating one")
886+
} else {
887+
let allUpToDate = true
888+
889+
for (const entry of zooGatewayProfiles) {
890+
try {
891+
const fullProfile = await this.providerSettingsManager.getProfile({ name: entry.name })
892+
if (fullProfile.zooSessionToken !== token) {
893+
allUpToDate = false
894+
this.log(
895+
fullProfile.zooSessionToken
896+
? "[ensureZooGatewayProfileSeeded] Token mismatch (stale session?), updating with current token"
897+
: "[ensureZooGatewayProfileSeeded] Existing zoo-gateway profile has no token, updating with cached token",
898+
)
899+
break
900+
}
901+
} catch {
902+
allUpToDate = false
903+
this.log("[ensureZooGatewayProfileSeeded] Failed to read existing profile, will re-seed")
904+
break
905+
}
906+
}
907+
908+
if (allUpToDate) {
909+
// All profiles have the current token — nothing to do
910+
return
911+
}
912+
}
913+
914+
// User has token but either no profile, some profiles without token, or stale tokens — seed all
915+
await this.handleZooCodeCallback(token)
854916
}
855917

856918
public async createTaskWithHistoryItem(
@@ -1641,12 +1703,80 @@ export class ClineProvider
16411703
await this.upsertProviderProfile(currentApiConfigName, newConfiguration)
16421704
}
16431705

1644-
// Zoo Code Auth (for observability telemetry)
1706+
// Zoo Code Auth
16451707

1646-
async handleZooCodeCallback(_token: string) {
1708+
async handleZooCodeCallback(token: string) {
16471709
// Auth mutation (token storage, subscription check, success toast) was already
16481710
// performed by handleAuthCallback() in handleUri.ts before this method was called.
1649-
// This method only needs to refresh the webview state to reflect the new auth status.
1711+
// Save the zoo-gateway provider profile with the session token so that
1712+
// ZooGatewayHandler can authenticate without any manual user input.
1713+
//
1714+
// activate: true ONLY if Zoo Gateway is already the active profile — this pushes
1715+
// the new token to the in-memory handler so the current task picks it up immediately.
1716+
// Otherwise activate: false — do NOT switch providers mid-conversation. The user
1717+
// must explicitly select Zoo Gateway in settings if they want to use it.
1718+
try {
1719+
const { apiConfiguration } = await this.getState()
1720+
const currentSettings = this.contextProxy.getProviderSettings()
1721+
const currentApiConfigName = this.contextProxy.getValues().currentApiConfigName
1722+
1723+
// Derive the gateway base URL from ZOO_CODE_BASE_URL so that non-prod environments
1724+
// (staging, local dev) route completions to the correct backend instead of always
1725+
// hard-coding production. An already-set value in the profile is NOT preserved here —
1726+
// it must always align with the auth server the user just authenticated against.
1727+
const { getZooCodeBaseUrl } = await import("../../services/zoo-code-auth")
1728+
const derivedGatewayBaseUrl = `${getZooCodeBaseUrl()}/api/gateway/v1`
1729+
1730+
// Check if Zoo Gateway is the currently active profile by apiProvider identity,
1731+
// not by profile name (profile names are user-renameable).
1732+
const isZooGatewayActive = currentSettings.apiProvider === "zoo-gateway"
1733+
1734+
// Always scan ALL profiles and update every zoo-gateway profile with the new token.
1735+
// This ensures renamed profiles, duplicate profiles, and inactive profiles all stay
1736+
// in sync. The model lookup in requestRouterModels uses .find() which returns the
1737+
// first zoo-gateway profile it finds — if that profile has a stale token, requests fail.
1738+
const allProfiles = await this.providerSettingsManager.listConfig()
1739+
const zooProfiles = allProfiles.filter((p) => p.apiProvider === "zoo-gateway")
1740+
1741+
if (zooProfiles.length === 0) {
1742+
// No existing zoo-gateway profile — create the canonical default.
1743+
const newConfiguration: ProviderSettings = {
1744+
apiProvider: "zoo-gateway",
1745+
zooSessionToken: token,
1746+
zooGatewayModelId: apiConfiguration.zooGatewayModelId,
1747+
zooGatewayBaseUrl: derivedGatewayBaseUrl,
1748+
}
1749+
// Activate only if zoo-gateway was the active provider (shouldn't happen if
1750+
// no profiles exist, but defensive).
1751+
await this.upsertProviderProfile("Zoo Gateway", newConfiguration, isZooGatewayActive)
1752+
} else {
1753+
// Update every existing zoo-gateway profile with the new token and the
1754+
// derived base URL so that environment-specific routing stays consistent.
1755+
for (const entry of zooProfiles) {
1756+
const isActiveProfile = isZooGatewayActive && entry.name === currentApiConfigName
1757+
const existing = await this.providerSettingsManager.getProfile({ name: entry.name })
1758+
const updated: ProviderSettings = {
1759+
...existing,
1760+
zooSessionToken: token,
1761+
zooGatewayBaseUrl: derivedGatewayBaseUrl,
1762+
}
1763+
if (isActiveProfile) {
1764+
// Use upsertProviderProfile with activate: true so the in-memory handler
1765+
// picks up the new token immediately for the current task.
1766+
await this.upsertProviderProfile(entry.name, updated, true)
1767+
} else {
1768+
// Non-active profiles just need the token saved to disk.
1769+
await this.providerSettingsManager.saveConfig(entry.name, updated)
1770+
}
1771+
}
1772+
}
1773+
} catch (error) {
1774+
this.log(
1775+
`[handleZooCodeCallback] Failed to save zoo-gateway profile: ${
1776+
error instanceof Error ? error.message : String(error)
1777+
}`,
1778+
)
1779+
}
16501780
await this.postStateToWebview()
16511781
}
16521782

src/core/webview/__tests__/ClineProvider.spec.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2473,10 +2473,12 @@ describe("ClineProvider - Router Models", () => {
24732473
openrouter: mockModels,
24742474
requesty: mockModels,
24752475
unbound: mockModels,
2476+
"vercel-ai-gateway": mockModels,
2477+
"zoo-gateway": mockModels,
2478+
roo: {},
24762479
litellm: mockModels,
24772480
ollama: {},
24782481
lmstudio: {},
2479-
"vercel-ai-gateway": mockModels,
24802482
poe: {},
24812483
deepseek: {},
24822484
},
@@ -2508,6 +2510,7 @@ describe("ClineProvider - Router Models", () => {
25082510
.mockRejectedValueOnce(new Error("Requesty API error")) // requesty fail
25092511
.mockResolvedValueOnce(mockModels) // unbound success
25102512
.mockResolvedValueOnce(mockModels) // vercel-ai-gateway success
2513+
.mockResolvedValueOnce(mockModels) // zoo-gateway success
25112514
.mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm fail
25122515

25132516
await messageHandler({ type: "requestRouterModels" })
@@ -2519,10 +2522,12 @@ describe("ClineProvider - Router Models", () => {
25192522
openrouter: mockModels,
25202523
requesty: {},
25212524
unbound: mockModels,
2525+
"vercel-ai-gateway": mockModels,
2526+
"zoo-gateway": mockModels,
2527+
roo: {},
25222528
ollama: {},
25232529
lmstudio: {},
25242530
litellm: {},
2525-
"vercel-ai-gateway": mockModels,
25262531
poe: {},
25272532
deepseek: {},
25282533
},
@@ -2614,10 +2619,12 @@ describe("ClineProvider - Router Models", () => {
26142619
openrouter: mockModels,
26152620
requesty: mockModels,
26162621
unbound: mockModels,
2622+
"vercel-ai-gateway": mockModels,
2623+
"zoo-gateway": mockModels,
2624+
roo: {},
26172625
litellm: {},
26182626
ollama: {},
26192627
lmstudio: {},
2620-
"vercel-ai-gateway": mockModels,
26212628
poe: {},
26222629
deepseek: {},
26232630
},

0 commit comments

Comments
 (0)