Handle private OAuth auth file edge cases

Cooper Gamble committed May 29, 2026 at 11:19 UTC 3c6aaae7378c3720602d1c2e4f7aa4b09a99f7fb
2 files changed +112 -9
plugins/_oauth/helpers/codex.py
+32 -9
@@ -38,6 +38,7 @@ REFRESH_INTERVAL = timedelta(minutes=55)
38 FALLBACK_CODEX_VERSION = "0.124.0"
39 OAUTH_ERROR_KEYS = {"error", "error_description"}
40 DEVICE_CODE_TIMEOUT_SECONDS = 15 * 60
41 +WINDOWS_LOCK_RETRY_SECONDS = 0.05
42 USAGE_ENDPOINT_PATHS = (
43 "/backend-api/codex/usage",
44 "/backend-api/wham/usage",
@@ -975,9 +976,12 @@ def resolve_auth_file_candidates() -> list[Path]:
976
977 def resolve_auth_write_path() -> Path:
978 explicit = codex_config()["auth_file_path"]
978 - if explicit:
979 - return _validate_private_auth_path(Path(explicit).expanduser())
980 - return Path(files.get_abs_path("usr", "plugins", "_oauth", "codex", AUTH_FILENAME))
979 + path = (
980 + Path(explicit).expanduser()
981 + if explicit
982 + else Path(files.get_abs_path("usr", "plugins", "_oauth", "codex", AUTH_FILENAME))
983 + )
984 + return _validate_private_auth_path(path)
985
986
987 def read_auth_file() -> tuple[Path, dict[str, Any]]:
@@ -993,7 +997,7 @@ def write_auth_file(path: Path, data: dict[str, Any]) -> None:
997
998 @contextmanager
999 def _auth_file_lock(path: Path) -> Iterator[None]:
996 - lock_path = path.with_name(f".{path.name}.lock")
1000 + lock_path = _auth_lock_path(path)
1001 lock_path.parent.mkdir(parents=True, exist_ok=True)
1002 with _AUTH_THREAD_LOCK:
1003 with lock_path.open("a+b") as handle:
@@ -1014,8 +1018,15 @@ def _lock_file(handle: BinaryIO) -> None:
1018 handle.write(b"\0")
1019 handle.flush()
1020 handle.seek(0)
1017 - msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
1018 - return
1021 + while True:
1022 + try:
1023 + msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
1024 + return
1025 + except OSError as exc:
1026 + if exc.errno not in {errno.EACCES, errno.EDEADLK}:
1027 + raise
1028 + time.sleep(WINDOWS_LOCK_RETRY_SECONDS)
1029 + handle.seek(0)
1030 raise RuntimeError("This platform does not support locking the Agent Zero OAuth auth file.")
1031
1032
@@ -1043,7 +1054,13 @@ def _write_auth_file_unlocked(path: Path, data: dict[str, Any]) -> None:
1054 path.parent.mkdir(parents=True, exist_ok=True)
1055 temporary_path = path.with_name(f".{path.name}.{os.getpid()}.{secrets.token_hex(8)}.tmp")
1056 try:
1046 - descriptor = os.open(temporary_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
1057 + try:
1058 + descriptor = os.open(temporary_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
1059 + except OSError as exc:
1060 + if exc.errno not in {errno.EACCES, errno.EROFS}:
1061 + raise
1062 + _write_auth_file_in_place(path, data)
1063 + return
1064 with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
1065 handle.write(json.dumps(data, indent=2) + "\n")
1066 handle.flush()
@@ -1071,12 +1088,13 @@ def _write_auth_file_in_place(path: Path, data: dict[str, Any]) -> None:
1088
1089
1090 def _validate_private_auth_path(path: Path) -> Path:
1074 - if _path_key(path) in {_path_key(candidate) for candidate in _known_codex_auth_paths()}:
1091 + resolved_path = path.expanduser().resolve(strict=False)
1092 + if _path_key(resolved_path) in {_path_key(candidate) for candidate in _known_codex_auth_paths()}:
1093 raise RuntimeError(
1094 "Agent Zero OAuth credentials must use an Agent Zero-owned auth file. "
1095 "Choose a private auth_file_path or leave it empty for the default private store."
1096 )
1079 - return path
1097 + return resolved_path
1098
1099
1100 def _known_codex_auth_paths() -> list[Path]:
@@ -1095,6 +1113,11 @@ def _path_key(path: Path) -> str:
1113 return os.path.normcase(str(path.expanduser().resolve(strict=False)))
1114
1115
1116 +def _auth_lock_path(path: Path) -> Path:
1117 + digest = hashlib.sha256(_path_key(path).encode("utf-8")).hexdigest()
1118 + return Path(files.get_abs_path("usr", "plugins", "_oauth", "codex", "locks", f"{digest}.lock"))
1119 +
1120 +
1121 def parse_jwt_claims(token: str) -> dict[str, Any]:
1122 if not token or token.count(".") != 2:
1123 return {}
tests/test_oauth_codex.py
+80
@@ -19,6 +19,15 @@ from plugins._oauth.extensions.python._functions.models.get_api_key.end._20_code
19 )
20
21
22 +@pytest.fixture(autouse=True)
23 +def use_temporary_auth_locks(tmp_path, monkeypatch):
24 + def lock_path(path: Path) -> Path:
25 + digest = codex.hashlib.sha256(codex._path_key(path).encode("utf-8")).hexdigest()
26 + return tmp_path / "locks" / f"{digest}.lock"
27 +
28 + monkeypatch.setattr(codex, "_auth_lock_path", lock_path)
29 +
30 +
31 def test_generate_pkce_produces_urlsafe_verifier_and_challenge():
32 pair = codex.generate_pkce()
33
@@ -298,6 +307,76 @@ def test_write_auth_file_falls_back_for_file_bind_mount(tmp_path, monkeypatch):
307 assert list(tmp_path.glob(".auth.json.*.tmp")) == []
308
309
310 +def test_write_auth_file_falls_back_when_parent_rejects_temporary_files(tmp_path, monkeypatch):
311 + auth_path = tmp_path / "auth.json"
312 + auth_path.write_text(json.dumps({"tokens": {"refresh_token": "refresh-0"}}), encoding="utf-8")
313 + open_file = codex.os.open
314 +
315 + def reject_temporary_file(path, flags, mode=0o777):
316 + if str(path).endswith(".tmp"):
317 + raise OSError(codex.errno.EACCES, "Permission denied", path)
318 + return open_file(path, flags, mode)
319 +
320 + monkeypatch.setattr(codex.os, "open", reject_temporary_file)
321 +
322 + codex.write_auth_file(auth_path, {"tokens": {"refresh_token": "refresh-1"}})
323 +
324 + assert json.loads(auth_path.read_text(encoding="utf-8")) == {
325 + "tokens": {"refresh_token": "refresh-1"}
326 + }
327 + assert not auth_path.with_name(".auth.json.lock").exists()
328 +
329 +
330 +def test_resolve_auth_write_path_preserves_custom_symlink_target(tmp_path, monkeypatch):
331 + target = tmp_path / "mounted" / "auth.json"
332 + target.parent.mkdir()
333 + target.write_text(json.dumps({"tokens": {"refresh_token": "refresh-0"}}), encoding="utf-8")
334 + symlink = tmp_path / "auth.json"
335 + symlink.symlink_to(target)
336 + monkeypatch.setattr(codex, "codex_config", lambda: {"auth_file_path": str(symlink)})
337 +
338 + resolved_path = codex.resolve_auth_write_path()
339 + codex.write_auth_file(resolved_path, {"tokens": {"refresh_token": "refresh-1"}})
340 +
341 + assert resolved_path == target
342 + assert symlink.is_symlink()
343 + assert json.loads(target.read_text(encoding="utf-8")) == {
344 + "tokens": {"refresh_token": "refresh-1"}
345 + }
346 +
347 +
348 +def test_lock_file_retries_windows_contention(tmp_path, monkeypatch):
349 + class FakeMsvcrt:
350 + LK_NBLCK = 1
351 + LK_UNLCK = 2
352 +
353 + def __init__(self):
354 + self.calls: list[int] = []
355 +
356 + def locking(self, _descriptor: int, mode: int, _length: int) -> None:
357 + self.calls.append(mode)
358 + if mode == self.LK_NBLCK and self.calls.count(mode) < 3:
359 + raise OSError(codex.errno.EACCES, "Permission denied")
360 +
361 + fake_msvcrt = FakeMsvcrt()
362 + sleeps: list[float] = []
363 + monkeypatch.setattr(codex, "fcntl", None)
364 + monkeypatch.setattr(codex, "msvcrt", fake_msvcrt)
365 + monkeypatch.setattr(codex.time, "sleep", sleeps.append)
366 +
367 + with (tmp_path / "auth.lock").open("a+b") as handle:
368 + codex._lock_file(handle)
369 + codex._unlock_file(handle)
370 +
371 + assert fake_msvcrt.calls == [
372 + fake_msvcrt.LK_NBLCK,
373 + fake_msvcrt.LK_NBLCK,
374 + fake_msvcrt.LK_NBLCK,
375 + fake_msvcrt.LK_UNLCK,
376 + ]
377 + assert sleeps == [codex.WINDOWS_LOCK_RETRY_SECONDS, codex.WINDOWS_LOCK_RETRY_SECONDS]
378 +
379 +
380 def test_load_auth_serializes_refresh_across_threads(tmp_path, monkeypatch):
381 auth_path = tmp_path / "auth.json"
382 _write_refreshable_auth(auth_path)
@@ -474,6 +553,7 @@ def _rotated_tokens() -> dict[str, str]:
553
554 def _load_auth_in_process(auth_path: str, refresh_started, release_refresh, calls, results) -> None:
555 codex.resolve_auth_write_path = lambda: Path(auth_path)
556 + codex._auth_lock_path = lambda _path: Path(auth_path).parent / ".auth.lock"
557
558 def refresh_tokens(refresh_token: str) -> dict[str, str]:
559 calls.put(refresh_token)