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 c54a287b3f..d8ae87d3d7 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 @@ -307,7 +308,8 @@ 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) @@ -317,7 +319,6 @@ def main() -> int: workspaces = [(home / "workspace one").resolve(), (home / "workspace $(touch sentinel) & two").resolve()] for workspace in workspaces: workspace.mkdir() - import json import shlex custom_registry = (home / "isolated" / "registry.json").resolve() run_script(fake_bin, home, ["install"], schema_version=2, extra_env={ @@ -351,6 +352,44 @@ def main() -> int: assert rejected.returncode != 0 assert chat_plist.read_bytes() == before + # 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, f"#!{sys.executable}\nimport json, os, sys\n" + "print(json.dumps({'homes':{k:os.environ[k] for k in " + "['CODEX_HOME','LOOPX_CHAT_CODEX_HOME']},'argv':sys.argv[1:]}))\n") + try: + launched = subprocess.run(preserved["ProgramArguments"], + capture_output=True, text=True, check=True) + transported = json.loads(launched.stdout) + assert transported["homes"] == {key: preserved["EnvironmentVariables"][key] + for key in ("CODEX_HOME", "LOOPX_CHAT_CODEX_HOME")} + argv = transported["argv"] + assert argv[argv.index("--registry") + 1] == str(custom_registry) + assert [argv[i + 1] for i, word in enumerate(argv[:-1]) if word == "--scan-path"] == [str(p) for p in workspaces] + assert "--global-registry" not in argv + assert not (root_sentinel := REPO_ROOT / "sentinel").exists(), root_sentinel + 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 8b2ffe2f98..dc966d6dcb 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) LOOPX_CHAT_SCAN_PATHS_JSON JSON array of absolute workspace directories (preserved on upgrade) EOF @@ -275,8 +276,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 @@ -284,14 +285,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": @@ -303,17 +305,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 chat_scan_paths chat_scan_args + 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 chat_scan_paths chat_scan_args status_command="$(resolve_status_command)" python_command="$(resolve_loopx_python "$status_command")" registry="$(resolve_global_registry "$python_command")" @@ -340,14 +342,15 @@ 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")" + 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")" chat_scan_paths="$(resolve_chat_scan_paths "$python_command")" chat_scan_args="$("$python_command" -c 'import json,shlex,sys; print("".join(" --scan-path " + shlex.quote(path) for path in json.load(sys.stdin)))' <<<"$chat_scan_paths")" expected_chat_runtime_identity >/dev/null || { echo "Could not resolve the installed LoopX runtime identity; existing plists were kept." >&2 return 1 } - codex_home_export=" export CODEX_HOME=$(shell_quote "$chat_codex_home"); export LOOPX_CHAT_CODEX_HOME=$(shell_quote "$chat_codex_home");" + codex_home_export=" export CODEX_HOME=$(shell_quote "$execution_codex_home"); export LOOPX_CHAT_CODEX_HOME=$(shell_quote "$chat_codex_home");" # Registry has already been resolved explicitly. --global-registry would # replace it with /registry.json and lose custom routes. 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 --host $(shell_quote "$host") --port $(shell_quote "$status_port") --limit $(shell_quote "$status_limit")$chat_scan_args$control_plane_write_arg" @@ -393,6 +396,8 @@ EOF $chat_label EnvironmentVariables + CODEX_HOME + $(xml_escape "$execution_codex_home") LOOPX_CHAT_CODEX_HOME $(xml_escape "$chat_codex_home") LOOPX_GLOBAL_REGISTRY