mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-13 01:22:06 +08:00
fix(gemini): align headless session/auth flow with async CLI behavior
Why: - Gemini does not expose a new session id synchronously. It emits the canonical id in the init stream event. - Creating temporary ids in web mode introduced identity drift, extra mapping logic, and harder resume/debug behavior. - Headless server runs often miss shell-inherited auth vars, while users configure Gemini through user-level env files. - Gemini mirrors session artifacts across folders, which caused duplicate sync events and duplicate session rows. What changed: - Removed temporary Gemini ids for new sessions. - New Gemini sessions are now created only after init provides session_id. - Persisted cliSessionId from the discovered canonical id, keeping one identifier across stream, storage, and resume. - Built Gemini spawn env from process env plus user-level fallback files: ~/.gemini/.env then ~/.env, honoring GEMINI_CLI_HOME. - Added --skip-trust for headless runs, because web flows cannot answer interactive trust prompts. - Improved terminal error mapping and rejection reasons, especially for auth exit code 41 with actionable context. - Limited Gemini synchronization to tmp JSONL chat artifacts, and disabled duplicate watcher/index paths that mirror the same sessions. - Added gemini-2.5-flash-lite to shared model constants. Result: - Gemini session identity is canonical and provider-consistent. - Headless auth now matches practical Gemini CLI configuration patterns. - Duplicate Gemini session indexing is reduced at the source. - Operators get clearer, actionable failure messages.
This commit is contained in:
@@ -1,19 +1,125 @@
|
|||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
import crossSpawn from 'cross-spawn';
|
import crossSpawn from 'cross-spawn';
|
||||||
|
|
||||||
// Use cross-spawn on Windows for correct .cmd resolution (same pattern as cursor-cli.js)
|
|
||||||
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
|
||||||
import { promises as fs } from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
import os from 'os';
|
|
||||||
import sessionManager from './sessionManager.js';
|
import sessionManager from './sessionManager.js';
|
||||||
import GeminiResponseHandler from './gemini-response-handler.js';
|
import GeminiResponseHandler from './gemini-response-handler.js';
|
||||||
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
||||||
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
||||||
import { createNormalizedMessage } from './shared/utils.js';
|
import { createNormalizedMessage } from './shared/utils.js';
|
||||||
|
|
||||||
|
// Use cross-spawn on Windows for correct .cmd resolution (same pattern as cursor-cli.js)
|
||||||
|
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||||
|
|
||||||
let activeGeminiProcesses = new Map(); // Track active processes by session ID
|
let activeGeminiProcesses = new Map(); // Track active processes by session ID
|
||||||
|
|
||||||
|
function mapGeminiExitCodeToMessage(exitCode) {
|
||||||
|
switch (exitCode) {
|
||||||
|
case 41:
|
||||||
|
return 'Gemini authentication failed (exit code 41). Run `gemini` in a terminal to choose an auth method, or configure a valid `GEMINI_API_KEY`.';
|
||||||
|
case 42:
|
||||||
|
return 'Gemini rejected the request input (exit code 42).';
|
||||||
|
case 44:
|
||||||
|
return 'Gemini sandbox error (exit code 44). Check local sandbox/container settings.';
|
||||||
|
case 52:
|
||||||
|
return 'Gemini configuration error (exit code 52). Check your Gemini settings files for invalid JSON/config.';
|
||||||
|
case 53:
|
||||||
|
return 'Gemini conversation turn limit reached (exit code 53). Start a new Gemini session.';
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const GEMINI_AUTH_ENV_KEYS = [
|
||||||
|
'GEMINI_API_KEY',
|
||||||
|
'GOOGLE_API_KEY',
|
||||||
|
'GOOGLE_CLOUD_PROJECT',
|
||||||
|
'GOOGLE_CLOUD_PROJECT_ID',
|
||||||
|
'GOOGLE_CLOUD_LOCATION',
|
||||||
|
'GOOGLE_APPLICATION_CREDENTIALS'
|
||||||
|
];
|
||||||
|
|
||||||
|
function parseEnvFileContent(content) {
|
||||||
|
const parsed = {};
|
||||||
|
|
||||||
|
for (const rawLine of content.split(/\r?\n/)) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line || line.startsWith('#')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportPrefix = 'export ';
|
||||||
|
const normalizedLine = line.startsWith(exportPrefix) ? line.slice(exportPrefix.length).trim() : line;
|
||||||
|
const separatorIndex = normalizedLine.indexOf('=');
|
||||||
|
|
||||||
|
if (separatorIndex <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = normalizedLine.slice(0, separatorIndex).trim();
|
||||||
|
if (!key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let value = normalizedLine.slice(separatorIndex + 1).trim();
|
||||||
|
const hasDoubleQuotes = value.startsWith('"') && value.endsWith('"');
|
||||||
|
const hasSingleQuotes = value.startsWith('\'') && value.endsWith('\'');
|
||||||
|
|
||||||
|
if (hasDoubleQuotes || hasSingleQuotes) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
} else {
|
||||||
|
// Support inline comments in unquoted values: KEY=value # comment
|
||||||
|
value = value.replace(/\s+#.*$/, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGeminiUserLevelEnv() {
|
||||||
|
const geminiCliHome = (process.env.GEMINI_CLI_HOME || '').trim() || os.homedir();
|
||||||
|
const envCandidates = [
|
||||||
|
path.join(geminiCliHome, '.gemini', '.env'),
|
||||||
|
path.join(geminiCliHome, '.env')
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const envPath of envCandidates) {
|
||||||
|
try {
|
||||||
|
await fs.access(envPath);
|
||||||
|
const content = await fs.readFile(envPath, 'utf8');
|
||||||
|
return parseEnvFileContent(content);
|
||||||
|
} catch {
|
||||||
|
// Keep scanning for the next candidate.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildGeminiProcessEnv() {
|
||||||
|
const processEnv = { ...process.env };
|
||||||
|
if (GEMINI_AUTH_ENV_KEYS.every((key) => processEnv[key])) {
|
||||||
|
return processEnv;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gemini CLI docs recommend ~/.gemini/.env for persistent headless auth settings.
|
||||||
|
// When the server process was launched without shell profile variables, we still
|
||||||
|
// want the spawned CLI process to inherit those user-level credentials.
|
||||||
|
const userEnv = await loadGeminiUserLevelEnv();
|
||||||
|
for (const key of GEMINI_AUTH_ENV_KEYS) {
|
||||||
|
if (!processEnv[key] && userEnv[key]) {
|
||||||
|
processEnv[key] = userEnv[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return processEnv;
|
||||||
|
}
|
||||||
|
|
||||||
async function spawnGemini(command, options = {}, ws) {
|
async function spawnGemini(command, options = {}, ws) {
|
||||||
const { sessionId, projectPath, cwd, toolsSettings, permissionMode, images, sessionSummary } = options;
|
const { sessionId, projectPath, cwd, toolsSettings, permissionMode, images, sessionSummary } = options;
|
||||||
let capturedSessionId = sessionId; // Track session ID throughout the process
|
let capturedSessionId = sessionId; // Track session ID throughout the process
|
||||||
@@ -100,6 +206,11 @@ async function spawnGemini(command, options = {}, ws) {
|
|||||||
args.push('--debug');
|
args.push('--debug');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This integration runs Gemini in headless mode and cannot answer trust prompts.
|
||||||
|
// Skip folder-trust interactivity so authenticated runs don't fail with
|
||||||
|
// FatalUntrustedWorkspaceError in previously unseen directories.
|
||||||
|
args.push('--skip-trust');
|
||||||
|
|
||||||
// Add MCP config flag only if MCP servers are configured
|
// Add MCP config flag only if MCP servers are configured
|
||||||
try {
|
try {
|
||||||
const geminiConfigPath = path.join(os.homedir(), '.gemini.json');
|
const geminiConfigPath = path.join(os.homedir(), '.gemini.json');
|
||||||
@@ -168,11 +279,13 @@ async function spawnGemini(command, options = {}, ws) {
|
|||||||
spawnArgs = ['-c', 'exec "$0" "$@"', geminiPath, ...args];
|
spawnArgs = ['-c', 'exec "$0" "$@"', geminiPath, ...args];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const spawnEnv = await buildGeminiProcessEnv();
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const geminiProcess = spawnFunction(spawnCmd, spawnArgs, {
|
const geminiProcess = spawnFunction(spawnCmd, spawnArgs, {
|
||||||
cwd: workingDir,
|
cwd: workingDir,
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
env: { ...process.env } // Inherit all environment variables
|
env: spawnEnv
|
||||||
});
|
});
|
||||||
let terminalNotificationSent = false;
|
let terminalNotificationSent = false;
|
||||||
let terminalFailureReason = null;
|
let terminalFailureReason = null;
|
||||||
@@ -276,12 +389,43 @@ async function spawnGemini(command, options = {}, ws) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onInit: (event) => {
|
onInit: (event) => {
|
||||||
if (capturedSessionId) {
|
const discoveredSessionId = event?.session_id;
|
||||||
const sess = sessionManager.getSession(capturedSessionId);
|
if (!discoveredSessionId) {
|
||||||
if (sess && !sess.cliSessionId) {
|
return;
|
||||||
sess.cliSessionId = event.session_id;
|
}
|
||||||
sessionManager.saveSession(capturedSessionId);
|
|
||||||
|
// New Gemini sessions announce their canonical ID asynchronously via the
|
||||||
|
// initial `init` stream event. Avoid synthetic IDs and only register
|
||||||
|
// the session once that real ID is known (same model used by Claude/Codex).
|
||||||
|
if (!capturedSessionId) {
|
||||||
|
capturedSessionId = discoveredSessionId;
|
||||||
|
|
||||||
|
sessionManager.createSession(capturedSessionId, cwd || process.cwd());
|
||||||
|
if (command) {
|
||||||
|
sessionManager.addMessage(capturedSessionId, 'user', command);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (processKey !== capturedSessionId) {
|
||||||
|
activeGeminiProcesses.delete(processKey);
|
||||||
|
activeGeminiProcesses.set(capturedSessionId, geminiProcess);
|
||||||
|
}
|
||||||
|
|
||||||
|
geminiProcess.sessionId = capturedSessionId;
|
||||||
|
|
||||||
|
if (ws.setSessionId && typeof ws.setSessionId === 'function') {
|
||||||
|
ws.setSessionId(capturedSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sessionId && !sessionCreatedSent) {
|
||||||
|
sessionCreatedSent = true;
|
||||||
|
ws.send(createNormalizedMessage({ kind: 'session_created', newSessionId: capturedSessionId, sessionId: capturedSessionId, provider: 'gemini' }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sess = sessionManager.getSession(capturedSessionId);
|
||||||
|
if (sess && !sess.cliSessionId) {
|
||||||
|
sess.cliSessionId = discoveredSessionId;
|
||||||
|
sessionManager.saveSession(capturedSessionId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -292,30 +436,6 @@ async function spawnGemini(command, options = {}, ws) {
|
|||||||
const rawOutput = data.toString();
|
const rawOutput = data.toString();
|
||||||
startTimeout(); // Re-arm the timeout
|
startTimeout(); // Re-arm the timeout
|
||||||
|
|
||||||
// For new sessions, create a session ID FIRST
|
|
||||||
if (!sessionId && !sessionCreatedSent && !capturedSessionId) {
|
|
||||||
capturedSessionId = `gemini_${Date.now()}`;
|
|
||||||
sessionCreatedSent = true;
|
|
||||||
|
|
||||||
// Create session in session manager
|
|
||||||
sessionManager.createSession(capturedSessionId, cwd || process.cwd());
|
|
||||||
|
|
||||||
// Save the user message now that we have a session ID
|
|
||||||
if (command) {
|
|
||||||
sessionManager.addMessage(capturedSessionId, 'user', command);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update process key with captured session ID
|
|
||||||
if (processKey !== capturedSessionId) {
|
|
||||||
activeGeminiProcesses.delete(processKey);
|
|
||||||
activeGeminiProcesses.set(capturedSessionId, geminiProcess);
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.setSessionId && typeof ws.setSessionId === 'function' && ws.setSessionId(capturedSessionId);
|
|
||||||
|
|
||||||
ws.send(createNormalizedMessage({ kind: 'session_created', newSessionId: capturedSessionId, sessionId: capturedSessionId, provider: 'gemini' }));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (responseHandler) {
|
if (responseHandler) {
|
||||||
responseHandler.processData(rawOutput);
|
responseHandler.processData(rawOutput);
|
||||||
} else if (rawOutput) {
|
} else if (rawOutput) {
|
||||||
@@ -381,12 +501,38 @@ async function spawnGemini(command, options = {}, ws) {
|
|||||||
notifyTerminalState({ code });
|
notifyTerminalState({ code });
|
||||||
resolve();
|
resolve();
|
||||||
} else {
|
} else {
|
||||||
// code 127 = shell "command not found" — check installation
|
const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId;
|
||||||
|
|
||||||
|
// code 127 = shell "command not found" - check installation
|
||||||
if (code === 127) {
|
if (code === 127) {
|
||||||
const installed = await providerAuthService.isProviderInstalled('gemini');
|
const installed = await providerAuthService.isProviderInstalled('gemini');
|
||||||
if (!installed) {
|
if (!installed) {
|
||||||
const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId;
|
terminalFailureReason = 'Gemini CLI is not installed. Please install it first: https://github.com/google-gemini/gemini-cli';
|
||||||
ws.send(createNormalizedMessage({ kind: 'error', content: 'Gemini CLI is not installed. Please install it first: https://github.com/google-gemini/gemini-cli', sessionId: socketSessionId, provider: 'gemini' }));
|
ws.send(createNormalizedMessage({ kind: 'error', content: terminalFailureReason, sessionId: socketSessionId, provider: 'gemini' }));
|
||||||
|
}
|
||||||
|
} else if (code === 41) {
|
||||||
|
// Gemini CLI documents exit code 41 as FatalAuthenticationError.
|
||||||
|
// Surface an actionable auth error instead of a generic exit-code message.
|
||||||
|
let authErrorSuffix = '';
|
||||||
|
try {
|
||||||
|
const authStatus = await providerAuthService.getProviderAuthStatus('gemini');
|
||||||
|
if (!authStatus?.authenticated && authStatus?.error) {
|
||||||
|
authErrorSuffix = ` Details: ${authStatus.error}`;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep base remediation text when auth status lookup fails.
|
||||||
|
}
|
||||||
|
|
||||||
|
terminalFailureReason =
|
||||||
|
'Gemini authentication failed (exit code 41). '
|
||||||
|
+ 'Run `gemini` in a terminal to choose an auth method, or configure `GEMINI_API_KEY`.'
|
||||||
|
+ authErrorSuffix;
|
||||||
|
ws.send(createNormalizedMessage({ kind: 'error', content: terminalFailureReason, sessionId: socketSessionId, provider: 'gemini' }));
|
||||||
|
} else {
|
||||||
|
const mappedError = mapGeminiExitCodeToMessage(code);
|
||||||
|
if (mappedError) {
|
||||||
|
terminalFailureReason = mappedError;
|
||||||
|
ws.send(createNormalizedMessage({ kind: 'error', content: terminalFailureReason, sessionId: socketSessionId, provider: 'gemini' }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,7 +540,14 @@ async function spawnGemini(command, options = {}, ws) {
|
|||||||
code,
|
code,
|
||||||
error: code === null ? 'Gemini CLI process was terminated or timed out' : null
|
error: code === null ? 'Gemini CLI process was terminated or timed out' : null
|
||||||
});
|
});
|
||||||
reject(new Error(code === null ? 'Gemini CLI process was terminated or timed out' : `Gemini CLI exited with code ${code}`));
|
reject(
|
||||||
|
new Error(
|
||||||
|
terminalFailureReason
|
||||||
|
|| (code === null
|
||||||
|
? 'Gemini CLI process was terminated or timed out'
|
||||||
|
: `Gemini CLI exited with code ${code}`)
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,37 @@ type GeminiCredentialsStatus = {
|
|||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type GeminiAuthType =
|
||||||
|
| 'oauth-personal'
|
||||||
|
| 'gemini-api-key'
|
||||||
|
| 'vertex-ai'
|
||||||
|
| 'compute-default-credentials'
|
||||||
|
| 'gateway'
|
||||||
|
| 'cloud-shell'
|
||||||
|
| null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Env keys Gemini CLI accepts for headless authentication.
|
||||||
|
* We check these from both process.env and user-level env files.
|
||||||
|
*/
|
||||||
|
const GEMINI_AUTH_ENV_KEYS = [
|
||||||
|
'GEMINI_API_KEY',
|
||||||
|
'GOOGLE_API_KEY',
|
||||||
|
'GOOGLE_CLOUD_PROJECT',
|
||||||
|
'GOOGLE_CLOUD_PROJECT_ID',
|
||||||
|
'GOOGLE_CLOUD_LOCATION',
|
||||||
|
'GOOGLE_APPLICATION_CREDENTIALS',
|
||||||
|
] as const;
|
||||||
|
|
||||||
export class GeminiProviderAuth implements IProviderAuth {
|
export class GeminiProviderAuth implements IProviderAuth {
|
||||||
|
/**
|
||||||
|
* Gemini CLI can override its home root via GEMINI_CLI_HOME.
|
||||||
|
* Use the same resolution so status checks match runtime behavior.
|
||||||
|
*/
|
||||||
|
private getGeminiCliHome(): string {
|
||||||
|
return process.env.GEMINI_CLI_HOME?.trim() || os.homedir();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks whether the Gemini CLI is available on this host.
|
* Checks whether the Gemini CLI is available on this host.
|
||||||
*/
|
*/
|
||||||
@@ -58,6 +88,88 @@ export class GeminiProviderAuth implements IProviderAuth {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses dotenv-style key/value pairs.
|
||||||
|
*/
|
||||||
|
private parseEnvFile(content: string): Record<string, string> {
|
||||||
|
const parsed: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const rawLine of content.split(/\r?\n/)) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line || line.startsWith('#')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedLine = line.startsWith('export ')
|
||||||
|
? line.slice('export '.length).trim()
|
||||||
|
: line;
|
||||||
|
const separatorIndex = normalizedLine.indexOf('=');
|
||||||
|
if (separatorIndex <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = normalizedLine.slice(0, separatorIndex).trim();
|
||||||
|
if (!key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let value = normalizedLine.slice(separatorIndex + 1).trim();
|
||||||
|
const quoted = (value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''));
|
||||||
|
if (quoted) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
} else {
|
||||||
|
value = value.replace(/\s+#.*$/, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads user-level auth env in Gemini's "first file found" order.
|
||||||
|
*/
|
||||||
|
private async loadUserLevelAuthEnv(): Promise<Record<string, string>> {
|
||||||
|
const geminiCliHome = this.getGeminiCliHome();
|
||||||
|
const envCandidates = [
|
||||||
|
path.join(geminiCliHome, '.gemini', '.env'),
|
||||||
|
path.join(geminiCliHome, '.env'),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const envPath of envCandidates) {
|
||||||
|
try {
|
||||||
|
const content = await readFile(envPath, 'utf8');
|
||||||
|
return this.parseEnvFile(content);
|
||||||
|
} catch {
|
||||||
|
// Continue to the next fallback.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads Gemini's selected auth type from settings.json when available.
|
||||||
|
*/
|
||||||
|
private async readSelectedAuthType(): Promise<GeminiAuthType> {
|
||||||
|
try {
|
||||||
|
const settingsPath = path.join(this.getGeminiCliHome(), '.gemini', 'settings.json');
|
||||||
|
const content = await readFile(settingsPath, 'utf8');
|
||||||
|
const settings = readObjectRecord(JSON.parse(content));
|
||||||
|
const security = readObjectRecord(settings?.security);
|
||||||
|
const auth = readObjectRecord(security?.auth);
|
||||||
|
const selectedType = readOptionalString(auth?.selectedType);
|
||||||
|
if (!selectedType) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectedType as GeminiAuthType;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks Gemini credentials from API key env vars or local OAuth credential files.
|
* Checks Gemini credentials from API key env vars or local OAuth credential files.
|
||||||
*/
|
*/
|
||||||
@@ -66,8 +178,46 @@ export class GeminiProviderAuth implements IProviderAuth {
|
|||||||
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
|
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userEnv = await this.loadUserLevelAuthEnv();
|
||||||
|
if (readOptionalString(userEnv.GEMINI_API_KEY)) {
|
||||||
|
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedType = await this.readSelectedAuthType();
|
||||||
|
if (selectedType === 'vertex-ai') {
|
||||||
|
const hasGoogleApiKey = Boolean(
|
||||||
|
process.env.GOOGLE_API_KEY?.trim()
|
||||||
|
|| readOptionalString(userEnv.GOOGLE_API_KEY)
|
||||||
|
);
|
||||||
|
const hasProject = Boolean(
|
||||||
|
process.env.GOOGLE_CLOUD_PROJECT?.trim()
|
||||||
|
|| process.env.GOOGLE_CLOUD_PROJECT_ID?.trim()
|
||||||
|
|| readOptionalString(userEnv.GOOGLE_CLOUD_PROJECT)
|
||||||
|
|| readOptionalString(userEnv.GOOGLE_CLOUD_PROJECT_ID)
|
||||||
|
);
|
||||||
|
const hasLocation = Boolean(
|
||||||
|
process.env.GOOGLE_CLOUD_LOCATION?.trim()
|
||||||
|
|| readOptionalString(userEnv.GOOGLE_CLOUD_LOCATION)
|
||||||
|
);
|
||||||
|
const hasServiceAccount = Boolean(
|
||||||
|
process.env.GOOGLE_APPLICATION_CREDENTIALS?.trim()
|
||||||
|
|| readOptionalString(userEnv.GOOGLE_APPLICATION_CREDENTIALS)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasGoogleApiKey || hasServiceAccount || (hasProject && hasLocation)) {
|
||||||
|
return { authenticated: true, email: 'Vertex AI Auth', method: 'vertex_ai' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
authenticated: false,
|
||||||
|
email: null,
|
||||||
|
method: 'vertex_ai',
|
||||||
|
error: 'Gemini is set to Vertex AI, but required env vars are missing',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const credsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json');
|
const credsPath = path.join(this.getGeminiCliHome(), '.gemini', 'oauth_creds.json');
|
||||||
const content = await readFile(credsPath, 'utf8');
|
const content = await readFile(credsPath, 'utf8');
|
||||||
const creds = readObjectRecord(JSON.parse(content)) ?? {};
|
const creds = readObjectRecord(JSON.parse(content)) ?? {};
|
||||||
const accessToken = readOptionalString(creds.access_token);
|
const accessToken = readOptionalString(creds.access_token);
|
||||||
@@ -106,6 +256,25 @@ export class GeminiProviderAuth implements IProviderAuth {
|
|||||||
method: 'credentials_file',
|
method: 'credentials_file',
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
|
if (selectedType === 'gemini-api-key') {
|
||||||
|
return {
|
||||||
|
authenticated: false,
|
||||||
|
email: null,
|
||||||
|
method: 'api_key',
|
||||||
|
error: 'Gemini is set to "Use Gemini API key", but GEMINI_API_KEY is unavailable',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedType === 'oauth-personal') {
|
||||||
|
return {
|
||||||
|
authenticated: false,
|
||||||
|
email: null,
|
||||||
|
method: 'credentials_file',
|
||||||
|
error: 'Gemini is set to Google sign-in, but no cached OAuth credentials were found',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no explicit auth type was selected, surface the generic "not configured" error.
|
||||||
return {
|
return {
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
email: null,
|
email: null,
|
||||||
@@ -140,7 +309,7 @@ export class GeminiProviderAuth implements IProviderAuth {
|
|||||||
*/
|
*/
|
||||||
private async getActiveAccountEmail(): Promise<string | null> {
|
private async getActiveAccountEmail(): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json');
|
const accPath = path.join(this.getGeminiCliHome(), '.gemini', 'google_accounts.json');
|
||||||
const accContent = await readFile(accPath, 'utf8');
|
const accContent = await readFile(accPath, 'utf8');
|
||||||
const accounts = readObjectRecord(JSON.parse(accContent));
|
const accounts = readObjectRecord(JSON.parse(accContent));
|
||||||
return readOptionalString(accounts?.active) ?? null;
|
return readOptionalString(accounts?.active) ?? null;
|
||||||
|
|||||||
@@ -39,33 +39,37 @@ export class GeminiSessionSynchronizer implements IProviderSessionSynchronizer {
|
|||||||
async synchronize(since?: Date): Promise<number> {
|
async synchronize(since?: Date): Promise<number> {
|
||||||
const projectHashLookup = this.buildProjectHashLookup();
|
const projectHashLookup = this.buildProjectHashLookup();
|
||||||
|
|
||||||
const legacySessionFiles = await findFilesRecursivelyCreatedAfter(
|
// const legacySessionFiles = await findFilesRecursivelyCreatedAfter(
|
||||||
path.join(this.geminiHome, 'sessions'),
|
// path.join(this.geminiHome, 'sessions'),
|
||||||
'.json',
|
// '.json',
|
||||||
since ?? null
|
// since ?? null
|
||||||
);
|
// );
|
||||||
const legacyTempFiles = await findFilesRecursivelyCreatedAfter(
|
// Gemini creates overlapping artifacts across `sessions/` and `tmp/`.
|
||||||
path.join(this.geminiHome, 'tmp'),
|
// We currently index only `tmp/*/chats/*.jsonl` because those files are the
|
||||||
'.json',
|
// live transcript source and avoid duplicate session rows from mirrored files.
|
||||||
since ?? null
|
// const legacyTempFiles = await findFilesRecursivelyCreatedAfter(
|
||||||
);
|
// path.join(this.geminiHome, 'tmp'),
|
||||||
const jsonlSessionFiles = await findFilesRecursivelyCreatedAfter(
|
// '.json',
|
||||||
path.join(this.geminiHome, 'sessions'),
|
// since ?? null
|
||||||
'.jsonl',
|
// );
|
||||||
since ?? null
|
// const jsonlSessionFiles = await findFilesRecursivelyCreatedAfter(
|
||||||
);
|
// path.join(this.geminiHome, 'sessions'),
|
||||||
|
// '.jsonl',
|
||||||
|
// since ?? null
|
||||||
|
// );
|
||||||
const jsonlTempFiles = await findFilesRecursivelyCreatedAfter(
|
const jsonlTempFiles = await findFilesRecursivelyCreatedAfter(
|
||||||
path.join(this.geminiHome, 'tmp'),
|
path.join(this.geminiHome, 'tmp'),
|
||||||
'.jsonl',
|
'.jsonl',
|
||||||
since ?? null
|
since ?? null
|
||||||
);
|
);
|
||||||
|
|
||||||
// Process legacy JSON first, then JSONL. If both exist for a session id,
|
// Current strategy: index only temp chat JSONL artifacts.
|
||||||
// the JSONL artifact becomes the canonical jsonl_path via upsert.
|
|
||||||
const files = [
|
const files = [
|
||||||
...legacySessionFiles,
|
// ...legacySessionFiles,
|
||||||
...legacyTempFiles,
|
// Intentionally disabled to avoid duplicate indexing from mirrored
|
||||||
...jsonlSessionFiles,
|
// `sessions/*.json` and `sessions/*.jsonl` artifacts.
|
||||||
|
// ...legacyTempFiles,
|
||||||
|
// ...jsonlSessionFiles,
|
||||||
...jsonlTempFiles,
|
...jsonlTempFiles,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -24,10 +24,12 @@ const PROVIDER_WATCH_PATHS: Array<{ provider: LLMProvider; rootPath: string }> =
|
|||||||
provider: 'codex',
|
provider: 'codex',
|
||||||
rootPath: path.join(os.homedir(), '.codex', 'sessions'),
|
rootPath: path.join(os.homedir(), '.codex', 'sessions'),
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
provider: 'gemini',
|
// provider: 'gemini',
|
||||||
rootPath: path.join(os.homedir(), '.gemini', 'sessions'),
|
// rootPath: path.join(os.homedir(), '.gemini', 'sessions'),
|
||||||
},
|
// },
|
||||||
|
// Keep `sessions/` watcher disabled: Gemini also mirrors artifacts there,
|
||||||
|
// which causes duplicate synchronization events.
|
||||||
{
|
{
|
||||||
provider: 'gemini',
|
provider: 'gemini',
|
||||||
rootPath: path.join(os.homedir(), '.gemini', 'tmp'),
|
rootPath: path.join(os.homedir(), '.gemini', 'tmp'),
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ export const GEMINI_MODELS = {
|
|||||||
{ value: "gemini-2.5-flash", label: "Gemini 2.5 Flash" },
|
{ value: "gemini-2.5-flash", label: "Gemini 2.5 Flash" },
|
||||||
{ value: "gemini-2.5-pro", label: "Gemini 2.5 Pro" },
|
{ value: "gemini-2.5-pro", label: "Gemini 2.5 Pro" },
|
||||||
{ value: "gemini-2.0-flash-lite", label: "Gemini 2.0 Flash Lite" },
|
{ value: "gemini-2.0-flash-lite", label: "Gemini 2.0 Flash Lite" },
|
||||||
|
{ value: "gemini-2.5-flash-lite", label: "Gemini 2.5 Flash Lite" },
|
||||||
{ value: "gemini-2.0-flash", label: "Gemini 2.0 Flash" },
|
{ value: "gemini-2.0-flash", label: "Gemini 2.0 Flash" },
|
||||||
{ value: "gemini-2.0-pro-exp", label: "Gemini 2.0 Pro Experimental" },
|
{ value: "gemini-2.0-pro-exp", label: "Gemini 2.0 Pro Experimental" },
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user