diff --git a/docs/guides/installing-loopx.md b/docs/guides/installing-loopx.md index 373a6f3244..84686f912c 100644 --- a/docs/guides/installing-loopx.md +++ b/docs/guides/installing-loopx.md @@ -16,6 +16,7 @@ Choose one installation owner and keep it authoritative: | --- | --- | --- | --- | | Normal release | Python package environment | `python3 -m pip install loopx` | `loopx update apply` or the manual pip sequence below | | Isolated CLI on an externally managed machine | `pipx` | `pipx install loopx` | `pipx upgrade loopx`, then refresh LoopX host material | +| Existing uv tool environment | `uv` | `uv tool install loopx` | `uv tool upgrade loopx`, then refresh LoopX host material | | Contributor or source qualification | Git checkout | clone/fetch plus `scripts/install-local.sh` | update the checkout explicitly, rerun the installer, validate `loopx-canary` before promotion | | No-clone recovery fallback | LoopX archive snapshot | published archive installer | `loopx update apply` | @@ -82,6 +83,50 @@ loopx workflow-skills --install loopx doctor ``` +## macOS service identity and workspace selection + +Use the existing `scripts/macos-dashboard-launchagent.sh` from a matching +checkout to manage the status and Chat services. `LOOPX_BIN_DIR` selects the +single installed CLI owner; the helper asks that command's installation-only +doctor for its Python executable and service identity. It does not install a +second model runner or read registered projects to qualify the package. + +```bash +LOOPX_GLOBAL_REGISTRY="$HOME/.loopx/registry.global.json" \ +LOOPX_CHAT_CODEX_HOME="$HOME/.codex" \ +LOOPX_CHAT_SCAN_PATHS_JSON='["/absolute/path/to/project-one", "/absolute/path/to/project-two"]' \ +bash scripts/macos-dashboard-launchagent.sh install +bash scripts/macos-dashboard-launchagent.sh status +``` + +The generated Chat plist preserves the registry, selected Codex home and +workspace directory array across `install`/`restart`; an explicit environment +override changes that selection. Paths must be existing absolute directories; +up to 32 are accepted. Selection enables discovery, while Core authorization +still owns conversation grants. An empty array retains the CLI's default +scan behavior. Existing plists without a workspace array also retain that +behavior until an operator explicitly selects directories. Missing or malformed +selected paths fail before either plist is replaced. The resolved registry is +passed verbatim, including when its filename differs from the default global +registry filename. + +Snapshot identities retain their release id and source revision. A non-editable +wheel instead exposes an additive `package_fingerprint` in the existing +`loopx_runtime_identity_v1`: SHA-256 over its actual RECORD-owned LoopX package +files, including Python, TypeScript and frontend assets. Editable, unowned, +redirected or incomplete packages cannot claim this fingerprint. It distinguishes +same-version artifacts for local service reuse; it does not certify a release, +publisher or source commit. Both services capture their identity at startup, so +an in-place upgrade cannot make an old process report the replacement's identity. +An unqualified install fails before the helper stops an existing service. + +After upgrading with the chosen package manager, use `restart` to replace the +owned local services and verify the exact running Chat identity. `stop` unloads +them; `uninstall` also removes their two plists. Restoring a prior package through +the same installation owner and restarting provides rollback. Source and +synthetic canary evidence do not establish actual login-after-reboot or provider +message acceptance. + ## Native Windows PowerShell 7 The PyPI distribution is also the default native Windows path. From PowerShell diff --git a/examples/macos-dashboard-launchagent-status-smoke.py b/examples/macos-dashboard-launchagent-status-smoke.py index 819b23e8f4..c54a287b3f 100644 --- a/examples/macos-dashboard-launchagent-status-smoke.py +++ b/examples/macos-dashboard-launchagent-status-smoke.py @@ -214,6 +214,10 @@ def main() -> int: fake_bin / "loopx", "#!/usr/bin/env bash\n" "if [[ \"$*\" == *\"--format json doctor\"* ]]; then\n" + " if [[ -n \"${FAKE_RUNTIME_IDENTITY:-}\" ]]; then\n" + " printf '{\"service_runtime_identity\":%s}\\n' \"$FAKE_RUNTIME_IDENTITY\"\n" + " exit 0\n" + " fi\n" " printf '%s\\n' '{\"release_manifest\":{\"manifest\":{\"release_id\":\"current-release\",\"package\":{\"version\":\"0.5.3\"},\"source\":{\"git_commit\":\"current-revision\"}}}}'\n" " exit 0\n" "fi\n" @@ -228,6 +232,10 @@ def main() -> int: fake_bin / "curl", "#!/usr/bin/env bash\n" "if [[ \"$*\" == *\"/api/chat/capabilities\"* ]]; then\n" + " if [[ -n \"${FAKE_RUNTIME_IDENTITY:-}\" ]]; then\n" + " printf '{\"ok\":true,\"schema_version\":\"loopx_chat_capabilities_v1\",\"runtime_identity\":%s}\\n' \"$FAKE_RUNTIME_IDENTITY\"\n" + " exit 0\n" + " fi\n" " printf '%s\\n' '{\"ok\":true,\"schema_version\":\"loopx_chat_capabilities_v1\",\"runtime_identity\":{\"schema_version\":\"loopx_runtime_identity_v1\",\"package_version\":\"0.5.3\",\"release_id\":\"current-release\",\"source_revision\":\"current-revision\"}}'\n" " exit 0\n" "fi\n" @@ -262,7 +270,8 @@ def main() -> int: default_plist = status_plist.read_text(encoding="utf-8") default_chat_plist = chat_plist.read_text(encoding="utf-8") assert "--enable-control-plane-write-api" not in default_plist, default_plist - assert " chat --global-registry " in default_chat_plist, default_chat_plist + assert " chat --host " in default_chat_plist, default_chat_plist + assert "--global-registry" not in default_chat_plist, default_chat_plist assert "--port 8767" in default_chat_plist, default_chat_plist assert "--replace-existing-loopx-chat" in default_chat_plist, default_chat_plist assert "--no-open" in default_chat_plist, default_chat_plist @@ -303,6 +312,45 @@ def main() -> int: extra_env={"CODEX_HOME": str(home / "unrelated-upgrader")}) assert plistlib.loads(chat_plist.read_bytes())["EnvironmentVariables"]["LOOPX_CHAT_CODEX_HOME"] == str(selected) + # Two independently selected workspaces and a custom registry survive + # reinstall. Shell metacharacters in a directory are literal arguments. + 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={ + "LOOPX_CHAT_SCAN_PATHS_JSON": json.dumps([str(p) for p in workspaces]), + "LOOPX_GLOBAL_REGISTRY": str(custom_registry), + }) + run_script(fake_bin, home, ["restart"], schema_version=2) + context_plist = plistlib.loads(chat_plist.read_bytes()) + assert json.loads(context_plist["EnvironmentVariables"]["LOOPX_CHAT_SCAN_PATHS_JSON"]) == [str(p) for p in workspaces] + command = shlex.split(context_plist["ProgramArguments"][2]) + assert [command[i + 1] for i, word in enumerate(command[:-1]) if word == "--scan-path"] == [str(p) for p in workspaces] + assert command[command.index("--registry") + 1] == str(custom_registry) + assert "--global-registry" not in command + assert str(custom_registry) in status_plist.read_text() + status_command = shlex.split(plistlib.loads(status_plist.read_bytes())["ProgramArguments"][2]) + assert [status_command[i + 1] for i, word in enumerate(status_command[:-1]) if word == "--scan-path"] == [str(p) for p in workspaces] + before = chat_plist.read_bytes() + rejected = run_script(fake_bin, home, ["install"], schema_version=2, + extra_env={"LOOPX_CHAT_SCAN_PATHS_JSON": '["relative"]'}, check=False) + assert rejected.returncode != 0 + assert chat_plist.read_bytes() == before + + wheel_identity = {"schema_version": "loopx_runtime_identity_v1", "package_version": "1.2.4", + "release_id": None, "source_revision": None, "package_fingerprint": "sha256:" + "a" * 64} + run_script(fake_bin, home, ["restart"], schema_version=2, + extra_env={"FAKE_RUNTIME_IDENTITY": json.dumps(wheel_identity)}) + before = chat_plist.read_bytes() + del wheel_identity["package_fingerprint"] + rejected = run_script(fake_bin, home, ["install"], schema_version=2, + extra_env={"FAKE_RUNTIME_IDENTITY": json.dumps(wheel_identity)}, check=False) + assert rejected.returncode != 0 + assert chat_plist.read_bytes() == before + # 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/loopx/chat_server.py b/loopx/chat_server.py index 0caa20e02f..bec518dbe7 100644 --- a/loopx/chat_server.py +++ b/loopx/chat_server.py @@ -426,6 +426,9 @@ class ChatHTTPServer(ThreadingHTTPServer): goal_subagent_configuration_enabled: bool def __init__(self, *args: Any, **kwargs: Any) -> None: + # Freeze before serving: an in-place package upgrade must not retag the + # old process with the identity of bytes it has never loaded. + self.runtime_identity = release_runtime_identity() super().__init__(*args, **kwargs) self.completed_todo_pages = CompletedTodoPages() @@ -1391,7 +1394,7 @@ def do_GET(self) -> None: "manager": manager_capabilities_projection( self.server.runtime_controller, self.server.chat_store ), - "runtime_identity": release_runtime_identity(), + "runtime_identity": self.server.runtime_identity, "agent_backend": "multi_adapter", "sandbox": "read-only", "approval_policy": "never", diff --git a/loopx/doctor.py b/loopx/doctor.py index 87af2242d3..a063bdd661 100644 --- a/loopx/doctor.py +++ b/loopx/doctor.py @@ -22,7 +22,7 @@ from .python_install_owner import PythonInstallOwner, python_distribution_upgrade_command, resolve_python_install_owner from .capabilities.project_skill_delivery import discover_project_scoped_skill_ids from .registry_writability import probe_registry_write_path -from .release_manifest import load_release_manifest, release_version_tag +from .release_manifest import load_release_manifest, release_runtime_identity, release_version_tag from .skill_install_readback import ( ARK_MANAGED_AGENT_REQUIRED_SKILL_IDS, PACKAGED_HOST_SKILL_IDS, @@ -1109,6 +1109,7 @@ def collect_doctor( payload = { "ok": all(check["ok"] for check in checks if check["required"]), "mode": "deep" if deep else "standard", + "service_runtime_identity": release_runtime_identity(), "agent_type": canonical_agent_type, "python": { "executable": sys.executable, diff --git a/loopx/release_candidate.py b/loopx/release_candidate.py index 85a6b8e3e7..e5c72e6baa 100644 --- a/loopx/release_candidate.py +++ b/loopx/release_candidate.py @@ -3,12 +3,14 @@ import importlib from concurrent.futures import ThreadPoolExecutor import os +import sys from importlib.metadata import PackageNotFoundError, distribution import subprocess from pathlib import Path from typing import Any, Sequence from .command_invocation import command_argv +from .release_manifest import release_runtime_identity REPRESENTATIVE_CLI_IMPORTS = ( @@ -301,6 +303,8 @@ def collect_installation_doctor(*, deep: bool) -> dict[str, Any]: payload: dict[str, Any] = { "mode": "deep" if deep else "standard", "scope": "installation_only", + "service_runtime_identity": release_runtime_identity(), + "python": {"executable": sys.executable}, "checks": checks, "typescript_control_plane": runtime, "path": {"loopx": str(command) if command else None}, diff --git a/loopx/release_manifest.py b/loopx/release_manifest.py index ec5a4320ef..b207f99d8d 100644 --- a/loopx/release_manifest.py +++ b/loopx/release_manifest.py @@ -1,6 +1,7 @@ from __future__ import annotations from datetime import datetime, timezone +from importlib.metadata import PackageNotFoundError, distribution import hashlib import json import os @@ -299,8 +300,9 @@ def release_runtime_identity(release_root: Path | None = None) -> dict[str, str """Return a public-safe identity for version-fencing local services. Installed snapshots carry an immutable release id and source revision. A - source checkout has neither, so callers can retain development coexistence - while installed launchers require an exact snapshot match. + source checkout has neither. Non-editable Python distributions instead + carry a digest of their owned package files. This fences local service + reuse; it does not certify a publisher, release or source revision. """ root = release_root @@ -318,7 +320,7 @@ def release_runtime_identity(release_root: Path | None = None) -> dict[str, str package = package if isinstance(package, dict) else {} source = manifest.get("source") source = source if isinstance(source, dict) else {} - return { + identity = { "schema_version": RUNTIME_IDENTITY_SCHEMA_VERSION, "package_version": ( package.get("version") @@ -336,6 +338,58 @@ def release_runtime_identity(release_root: Path | None = None) -> dict[str, str else None ), } + if not identity["release_id"] and root.resolve() == Path(__file__).resolve().parents[1]: + fingerprint = _distribution_runtime_fingerprint() + if fingerprint is not None: + identity["package_fingerprint"] = fingerprint + return identity + + +def _distribution_artifact_file(path: Path) -> bool: + return "__pycache__" not in path.parts and path.suffix not in {".pyc", ".pyo"} + + +def _distribution_runtime_fingerprint() -> str | None: + """Hash the actual imported, RECORD-owned wheel contents, not RECORD hashes. + + Refuse editable, redirected, incomplete or ambiguous ownership. Bytecode + caches are generated by Python and are not part of the installed artifact. + """ + try: + installed = distribution("loopx") + direct_url = json.loads(installed.read_text("direct_url.json") or "{}") + if direct_url.get("dir_info", {}).get("editable") or installed.version != __version__: + return None + package_root = Path(__file__).resolve().parent + files = { + item.as_posix(): Path(item.locate()) + for item in installed.files or () + if item.as_posix().startswith("loopx/") + and _distribution_artifact_file(Path(item.as_posix())) + } + module = files.get("loopx/release_manifest.py") + if module is None or module.resolve() != Path(__file__).resolve(): + return None + entries = list(package_root.rglob("*")) + if any(path.is_symlink() for path in entries): + return None + actual = { + "loopx/" + path.relative_to(package_root).as_posix() + for path in entries + if path.is_file() and _distribution_artifact_file(path.relative_to(package_root)) + } + if actual != files.keys(): + return None + digest = hashlib.sha256() + for name, path in sorted(files.items()): + expected = package_root.parent / name + if ".." in Path(name).parts or path.is_symlink() or path.absolute() != expected: + return None + digest.update(name.encode("utf-8") + b"\0") + digest.update(_sha256_file(path).encode("ascii") + b"\n") + return "sha256:" + digest.hexdigest() + except (PackageNotFoundError, OSError, ValueError, AttributeError, TypeError): + return None def main(argv: list[str] | None = None) -> int: diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index ed253fab81..971533eb27 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -455,7 +455,7 @@ }, { "site": "loopx/chat_server.py::.ChatRequestHandler._goal_channel_extension_ready::codec_read:load_registry#1", - "line": 999, + "line": 1002, "column": 24, "kind": "codec_read", "api": "load_registry", @@ -463,7 +463,7 @@ }, { "site": "loopx/chat_server.py::.ChatRequestHandler._registry_and_goal::codec_read:load_registry#1", - "line": 526, + "line": 529, "column": 20, "kind": "codec_read", "api": "load_registry", @@ -471,7 +471,7 @@ }, { "site": "loopx/chat_server.py::.serve_chat::codec_read:load_registry#1", - "line": 1568, + "line": 1571, "column": 16, "kind": "codec_read", "api": "load_registry", @@ -479,7 +479,7 @@ }, { "site": "loopx/chat_server.py::.serve_chat._wake_goal_context::codec_read:load_registry#1", - "line": 1673, + "line": 1676, "column": 20, "kind": "codec_read", "api": "load_registry", @@ -2159,7 +2159,7 @@ }, { "site": "loopx/status_server.py::.StatusRequestHandler._handle_extension_presentation_surfaces::codec_read:load_registry#1", - "line": 854, + "line": 858, "column": 24, "kind": "codec_read", "api": "load_registry", @@ -2167,7 +2167,7 @@ }, { "site": "loopx/status_server.py::.StatusRequestHandler._handle_extension_projection::codec_read:load_registry#1", - "line": 802, + "line": 806, "column": 24, "kind": "codec_read", "api": "load_registry", @@ -2175,7 +2175,7 @@ }, { "site": "loopx/status_server.py::.StatusRequestHandler._handle_periodic_report_index::codec_read:load_registry#1", - "line": 884, + "line": 888, "column": 24, "kind": "codec_read", "api": "load_registry", @@ -2183,7 +2183,7 @@ }, { "site": "loopx/status_server.py::.StatusRequestHandler._handle_periodic_report_projection::codec_read:load_registry#1", - "line": 948, + "line": 952, "column": 24, "kind": "codec_read", "api": "load_registry", @@ -2191,7 +2191,7 @@ }, { "site": "loopx/status_server.py::.StatusRequestHandler._handle_review_material::codec_read:load_registry#1", - "line": 746, + "line": 750, "column": 24, "kind": "codec_read", "api": "load_registry", @@ -2199,7 +2199,7 @@ }, { "site": "loopx/status_server.py::.StatusRequestHandler._status_readiness::codec_read:load_registry#1", - "line": 1016, + "line": 1020, "column": 24, "kind": "codec_read", "api": "load_registry", diff --git a/loopx/status_server.py b/loopx/status_server.py index 9171d5c954..239a83cdf6 100644 --- a/loopx/status_server.py +++ b/loopx/status_server.py @@ -237,6 +237,10 @@ class StatusHTTPServer(ThreadingHTTPServer): ssh_config_path: Path | None verbose: bool + def __init__(self, *args: Any, **kwargs: Any) -> None: + self.runtime_identity = release_runtime_identity() + super().__init__(*args, **kwargs) + class StatusRequestHandler(BaseHTTPRequestHandler): server: StatusHTTPServer @@ -985,7 +989,7 @@ def _handle_ssh_hosts(self) -> None: def _local_dashboard_api_payload(self) -> dict[str, Any]: return { "source": "serve-status", - "runtime_identity": release_runtime_identity(), + "runtime_identity": self.server.runtime_identity, "status_url": self.server.status_path, "health_url": "/healthz", "readiness_url": "/?readiness=1", diff --git a/scripts/macos-dashboard-launchagent.sh b/scripts/macos-dashboard-launchagent.sh index 6df6b53cef..8b2ffe2f98 100755 --- a/scripts/macos-dashboard-launchagent.sh +++ b/scripts/macos-dashboard-launchagent.sh @@ -48,6 +48,7 @@ Environment overrides: LOOPX_LAUNCH_LABEL_PREFIX LOOPX_LOG_MAX_BYTES Rotate an agent log once it exceeds this size (default 10 MiB) 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 } @@ -141,6 +142,14 @@ resolve_python_command() { resolve_loopx_python() { local python_command + # Ask the selected console script, rather than letting a checkout venv own + # the interpreter of an unrelated uv/pipx install. Legacy snapshots retain + # the existing resolver until their doctor exposes this projection. + python_command="$("$1" --format json doctor --installation-only | "$(resolve_python_command)" -c 'import json,sys; print((json.load(sys.stdin).get("python") or {}).get("executable") or "")')" || return 1 + if [[ -n "$python_command" && -x "$python_command" ]]; then + printf '%s\n' "$python_command" + return 0 + fi if python_command="$(bash "$repo_root/scripts/loopx-python.sh" 2>/dev/null)"; then printf '%s\n' "$python_command" return 0 @@ -149,12 +158,69 @@ resolve_loopx_python() { } resolve_global_registry() { - local python_command="$1" - if [[ -n "$registry_override" ]]; then - printf '%s\n' "$registry_override" - return 0 - fi - "$python_command" -c 'from loopx.paths import global_registry_path, select_default_runtime_root; print(global_registry_path(select_default_runtime_root()))' + # A Python stdin entry otherwise imports the caller's checkout before the + # selected distribution. Resolve defaults outside that checkout. + ( + cd / + "$1" - "$chat_plist" "$registry_override" <<'PY' +import plistlib +import shlex +import sys +from pathlib import Path + +selected = sys.argv[2] +target = Path(sys.argv[1]) +if not selected and target.exists(): + with target.open("rb") as stream: + plist = plistlib.load(stream) + selected = plist.get("EnvironmentVariables", {}).get("LOOPX_GLOBAL_REGISTRY") + if not selected: + args = plist.get("ProgramArguments", []) + words = shlex.split(args[2]) if len(args) == 3 and args[1] == "-c" else [] + if "--registry" in words: + selected = words[words.index("--registry") + 1] +if not selected: + from loopx.paths import global_registry_path, select_default_runtime_root + selected = str(global_registry_path(select_default_runtime_root())) +path = Path(selected).expanduser() +if not path.is_absolute(): + raise SystemExit("LoopX managed registry must be absolute") +print(path.resolve()) +PY + ) +} + +resolve_chat_scan_paths() { + "$1" - "$chat_plist" <<'PY' +import json +import os +import plistlib +import sys +from pathlib import Path + +selected = os.environ.get("LOOPX_CHAT_SCAN_PATHS_JSON") +target = Path(sys.argv[1]) +if selected is None and target.exists(): + with target.open("rb") as stream: + plist = plistlib.load(stream) + selected = plist.get("EnvironmentVariables", {}).get("LOOPX_CHAT_SCAN_PATHS_JSON") +if selected is not None and (not isinstance(selected, str) or len(selected) > 16000): + raise SystemExit("LoopX Chat workspace selection is too large") +paths = json.loads(selected) if selected is not None else [] +if not isinstance(paths, list) or len(paths) > 32: + raise SystemExit("LoopX Chat workspace selection must be an array of at most 32 directories") +resolved = [] +for item in paths: + if not isinstance(item, str) or not item or any(ord(c) < 32 for c in item): + raise SystemExit("LoopX Chat workspace paths must be nonempty strings without control characters") + path = Path(item).expanduser() + if not path.is_absolute() or not path.is_dir(): + raise SystemExit("LoopX Chat workspace paths must be existing absolute directories") + value = str(path.resolve()) + if value not in resolved: + resolved.append(value) +print(json.dumps(resolved, ensure_ascii=False)) +PY } resolve_optional_command() { @@ -247,9 +313,9 @@ 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 chat_scan_paths chat_scan_args status_command="$(resolve_status_command)" - python_command="$(resolve_loopx_python)" + python_command="$(resolve_loopx_python "$status_command")" registry="$(resolve_global_registry "$python_command")" codex_command="$(resolve_optional_command codex)" claude_command="$(resolve_optional_command claude)" @@ -275,9 +341,17 @@ write_plists() { lark_cli_arg=" --lark-cli-bin $(shell_quote "$lark_cli_command")" fi chat_codex_home="$(resolve_chat_codex_home "$python_command")" + 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");" - 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" + # 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" + 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 --host $(shell_quote "$host") --port $(shell_quote "$chat_port") --codex-bin $(shell_quote "$codex_command") --claude-bin $(shell_quote "$claude_command")$lark_cli_arg$chat_scan_args --replace-existing-loopx-chat --no-open" mkdir -p "$launch_agents_dir" "$logs_dir" @@ -321,6 +395,10 @@ EOF LOOPX_CHAT_CODEX_HOME $(xml_escape "$chat_codex_home") + LOOPX_GLOBAL_REGISTRY + $(xml_escape "$registry") + LOOPX_CHAT_SCAN_PATHS_JSON + $(xml_escape "$chat_scan_paths") ProgramArguments @@ -358,9 +436,14 @@ bootstrap_one() { } start_agents() { + local expected + expected="$(expected_chat_runtime_identity)" || { + echo "Could not resolve the installed LoopX runtime identity; existing agents were kept." >&2 + return 1 + } bootstrap_one "$status_label" "$status_plist" bootstrap_one "$chat_label" "$chat_plist" - verify_current_chat_runtime + verify_current_chat_runtime "$expected" } stop_agents() { @@ -372,7 +455,7 @@ expected_chat_runtime_identity() { local status_command python_command status_command="$(resolve_status_command)" python_command="$(resolve_python_command)" - "$status_command" --format json doctor | "$python_command" -c ' + "$status_command" --format json doctor --installation-only | "$python_command" -c ' import json import sys @@ -380,13 +463,16 @@ payload = json.load(sys.stdin) manifest = ((payload.get("release_manifest") or {}).get("manifest") or {}) package = manifest.get("package") or {} source = manifest.get("source") or {} -identity = { +identity = payload.get("service_runtime_identity") or { "schema_version": "loopx_runtime_identity_v1", "package_version": package.get("version"), "release_id": manifest.get("release_id"), "source_revision": source.get("git_commit"), } -if not identity["package_version"] or not identity["release_id"]: +if (not isinstance(identity, dict) + or identity.get("schema_version") != "loopx_runtime_identity_v1" + or not identity.get("package_version") + or not (identity.get("release_id") or identity.get("package_fingerprint"))): raise SystemExit(2) print(json.dumps(identity, sort_keys=True, separators=(",", ":"))) ' @@ -396,7 +482,7 @@ chat_runtime_identity() { local python_command payload python_command="$(resolve_python_command)" # A scheme-less curl endpoint defaults to local HTTP; managed replacement rejects non-loopback hosts. - payload="$(curl -fsS "$chat_runtime_endpoint/api/chat/capabilities" 2>/dev/null)" + payload="$(curl -fsS --connect-timeout 1 --max-time 5 "$chat_runtime_endpoint/api/chat/capabilities" 2>/dev/null)" "$python_command" -c ' import json import sys @@ -417,7 +503,7 @@ verify_current_chat_runtime() { echo "curl is required to verify the restarted LoopX Chat runtime." >&2 return 1 fi - expected="$(expected_chat_runtime_identity)" || { + expected="${1:-}"; [[ -n "$expected" ]] || expected="$(expected_chat_runtime_identity)" || { echo "Could not resolve the installed LoopX runtime identity." >&2 return 1 } @@ -443,7 +529,7 @@ print_status_contract_health() { echo "- control_plane_write_api: unknown" return fi - status_json="$(curl -fsS "$status_url" 2>/dev/null || true)" + status_json="$(curl -fsS --connect-timeout 1 --max-time 5 "$status_url" 2>/dev/null || true)" if [[ -z "$status_json" ]]; then echo "- status_contract: unavailable (status feed not reachable)" echo "- control_plane_write_api: unknown" diff --git a/tests/test_chat_server_cors.py b/tests/test_chat_server_cors.py index ade7f260b0..21fac86e41 100644 --- a/tests/test_chat_server_cors.py +++ b/tests/test_chat_server_cors.py @@ -113,6 +113,20 @@ def test_chat_capabilities_expose_public_runtime_identity() -> None: server.server_close() +def test_chat_keeps_its_startup_identity_after_package_replacement(monkeypatch: pytest.MonkeyPatch) -> None: + current = {"schema_version": "loopx_runtime_identity_v1", "package_fingerprint": "sha256:original"} + monkeypatch.setattr("loopx.chat_server.release_runtime_identity", lambda: dict(current)) + server, thread = _start_server() + try: + current["package_fingerprint"] = "sha256:replacement" + response = _request(server.server_address[1], method="GET", origin=None) + assert json.loads(response.read())["runtime_identity"]["package_fingerprint"] == "sha256:original" + finally: + server.shutdown() + thread.join(timeout=5) + server.server_close() + + def test_chat_json_rejects_foreign_cors_origin() -> None: server, thread = _start_server() try: diff --git a/tests/test_dashboard_command.py b/tests/test_dashboard_command.py index d940253810..b44c0da44f 100644 --- a/tests/test_dashboard_command.py +++ b/tests/test_dashboard_command.py @@ -336,6 +336,26 @@ class _StaleCapabilitiesHandler(_CapabilitiesHandler): thread.join(timeout=2) +def test_probe_existing_chat_fences_two_wheels_with_the_same_version(monkeypatch: pytest.MonkeyPatch) -> None: + from loopx import dashboard_launcher + + expected = {"schema_version": "loopx_runtime_identity_v1", "package_version": "1.2.4", + "release_id": None, "source_revision": None, "package_fingerprint": "sha256:new"} + monkeypatch.setattr(dashboard_launcher, "release_runtime_identity", lambda _root: expected) + class Handler(_CapabilitiesHandler): + capabilities = {"ok": True, "schema_version": "loopx_chat_capabilities_v1", + "runtime_identity": {**expected, "package_fingerprint": "sha256:old"}} + server, thread = _serve_capabilities(Handler) + try: + assert dashboard_launcher._probe_existing_chat("127.0.0.1", server.server_address[1]) == "stale" + Handler.capabilities["runtime_identity"] = expected + assert dashboard_launcher._probe_existing_chat("127.0.0.1", server.server_address[1]) == "matching" + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + def test_probe_existing_chat_defers_timeout_to_server_bind( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/test_distribution_runtime_identity.py b/tests/test_distribution_runtime_identity.py new file mode 100644 index 0000000000..0a46b0ed86 --- /dev/null +++ b/tests/test_distribution_runtime_identity.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import json +from pathlib import Path +from types import SimpleNamespace +import subprocess +import sys +import venv +import zipfile + +import pytest + +from loopx import release_manifest as manifest + + +@pytest.fixture +def owned_package(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + root = tmp_path / "site-packages" + package = root / "loopx" + package.mkdir(parents=True) + contents = {"release_manifest.py": "python", "control_plane/core.ts": "typed", + "web/chat/index.html": "frontend"} + for name, content in contents.items(): + path = package / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + items = [SimpleNamespace(as_posix=lambda name=name: "loopx/" + name, + locate=lambda name=name: package / name) for name in contents] + installed = SimpleNamespace(version=manifest.__version__, files=items, + read_text=lambda _name: None) + monkeypatch.setattr(manifest, "__file__", str(package / "release_manifest.py")) + monkeypatch.setattr(manifest, "distribution", lambda _name: installed) + monkeypatch.delenv("LOOPX_RELEASE_ROOT", raising=False) + return package, installed + + +@pytest.mark.parametrize("changed", ["release_manifest.py", "control_plane/core.ts", "web/chat/index.html"]) +def test_owned_wheel_identity_fences_changed_bytes_at_the_same_version(owned_package, changed): + package, _installed = owned_package + before = manifest.release_runtime_identity() + assert before["release_id"] is None and before["source_revision"] is None + assert before["package_fingerprint"].startswith("sha256:") + (package / changed).write_text("replacement") + after = manifest.release_runtime_identity() + assert after["package_version"] == before["package_version"] + assert after["package_fingerprint"] != before["package_fingerprint"] + + +def test_generated_bytecode_does_not_retag_the_installed_artifact(owned_package): + package, _installed = owned_package + before = manifest.release_runtime_identity() + (package / "__pycache__").mkdir() + (package / "__pycache__/release_manifest.cpython-312.pyc").write_bytes(b"cache") + assert manifest.release_runtime_identity() == before + + +def test_real_pip_compiled_and_uncompiled_wheel_have_the_same_identity(tmp_path: Path): + # Use the production identity module in a small valid wheel. pip, rather + # than this fixture, generates bytecode and its installed RECORD entries. + version = manifest.__version__ + metadata = f"loopx-{version}.dist-info" + contents = { + "loopx/__init__.py": Path(manifest.__file__).with_name("__init__.py").read_text(), + "loopx/release_manifest.py": Path(manifest.__file__).read_text(), + f"{metadata}/METADATA": f"Metadata-Version: 2.1\nName: loopx\nVersion: {version}\n", + f"{metadata}/WHEEL": "Wheel-Version: 1.0\nGenerator: test\nRoot-Is-Purelib: true\nTag: py3-none-any\n", + } + contents[f"{metadata}/RECORD"] = "".join(f"{name},,\n" for name in [*contents, f"{metadata}/RECORD"]) + wheel = tmp_path / f"loopx-{version}-py3-none-any.whl" + with zipfile.ZipFile(wheel, "w") as archive: + for name, content in contents.items(): + archive.writestr(name, content) + identities = [] + for compile_bytecode in (True, False): + environment = tmp_path / ("compiled" if compile_bytecode else "uncompiled") + venv.EnvBuilder(with_pip=True).create(environment) + python = environment / ("Scripts/python.exe" if sys.platform == "win32" else "bin/python") + subprocess.run([str(python), "-I", "-m", "pip", "install", "--no-index", "--no-deps", + "--disable-pip-version-check", *([] if compile_bytecode else ["--no-compile"]), + str(wheel)], check=True, capture_output=True, text=True, timeout=60) + observed = subprocess.run([str(python), "-I", "-c", + "import json; from importlib.metadata import distribution; " + "from loopx.release_manifest import release_runtime_identity; " + "print(json.dumps({'identity': release_runtime_identity(), " + "'recorded_bytecode': sum(p.suffix == '.pyc' for p in distribution('loopx').files)}))"], + check=True, capture_output=True, text=True, timeout=60) + result = json.loads(observed.stdout) + assert bool(result["recorded_bytecode"]) is compile_bytecode + assert result["identity"]["package_fingerprint"].startswith("sha256:") + identities.append(result["identity"]) + assert identities[0] == identities[1] + + +@pytest.mark.parametrize("invalid", ["editable", "unowned", "extra", "missing", "symlink", "broken_symlink", "version"]) +def test_incomplete_or_redirected_distribution_cannot_claim_an_artifact(owned_package, invalid): + package, installed = owned_package + if invalid == "editable": + installed.read_text = lambda _name: json.dumps({"dir_info": {"editable": True}}) + elif invalid == "unowned": + installed.files = [] + elif invalid == "extra": + (package / "injected.py").write_text("extra") + elif invalid == "missing": + (package / "control_plane/core.ts").unlink() + elif invalid == "symlink": + target = package.parent / "foreign.py" + target.write_text("foreign") + (package / "release_manifest.py").unlink() + (package / "release_manifest.py").symlink_to(target) + elif invalid == "broken_symlink": + (package / "unowned.py").symlink_to(package.parent / "absent.py") + else: + installed.version = "0.0.0" + assert "package_fingerprint" not in manifest.release_runtime_identity() + + +def test_unrelated_source_root_cannot_adopt_an_installed_distribution(owned_package, tmp_path): + assert "package_fingerprint" not in manifest.release_runtime_identity(tmp_path / "checkout") diff --git a/tests/test_status_server_fast_path.py b/tests/test_status_server_fast_path.py index 03efd260ac..8e156432ab 100644 --- a/tests/test_status_server_fast_path.py +++ b/tests/test_status_server_fast_path.py @@ -72,6 +72,16 @@ def fake_collect_status(**kwargs: object) -> dict[str, object]: assert calls[0]["include_goal_subagent_configuration"] is False +def test_status_keeps_its_startup_identity_after_package_replacement(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + current = {"schema_version": "loopx_runtime_identity_v1", "package_fingerprint": "sha256:original"} + monkeypatch.setattr("loopx.status_server.release_runtime_identity", lambda: dict(current)) + with _status_server(tmp_path) as base_url: + current["package_fingerprint"] = "sha256:replacement" + with urllib.request.urlopen(f"{base_url}/?readiness=1", timeout=5) as response: + payload = json.load(response) + assert payload["runtime_identity"]["package_fingerprint"] == "sha256:original" + + def test_status_endpoint_forwards_goal_activation_scope( tmp_path: Path, monkeypatch: pytest.MonkeyPatch,