from collections import OrderedDict import json from pathlib import Path from types import SimpleNamespace from urllib.parse import urlparse import pytest from api import config as api_config from api import models, routes, workspace def _write_session_sidecar(monkeypatch, tmp_path, session): session_dir = tmp_path / "sessions" session_dir.mkdir(exist_ok=True) monkeypatch.setattr(models, "SESSION_DIR", session_dir) monkeypatch.setattr(models, "_write_session_index", lambda **_kwargs: None) sidecar = session_dir / f"{session.session_id}.json" sidecar.write_text( json.dumps( { "session_id": session.session_id, "workspace": session.workspace, "messages": [{"role": "user", "content": "preserve me"}], } ), encoding="utf-8", ) return sidecar def test_profile_default_workspace_uses_live_config_default(monkeypatch, tmp_path): live_default = tmp_path / "live-default" live_default.mkdir() monkeypatch.setattr(api_config, "DEFAULT_WORKSPACE", live_default) monkeypatch.setattr(api_config, "get_config", lambda: {}) assert workspace._profile_default_workspace() == str(live_default.resolve()) def test_implicit_workspace_recovery_keeps_fallback_lazy(monkeypatch, tmp_path): valid = tmp_path / "valid" valid.mkdir() monkeypatch.setattr(workspace, "_home_path", lambda: tmp_path) resolved, recovered = workspace.resolve_implicit_workspace_with_recovery( valid, lambda: (_ for _ in ()).throw(AssertionError("fallback should stay lazy")), ) assert resolved == valid.resolve() assert recovered is False def test_resolve_chat_workspace_with_recovery_repairs_missing_implicit_workspace(monkeypatch, tmp_path): fallback = tmp_path / "fallback" fallback.mkdir() stale = tmp_path / "deleted-workspace" session = SimpleNamespace(session_id="sess-1", workspace=str(stale)) sidecar = _write_session_sidecar(monkeypatch, tmp_path, session) monkeypatch.setattr(workspace, "_home_path", lambda: tmp_path) monkeypatch.setattr(workspace, "load_workspaces", lambda: []) monkeypatch.setattr(routes, "get_last_workspace", lambda: str(fallback)) resolved = routes._resolve_chat_workspace_with_recovery(session, None) assert resolved == str(fallback.resolve()) assert session.workspace == str(fallback.resolve()) assert str(fallback.resolve()) in sidecar.read_text(encoding="utf-8") def test_chat_recovery_persistence_failure_fails_closed(monkeypatch, tmp_path): fallback = tmp_path / "fallback" fallback.mkdir() stale = tmp_path / "deleted-workspace" session = SimpleNamespace( session_id="sess-chat-save-fails", workspace=str(stale), save=lambda **_kwargs: (_ for _ in ()).throw(OSError("disk full")), ) monkeypatch.setattr(workspace, "_home_path", lambda: tmp_path) monkeypatch.setattr(workspace, "load_workspaces", lambda: []) monkeypatch.setattr(routes, "get_last_workspace", lambda: str(fallback)) with pytest.raises(routes.WorkspaceBindingPersistenceError): routes._resolve_chat_workspace_with_recovery(session, None) assert session.workspace == str(stale) def test_resolve_chat_workspace_with_recovery_preserves_explicit_errors(monkeypatch, tmp_path): fallback = tmp_path / "fallback" fallback.mkdir() stale = tmp_path / "deleted-workspace" def fake_resolve(value): if value == str(stale): raise ValueError(f"Path does not exist: {stale}") return Path(value).resolve() saved = {"count": 0} def fake_save(): saved["count"] += 1 session = SimpleNamespace(session_id="sess-2", workspace=str(fallback), save=fake_save) monkeypatch.setattr(routes, "resolve_trusted_workspace", fake_resolve) monkeypatch.setattr(routes, "get_last_workspace", lambda: str(fallback)) with pytest.raises(ValueError, match="Path does not exist"): routes._resolve_chat_workspace_with_recovery(session, str(stale)) assert session.workspace == str(fallback) assert saved["count"] == 0 def _post_new_session_with_workspace( monkeypatch, body, previous_session, fallback, *, previous_visible=True, ): import api.session_lifecycle as session_lifecycle captured = {"new": [], "fallback": 0} class _NewSession: session_id = "new-session" profile = "default" messages = [] def __init__(self, workspace): self.workspace = workspace def compact(self): return { "session_id": self.session_id, "profile": self.profile, "workspace": self.workspace, } def get_fallback(): captured["fallback"] += 1 return str(fallback) def create_session(**kwargs): captured["new"].append(kwargs) return _NewSession(kwargs["workspace"]) monkeypatch.setattr(routes, "read_body", lambda _handler: body) monkeypatch.setattr(routes, "_check_csrf", lambda _handler: True) monkeypatch.setattr(routes, "_csrf_exempt_path", lambda _path: False) monkeypatch.setattr(routes, "_handle_extension_sidecar_proxy", lambda *_a, **_k: False) monkeypatch.setattr(routes, "_guard_request_session_visibility", lambda *_a, **_k: True) monkeypatch.setattr( routes, "_session_id_visible_to_request_profile", lambda *_a, **_k: previous_visible, ) monkeypatch.setattr( routes, "get_session", lambda sid, metadata_only=False: previous_session if sid == body.get("prev_session_id") else (_ for _ in ()).throw(KeyError(sid)), ) monkeypatch.setattr(routes, "get_last_workspace", get_fallback) monkeypatch.setattr(routes, "_session_model_state_from_request", lambda *_a: (None, None)) monkeypatch.setattr(routes, "new_session", create_session) monkeypatch.setattr( session_lifecycle, "_register_background_commit_thread", lambda _thread: False, ) monkeypatch.setattr( routes, "j", lambda _handler, payload, status=200, **_kwargs: captured.setdefault( "response", (status, payload) ), ) monkeypatch.setattr( routes, "bad", lambda _handler, message, status=400, **_kwargs: captured.setdefault( "error", (status, message) ), ) handler = SimpleNamespace(command="POST", headers={}) routes.handle_post(handler, urlparse("/api/session/new")) return captured def test_session_new_recovers_deleted_workspace_inherited_from_visible_previous_session( monkeypatch, tmp_path ): fallback = tmp_path / "fallback" fallback.mkdir() stale = tmp_path / "deleted-workspace" previous = SimpleNamespace( session_id="previous-session", profile="default", workspace=str(stale), ) body = { "workspace": str(stale), "workspace_inherited_from_prev_session": True, "prev_session_id": previous.session_id, "profile": "default", "worktree": False, } monkeypatch.setattr(workspace, "_home_path", lambda: tmp_path) monkeypatch.setattr(workspace, "load_workspaces", lambda: []) captured = _post_new_session_with_workspace( monkeypatch, body, previous, fallback, ) assert "error" not in captured assert captured["response"][0] == 200 assert captured["response"][1]["session"]["workspace"] == str(fallback.resolve()) assert captured["new"][0]["workspace"] == str(fallback.resolve()) assert captured["fallback"] == 1 @pytest.mark.parametrize( ("mark_inherited", "previous_visible", "stored_workspace"), [ pytest.param(False, True, "requested", id="explicit-request"), pytest.param(True, False, "requested", id="foreign-previous-session"), pytest.param(True, True, "different", id="stored-workspace-mismatch"), ], ) def test_session_new_keeps_deleted_unverified_workspace_strict( monkeypatch, tmp_path, mark_inherited, previous_visible, stored_workspace, ): fallback = tmp_path / "fallback" fallback.mkdir() stale = tmp_path / "deleted-workspace" previous = SimpleNamespace( session_id="previous-session", profile="default", workspace=(str(stale) if stored_workspace == "requested" else str(tmp_path / "other")), ) body = { "workspace": str(stale), "prev_session_id": previous.session_id, "profile": "default", "worktree": False, } if mark_inherited: body["workspace_inherited_from_prev_session"] = True monkeypatch.setattr(workspace, "_home_path", lambda: tmp_path) monkeypatch.setattr(workspace, "load_workspaces", lambda: []) captured = _post_new_session_with_workspace( monkeypatch, body, previous, fallback, previous_visible=previous_visible, ) assert captured["error"][0] == 400 assert "does not exist" in captured["error"][1].lower() assert captured["new"] == [] assert captured["fallback"] == 0 def test_chat_recovery_preserves_existing_implicit_trust_error(monkeypatch, tmp_path): home = tmp_path / "home" fallback = home / "fallback" fallback.mkdir(parents=True) outside = tmp_path / "outside" outside.mkdir() saved = {"count": 0} session = SimpleNamespace( session_id="sess-untrusted", workspace=str(outside), save=lambda: saved.__setitem__("count", saved["count"] + 1), ) monkeypatch.setattr(workspace, "_home_path", lambda: home) monkeypatch.setattr(workspace, "load_workspaces", lambda: []) monkeypatch.setattr(workspace, "_BOOT_DEFAULT_WORKSPACE", fallback) monkeypatch.setattr(routes, "get_last_workspace", lambda: str(fallback)) with pytest.raises(ValueError, match="outside the user home directory"): routes._resolve_chat_workspace_with_recovery(session, None) assert session.workspace == str(outside) assert saved["count"] == 0 @pytest.mark.parametrize( "terminal_cfg", [ pytest.param( {"backend": "ssh", "cwd": "/Users/joeyshiue"}, id="cwd-absolute", ), pytest.param({"backend": "ssh"}, id="cwd-omitted"), pytest.param({"backend": "ssh", "cwd": ""}, id="cwd-empty"), pytest.param({"backend": "ssh", "cwd": "."}, id="cwd-dot"), ], ) def test_chat_recovery_preserves_remote_workspace_rejection( monkeypatch, tmp_path, terminal_cfg ): candidate = "/Users/other/projects/demo" fallback_path = tmp_path / "fallback" fallback_path.mkdir() fallback_calls = {"count": 0} saved = {"count": 0} session = SimpleNamespace( session_id="sess-remote-untrusted", workspace=candidate, save=lambda: saved.__setitem__("count", saved["count"] + 1), ) monkeypatch.setattr( api_config, "get_config", lambda: {"terminal": terminal_cfg}, ) monkeypatch.setattr(workspace, "_home_path", lambda: tmp_path) def fallback(): fallback_calls["count"] += 1 return fallback_path monkeypatch.setattr(routes, "get_last_workspace", fallback) with pytest.raises(ValueError, match="Path does not exist"): routes._resolve_chat_workspace_with_recovery(session, None) assert fallback_calls["count"] == 0 assert session.workspace == candidate assert saved["count"] == 0 def test_list_dir_recovers_missing_implicit_session_workspace(monkeypatch, tmp_path): fallback = tmp_path / "fallback" fallback.mkdir() stale = tmp_path / "deleted-workspace" session = SimpleNamespace( session_id="sess-list", workspace=str(stale), ) sidecar = _write_session_sidecar(monkeypatch, tmp_path, session) captured = {} monkeypatch.setattr(workspace, "_home_path", lambda: tmp_path) monkeypatch.setattr(workspace, "load_workspaces", lambda: []) monkeypatch.setattr(routes, "get_session", lambda _sid: session) monkeypatch.setattr(routes, "get_last_workspace", lambda: str(fallback)) def fake_list_dir(workspace_path, rel_path): captured["workspace"] = workspace_path captured["rel_path"] = rel_path return [] monkeypatch.setattr(routes, "list_dir", fake_list_dir) monkeypatch.setattr(routes, "dir_signature", lambda *_args: "sig") monkeypatch.setattr(routes, "j", lambda _handler, payload, **_kwargs: payload) payload = routes._handle_list_dir( object(), urlparse("/api/list?session_id=sess-list&path=.") ) assert captured == {"workspace": fallback.resolve(), "rel_path": "."} assert session.workspace == str(fallback.resolve()) assert str(fallback.resolve()) in sidecar.read_text(encoding="utf-8") assert payload == { "entries": [], "signature": "sig", "path": ".", "workspace": str(fallback.resolve()), "workspace_recovered": True, } def test_list_recovery_stays_bound_when_global_fallback_changes( monkeypatch, tmp_path ): """A later mutation must use the root that the Files pane displayed.""" fallback_a = tmp_path / "fallback-a" fallback_b = tmp_path / "fallback-b" fallback_a.mkdir() fallback_b.mkdir() stale = tmp_path / "deleted-workspace" selected = {"workspace": str(fallback_a)} session = SimpleNamespace( session_id="sess-authority", workspace=str(stale), profile=None, ) sidecar = _write_session_sidecar(monkeypatch, tmp_path, session) captured = {} monkeypatch.setattr(workspace, "_home_path", lambda: tmp_path) monkeypatch.setattr(workspace, "load_workspaces", lambda: []) monkeypatch.setattr(routes, "get_session", lambda _sid, **_kwargs: session) monkeypatch.setattr( routes, "get_last_workspace", lambda: selected["workspace"] ) def capture_list(workspace_path, _rel): captured["listed"] = workspace_path return [] monkeypatch.setattr(routes, "list_dir", capture_list) monkeypatch.setattr(routes, "dir_signature", lambda *_args: "sig") monkeypatch.setattr(routes, "j", lambda _handler, payload, **_kwargs: payload) payload = routes._handle_list_dir( object(), urlparse("/api/list?session_id=sess-authority&path=.") ) selected["workspace"] = str(fallback_b) assert payload["workspace"] == str(fallback_a.resolve()) assert session.workspace == str(fallback_a.resolve()) assert captured["listed"] == fallback_a.resolve() assert str(fallback_a.resolve()) in sidecar.read_text(encoding="utf-8") def test_persisted_list_recovery_anchors_later_create_dir_to_fallback_a( monkeypatch, tmp_path ): """Reproduce the Maintainer's A→B sequence across fresh sidecar loads.""" session_dir = tmp_path / "sessions" session_dir.mkdir() fallback_a = tmp_path / "fallback-a" fallback_b = tmp_path / "fallback-b" fallback_a.mkdir() fallback_b.mkdir() stale = tmp_path / "deleted-workspace" selected = {"workspace": str(fallback_a)} sid = "sess-http-authority" monkeypatch.setattr(models, "SESSION_DIR", session_dir) monkeypatch.setattr(models, "SESSIONS", OrderedDict()) monkeypatch.setattr(models, "_write_session_index", lambda **_kwargs: None) monkeypatch.setattr(workspace, "_home_path", lambda: tmp_path) monkeypatch.setattr(workspace, "load_workspaces", lambda: []) monkeypatch.setattr(routes, "get_last_workspace", lambda: selected["workspace"]) monkeypatch.setattr(models, "get_last_workspace", lambda: selected["workspace"]) monkeypatch.setattr(routes, "j", lambda _handler, payload, **_kwargs: payload) monkeypatch.setattr( routes, "bad", lambda _handler, message, status=400: {"error": message, "status": status}, ) session = models.Session( session_id=sid, workspace=str(stale), messages=[{"role": "user", "content": "preserve transcript"}], ) session.save(skip_index=True) models.SESSIONS.clear() listed = routes._handle_list_dir( object(), urlparse(f"/api/list?session_id={sid}&path=.") ) persisted = models.Session.load(sid) assert listed["workspace"] == str(fallback_a.resolve()) assert listed["workspace_recovered"] is True assert persisted is not None assert persisted.workspace == str(fallback_a.resolve()) assert persisted.messages == [{"role": "user", "content": "preserve transcript"}] models.SESSIONS.clear() selected["workspace"] = str(fallback_b) created = routes._handle_create_dir( object(), {"session_id": sid, "path": "gate-wrong-root"} ) assert created == {"ok": True, "path": "gate-wrong-root"} assert (fallback_a / "gate-wrong-root").is_dir() assert not (fallback_b / "gate-wrong-root").exists() def test_list_recovery_persistence_failure_fails_closed(monkeypatch, tmp_path): fallback = tmp_path / "fallback" fallback.mkdir() stale = tmp_path / "deleted-workspace" calls = {"list_dir": 0} session = SimpleNamespace( session_id="sess-save-fails", workspace=str(stale), save=lambda **_kwargs: (_ for _ in ()).throw(OSError("disk full")), ) monkeypatch.setattr(workspace, "_home_path", lambda: tmp_path) monkeypatch.setattr(workspace, "load_workspaces", lambda: []) monkeypatch.setattr(routes, "get_session", lambda _sid, **_kwargs: session) monkeypatch.setattr(routes, "get_last_workspace", lambda: str(fallback)) monkeypatch.setattr( routes, "list_dir", lambda *_args: calls.__setitem__("list_dir", calls["list_dir"] + 1), ) monkeypatch.setattr( routes, "bad", lambda _handler, message, status=400: {"error": message, "status": status}, ) payload = routes._handle_list_dir( object(), urlparse("/api/list?session_id=sess-save-fails&path=.") ) assert payload["status"] >= 400 assert "persist" in payload["error"].lower() assert calls["list_dir"] == 0 assert session.workspace == str(stale) def test_list_dir_does_not_recover_unpersistable_cli_workspace( monkeypatch, tmp_path ): stale = tmp_path / "deleted-cli-workspace" fallback = tmp_path / "fallback" fallback.mkdir() calls = {"fallback": 0, "list_dir": 0} monkeypatch.setattr(routes, "get_session", lambda _sid: (_ for _ in ()).throw(KeyError(_sid))) monkeypatch.setattr( routes, "get_cli_sessions", lambda: [{"session_id": "cli-stale", "workspace": str(stale)}], ) def get_fallback(): calls["fallback"] += 1 return str(fallback) monkeypatch.setattr(routes, "get_last_workspace", get_fallback) monkeypatch.setattr( routes, "list_dir", lambda *_args: calls.__setitem__("list_dir", calls["list_dir"] + 1), ) monkeypatch.setattr( routes, "bad", lambda _handler, message, status=400: {"error": message, "status": status}, ) payload = routes._handle_list_dir( object(), urlparse("/api/list?session_id=cli-stale&path=.") ) assert payload["status"] == 404 assert calls == {"fallback": 0, "list_dir": 0} @pytest.mark.parametrize( "terminal_cfg", [ pytest.param( {"backend": "ssh", "cwd": "/Users/joeyshiue"}, id="cwd-absolute", ), pytest.param({"backend": "ssh"}, id="cwd-omitted"), pytest.param({"backend": "ssh", "cwd": ""}, id="cwd-empty"), pytest.param({"backend": "ssh", "cwd": "."}, id="cwd-dot"), ], ) def test_list_dir_preserves_remote_workspace_rejection( monkeypatch, tmp_path, terminal_cfg ): candidate = "/Users/other/projects/demo" fallback_path = tmp_path / "fallback" fallback_path.mkdir() session = SimpleNamespace(session_id="sess-list-remote", workspace=candidate) calls = {"fallback": 0, "list_dir": 0} monkeypatch.setattr( api_config, "get_config", lambda: {"terminal": terminal_cfg}, ) monkeypatch.setattr(workspace, "_home_path", lambda: tmp_path) monkeypatch.setattr(routes, "get_session", lambda _sid: session) def fallback(): calls["fallback"] += 1 return fallback_path def fake_list_dir(*_args): calls["list_dir"] += 1 return [] monkeypatch.setattr(routes, "get_last_workspace", fallback) monkeypatch.setattr(routes, "list_dir", fake_list_dir) monkeypatch.setattr( routes, "bad", lambda _handler, message, status=400: {"error": message, "status": status}, ) payload = routes._handle_list_dir( object(), urlparse("/api/list?session_id=sess-list-remote&path=.") ) assert isinstance(payload, dict) assert payload["status"] == 404 assert "Path does not exist" in payload["error"] assert calls == {"fallback": 0, "list_dir": 0}