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)