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():