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)