From bb6fc2e744b59651785a67043fe41e986f6909d8 Mon Sep 17 00:00:00 2001 From: Teng Lin Date: Fri, 27 Feb 2026 12:37:19 -0500 Subject: [PATCH 1/6] fix: surface Gemini auth validation link on session creation failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Gemini CLI requires authorization, session/new returns a 401 with a validationLink buried in ValidationRequiredError. Previously this link was lost at two levels, causing the UI to display a generic 500 error. Changes: - acp-adapter: add AcpError class that preserves error code and data from JSON-RPC error responses (replaces plain Error) - api-sessions: detect AcpError with code 401 and return HTTP 401 with authRequired + validationLink instead of a generic 500 - web/api: add AuthRequiredError that carries validationLink; createSession now throws it on 401 auth responses - NewSessionDialog: on AuthRequiredError, show the error message and an "Authorize Gemini →" link that opens the validation URL in a new tab - patches/: add unified diff for gemini-cli 0.30.0 zedIntegration.js to forward validationLink into RequestError data (applied to installed CLI) --- ...-cli-0.30.0-zed-auth-validation-link.patch | 25 +++++++++++++++++ src/adapters/acp/acp-adapter.ts | 17 +++++++++-- src/http/api-sessions.ts | 20 +++++++++++-- web/src/api.ts | 26 ++++++++++++++++- web/src/components/NewSessionDialog.tsx | 28 +++++++++++++++---- 5 files changed, 105 insertions(+), 11 deletions(-) create mode 100644 patches/gemini-cli-0.30.0-zed-auth-validation-link.patch diff --git a/patches/gemini-cli-0.30.0-zed-auth-validation-link.patch b/patches/gemini-cli-0.30.0-zed-auth-validation-link.patch new file mode 100644 index 00000000..33ec818c --- /dev/null +++ b/patches/gemini-cli-0.30.0-zed-auth-validation-link.patch @@ -0,0 +1,25 @@ +--- a/gemini-cli-0.30.0/dist/src/zed-integration/zedIntegration.js ++++ b/gemini-cli-0.30.0/dist/src/zed-integration/zedIntegration.js +@@ -102,6 +102,7 @@ + const authType = loadedSettings.merged.security.auth.selectedType || AuthType.USE_GEMINI; + let isAuthenticated = false; + let authErrorMessage = ''; ++ let authErrorData = undefined; + try { + await config.refreshAuth(authType); + isAuthenticated = true; +@@ -116,10 +117,13 @@ + catch (e) { + isAuthenticated = false; + authErrorMessage = getAcpErrorMessage(e); ++ if (e && typeof e === 'object' && 'validationLink' in e && e.validationLink) { ++ authErrorData = { validationLink: e.validationLink, validationDescription: e.validationDescription, learnMoreUrl: e.learnMoreUrl }; ++ } + debugLogger.error(`Authentication failed: ${e instanceof Error ? e.stack : e}`); + } + if (!isAuthenticated) { +- throw new acp.RequestError(401, authErrorMessage || 'Authentication required.'); ++ throw new acp.RequestError(401, authErrorMessage || 'Authentication required.', authErrorData); + } + if (this.clientCapabilities?.fs) { + const acpFileSystemService = new AcpFileSystemService(this.connection, sessionId, this.clientCapabilities.fs, config.getFileSystemService()); diff --git a/src/adapters/acp/acp-adapter.ts b/src/adapters/acp/acp-adapter.ts index bc0cb0f8..9b90dbd6 100644 --- a/src/adapters/acp/acp-adapter.ts +++ b/src/adapters/acp/acp-adapter.ts @@ -24,6 +24,18 @@ import type { AcpInitializeResult, ErrorClassifier } from "./outbound-translator const PROTOCOL_VERSION = 1; +/** Error thrown when an ACP JSON-RPC response contains an error object. Preserves code and data. */ +export class AcpError extends Error { + constructor( + public readonly code: number, + message: string, + public readonly data?: unknown, + ) { + super(`ACP error: ${message}`); + this.name = "AcpError"; + } +} + /** Spawn function signature matching child_process.spawn. */ export type SpawnFn = (command: string, args: string[], options: SpawnOptions) => ChildProcess; @@ -194,8 +206,9 @@ async function waitForResponse( const remaining = lines.slice(i + 1); const leftover = remaining.length > 0 ? `${remaining.join("\n")}\n${buffer}` : buffer; if ("error" in msg && msg.error) { - const errorMsg = msg.error.message; - settle(() => reject(new Error(`ACP error: ${errorMsg}`))); + settle(() => + reject(new AcpError(msg.error!.code, msg.error!.message, msg.error!.data)), + ); } else { settle(() => resolve({ result: (msg as { result: T }).result, leftover })); } diff --git a/src/http/api-sessions.ts b/src/http/api-sessions.ts index 0f39ce1d..2b512777 100644 --- a/src/http/api-sessions.ts +++ b/src/http/api-sessions.ts @@ -1,6 +1,7 @@ import { existsSync, statSync } from "node:fs"; import type { IncomingMessage, ServerResponse } from "node:http"; import { resolve as resolvePath } from "node:path"; +import { AcpError } from "../adapters/acp/acp-adapter.js"; import { CLI_ADAPTER_NAMES, type CliAdapterName } from "../adapters/create-adapter.js"; import type { SessionCoordinator } from "../core/session-coordinator.js"; @@ -94,9 +95,22 @@ export function handleApiSessions( }); json(res, 201, result); } catch (err) { - json(res, 500, { - error: `Failed to create session: ${err instanceof Error ? err.message : err}`, - }); + if (err instanceof AcpError && err.code === 401) { + const data = err.data as + | { validationLink?: string; validationDescription?: string; learnMoreUrl?: string } + | undefined; + json(res, 401, { + error: err.message, + authRequired: true, + validationLink: data?.validationLink, + validationDescription: data?.validationDescription, + learnMoreUrl: data?.learnMoreUrl, + }); + } else { + json(res, 500, { + error: `Failed to create session: ${err instanceof Error ? err.message : err}`, + }); + } } }) .catch((err) => { diff --git a/web/src/api.ts b/web/src/api.ts index e442136f..126b770a 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -2,6 +2,22 @@ import type { SessionInfo } from "./store"; const BASE = "/api"; +export class AuthRequiredError extends Error { + readonly validationLink?: string; + readonly validationDescription?: string; + readonly learnMoreUrl?: string; + constructor( + message: string, + data: { validationLink?: string; validationDescription?: string; learnMoreUrl?: string }, + ) { + super(message); + this.name = "AuthRequiredError"; + this.validationLink = data.validationLink; + this.validationDescription = data.validationDescription; + this.learnMoreUrl = data.learnMoreUrl; + } +} + function getApiKey(): string | null { return ( document.querySelector('meta[name="beamcode-api-token"]')?.content ?? @@ -32,7 +48,15 @@ export async function createSession(options: { headers: { "Content-Type": "application/json", ...authHeaders() }, body: JSON.stringify(options), }); - if (!res.ok) throw new Error(`Failed to create session: ${res.status}`); + if (!res.ok) { + if (res.status === 401) { + const body = await res.json().catch(() => ({})); + if (body.authRequired) { + throw new AuthRequiredError(body.error ?? "Authentication required", body); + } + } + throw new Error(`Failed to create session: ${res.status}`); + } return res.json(); } diff --git a/web/src/components/NewSessionDialog.tsx b/web/src/components/NewSessionDialog.tsx index c8469d4b..b7fe0749 100644 --- a/web/src/components/NewSessionDialog.tsx +++ b/web/src/components/NewSessionDialog.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { createSession } from "../api"; +import { AuthRequiredError, createSession } from "../api"; import { useStore } from "../store"; import { updateSessionUrl } from "../utils/session"; import { connectToSession } from "../ws"; @@ -33,6 +33,7 @@ export function NewSessionDialog() { const [cwd, setCwd] = useState(""); const [creating, setCreating] = useState(false); const [error, setError] = useState(null); + const [validationLink, setValidationLink] = useState(null); const newButtonRef = useRef(null); const firstButtonRef = useRef(null); @@ -47,6 +48,7 @@ export function NewSessionDialog() { setModel(""); setCwd(""); setError(null); + setValidationLink(null); newButtonRef.current = document.querySelector( "[data-new-session-trigger]", ); @@ -63,6 +65,7 @@ export function NewSessionDialog() { if (creating) return; setCreating(true); setError(null); + setValidationLink(null); try { const session = await createSession({ adapter, @@ -75,7 +78,12 @@ export function NewSessionDialog() { updateSessionUrl(session.sessionId, "push"); close(); } catch (err) { - setError(err instanceof Error ? err.message : "Failed to create session"); + if (err instanceof AuthRequiredError) { + setError(err.message); + if (err.validationLink) setValidationLink(err.validationLink); + } else { + setError(err instanceof Error ? err.message : "Failed to create session"); + } } finally { setCreating(false); } @@ -169,12 +177,22 @@ export function NewSessionDialog() { {error && ( -

- {error} -

+

{error}

+ {validationLink && ( + + Authorize Gemini → + + )} + )}
From ae1b146db3f7f0c8f32ebd078a79e0253f683772 Mon Sep 17 00:00:00 2001 From: Teng Lin Date: Fri, 27 Feb 2026 12:55:46 -0500 Subject: [PATCH 2/6] fix: harden auth error path with sanitization, defensive code, and tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - acp-adapter: fall back to -32603 when JSON-RPC error omits numeric code, preventing silent regression to 500 when ACP agent sends malformed errors - web/api: sanitize validationLink to https?:// only before constructing AuthRequiredError; use server error body on non-authRequired 401 responses - NewSessionDialog: store validationDescription state and use it as link text (fallback "Authorize →") instead of hardcoded "Authorize Gemini →" - acp-adapter.test: add tests for AcpError code/data preservation and missing-code fallback to -32603; fix existing test to include code field - api-sessions.test: add tests for 401+authRequired with validationLink, 401 without validationLink, and non-401 AcpError falling through to 500 --- src/adapters/acp/acp-adapter.test.ts | 66 +++++++++++++++++++++++- src/adapters/acp/acp-adapter.ts | 6 +-- src/http/api-sessions.test.ts | 67 +++++++++++++++++++++++++ src/http/api-sessions.ts | 13 +++-- web/src/api.ts | 26 ++++++++-- web/src/components/NewSessionDialog.tsx | 6 ++- 6 files changed, 169 insertions(+), 15 deletions(-) diff --git a/src/adapters/acp/acp-adapter.test.ts b/src/adapters/acp/acp-adapter.test.ts index 9705f2aa..883a99d7 100644 --- a/src/adapters/acp/acp-adapter.test.ts +++ b/src/adapters/acp/acp-adapter.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createUnifiedMessage } from "../../core/types/unified-message.js"; import type { SpawnFn } from "./acp-adapter.js"; -import { AcpAdapter } from "./acp-adapter.js"; +import { AcpAdapter, AcpError } from "./acp-adapter.js"; import { autoRespond, createMockChild, @@ -125,13 +125,75 @@ describe("AcpAdapter", () => { const errResponse = JSON.stringify({ jsonrpc: "2.0", id: initReq.id, - error: { message: "agent initialization failed" }, + error: { code: -32000, message: "agent initialization failed" }, }); mockChild.stdout.emit("data", Buffer.from(`${errResponse}\n`)); await expect(connectPromise).rejects.toThrow("ACP error: agent initialization failed"); }); + it("rejects with AcpError preserving code and data on JSON-RPC error", async () => { + setup(); + const adapter = new AcpAdapter(mockSpawn); + const connectPromise = adapter.connect({ + sessionId: "sess-1", + adapterOptions: { command: "my-agent" }, + }); + + await tick(); + + const initReq = JSON.parse(mockChild.stdin.chunks[0]); + const errResponse = JSON.stringify({ + jsonrpc: "2.0", + id: initReq.id, + error: { + code: 401, + message: "Authentication required.", + data: { validationLink: "https://example.com/auth", validationDescription: "Sign in" }, + }, + }); + mockChild.stdout.emit("data", Buffer.from(`${errResponse}\n`)); + + const err = await connectPromise.then( + () => null, + (e: unknown) => e, + ); + expect(err).toBeInstanceOf(AcpError); + const acpErr = err as AcpError; + expect(acpErr.code).toBe(401); + expect(acpErr.message).toBe("ACP error: Authentication required."); + expect(acpErr.data).toEqual({ + validationLink: "https://example.com/auth", + validationDescription: "Sign in", + }); + }); + + it("rejects with code -32603 when JSON-RPC error omits code field", async () => { + setup(); + const adapter = new AcpAdapter(mockSpawn); + const connectPromise = adapter.connect({ + sessionId: "sess-1", + adapterOptions: { command: "my-agent" }, + }); + + await tick(); + + const initReq = JSON.parse(mockChild.stdin.chunks[0]); + const errResponse = JSON.stringify({ + jsonrpc: "2.0", + id: initReq.id, + error: { message: "no code field" }, + }); + mockChild.stdout.emit("data", Buffer.from(`${errResponse}\n`)); + + const err = await connectPromise.then( + () => null, + (e: unknown) => e, + ); + expect(err).toBeInstanceOf(AcpError); + expect((err as AcpError).code).toBe(-32603); + }); + it("rejects when stdout emits an error during handshake", async () => { setup(); const adapter = new AcpAdapter(mockSpawn); diff --git a/src/adapters/acp/acp-adapter.ts b/src/adapters/acp/acp-adapter.ts index 9b90dbd6..c4bdb6be 100644 --- a/src/adapters/acp/acp-adapter.ts +++ b/src/adapters/acp/acp-adapter.ts @@ -206,9 +206,9 @@ async function waitForResponse( const remaining = lines.slice(i + 1); const leftover = remaining.length > 0 ? `${remaining.join("\n")}\n${buffer}` : buffer; if ("error" in msg && msg.error) { - settle(() => - reject(new AcpError(msg.error!.code, msg.error!.message, msg.error!.data)), - ); + const { code, message, data } = msg.error; + const safeCode = typeof code === "number" ? code : -32603; + settle(() => reject(new AcpError(safeCode, message, data))); } else { settle(() => resolve({ result: (msg as { result: T }).result, leftover })); } diff --git a/src/http/api-sessions.test.ts b/src/http/api-sessions.test.ts index 7212c3d0..ff1a0f34 100644 --- a/src/http/api-sessions.test.ts +++ b/src/http/api-sessions.test.ts @@ -8,6 +8,7 @@ vi.mock("node:fs", () => ({ })); import { existsSync, statSync } from "node:fs"; +import { AcpError } from "../adapters/acp/acp-adapter.js"; import type { SessionCoordinator } from "../core/session-coordinator.js"; import { handleApiSessions } from "./api-sessions.js"; @@ -268,6 +269,72 @@ describe("handleApiSessions", () => { expect(parseBody(res)).toEqual({ error: "Failed to create session: internal failure" }); }); + it("POST /api/sessions returns 401 with authRequired when createSession throws AcpError 401 with validationLink", async () => { + const coordinator = mockSessionCoordinator({ + createSession: async () => { + throw new AcpError(401, "Authentication required.", { + validationLink: "https://example.com/auth", + validationDescription: "Sign in to continue", + learnMoreUrl: "https://example.com/docs", + }); + }, + }); + const req = mockReq("POST"); + const res = mockRes(); + + handleApiSessions(req, res, makeUrl("/api/sessions"), coordinator); + emitBody(req, JSON.stringify({})); + + await vi.waitFor(() => { + expect(res._status).toBe(401); + }); + expect(parseBody(res)).toEqual({ + error: "ACP error: Authentication required.", + authRequired: true, + validationLink: "https://example.com/auth", + validationDescription: "Sign in to continue", + learnMoreUrl: "https://example.com/docs", + }); + }); + + it("POST /api/sessions returns 401 with authRequired when validationLink is absent", async () => { + const coordinator = mockSessionCoordinator({ + createSession: async () => { + throw new AcpError(401, "Authentication required.", undefined); + }, + }); + const req = mockReq("POST"); + const res = mockRes(); + + handleApiSessions(req, res, makeUrl("/api/sessions"), coordinator); + emitBody(req, JSON.stringify({})); + + await vi.waitFor(() => { + expect(res._status).toBe(401); + }); + const body = parseBody(res) as Record; + expect(body.authRequired).toBe(true); + expect(body.validationLink).toBeUndefined(); + }); + + it("POST /api/sessions returns 500 for non-401 AcpError", async () => { + const coordinator = mockSessionCoordinator({ + createSession: async () => { + throw new AcpError(403, "Forbidden"); + }, + }); + const req = mockReq("POST"); + const res = mockRes(); + + handleApiSessions(req, res, makeUrl("/api/sessions"), coordinator); + emitBody(req, JSON.stringify({})); + + await vi.waitFor(() => { + expect(res._status).toBe(500); + }); + expect(parseBody(res)).toEqual({ error: "Failed to create session: ACP error: Forbidden" }); + }); + it("POST /api/sessions with body too large returns 413", async () => { const coordinator = mockSessionCoordinator(); const req = mockReq("POST"); diff --git a/src/http/api-sessions.ts b/src/http/api-sessions.ts index 2b512777..bae1bc4d 100644 --- a/src/http/api-sessions.ts +++ b/src/http/api-sessions.ts @@ -5,6 +5,12 @@ import { AcpError } from "../adapters/acp/acp-adapter.js"; import { CLI_ADAPTER_NAMES, type CliAdapterName } from "../adapters/create-adapter.js"; import type { SessionCoordinator } from "../core/session-coordinator.js"; +interface AcpAuthErrorData { + validationLink?: string; + validationDescription?: string; + learnMoreUrl?: string; +} + const MAX_BODY_BYTES = 1024 * 1024; // 1 MB function readBody(req: IncomingMessage): Promise { @@ -95,10 +101,9 @@ export function handleApiSessions( }); json(res, 201, result); } catch (err) { - if (err instanceof AcpError && err.code === 401) { - const data = err.data as - | { validationLink?: string; validationDescription?: string; learnMoreUrl?: string } - | undefined; + // 401 = legacy gemini-cli ≤0.30.0; -32000 = ACP-standard authRequired (≥0.31.0) + if (err instanceof AcpError && (err.code === 401 || err.code === -32000)) { + const data = err.data as AcpAuthErrorData | undefined; json(res, 401, { error: err.message, authRequired: true, diff --git a/web/src/api.ts b/web/src/api.ts index 126b770a..685a5f50 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -6,15 +6,24 @@ export class AuthRequiredError extends Error { readonly validationLink?: string; readonly validationDescription?: string; readonly learnMoreUrl?: string; + constructor( message: string, - data: { validationLink?: string; validationDescription?: string; learnMoreUrl?: string }, + { + validationLink, + validationDescription, + learnMoreUrl, + }: { + validationLink?: string; + validationDescription?: string; + learnMoreUrl?: string; + }, ) { super(message); this.name = "AuthRequiredError"; - this.validationLink = data.validationLink; - this.validationDescription = data.validationDescription; - this.learnMoreUrl = data.learnMoreUrl; + this.validationLink = validationLink; + this.validationDescription = validationDescription; + this.learnMoreUrl = learnMoreUrl; } } @@ -52,8 +61,15 @@ export async function createSession(options: { if (res.status === 401) { const body = await res.json().catch(() => ({})); if (body.authRequired) { - throw new AuthRequiredError(body.error ?? "Authentication required", body); + const rawLink: unknown = body.validationLink; + const safeLink = + typeof rawLink === "string" && /^https?:\/\//i.test(rawLink) ? rawLink : undefined; + throw new AuthRequiredError(body.error ?? "Authentication required", { + ...body, + validationLink: safeLink, + }); } + throw new Error(body.error ?? `Failed to create session: ${res.status}`); } throw new Error(`Failed to create session: ${res.status}`); } diff --git a/web/src/components/NewSessionDialog.tsx b/web/src/components/NewSessionDialog.tsx index b7fe0749..6c14fc01 100644 --- a/web/src/components/NewSessionDialog.tsx +++ b/web/src/components/NewSessionDialog.tsx @@ -34,6 +34,7 @@ export function NewSessionDialog() { const [creating, setCreating] = useState(false); const [error, setError] = useState(null); const [validationLink, setValidationLink] = useState(null); + const [validationDescription, setValidationDescription] = useState(null); const newButtonRef = useRef(null); const firstButtonRef = useRef(null); @@ -49,6 +50,7 @@ export function NewSessionDialog() { setCwd(""); setError(null); setValidationLink(null); + setValidationDescription(null); newButtonRef.current = document.querySelector( "[data-new-session-trigger]", ); @@ -66,6 +68,7 @@ export function NewSessionDialog() { setCreating(true); setError(null); setValidationLink(null); + setValidationDescription(null); try { const session = await createSession({ adapter, @@ -81,6 +84,7 @@ export function NewSessionDialog() { if (err instanceof AuthRequiredError) { setError(err.message); if (err.validationLink) setValidationLink(err.validationLink); + if (err.validationDescription) setValidationDescription(err.validationDescription); } else { setError(err instanceof Error ? err.message : "Failed to create session"); } @@ -189,7 +193,7 @@ export function NewSessionDialog() { rel="noopener noreferrer" className="mt-1.5 inline-block font-medium underline" > - Authorize Gemini → + {validationDescription ?? "Authorize →"} )}
From 8a2be92e6631d0b9edb7be1e297f98782a33f486 Mon Sep 17 00:00:00 2001 From: Teng Lin Date: Fri, 27 Feb 2026 17:55:41 -0500 Subject: [PATCH 3/6] refactor(ui): group error state into single CreationError object Consolidates the three separate error state variables (error, validationLink, validationDescription) into a single CreationError object, simplifying state resets to a single setError(null) call. Addresses gemini-code-assist review comment on PR #144. --- web/src/components/NewSessionDialog.tsx | 32 +++++++++++++------------ 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/web/src/components/NewSessionDialog.tsx b/web/src/components/NewSessionDialog.tsx index 6c14fc01..0102103d 100644 --- a/web/src/components/NewSessionDialog.tsx +++ b/web/src/components/NewSessionDialog.tsx @@ -4,6 +4,12 @@ import { useStore } from "../store"; import { updateSessionUrl } from "../utils/session"; import { connectToSession } from "../ws"; +interface CreationError { + message: string; + validationLink?: string | null; + validationDescription?: string | null; +} + export const ADAPTER_LABELS: Record = { claude: "Claude", codex: "Codex", @@ -32,9 +38,7 @@ export function NewSessionDialog() { const [model, setModel] = useState(""); const [cwd, setCwd] = useState(""); const [creating, setCreating] = useState(false); - const [error, setError] = useState(null); - const [validationLink, setValidationLink] = useState(null); - const [validationDescription, setValidationDescription] = useState(null); + const [error, setError] = useState(null); const newButtonRef = useRef(null); const firstButtonRef = useRef(null); @@ -49,8 +53,6 @@ export function NewSessionDialog() { setModel(""); setCwd(""); setError(null); - setValidationLink(null); - setValidationDescription(null); newButtonRef.current = document.querySelector( "[data-new-session-trigger]", ); @@ -67,8 +69,6 @@ export function NewSessionDialog() { if (creating) return; setCreating(true); setError(null); - setValidationLink(null); - setValidationDescription(null); try { const session = await createSession({ adapter, @@ -82,11 +82,13 @@ export function NewSessionDialog() { close(); } catch (err) { if (err instanceof AuthRequiredError) { - setError(err.message); - if (err.validationLink) setValidationLink(err.validationLink); - if (err.validationDescription) setValidationDescription(err.validationDescription); + setError({ + message: err.message, + validationLink: err.validationLink, + validationDescription: err.validationDescription, + }); } else { - setError(err instanceof Error ? err.message : "Failed to create session"); + setError({ message: err instanceof Error ? err.message : "Failed to create session" }); } } finally { setCreating(false); @@ -185,15 +187,15 @@ export function NewSessionDialog() { role="alert" className="mb-4 rounded-md bg-bc-error/10 px-3 py-2 text-xs text-bc-error" > -

{error}

- {validationLink && ( +

{error.message}

+ {error.validationLink && ( - {validationDescription ?? "Authorize →"} + {error.validationDescription ?? "Authorize →"} )} From b9e7cb338c2e7ff8dc72754466cb4841b792d8d7 Mon Sep 17 00:00:00 2001 From: Teng Lin Date: Fri, 27 Feb 2026 19:41:28 -0500 Subject: [PATCH 4/6] fix: surface Gemini OAuth URL via clearOauthClientCache + headless UI listener pattern Root causes identified and fixed in gemini-cli binary patches: 1. oauthClientPromises module-level cache in oauth2.js stores a rejected promise from the startup auth attempt. When session/new calls refreshAuth, it gets the cached failure without re-running getConsentForOauth. Fix: call clearOauthClientCache() in newSession() before refreshAuth(). 2. GeminiAgent constructor registers a ConsentRequest listener so getConsentForOauth() sees listenerCount > 0 and takes the interactive path (emitting the OAuth URL via UserFeedback) instead of throwing FatalAuthenticationError. 3. UserFeedback listener captures the OAuth URL and resolves a Promise.race against refreshAuth(), returning the URL to beamcode via validationLink in the 401 error data. 4. events.js patched to use globalThis.__geminiCoreEvents singleton so multiple dynamic imports of the module share one emitter instance. Verified: node /tmp/test-acp-auth.mjs returns validationLink successfully. --- ...-cli-0.30.0-zed-auth-validation-link.patch | 125 +++++++++++++++++- 1 file changed, 119 insertions(+), 6 deletions(-) diff --git a/patches/gemini-cli-0.30.0-zed-auth-validation-link.patch b/patches/gemini-cli-0.30.0-zed-auth-validation-link.patch index 33ec818c..4333e48b 100644 --- a/patches/gemini-cli-0.30.0-zed-auth-validation-link.patch +++ b/patches/gemini-cli-0.30.0-zed-auth-validation-link.patch @@ -1,14 +1,101 @@ +--- a/gemini-cli-0.30.0/node_modules/@google/gemini-cli-core/dist/src/utils/events.js ++++ b/gemini-cli-0.30.0/node_modules/@google/gemini-cli-core/dist/src/utils/events.js +@@ -157,4 +157,9 @@ + emitTelemetryTokenStorageType(event) { + this._emitOrQueue(CoreEvent.TelemetryTokenStorageType, event); + } + } +-export const coreEvents = new CoreEventEmitter(); ++// Use globalThis to survive multiple dynamic imports of this module in the ++// same Node.js process (e.g. from zedIntegration.js and a lazy import() in ++// the auth/MCP path). All copies of the module share one emitter. ++if (!globalThis.__geminiCoreEvents) { ++ globalThis.__geminiCoreEvents = new CoreEventEmitter(); ++} ++export const coreEvents = globalThis.__geminiCoreEvents; + --- a/gemini-cli-0.30.0/dist/src/zed-integration/zedIntegration.js +++ b/gemini-cli-0.30.0/dist/src/zed-integration/zedIntegration.js -@@ -102,6 +102,7 @@ +@@ -6,7 +6,7 @@ +-import { CoreToolCallStatus, AuthType, logToolCall, convertToFunctionResponse, ToolConfirmationOutcome, clearCachedCredentialFile, isNodeError, getErrorMessage, isWithinRoot, getErrorStatus, MCPServerConfig, DiscoveredMCPTool, StreamEventType, ToolCallEvent, debugLogger, ReadManyFilesTool, REFERENCE_CONTENT_START, resolveModel, createWorkingStdio, startupProfiler, Kind, partListUnionToString, LlmRole, coreEvents, CoreEvent, } from '@google/gemini-cli-core'; ++import { CoreToolCallStatus, AuthType, logToolCall, convertToFunctionResponse, ToolConfirmationOutcome, clearCachedCredentialFile, isNodeError, getErrorMessage, isWithinRoot, getErrorStatus, MCPServerConfig, DiscoveredMCPTool, StreamEventType, ToolCallEvent, debugLogger, ReadManyFilesTool, REFERENCE_CONTENT_START, resolveModel, createWorkingStdio, startupProfiler, Kind, partListUnionToString, LlmRole, coreEvents, CoreEvent, writeToStderr, clearOauthClientCache, } from '@google/gemini-cli-core'; + +@@ -31,6 +31,27 @@ + export class GeminiAgent { + config; + settings; + argv; + connection; + sessions = new Map(); + clientCapabilities; ++ // Pending resolver for capturing an OAuth URL from UserFeedback events. ++ // Set to a Promise resolve function before calling refreshAuth; cleared after. ++ _pendingAuthUrl = null; + constructor(config, settings, argv, connection) { + this.config = config; + this.settings = settings; + this.argv = argv; + this.connection = connection; ++ // Register as a persistent headless UI listener so getConsentForOauth() ++ // sees listenerCount > 0 and takes the interactive path (which generates ++ // an OAuth URL) rather than throwing FatalAuthenticationError immediately. ++ coreEvents.on(CoreEvent.ConsentRequest, ({ onConfirm } = {}) => { ++ onConfirm?.(true); ++ }); ++ coreEvents.on(CoreEvent.UserFeedback, ({ message = '' } = {}) => { ++ if (this._pendingAuthUrl) { ++ const m = String(message).match(/https?:\/\/[^\s\n]+/); ++ if (m) { ++ const resolve = this._pendingAuthUrl; ++ this._pendingAuthUrl = null; ++ resolve(m[0]); ++ } ++ } ++ }); + } + +@@ -120,9 +141,29 @@ + async newSession({ cwd, mcpServers, }) { + const sessionId = randomUUID(); + const loadedSettings = loadSettings(cwd); + const config = await this.newSessionConfig(sessionId, cwd, mcpServers, loadedSettings); const authType = loadedSettings.merged.security.auth.selectedType || AuthType.USE_GEMINI; let isAuthenticated = false; let authErrorMessage = ''; +- let authErrorData = undefined; + let authErrorData = undefined; ++ // The startup auth (in main()) may have cached a failed oauth client promise. ++ // Clear it so this session gets a fresh auth attempt with our ConsentRequest listener registered. ++ clearOauthClientCache(); ++ // Arm the pending URL capture slot. The persistent UserFeedback listener ++ // registered in the constructor will resolve this when authWithWeb emits ++ // the OAuth URL, allowing the race to win before loginCompletePromise times out. ++ const AUTH_DONE = Symbol('auth_done'); ++ const authUrlPromise = new Promise(resolve => { this._pendingAuthUrl = resolve; }); ++ const refreshPromise = config.refreshAuth(authType).then(() => AUTH_DONE); ++ // Attach a no-op catch so that if authUrlPromise wins the race the eventual ++ // timeout rejection from refreshPromise does not become an unhandled rejection. ++ refreshPromise.catch(() => {}); try { - await config.refreshAuth(authType); - isAuthenticated = true; -@@ -116,10 +117,13 @@ +- await config.refreshAuth(authType); +- isAuthenticated = true; ++ const winner = await Promise.race([refreshPromise, authUrlPromise]); ++ if (winner === AUTH_DONE) { ++ isAuthenticated = true; ++ // Extra validation for Gemini API key ++ const contentGeneratorConfig = config.getContentGeneratorConfig(); ++ if (authType === AuthType.USE_GEMINI && ++ (!contentGeneratorConfig || !contentGeneratorConfig.apiKey)) { ++ isAuthenticated = false; ++ authErrorMessage = 'Gemini API key is missing or not configured.'; ++ } ++ } else { ++ // URL captured from UserFeedback — auth is pending, surface the link ++ isAuthenticated = false; ++ authErrorMessage = 'Authentication required.'; ++ authErrorData = { validationLink: winner, validationDescription: 'Authorize \u2192' }; ++ } + } catch (e) { isAuthenticated = false; authErrorMessage = getAcpErrorMessage(e); @@ -17,9 +104,35 @@ + } debugLogger.error(`Authentication failed: ${e instanceof Error ? e.stack : e}`); } ++ finally { ++ this._pendingAuthUrl = null; ++ } if (!isAuthenticated) { - throw new acp.RequestError(401, authErrorMessage || 'Authentication required.'); + throw new acp.RequestError(401, authErrorMessage || 'Authentication required.', authErrorData); } - if (this.clientCapabilities?.fs) { - const acpFileSystemService = new AcpFileSystemService(this.connection, sessionId, this.clientCapabilities.fs, config.getFileSystemService()); + +@@ -209,10 +250,25 @@ + // 2. Authenticate BEFORE initializing configuration or starting MCP servers. + try { +- await config.refreshAuth(selectedAuthType); ++ const LOAD_AUTH_DONE = Symbol('load_auth_done'); ++ const loadUrlPromise = new Promise(resolve => { this._pendingAuthUrl = resolve; }); ++ const loadRefreshPromise = config.refreshAuth(selectedAuthType).then(() => LOAD_AUTH_DONE); ++ loadRefreshPromise.catch(() => {}); ++ const winner = await Promise.race([loadRefreshPromise, loadUrlPromise]); ++ if (winner !== LOAD_AUTH_DONE) { ++ throw acp.RequestError.authRequired({ validationLink: winner, validationDescription: 'Authorize \u2192' }); ++ } + } + catch (e) { + debugLogger.error(`Authentication failed: ${e}`); ++ if (e instanceof acp.RequestError) throw e; ++ if (e && typeof e === 'object' && 'validationLink' in e && e.validationLink) { ++ throw acp.RequestError.authRequired({ validationLink: e.validationLink, validationDescription: e.validationDescription, learnMoreUrl: e.learnMoreUrl }); ++ } + throw acp.RequestError.authRequired(); + } ++ finally { ++ this._pendingAuthUrl = null; ++ } From cf08e6f4e7c901c08879b36069d47484a141977c Mon Sep 17 00:00:00 2001 From: Teng Lin Date: Fri, 27 Feb 2026 20:44:16 -0500 Subject: [PATCH 5/6] fix: add TTY check to oauth2.js to prevent double-browser on OAuth callback When gemini-cli runs as a subprocess (piped stdin/stdout), calling open() internally AND having the parent UI open the same validationLink URL creates two browser tabs racing for the same loopback callback server. The first tab closes the server, so the second gets 'connection refused'. Following the GitHub CLI pattern (isInteractive = stdout.isTTY && stdin.isTTY), only open the browser when running in an interactive terminal. When running as a subprocess, the parent surfaces the URL and opens the browser itself. Update both the local binary patch and the upstream TypeScript patch. --- ...-cli-0.30.0-zed-auth-validation-link.patch | 70 ++++++ ...pstream-fix-zed-auth-validation-link.patch | 205 ++++++++++++++++++ 2 files changed, 275 insertions(+) create mode 100644 patches/gemini-cli-upstream-fix-zed-auth-validation-link.patch diff --git a/patches/gemini-cli-0.30.0-zed-auth-validation-link.patch b/patches/gemini-cli-0.30.0-zed-auth-validation-link.patch index 4333e48b..c0f95520 100644 --- a/patches/gemini-cli-0.30.0-zed-auth-validation-link.patch +++ b/patches/gemini-cli-0.30.0-zed-auth-validation-link.patch @@ -14,6 +14,76 @@ +} +export const coreEvents = globalThis.__geminiCoreEvents; +--- a/gemini-cli-0.30.0/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js ++++ b/gemini-cli-0.30.0/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js +@@ -204,16 +204,23 @@ + const webLogin = await authWithWeb(client); + coreEvents.emit(CoreEvent.UserFeedback, { + severity: 'info', + message: `\n\nCode Assist login required.\n` + + `Attempting to open authentication page in your browser.\n` + + `Otherwise navigate to:\n\n${webLogin.authUrl}\n\n\n`, + }); +- try { +- // Attempt to open the authentication URL in the default browser. +- // We do not use the `wait` option here because the main script's execution +- // is already paused by `loginCompletePromise`, which awaits the server callback. +- const childProcess = await open(webLogin.authUrl); +- // IMPORTANT: Attach an error handler to the returned child process. +- // Without this, if `open` fails to spawn a process (e.g., `xdg-open` is not found +- // in a minimal Docker container), it will emit an unhandled 'error' event, +- // causing the entire Node.js process to crash. +- childProcess.on('error', (error) => { +- coreEvents.emit(CoreEvent.UserFeedback, { +- severity: 'error', +- message: `Failed to open browser with error: ${getErrorMessage(error)}\n` + +- `Please try running again with NO_BROWSER=true set.`, +- }); +- }); +- } +- catch (err) { +- coreEvents.emit(CoreEvent.UserFeedback, { +- severity: 'error', +- message: `Failed to open browser with error: ${getErrorMessage(err)}\n` + +- `Please try running again with NO_BROWSER=true set.`, +- }); +- throw new FatalAuthenticationError(`Failed to open browser: ${getErrorMessage(err)}`); +- } ++ // Only open the browser when running in an interactive terminal. When ++ // stdin/stdout are piped (e.g. as a subprocess of an IDE such as Zed), ++ // the parent process surfaces the URL and opens the browser itself. ++ // Opening it here too would cause two tabs to race for the same OAuth ++ // callback; the first one closes the local server, so the second gets ++ // "connection refused". This mirrors the pattern used by GitHub CLI ++ // (isInteractive = stdout.isTTY && stdin.isTTY → skip open() if false). ++ if (process.stdout.isTTY && process.stdin.isTTY) { ++ try { ++ // Attempt to open the authentication URL in the default browser. ++ // We do not use the `wait` option here because the main script's execution ++ // is already paused by `loginCompletePromise`, which awaits the server callback. ++ const childProcess = await open(webLogin.authUrl); ++ // IMPORTANT: Attach an error handler to the returned child process. ++ // Without this, if `open` fails to spawn a process (e.g., `xdg-open` is not found ++ // in a minimal Docker container), it will emit an unhandled 'error' event, ++ // causing the entire Node.js process to crash. ++ childProcess.on('error', (error) => { ++ coreEvents.emit(CoreEvent.UserFeedback, { ++ severity: 'error', ++ message: `Failed to open browser with error: ${getErrorMessage(error)}\n` + ++ `Please try running again with NO_BROWSER=true set.`, ++ }); ++ }); ++ } ++ catch (err) { ++ coreEvents.emit(CoreEvent.UserFeedback, { ++ severity: 'error', ++ message: `Failed to open browser with error: ${getErrorMessage(err)}\n` + ++ `Please try running again with NO_BROWSER=true set.`, ++ }); ++ throw new FatalAuthenticationError(`Failed to open browser: ${getErrorMessage(err)}`); ++ } ++ } + --- a/gemini-cli-0.30.0/dist/src/zed-integration/zedIntegration.js +++ b/gemini-cli-0.30.0/dist/src/zed-integration/zedIntegration.js @@ -6,7 +6,7 @@ diff --git a/patches/gemini-cli-upstream-fix-zed-auth-validation-link.patch b/patches/gemini-cli-upstream-fix-zed-auth-validation-link.patch new file mode 100644 index 00000000..12433be7 --- /dev/null +++ b/patches/gemini-cli-upstream-fix-zed-auth-validation-link.patch @@ -0,0 +1,205 @@ +diff --git a/packages/cli/src/zed-integration/zedIntegration.test.ts b/packages/cli/src/zed-integration/zedIntegration.test.ts +index 37da303..1b29263 100644 +--- a/packages/cli/src/zed-integration/zedIntegration.test.ts ++++ b/packages/cli/src/zed-integration/zedIntegration.test.ts +@@ -22,6 +22,7 @@ import { + StreamEventType, + isWithinRoot, + ReadManyFilesTool, ++ ValidationRequiredError, + type GeminiChat, + type Config, + type MessageBus, +@@ -361,6 +362,54 @@ describe('GeminiAgent', () => { + debugSpy.mockRestore(); + }); + ++ it('should forward validationLink data when ValidationRequiredError is thrown in newSession', async () => { ++ const validationError = new ValidationRequiredError( ++ 'Validation required', ++ undefined, ++ 'https://example.com/validate', ++ 'Please verify your account', ++ 'https://example.com/learn-more', ++ ); ++ mockConfig.refreshAuth.mockRejectedValue(validationError); ++ vi.spyOn(console, 'error').mockImplementation(() => {}); ++ ++ const thrown = await agent ++ .newSession({ cwd: '/tmp', mcpServers: [] }) ++ .catch((e) => e); ++ ++ expect(thrown).toBeInstanceOf(acp.RequestError); ++ expect(thrown.code).toBe(-32000); ++ expect(thrown.data).toEqual({ ++ validationLink: 'https://example.com/validate', ++ validationDescription: 'Please verify your account', ++ learnMoreUrl: 'https://example.com/learn-more', ++ }); ++ }); ++ ++ it('should forward validationLink data when ValidationRequiredError is thrown in loadSession', async () => { ++ const validationError = new ValidationRequiredError( ++ 'Validation required', ++ undefined, ++ 'https://example.com/validate', ++ 'Please verify your account', ++ 'https://example.com/learn-more', ++ ); ++ mockConfig.refreshAuth.mockRejectedValue(validationError); ++ vi.spyOn(console, 'error').mockImplementation(() => {}); ++ ++ const thrown = await agent ++ .loadSession({ sessionId: 'test-session', cwd: '/tmp', mcpServers: [] }) ++ .catch((e) => e); ++ ++ expect(thrown).toBeInstanceOf(acp.RequestError); ++ expect(thrown.code).toBe(-32000); ++ expect(thrown.data).toEqual({ ++ validationLink: 'https://example.com/validate', ++ validationDescription: 'Please verify your account', ++ learnMoreUrl: 'https://example.com/learn-more', ++ }); ++ }); ++ + it('should initialize file system service if client supports it', async () => { + agent = new GeminiAgent(mockConfig, mockSettings, mockArgv, mockConnection); + await agent.initialize({ +diff --git a/packages/cli/src/zed-integration/zedIntegration.ts b/packages/cli/src/zed-integration/zedIntegration.ts +index e89a884..d82d78a 100644 +--- a/packages/cli/src/zed-integration/zedIntegration.ts ++++ b/packages/cli/src/zed-integration/zedIntegration.ts +@@ -39,6 +39,7 @@ import { + ApprovalMode, + getVersion, + convertSessionToClientHistory, ++ ValidationRequiredError, + } from '@google/gemini-cli-core'; + import * as acp from '@agentclientprotocol/sdk'; + import { AcpFileSystemService } from './fileSystemService.js'; +@@ -197,6 +198,13 @@ export class GeminiAgent { + + let isAuthenticated = false; + let authErrorMessage = ''; ++ let authErrorData: ++ | { ++ validationLink?: string; ++ validationDescription?: string; ++ learnMoreUrl?: string; ++ } ++ | undefined; + try { + await config.refreshAuth(authType, this.apiKey); + isAuthenticated = true; +@@ -213,6 +221,13 @@ export class GeminiAgent { + } catch (e) { + isAuthenticated = false; + authErrorMessage = getAcpErrorMessage(e); ++ if (e instanceof ValidationRequiredError) { ++ authErrorData = { ++ validationLink: e.validationLink, ++ validationDescription: e.validationDescription, ++ learnMoreUrl: e.learnMoreUrl, ++ }; ++ } + debugLogger.error( + `Authentication failed: ${e instanceof Error ? e.stack : e}`, + ); +@@ -222,6 +237,7 @@ export class GeminiAgent { + throw new acp.RequestError( + -32000, + authErrorMessage || 'Authentication required.', ++ authErrorData, + ); + } + +@@ -326,6 +342,13 @@ export class GeminiAgent { + await config.refreshAuth(selectedAuthType, this.apiKey); + } catch (e) { + debugLogger.error(`Authentication failed: ${e}`); ++ if (e instanceof ValidationRequiredError) { ++ throw acp.RequestError.authRequired({ ++ validationLink: e.validationLink, ++ validationDescription: e.validationDescription, ++ learnMoreUrl: e.learnMoreUrl, ++ }); ++ } + throw acp.RequestError.authRequired(); + } + +diff --git a/packages/core/src/code_assist/oauth2.ts b/packages/core/src/code_assist/oauth2.ts +--- a/packages/core/src/code_assist/oauth2.ts ++++ b/packages/core/src/code_assist/oauth2.ts +@@ -authWithWeb +authWithWeb @@ + coreEvents.emit(CoreEvent.UserFeedback, { + severity: 'info', + message: + `\n\nCode Assist login required.\n` + + `Attempting to open authentication page in your browser.\n` + + `Otherwise navigate to:\n\n${webLogin.authUrl}\n\n\n`, + }); +- try { +- // Attempt to open the authentication URL in the default browser. +- // We do not use the `wait` option here because the main script's execution +- // is already paused by `loginCompletePromise`, which awaits the server callback. +- const childProcess = await open(webLogin.authUrl); +- // IMPORTANT: Attach an error handler to the returned child process. +- // Without this, if `open` fails to spawn a process (e.g., `xdg-open` is not found +- // in a minimal Docker container), it will emit an unhandled 'error' event, +- // causing the entire Node.js process to crash. +- childProcess.on('error', (error) => { +- coreEvents.emit(CoreEvent.UserFeedback, { +- severity: 'error', +- message: +- `Failed to open browser with error: ${getErrorMessage(error)}\n` + +- `Please try running again with NO_BROWSER=true set.`, +- }); +- }); +- } catch (err) { +- coreEvents.emit(CoreEvent.UserFeedback, { +- severity: 'error', +- message: +- `Failed to open browser with error: ${getErrorMessage(err)}\n` + +- `Please try running again with NO_BROWSER=true set.`, +- }); +- throw new FatalAuthenticationError( +- `Failed to open browser: ${getErrorMessage(err)}`, +- ); +- } ++ // Only open the browser when running in an interactive terminal. When ++ // stdin/stdout are piped (e.g. as a subprocess of an IDE such as Zed), ++ // the parent process surfaces the URL via validationLink and opens the ++ // browser itself. Opening it here too would cause two tabs to race for ++ // the same OAuth callback; the first one closes the local server, so the ++ // second gets "connection refused". This mirrors the pattern used by ++ // GitHub CLI (isInteractive = stdout.isTTY && stdin.isTTY). ++ if (process.stdout.isTTY && process.stdin.isTTY) { ++ try { ++ // Attempt to open the authentication URL in the default browser. ++ // We do not use the `wait` option here because the main script's execution ++ // is already paused by `loginCompletePromise`, which awaits the server callback. ++ const childProcess = await open(webLogin.authUrl); ++ // IMPORTANT: Attach an error handler to the returned child process. ++ // Without this, if `open` fails to spawn a process (e.g., `xdg-open` is not found ++ // in a minimal Docker container), it will emit an unhandled 'error' event, ++ // causing the entire Node.js process to crash. ++ childProcess.on('error', (error) => { ++ coreEvents.emit(CoreEvent.UserFeedback, { ++ severity: 'error', ++ message: ++ `Failed to open browser with error: ${getErrorMessage(error)}\n` + ++ `Please try running again with NO_BROWSER=true set.`, ++ }); ++ }); ++ } catch (err) { ++ coreEvents.emit(CoreEvent.UserFeedback, { ++ severity: 'error', ++ message: ++ `Failed to open browser with error: ${getErrorMessage(err)}\n` + ++ `Please try running again with NO_BROWSER=true set.`, ++ }); ++ throw new FatalAuthenticationError( ++ `Failed to open browser: ${getErrorMessage(err)}`, ++ ); ++ } ++ } From 87caca1ce4d34a0d54e152fcb4dca177d6984fcf Mon Sep 17 00:00:00 2001 From: Teng Lin Date: Fri, 27 Feb 2026 21:35:35 -0500 Subject: [PATCH 6/6] fix: reuse in-flight OAuth loopback server across session/new retries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When newSession() captured a validationLink and returned 401, the background config.refreshAuth() promise kept the loopback HTTP server alive on a specific port. A subsequent session/new retry called clearOauthClientCache() unconditionally, which abandoned that server and started a fresh auth flow on a NEW port — causing "connection refused" when the browser tried to hit the original callback URL. Fix: track the in-flight refreshPromise in _pendingOAuthRefresh. On retry, await it (the server is still listening) then re-run config.refreshAuth() on the new config instance to load the freshly cached credentials. clearOauthClientCache() is only called when no OAuth flow is in progress. --- ...-cli-0.30.0-zed-auth-validation-link.patch | 35 +++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/patches/gemini-cli-0.30.0-zed-auth-validation-link.patch b/patches/gemini-cli-0.30.0-zed-auth-validation-link.patch index c0f95520..a628283f 100644 --- a/patches/gemini-cli-0.30.0-zed-auth-validation-link.patch +++ b/patches/gemini-cli-0.30.0-zed-auth-validation-link.patch @@ -90,7 +90,7 @@ -import { CoreToolCallStatus, AuthType, logToolCall, convertToFunctionResponse, ToolConfirmationOutcome, clearCachedCredentialFile, isNodeError, getErrorMessage, isWithinRoot, getErrorStatus, MCPServerConfig, DiscoveredMCPTool, StreamEventType, ToolCallEvent, debugLogger, ReadManyFilesTool, REFERENCE_CONTENT_START, resolveModel, createWorkingStdio, startupProfiler, Kind, partListUnionToString, LlmRole, coreEvents, CoreEvent, } from '@google/gemini-cli-core'; +import { CoreToolCallStatus, AuthType, logToolCall, convertToFunctionResponse, ToolConfirmationOutcome, clearCachedCredentialFile, isNodeError, getErrorMessage, isWithinRoot, getErrorStatus, MCPServerConfig, DiscoveredMCPTool, StreamEventType, ToolCallEvent, debugLogger, ReadManyFilesTool, REFERENCE_CONTENT_START, resolveModel, createWorkingStdio, startupProfiler, Kind, partListUnionToString, LlmRole, coreEvents, CoreEvent, writeToStderr, clearOauthClientCache, } from '@google/gemini-cli-core'; -@@ -31,6 +31,27 @@ +@@ -31,6 +31,34 @@ export class GeminiAgent { config; settings; @@ -101,6 +101,11 @@ + // Pending resolver for capturing an OAuth URL from UserFeedback events. + // Set to a Promise resolve function before calling refreshAuth; cleared after. + _pendingAuthUrl = null; ++ // In-flight config.refreshAuth() promise when a validationLink was returned to the ++ // client. Kept so that a retry session/new reuses the same loopback server (same ++ // port) rather than clearing the cache and starting a fresh server on a new port, ++ // which would make the callback URL the client already opened unreachable. ++ _pendingOAuthRefresh = null; constructor(config, settings, argv, connection) { this.config = config; this.settings = settings; @@ -124,7 +129,7 @@ + }); } -@@ -120,9 +141,29 @@ +@@ -120,9 +155,55 @@ async newSession({ cwd, mcpServers, }) { const sessionId = randomUUID(); const loadedSettings = loadSettings(cwd); @@ -134,6 +139,24 @@ let authErrorMessage = ''; - let authErrorData = undefined; + let authErrorData = undefined; ++ // If a previous session/new returned a validationLink and the loopback OAuth ++ // server is still running, await that in-flight promise instead of calling ++ // clearOauthClientCache() (which kills the local HTTP server and makes the ++ // callback URL the client opened unreachable on a different port). ++ if (this._pendingOAuthRefresh) { ++ try { ++ await this._pendingOAuthRefresh; ++ this._pendingOAuthRefresh = null; ++ // Credentials are now cached on disk; authenticate the new config from cache. ++ await config.refreshAuth(authType); ++ isAuthenticated = true; ++ } ++ catch (e) { ++ this._pendingOAuthRefresh = null; ++ debugLogger.error(`Pending OAuth auth failed: ${e instanceof Error ? e.stack : e}`); ++ } ++ } ++ if (!isAuthenticated) { + // The startup auth (in main()) may have cached a failed oauth client promise. + // Clear it so this session gets a fresh auth attempt with our ConsentRequest listener registered. + clearOauthClientCache(); @@ -152,6 +175,7 @@ + const winner = await Promise.race([refreshPromise, authUrlPromise]); + if (winner === AUTH_DONE) { + isAuthenticated = true; ++ this._pendingOAuthRefresh = null; + // Extra validation for Gemini API key + const contentGeneratorConfig = config.getContentGeneratorConfig(); + if (authType === AuthType.USE_GEMINI && @@ -160,14 +184,18 @@ + authErrorMessage = 'Gemini API key is missing or not configured.'; + } + } else { -+ // URL captured from UserFeedback — auth is pending, surface the link ++ // URL captured from UserFeedback — auth is pending, surface the link. ++ // Store refreshPromise so the next session/new retry awaits the same ++ // loopback server (same port) rather than starting a new one. + isAuthenticated = false; + authErrorMessage = 'Authentication required.'; + authErrorData = { validationLink: winner, validationDescription: 'Authorize \u2192' }; ++ this._pendingOAuthRefresh = refreshPromise; + } } catch (e) { isAuthenticated = false; ++ this._pendingOAuthRefresh = null; authErrorMessage = getAcpErrorMessage(e); + if (e && typeof e === 'object' && 'validationLink' in e && e.validationLink) { + authErrorData = { validationLink: e.validationLink, validationDescription: e.validationDescription, learnMoreUrl: e.learnMoreUrl }; @@ -177,6 +205,7 @@ + finally { + this._pendingAuthUrl = null; + } ++ } // end if (!isAuthenticated) if (!isAuthenticated) { - throw new acp.RequestError(401, authErrorMessage || 'Authentication required.'); + throw new acp.RequestError(401, authErrorMessage || 'Authentication required.', authErrorData);