Skip to content

Commit 75d4eee

Browse files
JamesRobert20James Mtendamemacursoragent
authored
feat(zoo-gateway): auth callback and multi-profile token sync (#347)
* feat(zoo-gateway): auth callback, profile token sync, and sign-out Co-authored-by: Cursor <cursoragent@cursor.com> * test(zoo-gateway): cover auth callback profile sync and sign-out Add ClineProvider tests for handleZooCodeCallback, ensureZooGatewayProfileSeeded, and webviewMessageHandler zooCodeSignOut to satisfy codecov patch on PR #347. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(zoo-gateway): surface 401/402/403 errors with actionable toasts Clear the cached token on 401 and offer sign-in. On insufficient credits or budget limits, open the credits page. On account frozen/banned, open support. Errors still propagate to the task layer after the toast. Co-authored-by: Cursor <cursoragent@cursor.com> * i18n(zoo-gateway): backfill zooAuth translations for 17 non-English locales Adds session_expired, out_of_credits, account_unavailable, budget_exceeded under zooAuth.errors and a new zooAuth.buttons block (sign_in, add_credits, contact_support) introduced by the gateway 401/402/403 UX so check-translations passes. Co-authored-by: Cursor <cursoragent@cursor.com> * test(zoo-gateway): cover surfaceGatewayApiError UX branches for codecov patch Adds vscode + i18n mocks and asserts the 401/402/403/429 paths in surfaceGatewayApiError: token clear + sign-in URL on 401, add-credits URL on 402 and budget-coded 429, support URL on 403, no-op on 429 without a budget code or on errors without a status. Also verifies the helper still runs before completePrompt rewraps the upstream error. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(zoo-gateway): address PR review feedback on auth, seeding, and errors Co-authored-by: Cursor <cursoragent@cursor.com> * test(zoo-gateway): cover stale baseUrl seeding path Co-authored-by: Cursor <cursoragent@cursor.com> * fix(zoo-gateway): sign-out clears stale profile tokens, simplify model fetch Co-authored-by: Cursor <cursoragent@cursor.com> * test(zoo-gateway): drop stale profile-scan test for requestRouterModels Co-authored-by: Cursor <cursoragent@cursor.com> * fix(zoo-gateway): treat verify 5xx as transient, do not clear token The website's /api/extension/auth/verify route now returns 503 when the backend can't reach the database, instead of crashing. The extension previously treated any non-OK response from this endpoint as a definitively invalid token, which meant a transient backend hiccup would silently clear the user's session and force a fresh sign-in. verifyZooCodeToken now returns "unreachable" for 5xx responses (same classification as a network error), so initZooCodeAuth keeps the cached token in place and reports subscription status as "unknown" until the backend recovers. handleAuthCallback shows the could-not-verify message on 5xx so users see this is a temporary issue rather than a bad token. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(zoo-gateway): split error classification from UX, extract callback fan-out Address review feedback on PR #347: - zoo-gateway.ts: split surfaceGatewayApiError into a pure classifyGatewayApiError (error -> action) plus a thin UX layer that switches on the action. The classifier is exported and covered by focused unit tests, so the status/code -> action mapping no longer needs the VS Code notification mocks to verify. - handleUri.ts: extract the per-instance token propagation loop into a propagateZooGatewayCallback helper, keeping the /auth-callback case focused on routing. Behaviour (sequential read-modify-write, per instance error isolation) is unchanged. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: isolate per-profile token cleanup on sign-out and assert state refresh Wrap per-profile work in the zoo-gateway sign-out cleanup loop in its own try/catch so one corrupted profile or failed write no longer aborts cleanup of the remaining profiles. Also assert postStateToWebview runs on the handleZooCodeCallback persistence-failure path. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: James Mtendamema <jmtendamema@geologicai.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 019d857 commit 75d4eee

31 files changed

Lines changed: 1247 additions & 104 deletions

src/activate/__tests__/handleUri.spec.ts

Lines changed: 97 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,75 @@ 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+
})
180+
181+
it("continues fan-out when one instance fails to persist the callback token", async () => {
182+
mockHandleZooCodeAuthCallback.mockResolvedValue(true)
183+
184+
const failingProvider = {
185+
handleZooCodeCallback: vi.fn(async () => {
186+
throw new Error("profile store unavailable")
187+
}),
188+
} as any
189+
const healthyProvider = { handleZooCodeCallback: vi.fn() } as any
190+
mockGetAllInstances.mockReturnValue([failingProvider, healthyProvider])
191+
192+
await handleUri({
193+
path: "/auth-callback",
194+
query: "token=zoo_ext_test_token",
195+
} as any)
196+
197+
expect(failingProvider.handleZooCodeCallback).toHaveBeenCalledWith("zoo_ext_test_token")
198+
expect(healthyProvider.handleZooCodeCallback).toHaveBeenCalledWith("zoo_ext_test_token")
199+
})
119200
})

src/activate/handleUri.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,34 @@ import { getRouterUnavailableSignInMessage } from "../core/config/routerRemoval"
44
import { ClineProvider } from "../core/webview/ClineProvider"
55
import { handleAuthCallback as handleZooCodeAuthCallback, setZooCodeUserInfo } from "../services/zoo-code-auth"
66

7+
/**
8+
* Persist the Zoo Code session token to every active provider instance.
9+
*
10+
* The profile settings write (handleZooCodeCallback) must run on any active
11+
* instance — not just the visible one — so the zoo-gateway zooSessionToken is
12+
* persisted even when the sidebar/panel is hidden at callback time.
13+
*
14+
* Run sequentially (NOT Promise.all): each ClineProvider's handleZooCodeCallback
15+
* does a read-modify-write on the same backing provider settings store
16+
* (listConfig → getProfile → saveConfig / upsertProviderProfile). Fanning out
17+
* concurrently across N instances can interleave reads/writes and clobber
18+
* updates. Serialization is cheap (at most a handful of instances) and avoids
19+
* the race.
20+
*/
21+
async function propagateZooGatewayCallback(token: string): Promise<void> {
22+
const allInstances = ClineProvider.getAllInstances()
23+
for (const instance of allInstances) {
24+
try {
25+
await instance.handleZooCodeCallback(token)
26+
} catch (error) {
27+
console.error(
28+
"Failed to persist Zoo Gateway token for a provider instance:",
29+
error instanceof Error ? error.message : error,
30+
)
31+
}
32+
}
33+
}
34+
735
export const handleUri = async (uri: vscode.Uri) => {
836
const path = uri.path
937
const query = new URLSearchParams(uri.query.replace(/\+/g, "%2B"))
@@ -50,10 +78,7 @@ export const handleUri = async (uri: vscode.Uri) => {
5078
email,
5179
image,
5280
})
53-
// Refresh webview state if a panel is currently open
54-
if (visibleProvider) {
55-
await visibleProvider.handleZooCodeCallback(token)
56-
}
81+
await propagateZooGatewayCallback(token)
5782
}
5883
}
5984
break

0 commit comments

Comments
 (0)