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..a628283f --- /dev/null +++ b/patches/gemini-cli-0.30.0-zed-auth-validation-link.patch @@ -0,0 +1,237 @@ +--- 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/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 @@ +-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,34 @@ + 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; ++ // 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; + 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 +155,55 @@ + 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; ++ // 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(); ++ // 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; ++ 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 && ++ (!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. ++ // 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 }; ++ } + debugLogger.error(`Authentication failed: ${e instanceof Error ? e.stack : e}`); + } ++ 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); + } + +@@ -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; ++ } 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)}`, ++ ); ++ } ++ } 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 bc0cb0f8..c4bdb6be 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}`))); + 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 0f39ce1d..bae1bc4d 100644 --- a/src/http/api-sessions.ts +++ b/src/http/api-sessions.ts @@ -1,9 +1,16 @@ 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"; +interface AcpAuthErrorData { + validationLink?: string; + validationDescription?: string; + learnMoreUrl?: string; +} + const MAX_BODY_BYTES = 1024 * 1024; // 1 MB function readBody(req: IncomingMessage): Promise { @@ -94,9 +101,21 @@ export function handleApiSessions( }); json(res, 201, result); } catch (err) { - json(res, 500, { - error: `Failed to create session: ${err instanceof Error ? err.message : err}`, - }); + // 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, + 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..685a5f50 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -2,6 +2,31 @@ 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, + { + validationLink, + validationDescription, + learnMoreUrl, + }: { + validationLink?: string; + validationDescription?: string; + learnMoreUrl?: string; + }, + ) { + super(message); + this.name = "AuthRequiredError"; + this.validationLink = validationLink; + this.validationDescription = validationDescription; + this.learnMoreUrl = learnMoreUrl; + } +} + function getApiKey(): string | null { return ( document.querySelector('meta[name="beamcode-api-token"]')?.content ?? @@ -32,7 +57,22 @@ 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) { + 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}`); + } return res.json(); } diff --git a/web/src/components/NewSessionDialog.tsx b/web/src/components/NewSessionDialog.tsx index c8469d4b..0102103d 100644 --- a/web/src/components/NewSessionDialog.tsx +++ b/web/src/components/NewSessionDialog.tsx @@ -1,9 +1,15 @@ 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"; +interface CreationError { + message: string; + validationLink?: string | null; + validationDescription?: string | null; +} + export const ADAPTER_LABELS: Record = { claude: "Claude", codex: "Codex", @@ -32,7 +38,7 @@ export function NewSessionDialog() { const [model, setModel] = useState(""); const [cwd, setCwd] = useState(""); const [creating, setCreating] = useState(false); - const [error, setError] = useState(null); + const [error, setError] = useState(null); const newButtonRef = useRef(null); const firstButtonRef = useRef(null); @@ -75,7 +81,15 @@ 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({ + message: err.message, + validationLink: err.validationLink, + validationDescription: err.validationDescription, + }); + } else { + setError({ message: err instanceof Error ? err.message : "Failed to create session" }); + } } finally { setCreating(false); } @@ -169,12 +183,22 @@ export function NewSessionDialog() { {error && ( -

- {error} -

+

{error.message}

+ {error.validationLink && ( + + {error.validationDescription ?? "Authorize →"} + + )} + )}