From 2037de49d36fe09196f01636887b3e45654a3b05 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:01:55 +0800 Subject: [PATCH 1/5] fix(skills): refresh scoped Ego task continuation on LoopX upgrades Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/cli_commands/workflow_skills.py | 4 +- loopx/ego_skill_delivery.py | 280 +++++++++++++++++++++++ loopx/windows_install.py | 9 + loopx/workflow_skill_install.py | 39 +++- scripts/install-local.sh | 13 ++ tests/test_ego_skill_delivery.py | 305 ++++++++++++++++++++++++++ 6 files changed, 646 insertions(+), 4 deletions(-) create mode 100644 loopx/ego_skill_delivery.py create mode 100644 tests/test_ego_skill_delivery.py diff --git a/loopx/cli_commands/workflow_skills.py b/loopx/cli_commands/workflow_skills.py index 48ec9170f3..08f317cfc0 100644 --- a/loopx/cli_commands/workflow_skills.py +++ b/loopx/cli_commands/workflow_skills.py @@ -30,12 +30,12 @@ def register_workflow_skills_command( mutation.add_argument( "--install", action="store_true", - help="Install the packaged workflow skills and managed $loopx entry skill.", + help="Install workflow skills and refresh continuation guidance for an already installed Ego skill.", ) mutation.add_argument( "--uninstall", action="store_true", - help="Remove unchanged LoopX-managed workflow skills.", + help="Remove unchanged LoopX-managed skills and restore any managed Ego skill to its upstream link.", ) parser.add_argument( "--skills-dir", diff --git a/loopx/ego_skill_delivery.py b/loopx/ego_skill_delivery.py new file mode 100644 index 0000000000..009f2122a5 --- /dev/null +++ b/loopx/ego_skill_delivery.py @@ -0,0 +1,280 @@ +"""Refresh the installed Ego host skill without writing the browser bundle. + +This filesystem adapter belongs to workflow-skill delivery. It supplies Agent +guidance, not browser-control authority or a new capability/provider lifecycle. +""" + +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +import shutil +import tempfile +from typing import Any + +from . import __version__ +from .file_lock import exclusive_file_lock + + +_MARKER = ".loopx-ego-skill.json" +_OWNER = "loopx_ego_host_skill_v1" +_POLICY = """ + +## LoopX: continuing an authorized browser task + +This section overrides only a blanket requirement above or in API references +to ask again before reclaiming a user-owned or inactive TaskSpace. + +For the same already-authorized task, reuse its known numeric TaskSpace and +Page without another ownership-confirmation question when your previous normal +finish, handoff, or pause between rounds left that space idle. Read its current +state first. A stable user-owned state left by that normal handoff is not by +itself evidence of active user intervention. Use the documented resume/claim +API; do not create a replacement space to avoid ownership recovery. + +Stop immediately for active user interaction, an unexpected live ownership +change, an explicit pause, or a user reservation of browser control. Do not +retry around a native user-control stop. An unresolved permission prompt, +device chooser, login or verification still belongs to its existing owner. +If this is an unrelated space, its task identity is unverified, or a current +user-control reservation remains unresolved, ask about that specific choice. + +This continuation guidance grants no new operation, account, publishing, +installation, paid-execution or permission authority. Keep the upstream +one-space-per-goal, Page protection, handoff, error and finish-once rules. +""" + + +def _digest(root: Path) -> str: + digest = hashlib.sha256() + for path in sorted(root.rglob("*")): + relative = path.relative_to(root).as_posix() + if relative == _MARKER: + continue + if path.is_symlink(): + content = b"link\0" + os.fsencode(os.readlink(path)) + elif path.is_file(): + content = b"file\0" + path.read_bytes() + else: + continue + digest.update(relative.encode() + b"\0" + content + b"\0") + return digest.hexdigest() + + +def ego_skill_mutation_paths(skills_dir: Path) -> list[Path]: + """Only inspect the shared Ego entry for the default personal skill root.""" + root = skills_dir.expanduser().resolve() + entry = root / "ego-browser" + codex_root = Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex") / "skills" + default_root = root == codex_root.expanduser().resolve() + + def present(path: Path) -> bool: + return ( + path.exists() + or path.is_symlink() + or any(path.parent.glob(f".{path.name}.loopx-previous.*")) + ) + + if not present(entry) and default_root: + entry = Path.home() / ".agents/skills/ego-browser" + if default_root and entry.is_symlink(): + resolved = entry.resolve() + if resolved.is_relative_to(Path.home().resolve()): + try: + marker = json.loads((resolved / _MARKER).read_text(encoding="utf-8")) + except (OSError, ValueError): + marker = None + if isinstance(marker, dict) and marker.get("owner") == _OWNER: + entry = resolved + return [entry] if present(entry) else [] + + +def _remove(path: Path) -> None: + if path.is_symlink() or path.is_file(): + path.unlink() + elif path.exists(): + shutil.rmtree(path) + + +def _publish(candidate: Path, entry: Path) -> None: + # Keep the original outside the temporary staging directory: a failure of + # both publish and restore must leave it available for recovery. + backups = list(entry.parent.glob(f".{entry.name}.loopx-previous.*")) + if backups: + raise RuntimeError( + f"Ego skill recovery required; inspect the preserved original at {backups[0]}" + ) + backup = entry.with_name(f".{entry.name}.loopx-previous.{os.getpid()}") + entry.replace(backup) + try: + candidate.replace(entry) + except BaseException: + backup.replace(entry) + raise + _remove(backup) + + +def reconcile_ego_browser_skill( + *, + skills_dir: Path, + execute: bool = False, + uninstall: bool = False, +) -> dict[str, Any]: + paths = ego_skill_mutation_paths(skills_dir) + if not paths: + return { + "ok": True, + "ready": False, + "status": "not_installed", + "reason": "No installed Ego skill in this host scope; browser installation is unchanged.", + } + entry = paths[0] + if execute: + with exclusive_file_lock(entry.parent / ".loopx-ego-skill"): + return _reconcile(entry, execute=True, uninstall=uninstall) + return _reconcile(entry, execute=False, uninstall=uninstall) + + +def _reconcile(entry: Path, *, execute: bool, uninstall: bool) -> dict[str, Any]: + # Ego owns this stable link across browser versions. Never infer an App + # directory or overwrite a custom source when this installation is absent. + vendor_ref = Path.home() / ".local/share/ego/ego-skills" + vendor = vendor_ref.resolve() + result: dict[str, Any] = {"ok": True, "path": str(entry), "ready": False} + backups = list(entry.parent.glob(f".{entry.name}.loopx-previous.*")) + if backups: + return { + **result, + "ok": False, + "status": "recovery_required", + "reason": f"A failed publish preserved the original at {backups[0]}; restore that entry before refreshing. No browser files were changed.", + } + try: + marker = ( + json.loads((entry / _MARKER).read_text(encoding="utf-8")) + if not entry.is_symlink() + else None + ) + except (OSError, ValueError): + marker = None + managed = isinstance(marker, dict) and marker.get("owner") == _OWNER + pristine_link = entry.is_symlink() and entry.resolve() == vendor + if not managed and not pristine_link: + return { + **result, + "status": "preserved_unmanaged", + "reason": "Custom or unrecognized Ego skill preserved. Keep its policy manually, or restore the upstream Ego skill link before refreshing.", + } + if managed and _digest(entry) != marker.get("tree_sha256"): + return { + **result, + "ok": not uninstall, + "status": "preserved_modified", + "reason": "Ego skill changed locally after LoopX delivery; no files were overwritten. Reconcile these edits before refreshing or rolling back.", + } + if uninstall and not managed: + return { + **result, + "status": "unmanaged", + "reason": "No LoopX Ego policy to remove.", + } + try: + upstream = (vendor / "SKILL.md").read_text(encoding="utf-8") + except (OSError, UnicodeError): + return { + **result, + "ok": not uninstall, + "status": "source_unavailable", + "reason": "Installed Ego skill source is unavailable; existing entry preserved. Restore Ego's skill installation and rerun workflow-skills --install.", + } + frontmatter = upstream.split("---", 2) + named_ego = ( + len(frontmatter) == 3 + and not frontmatter[0].strip() + and any( + line.partition(":")[0].strip() == "name" + and line.partition(":")[2].strip().strip("\"'") == "ego-browser" + for line in frontmatter[1].splitlines() + ) + ) + if (vendor / _MARKER).exists() or not named_ego: + return { + **result, + "ok": False, + "status": "invalid_source", + "reason": "Ego source is not an upstream ego-browser skill; entry preserved.", + } + if entry.parent.resolve().is_relative_to(vendor): + return { + **result, + "ok": False, + "status": "invalid_source", + "reason": "Ego source contains the delivery destination; entry preserved.", + } + source_digest = _digest(vendor) + policy_digest = hashlib.sha256(_POLICY.encode()).hexdigest() + if ( + not uninstall + and managed + and marker.get("source_sha256") == source_digest + and marker.get("policy_sha256") == policy_digest + and marker.get("loopx_version") == __version__ + ): + return { + **result, + "ready": True, + "status": "unchanged", + "reason": "Installed Ego API and LoopX continuation guidance are current.", + } + if not execute: + return { + **result, + "status": "would_restore" if uninstall else "would_refresh", + "reason": "Restore the current upstream skill link." + if uninstall + else "Copy the installed Ego skill and apply scoped task-continuation guidance.", + } + with tempfile.TemporaryDirectory( + prefix=".loopx-ego-", dir=entry.parent + ) as temporary: + candidate = Path(temporary) / "candidate" + if uninstall: + candidate.symlink_to(vendor_ref, target_is_directory=True) + else: + shutil.copytree(vendor, candidate) + if _digest(vendor) != source_digest: + raise RuntimeError( + "Ego source changed during skill copy; existing entry preserved. Retry after the browser upgrade finishes." + ) + candidate.chmod(candidate.stat().st_mode | 0o700) + (candidate / "SKILL.md").chmod( + (candidate / "SKILL.md").stat().st_mode | 0o200 + ) + (candidate / "SKILL.md").write_text( + upstream.rstrip() + _POLICY, encoding="utf-8" + ) + (candidate / _MARKER).write_text( + json.dumps( + { + "owner": _OWNER, + "loopx_version": __version__, + "source_sha256": source_digest, + "policy_sha256": policy_digest, + "tree_sha256": _digest(candidate), + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + _publish(candidate, entry) + return { + **result, + "ready": not uninstall, + "status": "restored" if uninstall else "refreshed", + "reason": "Restored Ego's current upstream skill link." + if uninstall + else "Updated the local Ego skill; browser files and user-control boundaries are unchanged.", + } diff --git a/loopx/windows_install.py b/loopx/windows_install.py index 5a69957af2..9e8d3fc5dd 100644 --- a/loopx/windows_install.py +++ b/loopx/windows_install.py @@ -343,11 +343,14 @@ def _copy_path(source: Path, target: Path) -> None: def _skill_mutation_paths(skills_dir: Path) -> list[Path]: + from .ego_skill_delivery import ego_skill_mutation_paths + codex_home = skills_dir.parent paths = { *(skills_dir / skill_id for skill_id in PACKAGED_HOST_SKILL_IDS), skills_dir / "loopx", skills_dir / SKILL_INSTALL_READBACK_FILENAME, + *ego_skill_mutation_paths(skills_dir), } slash_plan = install_slash_commands( execute=False, @@ -390,6 +393,8 @@ def _restore_paths(snapshots: Sequence[tuple[Path, Path | None]]) -> None: def _install_skills(release_root: Path, skills_dir: Path, installed_at: str) -> list[str]: + from .ego_skill_delivery import reconcile_ego_browser_skill + skills_dir.mkdir(parents=True, exist_ok=True) installed_ids: list[str] = [] for skill_id in PACKAGED_HOST_SKILL_IDS: @@ -418,6 +423,10 @@ def _install_skills(release_root: Path, skills_dir: Path, installed_at: str) -> source_root=release_root, installed_at=installed_at, ) + ego = reconcile_ego_browser_skill(skills_dir=skills_dir, execute=True) + print(f"Ego skill: {ego['status']}: {ego['reason']}", file=sys.stderr) + if not ego["ok"]: + raise RuntimeError(ego["reason"]) return installed_ids diff --git a/loopx/workflow_skill_install.py b/loopx/workflow_skill_install.py index 4d2c00263c..9cf0bb619c 100644 --- a/loopx/workflow_skill_install.py +++ b/loopx/workflow_skill_install.py @@ -12,6 +12,7 @@ from typing import Any, Iterator, Mapping from . import __version__ +from .ego_skill_delivery import reconcile_ego_browser_skill from .file_lock import exclusive_file_lock from .skill_install_readback import ( ARK_MANAGED_AGENT_REQUIRED_SKILL_IDS, @@ -297,6 +298,20 @@ def workflow_skill_install( expected_loopx_version_override=__version__, ) if uninstall: + ego = reconcile_ego_browser_skill( + skills_dir=target_root, execute=execute, uninstall=True, + ) + if not ego.get("ok"): + return { + "ok": False, + "schema_version": WORKFLOW_SKILL_INSTALL_SCHEMA_VERSION, + "operation": "uninstall" if execute else "uninstall_preview", + "skills_dir": str(target_root), + "source": _public_source(source), + "before": before, + "ego_browser": ego, + "reason": ego["reason"], + } result = ( _uninstall_managed_skills(target_root) if execute @@ -308,13 +323,14 @@ def workflow_skill_install( } ) return { - "ok": result["ok"], + "ok": bool(result["ok"]) and bool(ego.get("ok")), "schema_version": WORKFLOW_SKILL_INSTALL_SCHEMA_VERSION, "operation": "uninstall" if execute else "uninstall_preview", "skills_dir": str(target_root), "source": _public_source(source), "before": before, "result": result, + "ego_browser": ego, } if not source.get("available"): @@ -334,6 +350,18 @@ def workflow_skill_install( cli_bin=cli_bin, host_surface=host_surface, ) + ego_preview = reconcile_ego_browser_skill(skills_dir=target_root) + if not ego_preview.get("ok"): + return { + "ok": False, + "schema_version": WORKFLOW_SKILL_INSTALL_SCHEMA_VERSION, + "operation": "install" if execute else "inspect", + "skills_dir": str(target_root), + "source": _public_source(source), + "before": before, + "ego_browser": ego_preview, + "reason": ego_preview["reason"], + } if not execute: install_command = [ @@ -353,9 +381,11 @@ def workflow_skill_install( "source": _public_source(source), "before": before, "entry": entry_preview, + "ego_browser": ego_preview, "host_surface": host_surface, "install_required": ( not before.get("ready") + or ego_preview.get("status") == "would_refresh" or entry_preview.get("status") != "unchanged" or entry_preview.get("metadata_status") in { "would_create", "updated", "upgraded_legacy_managed", @@ -414,6 +444,7 @@ def workflow_skill_install( ) reconciliation = retire_duplicate_managed_skills(target_root, execute=True) + ego = reconcile_ego_browser_skill(skills_dir=target_root, execute=True) after = inspect_skill_install_readback( skills_dir=target_root, required_skill_ids=ARK_MANAGED_AGENT_REQUIRED_SKILL_IDS, @@ -422,7 +453,7 @@ def workflow_skill_install( expected_loopx_version_override=__version__, ) return { - "ok": bool(after.get("ready")), + "ok": bool(after.get("ready")) and bool(ego.get("ok")), "schema_version": WORKFLOW_SKILL_INSTALL_SCHEMA_VERSION, "operation": "install", "skills_dir": str(target_root), @@ -432,6 +463,7 @@ def workflow_skill_install( "entry": entry, "after": after, "skill_reconciliation": reconciliation, + "ego_browser": ego, "rollback_command": shlex.join( [ "loopx", @@ -472,6 +504,9 @@ def render_workflow_skill_install_markdown(payload: Mapping[str, Any]) -> str: lines.append(f"- install: `{payload['install_command']}`") if payload.get("rollback_command"): lines.append(f"- rollback: `{payload['rollback_command']}`") + ego = payload.get("ego_browser") or {} + if ego: + lines.append(f"- ego_browser: `{ego.get('status')}` — {ego.get('reason')}") result = payload.get("result") or {} if result: lines.extend( diff --git a/scripts/install-local.sh b/scripts/install-local.sh index b380393aa7..e81daf496c 100755 --- a/scripts/install-local.sh +++ b/scripts/install-local.sh @@ -471,6 +471,7 @@ from loopx.skill_install_readback import ( write_skill_install_readback, ) from loopx.slash_command_install import materialize_loopx_entry_skill +from loopx.ego_skill_delivery import reconcile_ego_browser_skill result = materialize_loopx_entry_skill( skills_dir=Path(os.environ["LOOPX_SKILL_INSTALL_DIR"]), @@ -500,6 +501,12 @@ write_skill_install_readback( source_root=Path(os.environ["LOOPX_SKILL_INSTALL_SOURCE_ROOT"]), installed_at=os.environ["LOOPX_SKILL_INSTALLED_AT"], ) +ego = reconcile_ego_browser_skill( + skills_dir=Path(os.environ["LOOPX_SKILL_INSTALL_DIR"]), execute=True, +) +print(f"Ego skill: {ego['status']}: {ego['reason']}", file=sys.stderr) +if not ego["ok"]: + raise SystemExit(1) if os.environ.get("LOOPX_SKILL_DEDUPE_OTHER_ROOT") != "0": dedupe = retire_duplicate_managed_skills( skills_dir=Path(os.environ["LOOPX_SKILL_INSTALL_DIR"]), @@ -544,6 +551,12 @@ from pathlib import Path from loopx.skill_install_readback import SKILL_INSTALL_READBACK_FILENAME from loopx.slash_command_install import materialize_loopx_entry_skill +from loopx.ego_skill_delivery import reconcile_ego_browser_skill + +ego = reconcile_ego_browser_skill(skills_dir=Path(os.environ["LOOPX_SKILL_INSTALL_DIR"])) +if not ego["ok"]: + print(f"loopx installer error: {ego['reason']}", file=sys.stderr) + raise SystemExit(1) result = materialize_loopx_entry_skill( skills_dir=Path(os.environ["LOOPX_SKILL_INSTALL_DIR"]), diff --git a/tests/test_ego_skill_delivery.py b/tests/test_ego_skill_delivery.py new file mode 100644 index 0000000000..27feebdea5 --- /dev/null +++ b/tests/test_ego_skill_delivery.py @@ -0,0 +1,305 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess +import sys + +import pytest + +from loopx import ego_skill_delivery as delivery +from loopx.workflow_skill_install import workflow_skill_install + + +@pytest.fixture +def host(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + monkeypatch.setenv("CODEX_HOME", str(home / ".codex")) + vendor = tmp_path / "app/version-one/ego-browser" + vendor.mkdir(parents=True) + (vendor / "SKILL.md").write_text( + "---\nname: ego-browser\ndescription: Browser operations.\n---\n" + "# Ego\nClaim only when the user explicitly asks.\n" + "Finish once; protect user tabs.\n" + ) + (vendor / "references").mkdir() + (vendor / "references/api.md").write_text("Version-one API.\n") + source = home / ".local/share/ego/ego-skills" + source.parent.mkdir(parents=True) + source.symlink_to(vendor, target_is_directory=True) + entry = home / ".agents/skills/ego-browser" + entry.parent.mkdir(parents=True) + entry.symlink_to(source, target_is_directory=True) + return home, vendor, source, entry, home / ".codex/skills" + + +def test_refresh_copies_installed_skill_without_editing_app_and_reuses_idle_task(host): + _, vendor, _, entry, skills = host + original = (vendor / "SKILL.md").read_bytes() + assert ( + delivery.reconcile_ego_browser_skill(skills_dir=skills)["status"] + == "would_refresh" + ) + assert entry.is_symlink() + result = delivery.reconcile_ego_browser_skill(skills_dir=skills, execute=True) + assert result["ready"] and result["status"] == "refreshed" + assert not entry.is_symlink() + assert (vendor / "SKILL.md").read_bytes() == original + text = (entry / "SKILL.md").read_text() + assert "same already-authorized task" in text + assert "without another ownership-confirmation question" in text + assert "Stop immediately for active user interaction" in text + assert "explicit pause" in text and "unrelated space" in text + assert "grants no new operation" in text + assert "Finish once; protect user tabs." in text + assert (entry / "references/api.md").read_text() == "Version-one API.\n" + assert ( + delivery.reconcile_ego_browser_skill(skills_dir=skills, execute=True)["status"] + == "unchanged" + ) + + +def test_loopx_refresh_tracks_new_vendor_version_and_rollback_follows_current_source( + host, tmp_path, monkeypatch +): + _, _, source, entry, skills = host + delivery.reconcile_ego_browser_skill(skills_dir=skills, execute=True) + next_vendor = tmp_path / "app/version-two/ego-browser" + next_vendor.mkdir(parents=True) + (next_vendor / "SKILL.md").write_text( + "---\nname: ego-browser\ndescription: Browser operations.\n---\nNew API\n" + ) + source.unlink() + source.symlink_to(next_vendor, target_is_directory=True) + monkeypatch.setattr(delivery, "__version__", "next-loopx") + assert ( + delivery.reconcile_ego_browser_skill(skills_dir=skills)["status"] + == "would_refresh" + ) + assert delivery.reconcile_ego_browser_skill(skills_dir=skills, execute=True)[ + "ready" + ] + assert "New API" in (entry / "SKILL.md").read_text() + assert not (entry / "references/api.md").exists() + marker = json.loads((entry / ".loopx-ego-skill.json").read_text()) + assert marker["loopx_version"] == "next-loopx" + assert ( + delivery.reconcile_ego_browser_skill(skills_dir=skills, uninstall=True)[ + "status" + ] + == "would_restore" + ) + assert not entry.is_symlink() + assert ( + delivery.reconcile_ego_browser_skill( + skills_dir=skills, execute=True, uninstall=True + )["status"] + == "restored" + ) + assert entry.is_symlink() and entry.resolve() == next_vendor + assert "LoopX: continuing" not in (entry / "SKILL.md").read_text() + + +@pytest.mark.parametrize("uninstall", [False, True]) +def test_user_edits_are_preserved_during_upgrade_and_rollback(host, uninstall): + _, _, _, entry, skills = host + delivery.reconcile_ego_browser_skill(skills_dir=skills, execute=True) + local = entry / "references/api.md" + local.write_text("My customization\n") + result = delivery.reconcile_ego_browser_skill( + skills_dir=skills, execute=True, uninstall=uninstall + ) + assert result["status"] == "preserved_modified" and not result["ready"] + assert "Reconcile these edits" in result["reason"] + assert local.read_text() == "My customization\n" + + +def test_unmanaged_copy_is_not_overwritten(host): + _, _, _, entry, skills = host + entry.unlink() + entry.mkdir() + (entry / "SKILL.md").write_text("Personal browser skill\n") + assert ( + delivery.reconcile_ego_browser_skill(skills_dir=skills, execute=True)["status"] + == "preserved_unmanaged" + ) + assert (entry / "SKILL.md").read_text() == "Personal browser skill\n" + + +def test_explicit_directory_does_not_mutate_shared_skill_or_install_absent_ego( + host, tmp_path +): + _, vendor, _, shared, _ = host + custom = tmp_path / "another-host/skills" + custom.mkdir(parents=True) + assert ( + delivery.reconcile_ego_browser_skill(skills_dir=custom, execute=True)["status"] + == "not_installed" + ) + assert shared.is_symlink() and shared.resolve() == vendor + explicit = custom / "ego-browser" + explicit.symlink_to(vendor, target_is_directory=True) + assert delivery.reconcile_ego_browser_skill(skills_dir=custom, execute=True)[ + "ready" + ] + assert shared.is_symlink() + assert not explicit.is_symlink() + + +@pytest.mark.parametrize("contents", ["broken", "---\nname: ego-browser-other\n---\n"]) +def test_invalid_upstream_preserves_link_and_reports_failure(host, contents): + _, vendor, _, entry, skills = host + (vendor / "SKILL.md").write_text(contents) + result = delivery.reconcile_ego_browser_skill(skills_dir=skills, execute=True) + assert not result["ok"] and result["status"] == "invalid_source" + assert entry.is_symlink() + + +def test_missing_upstream_is_visible_and_does_not_remove_managed_copy(host): + _, _, source, entry, skills = host + delivery.reconcile_ego_browser_skill(skills_dir=skills, execute=True) + source.unlink() + result = delivery.reconcile_ego_browser_skill(skills_dir=skills, execute=True) + assert result["status"] == "source_unavailable" and not result["ready"] + assert "workflow-skills --install" in result["reason"] + assert (entry / "SKILL.md").is_file() + + +def test_failed_atomic_publish_restores_original_vendor_link(host, monkeypatch): + _, vendor, _, entry, skills = host + replace = Path.replace + + def fail_candidate(path, target): + if path.name == "candidate": + raise OSError("publish interrupted") + return replace(path, target) + + monkeypatch.setattr(Path, "replace", fail_candidate) + with pytest.raises(OSError, match="publish interrupted"): + delivery.reconcile_ego_browser_skill(skills_dir=skills, execute=True) + assert entry.is_symlink() and entry.resolve() == vendor + assert not [p for p in entry.parent.glob(".loopx-ego-*") if p.is_dir()] + assert not list(entry.parent.glob(".ego-browser.loopx-previous.*")) + + +def test_failed_restore_retains_original_outside_temporary_cleanup(host, monkeypatch): + _, vendor, _, entry, skills = host + replace = Path.replace + + def fail_publish_and_restore(path, target): + if path.name == "candidate" or ".loopx-previous." in path.name: + raise OSError("interrupted twice") + return replace(path, target) + + monkeypatch.setattr(Path, "replace", fail_publish_and_restore) + with pytest.raises(OSError, match="interrupted twice"): + delivery.reconcile_ego_browser_skill(skills_dir=skills, execute=True) + backups = list(entry.parent.glob(".ego-browser.loopx-previous.*")) + assert len(backups) == 1 and backups[0].resolve() == vendor + assert (backups[0] / "SKILL.md").is_file() + result = delivery.reconcile_ego_browser_skill(skills_dir=skills, execute=True) + assert not result["ok"] and result["status"] == "recovery_required" + assert str(backups[0]) in result["reason"] + + +def test_default_host_follows_its_existing_managed_profile_alias(host): + home, _, _, entry, skills = host + delivery.reconcile_ego_browser_skill(skills_dir=skills, execute=True) + profile_copy = home / ".other-codex/skills/ego-browser" + profile_copy.parent.mkdir(parents=True) + entry.replace(profile_copy) + entry.symlink_to(profile_copy, target_is_directory=True) + result = delivery.reconcile_ego_browser_skill(skills_dir=skills, execute=True) + assert result["status"] == "unchanged" and result["path"] == str(profile_copy) + assert entry.is_symlink() + + +def test_workflow_upgrade_and_uninstall_reconcile_same_installed_ego(host): + _, vendor, _, entry, skills = host + preview = workflow_skill_install(skills_dir=skills) + assert preview["ego_browser"]["status"] == "would_refresh" + assert entry.is_symlink() + installed = workflow_skill_install(skills_dir=skills, execute=True) + assert installed["ok"] and installed["ego_browser"]["ready"] + assert ( + workflow_skill_install(skills_dir=skills)["ego_browser"]["status"] + == "unchanged" + ) + removed = workflow_skill_install(skills_dir=skills, execute=True, uninstall=True) + assert removed["ok"] and removed["ego_browser"]["status"] == "restored" + assert entry.resolve() == vendor + + +def test_windows_installer_includes_ego_in_existing_filesystem_rollback(host, tmp_path): + from loopx import windows_install + + _, vendor, _, entry, skills = host + backup = tmp_path / "backups" + backup.mkdir() + paths = windows_install._skill_mutation_paths(skills) + assert entry in paths + snapshots = windows_install._snapshot_paths(paths, backup) + windows_install._install_skills( + Path(__file__).resolve().parents[1], skills, "2026-01-01T00:00:00Z" + ) + assert not entry.is_symlink() + assert "LoopX: continuing" in (entry / "SKILL.md").read_text() + windows_install._restore_paths(snapshots) + assert entry.is_symlink() and entry.resolve() == vendor + + +def test_actual_cli_install_readback_and_rollback_use_isolated_host(host): + home, vendor, _, entry, skills = host + env = { + **os.environ, + "HOME": str(home), + "CODEX_HOME": str(skills.parent), + "LOOPX_USAGE_PING": "0", + } + + def cli(*args): + result = subprocess.run( + [ + sys.executable, + "-m", + "loopx.cli", + "workflow-skills", + "--format", + "json", + *args, + ], + cwd=Path(__file__).resolve().parents[1], + env=env, + text=True, + capture_output=True, + timeout=40, + ) + assert result.returncode == 0, result.stderr or result.stdout + return json.loads(result.stdout) + + assert cli()["ego_browser"]["status"] == "would_refresh" + assert cli("--install")["ego_browser"]["ready"] + assert cli()["ego_browser"]["status"] == "unchanged" + assert cli("--uninstall")["ego_browser"]["status"] == "restored" + assert entry.is_symlink() and entry.resolve() == vendor + + +def test_unresolved_ego_rollback_does_not_remove_other_workflow_skills(host): + _, _, _, entry, skills = host + assert workflow_skill_install(skills_dir=skills, execute=True)["ok"] + (entry / "SKILL.md").write_text("User changed the policy\n") + result = workflow_skill_install(skills_dir=skills, execute=True, uninstall=True) + assert not result["ok"] and result["ego_browser"]["status"] == "preserved_modified" + assert (skills / "loopx-project/SKILL.md").is_file() + + +def test_invalid_ego_preflight_stops_before_workflow_skill_mutation(host): + _, vendor, _, entry, skills = host + (vendor / "SKILL.md").write_text("Invalid upstream\n") + result = workflow_skill_install(skills_dir=skills, execute=True) + assert not result["ok"] and result["ego_browser"]["status"] == "invalid_source" + assert entry.is_symlink() + assert not skills.exists() From daf446cf8eb67c6dc01d5724ef3164a004f91840 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:01:56 +0800 Subject: [PATCH 2/5] docs(ego): explain continuation refresh and upstream rollback Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/integrations/ego-source-reader.md | 48 ++++++++++++++++++++++++++ skills/loopx-project/SKILL.md | 12 +++++++ 2 files changed, 60 insertions(+) diff --git a/docs/integrations/ego-source-reader.md b/docs/integrations/ego-source-reader.md index b6df3a2524..10b3ae108b 100644 --- a/docs/integrations/ego-source-reader.md +++ b/docs/integrations/ego-source-reader.md @@ -1,5 +1,53 @@ # Rendered public-source reading with Ego +## Host skill continuation and upgrades + +LoopX's existing workflow-skill refresh now carries a small continuation policy +for an **already installed** Ego browser skill. An authorized task can reuse its +known idle TaskSpace after an ordinary finish or handoff without another chat +confirmation. Active user intervention, explicit pauses, reserved control, +unknown task identity and browser-owned permission/login prompts retain their +existing stops. This is Agent guidance; it neither changes Ego's native control +checks nor gives the public-source reader a claim/takeover tool. + +Normal LoopX Python-package upgrades already run `workflow-skills --install`; +the POSIX and Windows installers use the same Ego delivery adapter. The adapter +reads Ego's installed skill through its stable local source link, copies its API +references and appends the scoped policy in a local host copy. It never edits +the browser bundle, installs Ego, launches a browser, changes an MCP entry or +expands any operation/account permission. No optional reader is enabled. +App-private runtime preparation that explicitly skips host integrations retains +that isolation; it is not a host-skill upgrade. Refresh the host through the +normal LoopX install/update path above. + +Inspect without writing, refresh after an independently authorized Ego upgrade, +and read back the result with the existing commands: + +```bash +loopx workflow-skills --format json +loopx workflow-skills --install --format json +loopx workflow-skills --format json +``` + +The `ego_browser` result distinguishes current, refreshed, absent, preserved +customization and unavailable-source/recovery cases. The Markdown output also +includes its reason. A missing Ego skill does not install one. Unmanaged copies +and locally modified managed copies are preserved with an actionable message. +An explicit nondefault `--skills-dir` never changes a shared host entry outside +that scope. At the default personal root, the existing shared Ego entry and its +LoopX-managed personal-profile alias are reused rather than duplicated. + +`loopx workflow-skills --uninstall` removes unchanged LoopX workflow skills and +restores an unchanged managed Ego copy to Ego's **current** upstream link. +Locally modified copies are preserved; an unresolved restore is reported rather +than silently removing their policy. An interrupted publish restores the old +entry. If restoring it also fails, its original is retained at the reported +backup path; restore that entry before retrying. Ego's own updater can reset its +host entry, so rerun the refresh after that independently authorized upgrade; +LoopX does not intercept or promise to modify the browser's updater. + +## Optional public-source reader + The optional `loopx.extensions.ego_source_reader` stdio MCP adapter gives an existing Codex host narrow `read_public_url` and `read_public_image` tools when its model shell cannot reach Ego's local bootstrap. It reuses an installed Ego browser with a reserved diff --git a/skills/loopx-project/SKILL.md b/skills/loopx-project/SKILL.md index c5099c9e91..4f444ccc07 100644 --- a/skills/loopx-project/SKILL.md +++ b/skills/loopx-project/SKILL.md @@ -34,6 +34,18 @@ automatically. ## Slash Command Fallback +LoopX's normal upgrade/install path also refreshes task-continuation guidance +for an already installed Ego browser skill. Reuse the same known idle TaskSpace +for the same authorized task without asking again merely because the previous +round finished or handed it back. Active user intervention, explicit pauses, +reserved control and unverified spaces retain the installed Ego skill's stop +rules. This is host guidance, not permission to bypass Ego's ownership checks. +After an independently authorized Ego upgrade, run `loopx workflow-skills +--install` and read `ego_browser`: it follows the current installed Ego source, +preserves custom edits and reports recovery steps. It does not upgrade Ego. +Use `loopx workflow-skills --uninstall` to restore an unchanged managed Ego +skill to its upstream link while removing LoopX-managed workflow skills. + When asked to upgrade LoopX on a machine with the desktop App, inspect the App bundle and its runtime as well as the CLI. `loopx doctor` exposes `desktop_installation` for standard macOS install locations; a paired bundle From db8739014c6b0b35c41fe37731d4b419b2528f0e Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:17:14 +0800 Subject: [PATCH 3/5] fix(skills): reconnect shared Ego entry after an upstream reset Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/integrations/ego-source-reader.md | 4 ++ loopx/ego_skill_delivery.py | 51 ++++++++++++++++++-- tests/test_ego_skill_delivery.py | 65 ++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 3 deletions(-) diff --git a/docs/integrations/ego-source-reader.md b/docs/integrations/ego-source-reader.md index 10b3ae108b..cb7f629cd3 100644 --- a/docs/integrations/ego-source-reader.md +++ b/docs/integrations/ego-source-reader.md @@ -36,6 +36,10 @@ and locally modified managed copies are preserved with an actionable message. An explicit nondefault `--skills-dir` never changes a shared host entry outside that scope. At the default personal root, the existing shared Ego entry and its LoopX-managed personal-profile alias are reused rather than duplicated. +If an Ego reinstall resets the shared entry to its pristine upstream link while +the managed personal copy remains, refresh reconnects that known link to the +managed copy. Custom shared aliases remain untouched; Windows rollback includes +both affected entries. `loopx workflow-skills --uninstall` removes unchanged LoopX workflow skills and restores an unchanged managed Ego copy to Ego's **current** upstream link. diff --git a/loopx/ego_skill_delivery.py b/loopx/ego_skill_delivery.py index 009f2122a5..53168143b4 100644 --- a/loopx/ego_skill_delivery.py +++ b/loopx/ego_skill_delivery.py @@ -88,7 +88,22 @@ def present(path: Path) -> bool: marker = None if isinstance(marker, dict) and marker.get("owner") == _OWNER: entry = resolved - return [entry] if present(entry) else [] + paths = [entry] if present(entry) else [] + shared = Path.home() / ".agents/skills/ego-browser" + if default_root and paths and entry != shared and not entry.is_symlink(): + try: + marker = json.loads((entry / _MARKER).read_text(encoding="utf-8")) + except (OSError, ValueError): + marker = None + vendor = (Path.home() / ".local/share/ego/ego-skills").resolve() + if ( + isinstance(marker, dict) + and marker.get("owner") == _OWNER + and shared.is_symlink() + and shared.resolve() == vendor + ): + paths.append(shared) + return paths def _remove(path: Path) -> None: @@ -133,8 +148,38 @@ def reconcile_ego_browser_skill( entry = paths[0] if execute: with exclusive_file_lock(entry.parent / ".loopx-ego-skill"): - return _reconcile(entry, execute=True, uninstall=uninstall) - return _reconcile(entry, execute=False, uninstall=uninstall) + result = _reconcile(entry, execute=True, uninstall=uninstall) + if not uninstall and result.get("ready") and len(paths) > 1: + shared = paths[1] + vendor = (Path.home() / ".local/share/ego/ego-skills").resolve() + # A browser reinstall may restore its pristine shared link. + # Recheck before replacing it; custom aliases are never owned. + if shared.is_symlink() and shared.resolve() == vendor: + with tempfile.TemporaryDirectory( + prefix=".loopx-ego-alias-", dir=shared.parent + ) as temporary: + alias = Path(temporary) / "alias" + alias.symlink_to(entry, target_is_directory=True) + alias.replace(shared) + result.update( + status="refreshed", + reason="Reconnected the upstream shared Ego link to its current managed host skill.", + ) + elif not shared.is_symlink() or shared.resolve() != entry.resolve(): + result.update( + ready=False, + status="preserved_unmanaged", + reason="Shared Ego entry changed during refresh; its custom target was preserved.", + ) + return result + result = _reconcile(entry, execute=False, uninstall=uninstall) + if not uninstall and result.get("ready") and len(paths) > 1: + result.update( + ready=False, + status="would_refresh", + reason="Reconnect Ego's pristine shared link to the current managed host skill.", + ) + return result def _reconcile(entry: Path, *, execute: bool, uninstall: bool) -> dict[str, Any]: diff --git a/tests/test_ego_skill_delivery.py b/tests/test_ego_skill_delivery.py index 27feebdea5..6c5c5770e2 100644 --- a/tests/test_ego_skill_delivery.py +++ b/tests/test_ego_skill_delivery.py @@ -303,3 +303,68 @@ def test_invalid_ego_preflight_stops_before_workflow_skill_mutation(host): assert not result["ok"] and result["ego_browser"]["status"] == "invalid_source" assert entry.is_symlink() assert not skills.exists() + + +def _profile_copy_with_reset_shared_entry(host): + _, _, source, entry, skills = host + delivery.reconcile_ego_browser_skill(skills_dir=skills, execute=True) + managed = skills / "ego-browser" + managed.parent.mkdir(parents=True, exist_ok=True) + entry.replace(managed) + entry.symlink_to(source, target_is_directory=True) + return entry, managed, skills + + +def test_refresh_reconnects_shared_entry_reset_by_browser_reinstall(host): + entry, managed, skills = _profile_copy_with_reset_shared_entry(host) + assert ( + delivery.reconcile_ego_browser_skill(skills_dir=skills)["status"] + == "would_refresh" + ) + assert ( + delivery.reconcile_ego_browser_skill(skills_dir=skills, execute=True)["status"] + == "refreshed" + ) + assert entry.resolve() == managed + assert ( + "without another ownership-confirmation question" + in (entry / "SKILL.md").read_text() + ) + assert ( + delivery.reconcile_ego_browser_skill(skills_dir=skills)["status"] == "unchanged" + ) + + +def test_profile_refresh_preserves_custom_shared_alias(host): + home, _, _, _, _ = host + entry, _, skills = _profile_copy_with_reset_shared_entry(host) + custom = home / "custom-browser" + custom.mkdir() + (custom / "SKILL.md").write_text("Personal policy\n") + entry.unlink() + entry.symlink_to(custom, target_is_directory=True) + delivery.reconcile_ego_browser_skill(skills_dir=skills, execute=True) + assert entry.resolve() == custom + assert (entry / "SKILL.md").read_text() == "Personal policy\n" + + +def test_windows_rollback_preserves_reset_shared_entry_and_managed_profile( + host, tmp_path +): + from loopx import windows_install + + entry, managed, skills = _profile_copy_with_reset_shared_entry(host) + source = host[2] + original = (managed / "SKILL.md").read_bytes() + paths = windows_install._skill_mutation_paths(skills) + assert entry in paths and managed in paths + backup = tmp_path / "alias-backup" + backup.mkdir() + snapshots = windows_install._snapshot_paths(paths, backup) + windows_install._install_skills( + Path(__file__).resolve().parents[1], skills, "2026-01-01T00:00:00Z" + ) + assert entry.resolve() == managed + windows_install._restore_paths(snapshots) + assert entry.resolve() == source.resolve() + assert (managed / "SKILL.md").read_bytes() == original From 408752a06e4f09cc5306edda325aee22d058dbdb Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:36:16 +0800 Subject: [PATCH 4/5] fix(skills): reclaim completed Ego tasks before resolving Pages Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/ego_skill_delivery.py | 42 +++++++++++++++++++++++++++----- tests/test_ego_skill_delivery.py | 22 +++++++++++++++++ 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/loopx/ego_skill_delivery.py b/loopx/ego_skill_delivery.py index 53168143b4..8205c70a8f 100644 --- a/loopx/ego_skill_delivery.py +++ b/loopx/ego_skill_delivery.py @@ -25,18 +25,48 @@ ## LoopX: continuing an authorized browser task This section overrides only a blanket requirement above or in API references -to ask again before reclaiming a user-owned or inactive TaskSpace. +to ask again before reclaiming a user-owned or inactive TaskSpace, including +an entry-time user-control message caused by your own normal handback. For the same already-authorized task, reuse its known numeric TaskSpace and Page without another ownership-confirmation question when your previous normal -finish, handoff, or pause between rounds left that space idle. Read its current -state first. A stable user-owned state left by that normal handoff is not by -itself evidence of active user intervention. Use the documented resume/claim -API; do not create a replacement space to avoid ownership recovery. +finish, handoff, or pause between rounds left that space available to the user. +Keep the successful handback receipt and the task identity/Page URL in task +context. This exception requires that provenance, continuing task authority, +and no subsequent user pause, control reservation or observed intervention. +Any user-owned step from the handoff must already be resolved. +Ownership metadata alone proves neither activity nor inactivity. + +Before constructing a TaskSpace handle, call `listTaskSpaces()` and match its +numeric id and task identity. `taskSpace(id)` may reconcile tabs immediately +and reject a normally finished, user-owned space before you can inspect it. +For this qualified continuation, use the documented `claimTaskSpace(id)` once +before resolving Pages; use `taskSpace(id)` when already Agent-owned. If an +initial handle lookup already returned the generic user-control/ended message, +the same known handback permits that one claim; the message alone is not a new +live takeover. Do not loop claims or create a replacement space. + +After normal finish, kept tabs can be unmanaged and old Page labels can be +absent. Read `task.tabs()`, match the known Page URL, and `task.adopt(tab.page)` +when it has no label. Reuse its returned label; do not assume the old `p1` still +exists or create another tab merely because a label was lost. For example, +after establishing the provenance and authority above: + +```js +const space = (await listTaskSpaces()).find(s => s.id === knownSpaceId); +if (!space || space.taskId !== knownTaskId) throw new Error("Task identity changed"); +const task = space.ownership === "agent" + ? await taskSpace(knownSpaceId) : await claimTaskSpace(knownSpaceId); +const tab = (await task.tabs()).find(t => t.url === knownPageUrl); +if (!tab) throw new Error("Original Page is unavailable"); +const page = tab.label ? task.page(tab.label) : await task.adopt(tab.page); +console.log(await page.snapshot()); +``` Stop immediately for active user interaction, an unexpected live ownership change, an explicit pause, or a user reservation of browser control. Do not -retry around a native user-control stop. An unresolved permission prompt, +reclaim after a new user-control stop during Agent-owned work, or after the +one qualified claim is refused. An unresolved permission prompt, device chooser, login or verification still belongs to its existing owner. If this is an unrelated space, its task identity is unverified, or a current user-control reservation remains unresolved, ask about that specific choice. diff --git a/tests/test_ego_skill_delivery.py b/tests/test_ego_skill_delivery.py index 6c5c5770e2..bd9e0bdc69 100644 --- a/tests/test_ego_skill_delivery.py +++ b/tests/test_ego_skill_delivery.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib import json import os from pathlib import Path @@ -103,6 +104,27 @@ def test_loopx_refresh_tracks_new_vendor_version_and_rollback_follows_current_so assert "LoopX: continuing" not in (entry / "SKILL.md").read_text() +def test_guidance_correction_refreshes_existing_install_without_version_bump( + host, monkeypatch +): + _, vendor, _, entry, skills = host + upstream = (vendor / "SKILL.md").read_bytes() + current_policy = delivery._POLICY + # The old policy is a completed managed installation, not a user edit. + # Corrected guidance must replace it even when Ego and LoopX versions match. + with monkeypatch.context() as old: + old.setattr(delivery, "_POLICY", "\n\nPrevious managed continuation policy.\n") + assert workflow_skill_install(skills_dir=skills, execute=True)["ok"] + assert workflow_skill_install(skills_dir=skills)["ego_browser"]["status"] == "would_refresh" + result = workflow_skill_install(skills_dir=skills, execute=True) + assert result["ok"] and result["ego_browser"]["status"] == "refreshed" + assert (entry / "SKILL.md").read_text() == upstream.decode().rstrip() + current_policy + marker = json.loads((entry / ".loopx-ego-skill.json").read_text()) + assert marker["policy_sha256"] == hashlib.sha256(current_policy.encode()).hexdigest() + assert workflow_skill_install(skills_dir=skills)["ego_browser"]["status"] == "unchanged" + assert (vendor / "SKILL.md").read_bytes() == upstream + + @pytest.mark.parametrize("uninstall", [False, True]) def test_user_edits_are_preserved_during_upgrade_and_rollback(host, uninstall): _, _, _, entry, skills = host From 419b5d27e58bf5f050598c7416a5599bce81dffe Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:40:40 +0800 Subject: [PATCH 5/5] fix(ego): preserve edits made during skill refresh Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/integrations/ego-source-reader.md | 8 +-- loopx/ego_skill_delivery.py | 66 +++++++++++++++++++++-- tests/test_ego_skill_delivery.py | 74 ++++++++++++++++++++++++-- 3 files changed, 138 insertions(+), 10 deletions(-) diff --git a/docs/integrations/ego-source-reader.md b/docs/integrations/ego-source-reader.md index cb7f629cd3..407f346cc2 100644 --- a/docs/integrations/ego-source-reader.md +++ b/docs/integrations/ego-source-reader.md @@ -44,9 +44,11 @@ both affected entries. `loopx workflow-skills --uninstall` removes unchanged LoopX workflow skills and restores an unchanged managed Ego copy to Ego's **current** upstream link. Locally modified copies are preserved; an unresolved restore is reported rather -than silently removing their policy. An interrupted publish restores the old -entry. If restoring it also fails, its original is retained at the reported -backup path; restore that entry before retrying. Ego's own updater can reset its +than silently removing their policy. Publication rechecks the original entry's +identity and contents after staging and parking it; a concurrent local edit or +shared-alias change keeps refresh unready and preserves the original. An +interrupted publish restores the old entry. If restoring it also fails, its +original is retained at the reported backup path; restore that entry before retrying. Ego's own updater can reset its host entry, so rerun the refresh after that independently authorized upgrade; LoopX does not intercept or promise to modify the browser's updater. diff --git a/loopx/ego_skill_delivery.py b/loopx/ego_skill_delivery.py index 8205c70a8f..5cb0e76386 100644 --- a/loopx/ego_skill_delivery.py +++ b/loopx/ego_skill_delivery.py @@ -143,7 +143,33 @@ def _remove(path: Path) -> None: shutil.rmtree(path) -def _publish(candidate: Path, entry: Path) -> None: +def _entry_version(entry: Path) -> str: + """Bind this publication to the entry checked before staging, not its name.""" + digest = hashlib.sha256() + for path in [entry, *sorted(entry.rglob("*"))]: + metadata = path.lstat() + digest.update( + json.dumps( + [ + path.relative_to(entry).as_posix(), + metadata.st_dev, + metadata.st_ino, + metadata.st_mode, + metadata.st_mtime_ns, + ] + ).encode() + ) + if entry.is_symlink(): + digest.update(os.fsencode(os.readlink(entry))) + else: + digest.update(_digest(entry).encode()) + marker = entry / _MARKER + if marker.is_file(): + digest.update(marker.read_bytes()) + return digest.hexdigest() + + +def _publish(candidate: Path, entry: Path, *, expected_version: str) -> bool: # Keep the original outside the temporary staging directory: a failure of # both publish and restore must leave it available for recovery. backups = list(entry.parent.glob(f".{entry.name}.loopx-previous.*")) @@ -151,14 +177,34 @@ def _publish(candidate: Path, entry: Path) -> None: raise RuntimeError( f"Ego skill recovery required; inspect the preserved original at {backups[0]}" ) + if _entry_version(entry) != expected_version: + return False backup = entry.with_name(f".{entry.name}.loopx-previous.{os.getpid()}") entry.replace(backup) + if _entry_version(backup) != expected_version: + # A user can edit without taking our publisher lock, including through + # a file handle opened before the move. Keep that changed original. + if entry.exists() or entry.is_symlink(): + raise RuntimeError( + f"Ego skill changed during publish; original preserved at {backup}" + ) + backup.replace(entry) + return False try: candidate.replace(entry) except BaseException: + if entry.exists() or entry.is_symlink(): + raise RuntimeError( + f"Ego skill publish interrupted; original preserved at {backup}" + ) backup.replace(entry) raise + if _entry_version(backup) != expected_version: + raise RuntimeError( + f"Ego skill changed during publish; original preserved at {backup}" + ) _remove(backup) + return True def reconcile_ego_browser_skill( @@ -184,13 +230,20 @@ def reconcile_ego_browser_skill( vendor = (Path.home() / ".local/share/ego/ego-skills").resolve() # A browser reinstall may restore its pristine shared link. # Recheck before replacing it; custom aliases are never owned. + shared_version = _entry_version(shared) if shared.is_symlink() and shared.resolve() == vendor: with tempfile.TemporaryDirectory( prefix=".loopx-ego-alias-", dir=shared.parent ) as temporary: alias = Path(temporary) / "alias" alias.symlink_to(entry, target_is_directory=True) - alias.replace(shared) + if not _publish(alias, shared, expected_version=shared_version): + return { + **result, + "ready": False, + "status": "preserved_unmanaged", + "reason": "Shared Ego entry changed during refresh; its custom target was preserved.", + } result.update( status="refreshed", reason="Reconnected the upstream shared Ego link to its current managed host skill.", @@ -226,6 +279,7 @@ def _reconcile(entry: Path, *, execute: bool, uninstall: bool) -> dict[str, Any] "status": "recovery_required", "reason": f"A failed publish preserved the original at {backups[0]}; restore that entry before refreshing. No browser files were changed.", } + entry_version = _entry_version(entry) try: marker = ( json.loads((entry / _MARKER).read_text(encoding="utf-8")) @@ -344,7 +398,13 @@ def _reconcile(entry: Path, *, execute: bool, uninstall: bool) -> dict[str, Any] + "\n", encoding="utf-8", ) - _publish(candidate, entry) + if not _publish(candidate, entry, expected_version=entry_version): + return { + **result, + "ok": not uninstall, + "status": "preserved_modified", + "reason": "Ego skill changed during refresh; the original was preserved. Reconcile these edits before retrying.", + } return { **result, "ready": not uninstall, diff --git a/tests/test_ego_skill_delivery.py b/tests/test_ego_skill_delivery.py index bd9e0bdc69..2b39e1ce61 100644 --- a/tests/test_ego_skill_delivery.py +++ b/tests/test_ego_skill_delivery.py @@ -115,13 +115,23 @@ def test_guidance_correction_refreshes_existing_install_without_version_bump( with monkeypatch.context() as old: old.setattr(delivery, "_POLICY", "\n\nPrevious managed continuation policy.\n") assert workflow_skill_install(skills_dir=skills, execute=True)["ok"] - assert workflow_skill_install(skills_dir=skills)["ego_browser"]["status"] == "would_refresh" + assert ( + workflow_skill_install(skills_dir=skills)["ego_browser"]["status"] + == "would_refresh" + ) result = workflow_skill_install(skills_dir=skills, execute=True) assert result["ok"] and result["ego_browser"]["status"] == "refreshed" - assert (entry / "SKILL.md").read_text() == upstream.decode().rstrip() + current_policy + assert ( + entry / "SKILL.md" + ).read_text() == upstream.decode().rstrip() + current_policy marker = json.loads((entry / ".loopx-ego-skill.json").read_text()) - assert marker["policy_sha256"] == hashlib.sha256(current_policy.encode()).hexdigest() - assert workflow_skill_install(skills_dir=skills)["ego_browser"]["status"] == "unchanged" + assert ( + marker["policy_sha256"] == hashlib.sha256(current_policy.encode()).hexdigest() + ) + assert ( + workflow_skill_install(skills_dir=skills)["ego_browser"]["status"] + == "unchanged" + ) assert (vendor / "SKILL.md").read_bytes() == upstream @@ -139,6 +149,40 @@ def test_user_edits_are_preserved_during_upgrade_and_rollback(host, uninstall): assert local.read_text() == "My customization\n" +@pytest.mark.parametrize( + "phase,uninstall", [("staged", False), ("parked", False), ("parked", True)] +) +def test_edits_during_skill_publication_are_preserved( + host, monkeypatch, phase, uninstall +): + _, vendor, _, entry, skills = host + delivery.reconcile_ego_browser_skill(skills_dir=skills, execute=True) + (vendor / "references/api.md").write_text("New upstream API\n") + copytree, replace = delivery.shutil.copytree, Path.replace + + def after_copy(source, target, *args, **kwargs): + result = copytree(source, target, *args, **kwargs) + if Path(source) == vendor and phase == "staged": + (entry / "references/api.md").write_text("Independent user edit\n") + return result + + def after_park(source, target): + result = replace(source, target) + if source == entry and phase == "parked": + (Path(target) / "references/api.md").write_text("Independent user edit\n") + return result + + monkeypatch.setattr(delivery.shutil, "copytree", after_copy) + monkeypatch.setattr(Path, "replace", after_park) + result = delivery.reconcile_ego_browser_skill( + skills_dir=skills, execute=True, uninstall=uninstall + ) + assert not result["ready"], ( + "Concurrent local edits cannot be reported as a ready refresh" + ) + assert (entry / "references/api.md").read_text() == "Independent user edit\n" + + def test_unmanaged_copy_is_not_overwritten(host): _, _, _, entry, skills = host entry.unlink() @@ -370,6 +414,28 @@ def test_profile_refresh_preserves_custom_shared_alias(host): assert (entry / "SKILL.md").read_text() == "Personal policy\n" +def test_reconnect_preserves_alias_changed_during_staging(host, monkeypatch): + home, _, _, _, _ = host + entry, _, skills = _profile_copy_with_reset_shared_entry(host) + custom = home / "custom-browser" + custom.mkdir() + (custom / "SKILL.md").write_text("Personal policy\n") + symlink_to = Path.symlink_to + + def change_shared_alias(path, target, **kwargs): + result = symlink_to(path, target, **kwargs) + if path.name == "alias": + entry.unlink() + symlink_to(entry, custom, target_is_directory=True) + return result + + monkeypatch.setattr(Path, "symlink_to", change_shared_alias) + result = delivery.reconcile_ego_browser_skill(skills_dir=skills, execute=True) + assert not result["ready"] and result["status"] == "preserved_unmanaged" + assert entry.resolve() == custom + assert (entry / "SKILL.md").read_text() == "Personal policy\n" + + def test_windows_rollback_preserves_reset_shared_entry_and_managed_profile( host, tmp_path ):