From 7c0393ec0d2f3e246cc0282df6f5bade705cd6b6 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 5 Oct 2026 06:34:00 +0800 Subject: [PATCH] fix(macos): preserve independent execution and Chat Codex homes Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../session-runtime-control-plane-adapter.md | 14 ++++++++ ...acos-dashboard-launchagent-status-smoke.py | 35 ++++++++++++++++++- scripts/macos-dashboard-launchagent.sh | 23 +++++++----- 3 files changed, 62 insertions(+), 10 deletions(-) diff --git a/docs/integrations/session-runtime-control-plane-adapter.md b/docs/integrations/session-runtime-control-plane-adapter.md index 07f3c950c0..c2425c5e9c 100644 --- a/docs/integrations/session-runtime-control-plane-adapter.md +++ b/docs/integrations/session-runtime-control-plane-adapter.md @@ -56,6 +56,20 @@ installing a deliberately different host profile. A later ambient `CODEX_HOME` does not overwrite it. Restore the original home to recover a home-mismatch gate; changing this variable is not a session-migration command. +The LaunchAgent keeps service execution `CODEX_HOME` separate from +`LOOPX_CHAT_CODEX_HOME`. Set both when a coordinator's workers resume in a +different existing home from its Chat app-server. Install/restart preserves the +recorded execution home when `CODEX_HOME` is omitted; an explicit value changes +only that service setting. A fresh install without an execution selection uses +the Chat home. Changing the Chat override no longer overwrites an existing +worker profile. This affects macOS-managed Chat/delegation launches, leaves +session-profile checks in force, and neither migrates sessions nor copies auth. + +中文:macOS 登录服务分别保存执行端 `CODEX_HOME` 与聊天端 +`LOOPX_CHAT_CODEX_HOME`。需要沿用不同的原会话时同时指定两者;升级或重启时未指定 +`CODEX_HOME` 就保留已安装的执行目录,新安装未指定则沿用聊天目录。只修改聊天目录 +不再覆盖已有 worker 配置;会话检查仍会拒绝不一致的恢复,不迁移历史或复制凭据。 + Sharing a host home does not by itself prove a SQLite lock failure. A desktop launcher should separate ordinary open (read-only identity/configuration checks) from offline account switching, migration, or rollback (exclusive ownership). diff --git a/examples/macos-dashboard-launchagent-status-smoke.py b/examples/macos-dashboard-launchagent-status-smoke.py index 819b23e8f4..17d9294d14 100644 --- a/examples/macos-dashboard-launchagent-status-smoke.py +++ b/examples/macos-dashboard-launchagent-status-smoke.py @@ -3,6 +3,7 @@ from __future__ import annotations +import json import os import plistlib import shutil @@ -298,11 +299,43 @@ def main() -> int: selected_chat_plist = chat_plist.read_text(encoding="utf-8") assert "--enable-control-plane-write-api" in write_plist, write_plist selected = (home / 'selected-codex-home').resolve() - assert f"export CODEX_HOME={selected};" in selected_chat_plist, selected_chat_plist + assert f"export LOOPX_CHAT_CODEX_HOME={selected};" in selected_chat_plist, selected_chat_plist + assert f"export CODEX_HOME={(home / '.codex').resolve()};" in selected_chat_plist, selected_chat_plist run_script(fake_bin, home, ["install"], schema_version=2, extra_env={"CODEX_HOME": str(home / "unrelated-upgrader")}) assert plistlib.loads(chat_plist.read_bytes())["EnvironmentVariables"]["LOOPX_CHAT_CODEX_HOME"] == str(selected) + # A coordinator can resume workers from another existing Codex home. + # The Chat override must not overwrite that execution profile. + execution_home = (home / "worker home").resolve() + run_script(fake_bin, home, ["install"], schema_version=2, + extra_env={"CODEX_HOME": str(execution_home), + "LOOPX_CHAT_CODEX_HOME": str(selected)}) + installed = plistlib.loads(chat_plist.read_bytes()) + assert installed["EnvironmentVariables"]["CODEX_HOME"] == str(execution_home) + assert installed["EnvironmentVariables"]["LOOPX_CHAT_CODEX_HOME"] == str(selected) + run_script(fake_bin, home, ["restart"], schema_version=2) + preserved = plistlib.loads(chat_plist.read_bytes()) + assert preserved["EnvironmentVariables"] == installed["EnvironmentVariables"] + # Execute the generated wrapper with a bounded fixture entrypoint. + # It must transport both settings, including spaces, to the same child. + fake_loopx = fake_bin / "loopx" + original_entry = fake_loopx.read_bytes() + write_executable(fake_loopx, "#!/usr/bin/env python3\nimport json, os\n" + "print(json.dumps({k:os.environ[k] for k in " + "['CODEX_HOME','LOOPX_CHAT_CODEX_HOME']}))\n") + try: + launched = subprocess.run(preserved["ProgramArguments"], + capture_output=True, text=True, check=True) + assert json.loads(launched.stdout) == preserved["EnvironmentVariables"] + finally: + fake_loopx.write_bytes(original_entry) + before_invalid_home = chat_plist.read_bytes() + rejected = run_script(fake_bin, home, ["install"], schema_version=2, + extra_env={"CODEX_HOME": "relative-worker-home"}, check=False) + assert rejected.returncode != 0 and "CODEX_HOME must be absolute" in rejected.stderr + assert chat_plist.read_bytes() == before_invalid_home + # Legacy generated plists used only a shell export. Preserve quoted # paths across upgrades without ever executing their command contents. legacy = plistlib.loads(chat_plist.read_bytes()) diff --git a/scripts/macos-dashboard-launchagent.sh b/scripts/macos-dashboard-launchagent.sh index 6df6b53cef..affcb3c668 100755 --- a/scripts/macos-dashboard-launchagent.sh +++ b/scripts/macos-dashboard-launchagent.sh @@ -47,6 +47,7 @@ Environment overrides: LOOPX_DASHBOARD_HOST LOOPX_LAUNCH_LABEL_PREFIX LOOPX_LOG_MAX_BYTES Rotate an agent log once it exceeds this size (default 10 MiB) + CODEX_HOME Explicit service execution home, independent of the Chat override LOOPX_CHAT_CODEX_HOME Explicit managed Codex home (upgrades preserve the existing binding) EOF } @@ -209,8 +210,8 @@ raise SystemExit(1) PY } -resolve_chat_codex_home() { - "$1" - "$chat_plist" <<'PY' +resolve_codex_home() { + "$1" - "$chat_plist" "$2" "${3:-}" <<'PY' import os from pathlib import Path import plistlib @@ -218,14 +219,15 @@ import shlex import sys target = Path(sys.argv[1]) -selected = os.environ.get("LOOPX_CHAT_CODEX_HOME") +variable, fallback = sys.argv[2:4] +selected = os.environ.get(variable) if not selected and target.exists(): # Decode, never execute, an old generated shell command. A malformed plist # must fail closed rather than silently adopt the upgrader's account home. with target.open("rb") as stream: plist = plistlib.load(stream) env = plist.get("EnvironmentVariables", {}) - selected = env.get("LOOPX_CHAT_CODEX_HOME") or env.get("CODEX_HOME") + selected = env.get(variable) or env.get("CODEX_HOME") if not selected: args = plist.get("ProgramArguments", []) if len(args) == 3 and args[1] == "-c": @@ -237,17 +239,17 @@ if not selected and target.exists(): selected = words[index + 1].split("=", 1)[1] break selected = selected or str(Path.home() / ".codex") -selected = selected or os.environ.get("CODEX_HOME") or str(Path.home() / ".codex") +selected = selected or fallback or os.environ.get("CODEX_HOME") or str(Path.home() / ".codex") path = Path(selected).expanduser() if not path.is_absolute(): - raise SystemExit("LoopX Chat Codex home must be absolute") + raise SystemExit(f"{variable} must be absolute") print(path.resolve()) PY } write_plists() { local status_command python_command codex_command claude_command lark_cli_command registry - local path_prefix command_path command_dir status_shell chat_shell control_plane_write_arg lark_cli_arg codex_home_export chat_codex_home + local path_prefix command_path command_dir status_shell chat_shell control_plane_write_arg lark_cli_arg codex_home_export chat_codex_home execution_codex_home status_command="$(resolve_status_command)" python_command="$(resolve_loopx_python)" registry="$(resolve_global_registry "$python_command")" @@ -274,8 +276,9 @@ write_plists() { if [[ -n "$lark_cli_command" ]]; then lark_cli_arg=" --lark-cli-bin $(shell_quote "$lark_cli_command")" fi - chat_codex_home="$(resolve_chat_codex_home "$python_command")" - codex_home_export=" export CODEX_HOME=$(shell_quote "$chat_codex_home"); export LOOPX_CHAT_CODEX_HOME=$(shell_quote "$chat_codex_home");" + chat_codex_home="$(resolve_codex_home "$python_command" LOOPX_CHAT_CODEX_HOME)" + execution_codex_home="$(resolve_codex_home "$python_command" CODEX_HOME "$chat_codex_home")" + codex_home_export=" export CODEX_HOME=$(shell_quote "$execution_codex_home"); export LOOPX_CHAT_CODEX_HOME=$(shell_quote "$chat_codex_home");" status_shell="$(log_rotation_prelude status) export LOOPX_PYTHON=$(shell_quote "$python_command"); export PATH=$(shell_quote "$path_prefix"):\$PATH; exec $(shell_quote "$status_command") --registry $(shell_quote "$registry") serve-status --global-registry --host $(shell_quote "$host") --port $(shell_quote "$status_port") --limit $(shell_quote "$status_limit")$control_plane_write_arg" chat_shell="$(log_rotation_prelude chat) export LOOPX_PYTHON=$(shell_quote "$python_command");$codex_home_export export PATH=$(shell_quote "$path_prefix"):\$PATH; exec $(shell_quote "$status_command") --registry $(shell_quote "$registry") chat --global-registry --host $(shell_quote "$host") --port $(shell_quote "$chat_port") --codex-bin $(shell_quote "$codex_command") --claude-bin $(shell_quote "$claude_command")$lark_cli_arg --replace-existing-loopx-chat --no-open" @@ -319,6 +322,8 @@ EOF $chat_label EnvironmentVariables + CODEX_HOME + $(xml_escape "$execution_codex_home") LOOPX_CHAT_CODEX_HOME $(xml_escape "$chat_codex_home")