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
45 changes: 45 additions & 0 deletions docs/guides/installing-loopx.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

Expand Down Expand Up @@ -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
Expand Down
50 changes: 49 additions & 1 deletion examples/macos-dashboard-launchagent-status-smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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())
Expand Down
5 changes: 4 additions & 1 deletion loopx/chat_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion loopx/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions loopx/release_candidate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down Expand Up @@ -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},
Expand Down
60 changes: 57 additions & 3 deletions loopx/release_manifest.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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")
Expand All @@ -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/")
Comment thread
loopx-agent marked this conversation as resolved.
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:
Expand Down
20 changes: 10 additions & 10 deletions loopx/semantics/project_registry_io_manifest_v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -455,31 +455,31 @@
},
{
"site": "loopx/chat_server.py::<module>.ChatRequestHandler._goal_channel_extension_ready::codec_read:load_registry#1",
"line": 999,
"line": 1002,
"column": 24,
"kind": "codec_read",
"api": "load_registry",
"classification": "codec_api"
},
{
"site": "loopx/chat_server.py::<module>.ChatRequestHandler._registry_and_goal::codec_read:load_registry#1",
"line": 526,
"line": 529,
"column": 20,
"kind": "codec_read",
"api": "load_registry",
"classification": "codec_api"
},
{
"site": "loopx/chat_server.py::<module>.serve_chat::codec_read:load_registry#1",
"line": 1568,
"line": 1571,
"column": 16,
"kind": "codec_read",
"api": "load_registry",
"classification": "codec_api"
},
{
"site": "loopx/chat_server.py::<module>.serve_chat._wake_goal_context::codec_read:load_registry#1",
"line": 1673,
"line": 1676,
"column": 20,
"kind": "codec_read",
"api": "load_registry",
Expand Down Expand Up @@ -2159,47 +2159,47 @@
},
{
"site": "loopx/status_server.py::<module>.StatusRequestHandler._handle_extension_presentation_surfaces::codec_read:load_registry#1",
"line": 854,
"line": 858,
"column": 24,
"kind": "codec_read",
"api": "load_registry",
"classification": "codec_api"
},
{
"site": "loopx/status_server.py::<module>.StatusRequestHandler._handle_extension_projection::codec_read:load_registry#1",
"line": 802,
"line": 806,
"column": 24,
"kind": "codec_read",
"api": "load_registry",
"classification": "codec_api"
},
{
"site": "loopx/status_server.py::<module>.StatusRequestHandler._handle_periodic_report_index::codec_read:load_registry#1",
"line": 884,
"line": 888,
"column": 24,
"kind": "codec_read",
"api": "load_registry",
"classification": "codec_api"
},
{
"site": "loopx/status_server.py::<module>.StatusRequestHandler._handle_periodic_report_projection::codec_read:load_registry#1",
"line": 948,
"line": 952,
"column": 24,
"kind": "codec_read",
"api": "load_registry",
"classification": "codec_api"
},
{
"site": "loopx/status_server.py::<module>.StatusRequestHandler._handle_review_material::codec_read:load_registry#1",
"line": 746,
"line": 750,
"column": 24,
"kind": "codec_read",
"api": "load_registry",
"classification": "codec_api"
},
{
"site": "loopx/status_server.py::<module>.StatusRequestHandler._status_readiness::codec_read:load_registry#1",
"line": 1016,
"line": 1020,
"column": 24,
"kind": "codec_read",
"api": "load_registry",
Expand Down
6 changes: 5 additions & 1 deletion loopx/status_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading