From 88f330ffa5447e433d3430f0e676bd791663705d Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Thu, 8 Oct 2026 09:28:32 +0800 Subject: [PATCH] fix(chat): isolate native Codex context for workspace-only sessions Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../app-conversation-and-async-inbox-v0.md | 26 +++++-- .../capabilities/native_chat/codex_context.py | 78 +++++++++++++++++++ loopx/chat_agent.py | 31 +++++--- loopx/chat_runtime.py | 15 +--- .../collaboration/conversation_scope.ts | 5 ++ .../project_conversation.test.ts | 15 ++++ tests/test_chat_agent.py | 29 +++++-- tests/test_chat_codex_context.py | 65 ++++++++++++++++ tests/test_chat_codex_home.py | 35 +++++++++ 9 files changed, 263 insertions(+), 36 deletions(-) create mode 100644 loopx/capabilities/native_chat/codex_context.py create mode 100644 tests/test_chat_codex_context.py diff --git a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md index 6f1b045f44..44081a5fad 100644 --- a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md +++ b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md @@ -112,9 +112,19 @@ inherited variables. Windows execution remains unqualified by the live probes. The narrowed profile also disables automatic skill catalog injection and project instruction discovery. The agent can read workspace-owned `AGENTS.md` and skills through its bounded file tools. Ordinary project Sessions keep native instruction -and skill discovery. This does not disable explicit host skill resolution, clear -old conversation context or suppress account-level instructions supplied by the -host; those remain separate privacy checks. +and skill discovery. Workspace-only native Sessions now also launch with a private +`HOME`/`CODEX_HOME`, a minimal process environment and file-based native credential +storage. Effective MCP servers, including managed and project configuration layers, +are disabled before the thread opens. Core derives the store identity from the +authorized workspace and App/owner binding; topics retain independent threads +without requiring a new login for every message. Default `host_default` Sessions +keep the account's existing native configuration and authentication. + +No authentication or conversation history is copied into the new store. Log in +through the native Codex flow with `CODEX_HOME` set to the Session's recorded home. +Existing workspace-only Sessions created with a shared home cannot silently +resume or migrate: choose a new Session explicitly after configuring its login. +Ordinary legacy Sessions keep their existing home and exact-thread resume behavior. The adapter sends the Core-owned named permissions profile, never a simultaneous legacy sandbox override, on start and exact-thread resume. It verifies the returned @@ -124,10 +134,12 @@ permissions. Other executors are rejected for this scope. The native helper uses the canonical Codex executable so a home-directory symlink needs no read grant. This is a filesystem-tool boundary, not complete community Bot isolation. It does -not authorize group audiences or isolate host-loaded instructions, skills, MCP -credentials, dynamic tools or a private registry. Those boundaries need their own -qualification before a public group is enabled; a successful file probe is not -public Bot acceptance. +not authorize group audiences, erase historical context, isolate arbitrary host +dynamic tools or make a checkout containing private files safe to publish. Group +admission, a clean public workspace, supported independent authentication and live +privacy/interaction qualification remain required before public enablement; native +context isolation and a successful file probe are prerequisites, not public Bot +acceptance. Typed Core/HTTP/native-host regressions qualify default writes, explicit read-only, workspace identity, independent App grants, old-Session rejection and exact-thread diff --git a/loopx/capabilities/native_chat/codex_context.py b/loopx/capabilities/native_chat/codex_context.py new file mode 100644 index 0000000000..925776fadc --- /dev/null +++ b/loopx/capabilities/native_chat/codex_context.py @@ -0,0 +1,78 @@ +"""Native Codex process context for the Core-owned project boundary. + +This is host adaptation, not another Session or authentication authority. +Ordinary Chat keeps its existing host configuration. Workspace-only Chat uses +an independent native store; no credentials or history are seeded into it. +""" +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any, Mapping + + +def codex_home(base_home: Path, policy: Mapping[str, Any] | None) -> Path: + base = base_home.expanduser().resolve() + key = policy.get("host_store_key") if policy else None + if key is None: + return base + if not isinstance(key, str): + raise ValueError("invalid Core-owned Codex store key") + home = base / "loopx-projects" / key / ".codex" + # An operator's pre-existing symlink must not silently select personal + # skills, authentication or a different upstream thread store. + if home.resolve() != home: + raise ValueError("workspace-only Codex home cannot follow a symlink") + return home + + +def home_for_project(base_home: Path, context: dict[str, str] | None) -> Path: + from ...control_plane.effect_runtime import effect_runtime_result + policy = effect_runtime_result("collaboration.project.session_identity", {"context": context}) if context else None + return codex_home(base_home, policy) + + +def require_session_home(base_home: Path, session: Mapping[str, Any]) -> None: + """A managed upstream thread's store is identity, never a routing override.""" + from ...chat_agent import CodexChatAgentError + context = session.get("project_context") + bound_home = session.get("codex_home") + isolated = context is not None and context.get("filesystem_scope") == "workspace_only" + expected = str(home_for_project(base_home, context)) + if (bound_home is not None and bound_home != expected) or (isolated and bound_home is None): + raise CodexChatAgentError( + "This managed Session belongs to a different Codex home. Restart LoopX Chat " + "with its original LOOPX_CHAT_CODEX_HOME; do not copy or rebind its history. " + "A workspace-only Session created with shared host context must be explicitly replaced.", + error_code="codex_home_mismatch", gate=None, + ) + + +def process_environment(home: Path, *, isolated: bool) -> dict[str, str]: + if not isolated: + return {**os.environ, "CODEX_HOME": str(home)} + host_home = home.parent + host_home.mkdir(parents=True, exist_ok=True, mode=0o700) + home.mkdir(exist_ok=True, mode=0o700) + env = {"HOME": str(host_home), "CODEX_HOME": str(home), "PATH": os.defpath} + # Windows process startup needs these public OS paths, not the account's + # PATH, provider keys, proxy credentials or other inherited variables. + if os.name == "nt": + env.update({key: os.environ[key] for key in ("SystemRoot", "WINDIR") if key in os.environ}) + env["USERPROFILE"] = str(host_home) + return env + + +def disable_mcp_servers(config: Mapping[str, Any], host_config: Mapping[str, Any]) -> dict[str, Any]: + """Disable effective native MCP servers, including project/managed layers. + + An empty map does not erase lower-layer entries. Only names are forwarded; + commands, environment and credentials remain with the native config owner. + """ + effective = config.get("mcp_servers", {}) + requested = host_config.get("mcp_servers", {}) + if not isinstance(effective, dict) or not isinstance(requested, dict): + raise ValueError("invalid native MCP server configuration") + return {**host_config, "mcp_servers": { + name: {"enabled": False} for name in effective.keys() | requested.keys() + }} diff --git a/loopx/chat_agent.py b/loopx/chat_agent.py index 75eb6cb467..aba5809e88 100644 --- a/loopx/chat_agent.py +++ b/loopx/chat_agent.py @@ -25,7 +25,7 @@ class CodexChatAgentError(RuntimeError): def __init__( - self, message: str, *, gate: dict[str, str], error_code: str = "host_gate" + self, message: str, *, gate: dict[str, str] | None, error_code: str = "host_gate" ) -> None: super().__init__(message) self.gate = gate @@ -250,11 +250,11 @@ def _model_catalog_compatibility_error() -> CodexChatAgentError: @contextmanager -def _current_builtin_model_catalog(codex_bin: str) -> Iterator[Path]: +def _current_builtin_model_catalog(codex_bin: str, *, environment: dict[str, str] | None = None) -> Iterator[Path]: """Materialize the selected Codex binary's current built-in catalog only.""" with tempfile.TemporaryDirectory(prefix="loopx-chat-codex-home-") as codex_home: - env = os.environ.copy() + env = dict(os.environ if environment is None else environment) env["CODEX_HOME"] = codex_home try: result = subprocess.run( @@ -627,13 +627,14 @@ def start( resolved = str(Path(resolved).resolve()) # Pin the host store explicitly, including compatibility retries. Never # redirect an existing thread by inheriting a different launch context. - runtime_home = ( + base_home = ( (codex_home or Path(os.environ.get("CODEX_HOME") or "~/.codex")) .expanduser() .resolve() ) - runtime_env = os.environ.copy() - runtime_env["CODEX_HOME"] = str(runtime_home) + from .capabilities.native_chat import codex_context + runtime_home = codex_context.codex_home(base_home, policy if project_context is not None else None) + runtime_env = codex_context.process_environment(runtime_home, isolated=bool(permissions_profile)) # Select an operator-defined native provider for ordinary Chat only. # Codex owns its configuration/authentication; never copy credentials # or replace a resumed thread to change its upstream transport. @@ -642,6 +643,10 @@ def start( if not execution_mode else "" ) command = [resolved, "app-server"] + if permissions_profile: + # Keep this native store independent even when the account's + # default credential backend is a shared OS keychain. + command.extend(["-c", 'cli_auth_credentials_store="file"']) if _compatibility_catalog_path is not None: command.extend( [ @@ -714,7 +719,7 @@ def start( read_project_defaults = project_context is not None and bool(resume_thread_id) and ( model is None or reasoning_effort is None ) - if read_project_defaults: + if read_project_defaults or permissions_profile: # Resume otherwise inherits the old thread's model/effort, even # when the owner's effective workspace configuration has changed. # Let Codex resolve trusted layers; never copy sandbox or approval @@ -726,10 +731,12 @@ def start( effective = config_result.get("config", {}) if not isinstance(effective, dict): raise session._runtime_error("Codex returned an invalid project configuration.") + if permissions_profile: + host_config = codex_context.disable_mcp_servers(effective, host_config or {}) selected = {**effective, **(host_config or {})} - if model is None: + if read_project_defaults and model is None: model = selected.get("model") - if reasoning_effort is None: + if read_project_defaults and reasoning_effort is None: reasoning_effort = selected.get("model_reasoning_effort") if any(value is not None and (not isinstance(value, str) or not value.strip()) for value in (model, reasoning_effort)): @@ -807,13 +814,13 @@ def start( # that public-safe context in each Turn prompt. Codex Goal mode is reserved # for autonomous execution; enabling it here causes conversational messages # to be treated as continuation ticks instead of the current user task. - session.next_request_id = 4 if read_project_defaults else 3 + session.next_request_id = 4 if read_project_defaults or permissions_profile else 3 return session except _LegacyModelCatalogSchemaError as exc: session.close() if _compatibility_catalog_path is not None: raise _model_catalog_compatibility_error() from exc - with _current_builtin_model_catalog(resolved) as catalog_path: + with _current_builtin_model_catalog(resolved, environment=runtime_env) as catalog_path: return cls.start( codex_bin=resolved, work_dir=root, @@ -828,7 +835,7 @@ def start( runtime_profile=runtime_profile, sandbox=selected_sandbox, project_context=project_context, - codex_home=runtime_home, + codex_home=base_home, model=model, reasoning_effort=reasoning_effort, dynamic_tools=dynamic_tools, diff --git a/loopx/chat_runtime.py b/loopx/chat_runtime.py index de7b01b2df..b47ce5ed91 100644 --- a/loopx/chat_runtime.py +++ b/loopx/chat_runtime.py @@ -19,7 +19,7 @@ manager_session_model_allocation, ) from .chat_coordination import PROJECT_COORDINATION_GUIDANCE, PROJECT_CONTEXT_VERSION, apply_context_handoff -from .capabilities.native_chat import project_context as project_context_policy +from .capabilities.native_chat import codex_context, project_context as project_context_policy from .control_plane.collaboration import conversation_scope from .capabilities.manager_runtime import ( load_effective_manager_runtime_profile, manager_runtime_session_fields, @@ -690,7 +690,7 @@ def open_session( upstream_thread_id=adapter.upstream_thread_id, upstream_mode="chat" if agent_id == "codex" else "default", channel_id=selected_channel, - codex_home=str(self.codex_home) if agent_id == "codex" else None, + codex_home=str(codex_context.home_for_project(self.codex_home, project_context)) if agent_id == "codex" else None, project_context=project_context, steward_context=steward_context, ) @@ -717,14 +717,7 @@ def _session_adapter_lock(self, session_id: str) -> threading.Lock: def _check_codex_home(self, session: dict[str, Any]) -> None: if session.get("agent_id") != "codex" or session.get("session_mode") == CHAT_SESSION_MODE_ATTACHED: return - bound_home = session.get("codex_home") - if bound_home is not None and bound_home != str(self.codex_home): - raise CodexChatAgentError( - "This managed Session belongs to a different Codex home. Restart LoopX Chat " - "with its original LOOPX_CHAT_CODEX_HOME; do not copy or rebind its history.", - error_code="codex_home_mismatch", - gate=None, - ) + codex_context.require_session_home(self.codex_home, session) def _ensure_adapter( self, @@ -969,7 +962,7 @@ def _ensure_adapter_locked( if session.get("agent_id") == "codex" and session.get("codex_home") is None: # A legacy session is bound only after successful upstream resume, # not when a service happens to start in a new environment. - self.store.update_session(session_id, codex_home=str(self.codex_home)) + self.store.update_session(session_id, codex_home=str(codex_context.home_for_project(self.codex_home, session.get("project_context")))) if is_manager_channel(session.get("channel_id")): assert manager_runtime is not None changes = { diff --git a/loopx/control_plane/collaboration/conversation_scope.ts b/loopx/control_plane/collaboration/conversation_scope.ts index 406df54734..4728363827 100644 --- a/loopx/control_plane/collaboration/conversation_scope.ts +++ b/loopx/control_plane/collaboration/conversation_scope.ts @@ -31,6 +31,11 @@ export function projectConversationIdentity(input: Record): Rec const context = normalizeProjectContext(input.context); const writable = context.grant === "workspace_write"; const permissions = context.filesystem_scope === "workspace_only" ? { + // Share authentication within this authorized workspace/owner binding, + // never with another App or local-owner context. Source topics retain + // separate threads without requiring a new login for every message. + host_store_key: [context.project_ref, ...(context.audience === "bound_owner" + ? [context.binding_id, context.provider_ref, context.operator_ref] : ["local"])].join("."), permissions_profile: `loopx_workspace_only_${writable ? "write" : "read"}`, host_config: { default_permissions: `loopx_workspace_only_${writable ? "write" : "read"}`, diff --git a/tests/control_plane_ts/project_conversation.test.ts b/tests/control_plane_ts/project_conversation.test.ts index 6bfeffc2bb..f2ec12cb4e 100644 --- a/tests/control_plane_ts/project_conversation.test.ts +++ b/tests/control_plane_ts/project_conversation.test.ts @@ -36,6 +36,7 @@ test("workspace-only filesystem scope is host-owned, persisted and narrower than assert.deepEqual(selected.context, scoped); const identity = projectConversationIdentity({context: selected.context}) as any; assert.equal(identity.host_config.default_permissions, identity.permissions_profile); + assert.equal(identity.host_store_key, `${context.project_ref}.local`); const profile = identity.host_config.permissions[identity.permissions_profile]; assert.equal(profile.filesystem[":root"], "deny"); assert.equal(profile.filesystem[":workspace_roots"]["."], grant === "workspace_write" ? "write" : "read"); @@ -55,4 +56,18 @@ test("workspace-only filesystem scope is host-owned, persisted and narrower than assert.throws(() => projectConversationIdentity({context: {...context, filesystem_scope: "anything"}})); assert.equal(projectConversationIdentity({context}).permissions_profile, undefined); assert.equal(projectConversationIdentity({context}).host_config, undefined); + assert.equal(projectConversationIdentity({context}).host_store_key, undefined); +}); + +test("private native store follows the authorized workspace and App owner, not a source topic or grant", () => { + const bound = {...context, audience: "bound_owner", filesystem_scope: "workspace_only", + binding_id: "b".repeat(24), provider_ref: "c".repeat(24), operator_ref: "d".repeat(24), source_ref: "e".repeat(24)}; + const key = projectConversationIdentity({context: bound}).host_store_key; + assert.equal(key, [bound.project_ref, bound.binding_id, bound.provider_ref, bound.operator_ref].join(".")); + for (const patch of [{source_ref: "f".repeat(24)}, {grant: "workspace_write"}]) { + assert.equal(projectConversationIdentity({context: {...bound, ...patch}}).host_store_key, key); + } + for (const field of ["project_ref", "binding_id", "provider_ref", "operator_ref"]) { + assert.notEqual(projectConversationIdentity({context: {...bound, [field]: "f".repeat(24)}}).host_store_key, key); + } }); diff --git a/tests/test_chat_agent.py b/tests/test_chat_agent.py index 3e049c1fa7..0edb414298 100644 --- a/tests/test_chat_agent.py +++ b/tests/test_chat_agent.py @@ -77,18 +77,26 @@ def test_project_filesystem_scope_is_verified_on_start_resume_and_pinned_per_tur from loopx.capabilities.native_chat.project_context import ChatProjectContexts context = ChatProjectContexts([tmp_path], workspace_grant=grant, filesystem_scope="workspace_only").available()[0] profile = "loopx_workspace_only_" + ("write" if grant == "workspace_write" else "read") - process = _FakeAppServerProcess(thread_response={"thread": {"id": "thread-loopx-chat"}, + process = _FakeAppServerProcess(config_response={"config": {"mcp_servers": {"managed_fixture": {"command": "private-command"}}}}, + thread_response={"thread": {"id": "thread-loopx-chat"}, "activePermissionProfile": {"id": profile}, "runtimeWorkspaceRoots": [str(tmp_path)]}) real_which, real_popen = chat_agent.shutil.which, chat_agent.subprocess.Popen binary = tmp_path / "native-codex" monkeypatch.setattr(chat_agent.shutil, "which", lambda name: str(binary) if name == "codex" else real_which(name)) - monkeypatch.setattr(chat_agent.subprocess, "Popen", lambda command, *a, **k: - process if command[0] == str(binary) else real_popen(command, *a, **k)) + launched = [] + monkeypatch.setenv("PRIVATE_FIXTURE_TOKEN", "synthetic-account-secret") + def popen(command, *args, **kwargs): + if command[0] != str(binary): + return real_popen(command, *args, **kwargs) + launched.append(kwargs["env"]) + return process + monkeypatch.setattr(chat_agent.subprocess, "Popen", popen) session = chat_agent.CodexChatAgentSession.start(codex_bin="codex", work_dir=tmp_path, - goal_id=None, objective="project", project_context=context, + goal_id=None, objective="project", project_context=context, codex_home=tmp_path / "account-codex", resume_thread_id="thread-loopx-chat" if resume else None, model="synthetic-model", reasoning_effort="high", host_config={"skills": {"include_instructions": True}, "project_doc_max_bytes": 32768, + "mcp_servers": {"caller_fixture": {"enabled": True}}, "shell_environment_policy": {"inherit": "all", "include_only": ["*"], "set": {"PRIVATE_FIXTURE": "synthetic"}}}) try: @@ -101,6 +109,13 @@ def test_project_filesystem_scope_is_verified_on_start_resume_and_pinned_per_tur assert params["config"]["default_permissions"] == profile assert params["config"]["skills"]["include_instructions"] is False assert params["config"]["project_doc_max_bytes"] == 0 + assert params["config"]["mcp_servers"] == { + "managed_fixture": {"enabled": False}, "caller_fixture": {"enabled": False}} + env = launched[0] + assert "PRIVATE_FIXTURE_TOKEN" not in env + assert Path(env["CODEX_HOME"]).parent == Path(env["HOME"]) + assert Path(env["HOME"]) != Path.home() + assert Path(env["CODEX_HOME"]) != tmp_path / "account-codex" environment = params["config"]["shell_environment_policy"] assert environment["inherit"] == "none" and environment["include_only"] == ["PATH"] assert environment["experimental_use_profile"] is False @@ -123,7 +138,8 @@ def test_project_filesystem_scope_is_verified_on_start_resume_and_pinned_per_tur ]) def test_project_filesystem_scope_rejects_missing_or_changed_native_readback_without_fallback(monkeypatch, tmp_path, response): from loopx.capabilities.native_chat.project_context import ChatProjectContexts - process = _FakeAppServerProcess(thread_response={"thread": {"id": "thread-loopx-chat"}, **response}) + process = _FakeAppServerProcess(config_response={"config": {}}, + thread_response={"thread": {"id": "thread-loopx-chat"}, **response}) real_which, real_popen = chat_agent.shutil.which, chat_agent.subprocess.Popen binary = tmp_path / "native-codex" monkeypatch.setattr(chat_agent.shutil, "which", lambda name: str(binary) if name == "codex" else real_which(name)) @@ -131,7 +147,8 @@ def test_project_filesystem_scope_rejects_missing_or_changed_native_readback_wit process if command[0] == str(binary) else real_popen(command, *a, **k)) with pytest.raises(chat_agent.CodexChatAgentError, match="did not apply"): chat_agent.CodexChatAgentSession.start(codex_bin="codex", work_dir=tmp_path, goal_id=None, - objective="project", project_context=ChatProjectContexts([tmp_path], filesystem_scope="workspace_only").available()[0], + objective="project", codex_home=tmp_path / "account-codex", + project_context=ChatProjectContexts([tmp_path], filesystem_scope="workspace_only").available()[0], resume_thread_id="thread-loopx-chat", model="synthetic-model", reasoning_effort="high") requests = [json.loads(line) for line in process.stdin.getvalue().splitlines()] assert sum(r.get("method") == "thread/resume" for r in requests) == 1 diff --git a/tests/test_chat_codex_context.py b/tests/test_chat_codex_context.py new file mode 100644 index 0000000000..5fdd001b2c --- /dev/null +++ b/tests/test_chat_codex_context.py @@ -0,0 +1,65 @@ +from pathlib import Path + +import pytest + +from loopx.capabilities.native_chat.codex_context import codex_home, disable_mcp_servers, process_environment + + +def test_default_host_preserves_environment_and_native_store(tmp_path, monkeypatch): + monkeypatch.setenv("SYNTHETIC_PROVIDER_KEY", "fixture") + home = codex_home(tmp_path, None) + env = process_environment(home, isolated=False) + assert home == tmp_path + assert env["SYNTHETIC_PROVIDER_KEY"] == "fixture" + assert env["CODEX_HOME"] == str(tmp_path) + + +def test_private_context_rejects_symlink_to_another_store(tmp_path): + root = tmp_path / "loopx-projects" + root.mkdir() + other = tmp_path / "account" + other.mkdir() + (root / "fixture").symlink_to(other, target_is_directory=True) + with pytest.raises(ValueError, match="symlink"): + codex_home(tmp_path, {"host_store_key": "fixture"}) + + +def test_private_host_environment_does_not_inherit_account_variables(tmp_path, monkeypatch): + monkeypatch.setenv("SYNTHETIC_PROVIDER_KEY", "fixture") + monkeypatch.setenv("HTTPS_PROXY", "fixture-private-proxy") + monkeypatch.setenv("BASH_ENV", "fixture-private-profile") + home = tmp_path / "private" / ".codex" + env = process_environment(home, isolated=True) + assert home.is_dir() + assert Path(env["HOME"]) == home.parent + assert env["CODEX_HOME"] == str(home) + assert all(k not in env for k in ("SYNTHETIC_PROVIDER_KEY", "HTTPS_PROXY", "BASH_ENV")) + + +def test_mcp_disable_overrides_all_config_layers_without_forwarding_secrets(): + private = {"mcp_servers": {"fixture": {"command": "private", "env": {"TOKEN": "secret"}}}} + requested = {"mcp_servers": {"caller": {"enabled": True}}, "model": "fixture"} + result = disable_mcp_servers(private, requested) + assert result == {"model": "fixture", "mcp_servers": { + "fixture": {"enabled": False}, "caller": {"enabled": False}}} + assert private["mcp_servers"]["fixture"]["env"]["TOKEN"] == "secret" + for config, override in [({"mcp_servers": "bad"}, {}), ({}, {"mcp_servers": ["bad"]})]: + with pytest.raises(ValueError, match="MCP"): + disable_mcp_servers(config, override) + + +def test_compatibility_catalog_probe_keeps_private_process_environment(tmp_path, monkeypatch): + import json + from types import SimpleNamespace + from loopx import chat_agent + monkeypatch.setenv("SYNTHETIC_PROVIDER_KEY", "account-secret") + env = process_environment(tmp_path / "private" / ".codex", isolated=True) + launched = [] + monkeypatch.setattr(chat_agent.subprocess, "run", lambda *args, **kwargs: + launched.append(kwargs["env"]) or SimpleNamespace(returncode=0, + stdout=json.dumps({"models": [{"base_instructions": "synthetic built-in model"}]}))) + with chat_agent._current_builtin_model_catalog("fixture-native-codex", environment=env): + assert "SYNTHETIC_PROVIDER_KEY" not in launched[0] + assert launched[0]["HOME"] == env["HOME"] + assert launched[0]["CODEX_HOME"] != env["CODEX_HOME"] + assert env["CODEX_HOME"] == str(tmp_path / "private" / ".codex") diff --git a/tests/test_chat_codex_home.py b/tests/test_chat_codex_home.py index 107e708c8b..e6fb8cf441 100644 --- a/tests/test_chat_codex_home.py +++ b/tests/test_chat_codex_home.py @@ -97,3 +97,38 @@ def test_attached_session_uses_existing_host_not_managed_adapter(tmp_path, monke assert created and turn["status"] == "queued" assert not runtime.adapters runtime.close() + + +def test_workspace_only_session_pins_private_home_and_resumes_exact_thread(tmp_path, monkeypatch): + from loopx.capabilities.native_chat.project_context import ChatProjectContexts + runtime = controller(tmp_path, monkeypatch) + runtime.project_contexts = ChatProjectContexts([tmp_path], filesystem_scope="workspace_only") + ref = runtime.project_contexts.available()[0]["project_ref"] + session, _ = runtime.open_session(goal_id=None, agent_id="codex", work_dir=tmp_path, + objective="fixture", mode="new", project_ref=ref) + assert session["codex_home"] == str(tmp_path / "bound" / "loopx-projects" / f"{ref}.local" / ".codex") + runtime.adapters.clear() + calls = [] + monkeypatch.setattr(runtime, "_start_adapter", lambda **kwargs: calls.append(kwargs) or Adapter()) + resumed = runtime.resume_session(session_id=session["session_id"], work_dir=tmp_path, objective="fixture") + assert resumed["codex_home"] == session["codex_home"] + assert calls[0]["resume_thread_id"] == "fixture-thread" + assert resumed["upstream_thread_id"] == "fixture-thread" + runtime.close() + + +@pytest.mark.parametrize("old_home", [None, "shared"]) +def test_workspace_only_old_shared_context_refuses_rebind_before_any_spawn(tmp_path, monkeypatch, old_home): + from loopx.capabilities.native_chat.project_context import ChatProjectContexts + runtime = controller(tmp_path, monkeypatch) + context = ChatProjectContexts([tmp_path], filesystem_scope="workspace_only").available()[0] + session = runtime.store.create_session(goal_id=None, agent_id="codex", adapter_kind="codex_app_server", + upstream_thread_id="old-thread", upstream_mode="chat", channel_id=f"project.{context['project_ref']}", + project_context=context, codex_home=str(runtime.codex_home) if old_home else None) + before = runtime.store.load_session(session["session_id"]) + monkeypatch.setattr(runtime, "_start_adapter", lambda **kwargs: pytest.fail("must not resume or replace thread")) + with pytest.raises(CodexChatAgentError) as error: + runtime.resume_session(session_id=session["session_id"], work_dir=tmp_path, objective="fixture") + assert error.value.error_code == "codex_home_mismatch" + assert runtime.store.load_session(session["session_id"]) == before + runtime.close()