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:
Haileyesus
2026-05-07 12:47:00 +03:00
parent 6f3e48f4fb
commit f289ce8419
5 changed files with 394 additions and 65 deletions

View File

@@ -15,7 +15,37 @@ type GeminiCredentialsStatus = {
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 {
/**
* 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.
*/
@@ -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.
*/
@@ -66,8 +178,46 @@ export class GeminiProviderAuth implements IProviderAuth {
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 {
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 creds = readObjectRecord(JSON.parse(content)) ?? {};
const accessToken = readOptionalString(creds.access_token);
@@ -106,6 +256,25 @@ export class GeminiProviderAuth implements IProviderAuth {
method: 'credentials_file',
};
} 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 {
authenticated: false,
email: null,
@@ -140,7 +309,7 @@ export class GeminiProviderAuth implements IProviderAuth {
*/
private async getActiveAccountEmail(): Promise<string | null> {
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 accounts = readObjectRecord(JSON.parse(accContent));
return readOptionalString(accounts?.active) ?? null;

View File

@@ -39,33 +39,37 @@ export class GeminiSessionSynchronizer implements IProviderSessionSynchronizer {
async synchronize(since?: Date): Promise<number> {
const projectHashLookup = this.buildProjectHashLookup();
const legacySessionFiles = await findFilesRecursivelyCreatedAfter(
path.join(this.geminiHome, 'sessions'),
'.json',
since ?? null
);
const legacyTempFiles = await findFilesRecursivelyCreatedAfter(
path.join(this.geminiHome, 'tmp'),
'.json',
since ?? null
);
const jsonlSessionFiles = await findFilesRecursivelyCreatedAfter(
path.join(this.geminiHome, 'sessions'),
'.jsonl',
since ?? null
);
// const legacySessionFiles = await findFilesRecursivelyCreatedAfter(
// path.join(this.geminiHome, 'sessions'),
// '.json',
// since ?? null
// );
// Gemini creates overlapping artifacts across `sessions/` and `tmp/`.
// We currently index only `tmp/*/chats/*.jsonl` because those files are the
// live transcript source and avoid duplicate session rows from mirrored files.
// const legacyTempFiles = await findFilesRecursivelyCreatedAfter(
// path.join(this.geminiHome, 'tmp'),
// '.json',
// since ?? null
// );
// const jsonlSessionFiles = await findFilesRecursivelyCreatedAfter(
// path.join(this.geminiHome, 'sessions'),
// '.jsonl',
// since ?? null
// );
const jsonlTempFiles = await findFilesRecursivelyCreatedAfter(
path.join(this.geminiHome, 'tmp'),
'.jsonl',
since ?? null
);
// Process legacy JSON first, then JSONL. If both exist for a session id,
// the JSONL artifact becomes the canonical jsonl_path via upsert.
// Current strategy: index only temp chat JSONL artifacts.
const files = [
...legacySessionFiles,
...legacyTempFiles,
...jsonlSessionFiles,
// ...legacySessionFiles,
// Intentionally disabled to avoid duplicate indexing from mirrored
// `sessions/*.json` and `sessions/*.jsonl` artifacts.
// ...legacyTempFiles,
// ...jsonlSessionFiles,
...jsonlTempFiles,
];

View File

@@ -24,10 +24,12 @@ const PROVIDER_WATCH_PATHS: Array<{ provider: LLMProvider; rootPath: string }> =
provider: 'codex',
rootPath: path.join(os.homedir(), '.codex', 'sessions'),
},
{
provider: 'gemini',
rootPath: path.join(os.homedir(), '.gemini', 'sessions'),
},
// {
// provider: 'gemini',
// rootPath: path.join(os.homedir(), '.gemini', 'sessions'),
// },
// Keep `sessions/` watcher disabled: Gemini also mirrors artifacts there,
// which causes duplicate synchronization events.
{
provider: 'gemini',
rootPath: path.join(os.homedir(), '.gemini', 'tmp'),