diff --git a/agent/credential_pool.py b/agent/credential_pool.py index a67eee6c42..b02514e990 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -22,8 +22,6 @@ from hermes_cli.auth import ( _auth_store_lock, _codex_access_token_is_expiring, _decode_jwt_claims, - _import_codex_cli_tokens, - _write_codex_cli_tokens, _load_auth_store, _load_provider_state, _resolve_kimi_base_url, @@ -457,39 +455,6 @@ class CredentialPool: logger.debug("Failed to sync from credentials file: %s", exc) return entry - def _sync_codex_entry_from_cli(self, entry: PooledCredential) -> PooledCredential: - """Sync an openai-codex pool entry from ~/.codex/auth.json if tokens differ. - - OpenAI OAuth refresh tokens are single-use and rotate on every refresh. - When the Codex CLI (or another Hermes profile) refreshes its token, - the pool entry's refresh_token becomes stale. This method detects that - by comparing against ~/.codex/auth.json and syncing the fresh pair. - """ - if self.provider != "openai-codex": - return entry - try: - cli_tokens = _import_codex_cli_tokens() - if not cli_tokens: - return entry - cli_refresh = cli_tokens.get("refresh_token", "") - cli_access = cli_tokens.get("access_token", "") - if cli_refresh and cli_refresh != entry.refresh_token: - logger.debug("Pool entry %s: syncing tokens from ~/.codex/auth.json (refresh token changed)", entry.id) - updated = replace( - entry, - access_token=cli_access, - refresh_token=cli_refresh, - last_status=None, - last_status_at=None, - last_error_code=None, - ) - self._replace_entry(entry, updated) - self._persist() - return updated - except Exception as exc: - logger.debug("Failed to sync from ~/.codex/auth.json: %s", exc) - return entry - def _sync_device_code_entry_to_auth_store(self, entry: PooledCredential) -> None: """Write refreshed pool entry tokens back to auth.json providers. @@ -585,13 +550,6 @@ class CredentialPool: except Exception as wexc: logger.debug("Failed to write refreshed token to credentials file: %s", wexc) elif self.provider == "openai-codex": - # Proactively sync from ~/.codex/auth.json before refresh. - # The Codex CLI (or another Hermes profile) may have already - # consumed our refresh_token. Syncing first avoids a - # "refresh_token_reused" error when the CLI has a newer pair. - synced = self._sync_codex_entry_from_cli(entry) - if synced is not entry: - entry = synced refreshed = auth_mod.refresh_codex_oauth_pure( entry.access_token, entry.refresh_token, @@ -677,45 +635,6 @@ class CredentialPool: # Credentials file had a valid (non-expired) token — use it directly logger.debug("Credentials file has valid token, using without refresh") return synced - # For openai-codex: the refresh_token may have been consumed by - # the Codex CLI between our proactive sync and the refresh call. - # Re-sync and retry once. - if self.provider == "openai-codex": - synced = self._sync_codex_entry_from_cli(entry) - if synced.refresh_token != entry.refresh_token: - logger.debug("Retrying Codex refresh with synced token from ~/.codex/auth.json") - try: - refreshed = auth_mod.refresh_codex_oauth_pure( - synced.access_token, - synced.refresh_token, - ) - updated = replace( - synced, - access_token=refreshed["access_token"], - refresh_token=refreshed["refresh_token"], - last_refresh=refreshed.get("last_refresh"), - last_status=STATUS_OK, - last_status_at=None, - last_error_code=None, - ) - self._replace_entry(synced, updated) - self._persist() - self._sync_device_code_entry_to_auth_store(updated) - try: - _write_codex_cli_tokens( - updated.access_token, - updated.refresh_token, - last_refresh=updated.last_refresh, - ) - except Exception as wexc: - logger.debug("Failed to write refreshed Codex tokens to CLI file (retry): %s", wexc) - return updated - except Exception as retry_exc: - logger.debug("Codex retry refresh also failed: %s", retry_exc) - elif not self._entry_needs_refresh(synced): - logger.debug("Codex CLI has valid token, using without refresh") - self._sync_device_code_entry_to_auth_store(synced) - return synced self._mark_exhausted(entry, None) return None @@ -734,17 +653,6 @@ class CredentialPool: # _seed_from_singletons() on the next load_pool() sees fresh state # instead of re-seeding stale/consumed tokens. self._sync_device_code_entry_to_auth_store(updated) - # Write refreshed tokens back to ~/.codex/auth.json so Codex CLI - # and VS Code don't hit "refresh_token_reused" on their next refresh. - if self.provider == "openai-codex": - try: - _write_codex_cli_tokens( - updated.access_token, - updated.refresh_token, - last_refresh=updated.last_refresh, - ) - except Exception as wexc: - logger.debug("Failed to write refreshed Codex tokens to CLI file: %s", wexc) return updated def _entry_needs_refresh(self, entry: PooledCredential) -> bool: @@ -790,16 +698,6 @@ class CredentialPool: if synced is not entry: entry = synced cleared_any = True - # For openai-codex entries, sync from ~/.codex/auth.json before - # any status/refresh checks. This picks up tokens refreshed by - # the Codex CLI or another Hermes profile. - if (self.provider == "openai-codex" - and entry.last_status == STATUS_EXHAUSTED - and entry.refresh_token): - synced = self._sync_codex_entry_from_cli(entry) - if synced is not entry: - entry = synced - cleared_any = True if entry.last_status == STATUS_EXHAUSTED: exhausted_until = _exhausted_until(entry) if exhausted_until is not None and now < exhausted_until: @@ -1218,8 +1116,8 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup elif provider == "openai-codex": # Respect user suppression — `hermes auth remove openai-codex` marks # the device_code source as suppressed so it won't be re-seeded from - # either the Hermes auth store or ~/.codex/auth.json. Without this - # gate the removal is instantly undone on the next load_pool() call. + # the Hermes auth store. Without this gate the removal is instantly + # undone on the next load_pool() call. codex_suppressed = False try: from hermes_cli.auth import is_source_suppressed @@ -1231,23 +1129,12 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup state = _load_provider_state(auth_store, "openai-codex") tokens = state.get("tokens") if isinstance(state, dict) else None - # Fallback: import from Codex CLI (~/.codex/auth.json) if Hermes auth - # store has no tokens. This mirrors resolve_codex_runtime_credentials() - # so that load_pool() and list_authenticated_providers() detect tokens - # that only exist in the Codex CLI shared file. - if not (isinstance(tokens, dict) and tokens.get("access_token")): - try: - from hermes_cli.auth import _import_codex_cli_tokens, _save_codex_tokens - cli_tokens = _import_codex_cli_tokens() - if cli_tokens: - logger.info("Importing Codex CLI tokens into Hermes auth store.") - _save_codex_tokens(cli_tokens) - # Re-read state after import - auth_store = _load_auth_store() - state = _load_provider_state(auth_store, "openai-codex") - tokens = state.get("tokens") if isinstance(state, dict) else None - except Exception as exc: - logger.debug("Codex CLI token import failed: %s", exc) + # Hermes owns its own Codex auth state — we do NOT auto-import from + # ~/.codex/auth.json at pool-load time. OAuth refresh tokens are + # single-use, so sharing them with Codex CLI / VS Code causes + # refresh_token_reused race failures. Users who want to adopt + # existing Codex CLI credentials get a one-time, explicit prompt + # via `hermes auth openai-codex`. if isinstance(tokens, dict) and tokens.get("access_token"): active_sources.add("device_code") changed |= _upsert_entry( diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index c468948e98..4623147a5a 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -1434,49 +1434,6 @@ def _read_codex_tokens(*, _lock: bool = True) -> Dict[str, Any]: } -def _write_codex_cli_tokens( - access_token: str, - refresh_token: str, - *, - last_refresh: Optional[str] = None, -) -> None: - """Write refreshed tokens back to ~/.codex/auth.json. - - OpenAI OAuth refresh tokens are single-use and rotate on every refresh. - When Hermes refreshes a token it consumes the old refresh_token; if we - don't write the new pair back, the Codex CLI (or VS Code extension) will - fail with ``refresh_token_reused`` on its next refresh attempt. - - This mirrors the Anthropic write-back to ~/.claude/.credentials.json - via ``_write_claude_code_credentials()``. - """ - codex_home = os.getenv("CODEX_HOME", "").strip() - if not codex_home: - codex_home = str(Path.home() / ".codex") - auth_path = Path(codex_home).expanduser() / "auth.json" - try: - existing: Dict[str, Any] = {} - if auth_path.is_file(): - existing = json.loads(auth_path.read_text(encoding="utf-8")) - if not isinstance(existing, dict): - existing = {} - - tokens_dict = existing.get("tokens") - if not isinstance(tokens_dict, dict): - tokens_dict = {} - tokens_dict["access_token"] = access_token - tokens_dict["refresh_token"] = refresh_token - existing["tokens"] = tokens_dict - if last_refresh is not None: - existing["last_refresh"] = last_refresh - - auth_path.parent.mkdir(parents=True, exist_ok=True) - auth_path.write_text(json.dumps(existing, indent=2), encoding="utf-8") - auth_path.chmod(0o600) - except (OSError, IOError) as exc: - logger.debug("Failed to write refreshed tokens to %s: %s", auth_path, exc) - - def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None) -> None: """Save Codex OAuth tokens to Hermes auth store (~/.hermes/auth.json).""" if last_refresh is None: @@ -1604,12 +1561,6 @@ def _refresh_codex_auth_tokens( updated_tokens["refresh_token"] = refreshed["refresh_token"] _save_codex_tokens(updated_tokens) - # Write back to ~/.codex/auth.json so Codex CLI / VS Code stay in sync. - _write_codex_cli_tokens( - refreshed["access_token"], - refreshed["refresh_token"], - last_refresh=refreshed.get("last_refresh"), - ) return updated_tokens @@ -1654,25 +1605,7 @@ def resolve_codex_runtime_credentials( refresh_skew_seconds: int = CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, ) -> Dict[str, Any]: """Resolve runtime credentials from Hermes's own Codex token store.""" - try: - data = _read_codex_tokens() - except AuthError as orig_err: - # Only attempt migration when there are NO tokens stored at all - # (code == "codex_auth_missing"), not when tokens exist but are invalid. - if orig_err.code != "codex_auth_missing": - raise - - # Migration: user had Codex as active provider with old storage (~/.codex/). - cli_tokens = _import_codex_cli_tokens() - if cli_tokens: - logger.info("Migrating Codex credentials from ~/.codex/ to Hermes auth store") - print("⚠️ Migrating Codex credentials to Hermes's own auth store.") - print(" This avoids conflicts with Codex CLI and VS Code.") - print(" Run `hermes auth` to create a fully independent session.\n") - _save_codex_tokens(cli_tokens) - data = _read_codex_tokens() - else: - raise + data = _read_codex_tokens() tokens = dict(data["tokens"]) access_token = str(tokens.get("access_token", "") or "").strip() refresh_timeout_seconds = float(os.getenv("HERMES_CODEX_REFRESH_TIMEOUT_SECONDS", "20")) diff --git a/tests/hermes_cli/test_auth_codex_provider.py b/tests/hermes_cli/test_auth_codex_provider.py index f05a80b6ac..ddcaf1721f 100644 --- a/tests/hermes_cli/test_auth_codex_provider.py +++ b/tests/hermes_cli/test_auth_codex_provider.py @@ -14,7 +14,6 @@ from hermes_cli.auth import ( PROVIDER_REGISTRY, _read_codex_tokens, _save_codex_tokens, - _write_codex_cli_tokens, _import_codex_cli_tokens, get_codex_auth_status, get_provider_auth_state, @@ -182,98 +181,6 @@ def test_codex_tokens_not_written_to_shared_file(tmp_path, monkeypatch): assert data["tokens"]["access_token"] == "hermes-at" -def test_write_codex_cli_tokens_creates_file(tmp_path, monkeypatch): - """_write_codex_cli_tokens creates ~/.codex/auth.json with refreshed tokens.""" - codex_home = tmp_path / "codex-cli" - monkeypatch.setenv("CODEX_HOME", str(codex_home)) - - _write_codex_cli_tokens("new-access", "new-refresh", last_refresh="2026-04-12T00:00:00Z") - - auth_path = codex_home / "auth.json" - assert auth_path.exists() - data = json.loads(auth_path.read_text()) - assert data["tokens"]["access_token"] == "new-access" - assert data["tokens"]["refresh_token"] == "new-refresh" - assert data["last_refresh"] == "2026-04-12T00:00:00Z" - # Verify file permissions are restricted - assert (auth_path.stat().st_mode & 0o777) == 0o600 - - -def test_write_codex_cli_tokens_preserves_existing(tmp_path, monkeypatch): - """_write_codex_cli_tokens preserves extra fields in existing auth.json.""" - codex_home = tmp_path / "codex-cli" - codex_home.mkdir(parents=True, exist_ok=True) - monkeypatch.setenv("CODEX_HOME", str(codex_home)) - - existing = { - "tokens": { - "access_token": "old-access", - "refresh_token": "old-refresh", - "extra_field": "preserved", - }, - "last_refresh": "2026-01-01T00:00:00Z", - "custom_key": "keep_me", - } - (codex_home / "auth.json").write_text(json.dumps(existing)) - - _write_codex_cli_tokens("updated-access", "updated-refresh") - - data = json.loads((codex_home / "auth.json").read_text()) - assert data["tokens"]["access_token"] == "updated-access" - assert data["tokens"]["refresh_token"] == "updated-refresh" - assert data["tokens"]["extra_field"] == "preserved" - assert data["custom_key"] == "keep_me" - # last_refresh not updated since we didn't pass it - assert data["last_refresh"] == "2026-01-01T00:00:00Z" - - -def test_write_codex_cli_tokens_handles_missing_dir(tmp_path, monkeypatch): - """_write_codex_cli_tokens creates parent directories if missing.""" - codex_home = tmp_path / "does" / "not" / "exist" - monkeypatch.setenv("CODEX_HOME", str(codex_home)) - - _write_codex_cli_tokens("at", "rt") - - assert (codex_home / "auth.json").exists() - data = json.loads((codex_home / "auth.json").read_text()) - assert data["tokens"]["access_token"] == "at" - - -def test_refresh_codex_auth_tokens_writes_back_to_cli(tmp_path, monkeypatch): - """After refreshing, _refresh_codex_auth_tokens writes back to ~/.codex/auth.json.""" - from hermes_cli.auth import _refresh_codex_auth_tokens - - hermes_home = tmp_path / "hermes" - codex_home = tmp_path / "codex-cli" - hermes_home.mkdir(parents=True, exist_ok=True) - codex_home.mkdir(parents=True, exist_ok=True) - (hermes_home / "auth.json").write_text(json.dumps({"version": 1, "providers": {}})) - monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - monkeypatch.setenv("CODEX_HOME", str(codex_home)) - - # Write initial CLI tokens - (codex_home / "auth.json").write_text(json.dumps({ - "tokens": {"access_token": "old-at", "refresh_token": "old-rt"}, - })) - - # Mock the pure refresh to return new tokens - monkeypatch.setattr("hermes_cli.auth.refresh_codex_oauth_pure", lambda *a, **kw: { - "access_token": "refreshed-at", - "refresh_token": "refreshed-rt", - "last_refresh": "2026-04-12T01:00:00Z", - }) - - _refresh_codex_auth_tokens( - {"access_token": "old-at", "refresh_token": "old-rt"}, - timeout_seconds=10, - ) - - # Verify CLI file was updated - cli_data = json.loads((codex_home / "auth.json").read_text()) - assert cli_data["tokens"]["access_token"] == "refreshed-at" - assert cli_data["tokens"]["refresh_token"] == "refreshed-rt" - - def test_resolve_returns_hermes_auth_store_source(tmp_path, monkeypatch): hermes_home = tmp_path / "hermes" _setup_hermes_auth(hermes_home)