diff --git a/desktop/src/agents/cli/gemini-native-config.cjs b/desktop/src/agents/cli/gemini-native-config.cjs new file mode 100644 index 0000000..dc68a97 --- /dev/null +++ b/desktop/src/agents/cli/gemini-native-config.cjs @@ -0,0 +1,70 @@ +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const JSON5 = require('json5') + +function readSettings(file) { + try { + const stat = fs.statSync(file) + if (!stat.isFile() || stat.size > 1024 * 1024) throw new Error('invalid settings') + const value = JSON5.parse(fs.readFileSync(file, 'utf8')) + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid settings') + return value + } catch (error) { + if (error.code === 'ENOENT') return {} + throw error + } +} + +function geminiNativeAuth(options = {}) { + const env = options.env || process.env + const platform = options.platform || process.platform + const home = options.home || os.homedir() + const base = env.GEMINI_CLI_HOME || home + const system = env.GEMINI_CLI_SYSTEM_SETTINGS_PATH || (platform === 'darwin' + ? '/Library/Application Support/GeminiCli/settings.json' : platform === 'win32' + ? 'C:\\ProgramData\\gemini-cli\\settings.json' : '/etc/gemini-cli/settings.json') + const defaults = env.GEMINI_CLI_SYSTEM_DEFAULTS_PATH || path.join(path.dirname(system), 'system-defaults.json') + if (![base, system, defaults].every(value => typeof value === 'string' && path.isAbsolute(value))) return { uncertain: true } + const root = path.join(base, '.gemini') + try { + const layers = [readSettings(defaults), readSettings(path.join(root, 'settings.json'))] + const systemSettings = readSettings(system) + const validOverrides = ['oauth-personal', 'gemini-api-key', 'vertex-ai', 'compute-default-credentials'] + const override = validOverrides.includes(env.GEMINI_CLI_AUTH_OVERRIDE) ? env.GEMINI_CLI_AUTH_OVERRIDE : '' + let authType = override || (layers[1].security?.auth?.selectedType ?? layers[0].security?.auth?.selectedType ?? '') + const workdir = typeof options.workdir === 'string' && path.isAbsolute(options.workdir) ? options.workdir : '' + if (workdir && path.resolve(workdir) !== path.resolve(base)) { + let trusted = env.GEMINI_CLI_TRUST_WORKSPACE === 'true' + if (env.GEMINI_RESTRICTED_MODE === 'true' || env.GEMINI_CLI_TRUST_WORKSPACE === 'false') trusted = false + else if (!trusted) { + const enabled = systemSettings.security?.folderTrust?.enabled + ?? layers[1].security?.folderTrust?.enabled ?? layers[0].security?.folderTrust?.enabled ?? true + if (enabled === false) trusted = true + else { + const trustFile = env.GEMINI_CLI_TRUSTED_FOLDERS_PATH || path.join(root, 'trustedFolders.json') + if (!path.isAbsolute(trustFile)) return { uncertain: true } + const rules = readSettings(trustFile) + const real = fs.realpathSync(workdir) + let longest = -1 + for (const [folder, level] of Object.entries(rules)) { + if (!path.isAbsolute(folder) || !['TRUST_FOLDER', 'TRUST_PARENT', 'DO_NOT_TRUST'].includes(level)) continue + const candidate = level === 'TRUST_PARENT' ? path.dirname(folder) : folder + const rootPath = fs.existsSync(candidate) ? fs.realpathSync(candidate) : path.resolve(candidate) + const relative = path.relative(rootPath, real) + if ((relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative))) + && rootPath.length > longest) { + longest = rootPath.length + trusted = level !== 'DO_NOT_TRUST' + } + } + } + } + if (trusted) authType = readSettings(path.join(workdir, '.gemini', 'settings.json')).security?.auth?.selectedType ?? authType + } + authType = systemSettings.security?.auth?.selectedType ?? authType + return { authType: typeof authType === 'string' ? authType : '', root } + } catch { return { uncertain: true } } +} + +module.exports = { geminiNativeAuth } diff --git a/desktop/src/agents/local-agent-readiness.cjs b/desktop/src/agents/local-agent-readiness.cjs index e9de8d0..dd1c513 100644 --- a/desktop/src/agents/local-agent-readiness.cjs +++ b/desktop/src/agents/local-agent-readiness.cjs @@ -12,6 +12,7 @@ const { OCR_PROVIDER_KEYS } = require('./cli/ocr-provider-environment.cjs') const { deepseekNativeConfig } = require('./cli/deepseek-native-config.cjs') const { hermesProviderFromConfig, hermesProviderEnvironment } = require('./hermes-config-summary.cjs') const { qwenCredentialSelection, qwenConfiguredAuth } = require('./cli/qwen-native-config.cjs') +const { geminiNativeAuth } = require('./cli/gemini-native-config.cjs') const { prepareCommand, searchPath } = require('./cli/cli-discovery.cjs') const { isOpenClawSecretReference } = require('./cli/openclaw-runtime.cjs') const { NETWORK_ENV_KEYS, networkEnvironment } = require('./cli/cli-network-environment.cjs') @@ -803,6 +804,21 @@ function nativeCredentialState(kind, options = {}) { const geminiHome = env.GEMINI_CLI_HOME ? (path.isAbsolute(env.GEMINI_CLI_HOME) ? path.join(env.GEMINI_CLI_HOME, '.gemini') : null) : path.join(home, '.gemini') + if (kind === 'gemini') { + const selection = geminiNativeAuth({ home, env, workdir: options.workdir, platform: options.platform }) + if (selection.uncertain) return { state: 'unknown', source: 'unverified' } + if (selection.authType === 'oauth-personal') { + return jsonContainsCredential(path.join(selection.root, 'oauth_creds.json')) + ? { state: 'ready', source: 'native-credential' } + : { state: 'unknown', source: 'unverified' } + } + if (selection.authType === 'gemini-api-key') { + const found = ['GOOGLE_API_KEY', 'GEMINI_API_KEY'].some(key => typeof env[key] === 'string' && env[key].trim()) + return found ? { state: 'ready', source: options.credentialSource === 'native-shell' ? 'native-shell' : 'native-credential' } + : { state: 'unknown', source: 'unverified' } + } + if (selection.authType) return { state: 'unknown', source: 'unverified' } + } const expandHome = value => String(value).replace(/^~(?=$|[\\/])/, home) const piHome = env.PI_CODING_AGENT_DIR ? expandHome(env.PI_CODING_AGENT_DIR) : path.join(home, '.pi', 'agent') const kimiHome = env.KIMI_CODE_HOME diff --git a/desktop/test/agents/cli/gemini-native-config.test.cjs b/desktop/test/agents/cli/gemini-native-config.test.cjs new file mode 100644 index 0000000..05b8e6f --- /dev/null +++ b/desktop/test/agents/cli/gemini-native-config.test.cjs @@ -0,0 +1,48 @@ +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const { geminiNativeAuth } = require('../../../src/agents/cli/gemini-native-config.cjs') +const { nativeCredentialState } = require('../../../src/agents/local-agent-readiness.cjs') + +test('Gemini resolves auth through native settings and workspace trust', t => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-gemini-auth-')) + t.after(() => fs.rmSync(home, { recursive: true, force: true })) + const root = path.join(home, '.gemini') + const project = path.join(home, 'project') + fs.mkdirSync(root) + fs.mkdirSync(path.join(project, '.gemini'), { recursive: true }) + const defaults = path.join(home, 'defaults.json') + const system = path.join(home, 'system.json') + const env = { GEMINI_CLI_SYSTEM_SETTINGS_PATH: system, GEMINI_CLI_SYSTEM_DEFAULTS_PATH: defaults } + const write = (file, auth) => fs.writeFileSync(file, JSON.stringify({ security: { auth: { selectedType: auth } } })) + write(defaults, 'oauth-personal') + write(path.join(root, 'settings.json'), 'gemini-api-key') + write(path.join(project, '.gemini', 'settings.json'), 'vertex-ai') + assert.equal(geminiNativeAuth({ home, env, workdir: project }).authType, 'gemini-api-key') + fs.writeFileSync(path.join(root, 'trustedFolders.json'), JSON.stringify({ [project]: 'TRUST_FOLDER' })) + assert.equal(geminiNativeAuth({ home, env, workdir: project }).authType, 'vertex-ai') + assert.equal(geminiNativeAuth({ home, env: { ...env, GEMINI_RESTRICTED_MODE: 'true' }, workdir: project }).authType, 'gemini-api-key') + assert.equal(geminiNativeAuth({ home, env: { ...env, GEMINI_CLI_AUTH_OVERRIDE: 'oauth-personal' } }).authType, 'oauth-personal') + write(system, 'vertex-ai') + assert.equal(geminiNativeAuth({ home, env: { ...env, GEMINI_CLI_AUTH_OVERRIDE: 'oauth-personal' }, workdir: project }).authType, 'vertex-ai') +}) + +test('Gemini readiness checks credentials for its selected auth mode', t => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-gemini-ready-')) + t.after(() => fs.rmSync(home, { recursive: true, force: true })) + const root = path.join(home, '.gemini') + fs.mkdirSync(root) + const env = { GEMINI_CLI_SYSTEM_SETTINGS_PATH: path.join(home, 'system.json'), GEMINI_CLI_AUTH_OVERRIDE: 'oauth-personal', GEMINI_API_KEY: 'unrelated' } + assert.deepEqual(nativeCredentialState('gemini', { home, env }), { state: 'unknown', source: 'unverified' }) + fs.writeFileSync(path.join(root, 'oauth_creds.json'), JSON.stringify({ refresh_token: 'oauth-token' })) + assert.deepEqual(nativeCredentialState('gemini', { home, env }), { state: 'ready', source: 'native-credential' }) + env.GEMINI_CLI_AUTH_OVERRIDE = 'gemini-api-key' + assert.deepEqual(nativeCredentialState('gemini', { home, env }), { state: 'ready', source: 'native-credential' }) + env.GEMINI_CLI_AUTH_OVERRIDE = 'vertex-ai' + assert.deepEqual(nativeCredentialState('gemini', { home, env }), { state: 'unknown', source: 'unverified' }) + env.GEMINI_CLI_AUTH_OVERRIDE = 'gemini-api-key' + delete env.GEMINI_API_KEY + assert.deepEqual(nativeCredentialState('gemini', { home, env }), { state: 'unknown', source: 'unverified' }) +})