diff --git a/docs/integrations/ego-source-reader.md b/docs/integrations/ego-source-reader.md index b6df3a2524..407f346cc2 100644 --- a/docs/integrations/ego-source-reader.md +++ b/docs/integrations/ego-source-reader.md @@ -1,5 +1,59 @@ # 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. +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. +Locally modified copies are preserved; an unresolved restore is reported rather +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. + +## 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/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..5cb0e76386 --- /dev/null +++ b/loopx/ego_skill_delivery.py @@ -0,0 +1,415 @@ +"""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, 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 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 +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. + +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 + 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: + if path.is_symlink() or path.is_file(): + path.unlink() + elif path.exists(): + shutil.rmtree(path) + + +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.*")) + if backups: + 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( + *, + 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"): + 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. + 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) + 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.", + ) + 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]: + # 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.", + } + entry_version = _entry_version(entry) + 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", + ) + 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, + "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 f753955309..a6c73c8446 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, @@ -424,6 +427,8 @@ def _restore_paths( 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: @@ -452,6 +457,10 @@ def _install_skills( 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/skills/loopx-project/SKILL.md b/skills/loopx-project/SKILL.md index 8ff2b7b097..9b2f5b3bfb 100644 --- a/skills/loopx-project/SKILL.md +++ b/skills/loopx-project/SKILL.md @@ -46,7 +46,7 @@ before its operation; ordinary admitted work does not load every procedure. | Operation | Required procedure | | --- | --- | | `/loopx` goal-start, connect, identity binding or scope changes | [Start and connect](references/start-and-connect.md) | -| Missing CLI, desktop upgrade, runtime drift, diagnosis or registry recovery | [Installation and diagnosis](references/installation-and-diagnosis.md) | +| Missing CLI, desktop upgrade, runtime drift, Ego host-skill refresh, diagnosis or registry recovery | [Installation and diagnosis](references/installation-and-diagnosis.md) | | Set up or repair autonomous execution, missing/contradictory contract, state-only delivery writeback | [Autonomous execution and settlement](references/autonomous-execution.md) | | Create, recover or change a recurring heartbeat or host loop | [Heartbeat hosts](references/heartbeat-hosts.md) | | Relay a review packet, record a human reward or run separately authorized child work | [Handoff and feedback](references/handoff-and-feedback.md) | diff --git a/skills/loopx-project/references/installation-and-diagnosis.md b/skills/loopx-project/references/installation-and-diagnosis.md index 68158665f9..965a05e2a2 100644 --- a/skills/loopx-project/references/installation-and-diagnosis.md +++ b/skills/loopx-project/references/installation-and-diagnosis.md @@ -21,6 +21,21 @@ upgraded CLI. Upgrade that App rather than downgrade a newer CLI to match its older bundle. Keep explicit developer runtime pins and their diagnostics intact. Keep SSH host verification separate; a host without an App needs no App install. +## Ego Host-Skill Refresh + +LoopX's normal host-skill upgrade/install path refreshes 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. App-private +runtime preparation that skips host integrations stays isolated. +Use `loopx workflow-skills --uninstall` to restore an unchanged managed Ego +skill to its current upstream link while removing LoopX-managed workflow skills. + ## Preflight Resolve the host command once before project work. On native Windows PowerShell diff --git a/tests/test_ego_skill_delivery.py b/tests/test_ego_skill_delivery.py new file mode 100644 index 0000000000..2b39e1ce61 --- /dev/null +++ b/tests/test_ego_skill_delivery.py @@ -0,0 +1,458 @@ +from __future__ import annotations + +import hashlib +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() + + +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 + 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" + + +@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() + 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() + + +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_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 +): + 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