Harden Codex OAuth refresh token ownership

Cooper Gamble committed May 29, 2026 at 11:10 UTC 2d5f7b89eccd5072a33108166cc049029b6ff376
5 files changed +405 -110
plugins/_oauth/README.md
+2 -2
@@ -5,8 +5,8 @@ Generic local OAuth bridge for Agent Zero.
5 The first provider is `Codex/ChatGPT Account`:
6
7 - signs in with OpenAI's Codex device-code flow
8 -- writes Codex-compatible `auth.json` credentials
8 +- writes credentials to an Agent Zero-owned `auth.json` file
9 - refreshes local tokens when needed
10 - exposes a loopback OpenAI-compatible wrapper at `/oauth/codex/v1`
11
12 -Tokens in `auth.json` are password-equivalent credentials. Keep this plugin on trusted local machines only.
12 +Tokens in `auth.json` are password-equivalent credentials. Keep this plugin on trusted local machines only. Do not configure `auth_file_path` to share a rotating refresh-token file with Codex CLI or another client.
plugins/_oauth/default_config.yaml
+2 -2
@@ -2,8 +2,8 @@
2 codex:
3 enabled: true
4
5 - # Empty means auto-discover CODEX_HOME/auth.json, ~/.codex/auth.json,
6 - # CHATGPT_LOCAL_HOME/auth.json, then ~/.chatgpt-local/auth.json.
5 + # Empty stores credentials in Agent Zero's private OAuth directory.
6 + # Never share a rotating refresh-token file with Codex CLI or another client.
7 auth_file_path: ""
8
9 issuer: "https://auth.openai.com"
plugins/_oauth/helpers/codex.py
+184 -96
@@ -6,11 +6,13 @@ import json
6 import os
7 import secrets
8 import subprocess
9 +import threading
10 import time
11 +from contextlib import contextmanager
12 from dataclasses import dataclass
13 from datetime import datetime, timedelta, timezone
14 from pathlib import Path
13 -from typing import Any, Iterable, Mapping
15 +from typing import Any, BinaryIO, Iterable, Iterator, Mapping
16 from urllib.parse import parse_qs, urlencode, urljoin, urlparse
17
18 import requests
@@ -18,6 +20,16 @@ import requests
20 from helpers import files
21 from plugins._oauth.helpers.config import codex_config
22
23 +try:
24 + import fcntl
25 +except ImportError:
26 + fcntl = None
27 +
28 +try:
29 + import msvcrt
30 +except ImportError:
31 + msvcrt = None
32 +
33
34 AUTH_FILENAME = "auth.json"
35 ACCESS_EXPIRY_MARGIN = timedelta(minutes=5)
@@ -30,6 +42,7 @@ USAGE_ENDPOINT_PATHS = (
42 "/backend-api/wham/usage",
43 "/api/codex/usage",
44 )
45 +_AUTH_THREAD_LOCK = threading.RLock()
46
47
48 @dataclass(frozen=True)
@@ -240,53 +253,63 @@ def poll_device_authorization(device_auth_id: str, user_code: str) -> dict[str,
253
254
255 def load_auth(*, ensure_fresh: bool = True) -> EffectiveAuth:
243 - path, data = read_auth_file()
244 - tokens = data.get("tokens") if isinstance(data, dict) else {}
245 - tokens = tokens if isinstance(tokens, dict) else {}
246 -
247 - access_token = _string(tokens.get("access_token"))
248 - id_token = _string(tokens.get("id_token"))
249 - refresh_token = _string(tokens.get("refresh_token"))
250 - account_id = _string(tokens.get("account_id")) or derive_account_id(id_token)
251 - last_refresh = _string(data.get("last_refresh")) if isinstance(data, dict) else ""
252 -
253 - if ensure_fresh and refresh_token and should_refresh(access_token, last_refresh):
254 - refreshed = refresh_tokens(refresh_token)
255 - access_token = refreshed.get("access_token") or access_token
256 - id_token = refreshed.get("id_token") or id_token
257 - refresh_token = refreshed.get("refresh_token") or refresh_token
258 - account_id = derive_account_id(id_token) or account_id
259 - last_refresh = utc_now_iso()
260 - data["tokens"] = {
261 - "id_token": id_token,
262 - "access_token": access_token,
263 - "refresh_token": refresh_token,
264 - "account_id": account_id,
265 - }
266 - data["last_refresh"] = last_refresh
267 - write_auth_file(path, data)
268 -
269 - if not access_token:
270 - raise RuntimeError("Codex/ChatGPT account access token not found. Connect the account first.")
271 - if not account_id:
272 - raise RuntimeError("Codex/ChatGPT account id not found. Connect the account again.")
273 -
274 - return EffectiveAuth(
275 - access_token=access_token,
276 - account_id=account_id,
277 - id_token=id_token,
278 - refresh_token=refresh_token,
279 - source_path=str(path),
280 - last_refresh=last_refresh,
281 - )
256 + path = resolve_auth_write_path()
257 + with _auth_file_lock(path):
258 + data = _read_auth_file_unlocked(path)
259 + tokens = data.get("tokens") if isinstance(data, dict) else {}
260 + tokens = tokens if isinstance(tokens, dict) else {}
261 +
262 + access_token = _string(tokens.get("access_token"))
263 + id_token = _string(tokens.get("id_token"))
264 + refresh_token = _string(tokens.get("refresh_token"))
265 + account_id = _string(tokens.get("account_id")) or derive_account_id(id_token)
266 + last_refresh = _string(data.get("last_refresh")) if isinstance(data, dict) else ""
267 +
268 + if ensure_fresh and refresh_token and should_refresh(access_token, last_refresh):
269 + refreshed = refresh_tokens(refresh_token)
270 + access_token = refreshed.get("access_token") or access_token
271 + id_token = refreshed.get("id_token") or id_token
272 + refresh_token = refreshed.get("refresh_token") or refresh_token
273 + account_id = derive_account_id(id_token) or account_id
274 + last_refresh = utc_now_iso()
275 + data["tokens"] = {
276 + "id_token": id_token,
277 + "access_token": access_token,
278 + "refresh_token": refresh_token,
279 + "account_id": account_id,
280 + }
281 + data["last_refresh"] = last_refresh
282 + _write_auth_file_unlocked(path, data)
283 +
284 + if not access_token:
285 + raise RuntimeError("Codex/ChatGPT account access token not found. Connect the account first.")
286 + if not account_id:
287 + raise RuntimeError("Codex/ChatGPT account id not found. Connect the account again.")
288 +
289 + return EffectiveAuth(
290 + access_token=access_token,
291 + account_id=account_id,
292 + id_token=id_token,
293 + refresh_token=refresh_token,
294 + source_path=str(path),
295 + last_refresh=last_refresh,
296 + )
297
298
299 def status() -> dict[str, Any]:
285 - candidates = resolve_auth_file_candidates()
286 - existing = [str(path) for path in candidates if path.is_file()]
300 + try:
301 + path = resolve_auth_write_path()
302 + except Exception as exc:
303 + return {
304 + "connected": False,
305 + "auth_file_path": "",
306 + "discovered_auth_files": [],
307 + "message": str(exc),
308 + }
309 + existing = [str(path)] if path.is_file() else []
310 result: dict[str, Any] = {
311 "connected": False,
289 - "auth_file_path": str(resolve_auth_write_path()),
312 + "auth_file_path": str(path),
313 "discovered_auth_files": existing,
314 }
315 try:
@@ -323,16 +346,23 @@ def disconnect_auth() -> dict[str, Any]:
346 removed_paths: list[str] = []
347 preserved_paths: list[str] = []
348
326 - for path in resolve_auth_file_candidates():
349 + path = resolve_auth_write_path()
350 + with _auth_file_lock(path):
351 if not path.is_file():
328 - continue
329 - try:
330 - with path.open("r", encoding="utf-8") as handle:
331 - data = json.load(handle)
332 - except Exception:
333 - continue
352 + return {
353 + "disconnected": False,
354 + "cleared_auth_files": [],
355 + "removed_auth_files": [],
356 + "preserved_auth_files": [],
357 + }
358 + data = _read_auth_file_unlocked(path)
359 if not isinstance(data, dict) or not _contains_chatgpt_auth(data):
335 - continue
360 + return {
361 + "disconnected": False,
362 + "cleared_auth_files": [],
363 + "removed_auth_files": [],
364 + "preserved_auth_files": [],
365 + }
366
367 cleaned = dict(data)
368 cleaned.pop("tokens", None)
@@ -342,12 +372,11 @@ def disconnect_auth() -> dict[str, Any]:
372
373 cleared_paths.append(str(path))
374 if _has_meaningful_auth_data(cleaned):
345 - write_auth_file(path, cleaned)
375 + _write_auth_file_unlocked(path, cleaned)
376 preserved_paths.append(str(path))
347 - continue
348 -
349 - path.unlink(missing_ok=True)
350 - removed_paths.append(str(path))
377 + else:
378 + path.unlink(missing_ok=True)
379 + removed_paths.append(str(path))
380
381 return {
382 "disconnected": bool(cleared_paths),
@@ -940,57 +969,116 @@ def resolve_codex_version() -> str:
969
970
971 def resolve_auth_file_candidates() -> list[Path]:
943 - cfg = codex_config()
944 - explicit = cfg["auth_file_path"]
945 - if explicit:
946 - return [Path(explicit).expanduser()]
947 -
948 - candidates: list[Path] = []
949 - for env_name in ("CHATGPT_LOCAL_HOME", "CODEX_HOME"):
950 - env_home = os.getenv(env_name)
951 - if env_home:
952 - candidates.append(Path(env_home).expanduser() / AUTH_FILENAME)
953 -
954 - home = Path.home()
955 - candidates.extend(
956 - [
957 - home / ".codex" / AUTH_FILENAME,
958 - home / ".chatgpt-local" / AUTH_FILENAME,
959 - Path(files.get_abs_path("usr", "plugins", "_oauth", "codex", AUTH_FILENAME)),
960 - ]
961 - )
962 - return _unique_paths(candidates)
972 + return [resolve_auth_write_path()]
973
974
975 def resolve_auth_write_path() -> Path:
966 - for candidate in resolve_auth_file_candidates():
967 - if candidate.is_file():
968 - return candidate
969 - return resolve_auth_file_candidates()[-1]
976 + explicit = codex_config()["auth_file_path"]
977 + if explicit:
978 + return _validate_private_auth_path(Path(explicit).expanduser())
979 + return Path(files.get_abs_path("usr", "plugins", "_oauth", "codex", AUTH_FILENAME))
980
981
982 def read_auth_file() -> tuple[Path, dict[str, Any]]:
973 - candidates = resolve_auth_file_candidates()
974 - for candidate in candidates:
975 - try:
976 - with candidate.open("r", encoding="utf-8") as handle:
977 - payload = json.load(handle)
978 - if isinstance(payload, dict):
979 - return candidate, payload
980 - except FileNotFoundError:
981 - continue
982 - except Exception:
983 - continue
984 - return resolve_auth_write_path(), {}
983 + path = resolve_auth_write_path()
984 + with _auth_file_lock(path):
985 + return path, _read_auth_file_unlocked(path)
986
987
988 def write_auth_file(path: Path, data: dict[str, Any]) -> None:
989 + with _auth_file_lock(path):
990 + _write_auth_file_unlocked(path, data)
991 +
992 +
993 +@contextmanager
994 +def _auth_file_lock(path: Path) -> Iterator[None]:
995 + lock_path = path.with_name(f".{path.name}.lock")
996 + lock_path.parent.mkdir(parents=True, exist_ok=True)
997 + with _AUTH_THREAD_LOCK:
998 + with lock_path.open("a+b") as handle:
999 + _lock_file(handle)
1000 + try:
1001 + yield
1002 + finally:
1003 + _unlock_file(handle)
1004 +
1005 +
1006 +def _lock_file(handle: BinaryIO) -> None:
1007 + if fcntl is not None:
1008 + fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
1009 + return
1010 + if msvcrt is not None:
1011 + handle.seek(0, os.SEEK_END)
1012 + if handle.tell() == 0:
1013 + handle.write(b"\0")
1014 + handle.flush()
1015 + handle.seek(0)
1016 + msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
1017 + return
1018 + raise RuntimeError("This platform does not support locking the Agent Zero OAuth auth file.")
1019 +
1020 +
1021 +def _unlock_file(handle: BinaryIO) -> None:
1022 + if fcntl is not None:
1023 + fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
1024 + return
1025 + if msvcrt is not None:
1026 + handle.seek(0)
1027 + msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
1028 +
1029 +
1030 +def _read_auth_file_unlocked(path: Path) -> dict[str, Any]:
1031 + try:
1032 + with path.open("r", encoding="utf-8") as handle:
1033 + payload = json.load(handle)
1034 + return payload if isinstance(payload, dict) else {}
1035 + except FileNotFoundError:
1036 + return {}
1037 + except Exception:
1038 + return {}
1039 +
1040 +
1041 +def _write_auth_file_unlocked(path: Path, data: dict[str, Any]) -> None:
1042 path.parent.mkdir(parents=True, exist_ok=True)
989 - path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
1043 + temporary_path = path.with_name(f".{path.name}.{os.getpid()}.{secrets.token_hex(8)}.tmp")
1044 try:
991 - path.chmod(0o600)
992 - except OSError:
993 - pass
1045 + descriptor = os.open(temporary_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
1046 + with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
1047 + handle.write(json.dumps(data, indent=2) + "\n")
1048 + handle.flush()
1049 + os.fsync(handle.fileno())
1050 + os.replace(temporary_path, path)
1051 + try:
1052 + path.chmod(0o600)
1053 + except OSError:
1054 + pass
1055 + finally:
1056 + temporary_path.unlink(missing_ok=True)
1057 +
1058 +
1059 +def _validate_private_auth_path(path: Path) -> Path:
1060 + if _path_key(path) in {_path_key(candidate) for candidate in _known_codex_auth_paths()}:
1061 + raise RuntimeError(
1062 + "Agent Zero OAuth credentials must use an Agent Zero-owned auth file. "
1063 + "Choose a private auth_file_path or leave it empty for the default private store."
1064 + )
1065 + return path
1066 +
1067 +
1068 +def _known_codex_auth_paths() -> list[Path]:
1069 + candidates = [
1070 + Path.home() / ".codex" / AUTH_FILENAME,
1071 + Path.home() / ".chatgpt-local" / AUTH_FILENAME,
1072 + ]
1073 + for env_name in ("CODEX_HOME", "CHATGPT_LOCAL_HOME"):
1074 + env_home = os.getenv(env_name)
1075 + if env_home:
1076 + candidates.append(Path(env_home).expanduser() / AUTH_FILENAME)
1077 + return _unique_paths(candidates)
1078 +
1079 +
1080 +def _path_key(path: Path) -> str:
1081 + return os.path.normcase(str(path.expanduser().resolve(strict=False)))
1082
1083
1084 def parse_jwt_claims(token: str) -> dict[str, Any]:
plugins/_oauth/webui/config.html
+9 -2
@@ -219,14 +219,15 @@
219 </div>
220 <div>
221 <span>Auth file</span>
222 - <code x-text="$store.oauthConfig.status?.codex?.auth_file_path || 'Auto-discover'"></code>
222 + <code x-text="$store.oauthConfig.status?.codex?.auth_file_path || 'Agent Zero private store'"></code>
223 </div>
224 </div>
225
226 <div class="oauth-grid">
227 <label>
228 <span>Auth file path</span>
229 - <input type="text" x-model="$store.oauthConfig.codex().auth_file_path" placeholder="Auto-discover" />
229 + <input type="text" x-model="$store.oauthConfig.codex().auth_file_path" placeholder="Agent Zero private store" />
230 + <small>Use an Agent Zero-owned file. Shared Codex CLI auth files are rejected.</small>
231 </label>
232 <label>
233 <span>Issuer</span>
@@ -749,6 +750,12 @@
750 gap: 6px;
751 }
752
753 + .oauth-grid small {
754 + color: var(--color-text-secondary);
755 + font-size: 0.72rem;
756 + line-height: 1.35;
757 + }
758 +
759 .oauth-grid input[type="text"],
760 .oauth-grid input[type="password"] {
761 width: 100%;
tests/test_oauth_codex.py
+208 -8
@@ -1,9 +1,15 @@
1 from __future__ import annotations
2
3 import json
4 +import multiprocessing
5 +import queue
6 +import stat
7 import sys
8 +import threading
9 +import time
10 from pathlib import Path
11
12 +import pytest
13 import yaml
14
15 sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
@@ -227,14 +233,167 @@ def test_normalize_usage_payload_accepts_zero_percent_headers():
233 assert usage["primary"]["label"] == "5h"
234
235
230 -def test_disconnect_auth_clears_chatgpt_tokens_and_preserves_api_key(tmp_path, monkeypatch):
236 +def test_default_auth_file_ignores_codex_cli_credentials(tmp_path, monkeypatch):
237 + shared_auth = tmp_path / ".codex" / "auth.json"
238 + private_auth = tmp_path / "usr" / "plugins" / "_oauth" / "codex" / "auth.json"
239 + shared_auth.parent.mkdir()
240 + shared_auth.write_text(json.dumps({"tokens": {"refresh_token": "shared"}}), encoding="utf-8")
241 + monkeypatch.setenv("HOME", str(tmp_path))
242 + monkeypatch.setattr(codex, "codex_config", lambda: {"auth_file_path": ""})
243 + monkeypatch.setattr(codex.files, "get_abs_path", lambda *parts: str(tmp_path.joinpath(*parts)))
244 +
245 + path, data = codex.read_auth_file()
246 +
247 + assert codex.resolve_auth_file_candidates() == [private_auth]
248 + assert path == private_auth
249 + assert data == {}
250 +
251 +
252 +def test_explicit_codex_cli_auth_path_is_rejected(tmp_path, monkeypatch):
253 + codex_home = tmp_path / "codex-home"
254 + monkeypatch.setenv("CODEX_HOME", str(codex_home))
255 + monkeypatch.setattr(codex, "codex_config", lambda: {"auth_file_path": str(codex_home / "auth.json")})
256 +
257 + with pytest.raises(RuntimeError, match="Agent Zero-owned auth file"):
258 + codex.resolve_auth_write_path()
259 +
260 +
261 +def test_write_auth_file_uses_atomic_replace_and_private_permissions(tmp_path, monkeypatch):
262 + auth_path = tmp_path / "auth.json"
263 + replacements: list[tuple[Path, Path]] = []
264 + replace = codex.os.replace
265 +
266 + def record_replace(source, destination):
267 + replacements.append((Path(source), Path(destination)))
268 + replace(source, destination)
269 +
270 + monkeypatch.setattr(codex.os, "replace", record_replace)
271 +
272 + codex.write_auth_file(auth_path, {"tokens": {"refresh_token": "refresh"}})
273 +
274 + assert json.loads(auth_path.read_text(encoding="utf-8")) == {
275 + "tokens": {"refresh_token": "refresh"}
276 + }
277 + assert stat.S_IMODE(auth_path.stat().st_mode) == 0o600
278 + assert len(replacements) == 1
279 + assert replacements[0][0] != auth_path
280 + assert replacements[0][1] == auth_path
281 + assert list(tmp_path.glob(".auth.json.*.tmp")) == []
282 +
283 +
284 +def test_load_auth_serializes_refresh_across_threads(tmp_path, monkeypatch):
285 + auth_path = tmp_path / "auth.json"
286 + _write_refreshable_auth(auth_path)
287 + monkeypatch.setattr(codex, "resolve_auth_write_path", lambda: auth_path)
288 + refresh_started = threading.Event()
289 + release_refresh = threading.Event()
290 + calls: list[str] = []
291 + results: list[codex.EffectiveAuth] = []
292 +
293 + def refresh_tokens(refresh_token: str) -> dict[str, str]:
294 + calls.append(refresh_token)
295 + refresh_started.set()
296 + assert release_refresh.wait(timeout=2)
297 + return _rotated_tokens()
298 +
299 + monkeypatch.setattr(codex, "refresh_tokens", refresh_tokens)
300 + first = threading.Thread(target=lambda: results.append(codex.load_auth()))
301 + second = threading.Thread(target=lambda: results.append(codex.load_auth()))
302 +
303 + first.start()
304 + assert refresh_started.wait(timeout=2)
305 + second.start()
306 + time.sleep(0.1)
307 +
308 + assert calls == ["refresh-0"]
309 + release_refresh.set()
310 + first.join(timeout=2)
311 + second.join(timeout=2)
312 +
313 + assert not first.is_alive()
314 + assert not second.is_alive()
315 + assert calls == ["refresh-0"]
316 + assert [result.refresh_token for result in results] == ["refresh-1", "refresh-1"]
317 +
318 +
319 +def test_load_auth_holds_lock_until_rotated_token_is_persisted(tmp_path, monkeypatch):
320 + auth_path = tmp_path / "auth.json"
321 + _write_refreshable_auth(auth_path)
322 + monkeypatch.setattr(codex, "resolve_auth_write_path", lambda: auth_path)
323 + persist_started = threading.Event()
324 + release_persist = threading.Event()
325 + calls: list[str] = []
326 + results: list[codex.EffectiveAuth] = []
327 + write_auth_file = codex._write_auth_file_unlocked
328 +
329 + def refresh_tokens(refresh_token: str) -> dict[str, str]:
330 + calls.append(refresh_token)
331 + return _rotated_tokens()
332 +
333 + def delay_write(path: Path, data: dict) -> None:
334 + persist_started.set()
335 + assert release_persist.wait(timeout=2)
336 + write_auth_file(path, data)
337 +
338 + monkeypatch.setattr(codex, "refresh_tokens", refresh_tokens)
339 + monkeypatch.setattr(codex, "_write_auth_file_unlocked", delay_write)
340 + first = threading.Thread(target=lambda: results.append(codex.load_auth()))
341 + second = threading.Thread(target=lambda: results.append(codex.load_auth()))
342 +
343 + first.start()
344 + assert persist_started.wait(timeout=2)
345 + second.start()
346 + time.sleep(0.1)
347 +
348 + assert calls == ["refresh-0"]
349 + release_persist.set()
350 + first.join(timeout=2)
351 + second.join(timeout=2)
352 +
353 + assert not first.is_alive()
354 + assert not second.is_alive()
355 + assert calls == ["refresh-0"]
356 + assert [result.refresh_token for result in results] == ["refresh-1", "refresh-1"]
357 +
358 +
359 +def test_load_auth_serializes_refresh_across_processes(tmp_path):
360 + auth_path = tmp_path / "auth.json"
361 + _write_refreshable_auth(auth_path)
362 + context = multiprocessing.get_context("spawn")
363 + refresh_started = context.Event()
364 + release_refresh = context.Event()
365 + calls = context.Queue()
366 + results = context.Queue()
367 + process_args = (str(auth_path), refresh_started, release_refresh, calls, results)
368 + first = context.Process(target=_load_auth_in_process, args=process_args)
369 + second = context.Process(target=_load_auth_in_process, args=process_args)
370 +
371 + first.start()
372 + assert refresh_started.wait(timeout=2)
373 + assert calls.get(timeout=2) == "refresh-0"
374 + second.start()
375 + with pytest.raises(queue.Empty):
376 + calls.get(timeout=0.2)
377 +
378 + release_refresh.set()
379 + first.join(timeout=3)
380 + second.join(timeout=3)
381 +
382 + assert first.exitcode == 0
383 + assert second.exitcode == 0
384 + with pytest.raises(queue.Empty):
385 + calls.get(timeout=0.2)
386 + assert sorted([results.get(timeout=2), results.get(timeout=2)]) == ["refresh-1", "refresh-1"]
387 +
388 +
389 +def test_disconnect_auth_only_mutates_agent_zero_private_auth_file(tmp_path, monkeypatch):
390 private_auth = tmp_path / "private-auth.json"
232 - shared_auth = tmp_path / "shared-auth.json"
391 + shared_auth = tmp_path / ".codex" / "auth.json"
392 private_auth.write_text(
393 json.dumps(
394 {
395 "auth_mode": "chatgpt",
237 - "OPENAI_API_KEY": None,
396 + "OPENAI_API_KEY": "sk-keep",
397 "tokens": {
398 "access_token": "access",
399 "refresh_token": "refresh",
@@ -246,26 +405,67 @@ def test_disconnect_auth_clears_chatgpt_tokens_and_preserves_api_key(tmp_path, m
405 ),
406 encoding="utf-8",
407 )
408 + shared_auth.parent.mkdir()
409 shared_auth.write_text(
410 json.dumps(
411 {
412 "auth_mode": "chatgpt",
253 - "OPENAI_API_KEY": "sk-keep",
413 + "OPENAI_API_KEY": None,
414 "tokens": {"access_token": "access", "account_id": "account"},
415 "last_refresh": "2026-01-01T00:00:00Z",
416 }
417 ),
418 encoding="utf-8",
419 )
260 - monkeypatch.setattr(codex, "resolve_auth_file_candidates", lambda: [private_auth, shared_auth])
420 + shared_before = shared_auth.read_text(encoding="utf-8")
421 + monkeypatch.setattr(codex, "resolve_auth_write_path", lambda: private_auth)
422
423 result = codex.disconnect_auth()
424
425 assert result["disconnected"] is True
265 - assert str(private_auth) in result["removed_auth_files"]
266 - assert not private_auth.exists()
267 - preserved = json.loads(shared_auth.read_text(encoding="utf-8"))
426 + assert result["preserved_auth_files"] == [str(private_auth)]
427 + preserved = json.loads(private_auth.read_text(encoding="utf-8"))
428 assert preserved == {"OPENAI_API_KEY": "sk-keep"}
429 + assert shared_auth.read_text(encoding="utf-8") == shared_before
430 +
431 +
432 +def _write_refreshable_auth(path: Path) -> None:
433 + path.write_text(
434 + json.dumps(
435 + {
436 + "auth_mode": "chatgpt",
437 + "tokens": {
438 + "access_token": "",
439 + "refresh_token": "refresh-0",
440 + "id_token": "",
441 + "account_id": "account",
442 + },
443 + "last_refresh": "",
444 + }
445 + ),
446 + encoding="utf-8",
447 + )
448 +
449 +
450 +def _rotated_tokens() -> dict[str, str]:
451 + return {
452 + "access_token": "access-1",
453 + "refresh_token": "refresh-1",
454 + "id_token": "",
455 + }
456 +
457 +
458 +def _load_auth_in_process(auth_path: str, refresh_started, release_refresh, calls, results) -> None:
459 + codex.resolve_auth_write_path = lambda: Path(auth_path)
460 +
461 + def refresh_tokens(refresh_token: str) -> dict[str, str]:
462 + calls.put(refresh_token)
463 + refresh_started.set()
464 + assert release_refresh.wait(timeout=5)
465 + return _rotated_tokens()
466 +
467 + codex.refresh_tokens = refresh_tokens
468 + results.put(codex.load_auth().refresh_token)
469
470
471 def test_provider_config_uses_container_local_agent_zero_origin():