Support bind-mounted private OAuth auth files

Cooper Gamble committed May 29, 2026 at 11:15 UTC 47078c00ae520df5bc19472c98437886eeb5cb08
2 files changed +32 -1
plugins/_oauth/helpers/codex.py
+15 -1
@@ -1,6 +1,7 @@
1 from __future__ import annotations
2
3 import base64
4 +import errno
5 import hashlib
6 import json
7 import os
@@ -1047,7 +1048,13 @@ def _write_auth_file_unlocked(path: Path, data: dict[str, Any]) -> None:
1048 handle.write(json.dumps(data, indent=2) + "\n")
1049 handle.flush()
1050 os.fsync(handle.fileno())
1050 - os.replace(temporary_path, path)
1051 + try:
1052 + os.replace(temporary_path, path)
1053 + except OSError as exc:
1054 + if exc.errno != errno.EBUSY:
1055 + raise
1056 + # Linux rejects replacement when a supported custom auth path is a file bind mount.
1057 + _write_auth_file_in_place(path, data)
1058 try:
1059 path.chmod(0o600)
1060 except OSError:
@@ -1056,6 +1063,13 @@ def _write_auth_file_unlocked(path: Path, data: dict[str, Any]) -> None:
1063 temporary_path.unlink(missing_ok=True)
1064
1065
1066 +def _write_auth_file_in_place(path: Path, data: dict[str, Any]) -> None:
1067 + with path.open("w", encoding="utf-8") as handle:
1068 + handle.write(json.dumps(data, indent=2) + "\n")
1069 + handle.flush()
1070 + os.fsync(handle.fileno())
1071 +
1072 +
1073 def _validate_private_auth_path(path: Path) -> Path:
1074 if _path_key(path) in {_path_key(candidate) for candidate in _known_codex_auth_paths()}:
1075 raise RuntimeError(
tests/test_oauth_codex.py
+17
@@ -281,6 +281,23 @@ def test_write_auth_file_uses_atomic_replace_and_private_permissions(tmp_path, m
281 assert list(tmp_path.glob(".auth.json.*.tmp")) == []
282
283
284 +def test_write_auth_file_falls_back_for_file_bind_mount(tmp_path, monkeypatch):
285 + auth_path = tmp_path / "auth.json"
286 + auth_path.write_text(json.dumps({"tokens": {"refresh_token": "refresh-0"}}), encoding="utf-8")
287 +
288 + def reject_replace(source, destination):
289 + raise OSError(codex.errno.EBUSY, "Device or resource busy", destination)
290 +
291 + monkeypatch.setattr(codex.os, "replace", reject_replace)
292 +
293 + codex.write_auth_file(auth_path, {"tokens": {"refresh_token": "refresh-1"}})
294 +
295 + assert json.loads(auth_path.read_text(encoding="utf-8")) == {
296 + "tokens": {"refresh_token": "refresh-1"}
297 + }
298 + assert list(tmp_path.glob(".auth.json.*.tmp")) == []
299 +
300 +
301 def test_load_auth_serializes_refresh_across_threads(tmp_path, monkeypatch):
302 auth_path = tmp_path / "auth.json"
303 _write_refreshable_auth(auth_path)