Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/integrations/session-runtime-control-plane-adapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
43 changes: 41 additions & 2 deletions examples/macos-dashboard-launchagent-status-smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from __future__ import annotations

import json
import os
import plistlib
import shutil
Expand Down Expand Up @@ -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)
Expand All @@ -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={
Expand Down Expand Up @@ -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())
Expand Down
23 changes: 14 additions & 9 deletions scripts/macos-dashboard-launchagent.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -275,23 +276,24 @@ 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
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":
Expand All @@ -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")"
Expand All @@ -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 <common_runtime_root>/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"
Expand Down Expand Up @@ -393,6 +396,8 @@ EOF
<string>$chat_label</string>
<key>EnvironmentVariables</key>
<dict>
<key>CODEX_HOME</key>
<string>$(xml_escape "$execution_codex_home")</string>
<key>LOOPX_CHAT_CODEX_HOME</key>
<string>$(xml_escape "$chat_codex_home")</string>
<key>LOOPX_GLOBAL_REGISTRY</key>
Expand Down
Loading