diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/services.rs b/apps/desktop/loopx-control-plane/src-tauri/src/services.rs index 877f52b70d..ee681c19d7 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/services.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/services.rs @@ -273,6 +273,13 @@ fn connect_owned_service( } let mut command = Command::new(&runtime.executable); configure_runtime_environment(&mut command); + if matches!(kind, ServiceKind::Chat) { + configure_codex_chat_home( + &mut command, + env::var_os("HOME"), + env::var_os("LOOPX_CHAT_CODEX_HOME"), + ); + } command .args(kind.command_args_on_port(port)) .stdin(Stdio::null()) @@ -403,6 +410,13 @@ fn connect_service( let mut command = Command::new(executable); configure_runtime_environment(&mut command); + if matches!(kind, ServiceKind::Chat) { + configure_codex_chat_home( + &mut command, + env::var_os("HOME"), + env::var_os("LOOPX_CHAT_CODEX_HOME"), + ); + } command .args(kind.command_args()) .stdin(Stdio::null()) @@ -767,6 +781,21 @@ pub(crate) fn configure_runtime_environment(command: &mut Command) { ); } +// A desktop Chat service has its own account binding. An unrelated terminal's +// CODEX_HOME must not pair the selected GPT model with another provider. CLI +// launches retain their environment semantics; explicit Chat overrides win. +fn configure_codex_chat_home( + command: &mut Command, + home: Option, + chat_home: Option, +) { + if let Some(selected) = chat_home.filter(|value| !value.is_empty()) { + command.env("LOOPX_CHAT_CODEX_HOME", selected); + } else if let Some(home) = home.filter(|value| !value.is_empty()) { + command.env("LOOPX_CHAT_CODEX_HOME", PathBuf::from(home).join(".codex")); + } +} + fn runtime_search_path( home: Option, inherited: Option, @@ -1143,6 +1172,35 @@ fn classify_response( #[cfg(test)] mod tests { use super::*; + + #[test] + fn desktop_chat_account_default_and_explicit_override_are_independent_of_caller_home() { + let user_home = PathBuf::from("synthetic-user-home"); + for chat_home in [None, Some(std::ffi::OsString::new())] { + let mut command = Command::new("synthetic-loopx"); + command.env("CODEX_HOME", "different-provider-home"); + configure_codex_chat_home(&mut command, Some(user_home.clone().into()), chat_home); + assert!(command + .get_envs() + .any(|(key, value)| key == "LOOPX_CHAT_CODEX_HOME" + && value == Some(user_home.join(".codex").as_os_str()))); + assert!(command.get_envs().any(|(key, value)| key == "CODEX_HOME" + && value == Some(std::ffi::OsStr::new("different-provider-home")))); + } + let mut command = Command::new("synthetic-loopx"); + configure_codex_chat_home( + &mut command, + Some(user_home.into()), + Some("selected-chat-home".into()), + ); + assert!(command + .get_envs() + .any(|(key, value)| key == "LOOPX_CHAT_CODEX_HOME" + && value == Some(std::ffi::OsStr::new("selected-chat-home")))); + let mut command = Command::new("synthetic-loopx"); + configure_codex_chat_home(&mut command, None, None); + assert_eq!(command.get_envs().count(), 0); + } #[test] fn package_identity_requires_core_installation_qualification() { let identity = serde_json::json!({"schema_version":"loopx_runtime_identity_v1", diff --git a/apps/presentation/dashboard/smoke/capability-configuration-smoke.ts b/apps/presentation/dashboard/smoke/capability-configuration-smoke.ts index 0331c8d532..4295ea2526 100644 --- a/apps/presentation/dashboard/smoke/capability-configuration-smoke.ts +++ b/apps/presentation/dashboard/smoke/capability-configuration-smoke.ts @@ -1,6 +1,17 @@ import assert from "node:assert/strict"; -import { goalReplanCadenceEditorValue, goalReplanCadenceWriteValue, hasReplanCadenceOverride, goalStorageEditorValue, parseEditableCapabilityJson, projectEditableCapabilityConfiguration, replanCadenceEditorDefaults } from "../src/data/capability-configuration.js"; +import { changeStewardExecutorDraft, goalReplanCadenceEditorValue, goalReplanCadenceWriteValue, hasReplanCadenceOverride, goalStorageEditorValue, parseEditableCapabilityJson, projectEditableCapabilityConfiguration, replanCadenceEditorDefaults } from "../src/data/capability-configuration.js"; + +const codexDraft = { executor_endpoint: "codex", executor_model: "gpt-fixture", executor_reasoning_effort: "high", selection_policy: "preferred" }; +const managedDraft = changeStewardExecutorDraft(codexDraft, "executor_endpoint", "dsh"); +assert.deepEqual(managedDraft, { ...codexDraft, executor_endpoint: "dsh", executor_model: null, executor_reasoning_effort: null }, + "switching Runtime lets the owning backend resolve the new provider defaults"); +assert.deepEqual(changeStewardExecutorDraft({ ...managedDraft, executor_model: "deepseek-fixture" }, "executor_endpoint", "codex"), + { ...codexDraft, executor_model: null, executor_reasoning_effort: null }, "switching back cannot send a DeepSeek model to Codex"); +assert.deepEqual(changeStewardExecutorDraft(codexDraft, "executor_endpoint", "codex"), codexDraft, + "reselecting the same Runtime preserves explicit model choices"); +assert.deepEqual(changeStewardExecutorDraft(codexDraft, "executor_model", "custom-model"), { ...codexDraft, executor_model: "custom-model" }); +assert.equal(codexDraft.executor_model, "gpt-fixture", "editing the draft does not mutate the accepted configuration"); const periodicReportEditor = { fields: [ diff --git a/apps/presentation/dashboard/src/data/capability-configuration.ts b/apps/presentation/dashboard/src/data/capability-configuration.ts index 9637edd3e5..819dd349e7 100644 --- a/apps/presentation/dashboard/src/data/capability-configuration.ts +++ b/apps/presentation/dashboard/src/data/capability-configuration.ts @@ -13,6 +13,16 @@ function configurationObject(value: unknown): Record { : {}; } +/** A saved model override belongs to the Runtime that was selected with it. */ +export function changeStewardExecutorDraft( + current: Record, key: string, value: unknown, +): Record { + const changed = { ...current, [key]: value }; + return key === "executor_endpoint" && value !== current.executor_endpoint + ? { ...changed, executor_model: null, executor_reasoning_effort: null } + : changed; +} + /** * Project a public capability value onto the editor-owned write surface. * diff --git a/apps/presentation/dashboard/src/features/personal-workspace/capability-configuration-fields.tsx b/apps/presentation/dashboard/src/features/personal-workspace/capability-configuration-fields.tsx index e883aca085..a587569b80 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/capability-configuration-fields.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/capability-configuration-fields.tsx @@ -22,6 +22,7 @@ function ConfigurationFieldControl({ copy, field, id, onChange, value, timezone const { locale } = useWorkspaceI18n(); const label = copy[field.key]?.label ?? field.label; const readOnly = !onChange; + const runtimeDefault = locale === "zh-CN" ? "使用所选 Runtime 的默认值" : "Use the selected Runtime's default"; const directionLabel = (direction: string) => locale === "zh-CN" ? direction === "forward" ? "正向" : "反向" : direction === "forward" ? "Forward" : "Reverse"; @@ -66,7 +67,7 @@ function ConfigurationFieldControl({ copy, field, id, onChange, value, timezone @@ -96,6 +97,7 @@ function ConfigurationFieldControl({ copy, field, id, onChange, value, timezone max={field.maximum} min={field.minimum} onChange={onChange ? (event) => onChange(field.key, numeric ? Number(event.target.value) : event.target.value) : undefined} + placeholder={field.key === "executor_model" ? runtimeDefault : undefined} readOnly={readOnly} required={field.required} type={numeric ? "number" : "text"} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/machine-configuration-settings.tsx b/apps/presentation/dashboard/src/features/personal-workspace/machine-configuration-settings.tsx index 9018c9711b..a589ebd977 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/machine-configuration-settings.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/machine-configuration-settings.tsx @@ -17,7 +17,7 @@ import { type MachineConfigurationRollbackPlan, type MachineConfigurationTransaction, } from "../../data/chat"; -import { goalStorageEditorValue, projectEditableCapabilityConfiguration, replanCadenceEditorValue } from "../../data/capability-configuration"; +import { changeStewardExecutorDraft, goalStorageEditorValue, projectEditableCapabilityConfiguration, replanCadenceEditorValue } from "../../data/capability-configuration"; import { CapabilityConfigurationFields } from "./capability-configuration-fields"; import { withReportScheduleTimezone } from "./periodic-report-schedule-field"; import { localizeCapability, localizedCapabilityFieldCopy } from "./capability-localization"; @@ -210,7 +210,9 @@ export function MachineConfigurationSettings({ section, onChanged }: { section: function changeDraft(key: string, value: unknown) { setDraft((current) => selected?.capability_id === "periodic_report" - ? withReportScheduleTimezone(current, key, value) : { ...current, [key]: value }); + ? withReportScheduleTimezone(current, key, value) + : selected?.capability_id === "steward_executor" + ? changeStewardExecutorDraft(current, key, value) : { ...current, [key]: value }); setPreview(null); setPreviewOperation("upsert"); setError(null); diff --git a/docs/architecture/rfcs/capable-manager-semantic-handoff-v0.md b/docs/architecture/rfcs/capable-manager-semantic-handoff-v0.md index 7613dd9467..a5925829c5 100644 --- a/docs/architecture/rfcs/capable-manager-semantic-handoff-v0.md +++ b/docs/architecture/rfcs/capable-manager-semantic-handoff-v0.md @@ -808,6 +808,20 @@ Target an ingress receipt within two seconds on a healthy local service, indepen Use existing service recovery and receipt pumps. No manager-specific business automation for each kind of request. Expose configuration and failures through the existing CLI, capability settings and manager conversation. Troubleshooting distinguishes model failure, tool/policy denial, state conflict, unreachable receiver and transport formatting/delivery failure. +**Provider configuration rejection:** a structured Responses +`invalid_request_error` remains a rejected request when its JSON envelope omits +HTTP status; conflicting or malformed status does not establish that category. +Chat explains the model/account/provider check without publishing upstream prose +or replaying the request. Desktop-owned Chat defaults to the standard Codex home +unless its dedicated Chat override is explicit, so an unrelated terminal's +provider configuration cannot replace the default account. CLI and LaunchAgent +home precedence, original Session identity, explicit Runtime selection and +provider permissions remain with their existing owners. Model discovery reads +the same native home as execution. Changing the executor in the guided settings +clears the previous model/effort overrides for the existing backend to resolve; +preview and apply still own the persisted change. This bounded repair +does not qualify sustained operation or every provider/model combination. + **Turn-bound steering (A23):** preserve the requested Session, exact Turn, unchanged text and ingress identity through browser persistence, dispatch and replay. The current frontend requires persistent retry readback before sending; diff --git a/docs/architecture/rfcs/capable-manager-semantic-handoff-v0.zh-CN.md b/docs/architecture/rfcs/capable-manager-semantic-handoff-v0.zh-CN.md index 57ff699b15..367807056d 100644 --- a/docs/architecture/rfcs/capable-manager-semantic-handoff-v0.zh-CN.md +++ b/docs/architecture/rfcs/capable-manager-semantic-handoff-v0.zh-CN.md @@ -654,6 +654,16 @@ M0 盘点真实字段和 producer;以下是迁移验收底线,不代表已 使用现有服务恢复和 receipt pump,不为每类请求创建管家业务 automation。配置/故障通过已有 CLI、capability settings、管家对话展示。诊断区分模型失败、工具/策略拒绝、状态冲突、接收方不可达、格式/传输失败。 +**Provider 配置拒绝:** 结构化 Responses `invalid_request_error` 的 JSON 包装即使 +省略 HTTP 状态,仍应识别为请求拒绝;冲突或格式错误的状态不能建立该分类。 +Chat 提示核对模型、账号和 provider,不发布上游原文,也不自动重放请求。 +桌面自己启动的 Chat 默认使用标准 Codex 目录,除非显式指定专用 Chat override, +避免无关终端的 provider 配置替换默认账号。CLI 与 LaunchAgent 的目录优先级、 +原 Session 身份、显式 Runtime 选择及 provider 权限仍归已有 owner。 +模型发现与执行读取同一原生目录。引导设置中切换执行器会清空原模型/档位覆盖, +由已有 backend 解析默认值;持久变更仍须通过原来的预览与应用。 +这一有界修复不代表持续运行或所有 provider/model 组合已资格化。 + **回合绑定的 steering(A23):** 浏览器保存、派发和重放全过程保持请求指定的 Session、精确 Turn、不变文本与 ingress 身份。当前前端发送前要求持久重试信息 回读成功,存储不可用时保留可编辑草稿。投递未知时沿原目标重试,确认未送达则 diff --git a/docs/development/assets/codex-provider-selection/after-desktop.png b/docs/development/assets/codex-provider-selection/after-desktop.png new file mode 100644 index 0000000000..3e6faf56e2 Binary files /dev/null and b/docs/development/assets/codex-provider-selection/after-desktop.png differ diff --git a/docs/development/assets/codex-provider-selection/after-phone-controls.png b/docs/development/assets/codex-provider-selection/after-phone-controls.png new file mode 100644 index 0000000000..542ae722f1 Binary files /dev/null and b/docs/development/assets/codex-provider-selection/after-phone-controls.png differ diff --git a/docs/development/assets/codex-provider-selection/after-phone.png b/docs/development/assets/codex-provider-selection/after-phone.png new file mode 100644 index 0000000000..ebdd44a66b Binary files /dev/null and b/docs/development/assets/codex-provider-selection/after-phone.png differ diff --git a/docs/development/assets/codex-provider-selection/before-desktop.png b/docs/development/assets/codex-provider-selection/before-desktop.png new file mode 100644 index 0000000000..b493438e85 Binary files /dev/null and b/docs/development/assets/codex-provider-selection/before-desktop.png differ diff --git a/docs/development/assets/codex-provider-selection/before-phone-controls.png b/docs/development/assets/codex-provider-selection/before-phone-controls.png new file mode 100644 index 0000000000..b26d5288c7 Binary files /dev/null and b/docs/development/assets/codex-provider-selection/before-phone-controls.png differ diff --git a/docs/integrations/session-runtime-control-plane-adapter.md b/docs/integrations/session-runtime-control-plane-adapter.md index 157f4c00f8..2cab104f44 100644 --- a/docs/integrations/session-runtime-control-plane-adapter.md +++ b/docs/integrations/session-runtime-control-plane-adapter.md @@ -50,6 +50,26 @@ Resume and submit reject a different home before starting an upstream process or modifying a turn. Legacy sessions acquire the binding only after successful upstream restoration; startup alone neither rebinds nor copies their history. +Desktop-owned Chat processes explicitly default `LOOPX_CHAT_CODEX_HOME` to +`~/.codex`, independently of the launching terminal's `CODEX_HOME`. An explicit +`LOOPX_CHAT_CODEX_HOME` still selects another native account configuration. +Direct CLI launches and existing LaunchAgents retain their documented +precedence. Switching the service home requires a new conversation; existing +threads remain bound to their original store. The steward defaults to Codex/GPT; +DeepSeek Harness remains an explicit Runtime choice with its own SDK and +operator credential, rather than sending GPT model names to a DeepSeek provider. +Model discovery uses the same captured native home as execution. The guided +steward editor clears model/effort overrides when changing executor, and labels +empty values as Runtime defaults; the existing preview/apply owns the write. + +中文:桌面自己启动的 Chat 默认固定到 `~/.codex`,不再继承启动终端的 +`CODEX_HOME`;显式 `LOOPX_CHAT_CODEX_HOME` 仍可选择其它原生账号配置。 +直接 CLI 和已有 LaunchAgent 的优先级不变。换目录需要新对话,旧线程保持原存储绑定。 +管家默认 Codex/GPT;DeepSeek Harness 是可选 Runtime,单独检查 SDK 与 operator +凭据,不把 GPT 模型名发给 DeepSeek provider。 +模型发现与执行共用启动时固定的原生目录。引导表单切换执行器会清空模型与档位覆盖, +空值明确表示 Runtime 默认值;写入仍归原有预览/应用流程。 + The macOS LaunchAgent installer preserves an existing binding on upgrade, including older shell-export plists. Use `LOOPX_CHAT_CODEX_HOME` explicitly when installing a deliberately different host profile. A later ambient `CODEX_HOME` diff --git a/loopx/chat_agent.py b/loopx/chat_agent.py index 088063f4ba..63a3c36df8 100644 --- a/loopx/chat_agent.py +++ b/loopx/chat_agent.py @@ -204,9 +204,9 @@ def _retry_error_details(error: Any) -> dict[str, Any]: def _terminal_turn_error(error: Any, fallback: str) -> CodexChatAgentError: """Project only the app-server's typed error, never its arbitrary prose.""" info = error.get("codexErrorInfo") if isinstance(error, dict) else None - # Some app-server versions wrap an HTTP error as JSON in `message` while - # reporting codexErrorInfo=other. Only the structured HTTP status/type is - # used here; the nested message may contain private request details. + # Some Responses providers return a structured error without an HTTP + # status. The typed error still identifies a rejected request; arbitrary + # nested prose may contain private request details and is never projected. if info == "other" and isinstance(error.get("message"), str): try: upstream = json.loads(error["message"]) @@ -214,7 +214,9 @@ def _terminal_turn_error(error: Any, fallback: str) -> CodexChatAgentError: upstream = None if ( isinstance(upstream, dict) - and upstream.get("status") == 400 + and ("status" not in upstream or ( + type(upstream["status"]) is int and upstream["status"] == 400 + )) and isinstance(upstream.get("error"), dict) and upstream["error"].get("type") == "invalid_request_error" ): @@ -224,7 +226,7 @@ def _terminal_turn_error(error: Any, fallback: str) -> CodexChatAgentError: error_code="upstream_invalid_request", gate=_host_tool_gate( summary, - "检查管家选择的模型、Codex CLI 与当前账户是否兼容,再重试。", + "在设置中核对所选模型与 Codex 账户、服务商配置是否匹配,再重试;本次请求不会自动重放。", ), ) # App-server v2 exposes camel-case discriminators. Unknown/new variants diff --git a/loopx/chat_providers.py b/loopx/chat_providers.py index b93bff8125..8ab2d62254 100644 --- a/loopx/chat_providers.py +++ b/loopx/chat_providers.py @@ -22,14 +22,15 @@ _PRIVATE_PARTS = {".git", ".env", ".ssh", ".aws", ".config", "node_modules"} -def codex_model_options(codex_bin: str) -> dict[str, Any] | None: - """Project the local Codex catalog without exposing its instructions.""" +def codex_model_options(codex_bin: str, *, codex_home: Path | None = None) -> dict[str, Any] | None: + """Read the selected account's catalog without exposing its instructions.""" executable = codex_bin if Path(codex_bin).is_absolute() else shutil.which(codex_bin) if not executable: return None try: result = subprocess.run( [str(executable), "debug", "models"], + env={**os.environ, **({"CODEX_HOME": str(codex_home)} if codex_home is not None else {})}, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace", timeout=15, check=False, ) diff --git a/loopx/chat_runtime.py b/loopx/chat_runtime.py index 6a8d8c8d42..a9e775a5c8 100644 --- a/loopx/chat_runtime.py +++ b/loopx/chat_runtime.py @@ -404,7 +404,7 @@ def inspect_agent_model_options(self, agent_id: str, *, work_dir: Path) -> dict[ if capability is None or not capability.get("available"): raise agent_endpoint_error(agent_id, reason=str((capability or {}).get("unavailable_reason") or "")) if agent_id == "codex": - return {"agent_id": agent_id, "model": codex_model_options(self.codex_bin), "supported": True} + return {"agent_id": agent_id, "model": codex_model_options(self.codex_bin, codex_home=self.codex_home), "supported": True} if agent_id == "claude-code": return { "agent_id": agent_id, diff --git a/tests/test_chat_agent.py b/tests/test_chat_agent.py index a506f41b59..1dd977a920 100644 --- a/tests/test_chat_agent.py +++ b/tests/test_chat_agent.py @@ -1494,14 +1494,15 @@ def test_typed_terminal_errors_preserve_category_without_promoting_partial_answe assert "不会自动重放" in caught.value.gate["next_action"] -def test_structured_invalid_upstream_request_is_not_a_generic_host_gate() -> None: +@pytest.mark.parametrize("status", [{}, {"status": 400}]) +def test_structured_invalid_upstream_request_is_not_a_generic_host_gate(status) -> None: error = chat_agent._terminal_turn_error( { "codexErrorInfo": "other", "message": json.dumps( { "type": "error", - "status": 400, + **status, "error": { "type": "invalid_request_error", "message": "private upstream model detail", @@ -1514,6 +1515,41 @@ def test_structured_invalid_upstream_request_is_not_a_generic_host_gate() -> Non assert error.error_code == "upstream_invalid_request" assert "private upstream" not in str(error) + json.dumps(error.gate) assert "模型" in error.gate["next_action"] + assert "不会自动重放" in error.gate["next_action"] + + +@pytest.mark.parametrize("status", [None, True, 400.0, "400", 401, 500]) +def test_conflicting_or_malformed_http_status_does_not_classify_request_error(status): + error = chat_agent._terminal_turn_error({"codexErrorInfo": "other", "message": json.dumps({ + "status": status, "error": {"type": "invalid_request_error", "message": "private detail"}, + })}, "generic fallback") + assert error.error_code == "host_gate" + assert str(error) == "generic fallback" + assert "private detail" not in json.dumps(error.gate) + + +@pytest.mark.parametrize("terminal_method", ["error", "turn/completed"]) +def test_statusless_request_rejection_settles_without_final_answer(monkeypatch, tmp_path, terminal_method): + session = chat_agent.CodexChatAgentSession( + process=_FakeAppServerProcess(), messages=queue.Queue(), + thread_id="request-fixture", work_dir=tmp_path, + ) + error = {"codexErrorInfo": "other", "message": json.dumps({"error": { + "type": "invalid_request_error", "message": "private provider model details", + }})} + params = {"threadId": "request-fixture", "turnId": "rejected-turn"} + if terminal_method == "error": + params.update(error=error, willRetry=False) + else: + params["turn"] = {"id": "rejected-turn", "status": "failed", "error": error} + monkeypatch.setattr(session, "_request", lambda *a, **kw: {"turn": {"id": "rejected-turn"}}) + monkeypatch.setattr(session, "_next_event", lambda **kw: {"method": terminal_method, "params": params}) + events = [] + with pytest.raises(chat_agent.CodexChatAgentError) as caught: + session.send("Answer this synthetic request.", on_event=lambda k, p: events.append((k, p))) + assert caught.value.error_code == "upstream_invalid_request" + assert "private provider" not in str(caught.value) + json.dumps(caught.value.gate) + assert not any(k == "answer.final" for k, _ in events) def test_unstructured_upstream_error_stays_generic() -> None: diff --git a/tests/test_chat_codex_home.py b/tests/test_chat_codex_home.py index e6fb8cf441..252465d958 100644 --- a/tests/test_chat_codex_home.py +++ b/tests/test_chat_codex_home.py @@ -1,4 +1,8 @@ """A managed upstream thread's home is identity, not restart-time routing.""" +import json +import os +import sys + import pytest from loopx.chat_agent import CodexChatAgentError @@ -34,6 +38,24 @@ def legacy(runtime): ) +def test_model_discovery_uses_the_same_home_as_runtime_not_ambient_provider(tmp_path, monkeypatch): + runtime = controller(tmp_path, monkeypatch) + bound = tmp_path / "bound" + ambient = tmp_path / "other-provider" + for root, model in [(bound, "gpt-fixture"), (ambient, "other-fixture")]: + root.mkdir() + (root / "models.json").write_text(json.dumps({"models": [{"slug": model}]})) + # Python runs this synthetic `debug models` command on every supported OS. + (tmp_path / "debug").write_text("import os\nfrom pathlib import Path\nprint((Path(os.environ['CODEX_HOME']) / 'models.json').read_text())\n") + runtime.codex_bin = sys.executable + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("CODEX_HOME", str(ambient)) + result = runtime.inspect_agent_model_options("codex", work_dir=tmp_path) + assert result["model"]["options"] == [{"value": "gpt-fixture", "label": "gpt-fixture"}] + assert os.environ["CODEX_HOME"] == str(ambient) + runtime.close() + + def test_new_session_records_home_and_process_environment_cannot_rebind(tmp_path, monkeypatch): runtime = controller(tmp_path, monkeypatch) monkeypatch.setenv("LOOPX_CHAT_CODEX_HOME", str(tmp_path / "other"))