Skip to content
This repository was archived by the owner on Feb 14, 2026. It is now read-only.

Commit 5a21dd1

Browse files
committed
feat(gemini): add Google Cloud Project support and improve error handling
- Add `happy gemini project set/get` commands for Workspace accounts - Store project ID per-account (linked to email) - Show helpful error message for "Authentication required" with guide link - Sync local ~/.gemini/oauth_creds.json on `happy connect gemini` - Add `happy connect status` command to show vendor auth status - Pass GOOGLE_CLOUD_PROJECT env var to Gemini SDK
1 parent 84acf3b commit 5a21dd1

6 files changed

Lines changed: 382 additions & 6 deletions

File tree

src/agent/factories/gemini.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ export interface GeminiBackendOptions extends AgentFactoryOptions {
3535
/** OAuth token from Happy cloud (via 'happy connect gemini') - highest priority */
3636
cloudToken?: string;
3737

38+
/** Current user email (from OAuth id_token) - used to match per-account project ID */
39+
currentUserEmail?: string;
40+
3841
/** Model to use. If undefined, will use local config, env var, or default.
3942
* If explicitly set to null, will use default (skip local config).
4043
* (defaults to GEMINI_MODEL env var or 'gemini-2.5-pro') */
@@ -92,6 +95,22 @@ export function createGeminiBackend(options: GeminiBackendOptions): AgentBackend
9295
// We don't use --model flag to avoid potential stdout conflicts with ACP protocol
9396
const geminiArgs = ['--experimental-acp'];
9497

98+
// Get Google Cloud Project from local config (for Workspace accounts)
99+
// Only use if: no email stored (global), or email matches current user
100+
let googleCloudProject: string | null = null;
101+
if (localConfig.googleCloudProject) {
102+
const storedEmail = localConfig.googleCloudProjectEmail;
103+
const currentEmail = options.currentUserEmail;
104+
105+
// Use project if: no email stored (applies to all), or emails match
106+
if (!storedEmail || storedEmail === currentEmail) {
107+
googleCloudProject = localConfig.googleCloudProject;
108+
logger.debug(`[Gemini] Using Google Cloud Project: ${googleCloudProject}${storedEmail ? ` (for ${storedEmail})` : ' (global)'}`);
109+
} else {
110+
logger.debug(`[Gemini] Skipping stored Google Cloud Project (stored for ${storedEmail}, current user is ${currentEmail || 'unknown'})`);
111+
}
112+
}
113+
95114
const backendOptions: AcpBackendOptions = {
96115
agentName: 'gemini',
97116
cwd: options.cwd,
@@ -102,6 +121,11 @@ export function createGeminiBackend(options: GeminiBackendOptions): AgentBackend
102121
...(apiKey ? { [GEMINI_API_KEY_ENV]: apiKey, [GOOGLE_API_KEY_ENV]: apiKey } : {}),
103122
// Pass model via env var - gemini CLI reads GEMINI_MODEL automatically
104123
[GEMINI_MODEL_ENV]: model,
124+
// Pass Google Cloud Project for Workspace accounts
125+
...(googleCloudProject ? {
126+
GOOGLE_CLOUD_PROJECT: googleCloudProject,
127+
GOOGLE_CLOUD_PROJECT_ID: googleCloudProject,
128+
} : {}),
105129
// Suppress debug output from gemini CLI to avoid stdout pollution
106130
NODE_ENV: 'production',
107131
DEBUG: '',

src/commands/connect.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import chalk from 'chalk';
2+
import { existsSync, mkdirSync, writeFileSync } from 'fs';
3+
import { homedir } from 'os';
4+
import { join } from 'path';
25
import { readCredentials } from '@/persistence';
36
import { ApiClient } from '@/api/api';
47
import { authenticateCodex } from './connect/authenticateCodex';
58
import { authenticateClaude } from './connect/authenticateClaude';
69
import { authenticateGemini } from './connect/authenticateGemini';
10+
import { decodeJwtPayload } from './connect/utils';
711

812
/**
913
* Handle connect subcommand
@@ -32,6 +36,9 @@ export async function handleConnectCommand(args: string[]): Promise<void> {
3236
case 'gemini':
3337
await handleConnectVendor('gemini', 'Gemini');
3438
break;
39+
case 'status':
40+
await handleConnectStatus();
41+
break;
3542
default:
3643
console.error(chalk.red(`Unknown connect target: ${subcommand}`));
3744
showConnectHelp();
@@ -47,6 +54,7 @@ ${chalk.bold('Usage:')}
4754
happy connect codex Store your Codex API key in Happy cloud
4855
happy connect claude Store your Anthropic API key in Happy cloud
4956
happy connect gemini Store your Gemini API key in Happy cloud
57+
happy connect status Show connection status for all vendors
5058
happy connect help Show this help message
5159
5260
${chalk.bold('Description:')}
@@ -58,6 +66,7 @@ ${chalk.bold('Examples:')}
5866
happy connect codex
5967
happy connect claude
6068
happy connect gemini
69+
happy connect status
6170
6271
${chalk.bold('Notes:')}
6372
• You must be authenticated with Happy first (run 'happy auth login')
@@ -98,8 +107,113 @@ async function handleConnectVendor(vendor: 'codex' | 'claude' | 'gemini', displa
98107
const geminiAuthTokens = await authenticateGemini();
99108
await api.registerVendorToken('gemini', { oauth: geminiAuthTokens });
100109
console.log('✅ Gemini token registered with server');
110+
111+
// Also update local Gemini config to keep tokens in sync
112+
updateLocalGeminiCredentials(geminiAuthTokens);
113+
101114
process.exit(0);
102115
} else {
103116
throw new Error(`Unsupported vendor: ${vendor}`);
104117
}
118+
}
119+
120+
/**
121+
* Show connection status for all vendors
122+
*/
123+
async function handleConnectStatus(): Promise<void> {
124+
console.log(chalk.bold('\n🔌 Connection Status\n'));
125+
126+
// Check if authenticated
127+
const credentials = await readCredentials();
128+
if (!credentials) {
129+
console.log(chalk.yellow('⚠️ Not authenticated with Happy'));
130+
console.log(chalk.gray(' Please run "happy auth login" first'));
131+
process.exit(1);
132+
}
133+
134+
// Create API client
135+
const api = await ApiClient.create(credentials);
136+
137+
// Check each vendor
138+
const vendors: Array<{ key: 'openai' | 'anthropic' | 'gemini'; name: string; display: string }> = [
139+
{ key: 'gemini', name: 'Gemini', display: 'Google Gemini' },
140+
{ key: 'openai', name: 'Codex', display: 'OpenAI Codex' },
141+
{ key: 'anthropic', name: 'Claude', display: 'Anthropic Claude' },
142+
];
143+
144+
for (const vendor of vendors) {
145+
try {
146+
const token = await api.getVendorToken(vendor.key);
147+
148+
if (token?.oauth) {
149+
// Try to extract user info from id_token (JWT)
150+
let userInfo = '';
151+
152+
if (token.oauth.id_token) {
153+
const payload = decodeJwtPayload(token.oauth.id_token);
154+
if (payload?.email) {
155+
userInfo = chalk.gray(` (${payload.email})`);
156+
}
157+
}
158+
159+
// Check if token might be expired
160+
const expiresAt = token.oauth.expires_at || (token.oauth.expires_in ? Date.now() + token.oauth.expires_in * 1000 : null);
161+
const isExpired = expiresAt && expiresAt < Date.now();
162+
163+
if (isExpired) {
164+
console.log(` ${chalk.yellow('⚠️')} ${vendor.display}: ${chalk.yellow('expired')}${userInfo}`);
165+
} else {
166+
console.log(` ${chalk.green('✓')} ${vendor.display}: ${chalk.green('connected')}${userInfo}`);
167+
}
168+
} else {
169+
console.log(` ${chalk.gray('○')} ${vendor.display}: ${chalk.gray('not connected')}`);
170+
}
171+
} catch {
172+
console.log(` ${chalk.gray('○')} ${vendor.display}: ${chalk.gray('not connected')}`);
173+
}
174+
}
175+
176+
console.log('');
177+
console.log(chalk.gray('To connect a vendor, run: happy connect <vendor>'));
178+
console.log(chalk.gray('Example: happy connect gemini'));
179+
console.log('');
180+
}
181+
182+
/**
183+
* Update local Gemini credentials file to keep in sync with Happy cloud
184+
* This ensures the Gemini SDK uses the same account as Happy
185+
*/
186+
function updateLocalGeminiCredentials(tokens: {
187+
access_token: string;
188+
refresh_token?: string;
189+
id_token?: string;
190+
expires_in?: number;
191+
token_type?: string;
192+
scope?: string;
193+
}): void {
194+
try {
195+
const geminiDir = join(homedir(), '.gemini');
196+
const credentialsPath = join(geminiDir, 'oauth_creds.json');
197+
198+
// Create directory if it doesn't exist
199+
if (!existsSync(geminiDir)) {
200+
mkdirSync(geminiDir, { recursive: true });
201+
}
202+
203+
// Write credentials in the format Gemini CLI expects
204+
const credentials = {
205+
access_token: tokens.access_token,
206+
token_type: tokens.token_type || 'Bearer',
207+
scope: tokens.scope || 'https://www.googleapis.com/auth/cloud-platform',
208+
...(tokens.refresh_token && { refresh_token: tokens.refresh_token }),
209+
...(tokens.id_token && { id_token: tokens.id_token }),
210+
...(tokens.expires_in && { expires_in: tokens.expires_in }),
211+
};
212+
213+
writeFileSync(credentialsPath, JSON.stringify(credentials, null, 2), 'utf-8');
214+
console.log(chalk.gray(` Updated local credentials: ${credentialsPath}`));
215+
} catch (error) {
216+
// Non-critical error - server tokens will still work
217+
console.log(chalk.yellow(` ⚠️ Could not update local credentials: ${error}`));
218+
}
105219
}

src/commands/connect/utils.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/**
2+
* Utility functions for connect commands
3+
*/
4+
5+
/**
6+
* Decode JWT payload without verification
7+
* Used to extract user info (email) from id_token
8+
*
9+
* @param token - JWT token string
10+
* @returns Decoded payload or null if invalid
11+
*/
12+
export function decodeJwtPayload(token: string): Record<string, unknown> | null {
13+
try {
14+
const parts = token.split('.');
15+
if (parts.length !== 3) {
16+
return null;
17+
}
18+
19+
// JWT payload is the second part, base64url encoded
20+
const payload = parts[1];
21+
22+
// Decode base64url to JSON
23+
const decoded = Buffer.from(payload, 'base64url').toString('utf-8');
24+
return JSON.parse(decoded);
25+
} catch {
26+
return null;
27+
}
28+
}
29+

src/gemini/runGemini.ts

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -94,11 +94,28 @@ export async function runGemini(opts: {
9494
// Fetch Gemini cloud token (from 'happy connect gemini')
9595
//
9696
let cloudToken: string | undefined = undefined;
97+
let currentUserEmail: string | undefined = undefined;
9798
try {
9899
const vendorToken = await api.getVendorToken('gemini');
99100
if (vendorToken?.oauth?.access_token) {
100101
cloudToken = vendorToken.oauth.access_token;
101102
logger.debug('[Gemini] Using OAuth token from Happy cloud');
103+
104+
// Extract email from id_token for per-account project matching
105+
if (vendorToken.oauth.id_token) {
106+
try {
107+
const parts = vendorToken.oauth.id_token.split('.');
108+
if (parts.length === 3) {
109+
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
110+
if (payload.email) {
111+
currentUserEmail = payload.email;
112+
logger.debug(`[Gemini] Current user email: ${currentUserEmail}`);
113+
}
114+
}
115+
} catch {
116+
logger.debug('[Gemini] Failed to decode id_token for email');
117+
}
118+
}
102119
}
103120
} catch (error) {
104121
logger.debug('[Gemini] Failed to fetch cloud token:', error);
@@ -514,12 +531,15 @@ export async function runGemini(opts: {
514531
break;
515532

516533
case 'status':
517-
// Log status changes for debugging
518-
logger.debug(`[gemini] Status changed: ${msg.status}${msg.detail ? ` - ${msg.detail}` : ''}`);
534+
// Log status changes for debugging - stringify object details
535+
const statusDetail = msg.detail
536+
? (typeof msg.detail === 'object' ? JSON.stringify(msg.detail) : String(msg.detail))
537+
: '';
538+
logger.debug(`[gemini] Status changed: ${msg.status}${statusDetail ? ` - ${statusDetail}` : ''}`);
519539

520540
// Log error status with details
521541
if (msg.status === 'error') {
522-
logger.debug(`[gemini] ⚠️ Error status received: ${msg.detail || 'Unknown error'}`);
542+
logger.debug(`[gemini] ⚠️ Error status received: ${statusDetail || 'Unknown error'}`);
523543

524544
// Send turn_aborted event (like Codex) when error occurs
525545
session.sendAgentMessage('gemini', {
@@ -567,8 +587,28 @@ export async function runGemini(opts: {
567587
isResponseInProgress = false;
568588
currentResponseMessageId = null;
569589

570-
// Show error in CLI UI
571-
const errorMessage = msg.detail || 'Unknown error';
590+
// Show error in CLI UI - handle object errors properly
591+
let errorMessage = 'Unknown error';
592+
if (msg.detail) {
593+
if (typeof msg.detail === 'object') {
594+
// Extract message from error object
595+
const detailObj = msg.detail as Record<string, unknown>;
596+
errorMessage = (detailObj.message as string) ||
597+
(detailObj.details as string) ||
598+
JSON.stringify(detailObj);
599+
} else {
600+
errorMessage = String(msg.detail);
601+
}
602+
}
603+
604+
// Check for authentication error and provide helpful message
605+
if (errorMessage.includes('Authentication required')) {
606+
errorMessage = `Authentication required.\n` +
607+
`For Google Workspace accounts, run: happy gemini project set <project-id>\n` +
608+
`Or use a different Google account: happy connect gemini\n` +
609+
`Guide: https://goo.gle/gemini-cli-auth-docs#workspace-gca`;
610+
}
611+
572612
messageBuffer.addMessage(`Error: ${errorMessage}`, 'status');
573613

574614
// Use sendAgentMessage for consistency with ACP format
@@ -879,6 +919,7 @@ export async function runGemini(opts: {
879919
mcpServers,
880920
permissionHandler,
881921
cloudToken,
922+
currentUserEmail,
882923
// Pass model from message - if undefined, will use local config/env/default
883924
// If explicitly null, will skip local config and use env/default
884925
model: modelToUse,
@@ -930,6 +971,7 @@ export async function runGemini(opts: {
930971
mcpServers,
931972
permissionHandler,
932973
cloudToken,
974+
currentUserEmail,
933975
// Pass model from message - if undefined, will use local config/env/default
934976
// If explicitly null, will skip local config and use env/default
935977
model: modelToUse,
@@ -1133,6 +1175,15 @@ export async function runGemini(opts: {
11331175
}
11341176
errorMsg = `Gemini quota exceeded.${resetTimeMsg} Try using a different model (gemini-2.5-flash-lite) or wait for quota reset.`;
11351177
}
1178+
// Check for authentication error (Google Workspace accounts need project ID)
1179+
else if (errorMessage.includes('Authentication required') ||
1180+
errorDetails.includes('Authentication required') ||
1181+
errorCode === -32000) {
1182+
errorMsg = `Authentication required. For Google Workspace accounts, you need to set a Google Cloud Project:\n` +
1183+
` happy gemini project set <your-project-id>\n` +
1184+
`Or use a different Google account: happy connect gemini\n` +
1185+
`Guide: https://goo.gle/gemini-cli-auth-docs#workspace-gca`;
1186+
}
11361187
// Check for empty error (command not found)
11371188
else if (Object.keys(error).length === 0) {
11381189
errorMsg = 'Failed to start Gemini. Is "gemini" CLI installed? Run: npm install -g @google/gemini-cli';

0 commit comments

Comments
 (0)