Skip to content

Commit afff7dc

Browse files
committed
fix(session): Implement session shelving to persist resume state across account switches
1 parent dafcd48 commit afff7dc

2 files changed

Lines changed: 149 additions & 6 deletions

File tree

src/codex_account_manager/config/manager.py

Lines changed: 74 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,29 @@ def delete_account(self, name: str):
205205
"""Alias for remove_account (Legacy compatibility)."""
206206
self.remove_account(name)
207207

208+
def _restore_target_session(self, target_account: Account, vault_slug: str):
209+
"""Restores sessions from storage to ~/.codex/sessions."""
210+
legacy_auth_file = self._get_legacy_auth_file()
211+
live_sessions_dir = legacy_auth_file.parent / "sessions"
212+
213+
# Ensure it's clean (should be clean from shelving, but just in case)
214+
if live_sessions_dir.exists():
215+
shutil.rmtree(live_sessions_dir)
216+
217+
# Derive canonical key for retrieval
218+
if vault_slug == "personal":
219+
storage_key = target_account.name
220+
else:
221+
storage_key = f"{vault_slug}/{target_account.name}"
222+
223+
storage_dir = self._get_session_storage_dir(storage_key)
224+
225+
if storage_dir.exists():
226+
shutil.copytree(storage_dir, live_sessions_dir)
227+
else:
228+
# If no stored session, just ensure the dir exists empty
229+
live_sessions_dir.mkdir(parents=True, exist_ok=True)
230+
208231
def switch_account(self, name: str):
209232
"""Switches active account. Validates existence first."""
210233
# 1. Validate & Normalize
@@ -216,9 +239,15 @@ def switch_account(self, name: str):
216239
# Check existence in vault (slugifies internally)
217240
acc = vault.get_account(acc_slug)
218241

219-
# 2. Sync Legacy Auth
242+
# 2a. Shelve Current Session (Save state of the OLD account)
243+
self._shelve_current_session()
244+
245+
# 2b. Sync Legacy Auth (Update credentials)
220246
self.sync_legacy_auth(acc)
221247

248+
# 2c. Restore Target Session (Load state of the NEW account)
249+
self._restore_target_session(acc, vault_slug)
250+
222251
# 3. Update Config with Canonical Reference
223252
cfg = self.load_config()
224253

@@ -231,6 +260,42 @@ def switch_account(self, name: str):
231260

232261
self.save_config(cfg)
233262

263+
def _get_session_storage_dir(self, account_name: str) -> Path:
264+
"""Returns the directory where sessions for a specific account are stored."""
265+
# We store shelved sessions in the root config dir separated by account
266+
return self.root / "shelved_sessions" / slugify(account_name)
267+
268+
def _shelve_current_session(self):
269+
"""Moves current ~/.codex/sessions to storage."""
270+
cfg = self.load_config()
271+
if not cfg.active_account:
272+
return # No active account to save session for
273+
274+
legacy_auth_file = self._get_legacy_auth_file()
275+
live_sessions_dir = legacy_auth_file.parent / "sessions"
276+
277+
if not live_sessions_dir.exists():
278+
return
279+
280+
# Identify where to save it
281+
# We handle 'personal/slug' vs 'slug' normalisation via the config
282+
# We just need a unique folder name.
283+
storage_dir = self._get_session_storage_dir(cfg.active_account)
284+
storage_dir.parent.mkdir(parents=True, exist_ok=True)
285+
286+
# If storage dir exists, we merge or replace?
287+
# rmtree+copytree is safest to ensure exact mirroring of state
288+
if storage_dir.exists():
289+
shutil.rmtree(storage_dir)
290+
291+
# Move mechanism: We copytree then rmtree orig, or move.
292+
# shutil.move can fail across filesystems, copytree is robust.
293+
# But we want to LEAVE the directory empty for the next guy? No, we restore next.
294+
shutil.copytree(live_sessions_dir, storage_dir)
295+
296+
# Clean up the live directory to be ready for the new account (or just to leave it clean)
297+
shutil.rmtree(live_sessions_dir)
298+
234299
def sync_legacy_auth(self, account: Account):
235300
"""Writes credentials to ~/.codex/auth.json (Legacy Support)."""
236301
# This logic used to be implicit in `list` or `status` checks.
@@ -260,11 +325,14 @@ def sync_legacy_auth(self, account: Account):
260325
f.write(json.dumps(data, indent=2))
261326

262327

263-
# Clear sessions to prevent "Token data not available" error
264-
sessions_dir = legacy_dir / "sessions"
265-
if sessions_dir.exists():
266-
shutil.rmtree(sessions_dir)
267-
sessions_dir.mkdir() # Recreate empty
328+
# STOP DELETING SESSIONS
329+
# sessions_dir = legacy_dir / "sessions"
330+
# if sessions_dir.exists():
331+
# shutil.rmtree(sessions_dir)
332+
# sessions_dir.mkdir() # Recreate empty
333+
334+
# Just ensure the directory exists so Codex doesn't crash if it expects it
335+
(legacy_dir / "sessions").mkdir(exist_ok=True)
268336

269337
os.chmod(legacy_auth_file, 0o600)
270338

tests/test_session_shelving.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import shutil
2+
import pytest
3+
from pathlib import Path
4+
from codex_account_manager.config.manager import ConfigManager
5+
from codex_account_manager.config.models import Account, AccountType
6+
7+
@pytest.fixture
8+
def mock_storage_root(tmp_path):
9+
"""Creates a temporary home directory for testing."""
10+
root = tmp_path / ".codex-accounts"
11+
legacy_auth = tmp_path / ".codex" / "auth.json"
12+
legacy_auth.parent.mkdir(parents=True, exist_ok=True)
13+
return root, legacy_auth
14+
15+
def test_session_persistence(mock_storage_root, monkeypatch):
16+
"""
17+
Verifies that sessions are shelved and restored correctly when switching accounts.
18+
"""
19+
root, legacy_auth = mock_storage_root
20+
21+
# Mock environment to map to our temp paths
22+
monkeypatch.setenv("HOME", str(root.parent))
23+
monkeypatch.setattr("codex_account_manager.config.manager.DEFAULT_CONFIG_ROOT", root)
24+
monkeypatch.setattr("codex_account_manager.config.manager.LEGACY_AUTH_FILE", legacy_auth)
25+
26+
mgr = ConfigManager(root_path=root)
27+
28+
# 1. Create two accounts
29+
acc1 = Account(name="personal", api_key="sk-1", type=AccountType.API_KEY)
30+
acc2 = Account(name="work", api_key="sk-2", type=AccountType.API_KEY)
31+
mgr.save_account(acc1)
32+
mgr.save_account(acc2)
33+
34+
# 2. Start as 'personal'
35+
mgr.switch_account("personal")
36+
37+
# 3. Simulate a Codex session file being created
38+
live_session_dir = legacy_auth.parent / "sessions"
39+
live_session_dir.mkdir(parents=True, exist_ok=True)
40+
session_file = live_session_dir / "session-123.json"
41+
session_file.write_text('{"id": "session-123", "context": "personal"}')
42+
43+
assert session_file.exists(), "Session file should simulate existing in ~/.codex/sessions"
44+
45+
# 4. Switch to 'work'
46+
mgr.switch_account("work")
47+
48+
# ASSERTIONS:
49+
# - Live session dir should be cleaned or empty (since 'work' has no previous session)
50+
# - Personal session should be in shelving
51+
52+
shelved_personal = root / "shelved_sessions" / "personal" / "session-123.json"
53+
assert shelved_personal.exists(), "Personal session should be shelved"
54+
assert shelved_personal.read_text() == '{"id": "session-123", "context": "personal"}'
55+
56+
assert not session_file.exists(), "Live session should be cleared after switch"
57+
58+
# 5. Simulate 'work' creating a session
59+
live_session_dir.mkdir(parents=True, exist_ok=True) # Ensure dir exists if logic cleared it
60+
work_session = live_session_dir / "work-session.json"
61+
work_session.write_text('{"id": "work-1", "context": "work"}')
62+
63+
# 6. Switch back to 'personal'
64+
mgr.switch_account("personal")
65+
66+
# ASSERTIONS
67+
# - Live session dir should now contain 'session-123.json' (Restored)
68+
# - 'work-session.json' should be in shelving for 'work'
69+
70+
restored_personal = live_session_dir / "session-123.json"
71+
assert restored_personal.exists(), "Personal session should be restored"
72+
assert restored_personal.read_text() == '{"id": "session-123", "context": "personal"}'
73+
74+
shelved_work = root / "shelved_sessions" / "work" / "work-session.json"
75+
assert shelved_work.exists(), "Work session should be shelved"

0 commit comments

Comments
 (0)