diff --git a/examples/install-local-overwrite-smoke.py b/examples/install-local-overwrite-smoke.py index 4c8853c6c4..bc973da140 100644 --- a/examples/install-local-overwrite-smoke.py +++ b/examples/install-local-overwrite-smoke.py @@ -7,6 +7,7 @@ import subprocess import sys import tempfile +import time from pathlib import Path @@ -40,7 +41,9 @@ def run_install( *, check: bool = True, ) -> subprocess.CompletedProcess[str]: - return subprocess.run( + print(f"install-local-overwrite-smoke: installing {release_id}", flush=True) + started = time.monotonic() + result = subprocess.run( [str(INSTALL_SCRIPT)], cwd=REPO_ROOT, env={**env, "LOOPX_RELEASE_ID": release_id}, @@ -48,6 +51,8 @@ def run_install( capture_output=True, text=True, ) + print(f"install-local-overwrite-smoke: {release_id} in {time.monotonic() - started:.2f}s", flush=True) + return result def assert_loopx_link_points_to(wrapper: Path, release_id: str) -> None: @@ -90,6 +95,8 @@ def assert_directory_is_not_overwritten() -> None: profile = home / ".zshrc" wrapper = bin_dir / "loopx" wrapper.mkdir(parents=True) + sentinel = wrapper / "user.txt" + sentinel.write_text("keep user content", encoding="utf-8") env = install_env(root, bin_dir, profile) failed = run_install(env, "directory-conflict", check=False) @@ -97,6 +104,10 @@ def assert_directory_is_not_overwritten() -> None: assert wrapper.is_dir(), wrapper assert "loopx installer error:" in failed.stderr, failed.stderr assert f"{wrapper} is a directory; remove it before installing" in failed.stderr, failed.stderr + assert sentinel.read_text(encoding="utf-8") == "keep user content" + assert not (home / ".local" / "share" / "loopx" / "releases").exists() + assert not (home / ".codex").exists() + assert not profile.exists() def main() -> int: diff --git a/examples/install-local-smoke.py b/examples/install-local-smoke.py index 58d656c2eb..8691a8d554 100644 --- a/examples/install-local-smoke.py +++ b/examples/install-local-smoke.py @@ -9,6 +9,7 @@ import subprocess import sys import tempfile +import time from pathlib import Path @@ -71,7 +72,9 @@ def run_install( cwd: Path = REPO_ROOT, revalidate_extensions: bool = True, ) -> subprocess.CompletedProcess[str]: - return subprocess.run( + print(f"install-local-smoke: installing {release_id}", flush=True) + started = time.monotonic() + result = subprocess.run( [str(INSTALL_SCRIPT)], cwd=cwd, env={ @@ -85,6 +88,11 @@ def run_install( capture_output=True, text=True, ) + print( + f"install-local-smoke: installed {release_id} in {time.monotonic() - started:.2f}s", + flush=True, + ) + return result def write_promotion_readiness( @@ -859,60 +867,60 @@ def main() -> int: assert "```bash\n" in canary_task_body and "LOOPX_TURN=" in canary_task_body, canary_payload assert "not a command-prefix assignment" in canary_task_body, canary_payload - # The initial install exercises the default post-install extension - # revalidation. Repeated fixture installs do not add coverage for that - # same provider scan, so skip the optional pass to keep this smoke - # inside the public-suite timeout budget. - fresh_install = run_install( - env, "install-smoke-fresh", revalidate_extensions=False - ) - assert "loopx installed locally" in fresh_install.stdout, fresh_install.stdout - assert "loopx install warning" not in fresh_install.stderr, fresh_install.stderr - - stale_generated_at = (datetime.now(timezone.utc) - timedelta(hours=25)).replace(microsecond=0).isoformat() - write_promotion_readiness(runtime_run_dir, generated_at=stale_generated_at, label="stale") - stale_install = run_install( - env, "install-smoke-stale", revalidate_extensions=False - ) - assert "loopx installed locally" in stale_install.stdout, stale_install.stdout - assert "promotion-readiness evidence is stale" in stale_install.stderr, stale_install.stderr - assert "age_hours=" in stale_install.stderr, stale_install.stderr - assert "non-blocking" in stale_install.stderr, stale_install.stderr - + # Exercise two actual upgrades after the default install. Readiness + # freshness and optional Host configuration are independent inputs: + # pair fresh evidence with a broken OpenCode config, then stale evidence + # with a healthy config. Keep every warning/Host assertion without + # paying for two extra copies and complete candidate checks. + # The initial install covers post-install extension revalidation; + # repeating that same provider scan does not add upgrade coverage. blocked_opencode_root = home / ".config" / "opencode-blocked" blocked_opencode_root.mkdir(parents=True) (blocked_opencode_root / "opencode.jsonc").write_text( "{ invalid\n", encoding="utf-8", ) - blocked_opencode_install = run_install( + fresh_install = run_install( { **env, "LOOPX_INSTALL_OPENCODE": "1", "OPENCODE_CONFIG_DIR": str(blocked_opencode_root), }, - "install-smoke-opencode-blocked", + "install-smoke-fresh", revalidate_extensions=False, ) + assert "loopx installed locally" in fresh_install.stdout, fresh_install.stdout + assert "loopx install warning" not in fresh_install.stderr, fresh_install.stderr assert ( "loopx OpenCode bridge: install attempted; run manually:" - in blocked_opencode_install.stdout - ), blocked_opencode_install.stdout + in fresh_install.stdout + ), fresh_install.stdout assert (blocked_opencode_root / "commands" / "loopx.md").is_file() assert not (blocked_opencode_root / "plugins" / "loopx-goal.js").exists() assert not (blocked_opencode_root / "loopx" / "goal-bridge-runtime.mjs").exists() assert not (blocked_opencode_root / "package.json").exists() + assert wrapper.resolve().parents[1].name == "install-smoke-fresh" + assert fallback_wrapper.resolve().parents[1] == wrapper.resolve().parents[1] - opencode_install = run_install( + stale_generated_at = (datetime.now(timezone.utc) - timedelta(hours=25)).replace(microsecond=0).isoformat() + write_promotion_readiness(runtime_run_dir, generated_at=stale_generated_at, label="stale") + stale_install = run_install( {**env, "LOOPX_INSTALL_OPENCODE": "1"}, - "install-smoke-opencode", + "install-smoke-stale", revalidate_extensions=False, ) - assert "loopx OpenCode bridge:" in opencode_install.stdout, opencode_install.stdout + assert "loopx installed locally" in stale_install.stdout, stale_install.stdout + assert "promotion-readiness evidence is stale" in stale_install.stderr, stale_install.stderr + assert "age_hours=" in stale_install.stderr, stale_install.stderr + assert "non-blocking" in stale_install.stderr, stale_install.stderr + + assert "loopx OpenCode bridge:" in stale_install.stdout, stale_install.stdout assert (opencode_root / "commands" / "loopx.md").is_file() assert (opencode_root / "plugins" / "loopx-goal.js").is_file() assert (opencode_root / "loopx" / "goal-bridge-runtime.mjs").is_file() assert (opencode_root / "package.json").is_file() + assert wrapper.resolve().parents[1].name == "install-smoke-stale" + assert fallback_wrapper.resolve().parents[1] == wrapper.resolve().parents[1] print("install-local-smoke ok") return 0 diff --git a/loopx/cli.py b/loopx/cli.py index 1f5a9c25c7..e3c3877266 100644 --- a/loopx/cli.py +++ b/loopx/cli.py @@ -97,12 +97,9 @@ handle_bootstrap_connect_command, handle_canary_command, handle_coordination_shadow_command, - handle_authority_archive_command, handle_capability_command, - handle_doctor_command, handle_dreaming_command, handle_evidence_log_command, - handle_extension_command, handle_explore_command, handle_first_run_report_command, handle_goal_channel_command, @@ -120,7 +117,6 @@ handle_ready_score_command, handle_review_batch_command, handle_registry_admin_command, - handle_slash_commands_command, handle_starter_command, handle_summary_all_command, handle_support_control_command, @@ -207,8 +203,7 @@ register_lark_periodic_report_commands, ) from .help_surface import ( - build_command_reference_payload, - render_command_reference_markdown, + register_command_reference, render_concise_help, top_level_help_requested, ) @@ -271,11 +266,7 @@ def build_parser() -> LoopXArgumentParser: register_version_command(sub, add_subcommand_format) - commands_parser = sub.add_parser( - "commands", - help="Show grouped LoopX command reference for operators and contributors.", - ) - add_subcommand_format(commands_parser) + register_command_reference(sub) register_bootstrap_connect_command(sub) @@ -424,14 +415,6 @@ def main(argv: list[str] | None = None) -> int: if version_result is not None: return version_result - if args.command == "commands": - print_payload( - build_command_reference_payload(), - output_format(args), - render_command_reference_markdown, - ) - return 0 - bootstrap_connect_result = handle_bootstrap_connect_command( args, registry_path=registry_path, @@ -444,9 +427,6 @@ def main(argv: list[str] | None = None) -> int: if starter_result is not None: return starter_result - if args.command == "doctor": - return handle_doctor_command(args, print_payload) - workflow_skills_result = handle_workflow_skills_command( args, output_format=output_format, @@ -513,15 +493,6 @@ def main(argv: list[str] | None = None) -> int: if reliability_diagnostics_result is not None: return reliability_diagnostics_result - extension_result = handle_extension_command( - args, - runtime_root_arg=args.runtime_root, - output_format=output_format, - print_payload=print_payload, - ) - if extension_result is not None: - return extension_result - change_quality_result = handle_change_quality_command( args, registry_path=registry_path, @@ -880,14 +851,6 @@ def main(argv: list[str] | None = None) -> int: if deepresearch_result is not None: return deepresearch_result - slash_commands_result = handle_slash_commands_command( - args, - output_format=output_format, - print_payload=print_payload, - ) - if slash_commands_result is not None: - return slash_commands_result - if args.command == "dreaming": return handle_dreaming_command( args, @@ -924,13 +887,6 @@ def main(argv: list[str] | None = None) -> int: ) if cadence_result is not None: return cadence_result - authority_archive_result = handle_authority_archive_command( - args, registry_path=registry_path, runtime_root_arg=args.runtime_root, - output_format=output_format, print_payload=print_payload, - ) - if authority_archive_result is not None: - return authority_archive_result - coordination_shadow_result = handle_coordination_shadow_command( args, registry_path=registry_path, diff --git a/loopx/cli_runtime.py b/loopx/cli_runtime.py index 6c117fb56f..6666cf37ba 100644 --- a/loopx/cli_runtime.py +++ b/loopx/cli_runtime.py @@ -60,7 +60,8 @@ _STATUS_COMMANDS = frozenset({"check", "status", "diagnose", "review-packet"}) _SELECTED_COMMANDS = _STATUS_COMMANDS | { - "todo", "quota", "change-window", "delegation", "turn", + "todo", "quota", "change-window", "delegation", "turn", "doctor", "commands", + "authority-archive", "extension", "slash-commands", } @@ -228,6 +229,26 @@ def _build_selected_parser(command: str) -> LoopXArgumentParser: from .cli_commands.turn_registration import register_turn_commands register_turn_commands(subparsers, add_subcommand_format) + elif command == "authority-archive": + from .cli_commands.authority_archive import register_authority_archive_command + + register_authority_archive_command(subparsers, add_subcommand_format) + elif command == "extension": + from .cli_commands.extension import register_extension_commands + + register_extension_commands(subparsers, add_subcommand_format) + elif command == "slash-commands": + from .cli_commands.slash_commands import register_slash_commands_command + + register_slash_commands_command(subparsers, add_subcommand_format) + elif command == "doctor": + from .cli_commands.doctor import register_doctor_command + + register_doctor_command(subparsers, add_subcommand_format) + elif command == "commands": + from .help_surface import register_command_reference + + register_command_reference(subparsers) else: # pragma: no cover - caller guards the private interface raise ValueError(f"unsupported selected command: {command}") return parser @@ -239,6 +260,40 @@ def _dispatch_common_command( registry_path: Path, allow_missing_registry: bool, ) -> int | None: + if args.command == "authority-archive": + from .cli_commands.authority_archive import handle_authority_archive_command + + return handle_authority_archive_command( + args, registry_path=registry_path, runtime_root_arg=args.runtime_root, + output_format=output_format, print_payload=print_payload, + ) + if args.command == "extension": + from .cli_commands.extension import handle_extension_command + + return handle_extension_command( + args, runtime_root_arg=args.runtime_root, + output_format=output_format, print_payload=print_payload, + ) + if args.command == "slash-commands": + from .cli_commands.slash_commands import handle_slash_commands_command + + return handle_slash_commands_command( + args, output_format=output_format, print_payload=print_payload, + ) + if args.command == "doctor": + from .cli_commands.doctor import handle_doctor_command + + return handle_doctor_command(args, print_payload) + if args.command == "commands": + from .help_surface import ( + build_command_reference_payload, render_command_reference_markdown, + ) + + print_payload( + build_command_reference_payload(), output_format(args), + render_command_reference_markdown, + ) + return 0 if args.command == "delegation": from .cli_commands.delegation import handle_delegation from .control_plane.coordination.local_authority_shadow_adapter import ( diff --git a/loopx/help_surface.py b/loopx/help_surface.py index 74557ccf28..9306bd21c2 100644 --- a/loopx/help_surface.py +++ b/loopx/help_surface.py @@ -1,14 +1,25 @@ from __future__ import annotations +import argparse import re from typing import Any from . import __version__ -from .cli_runtime import GLOBAL_OPTIONS_WITH_EQUALS, GLOBAL_OPTIONS_WITH_VALUE +from .cli_runtime import ( + GLOBAL_OPTIONS_WITH_EQUALS, GLOBAL_OPTIONS_WITH_VALUE, add_subcommand_format, +) HELP_FLAGS = {"-h", "--help"} +def register_command_reference(subparsers: argparse._SubParsersAction) -> None: + parser = subparsers.add_parser( + "commands", + help="Show grouped LoopX command reference for operators and contributors.", + ) + add_subcommand_format(parser) + + COMMAND_GROUPS: list[dict[str, object]] = [ { "title": "Start here", diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index bf1f48417b..8d85962936 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -495,7 +495,7 @@ }, { "site": "loopx/cli.py::.main::codec_read:load_project_registry#1", - "line": 836, + "line": 807, "column": 17, "kind": "codec_read", "api": "load_project_registry", diff --git a/pyproject.toml b/pyproject.toml index f70a6b8da7..01be99e01d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -138,8 +138,8 @@ include = ["loopx*"] [tool.pytest.ini_options] markers = ["stage2c_e2e: real process Stage 2C correctness and recovery acceptance"] -# Optional provider tests run explicitly in ark-turn.yml with their own SDK. -norecursedirs = ["deprecate", "packages/loopx-ark-turn"] +# Private snapshots are not source tests; optional provider tests run in ark-turn.yml. +norecursedirs = [".local", "deprecate", "packages/loopx-ark-turn"] [tool.coverage.run] source = ["loopx"] diff --git a/scripts/install-local.sh b/scripts/install-local.sh index 90290b532b..186903d8ce 100755 --- a/scripts/install-local.sh +++ b/scripts/install-local.sh @@ -263,6 +263,33 @@ copy_path() { fi } +copy_apps() { + local src="$1" + local dst="$2" + if [[ ! -d "$src" || -L "$src" ]]; then + copy_path "$src" "$dst" + return + fi + # Application dependencies and build outputs never belong to a release. + # Exclude them before copying so staging does not pay to copy and delete them. + "${LOOPX_PYTHON:-python3}" - "$src" "$dst" <<'PY' +from pathlib import Path +import shutil +import sys + +excluded = {"node_modules", ".next", "dist", "build", "coverage"} + +def ignore(directory, names): + root = Path(directory) + return [ + name for name in names + if name in excluded and ((root / name).is_dir() or (root / name).is_symlink()) + ] + +shutil.copytree(sys.argv[1], sys.argv[2], symlinks=True, ignore=ignore) +PY +} + append_legacy_line() { local message="$1" if [[ -z "$legacy_line" ]]; then @@ -294,15 +321,21 @@ disable_legacy_shim() { append_legacy_line "legacy command disabled: $disabled" } +check_symlink_destination() { + local link="$1" + if [[ ! -L "$link" && -d "$link" ]]; then + echo "loopx installer error: $link is a directory; remove it before installing" >&2 + return 1 + fi +} + install_symlink() { local target="$1" local link="$2" local tmp="$link.tmp.$$" + # Repeat the check at replacement time in case a caller changed the path. + check_symlink_destination "$link" || return 1 rm -f "$tmp" - if [[ ! -L "$link" && -d "$link" ]]; then - echo "loopx installer error: $link is a directory; remove it before installing" >&2 - return 1 - fi ln -s "$target" "$tmp" LOOPX_LINK_TMP="$tmp" LOOPX_LINK_TARGET="$link" "${LOOPX_PYTHON:-python3}" - <<'PY' import os @@ -683,6 +716,19 @@ promote_default=0 if resolve_default_promotion; then promote_default=1 fi +# Reject unusable entry targets before building candidates or upgrading data. +# Canary-only installs must leave the default entry alone, even if it is a directory. +if [[ "$promote_default" == "1" ]]; then + check_symlink_destination "$bin_dir/loopx" + check_symlink_destination "$bin_dir/loopx-apply-rrule" +elif [[ "$install_canary" == "0" ]]; then + echo "loopx installer error: default promotion is guarded and LOOPX_INSTALL_CANARY=0 leaves no install target" >&2 + echo "Set LOOPX_PROMOTE_DEFAULT=1 only after explicitly approving this checkout." >&2 + exit 2 +fi +if [[ "$install_canary" != "0" ]]; then + check_symlink_destination "$bin_dir/loopx-canary" +fi if [[ "$promote_default" == "1" ]]; then # Preparing shared Chat assets is part of the guarded installation. mkdir -p "$releases_dir" @@ -703,11 +749,6 @@ fi "${LOOPX_PYTHON:-python3}" "$repo_root/scripts/chat_bundle.py" "${chat_bundle_args[@]}" if [[ "$promote_default" == "0" ]]; then - if [[ "$install_canary" == "0" ]]; then - echo "loopx installer error: default promotion is guarded and LOOPX_INSTALL_CANARY=0 leaves no install target" >&2 - echo "Set LOOPX_PROMOTE_DEFAULT=1 only after explicitly approving this checkout." >&2 - exit 2 - fi mkdir -p "$bin_dir" chmod +x "$repo_root/scripts/loopx" install_symlink "$repo_root/scripts/loopx" "$bin_dir/loopx-canary" @@ -749,7 +790,7 @@ copy_path "$repo_root/skills" "$release_tmp/skills" copy_path "$repo_root/docs" "$release_tmp/docs" copy_path "$repo_root/man" "$release_tmp/man" copy_path "$repo_root/examples" "$release_tmp/examples" -copy_path "$repo_root/apps" "$release_tmp/apps" +copy_apps "$repo_root/apps" "$release_tmp/apps" copy_path "$repo_root/.github" "$release_tmp/.github" copy_path "$repo_root/README.md" "$release_tmp/README.md" copy_path "$repo_root/LICENSE" "$release_tmp/LICENSE" @@ -759,11 +800,6 @@ copy_path "$repo_root/MANIFEST.in" "$release_tmp/MANIFEST.in" printf '%s\n' "$LOOPX_PYTHON" >"$release_tmp/.loopx-python" find "$release_tmp" -name __pycache__ -type d -prune -exec rm -rf {} + find "$release_tmp" -name '*.pyc' -type f -delete -if [[ -d "$release_tmp/apps" ]]; then - find "$release_tmp/apps" \ - \( -name node_modules -o -name .next -o -name dist -o -name build -o -name coverage \) \ - \( -type d -o -type l \) -prune -exec rm -rf {} + -fi PYTHONPATH="$release_tmp" "${LOOPX_PYTHON:-python3}" \ "$release_tmp/scripts/render-manpage.py" \ --output "$release_tmp/man/loopx.1" diff --git a/tests/architecture/test_turn_contract_generation.py b/tests/architecture/test_turn_contract_generation.py index e14adf24c5..9bd8cf1e28 100644 --- a/tests/architecture/test_turn_contract_generation.py +++ b/tests/architecture/test_turn_contract_generation.py @@ -260,7 +260,10 @@ def test_new_independent_twin_cannot_hide_behind_generated_pair(monkeypatch): ) assert counts is not None raw, generated, maintained, budget = map(int, counts.groups()) - assert generated == 1 and raw == maintained + generated + # Source-verified bindings may grow as decision owners converge. The + # independently maintained twin budget remains the frozen limit below. + assert generated >= 1 + assert raw == maintained + generated from loopx.semantics.inventory import SourceFile target = smoke["check_dual_runtime_twins"].__globals__ diff --git a/tests/control_plane/test_prompt_upgrade_hook.py b/tests/control_plane/test_prompt_upgrade_hook.py index a8ade637cb..e1d827b329 100644 --- a/tests/control_plane/test_prompt_upgrade_hook.py +++ b/tests/control_plane/test_prompt_upgrade_hook.py @@ -138,7 +138,7 @@ def test_partial_reconciliation_preserves_other_pending_entries(tmp_path, monkey @pytest.mark.parametrize("route_source", ["quota_cli_invocation", "loopx_turn_run_once"]) -def test_live_decision_adds_only_existing_required_read_channel(tmp_path, monkeypatch, route_source): +def test_upgrade_read_projection_preserves_work_authority(tmp_path, monkeypatch, route_source): home, path, database, registry, root, _, desired, _ = _deferred(tmp_path, monkeypatch) monkeypatch.setattr("loopx.control_plane.scheduler.scheduler_hint.now_utc", lambda: datetime(2026, 1, 1, tzinfo=timezone.utc)) @@ -167,8 +167,18 @@ def test_live_decision_adds_only_existing_required_read_channel(tmp_path, monkey assert envelope["compaction"]["budget_bytes"] == 8192 + 1536 assert envelope["compaction"]["hook_prompt_budget_bytes"] == 1536 assert build_turn_envelope(baseline)["compaction"]["budget_bytes"] == 8192 + assert baseline.get("turn_start_capability_hook_dispatch") is None + dispatch = pending["turn_start_capability_hook_dispatch"] + assert set(dispatch) == {"required_reads"} + assert len(dispatch["required_reads"]) == 1 + read = dispatch["required_reads"][0] + assert read["kind"] == hint["kind"] == "automation_prompt_upgrade" + assert read["command"] == hint["command"] + assert read["ordering"] == "before_work" + assert read["prompt_budget_bytes"] == 1536 for key in baseline.keys() | pending.keys(): - if key not in {"required_reads", "interaction_contract", "protocol_action_packet"}: + if key not in {"required_reads", "interaction_contract", "protocol_action_packet", + "turn_start_capability_hook_dispatch"}: assert pending.get(key) == baseline.get(key), key _set_fixture_prompt(path, database, desired) assert build_live_quota_should_run_decision(status, **kwargs) == baseline diff --git a/tests/test_cli_entrypoint.py b/tests/test_cli_entrypoint.py index 9e98d3ae81..3db73abb27 100644 --- a/tests/test_cli_entrypoint.py +++ b/tests/test_cli_entrypoint.py @@ -198,6 +198,11 @@ def test_top_level_help_skips_full_cli_import() -> None: @pytest.mark.parametrize( ("argv", "registration_module", "handler_module"), [ + (["commands", "--help"], "loopx.help_surface", "loopx.help_surface"), + (["doctor", "--help"], "loopx.cli_commands.doctor", "loopx.cli_commands.doctor"), + (["authority-archive", "--help"], "loopx.cli_commands.authority_archive", "loopx.cli_commands.authority_archive"), + (["extension", "--help"], "loopx.cli_commands.extension", "loopx.cli_commands.extension"), + (["slash-commands", "--help"], "loopx.cli_commands.slash_commands", "loopx.cli_commands.slash_commands"), (["check", "--help"], "loopx.cli_commands.status_registration", "loopx.cli_commands.status"), (["status", "--help"], "loopx.cli_commands.status_registration", "loopx.cli_commands.status"), (["diagnose", "--help"], "loopx.cli_commands.status_registration", "loopx.cli_commands.status"), @@ -261,6 +266,26 @@ def test_command_submodule_import_does_not_expand_all_cli_commands() -> None: def test_selected_parser_matches_full_help_and_diagnostics() -> None: argv_cases = [ + ["commands", "--help"], + ["commands", "--format", "json"], + ["commands", "--format", "markdown"], + ["doctor", "--help"], + ["doctor", "--installation-only", "--agent-type", "codex-app"], + ["doctor", "--agent-type", "unknown-host"], + ["doctor", "--unknown-option"], + ["commands", "--unknown-option"], + ["authority-archive", "--help"], + ["authority-archive", "upgrade", "--help"], + ["authority-archive", "upgrade", "--unknown-option"], + ["authority-archive", "upgrade", "--execute", "--require-current"], + ["extension", "--help"], + ["extension", "doctor", "--help"], + ["extension", "doctor", "--unknown-option"], + ["extension", "install"], + ["slash-commands", "--help"], + ["slash-commands", "--install", "--help"], + ["slash-commands", "--unknown-option"], + ["slash-commands", "--surface", "unknown-host"], ["check", "--help"], ["status", "--help"], ["diagnose", "--help"], @@ -284,6 +309,72 @@ def test_selected_parser_matches_full_help_and_diagnostics() -> None: assert selected == full +@pytest.mark.parametrize("command", ["authority-archive", "extension", "slash-commands"]) +def test_selected_admin_preview_matches_full_cli_without_writes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, command: str, +) -> None: + registry, runtime_root = write_command_fixture(tmp_path) + runtime_root.mkdir() + host_home = tmp_path / "host-home" + host_home.mkdir() + monkeypatch.setenv("HOME", str(host_home)) + monkeypatch.setenv("CODEX_HOME", str(host_home / ".codex")) + monkeypatch.setenv("LOOPX_RUNTIME_ROOT", str(runtime_root)) + monkeypatch.setenv("LOOPX_REGISTRY", str(registry)) + monkeypatch.setenv("LOOPX_USAGE_PING", "0") + argv = ["--registry", str(registry), "--runtime-root", str(runtime_root), + "--format", "json", command] + if command == "authority-archive": + argv += ["upgrade", "--require-current"] + elif command == "extension": + argv += ["doctor", "--all-enabled", "--state-file", str(runtime_root / "extensions.json")] + else: + argv += ["--install", "--dry-run", "--surface", "codex", "--codex-home", str(host_home / ".codex")] + + before = {str(path.relative_to(tmp_path)): path.read_bytes() + for path in tmp_path.rglob("*") if path.is_file()} + selected = run_cli_main("loopx.entrypoint", argv, forbidden_modules=("loopx.cli",)) + full = run_cli_main("loopx.cli", argv) + + assert selected.returncode == full.returncode == 0, (selected.stderr, full.stderr) + assert selected.stdout == full.stdout + assert selected.stderr == full.stderr == "" + assert {str(path.relative_to(tmp_path)): path.read_bytes() + for path in tmp_path.rglob("*") if path.is_file()} == before + assert not list(host_home.iterdir()) + assert not list(runtime_root.iterdir()) + + +@pytest.mark.parametrize("module", ["loopx.entrypoint", "loopx.cli"]) +@pytest.mark.parametrize("healthy", [True, False]) +def test_doctor_dispatch_preserves_owner_flags_and_failure( + module: str, healthy: bool, +) -> None: + script = f""" +import contextlib +import io +import json + +import loopx.cli_commands.doctor as owner +from {module} import main + +observed = [] +def collect(**kwargs): + observed.append(kwargs) + return {{"ok": {healthy!r}, "scope": "installation_only"}} +owner.collect_doctor = collect +output = io.StringIO() +with contextlib.redirect_stdout(output): + code = main(["--format", "markdown", "doctor", "--format", "json", + "--deep", "--installation-only"]) +assert code == {0 if healthy else 1} +assert observed == [{{"deep": True, "agent_type": None, "installation_only": True}}] +assert json.loads(output.getvalue()) == {{"ok": {healthy!r}, "scope": "installation_only"}} +""" + completed = run_isolated_script(script) + assert completed.returncode == 0, completed.stderr + + def test_selected_todo_execution_matches_full_cli(tmp_path: Path) -> None: registry, runtime_root = write_command_fixture(tmp_path) argv = [ diff --git a/tests/test_install_copy_fallback.py b/tests/test_install_copy_fallback.py index 7f476dd4bf..6f8d632aa5 100644 --- a/tests/test_install_copy_fallback.py +++ b/tests/test_install_copy_fallback.py @@ -1,6 +1,62 @@ import os from pathlib import Path import subprocess +import sys + +import pytest + + +def blocked_installer_fixture(tmp_path, name): + source = Path(__file__).parents[1] / "scripts/install-local.sh" + scripts = tmp_path / "checkout" / "scripts" + scripts.mkdir(parents=True) + installer = scripts / source.name + installer.write_bytes(source.read_bytes()) + installer.chmod(0o755) + marker = tmp_path / "chat-build-started" + (scripts / "chat_bundle.py").write_text( + f"from pathlib import Path\nPath({str(marker)!r}).touch()\nraise SystemExit(71)\n" + ) + home = tmp_path / "home" + blocked = home / ".local/bin" / name + blocked.mkdir(parents=True) + (blocked / "user.txt").write_text("preserve user directory") + runtime = home / ".codex/loopx" + runtime.mkdir(parents=True) + (runtime / "registry.global.json").write_text('{"goals": []}\n') + env = {**os.environ, "HOME": str(home), "CODEX_HOME": str(home / ".codex"), + "LOOPX_BIN_DIR": str(blocked.parent), "LOOPX_RELEASES_DIR": str(home / "releases"), + "LOOPX_PYTHON": sys.executable, "LOOPX_INSTALL_GUARD_HELD": "0"} + return installer, home, marker, env + + +@pytest.mark.parametrize( + ("promote", "canary", "name"), + [("1", "0", "loopx"), ("1", "0", "loopx-apply-rrule"), + ("1", "1", "loopx-canary"), ("0", "1", "loopx-canary")], +) +def test_blocked_entry_target_fails_before_build_or_state_changes(tmp_path, promote, canary, name): + installer, home, marker, env = blocked_installer_fixture(tmp_path, name) + before = {str(path.relative_to(home)): path.read_bytes() + for path in home.rglob("*") if path.is_file()} + result = subprocess.run([str(installer)], + env={**env, "LOOPX_PROMOTE_DEFAULT": promote, "LOOPX_INSTALL_CANARY": canary}, + capture_output=True, text=True, timeout=10) + assert result.returncode == 1, result.stderr + assert "is a directory; remove it before installing" in result.stderr + assert not marker.exists() + assert not (home / "releases").exists() + assert {str(path.relative_to(home)): path.read_bytes() + for path in home.rglob("*") if path.is_file()} == before + + +def test_canary_only_does_not_reject_untouched_default_entry(tmp_path): + installer, _, marker, env = blocked_installer_fixture(tmp_path, "loopx") + result = subprocess.run([str(installer)], + env={**env, "LOOPX_PROMOTE_DEFAULT": "0", "LOOPX_INSTALL_CANARY": "1"}, + capture_output=True, text=True, timeout=10) + assert result.returncode == 71, result.stderr + assert marker.exists() def test_clone_failure_discards_only_partial_staging_target(tmp_path): @@ -35,3 +91,77 @@ def test_clone_failure_discards_only_partial_staging_target(tmp_path): assert sorted(p.name for p in destination.iterdir()) == ["keep.txt"] (destination / "keep.txt").write_text("changed") assert (source / "keep.txt").read_text() == "original" + + +def run_apps_copy(source, destination): + script = (Path(__file__).parents[1] / "scripts/install-local.sh").read_text() + functions = "\n".join( + name + "() {" + script.split(name + "() {", 1)[1].split("\n}\n", 1)[0] + "\n}" + for name in ("copy_path", "copy_apps") + ) + return subprocess.run( + ["bash", "-c", 'set -eu\ncopy_platform=other\n' + functions + '\ncopy_apps "$1" "$2"', + "apps-copy-test", str(source), str(destination)], + env={**os.environ, "LOOPX_PYTHON": sys.executable}, + capture_output=True, + text=True, + ) + + +def test_apps_snapshot_excludes_developer_trees_without_changing_files(tmp_path): + source, destination = tmp_path / "source apps", tmp_path / "release apps" + project = source / "project" + project.mkdir(parents=True) + for name in ("node_modules", ".next", "dist", "build", "coverage"): + tree = project / "nested" / name + tree.mkdir(parents=True) + (tree / "not-shipped.js").write_text("development output") + (project / "nested" / "keep.ts").write_text("source") + executable = project / "run.sh" + executable.write_text("#!/bin/sh\nexit 0\n") + executable.chmod(0o751) + (project / "source-link").symlink_to("nested/keep.ts") + (project / "dangling-link").symlink_to("missing.ts") + (project / "node_modules").symlink_to("missing-dependency") + + result = run_apps_copy(source, destination) + assert result.returncode == 0, result.stderr + assert sorted(p.name for p in (destination / "project" / "nested").iterdir()) == ["keep.ts"] + delivered = destination / "project" + assert sorted(p.name for p in delivered.iterdir()) == [ + "dangling-link", "nested", "run.sh", "source-link" + ] + assert (delivered / "run.sh").read_bytes() == executable.read_bytes() + assert (delivered / "run.sh").stat().st_mode & 0o777 == 0o751 + assert (delivered / "source-link").readlink() == Path("nested/keep.ts") + assert (delivered / "dangling-link").readlink() == Path("missing.ts") + (delivered / "nested" / "keep.ts").write_text("changed release") + assert (project / "nested" / "keep.ts").read_text() == "source" + assert (project / "nested" / "node_modules" / "not-shipped.js").is_file() + + +def test_apps_snapshot_keeps_ordinary_files_with_excluded_directory_names(tmp_path): + source, destination = tmp_path / "source", tmp_path / "release" + source.mkdir() + names = ("node_modules", ".next", "dist", "build", "coverage") + for name in names: + (source / name).write_text("ordinary source file") + result = run_apps_copy(source, destination) + assert result.returncode == 0, result.stderr + assert sorted(p.name for p in destination.iterdir()) == sorted(names) + assert all((destination / name).read_text() == "ordinary source file" for name in names) + + +def test_apps_snapshot_preserves_root_symlink_and_absent_source(tmp_path): + source, destination = tmp_path / "source-link", tmp_path / "release-link" + real_source = tmp_path / "real-source" + real_source.mkdir() + source.symlink_to("real-source", target_is_directory=True) + result = run_apps_copy(source, destination) + assert result.returncode == 0, result.stderr + assert destination.is_symlink() + assert destination.readlink() == Path("real-source") + absent_destination = tmp_path / "absent-release" + result = run_apps_copy(tmp_path / "absent-source", absent_destination) + assert result.returncode == 0, result.stderr + assert not absent_destination.exists()