Skip to content

Commit 700972a

Browse files
bleonardclaude
andauthored
fix(gemini_cli): pre-select auth method for headless API-key/Vertex runs (harbor-framework#1794)
Headless `gemini --prompt` cannot show the auth-method dialog and exits with "Invalid auth method selected" unless a method is pre-selected in settings.json. The agent only wrote security.auth.selectedType for the OAuth path, so API-key and Vertex runs relied on gemini-cli auto-detection -- which also breaks when a custom GOOGLE_GEMINI_BASE_URL (e.g. a proxy) is set. Resolve the auth method from the env credentials in use (GEMINI_API_KEY/GOOGLE_API_KEY -> gemini-api-key, GOOGLE_GENAI_USE_VERTEXAI -> vertex-ai) and pin it in settings.json. The settings builder now takes a single auth_type ("oauth-personal", "gemini-api-key", or "vertex-ai") instead of an OAuth-only boolean. Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
1 parent 51f6007 commit 700972a

3 files changed

Lines changed: 151 additions & 9 deletions

File tree

src/harbor/agents/installed/gemini_cli.py

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -595,16 +595,21 @@ def _build_register_skills_command(self) -> str | None:
595595
)
596596

597597
def _build_settings_config(
598-
self, model: str | None = None, use_oauth: bool = False
598+
self,
599+
model: str | None = None,
600+
auth_type: str | None = None,
599601
) -> tuple[dict[str, Any] | None, str | None]:
600602
"""Build Gemini CLI settings and optional model alias for this run."""
601603
config: dict[str, Any] = {}
602604
model_alias: str | None = None
603605

604-
if use_oauth:
605-
# Force "Login with Google" so headless mode uses the uploaded
606-
# oauth_creds.json instead of prompting for an auth method.
607-
config["security"] = {"auth": {"selectedType": "oauth-personal"}}
606+
if auth_type is not None:
607+
# Headless `gemini --prompt` cannot show the auth-method dialog and
608+
# exits with "Invalid auth method selected" unless one is
609+
# pre-selected. Its auto-detection also breaks when a custom
610+
# GOOGLE_GEMINI_BASE_URL is set, so pin the resolved method
611+
# ("oauth-personal", "gemini-api-key", or "vertex-ai").
612+
config["security"] = {"auth": {"selectedType": auth_type}}
608613

609614
if self.mcp_servers:
610615
servers = {}
@@ -645,16 +650,36 @@ def _build_settings_config(
645650
return config, model_alias
646651

647652
def _build_settings_command(
648-
self, model: str | None = None, use_oauth: bool = False
653+
self,
654+
model: str | None = None,
655+
auth_type: str | None = None,
649656
) -> tuple[str | None, str | None]:
650657
"""Return the settings write command and optional run model alias."""
651-
config, model_alias = self._build_settings_config(model, use_oauth)
658+
config, model_alias = self._build_settings_config(model, auth_type)
652659
if config is None:
653660
return None, model_alias
654661
escaped = shlex.quote(json.dumps(config, indent=2))
655662
command = f"mkdir -p ~/.gemini && printf %s {escaped} > ~/.gemini/settings.json"
656663
return command, model_alias
657664

665+
def _resolve_env_auth_type(self) -> str | None:
666+
"""Gemini CLI AuthType to pre-select for env-credential (non-OAuth) runs.
667+
668+
Headless `gemini --prompt` cannot show the auth-method dialog, so the
669+
method must be pre-selected in settings.json. Returns None when no
670+
recognized credentials are present, leaving the CLI's own selection
671+
untouched.
672+
"""
673+
if parse_bool_env_value(
674+
self._get_env("GOOGLE_GENAI_USE_VERTEXAI"),
675+
name="GOOGLE_GENAI_USE_VERTEXAI",
676+
default=False,
677+
):
678+
return "vertex-ai"
679+
if self._get_env("GEMINI_API_KEY") or self._get_env("GOOGLE_API_KEY"):
680+
return "gemini-api-key"
681+
return None
682+
658683
def _resolve_oauth_creds_path(self) -> Path | None:
659684
"""Resolve which Gemini OAuth credentials file to inject, if any.
660685
@@ -742,6 +767,10 @@ async def run(
742767
# 3. Default: env credentials (GEMINI_API_KEY / Vertex / etc.)
743768
oauth_creds_path = self._resolve_oauth_creds_path()
744769
use_oauth = oauth_creds_path is not None
770+
# Pre-select the auth method gemini-cli should use in headless mode:
771+
# "oauth-personal" when injecting OAuth creds, otherwise whatever the
772+
# env credentials imply.
773+
auth_type = "oauth-personal" if use_oauth else self._resolve_env_auth_type()
745774

746775
if use_oauth:
747776
# Don't leak an API key alongside OAuth (it would change the auth
@@ -773,7 +802,7 @@ async def run(
773802
await self.exec_as_agent(environment, command=skills_command, env=env)
774803

775804
settings_command, model_alias = self._build_settings_command(
776-
model, use_oauth=use_oauth
805+
model, auth_type=auth_type
777806
)
778807
if settings_command:
779808
await self.exec_as_agent(environment, command=settings_command, env=env)

tests/unit/agents/installed/test_gemini_cli.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,68 @@ def test_force_oauth_invalid_raises(self, monkeypatch, temp_dir):
409409
agent._resolve_oauth_creds_path()
410410

411411

412+
class TestResolveEnvAuthType:
413+
"""Test _resolve_env_auth_type() credential detection."""
414+
415+
def test_api_key_selects_gemini_api_key(self, monkeypatch, temp_dir):
416+
"""GEMINI_API_KEY selects the gemini-api-key auth method."""
417+
monkeypatch.delenv("GOOGLE_GENAI_USE_VERTEXAI", raising=False)
418+
monkeypatch.delenv("GOOGLE_API_KEY", raising=False)
419+
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
420+
421+
agent = GeminiCli(logs_dir=temp_dir, model_name=_OAUTH_MODEL)
422+
assert agent._resolve_env_auth_type() == "gemini-api-key"
423+
424+
def test_google_api_key_selects_gemini_api_key(self, monkeypatch, temp_dir):
425+
"""GOOGLE_API_KEY also selects the gemini-api-key auth method."""
426+
monkeypatch.delenv("GOOGLE_GENAI_USE_VERTEXAI", raising=False)
427+
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
428+
monkeypatch.setenv("GOOGLE_API_KEY", "test-key")
429+
430+
agent = GeminiCli(logs_dir=temp_dir, model_name=_OAUTH_MODEL)
431+
assert agent._resolve_env_auth_type() == "gemini-api-key"
432+
433+
def test_vertex_flag_selects_vertex_ai(self, monkeypatch, temp_dir):
434+
"""A truthy GOOGLE_GENAI_USE_VERTEXAI selects the vertex-ai method."""
435+
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
436+
monkeypatch.delenv("GOOGLE_API_KEY", raising=False)
437+
monkeypatch.setenv("GOOGLE_GENAI_USE_VERTEXAI", "true")
438+
439+
agent = GeminiCli(logs_dir=temp_dir, model_name=_OAUTH_MODEL)
440+
assert agent._resolve_env_auth_type() == "vertex-ai"
441+
442+
def test_vertex_takes_precedence_over_api_key(self, monkeypatch, temp_dir):
443+
"""Vertex wins when both the Vertex flag and an API key are present."""
444+
monkeypatch.delenv("GOOGLE_API_KEY", raising=False)
445+
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
446+
monkeypatch.setenv("GOOGLE_GENAI_USE_VERTEXAI", "true")
447+
448+
agent = GeminiCli(logs_dir=temp_dir, model_name=_OAUTH_MODEL)
449+
assert agent._resolve_env_auth_type() == "vertex-ai"
450+
451+
def test_api_key_via_extra_env(self, monkeypatch, temp_dir):
452+
"""GEMINI_API_KEY supplied via extra_env (--ae) is detected."""
453+
monkeypatch.delenv("GOOGLE_GENAI_USE_VERTEXAI", raising=False)
454+
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
455+
monkeypatch.delenv("GOOGLE_API_KEY", raising=False)
456+
457+
agent = GeminiCli(
458+
logs_dir=temp_dir,
459+
model_name=_OAUTH_MODEL,
460+
extra_env={"GEMINI_API_KEY": "test-key"},
461+
)
462+
assert agent._resolve_env_auth_type() == "gemini-api-key"
463+
464+
def test_no_credentials_returns_none(self, monkeypatch, temp_dir):
465+
"""With no recognized credentials, selection is left to the CLI."""
466+
monkeypatch.delenv("GOOGLE_GENAI_USE_VERTEXAI", raising=False)
467+
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
468+
monkeypatch.delenv("GOOGLE_API_KEY", raising=False)
469+
470+
agent = GeminiCli(logs_dir=temp_dir, model_name=_OAUTH_MODEL)
471+
assert agent._resolve_env_auth_type() is None
472+
473+
412474
class TestGeminiRunAuth:
413475
"""Test that run() wires auth correctly."""
414476

@@ -493,3 +555,50 @@ async def test_uses_api_key_when_no_oauth_creds(
493555
if "GEMINI_API_KEY" in c.kwargs["env"]
494556
)
495557
assert run_call.kwargs["env"]["GEMINI_API_KEY"] == "test-key"
558+
559+
@pytest.mark.asyncio
560+
async def test_pins_api_key_auth_in_settings(self, tmp_path, monkeypatch, temp_dir):
561+
"""API-key runs pre-select gemini-api-key auth in settings.json so
562+
headless mode does not fail with "Invalid auth method selected"."""
563+
monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path))
564+
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
565+
monkeypatch.delenv("GOOGLE_GENAI_USE_VERTEXAI", raising=False)
566+
monkeypatch.delenv("GOOGLE_API_KEY", raising=False)
567+
monkeypatch.delenv("GEMINI_OAUTH_CREDS_PATH", raising=False)
568+
monkeypatch.delenv("GEMINI_FORCE_OAUTH", raising=False)
569+
570+
agent = GeminiCli(logs_dir=temp_dir, model_name=_OAUTH_MODEL)
571+
mock_env = AsyncMock()
572+
mock_env.default_user = "agent"
573+
mock_env.exec.return_value = AsyncMock(return_code=0, stdout="", stderr="")
574+
await agent.run("do something", mock_env, AsyncMock())
575+
576+
settings_call = next(
577+
c
578+
for c in mock_env.exec.call_args_list
579+
if "settings.json" in c.kwargs["command"]
580+
)
581+
assert "gemini-api-key" in settings_call.kwargs["command"]
582+
583+
@pytest.mark.asyncio
584+
async def test_pins_oauth_personal_auth_in_settings(
585+
self, tmp_path, monkeypatch, temp_dir
586+
):
587+
"""OAuth runs pre-select oauth-personal auth in settings.json."""
588+
creds_file = tmp_path / "oauth_creds.json"
589+
creds_file.write_text(json.dumps({"access_token": "tok"}))
590+
monkeypatch.setenv("GEMINI_OAUTH_CREDS_PATH", str(creds_file))
591+
monkeypatch.delenv("GEMINI_FORCE_OAUTH", raising=False)
592+
593+
agent = GeminiCli(logs_dir=temp_dir, model_name=_OAUTH_MODEL)
594+
mock_env = AsyncMock()
595+
mock_env.default_user = "agent"
596+
mock_env.exec.return_value = AsyncMock(return_code=0, stdout="", stderr="")
597+
await agent.run("do something", mock_env, AsyncMock())
598+
599+
settings_call = next(
600+
c
601+
for c in mock_env.exec.call_args_list
602+
if "settings.json" in c.kwargs["command"]
603+
)
604+
assert "oauth-personal" in settings_call.kwargs["command"]

tests/unit/agents/installed/test_gemini_cli_mcp.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,11 @@ class TestCreateRunAgentCommandsMCP:
158158
"""Test that run() handles MCP servers correctly."""
159159

160160
@pytest.mark.asyncio
161-
async def test_no_mcp_servers_no_settings_command(self, temp_dir):
161+
async def test_no_mcp_servers_no_settings_command(self, monkeypatch, temp_dir):
162+
# No MCP servers and no recognized credentials means nothing to write.
163+
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
164+
monkeypatch.delenv("GOOGLE_API_KEY", raising=False)
165+
monkeypatch.delenv("GOOGLE_GENAI_USE_VERTEXAI", raising=False)
162166
agent = GeminiCli(logs_dir=temp_dir, model_name="google/gemini-2.5-pro")
163167
mock_env = AsyncMock()
164168
mock_env.exec.return_value = AsyncMock(return_code=0, stdout="", stderr="")

0 commit comments

Comments
 (0)