From 955815e8f4f953d43ea752e94944adb21a357911 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Lacasse?= Date: Mon, 27 Jul 2026 20:11:24 -0400 Subject: [PATCH] test(install): add Docker lifecycle sandbox Co-authored-by: Codex --- .github/workflows/ci.yml | 3 + .github/workflows/install-sandbox.yml | 143 +++ tests/install_sandbox/test_ci_result.py | 127 +++ tests/install_sandbox/test_docker.py | 217 +++++ tests/install_sandbox/test_effects.py | 408 +++++++++ tests/install_sandbox/test_lifecycle.py | 482 +++++++++++ tests/install_sandbox/test_real_catalog.py | 67 ++ tests/install_sandbox/test_reporting_cli.py | 207 +++++ tests/install_sandbox/test_run_artifacts.py | 442 ++++++++++ tests/install_sandbox/test_specs.py | 268 ++++++ tools/install_sandbox/.dockerignore | 4 + tools/install_sandbox/.gitignore | 3 + tools/install_sandbox/AGENTS.md | 30 + tools/install_sandbox/Dockerfile | 38 + tools/install_sandbox/README.md | 265 ++++++ tools/install_sandbox/__init__.py | 1 + tools/install_sandbox/ci_result.py | 109 +++ tools/install_sandbox/docker.py | 240 ++++++ tools/install_sandbox/effects.py | 606 +++++++++++++ tools/install_sandbox/lifecycle.py | 812 ++++++++++++++++++ tools/install_sandbox/models.py | 184 ++++ tools/install_sandbox/reporting.py | 108 +++ tools/install_sandbox/run.py | 180 ++++ tools/install_sandbox/run_artifacts.py | 581 +++++++++++++ tools/install_sandbox/sandbox_runner.py | 181 ++++ tools/install_sandbox/specs.py | 270 ++++++ tools/install_sandbox/specs/README.md | 138 +++ tools/install_sandbox/specs/agents.yaml | 17 + tools/install_sandbox/specs/aider.yaml | 19 + tools/install_sandbox/specs/amp.yaml | 22 + .../specs/antigravity-windows.yaml | 17 + tools/install_sandbox/specs/antigravity.yaml | 40 + tools/install_sandbox/specs/claude.yaml | 48 ++ tools/install_sandbox/specs/claw.yaml | 21 + tools/install_sandbox/specs/codebuddy.yaml | 46 + tools/install_sandbox/specs/codex.yaml | 30 + tools/install_sandbox/specs/copilot.yaml | 17 + tools/install_sandbox/specs/cursor.yaml | 12 + tools/install_sandbox/specs/devin.yaml | 19 + tools/install_sandbox/specs/droid.yaml | 21 + tools/install_sandbox/specs/gemini.yaml | 45 + tools/install_sandbox/specs/hermes.yaml | 23 + tools/install_sandbox/specs/kilo.yaml | 39 + tools/install_sandbox/specs/kimi.yaml | 16 + tools/install_sandbox/specs/kiro.yaml | 21 + tools/install_sandbox/specs/opencode.yaml | 41 + tools/install_sandbox/specs/pi.yaml | 17 + tools/install_sandbox/specs/trae-cn.yaml | 21 + tools/install_sandbox/specs/trae.yaml | 21 + tools/install_sandbox/specs/vscode.yaml | 30 + tools/install_sandbox/specs/windows.yaml | 48 ++ 51 files changed, 6765 insertions(+) create mode 100644 .github/workflows/install-sandbox.yml create mode 100644 tests/install_sandbox/test_ci_result.py create mode 100644 tests/install_sandbox/test_docker.py create mode 100644 tests/install_sandbox/test_effects.py create mode 100644 tests/install_sandbox/test_lifecycle.py create mode 100644 tests/install_sandbox/test_real_catalog.py create mode 100644 tests/install_sandbox/test_reporting_cli.py create mode 100644 tests/install_sandbox/test_run_artifacts.py create mode 100644 tests/install_sandbox/test_specs.py create mode 100644 tools/install_sandbox/.dockerignore create mode 100644 tools/install_sandbox/.gitignore create mode 100644 tools/install_sandbox/AGENTS.md create mode 100644 tools/install_sandbox/Dockerfile create mode 100644 tools/install_sandbox/README.md create mode 100644 tools/install_sandbox/__init__.py create mode 100644 tools/install_sandbox/ci_result.py create mode 100644 tools/install_sandbox/docker.py create mode 100644 tools/install_sandbox/effects.py create mode 100644 tools/install_sandbox/lifecycle.py create mode 100644 tools/install_sandbox/models.py create mode 100644 tools/install_sandbox/reporting.py create mode 100644 tools/install_sandbox/run.py create mode 100644 tools/install_sandbox/run_artifacts.py create mode 100644 tools/install_sandbox/sandbox_runner.py create mode 100644 tools/install_sandbox/specs.py create mode 100644 tools/install_sandbox/specs/README.md create mode 100644 tools/install_sandbox/specs/agents.yaml create mode 100644 tools/install_sandbox/specs/aider.yaml create mode 100644 tools/install_sandbox/specs/amp.yaml create mode 100644 tools/install_sandbox/specs/antigravity-windows.yaml create mode 100644 tools/install_sandbox/specs/antigravity.yaml create mode 100644 tools/install_sandbox/specs/claude.yaml create mode 100644 tools/install_sandbox/specs/claw.yaml create mode 100644 tools/install_sandbox/specs/codebuddy.yaml create mode 100644 tools/install_sandbox/specs/codex.yaml create mode 100644 tools/install_sandbox/specs/copilot.yaml create mode 100644 tools/install_sandbox/specs/cursor.yaml create mode 100644 tools/install_sandbox/specs/devin.yaml create mode 100644 tools/install_sandbox/specs/droid.yaml create mode 100644 tools/install_sandbox/specs/gemini.yaml create mode 100644 tools/install_sandbox/specs/hermes.yaml create mode 100644 tools/install_sandbox/specs/kilo.yaml create mode 100644 tools/install_sandbox/specs/kimi.yaml create mode 100644 tools/install_sandbox/specs/kiro.yaml create mode 100644 tools/install_sandbox/specs/opencode.yaml create mode 100644 tools/install_sandbox/specs/pi.yaml create mode 100644 tools/install_sandbox/specs/trae-cn.yaml create mode 100644 tools/install_sandbox/specs/trae.yaml create mode 100644 tools/install_sandbox/specs/vscode.yaml create mode 100644 tools/install_sandbox/specs/windows.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 548b40f22..d99ed5eda 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,9 @@ jobs: # --frozen keeps uv from re-resolving and rewriting uv.lock as a side # effect of `uv run`; the lock is committed and must not churn in CI. + - name: Type-check install-sandbox tools + run: uv run --frozen pyright --warnings tools/install_sandbox + - name: Check generated skill artifacts are up to date run: uv run --frozen python -m tools.skillgen --check diff --git a/.github/workflows/install-sandbox.yml b/.github/workflows/install-sandbox.yml new file mode 100644 index 000000000..64e6b3efe --- /dev/null +++ b/.github/workflows/install-sandbox.yml @@ -0,0 +1,143 @@ +# Cost controls: shorten retention to reduce stored artifact-days. +# Cost controls: lower compression from 9 if runner time matters more than storage. +name: Install sandbox + +env: + INSTALL_SANDBOX_ARTIFACT_RETENTION_DAYS: 14 + INSTALL_SANDBOX_ARTIFACT_COMPRESSION_LEVEL: 9 + +on: + pull_request: + branches: ["v8", "main"] + paths: + - "graphify/install.py" + - "graphify/__main__.py" + - "graphify/hooks.py" + - "graphify/paths.py" + - "graphify/skill*.md" + - "graphify/command*.md" + - "graphify/skills/**" + - "graphify/always_on/**" + - "tools/install_sandbox/**" + - "tests/install_sandbox/**" + - "pyproject.toml" + - "uv.lock" + - ".github/workflows/install-sandbox.yml" + push: + branches: ["v8", "main"] + paths: + - "graphify/install.py" + - "graphify/__main__.py" + - "graphify/hooks.py" + - "graphify/paths.py" + - "graphify/skill*.md" + - "graphify/command*.md" + - "graphify/skills/**" + - "graphify/always_on/**" + - "tools/install_sandbox/**" + - "tests/install_sandbox/**" + - "pyproject.toml" + - "uv.lock" + - ".github/workflows/install-sandbox.yml" + schedule: + - cron: "27 5 * * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: install-sandbox-${{ github.event_name }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + full-catalog: + name: Full catalog (advisory) + runs-on: ubuntu-latest + timeout-minutes: 150 + + steps: + - name: Check out source + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Install uv and Python + uses: astral-sh/setup-uv@v8.1.0 + with: + python-version: "3.12" + + - name: Install dependencies + run: uv sync --all-extras --frozen + + - name: Run full install sandbox + id: sandbox + shell: bash + run: | + set +e + uv run --frozen python tools/install_sandbox/run.py \ + --repo . \ + --all \ + --scope both \ + --output "$RUNNER_TEMP/graphify-install-sandbox" + sandbox_exit=$? + set -e + printf 'exit_code=%s\n' "$sandbox_exit" >> "$GITHUB_OUTPUT" + + - name: Upload install-sandbox artifacts + id: artifact + if: always() + uses: actions/upload-artifact@v7 + with: + name: install-sandbox-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/graphify-install-sandbox + if-no-files-found: warn + retention-days: ${{ env.INSTALL_SANDBOX_ARTIFACT_RETENTION_DAYS }} + compression-level: ${{ env.INSTALL_SANDBOX_ARTIFACT_COMPRESSION_LEVEL }} + + - name: Write job summary + if: always() + shell: bash + env: + ARTIFACT_URL: ${{ steps.artifact.outputs.artifact-url }} + OUTPUT_DIR: ${{ runner.temp }}/graphify-install-sandbox + SANDBOX_EXIT_CODE: ${{ steps.sandbox.outputs.exit_code }} + run: | + { + echo "## Install sandbox" + echo + echo "- Runner exit code: \`${SANDBOX_EXIT_CODE:-not recorded}\`" + if [[ -n "$ARTIFACT_URL" ]]; then + echo "- [Download the complete diagnostic bundle]($ARTIFACT_URL)" + else + echo "- The artifact upload did not provide a download URL." + fi + echo + if [[ -f "$OUTPUT_DIR/report.md" ]]; then + cat "$OUTPUT_DIR/report.md" + elif [[ -f "$OUTPUT_DIR/run.json" ]]; then + echo "The behavioral report is unavailable. Host lifecycle metadata follows:" + echo + echo '```json' + cat "$OUTPUT_DIR/run.json" + echo + echo '```' + else + echo "No report.md or run.json was produced." + fi + } >> "$GITHUB_STEP_SUMMARY" + + - name: Apply install-sandbox result + if: always() + shell: bash + env: + RUN_METADATA: ${{ runner.temp }}/graphify-install-sandbox/run.json + SANDBOX_EXIT_CODE: ${{ steps.sandbox.outputs.exit_code }} + run: | + if [[ ! "$SANDBOX_EXIT_CODE" =~ ^[0-9]+$ ]]; then + echo "::error::The install-sandbox runner did not record an exit code." + exit 1 + fi + uv run --frozen python -m tools.install_sandbox.ci_result \ + --run-json "$RUN_METADATA" \ + --runner-exit-code "$SANDBOX_EXIT_CODE" diff --git a/tests/install_sandbox/test_ci_result.py b/tests/install_sandbox/test_ci_result.py new file mode 100644 index 000000000..d253a8598 --- /dev/null +++ b/tests/install_sandbox/test_ci_result.py @@ -0,0 +1,127 @@ +import json + +import pytest + +from tools.install_sandbox.ci_result import classify_ci_result, main + + +@pytest.mark.parametrize( + ("state", "runner_exit_code", "annotation"), + [ + ("passed", 0, "notice"), + ("failed", 1, "warning"), + ], +) +def test_completed_diagnostics_are_successful_ci_results( + state, + runner_exit_code, + annotation, +): + result = classify_ci_result( + {"state": state, "exit_code": runner_exit_code}, + runner_exit_code, + ) + + assert result.annotation == annotation + assert result.exit_code == 0 + + +@pytest.mark.parametrize( + ("state", "runner_exit_code"), + [ + ("incomplete", 2), + ("incomplete", 127), + ("interrupted", 143), + ], +) +def test_noncompleted_diagnostics_fail_ci(state, runner_exit_code): + result = classify_ci_result( + {"state": state, "exit_code": runner_exit_code}, + runner_exit_code, + ) + + assert result.annotation == "error" + assert result.exit_code == runner_exit_code + + +@pytest.mark.parametrize( + ("metadata", "runner_exit_code", "message"), + [ + ({"state": "passed", "exit_code": 1}, 1, "passed state"), + ({"state": "failed", "exit_code": 2}, 2, "failed state"), + ({"state": "incomplete", "exit_code": 0}, 0, "nonzero exit code"), + ({"state": "running", "exit_code": 0}, 0, "unknown terminal state"), + ({"state": "passed", "exit_code": 0}, 1, "does not match"), + ({"state": "passed", "exit_code": None}, 0, "not an integer"), + ], +) +def test_invalid_result_contract_fails_ci(metadata, runner_exit_code, message): + result = classify_ci_result(metadata, runner_exit_code) + + assert result.annotation == "error" + assert result.exit_code == 2 + assert message in result.message + + +def test_cli_emits_warning_for_completed_behavioral_findings(tmp_path, capsys): + run_json = tmp_path / "run.json" + run_json.write_text( + json.dumps({"state": "failed", "exit_code": 1}), + encoding="utf-8", + ) + + exit_code = main( + [ + "--run-json", + str(run_json), + "--runner-exit-code", + "1", + ] + ) + + assert exit_code == 0 + assert capsys.readouterr().out.startswith("::warning::") + + +def test_cli_fails_when_run_metadata_is_missing(tmp_path, capsys): + run_json = tmp_path / "missing-run.json" + + exit_code = main( + [ + "--run-json", + str(run_json), + "--runner-exit-code", + "2", + ] + ) + + assert exit_code == 2 + output = capsys.readouterr().out + assert output.startswith("::error::") + assert "cannot read" in output + + +@pytest.mark.parametrize( + ("contents", "message"), + [ + ("{", "cannot read"), + ("[]", "root is not an object"), + ], +) +def test_cli_fails_for_unusable_run_metadata(tmp_path, capsys, contents, message): + run_json = tmp_path / "run.json" + run_json.write_text(contents, encoding="utf-8") + + exit_code = main( + [ + "--run-json", + str(run_json), + "--runner-exit-code", + "1", + ] + ) + + assert exit_code == 2 + output = capsys.readouterr().out + assert output.startswith("::error::") + assert message in output diff --git a/tests/install_sandbox/test_docker.py b/tests/install_sandbox/test_docker.py new file mode 100644 index 000000000..93508c9d9 --- /dev/null +++ b/tests/install_sandbox/test_docker.py @@ -0,0 +1,217 @@ +import sys +from pathlib import Path + +import pytest + +from tools.install_sandbox import docker +from tools.install_sandbox.docker import ( + CONTAINER_HOME, + CONTAINER_OUTPUT, + CONTAINER_PROJECT, + CONTAINER_REPO, + CONTAINER_SOURCE, + CONTAINER_USER_CWD, + CONTAINER_XDG, + build_image_command, + build_run_command, +) +from tools.install_sandbox import sandbox_runner +from tools.install_sandbox.models import SandboxRoots, ScenarioResult + + +def test_docker_commands_mount_source_read_only_and_isolate_every_root(tmp_path): + repo = tmp_path / "repo" + output = tmp_path / "output" + command = build_run_command( + runtime="docker", + image="sandbox:test", + repo=repo, + output=output, + target="demo", + all_targets=False, + scope="project", + ) + + assert build_image_command("docker", "sandbox:test")[:4] == [ + "docker", + "build", + "--tag", + "sandbox:test", + ] + assert f"{repo}:{CONTAINER_REPO}:ro" in command + assert f"{output}:{CONTAINER_OUTPUT}:rw" in command + for path in { + CONTAINER_HOME, + CONTAINER_XDG, + CONTAINER_PROJECT, + CONTAINER_USER_CWD, + CONTAINER_SOURCE, + CONTAINER_REPO, + CONTAINER_OUTPUT, + }: + assert path in " ".join(command) + assert len( + { + CONTAINER_HOME, + CONTAINER_XDG, + CONTAINER_PROJECT, + CONTAINER_USER_CWD, + CONTAINER_SOURCE, + CONTAINER_REPO, + CONTAINER_OUTPUT, + } + ) == 7 + assert command[-4:] == ["--scope", "project", "--target", "demo"] + + +def test_docker_command_requires_exactly_one_selection(tmp_path): + with pytest.raises(ValueError, match="exactly one"): + build_run_command( + runtime="docker", + image="image", + repo=tmp_path / "repo", + output=tmp_path / "out", + target=None, + all_targets=False, + scope="both", + ) + + +def test_host_command_output_is_streamed_with_phase_and_stream_labels(): + observed = [] + + exit_code = docker._run( + [ + sys.executable, + "-c", + "import sys; print('from stdout'); print('from stderr', file=sys.stderr)", + ], + 10, + phase="example", + on_output=lambda phase, stream, text: observed.append( + (phase, stream, text) + ), + ) + + assert exit_code == 0 + assert observed[0][0:2] == ("example", "command") + assert ("example", "stdout", "from stdout\n") in observed + assert ("example", "stderr", "from stderr\n") in observed + + +def test_run_sandbox_announces_build_and_container_phases(tmp_path, monkeypatch): + phases = [] + commands = [] + + def fake_run(argv, timeout, *, phase, on_output): + commands.append((argv, timeout, phase, on_output)) + return 0 + + monkeypatch.setattr(docker, "_run", fake_run) + + exit_code = docker.run_sandbox( + repo=tmp_path / "repo", + output=tmp_path / "output", + target="demo", + all_targets=False, + scope="project", + on_phase=phases.append, + ) + + assert exit_code == 0 + assert phases == ["docker_build", "container"] + assert [item[2] for item in commands] == ["docker_build", "container"] + + +def test_container_oracle_is_packaged_with_harness_not_subject_repo( + tmp_path, + monkeypatch, +): + harness_specs = tmp_path / "harness" / "specs" + subject_repo = tmp_path / "subject" + subject_specs = subject_repo / "tools" / "install_sandbox" / "specs" + harness_specs.mkdir(parents=True) + subject_specs.mkdir(parents=True) + body = ( + "unsupported:\n" + " project: unavailable\n" + "scopes:\n" + " user:\n" + " effects:\n" + " - {root: home, path: fixture.txt}\n" + ) + (harness_specs / "demo.yaml").write_text( + body, + encoding="utf-8", + ) + (subject_specs / "subject-only.yaml").write_text( + body, + encoding="utf-8", + ) + + root_paths = { + name: tmp_path / name + for name in ( + "home", + "xdg", + "project", + "user_cwd", + "source", + "output", + ) + } + roots = SandboxRoots(repo_mount=subject_repo, **root_paths) + observed = {} + + def fake_copy_and_install_package(actual_roots, catalog): + observed["repo_mount"] = actual_roots.repo_mount + observed["catalog_names"] = tuple(catalog) + return { + "repo_mount_read_only": True, + "package_version": "1.0", + } + + def fake_run_scenario(scenario, actual_roots, *, expected_version): + observed["scenario_target"] = scenario.target.name + return ScenarioResult( + scenario=scenario.name, + target=scenario.target.name, + scope=scenario.scope.value, + status="PASS", + phases=[], + ) + + monkeypatch.setattr( + sandbox_runner, + "roots_from_environment", + lambda: roots, + ) + monkeypatch.setattr( + sandbox_runner, + "copy_and_install_package", + fake_copy_and_install_package, + ) + monkeypatch.setattr(sandbox_runner, "run_scenario", fake_run_scenario) + monkeypatch.setattr( + sandbox_runner, + "run_purge_check", + lambda actual_roots: {"status": "PASS"}, + ) + monkeypatch.setattr( + sandbox_runner, + "write_run_outputs", + lambda output, manifest: None, + ) + + exit_code = sandbox_runner.main( + ["--target", "demo", "--scope", "user"], + spec_dir=harness_specs, + ) + + assert exit_code == 0 + assert observed == { + "repo_mount": subject_repo, + "catalog_names": ("demo",), + "scenario_target": "demo", + } + assert harness_specs.resolve() != subject_specs.resolve() diff --git a/tests/install_sandbox/test_effects.py b/tests/install_sandbox/test_effects.py new file mode 100644 index 000000000..085088786 --- /dev/null +++ b/tests/install_sandbox/test_effects.py @@ -0,0 +1,408 @@ +import json +from pathlib import Path + +from tools.install_sandbox.effects import ( + REFERENCE_NAMES, + USER_JSON_SEED, + contains_json, + snapshot, + validate_installed, + validate_no_unexpected_changes, + validate_removed, +) +from tools.install_sandbox.models import Effect, EffectKind, Root + + +def roots(tmp_path): + result = {root: tmp_path / root.value for root in Root} + for path in result.values(): + path.mkdir() + return result + + +def test_json_subset_matching_is_order_independent_for_list_entries(): + actual = { + "hooks": { + "PreToolUse": [ + {"matcher": "Read|Glob", "hooks": [{"command": "graphify"}]}, + {"matcher": "Bash|Grep", "hooks": [{"command": "graphify"}]}, + ] + }, + "user": "kept", + } + expected = { + "hooks": { + "PreToolUse": [{"matcher": "Bash|Grep"}, {"matcher": "Read|Glob"}] + } + } + + assert contains_json(actual, expected) + + +def test_json_backup_is_validated_and_allowed_as_a_declared_change(tmp_path): + root_map = roots(tmp_path) + settings = root_map[Root.PROJECT] / ".demo/settings.json" + settings.parent.mkdir() + settings.write_text( + json.dumps( + { + **USER_JSON_SEED, + "hooks": {"PreToolUse": [{"matcher": "Bash"}]}, + } + ), + encoding="utf-8", + ) + before = snapshot(root_map) + backup = settings.with_name(settings.name + ".graphify-bak") + backup.write_text(json.dumps(USER_JSON_SEED), encoding="utf-8") + after = snapshot(root_map) + effect = Effect( + kind=EffectKind.JSON, + root=Root.PROJECT, + path=".demo/settings.json", + entries={"hooks": {"PreToolUse": [{"matcher": "Bash"}]}}, + preserves_backup=True, + ) + + installed = validate_installed([effect], root_map, tmp_path) + changed = validate_no_unexpected_changes([effect], before, after) + + assert all(result.passed for result in installed), installed + assert changed.passed + + backup.write_text(json.dumps({"wrong": True}), encoding="utf-8") + removed = validate_removed([effect], root_map) + + assert any( + result.check.endswith("backup preserved") and not result.passed + for result in removed + ) + + +def test_progressive_skill_validates_payload_version_exact_refs_and_pointers( + tmp_path, +): + root_map = roots(tmp_path) + source = tmp_path / "source" + skill_source = source / "graphify" / "skill.md" + refs_source = source / "graphify" / "skills" / "demo" / "references" + refs_source.mkdir(parents=True) + skill_source.parent.mkdir(parents=True, exist_ok=True) + skill_source.write_text( + "\n".join(f"[{name}](references/{name})" for name in REFERENCE_NAMES), + encoding="utf-8", + ) + for name in REFERENCE_NAMES: + (refs_source / name).write_text(name, encoding="utf-8") + + installed = root_map[Root.HOME] / ".tool/graphify/SKILL.md" + installed.parent.mkdir(parents=True) + installed.write_bytes(skill_source.read_bytes()) + (installed.parent / ".graphify_version").write_text("1.0", encoding="utf-8") + installed_refs = installed.parent / "references" + installed_refs.mkdir() + for name in REFERENCE_NAMES: + (installed_refs / name).write_bytes((refs_source / name).read_bytes()) + effect = Effect( + kind=EffectKind.SKILL, + root=Root.HOME, + path=".tool/graphify/SKILL.md", + source="graphify/skill.md", + reference_bundle="demo", + ) + + results = validate_installed( + [effect], + root_map, + source, + expected_version="1.0", + ) + + assert results + assert all(result.passed for result in results), results + (installed.parent / ".graphify_version").write_text( + "0.0.0-stale", + encoding="utf-8", + ) + stale_results = validate_installed( + [effect], + root_map, + source, + expected_version="1.0", + ) + assert any( + result.check == "version sidecar" and not result.passed + for result in stale_results + ) + + +def test_markdown_json_and_reminder_checks_are_behavioral(tmp_path): + root_map = roots(tmp_path) + project = root_map[Root.PROJECT] + notes = project / "notes.md" + notes.write_text("# User\n\nkeep\n\n## graphify\nowned\n", encoding="utf-8") + config = project / ".demo/config.json" + config.parent.mkdir() + config.write_text( + json.dumps( + { + "user_owned": True, + "plugin": [".demo/plugins/graphify.js"], + } + ), + encoding="utf-8", + ) + plugin = project / ".demo/plugins/graphify.js" + plugin.parent.mkdir(exist_ok=True) + plugin.write_text( + "// comments may mention ` and $( safely\n" + "const command = 'echo \"[graphify] run graphify query with your question\" ; ' + command;\n", + encoding="utf-8", + ) + effects = [ + Effect( + kind=EffectKind.SECTION, + root=Root.PROJECT, + path="notes.md", + marker="## graphify", + ), + Effect( + kind=EffectKind.JSON, + root=Root.PROJECT, + path=".demo/config.json", + entries={"plugin": [".demo/plugins/graphify.js"]}, + ), + Effect( + kind=EffectKind.REMINDER_PLUGIN, + root=Root.PROJECT, + path=".demo/plugins/graphify.js", + required_text=("[graphify]", "graphify query"), + forbidden_text=("`", "$(", "&&"), + ), + ] + + results = validate_installed(effects, root_map, tmp_path / "unused") + + assert all(result.passed for result in results), results + notes.write_text("# User\n\nkeep\n", encoding="utf-8") + config.write_text( + json.dumps({"user_owned": True, "plugin": []}), encoding="utf-8" + ) + plugin.unlink() + assert all(result.passed for result in validate_removed(effects, root_map)) + assert "keep" in notes.read_text(encoding="utf-8") + assert json.loads(config.read_text(encoding="utf-8"))["user_owned"] is True + + +def test_unsafe_text_inside_captured_reminder_fails(tmp_path): + root_map = roots(tmp_path) + plugin = root_map[Root.PROJECT] / "plugin.js" + plugin.write_text( + "const x = 'echo \"[graphify] run $(graphify query)\" && ' + command;\n", + encoding="utf-8", + ) + effect = Effect( + kind=EffectKind.REMINDER_PLUGIN, + root=Root.PROJECT, + path="plugin.js", + required_text=("[graphify]",), + forbidden_text=("$(", "&&"), + ) + + results = validate_installed([effect], root_map, tmp_path) + + assert any(not result.passed for result in results) + + +def test_owned_section_with_correct_marker_and_wrong_body_fails(tmp_path): + root_map = roots(tmp_path) + source = tmp_path / "source" + expected = source / "fixtures" / "notes.md" + expected.parent.mkdir(parents=True) + expected.write_text( + "## graphify\n\nrequired graph instructions\n", + encoding="utf-8", + ) + target = root_map[Root.PROJECT] / "notes.md" + target.write_text( + "# User notes\n\nkeep\n\n## graphify\n\nstale instructions\n", + encoding="utf-8", + ) + effect = Effect( + kind=EffectKind.SECTION, + root=Root.PROJECT, + path="notes.md", + marker="## graphify", + source="fixtures/notes.md", + ) + + results = validate_installed([effect], root_map, source) + + assert any( + result.check == "payload equality" and not result.passed + for result in results + ) + + +def test_duplicate_owned_section_marker_fails_even_when_last_body_is_correct( + tmp_path, +): + root_map = roots(tmp_path) + target = root_map[Root.PROJECT] / "notes.md" + target.write_text( + "# User notes\n\n" + "## graphify\n\nstale instructions\n\n" + "## graphify\n\ncurrent instructions\n", + encoding="utf-8", + ) + effect = Effect( + kind=EffectKind.SECTION, + root=Root.PROJECT, + path="notes.md", + marker="## graphify", + required_text=("current instructions",), + ) + + results = validate_installed([effect], root_map, tmp_path) + + assert any( + result.check.endswith("owned section") and not result.passed + for result in results + ) + + +def test_json_required_text_must_belong_to_each_declared_hook_entry(tmp_path): + root_map = roots(tmp_path) + settings = root_map[Root.PROJECT] / ".demo/settings.json" + settings.parent.mkdir() + settings.write_text( + json.dumps( + { + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash|Grep", + "hooks": [{"command": "graphify hook-guard search"}], + }, + { + "matcher": "Read|Glob", + "hooks": [{"command": "wrong-command"}], + }, + ] + }, + "user_note": "graphify hook-guard", + } + ), + encoding="utf-8", + ) + effect = Effect( + kind=EffectKind.JSON, + root=Root.PROJECT, + path=".demo/settings.json", + entries={ + "hooks": { + "PreToolUse": [ + {"matcher": "Bash|Grep"}, + {"matcher": "Read|Glob"}, + ] + } + }, + required_text=("graphify", "hook-guard"), + ) + + results = validate_installed([effect], root_map, tmp_path) + + assert any( + result.check.endswith("JSON owned entry payloads") + and not result.passed + for result in results + ) + + +def test_owned_section_body_left_after_heading_removal_fails(tmp_path): + root_map = roots(tmp_path) + source = tmp_path / "source" + expected = source / "fixtures" / "notes.md" + expected.parent.mkdir(parents=True) + expected.write_text( + "## graphify\n\nrequired graph instructions\n", + encoding="utf-8", + ) + target = root_map[Root.PROJECT] / "notes.md" + target.write_text( + "# User notes\n\nkeep\n\nrequired graph instructions\n", + encoding="utf-8", + ) + effect = Effect( + kind=EffectKind.SECTION, + root=Root.PROJECT, + path="notes.md", + marker="## graphify", + source="fixtures/notes.md", + ) + + results = validate_removed([effect], root_map, source) + + assert any(not result.passed for result in results) + + +def test_partial_json_hook_cleanup_fails_removal_validation(tmp_path): + root_map = roots(tmp_path) + settings = root_map[Root.PROJECT] / ".demo/settings.json" + settings.parent.mkdir() + settings.write_text( + json.dumps( + { + "hooks": { + "PreToolUse": [ + {"matcher": "Read|Glob", "hooks": [{"command": "graphify"}]} + ] + }, + "user_owned": True, + } + ), + encoding="utf-8", + ) + effect = Effect( + kind=EffectKind.JSON, + root=Root.PROJECT, + path=".demo/settings.json", + entries={ + "hooks": { + "PreToolUse": [ + {"matcher": "Bash|Grep"}, + {"matcher": "Read|Glob"}, + ] + } + }, + ) + + results = validate_removed([effect], root_map) + + assert any(not result.passed for result in results) + + +def test_skill_frontmatter_mode_requires_declared_discovery_text(tmp_path): + root_map = roots(tmp_path) + source = tmp_path / "source" + skill_source = source / "graphify" / "skill.md" + skill_source.parent.mkdir(parents=True) + skill_source.write_text("# graphify skill\n", encoding="utf-8") + installed = root_map[Root.HOME] / ".demo/skills/graphify/SKILL.md" + installed.parent.mkdir(parents=True) + installed.write_bytes(skill_source.read_bytes()) + effect = Effect( + kind=EffectKind.SKILL, + root=Root.HOME, + path=".demo/skills/graphify/SKILL.md", + source="graphify/skill.md", + payload_mode="frontmatter-body", + required_text=("name: graphify-manager",), + ) + + results = validate_installed([effect], root_map, source) + + assert any( + result.check.endswith("requires text") and not result.passed + for result in results + ) diff --git a/tests/install_sandbox/test_lifecycle.py b/tests/install_sandbox/test_lifecycle.py new file mode 100644 index 000000000..65de90a3d --- /dev/null +++ b/tests/install_sandbox/test_lifecycle.py @@ -0,0 +1,482 @@ +import subprocess +import shutil +from pathlib import Path +from typing import cast + +import pytest + +from tools.install_sandbox.effects import REFERENCE_NAMES, resolve_effect +from tools.install_sandbox.lifecycle import ( + execute_command, + install_command, + run_scenario, + run_universal_uninstall_scenario, + scenario_steps, + uninstall_command, +) +from tools.install_sandbox.models import ( + CommandMode, + CommandResult, + Effect, + EffectKind, + Root, + SandboxRoots, + Scenario, + Scope, + ScopeSpec, + TargetSpec, +) + + +def make_roots(tmp_path): + values = { + name: tmp_path / name + for name in ( + "home", + "xdg", + "project", + "user_cwd", + "source", + "repo_mount", + "output", + ) + } + for path in values.values(): + path.mkdir() + return SandboxRoots(**values) + + +def progressive_scenario(): + effect = Effect( + kind=EffectKind.SKILL, + root=Root.PROJECT, + path=".demo/skills/graphify/SKILL.md", + source="graphify/skill.md", + reference_bundle="demo", + ) + target = TargetSpec( + name="demo", + scopes={Scope.PROJECT: ScopeSpec(effects=(effect,))}, + unsupported={Scope.USER: "test-only"}, + ) + return Scenario(target=target, scope=Scope.PROJECT) + + +def test_commands_and_lifecycle_order_derive_common_project_policy(): + scenario = progressive_scenario() + + assert install_command(scenario) == ( + "graphify", + "install", + "--project", + "--platform", + "demo", + ) + assert uninstall_command(scenario) == ( + "graphify", + "uninstall", + "--project", + "--platform", + "demo", + ) + assert scenario_steps(scenario) == ( + "install", + "reinstall", + "repair-progressive-sidecars", + "uninstall", + ) + + +@pytest.mark.parametrize("scope", list(Scope)) +def test_direct_command_modes_use_filename_target_and_verb(scope): + target = TargetSpec( + name="demo", + scopes={ + scope: ScopeSpec( + effects=(), + install_mode=CommandMode.DIRECT, + uninstall_mode=CommandMode.DIRECT, + ) + }, + unsupported={other: "test-only" for other in Scope if other is not scope}, + ) + scenario = Scenario(target=target, scope=scope) + + assert install_command(scenario) == ("graphify", "demo", "install") + assert uninstall_command(scenario) == ("graphify", "demo", "uninstall") + + +def test_omitted_command_modes_keep_scope_specific_generic_behavior(): + user = file_scenario("generic", Scope.USER) + project = file_scenario("generic", Scope.PROJECT) + + assert install_command(user) == ( + "graphify", + "install", + "--platform", + "generic", + ) + assert uninstall_command(user) is None + assert install_command(project) == ( + "graphify", + "install", + "--project", + "--platform", + "generic", + ) + assert uninstall_command(project) == ( + "graphify", + "uninstall", + "--project", + "--platform", + "generic", + ) + + +@pytest.mark.parametrize("verb", ["install", "uninstall"]) +def test_manually_constructed_invalid_command_modes_fail_closed(verb): + invalid_mode = cast(CommandMode, "direct") + scope_spec = ( + ScopeSpec(effects=(), install_mode=invalid_mode) + if verb == "install" + else ScopeSpec(effects=(), uninstall_mode=invalid_mode) + ) + scenario = Scenario( + target=TargetSpec( + name="demo", + scopes={Scope.PROJECT: scope_spec}, + unsupported={Scope.USER: "test-only"}, + ), + scope=Scope.PROJECT, + ) + + command_builder = install_command if verb == "install" else uninstall_command + with pytest.raises(ValueError, match=f"invalid {verb} command mode"): + command_builder(scenario) + + +def test_execute_command_decodes_timeout_output(tmp_path, monkeypatch): + def time_out(*_args, **_kwargs): + raise subprocess.TimeoutExpired( + ("graphify", "install"), + 1, + output=b"partial stdout\n", + stderr=b"partial stderr: \xff\n", + ) + + monkeypatch.setattr(subprocess, "run", time_out) + + result = execute_command( + ("graphify", "install"), + tmp_path, + {}, + tmp_path / "artifacts", + "install", + ) + + assert result.exit_code == 124 + assert result.timed_out + assert result.stdout == "partial stdout\n" + assert result.stderr == "partial stderr: \ufffd\n" + assert (tmp_path / "artifacts/install.stdout.log").read_text( + encoding="utf-8" + ) == result.stdout + assert (tmp_path / "artifacts/install.stderr.log").read_text( + encoding="utf-8" + ) == result.stderr + + +def test_full_lifecycle_is_idempotent_repairs_sidecars_and_preserves_user_content( + tmp_path, +): + roots = make_roots(tmp_path) + scenario = progressive_scenario() + skill_source = roots.source / "graphify/skill.md" + refs_source = roots.source / "graphify/skills/demo/references" + refs_source.mkdir(parents=True) + skill_source.parent.mkdir(parents=True, exist_ok=True) + skill_source.write_text( + "\n".join(f"(references/{name})" for name in REFERENCE_NAMES), + encoding="utf-8", + ) + for name in REFERENCE_NAMES: + (refs_source / name).write_text(name, encoding="utf-8") + + def fake_executor(argv, cwd, env, artifact_dir, label): + effect = scenario.contract.effects[0] + skill = resolve_effect(effect, roots.effect_roots()) + if "uninstall" in argv: + skill.unlink(missing_ok=True) + (skill.parent / ".graphify_version").unlink(missing_ok=True) + shutil.rmtree(skill.parent / "references", ignore_errors=True) + shutil.rmtree(skill.parent / "references.tmp", ignore_errors=True) + else: + skill.parent.mkdir(parents=True, exist_ok=True) + skill.write_bytes(skill_source.read_bytes()) + (skill.parent / ".graphify_version").write_text( + "1.0", encoding="utf-8" + ) + shutil.rmtree(skill.parent / "references", ignore_errors=True) + shutil.rmtree(skill.parent / "references.tmp", ignore_errors=True) + shutil.copytree(refs_source, skill.parent / "references") + return CommandResult(tuple(argv), str(cwd), 0, "", "") + + result = run_scenario(scenario, roots, executor=fake_executor) + + assert result.status == "PASS" + assert [phase.name for phase in result.phases] == [ + "install", + "reinstall", + "repair-progressive-sidecars", + "uninstall", + ] + assert all( + check.passed for phase in result.phases for check in phase.validations + ) + assert (roots.project / "user-owned.txt").read_text(encoding="utf-8") + assert not ( + roots.project / ".demo/skills/graphify/references.tmp" + ).exists() + assert (roots.output / "scenarios/demo-project/result.json").is_file() + + +def test_lifecycle_reports_not_applicable_user_uninstall(tmp_path): + roots = make_roots(tmp_path) + source = roots.source / "owned.txt" + source.write_text("owned", encoding="utf-8") + effect = Effect( + kind=EffectKind.FILE, + root=Root.HOME, + path="owned.txt", + source="owned.txt", + ) + target = TargetSpec( + name="demo", + scopes={Scope.USER: ScopeSpec(effects=(effect,))}, + unsupported={Scope.PROJECT: "test-only"}, + ) + scenario = Scenario(target=target, scope=Scope.USER) + + def fake_executor(argv, cwd, env, artifact_dir, label): + (roots.home / "owned.txt").write_text("owned", encoding="utf-8") + return CommandResult(tuple(argv), str(cwd), 0, "", "") + + result = run_scenario(scenario, roots, executor=fake_executor) + + assert result.status == "PASS" + assert result.phases[-1].status == "NOT_APPLICABLE" + assert (roots.home / "user-owned.txt").is_file() + + +def file_scenario(name, scope=Scope.PROJECT): + root = Root.PROJECT if scope is Scope.PROJECT else Root.HOME + effect = Effect( + kind=EffectKind.FILE, + root=root, + path=f".{name}/graphify.md", + source=f"{name}.md", + ) + target = TargetSpec( + name=name, + scopes={scope: ScopeSpec(effects=(effect,))}, + unsupported={ + other: "test-only" + for other in Scope + if other is not scope + }, + ) + return Scenario(target=target, scope=scope) + + +def test_lifecycle_rejects_stable_undeclared_filesystem_effect(tmp_path): + roots = make_roots(tmp_path) + scenario = file_scenario("demo") + source = roots.source / "demo.md" + source.write_text("owned", encoding="utf-8") + + def fake_executor(argv, cwd, env, artifact_dir, label): + effect_path = resolve_effect( + scenario.contract.effects[0], + roots.effect_roots(), + ) + if "uninstall" in argv: + effect_path.unlink(missing_ok=True) + else: + effect_path.parent.mkdir(parents=True, exist_ok=True) + effect_path.write_text("owned", encoding="utf-8") + (roots.project / ".legacy/graphify.md").parent.mkdir( + parents=True, + exist_ok=True, + ) + (roots.project / ".legacy/graphify.md").write_text( + "unexpected", + encoding="utf-8", + ) + return CommandResult(tuple(argv), str(cwd), 0, "", "") + + result = run_scenario(scenario, roots, executor=fake_executor) + + assert result.status == "FAIL" + assert any( + check.check == "filesystem changes stay within declared effects" + and not check.passed + for phase in result.phases + for check in phase.validations + ) + + +def test_lifecycle_stops_after_failed_initial_install_command(tmp_path): + roots = make_roots(tmp_path) + scenario = file_scenario("demo") + (roots.source / "demo.md").write_text("owned", encoding="utf-8") + calls = [] + + def failing_executor(argv, cwd, env, artifact_dir, label): + calls.append(label) + return CommandResult(tuple(argv), str(cwd), 1, "", "failed") + + result = run_scenario(scenario, roots, executor=failing_executor) + + assert result.status == "FAIL" + assert calls == ["install"] + assert [phase.name for phase in result.phases] == ["install"] + + +def test_grouped_user_uninstall_removes_all_installed_effects(tmp_path): + roots = make_roots(tmp_path) + scenarios = [ + file_scenario("first", Scope.USER), + file_scenario("second", Scope.USER), + ] + for item in scenarios: + (roots.source / f"{item.target.name}.md").write_text( + item.target.name, + encoding="utf-8", + ) + + def fake_executor(argv, cwd, env, artifact_dir, label): + if tuple(argv) == ("graphify", "uninstall"): + for item in scenarios: + resolve_effect( + item.contract.effects[0], + roots.effect_roots(), + ).unlink(missing_ok=True) + else: + item = next( + scenario + for scenario in scenarios + if scenario.target.name == argv[-1] + ) + path = resolve_effect( + item.contract.effects[0], + roots.effect_roots(), + ) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(item.target.name, encoding="utf-8") + return CommandResult(tuple(argv), str(cwd), 0, "", "") + + result = run_universal_uninstall_scenario( + scenarios, + roots, + executor=fake_executor, + ) + + assert result.status == "PASS" + assert result.phases[-1].name == "uninstall" + assert (roots.project / "graphify-out/graph.json").is_file() + assert all( + not resolve_effect(item.contract.effects[0], roots.effect_roots()).exists() + for item in scenarios + ) + + +def test_grouped_project_uninstall_detects_user_scope_removal(tmp_path): + roots = make_roots(tmp_path) + project = file_scenario("project-demo", Scope.PROJECT) + user = file_scenario("user-demo", Scope.USER) + for item in (project, user): + (roots.source / f"{item.target.name}.md").write_text( + item.target.name, + encoding="utf-8", + ) + + def fake_executor(argv, cwd, env, artifact_dir, label): + if tuple(argv) == ("graphify", "uninstall", "--project"): + for item in (project, user): + resolve_effect( + item.contract.effects[0], + roots.effect_roots(), + ).unlink(missing_ok=True) + else: + item = user if argv[-1] == user.target.name else project + path = resolve_effect( + item.contract.effects[0], + roots.effect_roots(), + ) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(item.target.name, encoding="utf-8") + return CommandResult(tuple(argv), str(cwd), 0, "", "") + + result = run_universal_uninstall_scenario( + [project], + roots, + preserved_scenarios=[user], + executor=fake_executor, + ) + + assert result.status == "FAIL" + assert any( + check.check == "filesystem changes stay within declared effects" + and not check.passed + for check in result.phases[-1].validations + ) + + +def test_failed_preserved_user_setup_does_not_cascade_into_later_phases(tmp_path): + roots = make_roots(tmp_path) + project = file_scenario("project-demo", Scope.PROJECT) + failed_user = file_scenario("failed-user", Scope.USER) + healthy_user = file_scenario("healthy-user", Scope.USER) + scenarios = (project, failed_user, healthy_user) + for item in scenarios: + (roots.source / f"{item.target.name}.md").write_text( + item.target.name, + encoding="utf-8", + ) + + def fake_executor(argv, cwd, env, artifact_dir, label): + if label == "prepare-user-failed-user": + return CommandResult(tuple(argv), str(cwd), 1, "", "failed") + if label == "uninstall": + resolve_effect( + project.contract.effects[0], + roots.effect_roots(), + ).unlink(missing_ok=True) + else: + item = next( + scenario + for scenario in scenarios + if scenario.target.name == argv[-1] + ) + path = resolve_effect( + item.contract.effects[0], + roots.effect_roots(), + ) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(item.target.name, encoding="utf-8") + return CommandResult(tuple(argv), str(cwd), 0, "", "") + + result = run_universal_uninstall_scenario( + [project], + roots, + preserved_scenarios=[failed_user, healthy_user], + executor=fake_executor, + ) + + assert result.status == "FAIL" + assert [(phase.name, phase.status) for phase in result.phases] == [ + ("prepare-user-failed-user", "FAIL"), + ("prepare-user-healthy-user", "PASS"), + ("install-project-demo", "PASS"), + ("uninstall", "PASS"), + ] diff --git a/tests/install_sandbox/test_real_catalog.py b/tests/install_sandbox/test_real_catalog.py new file mode 100644 index 000000000..1057f5f76 --- /dev/null +++ b/tests/install_sandbox/test_real_catalog.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path +from time import perf_counter +from typing import Callable + +from tools.install_sandbox.specs import load_catalog + + +REPOSITORY = Path(__file__).resolve().parents[2] +SPEC_DIRECTORY = REPOSITORY / "tools" / "install_sandbox" / "specs" +SEPARATELY_EXPOSED_INSTALL_TARGETS = {"vscode"} + + +def _published_install_targets(help_text: str) -> set[str]: + prefix = "Platforms:" + platforms_line = next( + (line for line in help_text.splitlines() if line.startswith(prefix)), + None, + ) + assert platforms_line is not None, ( + "graphify install --help did not publish a Platforms line" + ) + return { + name.strip() + for name in platforms_line.removeprefix(prefix).split(",") + if name.strip() + } + + +def test_checked_in_catalog_matches_current_checkout_install_help( + record_property: Callable[[str, object], None], +) -> None: + started = perf_counter() + catalog = load_catalog(SPEC_DIRECTORY) + help_result = subprocess.run( + [sys.executable, "-m", "graphify", "install", "--help"], + cwd=REPOSITORY, + check=True, + capture_output=True, + text=True, + ) + elapsed = perf_counter() - started + record_property("real_catalog_contract_seconds", elapsed) + + spec_targets = set(catalog) + published_targets = ( + _published_install_targets(help_result.stdout) + | SEPARATELY_EXPOSED_INSTALL_TARGETS + ) + missing_specs = sorted(published_targets - spec_targets) + stale_specs = sorted(spec_targets - published_targets) + + diagnostics = [] + if missing_specs: + diagnostics.append( + "missing specs for published install targets: " + + ", ".join(missing_specs) + ) + if stale_specs: + diagnostics.append( + "stale specs without published install targets: " + + ", ".join(stale_specs) + ) + assert not diagnostics, "\n".join(diagnostics) diff --git a/tests/install_sandbox/test_reporting_cli.py b/tests/install_sandbox/test_reporting_cli.py new file mode 100644 index 000000000..4111b82b3 --- /dev/null +++ b/tests/install_sandbox/test_reporting_cli.py @@ -0,0 +1,207 @@ +import json +import signal +from pathlib import Path + +import pytest + +from tools.install_sandbox import run as run_module +from tools.install_sandbox.models import PhaseResult, ScenarioResult +from tools.install_sandbox.reporting import ( + build_manifest, + render_report, + write_run_outputs, +) +from tools.install_sandbox.run import RunInterrupted, classify_result, main, parser + + +def _graphify_repo(tmp_path: Path) -> Path: + repo = tmp_path / "repo" + (repo / "graphify").mkdir(parents=True) + (repo / "pyproject.toml").write_text("[project]\nname='fixture'\n", encoding="utf-8") + return repo + + +def _valid_spec_dir(tmp_path: Path) -> Path: + spec_dir = tmp_path / "specs" + spec_dir.mkdir() + (spec_dir / "demo.yaml").write_text( + """ +scopes: {} +unsupported: + user: User scope is unavailable in this fixture. + project: Project scope is unavailable in this fixture. +""".lstrip(), + encoding="utf-8", + ) + return spec_dir + + +def test_public_cli_has_exact_selection_and_scope_defaults(tmp_path): + args = parser(("demo", "generic")).parse_args( + ["--repo", str(tmp_path), "--target", "demo"] + ) + + assert args.target == "demo" + assert args.all_targets is False + assert args.scope == "both" + assert args.output is None + + +def test_cli_rejects_non_graphify_repo_before_docker(tmp_path, capsys): + spec_dir = tmp_path / "specs" + spec_dir.mkdir() + + assert main( + ["--repo", str(tmp_path), "--all"], + spec_dir=spec_dir, + ) == 2 + assert "not a Graphify source checkout" in capsys.readouterr().err + + +@pytest.mark.parametrize( + ("raw_exit", "write_outputs", "expected_state", "expected_exit"), + [ + (0, True, "passed", 0), + (1, True, "failed", 1), + (127, False, "incomplete", 127), + (0, False, "incomplete", 2), + ], +) +def test_host_runner_classifies_complete_and_incomplete_results( + tmp_path, + monkeypatch, + raw_exit, + write_outputs, + expected_state, + expected_exit, +): + repo = _graphify_repo(tmp_path) + spec_dir = _valid_spec_dir(tmp_path) + output = tmp_path / "output" + + def fake_run_sandbox(**arguments): + arguments["on_phase"]("docker_build") + arguments["on_output"]( + "docker_build", + "stdout", + "synthetic build output\n", + ) + arguments["on_phase"]("container") + if write_outputs: + (arguments["output"] / "manifest.json").write_text( + "{}\n", + encoding="utf-8", + ) + (arguments["output"] / "report.md").write_text( + "# report\n", + encoding="utf-8", + ) + return raw_exit + + monkeypatch.setattr(run_module, "run_sandbox", fake_run_sandbox) + + exit_code = main( + ["--repo", str(repo), "--all", "--output", str(output)], + spec_dir=spec_dir, + ) + + metadata = json.loads((output / "run.json").read_text(encoding="utf-8")) + assert exit_code == expected_exit + assert metadata["state"] == expected_state + assert metadata["exit_code"] == expected_exit + assert metadata["phase"] == "container" + assert "[docker_build] [stdout] synthetic build output" in ( + output / "runner.log" + ).read_text(encoding="utf-8") + + +def test_catalog_failure_has_host_diagnostics_before_docker(tmp_path, monkeypatch): + repo = _graphify_repo(tmp_path) + spec_dir = tmp_path / "specs" + spec_dir.mkdir() + (spec_dir / "broken.yaml").write_text("unknown: true\n", encoding="utf-8") + output = tmp_path / "output" + called = False + + def fake_run_sandbox(**_arguments): + nonlocal called + called = True + return 0 + + monkeypatch.setattr(run_module, "run_sandbox", fake_run_sandbox) + + exit_code = main( + ["--repo", str(repo), "--all", "--output", str(output)], + spec_dir=spec_dir, + ) + + metadata = json.loads((output / "run.json").read_text(encoding="utf-8")) + assert exit_code == 2 + assert called is False + assert metadata["state"] == "incomplete" + assert "catalog preflight failed" in (output / "runner.log").read_text( + encoding="utf-8" + ) + + +def test_caught_signal_uses_conventional_exit_code_and_interrupted_state( + tmp_path, + monkeypatch, +): + repo = _graphify_repo(tmp_path) + spec_dir = _valid_spec_dir(tmp_path) + output = tmp_path / "output" + + def interrupt_run(**_arguments): + raise RunInterrupted(signal.SIGTERM) + + monkeypatch.setattr(run_module, "run_sandbox", interrupt_run) + + exit_code = main( + ["--repo", str(repo), "--all", "--output", str(output)], + spec_dir=spec_dir, + ) + + metadata = json.loads((output / "run.json").read_text(encoding="utf-8")) + assert exit_code == 143 + assert metadata["state"] == "interrupted" + assert metadata["exit_code"] == 143 + + +def test_result_classification_preserves_raw_nonzero_codes(): + assert classify_result(0, complete=True) == ("passed", 0) + assert classify_result(1, complete=True) == ("failed", 1) + assert classify_result(124, complete=False) == ("incomplete", 124) + assert classify_result(0, complete=False) == ("incomplete", 2) + + +def test_reporting_is_concise_and_writes_only_top_level_contract_files(tmp_path): + result = ScenarioResult( + scenario="demo-project", + target="demo", + scope="project", + status="PASS", + phases=[ + PhaseResult(name="install", status="PASS"), + PhaseResult(name="uninstall", status="PASS"), + ], + artifact_dir="scenarios/demo-project", + ) + manifest = build_manifest( + repo=Path("/repo"), + selection={"target": "demo", "all": False, "scope": "project"}, + package={"version": "graphify 1.0"}, + results=[result], + purge={"status": "PASS"}, + ) + + report = render_report(manifest) + write_run_outputs(tmp_path, manifest) + + assert "demo-project" in report + assert "PASS=1" in report + assert len(report.splitlines()) < 20 + assert {item.name for item in tmp_path.iterdir()} == { + "manifest.json", + "report.md", + } diff --git a/tests/install_sandbox/test_run_artifacts.py b/tests/install_sandbox/test_run_artifacts.py new file mode 100644 index 000000000..6afd52359 --- /dev/null +++ b/tests/install_sandbox/test_run_artifacts.py @@ -0,0 +1,442 @@ +from __future__ import annotations + +import json +import os +import shutil +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest + +from tools.install_sandbox import run_artifacts +from tools.install_sandbox.run_artifacts import ( + ArtifactError, + RunArtifacts, + complete_outputs, + make_run_id, + prune_managed_runs, +) + + +NOW = datetime(2026, 7, 26, 12, 34, 56, tzinfo=timezone.utc) + + +def fixed_clock() -> datetime: + return NOW + + +def read_metadata(output: Path) -> dict: + return json.loads((output / "run.json").read_text(encoding="utf-8")) + + +def allocate(tmp_path: Path, **overrides) -> RunArtifacts: + arguments = { + "repo": tmp_path / "repo", + "target": "demo", + "all_targets": False, + "scope": "both", + "managed_root": tmp_path / "managed", + "clock": fixed_clock, + } + arguments.update(overrides) + return RunArtifacts.allocate(**arguments) + + +def write_managed_run( + root: Path, + *, + stamp: datetime, + collision: int = 1, + state: str = "passed", + managed: bool = True, +) -> Path: + run_id = make_run_id( + target="demo", + all_targets=False, + scope="both", + started_at=stamp, + collision=collision, + ) + output = root / run_id + output.mkdir(parents=True) + finished = stamp + timedelta(minutes=1) + metadata = { + "schema_version": 1, + "run_id": run_id, + "managed": managed, + "started_at": stamp.isoformat().replace("+00:00", "Z"), + "updated_at": finished.isoformat().replace("+00:00", "Z"), + "finished_at": ( + None if state == "running" else finished.isoformat().replace("+00:00", "Z") + ), + "repository": str(root.parent / "repo"), + "output": str(output.resolve()), + "selection": {"all": False, "target": "demo", "scope": "both"}, + "phase": "container_run", + "state": state, + "exit_code": None if state == "running" else 0, + } + (output / "run.json").write_text( + json.dumps(metadata), + encoding="utf-8", + ) + return output + + +def test_run_ids_use_utc_basic_iso_selection_scope_and_numeric_collisions(): + local_time = datetime( + 2026, + 7, + 26, + 8, + 34, + 56, + tzinfo=timezone(timedelta(hours=-4)), + ) + + assert ( + make_run_id( + target=None, + all_targets=True, + scope="project", + started_at=local_time, + ) + == "20260726T123456Z-all-project" + ) + assert ( + make_run_id( + target="demo", + all_targets=False, + scope="both", + started_at=NOW, + collision=2, + ) + == "20260726T123456Z-demo-both-02" + ) + assert ( + make_run_id( + target="demo", + all_targets=False, + scope="both", + started_at=NOW, + collision=100, + ) + == "20260726T123456Z-demo-both-100" + ) + + +def test_allocation_creates_host_artifacts_and_complete_running_metadata(tmp_path): + artifacts = allocate(tmp_path) + + assert artifacts.output.name == "20260726T123456Z-demo-both" + assert artifacts.managed is True + assert (artifacts.output / "runner.log").is_file() + assert artifacts.logger.closed is False + assert read_metadata(artifacts.output) == { + "schema_version": 1, + "run_id": artifacts.run_id, + "managed": True, + "started_at": "2026-07-26T12:34:56Z", + "updated_at": "2026-07-26T12:34:56Z", + "finished_at": None, + "repository": str((tmp_path / "repo").resolve()), + "output": str(artifacts.output.resolve()), + "selection": {"all": False, "target": "demo", "scope": "both"}, + "phase": "host_preflight", + "state": "running", + "exit_code": None, + } + artifacts.logger.close() + + +def test_same_second_managed_allocations_claim_numeric_collisions_atomically(tmp_path): + root = tmp_path / "managed" + + def allocate_one(_: int) -> RunArtifacts: + return RunArtifacts.allocate( + repo=tmp_path / "repo", + target="demo", + all_targets=False, + scope="both", + managed_root=root, + clock=fixed_clock, + ) + + with ThreadPoolExecutor(max_workers=8) as executor: + artifacts = list(executor.map(allocate_one, range(12))) + + names = sorted(item.output.name for item in artifacts) + assert len(set(names)) == 12 + assert "20260726T123456Z-demo-both" in names + assert "20260726T123456Z-demo-both-02" in names + assert "20260726T123456Z-demo-both-12" in names + for item in artifacts: + assert read_metadata(item.output)["run_id"] == item.output.name + item.logger.close() + + +@pytest.mark.parametrize("precreate", [False, True]) +def test_external_output_may_be_absent_or_an_empty_real_directory(tmp_path, precreate): + output = tmp_path / "external" / "leaf" + if precreate: + output.mkdir(parents=True) + + artifacts = allocate(tmp_path, output=output) + + assert artifacts.output == output.resolve() + assert artifacts.managed is False + assert read_metadata(output)["managed"] is False + artifacts.logger.close() + + +def test_external_output_rejects_non_empty_directory_file_and_symlink(tmp_path): + non_empty = tmp_path / "non-empty" + non_empty.mkdir() + (non_empty / "stale").write_text("old", encoding="utf-8") + file_output = tmp_path / "file" + file_output.write_text("not a directory", encoding="utf-8") + real_output = tmp_path / "real" + real_output.mkdir() + symlink_output = tmp_path / "link" + symlink_output.symlink_to(real_output, target_is_directory=True) + + with pytest.raises(ArtifactError, match="must be empty"): + allocate(tmp_path, output=non_empty) + with pytest.raises(ArtifactError, match="not a real directory"): + allocate(tmp_path, output=file_output) + with pytest.raises(ArtifactError, match="must not be a symlink"): + allocate(tmp_path, output=symlink_output) + + +@pytest.mark.parametrize("relative", [".", "manual", "nested/manual"]) +def test_explicit_output_must_be_separate_from_managed_root(tmp_path, relative): + managed_root = tmp_path / "managed" + output = managed_root / relative + + with pytest.raises(ArtifactError, match="outside the managed output root"): + allocate(tmp_path, managed_root=managed_root, output=output) + + +def test_explicit_output_logically_beneath_managed_root_cannot_escape_via_symlink( + tmp_path, +): + managed_root = tmp_path / "managed" + managed_root.mkdir() + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + (managed_root / "escape").symlink_to(elsewhere, target_is_directory=True) + + with pytest.raises(ArtifactError, match="outside the managed output root"): + allocate( + tmp_path, + managed_root=managed_root, + output=managed_root / "escape" / "leaf", + ) + + +def test_set_phase_replaces_run_json_with_a_complete_document(tmp_path, monkeypatch): + artifacts = allocate(tmp_path) + metadata_path = artifacts.output / "run.json" + original_replace = run_artifacts.os.replace + observations = [] + + def observing_replace(source, destination): + source_value = json.loads(Path(source).read_text(encoding="utf-8")) + destination_value = json.loads(Path(destination).read_text(encoding="utf-8")) + observations.append((source_value, destination_value)) + original_replace(source, destination) + + monkeypatch.setattr(run_artifacts.os, "replace", observing_replace) + + artifacts.set_phase("docker_build") + + assert observations[0][0]["phase"] == "docker_build" + assert observations[0][1]["phase"] == "host_preflight" + assert read_metadata(artifacts.output)["phase"] == "docker_build" + assert list(artifacts.output.glob(".run.json.*.tmp")) == [] + artifacts.logger.close() + + +@pytest.mark.parametrize( + ("state", "exit_code"), + [ + ("passed", 0), + ("failed", 1), + ("incomplete", 127), + ("interrupted", 130), + ], +) +def test_running_run_transitions_to_every_terminal_state(tmp_path, state, exit_code): + artifacts = allocate(tmp_path) + + artifacts.finalize(state, exit_code) + + metadata = read_metadata(artifacts.output) + assert metadata["state"] == state + assert metadata["exit_code"] == exit_code + assert metadata["finished_at"] == "2026-07-26T12:34:56Z" + assert artifacts.logger.closed is True + with pytest.raises(RuntimeError, match="already been finalized"): + artifacts.finalize(state, exit_code) + + +def test_phase_logger_labels_file_and_mirrored_console_output(tmp_path, capsys): + artifacts = allocate(tmp_path) + + artifacts.logger.write("command", "$ preflight") + artifacts.logger.write("stdout", "preflight ok") + artifacts.set_phase("docker_build") + artifacts.logger.write("stderr", "build warning\nsecond line") + artifacts.logger.close() + + captured = capsys.readouterr() + log = (artifacts.output / "runner.log").read_text(encoding="utf-8") + assert "[host_preflight] [command] $ preflight" in captured.out + assert "[host_preflight] [stdout] preflight ok" in captured.out + assert "[docker_build] [stderr] build warning" in captured.err + assert "[docker_build] [stderr] second line" in captured.err + assert "[host_preflight] [stdout] preflight ok" in log + assert "[docker_build] [stderr] build warning" in log + + +def test_complete_outputs_require_fresh_non_empty_regular_non_symlink_files(tmp_path): + output = tmp_path / "output" + output.mkdir() + start = NOW + manifest = output / "manifest.json" + report = output / "report.md" + manifest.write_text("{}\n", encoding="utf-8") + report.write_text("# Report\n", encoding="utf-8") + fresh = start.timestamp() + 1 + os.utime(manifest, (fresh, fresh)) + os.utime(report, (fresh, fresh)) + + assert complete_outputs(output, start) is True + + stale = start.timestamp() - 1 + os.utime(report, (stale, stale)) + assert complete_outputs(output, start) is False + + report.unlink() + report.write_text("", encoding="utf-8") + os.utime(report, (fresh, fresh)) + assert complete_outputs(output, start) is False + + report.unlink() + real_report = output / "real-report.md" + real_report.write_text("# Report\n", encoding="utf-8") + os.utime(real_report, (fresh, fresh)) + report.symlink_to(real_report) + assert complete_outputs(output, start) is False + + +def test_pruning_counts_all_terminal_states_equally_and_keeps_newest_five(tmp_path): + root = tmp_path / "managed" + states = ["passed", "failed", "incomplete", "interrupted", "passed", "failed", "passed"] + runs = [ + write_managed_run( + root, + stamp=NOW + timedelta(minutes=index), + state=state, + ) + for index, state in enumerate(states) + ] + + removed = prune_managed_runs(root, keep=5, warn=lambda message: None) + + assert set(removed) == set(runs[:2]) + assert all(not path.exists() for path in runs[:2]) + assert all(path.is_dir() for path in runs[2:]) + + +def test_pruning_uses_numeric_collision_order_for_same_second_runs(tmp_path): + root = tmp_path / "managed" + runs = [ + write_managed_run(root, stamp=NOW, collision=collision) + for collision in range(1, 8) + ] + + removed = prune_managed_runs(root, keep=5, warn=lambda message: None) + + assert set(removed) == set(runs[:2]) + + +def test_pruning_preserves_ambiguous_unowned_active_and_symlink_entries(tmp_path): + root = tmp_path / "managed" + root.mkdir() + valid_runs = [ + write_managed_run(root, stamp=NOW + timedelta(minutes=index + 10)) + for index in range(6) + ] + running = write_managed_run(root, stamp=NOW, state="running") + + malformed = root / "malformed" + malformed.mkdir() + (malformed / "run.json").write_text("{", encoding="utf-8") + + unreadable = root / "unreadable" + unreadable.mkdir() + (unreadable / "run.json").write_bytes(b"\xff") + + unmarked = root / "unmarked" + unmarked.mkdir() + + external = write_managed_run( + root, + stamp=NOW + timedelta(minutes=1), + managed=False, + ) + + symlink_target = tmp_path / "symlink-target" + symlink_target.mkdir() + (symlink_target / "sentinel").write_text("keep", encoding="utf-8") + symlink = root / "20260726T123456Z-demo-both-99" + symlink.symlink_to(symlink_target, target_is_directory=True) + warnings = [] + + removed = prune_managed_runs(root, keep=5, warn=warnings.append) + + assert removed == (valid_runs[0],) + for preserved in (running, malformed, unreadable, unmarked, external, symlink): + assert preserved.exists() + assert (symlink_target / "sentinel").read_text(encoding="utf-8") == "keep" + assert any("running managed run" in warning for warning in warnings) + assert any("malformed run metadata" in warning for warning in warnings) + assert any("unreadable run metadata" in warning for warning in warnings) + assert any("unmarked managed-root directory" in warning for warning in warnings) + assert any("externally owned run" in warning for warning in warnings) + assert any("symlinked managed-root entry" in warning for warning in warnings) + + +def test_pruning_tolerates_a_concurrent_removal_race(tmp_path, monkeypatch): + root = tmp_path / "managed" + runs = [ + write_managed_run(root, stamp=NOW + timedelta(minutes=index)) + for index in range(2) + ] + original_rmtree = shutil.rmtree + + def racing_rmtree(path): + original_rmtree(path) + raise FileNotFoundError(path) + + monkeypatch.setattr(run_artifacts.shutil, "rmtree", racing_rmtree) + + assert prune_managed_runs(root, keep=1, warn=lambda message: None) == () + assert runs[0].exists() is False + assert runs[1].exists() is True + + +def test_pruning_does_not_touch_external_output(tmp_path): + managed_root = tmp_path / "managed" + external_output = tmp_path / "external" + artifacts = allocate( + tmp_path, + managed_root=managed_root, + output=external_output, + ) + artifacts.finalize("passed", 0) + + assert prune_managed_runs(managed_root, keep=0) == () + assert external_output.is_dir() diff --git a/tests/install_sandbox/test_specs.py b/tests/install_sandbox/test_specs.py new file mode 100644 index 000000000..caa7769a3 --- /dev/null +++ b/tests/install_sandbox/test_specs.py @@ -0,0 +1,268 @@ +from pathlib import Path + +import pytest + +from tools.install_sandbox.models import CommandMode, EffectKind, Scope +from tools.install_sandbox.specs import ( + SpecError, + catalog_names, + load_catalog, + load_target, +) + + +@pytest.fixture +def fictional_spec_dir(tmp_path: Path) -> Path: + specs = tmp_path / "specs" + specs.mkdir() + (specs / "archive.yaml").write_text( + """ +limitations: + - Synthetic target used to exercise unsupported scopes. +universal_uninstall_scopes: [user] +unsupported: + project: Project installation is unavailable in this fixture. +scopes: + user: + effects: + - kind: text + root: home + path: .archive/notice.txt + required_text: [present] + forbidden_text: [absent] +""".lstrip(), + encoding="utf-8", + ) + (specs / "demo.yaml").write_text( + """ +universal_uninstall_scopes: [user, project] +scopes: + user: + install_mode: direct + uninstall_mode: direct + effects: + - root: home + path: .demo/config.txt + source: fixtures/demo-user.txt + project: + install_mode: direct + uninstall_mode: direct + effects: + - root: project + path: .demo/config.txt + source: fixtures/demo-project.txt +""".lstrip(), + encoding="utf-8", + ) + (specs / "generic.yaml").write_text( + """ +universal_uninstall_scopes: [project] +scopes: + user: + effects: + - kind: text + root: home + path: .generic/instructions.txt + required_text: [enabled] + forbidden_text: [disabled] + project: + effects: + - root: project + path: .generic/config.txt + source: fixtures/generic-project.txt +""".lstrip(), + encoding="utf-8", + ) + return specs + + +def test_fictional_catalog_loads_modes_defaults_oracles_and_scope_declarations( + fictional_spec_dir: Path, +): + catalog = load_catalog(fictional_spec_dir) + + assert tuple(catalog) == ("archive", "demo", "generic") + assert catalog_names(fictional_spec_dir) == ("archive", "demo", "generic") + + demo = catalog["demo"] + for scope in Scope: + assert demo.supports(scope) + assert demo.scopes[scope].install_mode is CommandMode.DIRECT + assert demo.scopes[scope].uninstall_mode is CommandMode.DIRECT + assert demo.scopes[scope].effects[0].kind is EffectKind.FILE + + generic = catalog["generic"] + for scope in Scope: + assert generic.supports(scope) + assert generic.scopes[scope].install_mode is None + assert generic.scopes[scope].uninstall_mode is None + assert generic.scopes[Scope.USER].effects[0].required_text == ("enabled",) + assert generic.scopes[Scope.USER].effects[0].forbidden_text == ("disabled",) + assert generic.scopes[Scope.PROJECT].effects[0].kind is EffectKind.FILE + + archive = catalog["archive"] + assert archive.supports(Scope.USER) + assert not archive.supports(Scope.PROJECT) + assert archive.unsupported == { + Scope.PROJECT: "Project installation is unavailable in this fixture." + } + assert archive.limitations == ( + "Synthetic target used to exercise unsupported scopes.", + ) + + +def test_aggregate_uninstall_groups_come_from_fictional_specs( + fictional_spec_dir: Path, +): + catalog = load_catalog(fictional_spec_dir) + + selected = { + scope: { + name + for name, target in catalog.items() + if scope in target.universal_uninstall_scopes + } + for scope in Scope + } + + assert selected == { + Scope.USER: {"archive", "demo"}, + Scope.PROJECT: {"demo", "generic"}, + } + + +def test_real_catalog_declares_settings_backups_owned_by_current_installer(): + spec_dir = Path("tools/install_sandbox/specs") + catalog = load_catalog(spec_dir) + declared = { + (target.name, scope.value, effect.path) + for target in catalog.values() + for scope, scope_spec in target.scopes.items() + for effect in scope_spec.effects + if effect.preserves_backup + } + + assert declared == { + ("claude", "project", ".claude/settings.json"), + ("codebuddy", "project", ".codebuddy/settings.json"), + ("codebuddy", "user", ".codebuddy/settings.json"), + ("codex", "project", ".codex/hooks.json"), + ("gemini", "project", ".gemini/settings.json"), + ("gemini", "user", ".gemini/settings.json"), + ("windows", "project", ".claude/settings.json"), + } + + +@pytest.mark.parametrize( + ("body", "message"), + [ + ( + "mystery: true\nscopes:\n user:\n effects: []\n", + "unknown keys", + ), + ( + "unsupported:\n project: no\nscopes:\n user:\n effects:\n" + " - {root: nowhere, path: ok}\n", + "invalid", + ), + ( + "unsupported:\n project: no\nscopes:\n user:\n effects:\n" + " - {root: home, path: ../escape}\n", + "safe relative", + ), + ( + "unsupported:\n project: no\nscopes:\n user:\n effects:\n" + " - {kind: section, root: home, path: notes.md, " + "marker: '## graphify'}\n", + "require source or required_text", + ), + ( + "universal_uninstall_scopes: [project]\n" + "unsupported:\n project: unavailable\n" + "scopes:\n user:\n effects:\n" + " - {root: home, path: x}\n", + "not supported", + ), + ( + "unsupported:\n project: no\nscopes:\n user:\n effects:\n" + " - {kind: file, root: home, path: x, " + "preserves_backup: true}\n", + "only valid for JSON", + ), + ( + "unsupported:\n project: no\nscopes:\n user:\n effects:\n" + " - {kind: json, root: home, path: x, " + "entries: {owned: true}, preserves_backup: 'yes'}\n", + "must be a boolean", + ), + ], +) +def test_loader_rejects_unknown_keys_bad_roots_and_unsafe_paths( + tmp_path: Path, + body: str, + message: str, +): + spec = tmp_path / "sample.yaml" + spec.write_text(body, encoding="utf-8") + + with pytest.raises(SpecError, match=message): + load_target(spec) + + +@pytest.mark.parametrize("field", ["install_mode", "uninstall_mode"]) +@pytest.mark.parametrize( + "value", + [ + "null", + "[graphify, demo, install]", + "unknown", + "Direct", + "17", + "true", + "{mode: direct}", + ], +) +def test_loader_rejects_every_present_non_direct_command_mode( + tmp_path: Path, + field: str, + value: str, +): + spec = tmp_path / "sample.yaml" + spec.write_text( + ( + "unsupported:\n" + " project: unavailable\n" + "scopes:\n" + " user:\n" + f" {field}: {value}\n" + " effects:\n" + " - {root: home, path: fixture.txt}\n" + ), + encoding="utf-8", + ) + + with pytest.raises(SpecError, match=field): + load_target(spec) + + +@pytest.mark.parametrize("legacy_field", ["install", "uninstall"]) +def test_loader_rejects_legacy_command_fields( + tmp_path: Path, + legacy_field: str, +): + spec = tmp_path / "sample.yaml" + spec.write_text( + ( + "unsupported:\n" + " project: unavailable\n" + "scopes:\n" + " user:\n" + f" {legacy_field}: [graphify, demo, install]\n" + " effects:\n" + " - {root: home, path: fixture.txt}\n" + ), + encoding="utf-8", + ) + + with pytest.raises(SpecError, match="unknown keys"): + load_target(spec) diff --git a/tools/install_sandbox/.dockerignore b/tools/install_sandbox/.dockerignore new file mode 100644 index 000000000..5b15042f3 --- /dev/null +++ b/tools/install_sandbox/.dockerignore @@ -0,0 +1,4 @@ +out +__pycache__ +*.pyc +.pytest_cache diff --git a/tools/install_sandbox/.gitignore b/tools/install_sandbox/.gitignore new file mode 100644 index 000000000..726b97109 --- /dev/null +++ b/tools/install_sandbox/.gitignore @@ -0,0 +1,3 @@ +out/ +__pycache__/ +*.pyc diff --git a/tools/install_sandbox/AGENTS.md b/tools/install_sandbox/AGENTS.md new file mode 100644 index 000000000..4a5b452c3 --- /dev/null +++ b/tools/install_sandbox/AGENTS.md @@ -0,0 +1,30 @@ +# Install sandbox instructions + +Before changing target-related data or code that consumes it, read +`README.md` and `specs/README.md`. + +Classify each new piece of data before deciding where it belongs: + +- An irreducible target fact belongs in that target's YAML spec. +- A deterministic derivation belongs in Python. +- A cross-target harness policy belongs in generic Python that operates on the + loaded catalog, without naming real targets. +- A product observation belongs in a report or defect record until it is + independently established as an oracle fact. + +The YAML filename stems and the validated specs loaded from them are the +current authority for catalog membership and target facts. Never introduce a +Python collection of real target names as a second catalog, target grouping, +or policy input. + +Keep schema vocabulary, validation, defaults, and generic derivation in +Python. The present YAML shape is not permanent: simplify the schema when a +field can be derived safely from filename, scope, existing facts, or a +target-independent convention. + +The harness oracle must remain independent of the product being tested. Never +derive expected effects or target membership from product output, discovery, +or behavior observed during the run. + +Report product defects exposed by the sandbox. Do not change production +behavior unless the request explicitly includes that fix. diff --git a/tools/install_sandbox/Dockerfile b/tools/install_sandbox/Dockerfile new file mode 100644 index 000000000..effb10cdb --- /dev/null +++ b/tools/install_sandbox/Dockerfile @@ -0,0 +1,38 @@ +FROM python:3.12-slim + +ENV DEBIAN_FRONTEND=noninteractive \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PIP_NO_CACHE_DIR=1 \ + PYTHONPATH=/runner \ + HOME=/tmp/graphify-home \ + XDG_CONFIG_HOME=/tmp/graphify-xdg \ + GRAPHIFY_SANDBOX_REPO=/mnt/graphify-repo \ + GRAPHIFY_SANDBOX_OUTPUT=/sandbox-out \ + GRAPHIFY_SANDBOX_PROJECT=/tmp/graphify-project \ + GRAPHIFY_SANDBOX_USER_CWD=/tmp/graphify-user-cwd \ + GRAPHIFY_SANDBOX_SOURCE=/tmp/graphify-source + +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential ca-certificates git \ + && rm -rf /var/lib/apt/lists/* \ + && python -m pip install "PyYAML>=6.0.3" + +RUN mkdir -p \ + /runner/tools/install_sandbox \ + /tmp/graphify-home \ + /tmp/graphify-xdg \ + /tmp/graphify-project \ + /tmp/graphify-user-cwd \ + /sandbox-out \ + && touch /runner/tools/__init__.py \ + && chmod -R 0777 \ + /tmp/graphify-home \ + /tmp/graphify-xdg \ + /tmp/graphify-project \ + /tmp/graphify-user-cwd \ + /sandbox-out + +COPY . /runner/tools/install_sandbox/ + +WORKDIR /tmp/graphify-project +ENTRYPOINT ["python", "-m", "tools.install_sandbox.sandbox_runner"] diff --git a/tools/install_sandbox/README.md b/tools/install_sandbox/README.md new file mode 100644 index 000000000..ab5a35e67 --- /dev/null +++ b/tools/install_sandbox/README.md @@ -0,0 +1,265 @@ +# Graphify install sandbox + +The install sandbox is a Tier 1 behavioral test harness for Graphify's +installer. It installs the current source checkout inside Docker, exercises +the installer lifecycle in isolated user and project roots, and verifies the +Graphify-owned files that lifecycle should create, repair, preserve, and +remove. + +Use it when changing installer commands, packaged integration files, target +specifications, cleanup behavior, or universal uninstall. It catches problems +such as wrong paths or payloads, duplicate managed Markdown, stale sidecars, +removed user content, and undeclared filesystem changes. + +> **Coverage boundary:** the sandbox verifies installer-owned filesystem +> effects. It does not prove that a target tool discovers, authenticates, +> loads, or executes the installed integration. + +## Prerequisites + +Run the commands from a Graphify source checkout with: + +- the repository development environment installed (`uv sync --all-extras`); +- a running Docker daemon available to the current user. + +The examples use the repository's locked environment. If the project +environment is already active, `python` can replace `uv run --frozen python`. + +## Quick start + +Start with one target and one scope while developing: + +```bash +uv run --frozen python tools/install_sandbox/run.py \ + --repo . \ + --target codex \ + --scope project +``` + +Run the complete catalog before finalizing a catalog-wide installer change: + +```bash +uv run --frozen python tools/install_sandbox/run.py \ + --repo . \ + --all \ + --scope both +``` + +Each invocation builds the harness image before starting the container, so a +full-catalog run takes longer than a single-target run. + +## Choose what to run + +Exactly one of `--target` and `--all` is required. + +| Argument | Meaning | +| --- | --- | +| `--repo PATH` | Graphify source checkout to install and test. | +| `--target NAME` | Run one target. Use `--help` to list names from the current catalog. | +| `--all` | Run every target plus catalog-wide installer checks. | +| `--scope user\|project\|both` | Select install roots; defaults to `both`. | +| `--output DIR` | Use an absent or empty caller-owned directory instead of managed local output. | + +Without `--output`, the runner creates a managed run beneath the ignored +`tools/install_sandbox/out/` root. For example: + +```text +tools/install_sandbox/out/20260726T143012Z-codex-project/ +tools/install_sandbox/out/20260726T143012Z-codex-project-02/ +tools/install_sandbox/out/20260726T143012Z-all-both/ +``` + +The numeric suffix resolves same-second collisions. For example, give an agent +or CI job an empty external artifact directory: + +```bash +sandbox_output_dir="$(mktemp -d /tmp/graphify-install-sandbox-codex.XXXXXX)" +uv run --frozen python tools/install_sandbox/run.py \ + --repo . \ + --target codex \ + --scope both \ + --output "$sandbox_output_dir" +``` + +Set `GRAPHIFY_SANDBOX_RUNTIME` only when a different Docker-compatible runtime +executable should replace the default `docker` command. + +## Output ownership and lifecycle + +Managed and external output have deliberately separate ownership: + +- A default run is managed by the host runner. Its run ID, metadata, and + keep-five retention policy belong to the sandbox. +- `--output DIR` is an external leaf owned by the caller. The path must either + be absent or be an empty real directory. Symlinks, non-empty directories, and + explicit paths beneath `tools/install_sandbox/out/` are rejected. +- The runner never prunes external output. CI, an agent, or the person who + supplied `--output` decides how long to keep it. + +Every accepted destination is fresh: the runner allocates managed directories +atomically and never reuses files from an earlier run. It allocates the +diagnostic bundle after validating the repository but before catalog preflight, +so catalog, Docker build, and runtime failures can still leave host diagnostics. + +The host writes two top-level lifecycle artifacts: + +| Artifact | Use | +| --- | --- | +| `run.json` | Schema version, run ID, managed flag, timestamps, repository and output paths, selection, current phase, state, and exit code. | +| `runner.log` | Phase-labelled host preflight, Docker build, and container output, mirrored to the console. | + +The runner replaces `run.json` atomically as the phase or state changes, so a +reader never observes a partially written metadata file. `run.json` moves +through these states: + +| State | Meaning | +| --- | --- | +| `running` | The run has been allocated and has not reached a terminal state. An uncatchable termination can leave this state behind. | +| `passed` | Exit `0`, with a complete container `manifest.json` and `report.md`. | +| `failed` | Exit `1`, with complete behavioral results that contain a product-contract failure. | +| `incomplete` | Catalog, image-build, container-runtime, or missing-output failure prevented complete behavioral results. | +| `interrupted` | The host caught `SIGINT` or `SIGTERM` and returned the conventional signal exit code. | + +Before allocating a managed run, the runner removes only surplus, valid, +terminal managed runs. After finalization it keeps the newest five, counting +`passed`, `failed`, `incomplete`, and `interrupted` equally. It does not delete +`running`, malformed, unreadable, unmarked, external, or symlinked entries. +Leftover `running` entries are preserved with a warning because the runner +cannot prove that another process is not using them. + +### Optional VS Code exclusions + +Repository-local managed output can still add editor file-watching and search +work. If that becomes noticeable, merge these keys into your existing +workspace settings: + +```json +{ + "files.watcherExclude": { + "**/tools/install_sandbox/out/**": true + }, + "search.exclude": { + "**/tools/install_sandbox/out/**": true + } +} +``` + +Do not overwrite an existing `.vscode/settings.json`; merge with settings +already there and keep editor configuration local and untracked. Do not use +`files.exclude` for this purpose, because it would hide recent diagnostic +artifacts from the file explorer. + +## What a run checks + +For each supported target/scope pair, the harness exercises install, +reinstall, progressive-sidecar repair, and uninstall as applicable. It checks +exact packaged content and version stamps, preserves unrelated user content, +and rejects filesystem changes outside the effects declared for that target. + +Every run also compares the spec catalog with the public installer target list +and checks that `graphify uninstall --purge` removes `graphify-out/` without +removing unrelated content. A full `--all` run additionally: + +- runs grouped user and project universal-uninstall scenarios; +- proves user-scope installations survive `graphify uninstall --project`; +- proves uninstall without `--purge` preserves `graphify-out/`. + +## Read the result + +Start with `/report.md`. It gives the overall scenario counts, purge +status, runtime limitations, and links to failed scenarios. + +- `PASS` means the supported scenario met every declared installer contract. +- `FAIL` means at least one command or filesystem assertion failed. +- `UNSUPPORTED` is a declared coverage limitation, not a failure. +- `NOT_APPLICABLE` means that lifecycle phase is not defined for the scenario. + +Use the remaining artifacts only when more detail is needed: + +| Artifact | Use | +| --- | --- | +| `run.json` | Host-owned lifecycle metadata and raw exit classification. | +| `runner.log` | Complete phase-labelled host and container console output. | +| `manifest.json` | Machine-readable run selection, package data, summary, scenarios, and purge result. | +| `scenarios//result.json` | Assertions and phase status for one scenario. | +| `scenarios//*.stdout.log` and `*.stderr.log` | Installer command output. | +| `scenarios//*.json` snapshots | Filesystem state before and after lifecycle phases. | +| `scenarios//commands.log` | Commands executed for the scenario. | + +An exit status of `0` means all supported scenarios and the purge check +passed. Exit status `1` means a scenario or purge check failed. Other nonzero +statuses indicate invalid input, catalog validation, image build, runtime, or +container-execution problems; read the console error before interpreting +scenario artifacts. + +For agent handoff, report the exact command, output directory, exit status, +summary from `report.md`, and any failed scenario names. Do not infer a product +failure from an image-build or container-runtime error. + +## Continuous integration + +Normal pytest provides the fast contract: it strictly loads the checked-in +YAML catalog and compares its filename-derived targets with the current +checkout's public `graphify install --help` targets. The Docker workflow adds +the slower filesystem-effect oracle. + +The advisory `.github/workflows/install-sandbox.yml` workflow runs one Ubuntu, +Python 3.12 full-catalog invocation with `--all --scope both`. It is independent +of the normal pytest matrix and runs: + +- for pull requests into, and pushes to, `v8` or `main` when installer code, + packaged skills or commands, sandbox code/tests, packaging metadata, the lock + file, or the workflow changes; +- nightly at `05:27 UTC`; +- on manual `workflow_dispatch`. + +Scheduled and manual runs always execute the full catalog. Pull-request runs +cancel an older in-progress sandbox run for the same pull request. The workflow +uses a fresh external leaf at +`$RUNNER_TEMP/graphify-install-sandbox`, uploads the complete directory after +success or failure as +`install-sandbox--`, writes `report.md` (or `run.json` +when the report is unavailable) to the job summary, and only then evaluates +the host lifecycle metadata. A `passed` run succeeds. A complete `failed` run +also succeeds after emitting a warning because it represents behavioral +findings, not a broken diagnostic. An `incomplete` or `interrupted` run, missing +metadata, malformed metadata, or an exit-code mismatch fails the workflow. The +runner's original exit code remains recorded in `run.json` and the job summary. +This workflow is advisory for completed findings; it is not a required-check +gate. + +Two commented settings near the top of the workflow are the storage-cost +controls: + +- `INSTALL_SANDBOX_ARTIFACT_RETENTION_DAYS` is `14`; +- `INSTALL_SANDBOX_ARTIFACT_COMPRESSION_LEVEL` is `9`. + +Artifacts are compressed during upload. GitHub Actions artifacts are immutable +after upload, so the workflow does not attempt age-based recompression; +maintainers can reduce cost by changing those retention or compression values. +A representative current bundle measured about 3.1 MB locally and roughly +80 KB at maximum compression. See +[upload-artifact compression and immutability](https://github.com/actions/upload-artifact). + +## Catalog authority + +The YAML specs are the authority for target membership, install effects, and +universal-uninstall eligibility. Python discovers the catalog from spec +filenames and derives aggregate scenarios from those declarations; it does not +maintain a second list of target names. + +The [spec authority guide](specs/README.md) explains how to place new target +facts, deterministic derivations, cross-target policy, and product +observations without freezing the current YAML schema. + +## Isolation and platform limits + +The repository is mounted read-only, copied to a separate container source +directory, and installed from that copy. HOME, XDG configuration, project, +user working directory, copied source, and output are distinct isolated roots. +No real user home is mounted. + +Windows and Antigravity-Windows scenarios only compare packaged payloads in the +Linux container. They do not validate Windows paths, shells, permissions, +cleanup, or runtime discovery. Hermes validates its normal Linux path only; +`%LOCALAPPDATA%` behavior is not exercised. diff --git a/tools/install_sandbox/__init__.py b/tools/install_sandbox/__init__.py new file mode 100644 index 000000000..521cee232 --- /dev/null +++ b/tools/install_sandbox/__init__.py @@ -0,0 +1 @@ +"""Behavioral Docker sandbox for Graphify installer file effects.""" diff --git a/tools/install_sandbox/ci_result.py b/tools/install_sandbox/ci_result.py new file mode 100644 index 000000000..5509be52f --- /dev/null +++ b/tools/install_sandbox/ci_result.py @@ -0,0 +1,109 @@ +"""Translate install-sandbox lifecycle metadata into a GitHub Actions result.""" + +from __future__ import annotations + +import argparse +import json +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Literal + + +Annotation = Literal["notice", "warning", "error"] + + +@dataclass(frozen=True) +class CIResult: + """One GitHub Actions annotation and the step exit code it accompanies.""" + + annotation: Annotation + message: str + exit_code: int + + +def _invalid(message: str) -> CIResult: + return CIResult("error", f"Invalid install-sandbox result: {message}", 2) + + +def classify_ci_result( + metadata: Mapping[str, object], + runner_exit_code: int, +) -> CIResult: + """Keep completed findings advisory while failing broken diagnostic runs.""" + + if not 0 <= runner_exit_code <= 255: + return _invalid(f"runner exit code is out of range: {runner_exit_code}") + + state = metadata.get("state") + metadata_exit_code = metadata.get("exit_code") + if not isinstance(state, str): + return _invalid("run.json state is not a string") + if type(metadata_exit_code) is not int: + return _invalid("run.json exit_code is not an integer") + if metadata_exit_code != runner_exit_code: + return _invalid( + "run.json exit_code " + f"{metadata_exit_code} does not match runner exit code {runner_exit_code}" + ) + + if state == "passed": + if runner_exit_code != 0: + return _invalid("passed state must have exit code 0") + return CIResult( + "notice", + "Install sandbox completed without behavioral findings.", + 0, + ) + + if state == "failed": + if runner_exit_code != 1: + return _invalid("failed state must have exit code 1") + return CIResult( + "warning", + "Install sandbox completed with behavioral findings; " + "see the job summary and uploaded diagnostic bundle.", + 0, + ) + + if state in {"incomplete", "interrupted"}: + if runner_exit_code == 0: + return _invalid(f"{state} state must have a nonzero exit code") + return CIResult( + "error", + f"Install sandbox diagnostic {state} with exit code {runner_exit_code}.", + runner_exit_code, + ) + + return _invalid(f"unknown terminal state: {state!r}") + + +def parser() -> argparse.ArgumentParser: + result = argparse.ArgumentParser(description=__doc__) + result.add_argument("--run-json", required=True, type=Path) + result.add_argument("--runner-exit-code", required=True, type=int) + return result + + +def _load_metadata(path: Path) -> Mapping[str, object]: + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise ValueError("run.json root is not an object") + return value + + +def main(argv: Sequence[str] | None = None) -> int: + args = parser().parse_args(argv) + try: + metadata = _load_metadata(args.run_json) + except (OSError, UnicodeError, json.JSONDecodeError, ValueError) as exc: + result = _invalid(f"cannot read {args.run_json}: {exc}") + else: + result = classify_ci_result(metadata, args.runner_exit_code) + + print(f"::{result.annotation}::{result.message}") + return result.exit_code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/install_sandbox/docker.py b/tools/install_sandbox/docker.py new file mode 100644 index 000000000..858aa4a7e --- /dev/null +++ b/tools/install_sandbox/docker.py @@ -0,0 +1,240 @@ +"""Host-side Docker build and isolated container execution.""" + +from __future__ import annotations + +import os +import shlex +import subprocess +import sys +import threading +from collections.abc import Callable +from pathlib import Path +from typing import TextIO + + +HARNESS_DIR = Path(__file__).resolve().parent +DEFAULT_IMAGE = "graphify-install-sandbox-v8:local" +BUILD_TIMEOUT_SECONDS = 900 +RUN_TIMEOUT_SECONDS = 7200 + +CONTAINER_REPO = "/mnt/graphify-repo" +CONTAINER_OUTPUT = "/sandbox-out" +CONTAINER_HOME = "/tmp/graphify-home" +CONTAINER_XDG = "/tmp/graphify-xdg" +CONTAINER_PROJECT = "/tmp/graphify-project" +CONTAINER_USER_CWD = "/tmp/graphify-user-cwd" +CONTAINER_SOURCE = "/tmp/graphify-source" + +PhaseHandler = Callable[[str], None] +OutputHandler = Callable[[str, str, str], None] + + +def build_image_command(runtime: str, image: str) -> list[str]: + return [runtime, "build", "--tag", image, str(HARNESS_DIR)] + + +def build_run_command( + *, + runtime: str, + image: str, + repo: Path, + output: Path, + target: str | None, + all_targets: bool, + scope: str, +) -> list[str]: + if bool(target) == bool(all_targets): + raise ValueError("select exactly one target mode") + command = [runtime, "run", "--rm"] + if hasattr(os, "getuid") and hasattr(os, "getgid"): + command.extend(["--user", f"{os.getuid()}:{os.getgid()}"]) + for key, value in { + "HOME": CONTAINER_HOME, + "XDG_CONFIG_HOME": CONTAINER_XDG, + "GRAPHIFY_SANDBOX_REPO": CONTAINER_REPO, + "GRAPHIFY_SANDBOX_OUTPUT": CONTAINER_OUTPUT, + "GRAPHIFY_SANDBOX_PROJECT": CONTAINER_PROJECT, + "GRAPHIFY_SANDBOX_USER_CWD": CONTAINER_USER_CWD, + "GRAPHIFY_SANDBOX_SOURCE": CONTAINER_SOURCE, + }.items(): + command.extend(["--env", f"{key}={value}"]) + command.extend( + [ + "--volume", + f"{repo}:{CONTAINER_REPO}:ro", + "--volume", + f"{output}:{CONTAINER_OUTPUT}:rw", + "--workdir", + CONTAINER_PROJECT, + image, + "--scope", + scope, + ] + ) + if all_targets: + command.append("--all") + else: + command.extend(["--target", str(target)]) + return command + + +def _emit( + *, + phase: str, + stream: str, + text: str, + on_output: OutputHandler | None, +) -> None: + if on_output is not None: + on_output(phase, stream, text) + return + destination = sys.stderr if stream == "stderr" else sys.stdout + print(text, end="", file=destination, flush=True) + + +def _pipe_output( + pipe: TextIO, + *, + phase: str, + stream: str, + on_output: OutputHandler | None, +) -> None: + try: + for line in iter(pipe.readline, ""): + _emit( + phase=phase, + stream=stream, + text=line, + on_output=on_output, + ) + finally: + pipe.close() + + +def _stop_process(process: subprocess.Popen[str]) -> None: + if process.poll() is not None: + return + process.terminate() + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + + +def _run( + argv: list[str], + timeout: int, + *, + phase: str, + on_output: OutputHandler | None = None, +) -> int: + _emit( + phase=phase, + stream="command", + text=f"$ {shlex.join(argv)}\n", + on_output=on_output, + ) + try: + process = subprocess.Popen( + argv, + shell=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + bufsize=1, + ) + except FileNotFoundError: + _emit( + phase=phase, + stream="stderr", + text=f"error: container runtime not found: {argv[0]}\n", + on_output=on_output, + ) + return 127 + + assert process.stdout is not None + assert process.stderr is not None + threads = [ + threading.Thread( + target=_pipe_output, + args=(process.stdout,), + kwargs={ + "phase": phase, + "stream": "stdout", + "on_output": on_output, + }, + ), + threading.Thread( + target=_pipe_output, + args=(process.stderr,), + kwargs={ + "phase": phase, + "stream": "stderr", + "on_output": on_output, + }, + ), + ] + for thread in threads: + thread.start() + try: + return process.wait(timeout=timeout) + except subprocess.TimeoutExpired: + _emit( + phase=phase, + stream="stderr", + text=( + f"error: command timed out after {timeout} seconds: " + f"{shlex.join(argv)}\n" + ), + on_output=on_output, + ) + _stop_process(process) + return 124 + finally: + _stop_process(process) + for thread in threads: + thread.join() + + +def run_sandbox( + *, + repo: Path, + output: Path, + target: str | None, + all_targets: bool, + scope: str, + runtime: str = "docker", + image: str = DEFAULT_IMAGE, + on_phase: PhaseHandler | None = None, + on_output: OutputHandler | None = None, +) -> int: + output.mkdir(parents=True, exist_ok=True) + if on_phase is not None: + on_phase("docker_build") + build = _run( + build_image_command(runtime, image), + BUILD_TIMEOUT_SECONDS, + phase="docker_build", + on_output=on_output, + ) + if build: + return build + if on_phase is not None: + on_phase("container") + return _run( + build_run_command( + runtime=runtime, + image=image, + repo=repo, + output=output, + target=target, + all_targets=all_targets, + scope=scope, + ), + RUN_TIMEOUT_SECONDS, + phase="container", + on_output=on_output, + ) diff --git a/tools/install_sandbox/effects.py b/tools/install_sandbox/effects.py new file mode 100644 index 000000000..e26d0248b --- /dev/null +++ b/tools/install_sandbox/effects.py @@ -0,0 +1,606 @@ +"""Filesystem observations and behavioral effect validation.""" + +from __future__ import annotations + +import hashlib +import json +import re +from pathlib import Path, PurePosixPath +from typing import Any, Iterable, Mapping, Sequence + +from .models import Effect, EffectKind, Observation, Root, ValidationResult + + +REFERENCE_NAMES = frozenset( + { + "add-watch.md", + "exports.md", + "extraction-spec.md", + "github-and-merge.md", + "hooks.md", + "query.md", + "transcribe.md", + "update.md", + } +) +_POINTER_RE = re.compile(r"references/([A-Za-z0-9_.-]+\.md)") +_REMINDER_RE = re.compile(r"""['"]echo "([^"\n]*)"\s*;\s*['"]\s*\+""") +_SNAPSHOT_EXCLUDES = frozenset({".cache", ".local", "__pycache__"}) +USER_JSON_SEED = {"user_owned": {"keep": True}} + + +def resolve_effect(effect: Effect, roots: Mapping[Root, Path]) -> Path: + return roots[effect.root] / effect.path + + +def observe(effect: Effect, roots: Mapping[Root, Path]) -> Observation: + path = resolve_effect(effect, roots) + text = None + json_value = None + is_file = path.is_file() + if is_file: + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + text = None + if effect.kind is EffectKind.JSON and text is not None: + try: + json_value = json.loads(text) + except json.JSONDecodeError: + json_value = None + return Observation( + root=effect.root, + path=effect.path, + exists=path.exists(), + is_file=is_file, + text=text, + json_value=json_value, + ) + + +def _result(check: str, passed: bool, detail: str) -> ValidationResult: + return ValidationResult(check=check, passed=passed, detail=detail) + + +def _validate_preserved_backup(effect: Effect, path: Path) -> ValidationResult: + backup = path.with_name(path.name + ".graphify-bak") + try: + value = json.loads(backup.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + value = None + return _result( + f"{effect.root.value}:{effect.path} backup preserved", + value == USER_JSON_SEED, + f"{backup} retains the pre-install user JSON", + ) + + +def _expanded(value: Any, roots: Mapping[Root, Path]) -> Any: + if isinstance(value, str): + replacements = { + "{home}": str(roots[Root.HOME]), + "{xdg}": str(roots[Root.XDG]), + "{project}": str(roots[Root.PROJECT]), + "{user_cwd}": str(roots[Root.USER_CWD]), + } + for token, replacement in replacements.items(): + value = value.replace(token, replacement) + return value + if isinstance(value, list): + return [_expanded(item, roots) for item in value] + if isinstance(value, dict): + return {key: _expanded(item, roots) for key, item in value.items()} + return value + + +def _frontmatter_body(text: str) -> str: + if not text.startswith("---\n"): + return text + closing = text.find("\n---\n", 4) + if closing == -1: + return text + return text[closing + len("\n---\n") :].lstrip("\n") + + +def contains_json(actual: Any, expected: Any) -> bool: + """Return whether *actual* contains the expected JSON subset.""" + if isinstance(expected, dict): + return isinstance(actual, dict) and all( + key in actual and contains_json(actual[key], value) + for key, value in expected.items() + ) + if isinstance(expected, list): + if not isinstance(actual, list): + return False + return all( + any(contains_json(candidate, item) for candidate in actual) + for item in expected + ) + return actual == expected + + +def _validate_payload( + effect: Effect, + actual: str | None, + source_root: Path, +) -> list[ValidationResult]: + if effect.source is None or actual is None: + return [] + source = source_root / effect.source + try: + expected = source.read_text(encoding="utf-8") + except OSError as exc: + return [_result("payload source", False, f"cannot read {source}: {exc}")] + if effect.kind is EffectKind.SECTION: + expected = expected.strip() + actual = actual.strip() + comparisons = { + "exact": actual == expected, + "prefix": actual.startswith(expected), + "suffix": actual.endswith(expected), + "contains": expected in actual, + "frontmatter-body": _frontmatter_body(actual) + == _frontmatter_body(expected), + } + passed = comparisons[effect.payload_mode] + return [ + _result( + "payload equality", + passed, + f"{effect.path} {effect.payload_mode}-matches {effect.source}" + if passed + else f"{effect.path} does not {effect.payload_mode}-match {effect.source}", + ) + ] + + +def _owned_section(text: str, marker: str) -> str | None: + """Return the last exact owned heading section, mirroring the installer.""" + lines = text.splitlines() + starts = [index for index, line in enumerate(lines) if line.strip() == marker] + if not starts: + return None + start = starts[-1] + heading_level = len(marker) - len(marker.lstrip("#")) + boundary = "#" * heading_level + " " + end = len(lines) + for index in range(start + 1, len(lines)): + if lines[index].startswith(boundary): + end = index + break + return "\n".join(lines[start:end]).strip() + + +def _validate_sidecars( + effect: Effect, + path: Path, + source_root: Path, + expected_version: str | None, +) -> list[ValidationResult]: + results: list[ValidationResult] = [] + version = path.parent / ".graphify_version" + actual_version = ( + version.read_text(encoding="utf-8").strip() + if version.is_file() + else None + ) + version_matches = ( + actual_version == expected_version + if expected_version is not None + else bool(actual_version) + ) + results.append( + _result( + "version sidecar", + version_matches, + ( + f"{version} equals installed Graphify version {expected_version!r}" + if expected_version is not None + else f"{version} is present and non-empty" + ), + ) + ) + refs = path.parent / "references" + staged = path.parent / "references.tmp" + if effect.reference_bundle is None: + results.append( + _result( + "monolith sidecars", + not refs.exists() and not staged.exists(), + f"{path.parent} has no progressive reference sidecars", + ) + ) + return results + + actual_names = ( + frozenset(item.name for item in refs.iterdir() if item.is_file()) + if refs.is_dir() + else frozenset() + ) + results.append( + _result( + "reference set", + actual_names == REFERENCE_NAMES, + f"{effect.path} has the exact 8-reference set: {sorted(actual_names)}", + ) + ) + results.append( + _result( + "staged sidecar absent", + not staged.exists(), + f"{staged} does not exist", + ) + ) + source_refs = ( + source_root / "graphify" / "skills" / effect.reference_bundle / "references" + ) + equal = actual_names == REFERENCE_NAMES + for name in REFERENCE_NAMES: + actual_file = refs / name + source_file = source_refs / name + if not actual_file.is_file() or not source_file.is_file(): + equal = False + continue + if actual_file.read_bytes() != source_file.read_bytes(): + equal = False + results.append( + _result( + "reference payloads", + equal, + f"{refs} matches packaged {effect.reference_bundle} references", + ) + ) + skill_text = path.read_text(encoding="utf-8") if path.is_file() else "" + pointers = frozenset(_POINTER_RE.findall(skill_text)) + resolved = bool(pointers) and all((refs / pointer).is_file() for pointer in pointers) + results.append( + _result( + "reference pointers", + resolved, + f"{len(pointers)} SKILL.md reference pointers resolve", + ) + ) + return results + + +def _validate_reminder( + effect: Effect, observation: Observation +) -> list[ValidationResult]: + text = observation.text or "" + matches = _REMINDER_RE.findall(text) + if len(matches) != 1: + return [ + _result( + "captured reminder", + False, + f"expected one semicolon-joined reminder string, found {len(matches)}", + ) + ] + reminder = matches[0] + required = all(fragment in reminder for fragment in effect.required_text) + forbidden = all(fragment not in reminder for fragment in effect.forbidden_text) + return [ + _result( + "captured reminder required text", + required, + f"captured reminder contains {list(effect.required_text)}", + ), + _result( + "captured reminder forbidden text", + forbidden, + f"captured reminder excludes {list(effect.forbidden_text)}", + ), + _result( + "captured reminder semicolon join", + '" ; \' +' in text and '" && \' +' not in text, + "reminder uses the cross-shell semicolon join, not the old && join", + ), + ] + + +def validate_installed( + effects: Iterable[Effect], + roots: Mapping[Root, Path], + source_root: Path, + expected_version: str | None = None, +) -> list[ValidationResult]: + results: list[ValidationResult] = [] + for effect in effects: + path = resolve_effect(effect, roots) + observation = observe(effect, roots) + label = f"{effect.root.value}:{effect.path}" + results.append( + _result( + f"{label} exists", + observation.is_file, + f"{path} is a regular file", + ) + ) + if not observation.is_file: + continue + text = observation.text or "" + fragment_text = text + if effect.kind is EffectKind.SECTION: + section = _owned_section(text, effect.marker or "") + marker_count = sum( + line.strip() == effect.marker for line in text.splitlines() + ) + results.append( + _result( + f"{label} owned section", + marker_count == 1, + f"exact marker line {effect.marker!r} occurs once, found {marker_count}", + ) + ) + fragment_text = section or "" + results.extend(_validate_payload(effect, section, source_root)) + else: + results.extend(_validate_payload(effect, observation.text, source_root)) + if effect.kind is EffectKind.JSON: + expected = _expanded(dict(effect.entries), roots) + results.append( + _result( + f"{label} JSON entries", + contains_json(observation.json_value, expected), + f"JSON contains {expected!r}", + ) + ) + if effect.required_text: + bound = _json_owned_entries_bind_required_text( + observation.json_value, + expected, + effect.required_text, + ) + results.append( + _result( + f"{label} JSON owned entry payloads", + bound, + "each declared JSON list entry occurs once and contains " + f"{list(effect.required_text)!r}", + ) + ) + if effect.preserves_backup: + results.append(_validate_preserved_backup(effect, path)) + elif effect.kind is EffectKind.REMINDER_PLUGIN: + results.extend(_validate_reminder(effect, observation)) + if effect.kind is not EffectKind.REMINDER_PLUGIN: + for fragment in effect.required_text: + results.append( + _result( + f"{label} requires text", + fragment in fragment_text, + f"{fragment!r} is present in the owned content", + ) + ) + for fragment in effect.forbidden_text: + results.append( + _result( + f"{label} forbids text", + fragment not in fragment_text, + f"{fragment!r} is absent from the owned content", + ) + ) + if effect.kind is EffectKind.SKILL: + results.extend( + _validate_sidecars( + effect, + path, + source_root, + expected_version, + ) + ) + return results + + +def _json_owned_entries_bind_required_text( + actual: Any, + expected: Any, + required_text: tuple[str, ...], +) -> bool: + """Bind required text to each declared JSON list entry, not the whole file.""" + if isinstance(expected, dict): + if not isinstance(actual, dict): + return False + return all( + key in actual + and _json_owned_entries_bind_required_text( + actual[key], + value, + required_text, + ) + for key, value in expected.items() + ) + if isinstance(expected, list): + if not isinstance(actual, list): + return False + for expected_item in expected: + if not isinstance(expected_item, dict): + continue + matches = [ + candidate + for candidate in actual + if contains_json(candidate, expected_item) + ] + if len(matches) != 1: + return False + serialized = json.dumps(matches[0], sort_keys=True) + if not all(fragment in serialized for fragment in required_text): + return False + return True + return True + + +def json_owned_entries_absent(actual: Any, expected: Any) -> bool: + """Return whether every independently owned expected JSON entry is absent.""" + if isinstance(expected, dict): + if not isinstance(actual, dict): + return True + return all( + key not in actual or json_owned_entries_absent(actual[key], value) + for key, value in expected.items() + ) + if isinstance(expected, list): + if not isinstance(actual, list): + return True + return all( + not any(contains_json(candidate, item) for candidate in actual) + for item in expected + ) + return actual != expected + + +def validate_removed( + effects: Iterable[Effect], + roots: Mapping[Root, Path], + source_root: Path | None = None, +) -> list[ValidationResult]: + results: list[ValidationResult] = [] + for effect in effects: + path = resolve_effect(effect, roots) + observation = observe(effect, roots) + label = f"{effect.root.value}:{effect.path}" + if effect.kind is EffectKind.SECTION: + text = observation.text or "" + absent = effect.marker not in {line.strip() for line in text.splitlines()} + residual_body = False + if effect.source and source_root is not None: + try: + expected = (source_root / effect.source).read_text( + encoding="utf-8" + ) + except OSError: + residual_body = True + else: + expected_lines = expected.strip().splitlines() + body = "\n".join(expected_lines[1:]).strip() + residual_body = bool(body) and body in text + elif effect.required_text: + residual_body = all( + fragment in text for fragment in effect.required_text + ) + absent = absent and not residual_body + detail = ( + f"owned marker {effect.marker!r} and owned section body are absent" + ) + elif effect.kind is EffectKind.JSON: + expected = _expanded(dict(effect.entries), roots) + absent = json_owned_entries_absent(observation.json_value, expected) + detail = f"owned JSON entries are absent: {expected!r}" + else: + absent = not path.exists() + detail = f"owned file {path} is absent" + results.append(_result(f"{label} removed", absent, detail)) + if effect.preserves_backup: + results.append(_validate_preserved_backup(effect, path)) + if effect.kind is EffectKind.SKILL: + refs = path.parent / "references" + staged = path.parent / "references.tmp" + version = path.parent / ".graphify_version" + results.append( + _result( + f"{label} sidecars removed", + not refs.exists() + and not staged.exists() + and not version.exists(), + "references, references.tmp, and version stamp are absent", + ) + ) + return results + + +def snapshot( + roots: Mapping[Root, Path], +) -> dict[str, list[dict[str, object]]]: + result: dict[str, list[dict[str, object]]] = {} + for root_name, base in roots.items(): + files: list[dict[str, object]] = [] + if base.exists(): + for path in sorted(base.rglob("*")): + try: + relative = path.relative_to(base) + except ValueError: + continue + if any(part in _SNAPSHOT_EXCLUDES for part in relative.parts): + continue + if path.is_symlink(): + files.append( + { + "path": relative.as_posix(), + "type": "symlink", + "target": str(path.readlink()), + } + ) + elif path.is_file(): + content = path.read_bytes() + files.append( + { + "path": relative.as_posix(), + "type": "file", + "size": len(content), + "sha256": hashlib.sha256(content).hexdigest(), + } + ) + result[root_name.value] = files + return result + + +def snapshot_digest(value: Mapping[str, Any]) -> str: + encoded = json.dumps(value, sort_keys=True, separators=(",", ":")).encode() + return hashlib.sha256(encoded).hexdigest() + + +def validate_no_unexpected_changes( + effects: Iterable[Effect], + before: Mapping[str, Sequence[Mapping[str, object]]], + after: Mapping[str, Sequence[Mapping[str, object]]], +) -> ValidationResult: + """Require every changed snapshot path to belong to a declared effect.""" + allowed: dict[str, set[str]] = {} + for effect in effects: + paths = allowed.setdefault(effect.root.value, set()) + effect_path = PurePosixPath(effect.path) + paths.add(effect_path.as_posix()) + if effect.preserves_backup: + paths.add( + effect_path.with_name( + effect_path.name + ".graphify-bak" + ).as_posix() + ) + if effect.kind is not EffectKind.SKILL: + continue + paths.add((effect_path.parent / ".graphify_version").as_posix()) + if effect.reference_bundle: + paths.update( + (effect_path.parent / "references" / name).as_posix() + for name in REFERENCE_NAMES + ) + + unexpected: list[str] = [] + for root_name in sorted(set(before) | set(after)): + before_files = { + str(item["path"]): dict(item) + for item in before.get(root_name, []) + } + after_files = { + str(item["path"]): dict(item) + for item in after.get(root_name, []) + } + for path in sorted(set(before_files) | set(after_files)): + if ( + before_files.get(path) != after_files.get(path) + and path not in allowed.get(root_name, set()) + ): + unexpected.append(f"{root_name}:{path}") + + preview = ", ".join(unexpected[:8]) + if len(unexpected) > 8: + preview += f", and {len(unexpected) - 8} more" + return _result( + "filesystem changes stay within declared effects", + not unexpected, + ( + "no undeclared files changed" + if not unexpected + else f"undeclared changed paths: {preview}" + ), + ) diff --git a/tools/install_sandbox/lifecycle.py b/tools/install_sandbox/lifecycle.py new file mode 100644 index 000000000..36b743c6f --- /dev/null +++ b/tools/install_sandbox/lifecycle.py @@ -0,0 +1,812 @@ +"""In-container installer lifecycle execution.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Iterable, Mapping + +from .effects import ( + USER_JSON_SEED, + resolve_effect, + snapshot, + snapshot_digest, + validate_installed, + validate_no_unexpected_changes, + validate_removed, +) +from .models import ( + CommandMode, + CommandResult, + EffectKind, + PhaseResult, + Root, + SandboxRoots, + Scenario, + ScenarioResult, + Scope, + ValidationResult, +) + + +COMMAND_TIMEOUT_SECONDS = 180 +PACKAGE_TIMEOUT_SECONDS = 900 +USER_SENTINEL = "graphify sandbox unrelated user content\n" +STALE_SECTION_SENTINEL = "graphify sandbox stale owned section" + + +@dataclass(frozen=True) +class Seed: + path: Path + kind: str + + +CommandExecutor = Callable[ + [tuple[str, ...], Path, Mapping[str, str], Path, str], CommandResult +] + + +def _timeout_output_text(value: str | bytes | None) -> str: + if value is None: + return "" + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace") + return value + + +def scenario_steps(scenario: Scenario) -> tuple[str, ...]: + steps = ["install", "reinstall"] + if any( + effect.kind is EffectKind.SKILL and effect.reference_bundle + for effect in scenario.contract.effects + ): + steps.append("repair-progressive-sidecars") + steps.append("uninstall" if uninstall_command(scenario) else "uninstall-not-applicable") + return tuple(steps) + + +def install_command(scenario: Scenario) -> tuple[str, ...]: + mode = scenario.contract.install_mode + if mode is CommandMode.DIRECT: + return ("graphify", scenario.target.name, "install") + if mode is not None: + raise ValueError(f"invalid install command mode: {mode!r}") + command = ["graphify", "install"] + if scenario.scope is Scope.PROJECT: + command.append("--project") + command.extend(["--platform", scenario.target.name]) + return tuple(command) + + +def uninstall_command(scenario: Scenario) -> tuple[str, ...] | None: + mode = scenario.contract.uninstall_mode + if mode is CommandMode.DIRECT: + return ("graphify", scenario.target.name, "uninstall") + if mode is not None: + raise ValueError(f"invalid uninstall command mode: {mode!r}") + if scenario.scope is Scope.PROJECT: + return ( + "graphify", + "uninstall", + "--project", + "--platform", + scenario.target.name, + ) + return None + + +def command_environment(roots: SandboxRoots) -> dict[str, str]: + env = os.environ.copy() + env["HOME"] = str(roots.home) + env["XDG_CONFIG_HOME"] = str(roots.xdg) + env["PATH"] = f"{roots.home / '.local' / 'bin'}:{env.get('PATH', '')}" + env.pop("CLAUDE_CONFIG_DIR", None) + env.pop("LOCALAPPDATA", None) + return env + + +def execute_command( + argv: tuple[str, ...], + cwd: Path, + env: Mapping[str, str], + artifact_dir: Path, + label: str, +) -> CommandResult: + artifact_dir.mkdir(parents=True, exist_ok=True) + timed_out = False + try: + completed = subprocess.run( + argv, + cwd=cwd, + env=dict(env), + capture_output=True, + text=True, + shell=False, + timeout=COMMAND_TIMEOUT_SECONDS, + check=False, + ) + exit_code = completed.returncode + stdout = completed.stdout + stderr = completed.stderr + except subprocess.TimeoutExpired as exc: + timed_out = True + exit_code = 124 + stdout = _timeout_output_text(exc.stdout) + stderr = _timeout_output_text(exc.stderr) + (artifact_dir / f"{label}.stdout.log").write_text(stdout, encoding="utf-8") + (artifact_dir / f"{label}.stderr.log").write_text(stderr, encoding="utf-8") + record = { + "label": label, + "argv": list(argv), + "cwd": str(cwd), + "exit_code": exit_code, + "timed_out": timed_out, + } + with (artifact_dir / "commands.log").open("a", encoding="utf-8") as handle: + handle.write(json.dumps(record, sort_keys=True) + "\n") + return CommandResult( + argv=argv, + cwd=str(cwd), + exit_code=exit_code, + stdout=stdout, + stderr=stderr, + timed_out=timed_out, + ) + + +def reset_scenario_roots(roots: SandboxRoots) -> None: + for base, preserve in ( + (roots.home, {".local"}), + (roots.xdg, set()), + (roots.project, set()), + (roots.user_cwd, set()), + ): + base.mkdir(parents=True, exist_ok=True) + for child in base.iterdir(): + if child.name in preserve: + continue + if child.is_dir() and not child.is_symlink(): + shutil.rmtree(child) + else: + child.unlink() + + +def seed_user_content(scenario: Scenario, roots: Mapping[Root, Path]) -> list[Seed]: + seeds: list[Seed] = [] + for root, base in roots.items(): + base.mkdir(parents=True, exist_ok=True) + sentinel = base / "user-owned.txt" + sentinel.write_text(USER_SENTINEL, encoding="utf-8") + seeds.append(Seed(sentinel, "sentinel")) + + for effect in scenario.contract.effects: + path = resolve_effect(effect, roots) + path.parent.mkdir(parents=True, exist_ok=True) + sibling = path.parent / "unrelated.txt" + if not sibling.exists(): + sibling.write_text(USER_SENTINEL, encoding="utf-8") + seeds.append(Seed(sibling, "sentinel")) + if effect.kind is EffectKind.SECTION and not path.exists(): + path.write_text( + "# User notes\n\n" + "keep this section\n\n" + f"{effect.marker}\n\n" + f"{STALE_SECTION_SENTINEL}\n", + encoding="utf-8", + ) + seeds.append(Seed(path, "section")) + elif effect.kind is EffectKind.JSON and not path.exists(): + path.write_text( + json.dumps(USER_JSON_SEED, indent=2) + "\n", + encoding="utf-8", + ) + seeds.append(Seed(path, "json")) + return seeds + + +def validate_user_content(seeds: Iterable[Seed]) -> list[ValidationResult]: + results: list[ValidationResult] = [] + for seed in seeds: + passed = seed.path.is_file() + detail = f"{seed.path} still exists" + if passed and seed.kind == "sentinel": + passed = seed.path.read_text(encoding="utf-8") == USER_SENTINEL + detail = f"{seed.path} retains unrelated content" + elif passed and seed.kind == "section": + text = seed.path.read_text(encoding="utf-8") + passed = ( + "keep this section" in text + and STALE_SECTION_SENTINEL not in text + ) + detail = ( + f"{seed.path} retains the user's Markdown section and replaces " + "stale Graphify-owned content" + ) + elif passed and seed.kind == "json": + try: + value = json.loads(seed.path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + value = {} + passed = value.get("user_owned") == {"keep": True} + detail = f"{seed.path} retains the user's JSON entry" + results.append( + ValidationResult( + check="unrelated user content", + passed=passed, + detail=detail, + ) + ) + return results + + +def seed_stale_sidecars( + scenario: Scenario, + roots: Mapping[Root, Path], +) -> int: + seeded = 0 + for effect in scenario.contract.effects: + if effect.kind is not EffectKind.SKILL or not effect.reference_bundle: + continue + skill = resolve_effect(effect, roots) + references = skill.parent / "references" + references.mkdir(parents=True, exist_ok=True) + (references / "update.md").write_text("stale\n", encoding="utf-8") + (references / "stale.md").write_text("stale\n", encoding="utf-8") + staged = skill.parent / "references.tmp" + staged.mkdir(parents=True, exist_ok=True) + (staged / "stale.md").write_text("stale\n", encoding="utf-8") + (skill.parent / ".graphify_version").write_text( + "0.0.0-stale", + encoding="utf-8", + ) + seeded += 1 + return seeded + + +def _phase_status( + command: CommandResult | None, + validations: Iterable[ValidationResult], +) -> str: + if command is not None and not command.passed: + return "FAIL" + return "PASS" if all(item.passed for item in validations) else "FAIL" + + +def _write_snapshot( + path: Path, + roots: Mapping[Root, Path], +) -> dict[str, list[dict[str, object]]]: + value = snapshot(roots) + path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return value + + +def run_scenario( + scenario: Scenario, + roots: SandboxRoots, + *, + executor: CommandExecutor = execute_command, + expected_version: str | None = None, +) -> ScenarioResult: + artifact_dir = roots.output / "scenarios" / scenario.name + artifact_dir.mkdir(parents=True, exist_ok=True) + reset_scenario_roots(roots) + effect_roots = roots.effect_roots() + seeds = seed_user_content(scenario, effect_roots) + before_snapshot = _write_snapshot(artifact_dir / "before.json", effect_roots) + env = command_environment(roots) + cwd = roots.user_cwd if scenario.scope is Scope.USER else roots.project + phases: list[PhaseResult] = [] + + install = executor( + install_command(scenario), cwd, env, artifact_dir, "install" + ) + installed_checks = validate_installed( + scenario.contract.effects, + effect_roots, + roots.source, + expected_version, + ) + installed_checks.extend(validate_user_content(seeds)) + installed_snapshot = _write_snapshot( + artifact_dir / "after-install.json", effect_roots + ) + installed_checks.append( + validate_no_unexpected_changes( + scenario.contract.effects, + before_snapshot, + installed_snapshot, + ) + ) + phases.append( + PhaseResult( + name="install", + status=_phase_status(install, installed_checks), + command=install, + validations=installed_checks, + ) + ) + if not install.passed: + return _finish_scenario( + scenario=scenario, + phases=phases, + artifact_dir=artifact_dir, + roots=roots, + ) + + reinstall = executor( + install_command(scenario), cwd, env, artifact_dir, "reinstall" + ) + reinstall_checks = validate_installed( + scenario.contract.effects, + effect_roots, + roots.source, + expected_version, + ) + reinstall_checks.extend(validate_user_content(seeds)) + reinstalled_snapshot = _write_snapshot( + artifact_dir / "after-reinstall.json", effect_roots + ) + reinstall_checks.append( + ValidationResult( + check="idempotent filesystem state", + passed=snapshot_digest(installed_snapshot) + == snapshot_digest(reinstalled_snapshot), + detail="second install leaves the same filesystem snapshot", + ) + ) + reinstall_checks.append( + validate_no_unexpected_changes( + scenario.contract.effects, + before_snapshot, + reinstalled_snapshot, + ) + ) + phases.append( + PhaseResult( + name="reinstall", + status=_phase_status(reinstall, reinstall_checks), + command=reinstall, + validations=reinstall_checks, + ) + ) + + sidecar_count = seed_stale_sidecars(scenario, effect_roots) + if sidecar_count: + repair = executor( + install_command(scenario), cwd, env, artifact_dir, "repair-sidecars" + ) + repair_checks = validate_installed( + scenario.contract.effects, + effect_roots, + roots.source, + expected_version, + ) + repair_checks.extend(validate_user_content(seeds)) + repaired_snapshot = _write_snapshot( + artifact_dir / "after-repair.json", effect_roots + ) + repair_checks.append( + ValidationResult( + check="repaired stable filesystem state", + passed=snapshot_digest(installed_snapshot) + == snapshot_digest(repaired_snapshot), + detail="repair removes stale references and restores installed state", + ) + ) + repair_checks.append( + validate_no_unexpected_changes( + scenario.contract.effects, + before_snapshot, + repaired_snapshot, + ) + ) + phases.append( + PhaseResult( + name="repair-progressive-sidecars", + status=_phase_status(repair, repair_checks), + command=repair, + validations=repair_checks, + ) + ) + + uninstall_argv = uninstall_command(scenario) + if uninstall_argv is None: + preserve_checks = validate_user_content(seeds) + phases.append( + PhaseResult( + name="uninstall", + status="NOT_APPLICABLE", + validations=preserve_checks, + ) + ) + _write_snapshot(artifact_dir / "after-uninstall-not-applicable.json", effect_roots) + else: + uninstall = executor( + uninstall_argv, cwd, env, artifact_dir, "uninstall" + ) + uninstall_checks = validate_removed( + scenario.contract.effects, + effect_roots, + roots.source, + ) + uninstall_checks.extend(validate_user_content(seeds)) + uninstalled_snapshot = _write_snapshot( + artifact_dir / "after-uninstall.json", + effect_roots, + ) + uninstall_checks.append( + validate_no_unexpected_changes( + scenario.contract.effects, + before_snapshot, + uninstalled_snapshot, + ) + ) + phases.append( + PhaseResult( + name="uninstall", + status=_phase_status(uninstall, uninstall_checks), + command=uninstall, + validations=uninstall_checks, + ) + ) + + return _finish_scenario( + scenario=scenario, + phases=phases, + artifact_dir=artifact_dir, + roots=roots, + ) + + +def _finish_scenario( + *, + scenario: Scenario, + phases: list[PhaseResult], + artifact_dir: Path, + roots: SandboxRoots, +) -> ScenarioResult: + status = "PASS" if all( + phase.status in {"PASS", "NOT_APPLICABLE"} for phase in phases + ) else "FAIL" + result = ScenarioResult( + scenario=scenario.name, + target=scenario.target.name, + scope=scenario.scope.value, + status=status, + phases=phases, + limitations=scenario.target.limitations, + artifact_dir=str(artifact_dir.relative_to(roots.output)), + ) + (artifact_dir / "result.json").write_text( + json.dumps(result.as_dict(), indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + return result + + +def run_universal_uninstall_scenario( + scenarios: Iterable[Scenario], + roots: SandboxRoots, + *, + preserved_scenarios: Iterable[Scenario] = (), + executor: CommandExecutor = execute_command, + expected_version: str | None = None, +) -> ScenarioResult: + """Install a target group, then exercise the public broad uninstall command.""" + selected = tuple(scenarios) + preserved = tuple(preserved_scenarios) + if not selected: + raise ValueError("universal uninstall requires at least one scenario") + scope = selected[0].scope + if any(item.scope is not scope for item in selected): + raise ValueError("universal uninstall scenarios must use one scope") + if any(item.scope is not Scope.USER for item in preserved): + raise ValueError("preserved universal-uninstall scenarios must be user scope") + + name = f"universal-uninstall-{scope.value}" + artifact_dir = roots.output / "scenarios" / name + artifact_dir.mkdir(parents=True, exist_ok=True) + reset_scenario_roots(roots) + effect_roots = roots.effect_roots() + seed_groups = [ + (item, seed_user_content(item, effect_roots)) + for item in (*preserved, *selected) + ] + active_seeds: dict[tuple[Path, str], Seed] = {} + graph = roots.project / "graphify-out" / "graph.json" + graph.parent.mkdir(parents=True, exist_ok=True) + graph.write_text("{}\n", encoding="utf-8") + env = command_environment(roots) + phases: list[PhaseResult] = [] + prepared: list[Scenario] = [] + + for item in preserved: + item_seeds = next( + seeds for candidate, seeds in seed_groups if candidate is item + ) + command = executor( + install_command(item), + roots.user_cwd if item.scope is Scope.USER else roots.project, + env, + artifact_dir, + f"prepare-user-{item.target.name}", + ) + checks = validate_installed( + item.contract.effects, + effect_roots, + roots.source, + expected_version, + ) + checks.extend(validate_user_content(item_seeds)) + phase = PhaseResult( + name=f"prepare-user-{item.target.name}", + status=_phase_status(command, checks), + command=command, + validations=checks, + ) + phases.append(phase) + if phase.status == "PASS": + prepared.append(item) + active_seeds.update( + {(seed.path, seed.kind): seed for seed in item_seeds} + ) + + before_snapshot = _write_snapshot( + artifact_dir / "before.json", + effect_roots, + ) + installed_effects = [] + previous_snapshot = before_snapshot + cwd = roots.user_cwd if scope is Scope.USER else roots.project + for item in selected: + item_seeds = next( + seeds for candidate, seeds in seed_groups if candidate is item + ) + active_seeds.update( + {(seed.path, seed.kind): seed for seed in item_seeds} + ) + command = executor( + install_command(item), + cwd, + env, + artifact_dir, + f"install-{item.target.name}", + ) + installed_effects.extend(item.contract.effects) + checks = validate_installed( + item.contract.effects, + effect_roots, + roots.source, + expected_version, + ) + checks.extend(validate_user_content(item_seeds)) + current_snapshot = _write_snapshot( + artifact_dir / f"after-install-{item.target.name}.json", + effect_roots, + ) + checks.append( + validate_no_unexpected_changes( + item.contract.effects, + previous_snapshot, + current_snapshot, + ) + ) + phases.append( + PhaseResult( + name=f"install-{item.target.name}", + status=_phase_status(command, checks), + command=command, + validations=checks, + ) + ) + previous_snapshot = current_snapshot + + uninstall_argv = ( + ("graphify", "uninstall") + if scope is Scope.USER + else ("graphify", "uninstall", "--project") + ) + uninstall = executor( + uninstall_argv, + cwd, + env, + artifact_dir, + "uninstall", + ) + uninstall_checks = validate_removed( + installed_effects, + effect_roots, + roots.source, + ) + for item in prepared: + uninstall_checks.extend( + validate_installed( + item.contract.effects, + effect_roots, + roots.source, + expected_version, + ) + ) + uninstall_checks.extend(validate_user_content(active_seeds.values())) + uninstall_checks.append( + ValidationResult( + check="non-purge uninstall preserves graphify-out", + passed=graph.is_file() and graph.read_text(encoding="utf-8") == "{}\n", + detail=f"{graph.parent} survives broad uninstall without --purge", + ) + ) + after_snapshot = _write_snapshot( + artifact_dir / "after-uninstall.json", + effect_roots, + ) + uninstall_checks.append( + validate_no_unexpected_changes( + installed_effects, + before_snapshot, + after_snapshot, + ) + ) + phases.append( + PhaseResult( + name="uninstall", + status=_phase_status(uninstall, uninstall_checks), + command=uninstall, + validations=uninstall_checks, + ) + ) + + limitations = tuple( + dict.fromkeys( + limitation + for item in (*selected, *preserved) + for limitation in item.target.limitations + ) + ) + result = ScenarioResult( + scenario=name, + target="multiple", + scope=scope.value, + status="PASS" if all(phase.status == "PASS" for phase in phases) else "FAIL", + phases=phases, + limitations=limitations, + artifact_dir=str(artifact_dir.relative_to(roots.output)), + ) + (artifact_dir / "result.json").write_text( + json.dumps(result.as_dict(), indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + return result + + +def copy_and_install_package( + roots: SandboxRoots, + catalog_targets: Iterable[str], +) -> dict[str, object]: + if roots.source.exists(): + shutil.rmtree(roots.source) + ignored = shutil.ignore_patterns( + ".git", + ".venv", + "__pycache__", + "graphify-out", + "my-docs", + "out", + "*.pyc", + ) + shutil.copytree(roots.repo_mount, roots.source, ignore=ignored) + env = command_environment(roots) + package_dir = roots.output / "package" + package_dir.mkdir(parents=True, exist_ok=True) + argv = (sys.executable, "-m", "pip", "install", "--user", str(roots.source)) + result = execute_command(argv, Path("/tmp"), env, package_dir, "pip-install") + if not result.passed: + raise RuntimeError("package installation failed; see package logs") + probe = execute_command( + ("graphify", "--version"), + Path("/tmp"), + env, + package_dir, + "version", + ) + if not probe.passed: + raise RuntimeError("installed graphify version probe failed") + version_text = probe.stdout.strip() + prefix = "graphify " + if not version_text.startswith(prefix): + raise RuntimeError(f"unexpected graphify version output: {version_text!r}") + package_version = version_text.removeprefix(prefix) + help_probe = execute_command( + ("graphify", "install", "--help"), + Path("/tmp"), + env, + package_dir, + "install-help", + ) + if not help_probe.passed: + raise RuntimeError("installed graphify install-help probe failed") + public_targets = parse_public_install_targets(help_probe.stdout) + expected_targets = set(catalog_targets) + if public_targets != expected_targets: + missing = sorted(public_targets - expected_targets) + stale = sorted(expected_targets - public_targets) + raise RuntimeError( + "sandbox target catalog does not match public install targets " + f"(missing specs: {missing}; stale specs: {stale})" + ) + return { + "install_argv": list(argv), + "source": str(roots.source), + "repo_mount": str(roots.repo_mount), + "repo_mount_read_only": probe_read_only(roots.repo_mount), + "version": version_text, + "package_version": package_version, + "public_install_targets": sorted(public_targets), + } + + +def parse_public_install_targets(help_text: str) -> set[str]: + """Read the public platform list and include the dedicated VS Code command.""" + for line in help_text.splitlines(): + if line.startswith("Platforms: "): + generic = { + item.strip() + for item in line.removeprefix("Platforms: ").split(",") + if item.strip() + } + return generic | {"vscode"} + raise RuntimeError("graphify install help did not publish a Platforms line") + + +def probe_read_only(path: Path) -> bool: + probe = path / ".graphify-install-sandbox-write-probe" + try: + probe.write_text("unsafe", encoding="utf-8") + except OSError: + return True + probe.unlink(missing_ok=True) + return False + + +def run_purge_check( + roots: SandboxRoots, + *, + executor: CommandExecutor = execute_command, +) -> dict[str, object]: + reset_scenario_roots(roots) + graph = roots.project / "graphify-out" / "graph.json" + graph.parent.mkdir(parents=True, exist_ok=True) + graph.write_text("{}\n", encoding="utf-8") + sentinel = roots.project / "user-owned.txt" + sentinel.write_text(USER_SENTINEL, encoding="utf-8") + artifact_dir = roots.output / "purge" + command = executor( + ("graphify", "uninstall", "--purge"), + roots.project, + command_environment(roots), + artifact_dir, + "purge", + ) + passed = ( + command.passed + and not graph.parent.exists() + and sentinel.read_text(encoding="utf-8") == USER_SENTINEL + ) + result = { + "status": "PASS" if passed else "FAIL", + "command": command.as_dict(), + "graphify_out_removed": not graph.parent.exists(), + "unrelated_content_preserved": sentinel.is_file() + and sentinel.read_text(encoding="utf-8") == USER_SENTINEL, + } + artifact_dir.mkdir(parents=True, exist_ok=True) + (artifact_dir / "result.json").write_text( + json.dumps(result, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + return result diff --git a/tools/install_sandbox/models.py b/tools/install_sandbox/models.py new file mode 100644 index 000000000..3782a5f21 --- /dev/null +++ b/tools/install_sandbox/models.py @@ -0,0 +1,184 @@ +"""Pure contract models shared by the host and in-container runner.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path +from typing import Any, Mapping + + +class Scope(str, Enum): + USER = "user" + PROJECT = "project" + + +class Root(str, Enum): + HOME = "home" + XDG = "xdg" + PROJECT = "project" + USER_CWD = "user_cwd" + + +class EffectKind(str, Enum): + SKILL = "skill" + FILE = "file" + SECTION = "section" + JSON = "json" + TEXT = "text" + REMINDER_PLUGIN = "reminder_plugin" + + +class CommandMode(str, Enum): + DIRECT = "direct" + + +@dataclass(frozen=True) +class Effect: + kind: EffectKind + root: Root + path: str + source: str | None = None + payload_mode: str = "exact" + marker: str | None = None + entries: Mapping[str, Any] = field(default_factory=dict) + required_text: tuple[str, ...] = () + forbidden_text: tuple[str, ...] = () + reference_bundle: str | None = None + preserves_backup: bool = False + + +@dataclass(frozen=True) +class ScopeSpec: + effects: tuple[Effect, ...] + install_mode: CommandMode | None = None + uninstall_mode: CommandMode | None = None + + +@dataclass(frozen=True) +class TargetSpec: + name: str + scopes: Mapping[Scope, ScopeSpec] + unsupported: Mapping[Scope, str] = field(default_factory=dict) + limitations: tuple[str, ...] = () + universal_uninstall_scopes: frozenset[Scope] = frozenset() + + def supports(self, scope: Scope) -> bool: + return scope in self.scopes and scope not in self.unsupported + + +@dataclass(frozen=True) +class Scenario: + target: TargetSpec + scope: Scope + + @property + def name(self) -> str: + return f"{self.target.name}-{self.scope.value}" + + @property + def contract(self) -> ScopeSpec: + return self.target.scopes[self.scope] + + +@dataclass(frozen=True) +class Observation: + root: Root + path: str + exists: bool + is_file: bool + text: str | None = None + json_value: Any = None + + +@dataclass(frozen=True) +class ValidationResult: + check: str + passed: bool + detail: str + + def as_dict(self) -> dict[str, object]: + return { + "check": self.check, + "passed": self.passed, + "detail": self.detail, + } + + +@dataclass(frozen=True) +class CommandResult: + argv: tuple[str, ...] + cwd: str + exit_code: int + stdout: str + stderr: str + timed_out: bool = False + + @property + def passed(self) -> bool: + return self.exit_code == 0 and not self.timed_out + + def as_dict(self) -> dict[str, object]: + return { + "argv": list(self.argv), + "cwd": self.cwd, + "exit_code": self.exit_code, + "timed_out": self.timed_out, + } + + +@dataclass +class PhaseResult: + name: str + status: str + validations: list[ValidationResult] = field(default_factory=list) + command: CommandResult | None = None + + def as_dict(self) -> dict[str, object]: + return { + "name": self.name, + "status": self.status, + "command": self.command.as_dict() if self.command else None, + "validations": [item.as_dict() for item in self.validations], + } + + +@dataclass +class ScenarioResult: + scenario: str + target: str + scope: str + status: str + phases: list[PhaseResult] + limitations: tuple[str, ...] = () + artifact_dir: str | None = None + + def as_dict(self) -> dict[str, object]: + return { + "scenario": self.scenario, + "target": self.target, + "scope": self.scope, + "status": self.status, + "limitations": list(self.limitations), + "artifact_dir": self.artifact_dir, + "phases": [phase.as_dict() for phase in self.phases], + } + + +@dataclass(frozen=True) +class SandboxRoots: + home: Path + xdg: Path + project: Path + user_cwd: Path + source: Path + repo_mount: Path + output: Path + + def effect_roots(self) -> Mapping[Root, Path]: + return { + Root.HOME: self.home, + Root.XDG: self.xdg, + Root.PROJECT: self.project, + Root.USER_CWD: self.user_cwd, + } diff --git a/tools/install_sandbox/reporting.py b/tools/install_sandbox/reporting.py new file mode 100644 index 000000000..107100aa0 --- /dev/null +++ b/tools/install_sandbox/reporting.py @@ -0,0 +1,108 @@ +"""Concise manifest and Markdown reporting.""" + +from __future__ import annotations + +import json +from collections import Counter +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterable, Mapping + +from .models import ScenarioResult + + +def utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat() + + +def summarize(results: Iterable[ScenarioResult]) -> dict[str, int]: + counts = Counter(result.status for result in results) + return dict(sorted(counts.items())) + + +def build_manifest( + *, + repo: Path, + selection: Mapping[str, object], + package: Mapping[str, object], + results: list[ScenarioResult], + purge: Mapping[str, object], +) -> dict[str, object]: + return { + "harness": "graphify-install-sandbox-v8", + "generated_at": utc_now(), + "repo": str(repo), + "selection": dict(selection), + "package": dict(package), + "summary": summarize(results), + "scenario_count": len(results), + "scenarios": [result.as_dict() for result in results], + "purge": dict(purge), + } + + +def render_report(manifest: Mapping[str, Any]) -> str: + summary = manifest.get("summary", {}) + summary_text = ", ".join(f"{key}={value}" for key, value in summary.items()) + lines = [ + "# Graphify install sandbox", + "", + f"Status: {summary_text or 'no scenarios'}; purge={manifest['purge']['status']}", + "", + "| Scenario | Status | Uninstall |", + "|---|---:|---:|", + ] + limitations: list[str] = [] + for scenario in manifest.get("scenarios", []): + phases = scenario.get("phases", []) + uninstall = next( + ( + phase.get("status", "UNKNOWN") + for phase in phases + if phase.get("name") == "uninstall" + ), + "N/A", + ) + lines.append( + f"| {scenario['scenario']} | {scenario['status']} | {uninstall} |" + ) + for limitation in scenario.get("limitations", []): + if limitation not in limitations: + limitations.append(limitation) + if limitations: + lines.extend(["", "## Runtime limitations", ""]) + lines.extend(f"- {item}" for item in limitations) + failures = [ + scenario + for scenario in manifest.get("scenarios", []) + if scenario.get("status") == "FAIL" + ] + if failures: + lines.extend(["", "## Failed scenarios", ""]) + for scenario in failures: + failed_phases = [ + phase["name"] + for phase in scenario.get("phases", []) + if phase.get("status") == "FAIL" + ] + lines.append( + f"- `{scenario['scenario']}`: {', '.join(failed_phases)} " + f"([{scenario['artifact_dir']}/result.json]({scenario['artifact_dir']}/result.json))" + ) + lines.extend( + [ + "", + "Detailed command logs and filesystem snapshots are under `scenarios/`.", + "", + ] + ) + return "\n".join(lines) + + +def write_run_outputs(output: Path, manifest: Mapping[str, Any]) -> None: + output.mkdir(parents=True, exist_ok=True) + (output / "manifest.json").write_text( + json.dumps(manifest, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + (output / "report.md").write_text(render_report(manifest), encoding="utf-8") diff --git a/tools/install_sandbox/run.py b/tools/install_sandbox/run.py new file mode 100644 index 000000000..ce107a9c3 --- /dev/null +++ b/tools/install_sandbox/run.py @@ -0,0 +1,180 @@ +"""Run Graphify installer lifecycle contracts inside isolated Docker roots.""" + +from __future__ import annotations + +import argparse +import os +import signal +import sys +from collections.abc import Generator, Iterable +from contextlib import contextmanager +from pathlib import Path + +if __package__ in {None, ""}: + sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from tools.install_sandbox.docker import run_sandbox +from tools.install_sandbox.run_artifacts import ( + MANAGED_ROOT, + ArtifactError, + RunArtifacts, + prune_managed_runs, +) +from tools.install_sandbox.specs import SpecError, catalog_names, load_catalog + + +HARNESS_SPEC_DIR = Path(__file__).resolve().parent / "specs" + + +class RunInterrupted(Exception): + """Raised when the host catches a signal while a run is active.""" + + def __init__(self, signum: int) -> None: + self.signum = signum + super().__init__(f"caught signal {signum}") + + +def parser(target_names: Iterable[str]) -> argparse.ArgumentParser: + result = argparse.ArgumentParser(description=__doc__) + result.add_argument("--repo", required=True, type=Path) + selection = result.add_mutually_exclusive_group(required=True) + selection.add_argument("--target", choices=tuple(target_names)) + selection.add_argument("--all", action="store_true", dest="all_targets") + result.add_argument( + "--scope", + choices=("user", "project", "both"), + default="both", + ) + result.add_argument("--output", type=Path) + return result + + +@contextmanager +def _caught_run_signals() -> Generator[None, None, None]: + watched = (signal.SIGINT, signal.SIGTERM) + previous = {signum: signal.getsignal(signum) for signum in watched} + + def interrupt(signum, _frame) -> None: + raise RunInterrupted(signum) + + try: + for signum in watched: + signal.signal(signum, interrupt) + yield + finally: + for signum, handler in previous.items(): + signal.signal(signum, handler) + + +def classify_result(exit_code: int, *, complete: bool) -> tuple[str, int]: + """Classify the raw runner result without hiding nonzero exit codes.""" + + if complete and exit_code == 0: + return "passed", exit_code + if complete and exit_code == 1: + return "failed", exit_code + return "incomplete", exit_code or 2 + + +def _finish(artifacts: RunArtifacts, state: str, exit_code: int) -> int: + stream = "stderr" if exit_code else "stdout" + artifacts.logger.write( + stream, + f"run finished: state={state} exit_code={exit_code}\n", + ) + artifacts.finalize(state, exit_code) + if artifacts.managed: + prune_managed_runs(MANAGED_ROOT, keep=5) + return exit_code + + +def _forward_output( + artifacts: RunArtifacts, + phase: str, + stream: str, + text: str, +) -> None: + if artifacts.metadata["phase"] != phase: + artifacts.set_phase(phase) + artifacts.logger.write(stream, text) + + +def main( + argv: list[str] | None = None, + *, + spec_dir: Path = HARNESS_SPEC_DIR, +) -> int: + args = parser(catalog_names(spec_dir)).parse_args(argv) + repo = args.repo.expanduser().resolve() + if not (repo / "pyproject.toml").is_file() or not (repo / "graphify").is_dir(): + print(f"error: not a Graphify source checkout: {repo}", file=sys.stderr) + return 2 + + if args.output is None: + prune_managed_runs(MANAGED_ROOT, keep=5) + try: + artifacts = RunArtifacts.allocate( + repo=repo, + target=args.target, + all_targets=args.all_targets, + scope=args.scope, + output=args.output, + ) + except (ArtifactError, OSError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + artifacts.logger.write("stdout", f"artifacts: {artifacts.output}\n") + try: + with _caught_run_signals(): + artifacts.logger.write( + "stdout", + f"loading strict catalog from {spec_dir.resolve()}\n", + ) + catalog = load_catalog(spec_dir) + artifacts.logger.write( + "stdout", + f"catalog preflight passed: {len(catalog)} targets\n", + ) + runtime = os.environ.get("GRAPHIFY_SANDBOX_RUNTIME", "docker") + exit_code = run_sandbox( + repo=repo, + output=artifacts.output, + target=args.target, + all_targets=args.all_targets, + scope=args.scope, + runtime=runtime, + on_phase=artifacts.set_phase, + on_output=lambda phase, stream, text: _forward_output( + artifacts, + phase, + stream, + text, + ), + ) + except SpecError as exc: + artifacts.logger.write("stderr", f"catalog preflight failed: {exc}\n") + return _finish(artifacts, "incomplete", 2) + except RunInterrupted as exc: + exit_code = 128 + exc.signum + artifacts.logger.write( + "stderr", + f"run interrupted by signal {exc.signum}\n", + ) + return _finish(artifacts, "interrupted", exit_code) + except Exception as exc: + artifacts.logger.write( + "stderr", + f"host runner failed: {type(exc).__name__}: {exc}\n", + ) + return _finish(artifacts, "incomplete", 2) + + state, final_exit_code = classify_result( + exit_code, + complete=artifacts.complete_outputs(), + ) + return _finish(artifacts, state, final_exit_code) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/install_sandbox/run_artifacts.py b/tools/install_sandbox/run_artifacts.py new file mode 100644 index 000000000..5eaf0fd11 --- /dev/null +++ b/tools/install_sandbox/run_artifacts.py @@ -0,0 +1,581 @@ +"""Host-owned install-sandbox run artifacts and managed-run retention.""" + +from __future__ import annotations + +import json +import os +import re +import shutil +import stat +import sys +import threading +import uuid +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, TextIO + + +SCHEMA_VERSION = 1 +MANAGED_ROOT = Path(__file__).resolve().parent / "out" +TERMINAL_STATES = frozenset({"passed", "failed", "incomplete", "interrupted"}) +VALID_STATES = TERMINAL_STATES | {"running"} +VALID_SCOPES = frozenset({"user", "project", "both"}) + +_SELECTION_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*\Z") +_RUN_ID_RE = re.compile( + r"(?P\d{8}T\d{6}Z)-" + r"(?P[A-Za-z0-9][A-Za-z0-9._-]*)-" + r"(?Puser|project|both)" + r"(?:-(?P\d{2,}))?\Z" +) + +Clock = Callable[[], datetime] +WarningSink = Callable[[str], None] + + +class ArtifactError(ValueError): + """An unsafe or invalid artifact destination was requested.""" + + +def _utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def _as_utc(value: datetime) -> datetime: + if value.tzinfo is None: + raise ArtifactError("artifact timestamps must be timezone-aware") + return value.astimezone(timezone.utc) + + +def _timestamp(value: datetime) -> str: + return _as_utc(value).isoformat().replace("+00:00", "Z") + + +def _parse_timestamp(value: object) -> datetime: + if not isinstance(value, str): + raise ValueError("timestamp is not a string") + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + if parsed.tzinfo is None: + raise ValueError("timestamp has no timezone") + return parsed.astimezone(timezone.utc) + + +def _atomic_write_json(path: Path, value: Mapping[str, Any]) -> None: + """Replace *path* with one complete JSON document from the same directory.""" + + encoded = (json.dumps(value, indent=2, sort_keys=True) + "\n").encode("utf-8") + temporary = path.parent / f".{path.name}.{uuid.uuid4().hex}.tmp" + descriptor = os.open(temporary, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644) + try: + with os.fdopen(descriptor, "wb") as stream: + stream.write(encoded) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + try: + temporary.unlink() + except FileNotFoundError: + pass + + +def _resolved(path: Path) -> Path: + return path.expanduser().resolve(strict=False) + + +def _is_within(path: Path, root: Path) -> bool: + try: + path.relative_to(root) + except ValueError: + return False + return True + + +def _selection_name(*, target: str | None, all_targets: bool) -> str: + if bool(target) == bool(all_targets): + raise ArtifactError("select exactly one target mode") + selection = "all" if all_targets else str(target) + if not _SELECTION_RE.fullmatch(selection): + raise ArtifactError(f"unsafe selection for run ID: {selection!r}") + return selection + + +def make_run_id( + *, + target: str | None, + all_targets: bool, + scope: str, + started_at: datetime, + collision: int = 1, +) -> str: + """Return the sortable managed run ID for a selection and UTC instant.""" + + if scope not in VALID_SCOPES: + raise ArtifactError(f"unsupported scope: {scope!r}") + if collision < 1: + raise ArtifactError("collision number must be positive") + selection = _selection_name(target=target, all_targets=all_targets) + stamp = _as_utc(started_at).strftime("%Y%m%dT%H%M%SZ") + suffix = "" if collision == 1 else f"-{collision:02d}" + return f"{stamp}-{selection}-{scope}{suffix}" + + +def _prepare_managed_root(root: Path) -> Path: + if root.is_symlink(): + raise ArtifactError(f"managed output root must not be a symlink: {root}") + try: + root.mkdir(parents=True, exist_ok=True) + except OSError as exc: + raise ArtifactError(f"cannot create managed output root {root}: {exc}") from exc + if root.is_symlink() or not root.is_dir(): + raise ArtifactError(f"managed output root is not a real directory: {root}") + return root.resolve() + + +def _allocate_managed_output( + root: Path, + *, + target: str | None, + all_targets: bool, + scope: str, + started_at: datetime, +) -> tuple[str, Path]: + managed_root = _prepare_managed_root(root) + collision = 1 + while True: + run_id = make_run_id( + target=target, + all_targets=all_targets, + scope=scope, + started_at=started_at, + collision=collision, + ) + output = managed_root / run_id + try: + output.mkdir() + except FileExistsError: + collision += 1 + continue + return run_id, output + + +def _allocate_external_output(output: Path, managed_root: Path) -> Path: + requested = output.expanduser() + if requested.is_symlink(): + raise ArtifactError(f"external output must not be a symlink: {requested}") + + lexical_output = Path(os.path.abspath(requested)) + lexical_root = Path(os.path.abspath(managed_root.expanduser())) + resolved_output = requested.resolve(strict=False) + resolved_root = managed_root.expanduser().resolve(strict=False) + if _is_within(lexical_output, lexical_root) or _is_within( + resolved_output, + resolved_root, + ): + raise ArtifactError( + f"explicit --output must be outside the managed output root: {managed_root}" + ) + + if requested.exists(): + if requested.is_symlink() or not requested.is_dir(): + raise ArtifactError(f"external output is not a real directory: {requested}") + try: + next(requested.iterdir()) + except StopIteration: + pass + except OSError as exc: + raise ArtifactError(f"cannot inspect external output {requested}: {exc}") from exc + else: + raise ArtifactError(f"external output must be empty: {requested}") + else: + try: + requested.mkdir(parents=True) + except FileExistsError as exc: + raise ArtifactError( + f"external output changed while it was being allocated: {requested}" + ) from exc + except OSError as exc: + raise ArtifactError(f"cannot create external output {requested}: {exc}") from exc + + if requested.is_symlink() or not requested.is_dir(): + raise ArtifactError(f"external output is not a real directory: {requested}") + return requested.resolve() + + +class PhaseLogger: + """Write phase-labelled host output to ``runner.log`` and the console.""" + + def __init__( + self, + path: Path, + *, + phase: str, + clock: Clock = _utc_now, + stdout: TextIO | None = None, + stderr: TextIO | None = None, + ) -> None: + if not phase: + raise ArtifactError("log phase must not be empty") + self.path = path + self._phase = phase + self._clock = clock + self._stdout = stdout or sys.stdout + self._stderr = stderr or sys.stderr + self._stream = path.open("x", encoding="utf-8", buffering=1) + self._lock = threading.Lock() + + @property + def closed(self) -> bool: + return self._stream.closed + + def set_phase(self, phase: str) -> None: + if not phase: + raise ArtifactError("log phase must not be empty") + with self._lock: + if self.closed: + raise RuntimeError("runner log is closed") + self._phase = phase + + def write(self, stream: str, text: str) -> None: + """Write labelled *text* and mirror it to the named console stream.""" + + if stream not in {"command", "stdout", "stderr"}: + raise ArtifactError(f"unsupported log stream: {stream!r}") + if not isinstance(text, str): + raise TypeError("log text must be a string") + if not text: + return + + with self._lock: + if self.closed: + raise RuntimeError("runner log is closed") + timestamp = _timestamp(self._clock()) + destination = self._stderr if stream == "stderr" else self._stdout + for line in text.splitlines() or [""]: + rendered = f"[{timestamp}] [{self._phase}] [{stream}] {line}\n" + self._stream.write(rendered) + destination.write(rendered) + self._stream.flush() + destination.flush() + + def close(self) -> None: + with self._lock: + if not self.closed: + self._stream.flush() + self._stream.close() + + +@dataclass +class RunArtifacts: + """A host-owned run directory, metadata document, and mirrored logger.""" + + output: Path + run_id: str + managed: bool + started_at: datetime + logger: PhaseLogger + _metadata: dict[str, Any] = field(repr=False) + _clock: Clock = field(repr=False) + + @classmethod + def allocate( + cls, + *, + repo: Path, + target: str | None, + all_targets: bool, + scope: str, + output: Path | None = None, + managed_root: Path = MANAGED_ROOT, + clock: Clock = _utc_now, + phase: str = "host_preflight", + ) -> RunArtifacts: + """Allocate one fresh run and create its host artifacts immediately.""" + + if not phase: + raise ArtifactError("run phase must not be empty") + started_at = _as_utc(clock()) + if output is None: + run_id, allocated_output = _allocate_managed_output( + managed_root, + target=target, + all_targets=all_targets, + scope=scope, + started_at=started_at, + ) + managed = True + else: + run_id = make_run_id( + target=target, + all_targets=all_targets, + scope=scope, + started_at=started_at, + ) + allocated_output = _allocate_external_output(output, managed_root) + managed = False + + selection = { + "all": all_targets, + "target": target, + "scope": scope, + } + metadata: dict[str, Any] = { + "schema_version": SCHEMA_VERSION, + "run_id": run_id, + "managed": managed, + "started_at": _timestamp(started_at), + "updated_at": _timestamp(started_at), + "finished_at": None, + "repository": str(_resolved(repo)), + "output": str(allocated_output), + "selection": selection, + "phase": phase, + "state": "running", + "exit_code": None, + } + _atomic_write_json(allocated_output / "run.json", metadata) + logger = PhaseLogger( + allocated_output / "runner.log", + phase=phase, + clock=clock, + ) + return cls( + output=allocated_output, + run_id=run_id, + managed=managed, + started_at=started_at, + logger=logger, + _metadata=metadata, + _clock=clock, + ) + + @property + def metadata(self) -> Mapping[str, Any]: + return dict(self._metadata) + + def set_phase(self, phase: str) -> None: + """Move a running invocation to another host phase atomically.""" + + if self._metadata["state"] != "running": + raise RuntimeError("cannot change phase after run finalization") + if not phase: + raise ArtifactError("run phase must not be empty") + updated = dict(self._metadata) + updated["phase"] = phase + updated["updated_at"] = _timestamp(self._clock()) + _atomic_write_json(self.output / "run.json", updated) + self._metadata = updated + self.logger.set_phase(phase) + + def complete_outputs(self) -> bool: + """Return whether fresh container-owned manifest and report files exist.""" + + return complete_outputs(self.output, self.started_at) + + def finalize(self, state: str, exit_code: int) -> None: + """Close host logging and atomically record one terminal state.""" + + if state not in TERMINAL_STATES: + raise ArtifactError(f"invalid terminal run state: {state!r}") + if self._metadata["state"] != "running": + raise RuntimeError("run has already been finalized") + if not isinstance(exit_code, int): + raise TypeError("exit code must be an integer") + + finished_at = _as_utc(self._clock()) + updated = dict(self._metadata) + updated.update( + { + "state": state, + "exit_code": exit_code, + "updated_at": _timestamp(finished_at), + "finished_at": _timestamp(finished_at), + } + ) + try: + self.logger.close() + finally: + _atomic_write_json(self.output / "run.json", updated) + self._metadata = updated + + +def fresh_regular_file(path: Path, started_at: datetime) -> bool: + """Return whether *path* is a fresh, non-empty regular file, not a symlink.""" + + try: + details = path.lstat() + except OSError: + return False + return ( + stat.S_ISREG(details.st_mode) + and not path.is_symlink() + and details.st_size > 0 + and details.st_mtime >= _as_utc(started_at).timestamp() + ) + + +def complete_outputs(output: Path, started_at: datetime) -> bool: + """Check the complete, fresh container-owned top-level output contract.""" + + return all( + fresh_regular_file(output / name, started_at) + for name in ("manifest.json", "report.md") + ) + + +@dataclass(frozen=True) +class _PruneCandidate: + path: Path + started_at: datetime + collision: int + + +def _warn_to_stderr(message: str) -> None: + print(f"warning: {message}", file=sys.stderr) + + +def _prune_candidate(path: Path, warn: WarningSink) -> _PruneCandidate | None: + try: + if path.is_symlink(): + warn(f"preserving symlinked managed-root entry: {path}") + return None + if not path.is_dir(): + warn(f"preserving non-directory managed-root entry: {path}") + return None + metadata_path = path / "run.json" + if metadata_path.is_symlink(): + warn(f"preserving run with symlinked metadata: {path}") + return None + try: + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + except FileNotFoundError: + warn(f"preserving unmarked managed-root directory: {path}") + return None + except (OSError, UnicodeError) as exc: + warn(f"preserving unreadable run metadata at {path}: {exc}") + return None + except json.JSONDecodeError as exc: + warn(f"preserving malformed run metadata at {path}: {exc}") + return None + except FileNotFoundError: + return None + except OSError as exc: + warn(f"preserving managed-root entry that could not be inspected at {path}: {exc}") + return None + + if not isinstance(metadata, dict): + warn(f"preserving malformed run metadata at {path}: expected an object") + return None + if metadata.get("schema_version") != SCHEMA_VERSION: + warn(f"preserving run with unknown metadata schema at {path}") + return None + if metadata.get("managed") is not True: + warn(f"preserving externally owned run at {path}") + return None + if metadata.get("run_id") != path.name: + warn(f"preserving run whose ID does not match its directory at {path}") + return None + + match = _RUN_ID_RE.fullmatch(path.name) + if match is None: + warn(f"preserving managed run with malformed ID at {path}") + return None + if metadata.get("output") != str(path.resolve()): + warn(f"preserving run whose recorded output path does not match {path}") + return None + if not isinstance(metadata.get("repository"), str): + warn(f"preserving run without a repository path at {path}") + return None + if not isinstance(metadata.get("phase"), str) or not metadata["phase"]: + warn(f"preserving run without a phase at {path}") + return None + + selection = metadata.get("selection") + expected_selection = match.group("selection") + if ( + not isinstance(selection, dict) + or selection.get("scope") != match.group("scope") + or selection.get("all") is not (expected_selection == "all") + or selection.get("target") + != (None if expected_selection == "all" else expected_selection) + ): + warn(f"preserving run with malformed selection metadata at {path}") + return None + + state = metadata.get("state") + if state == "running": + warn(f"preserving running managed run: {path}") + return None + if state not in TERMINAL_STATES: + warn(f"preserving run with unknown state at {path}") + return None + if not isinstance(metadata.get("exit_code"), int): + warn(f"preserving terminal run without an exit code at {path}") + return None + if not isinstance(metadata.get("finished_at"), str): + warn(f"preserving terminal run without a finished timestamp at {path}") + return None + + try: + started_at = _parse_timestamp(metadata.get("started_at")) + _parse_timestamp(metadata.get("updated_at")) + _parse_timestamp(metadata.get("finished_at")) + except (TypeError, ValueError) as exc: + warn(f"preserving run with malformed timestamps at {path}: {exc}") + return None + collision_text = match.group("collision") + collision = int(collision_text) if collision_text is not None else 1 + return _PruneCandidate(path=path, started_at=started_at, collision=collision) + + +def prune_managed_runs( + root: Path = MANAGED_ROOT, + *, + keep: int = 5, + warn: WarningSink = _warn_to_stderr, +) -> tuple[Path, ...]: + """Remove only surplus, positively marked terminal managed runs.""" + + if keep < 0: + raise ArtifactError("managed-run retention must not be negative") + if root.is_symlink(): + warn(f"refusing to prune symlinked managed output root: {root}") + return () + try: + entries = tuple(root.iterdir()) + except FileNotFoundError: + return () + except OSError as exc: + warn(f"could not inspect managed output root {root}: {exc}") + return () + + candidates = [ + candidate + for entry in entries + if (candidate := _prune_candidate(entry, warn)) is not None + ] + candidates.sort( + key=lambda candidate: ( + candidate.started_at, + candidate.collision, + candidate.path.name, + ), + reverse=True, + ) + + removed: list[Path] = [] + for candidate in candidates[keep:]: + # Re-read the positive ownership marker immediately before deletion. + if _prune_candidate(candidate.path, warn) is None: + continue + try: + shutil.rmtree(candidate.path) + except FileNotFoundError: + continue + except OSError as exc: + warn(f"could not prune managed run {candidate.path}: {exc}") + continue + removed.append(candidate.path) + return tuple(removed) diff --git a/tools/install_sandbox/sandbox_runner.py b/tools/install_sandbox/sandbox_runner.py new file mode 100644 index 000000000..56c48c9cb --- /dev/null +++ b/tools/install_sandbox/sandbox_runner.py @@ -0,0 +1,181 @@ +"""Thin in-container entrypoint for lifecycle execution.""" + +from __future__ import annotations + +import argparse +import os +import sys +from collections.abc import Iterable +from pathlib import Path + +from .lifecycle import ( + copy_and_install_package, + run_purge_check, + run_scenario, + run_universal_uninstall_scenario, +) +from .models import SandboxRoots, Scenario, ScenarioResult, Scope +from .reporting import build_manifest, write_run_outputs +from .specs import SpecError, catalog_names, load_catalog + + +HARNESS_SPEC_DIR = Path(__file__).resolve().parent / "specs" + + +def parser(target_names: Iterable[str]) -> argparse.ArgumentParser: + result = argparse.ArgumentParser(description=__doc__) + selection = result.add_mutually_exclusive_group(required=True) + selection.add_argument("--target", choices=tuple(target_names)) + selection.add_argument("--all", action="store_true", dest="all_targets") + result.add_argument( + "--scope", + choices=("user", "project", "both"), + default="both", + ) + return result + + +def roots_from_environment() -> SandboxRoots: + required = { + "home": "HOME", + "xdg": "XDG_CONFIG_HOME", + "project": "GRAPHIFY_SANDBOX_PROJECT", + "user_cwd": "GRAPHIFY_SANDBOX_USER_CWD", + "source": "GRAPHIFY_SANDBOX_SOURCE", + "repo_mount": "GRAPHIFY_SANDBOX_REPO", + "output": "GRAPHIFY_SANDBOX_OUTPUT", + } + values: dict[str, Path] = {} + for name, variable in required.items(): + raw = os.environ.get(variable) + if not raw: + raise RuntimeError(f"missing required container environment: {variable}") + values[name] = Path(raw) + resolved = [path.resolve() for path in values.values()] + if len(set(resolved)) != len(resolved): + raise RuntimeError("sandbox roots must all be distinct") + return SandboxRoots(**values) + + +def _unsupported(target, scope: Scope, output: Path) -> ScenarioResult: + reason = target.unsupported[scope] + return ScenarioResult( + scenario=f"{target.name}-{scope.value}", + target=target.name, + scope=scope.value, + status="UNSUPPORTED", + phases=[], + limitations=(reason, *target.limitations), + artifact_dir=None, + ) + + +def main( + argv: list[str] | None = None, + *, + spec_dir: Path = HARNESS_SPEC_DIR, +) -> int: + args = parser(catalog_names(spec_dir)).parse_args(argv) + roots = roots_from_environment() + try: + catalog = load_catalog(spec_dir) + except SpecError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + package = copy_and_install_package(roots, catalog) + if package["repo_mount_read_only"] is not True: + raise RuntimeError("repository mount is writable; refusing unsafe sandbox run") + + names = list(catalog) if args.all_targets else [args.target] + scopes = ( + list(Scope) + if args.scope == "both" + else [Scope(args.scope)] + ) + results: list[ScenarioResult] = [] + for name in names: + target = catalog[name] + for scope in scopes: + if not target.supports(scope): + results.append(_unsupported(target, scope, roots.output)) + continue + print(f"==> {name} / {scope.value}", flush=True) + try: + results.append( + run_scenario( + Scenario(target=target, scope=scope), + roots, + expected_version=str(package["package_version"]), + ) + ) + except Exception as exc: + print(f"scenario {name}-{scope.value} crashed: {exc}", file=sys.stderr) + results.append( + ScenarioResult( + scenario=f"{name}-{scope.value}", + target=name, + scope=scope.value, + status="FAIL", + phases=[], + limitations=target.limitations, + artifact_dir=f"scenarios/{name}-{scope.value}", + ) + ) + if args.all_targets: + for scope in scopes: + selected = [ + Scenario(target=target, scope=scope) + for target in catalog.values() + if scope in target.universal_uninstall_scopes + ] + preserved = ( + [ + Scenario(target=item.target, scope=Scope.USER) + for item in selected + if item.target.supports(Scope.USER) + ] + if scope is Scope.PROJECT + else [] + ) + scenario_name = f"universal-uninstall-{scope.value}" + print(f"==> {scenario_name}", flush=True) + try: + results.append( + run_universal_uninstall_scenario( + selected, + roots, + preserved_scenarios=preserved, + expected_version=str(package["package_version"]), + ) + ) + except Exception as exc: + print(f"scenario {scenario_name} crashed: {exc}", file=sys.stderr) + results.append( + ScenarioResult( + scenario=scenario_name, + target="multiple", + scope=scope.value, + status="FAIL", + phases=[], + artifact_dir=f"scenarios/{scenario_name}", + ) + ) + purge = run_purge_check(roots) + manifest = build_manifest( + repo=roots.repo_mount, + selection={ + "target": args.target, + "all": args.all_targets, + "scope": args.scope, + }, + package=package, + results=results, + purge=purge, + ) + write_run_outputs(roots.output, manifest) + failed = any(result.status == "FAIL" for result in results) + return 1 if failed or purge["status"] == "FAIL" else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/install_sandbox/specs.py b/tools/install_sandbox/specs.py new file mode 100644 index 000000000..839aa8acd --- /dev/null +++ b/tools/install_sandbox/specs.py @@ -0,0 +1,270 @@ +"""Strict loading for the compact target-fact catalog.""" + +from __future__ import annotations + +from pathlib import Path, PurePosixPath +from typing import Any, Mapping + +import yaml + +from .models import ( + CommandMode, + Effect, + EffectKind, + Root, + Scope, + ScopeSpec, + TargetSpec, +) + + +_TOP_KEYS = { + "scopes", + "unsupported", + "limitations", + "universal_uninstall_scopes", +} +_SCOPE_KEYS = {"effects", "install_mode", "uninstall_mode"} +_EFFECT_KEYS = { + "kind", + "root", + "path", + "source", + "payload_mode", + "marker", + "entries", + "required_text", + "forbidden_text", + "reference_bundle", + "preserves_backup", +} +_PAYLOAD_MODES = {"exact", "prefix", "suffix", "contains", "frontmatter-body"} + + +class SpecError(ValueError): + """Raised when target facts are incomplete, unsafe, or ambiguous.""" + + +def _mapping(value: Any, label: str) -> Mapping[str, Any]: + if not isinstance(value, dict): + raise SpecError(f"{label} must be a mapping") + if not all(isinstance(key, str) for key in value): + raise SpecError(f"{label} keys must be strings") + return value + + +def _unknown(mapping: Mapping[str, Any], allowed: set[str], label: str) -> None: + unknown = sorted(set(mapping) - allowed) + if unknown: + raise SpecError(f"{label} has unknown keys: {', '.join(unknown)}") + + +def _safe_relative(value: Any, label: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise SpecError(f"{label} must be a non-empty string") + if "\\" in value: + raise SpecError(f"{label} must use POSIX separators") + path = PurePosixPath(value) + if path.is_absolute() or any(part in {"", ".", ".."} for part in path.parts): + raise SpecError(f"{label} must be a safe relative path: {value!r}") + return value + + +def _strings(value: Any, label: str) -> tuple[str, ...]: + if value is None: + return () + if not isinstance(value, list) or not all( + isinstance(item, str) and item for item in value + ): + raise SpecError(f"{label} must be a list of non-empty strings") + return tuple(value) + + +def _command_mode( + mapping: Mapping[str, Any], + key: str, + label: str, +) -> CommandMode | None: + if key not in mapping: + return None + value = mapping[key] + if not isinstance(value, str): + raise SpecError(f"{label} must be the scalar 'direct'") + try: + return CommandMode(value) + except ValueError as exc: + raise SpecError(f"{label} is invalid: {value!r}") from exc + + +def _scopes(value: Any, label: str) -> frozenset[Scope]: + names = _strings(value, label) + if len(names) != len(set(names)): + raise SpecError(f"{label} must not contain duplicates") + try: + return frozenset(Scope(name) for name in names) + except ValueError as exc: + raise SpecError(f"{label} contains an invalid scope") from exc + + +def _effect(value: Any, label: str) -> Effect: + raw = _mapping(value, label) + _unknown(raw, _EFFECT_KEYS, label) + try: + kind = EffectKind(raw.get("kind", "file")) + except ValueError as exc: + raise SpecError(f"{label}.kind is invalid: {raw.get('kind')!r}") from exc + try: + root = Root(raw["root"]) + except KeyError as exc: + raise SpecError(f"{label}.root is required") from exc + except ValueError as exc: + raise SpecError(f"{label}.root is invalid: {raw.get('root')!r}") from exc + path = _safe_relative(raw.get("path"), f"{label}.path") + source = raw.get("source") + if source is not None: + source = _safe_relative(source, f"{label}.source") + payload_mode = raw.get("payload_mode", "exact") + if payload_mode not in _PAYLOAD_MODES: + raise SpecError(f"{label}.payload_mode is invalid: {payload_mode!r}") + marker = raw.get("marker") + if marker is not None and (not isinstance(marker, str) or not marker): + raise SpecError(f"{label}.marker must be a non-empty string") + entries = raw.get("entries", {}) + entries = _mapping(entries, f"{label}.entries") + reference_bundle = raw.get("reference_bundle") + if reference_bundle is not None: + reference_bundle = _safe_relative( + reference_bundle, f"{label}.reference_bundle" + ) + if "/" in reference_bundle: + raise SpecError(f"{label}.reference_bundle must be one directory name") + if kind is EffectKind.SKILL and source is None: + raise SpecError(f"{label}.source is required for skill effects") + if kind is EffectKind.SECTION and marker is None: + raise SpecError(f"{label}.marker is required for section effects") + required_text = _strings(raw.get("required_text"), f"{label}.required_text") + if kind is EffectKind.SECTION and source is None and not required_text: + raise SpecError( + f"{label} section effects require source or required_text" + ) + if kind is EffectKind.JSON and not entries: + raise SpecError(f"{label}.entries is required for JSON effects") + preserves_backup = raw.get("preserves_backup", False) + if not isinstance(preserves_backup, bool): + raise SpecError(f"{label}.preserves_backup must be a boolean") + if preserves_backup and kind is not EffectKind.JSON: + raise SpecError( + f"{label}.preserves_backup is only valid for JSON effects" + ) + return Effect( + kind=kind, + root=root, + path=path, + source=source, + payload_mode=payload_mode, + marker=marker, + entries=entries, + required_text=required_text, + forbidden_text=_strings( + raw.get("forbidden_text"), f"{label}.forbidden_text" + ), + reference_bundle=reference_bundle, + preserves_backup=preserves_backup, + ) + + +def load_target(path: Path) -> TargetSpec: + try: + loaded = yaml.safe_load(path.read_text(encoding="utf-8")) + except (OSError, yaml.YAMLError) as exc: + raise SpecError(f"cannot load {path}: {exc}") from exc + raw = _mapping(loaded, path.name) + _unknown(raw, _TOP_KEYS, path.name) + scopes_raw = _mapping(raw.get("scopes"), f"{path.name}.scopes") + scopes: dict[Scope, ScopeSpec] = {} + for scope_name, scope_value in scopes_raw.items(): + try: + scope = Scope(scope_name) + except ValueError as exc: + raise SpecError( + f"{path.name}.scopes has invalid scope {scope_name!r}" + ) from exc + scope_raw = _mapping(scope_value, f"{path.name}.{scope.value}") + _unknown(scope_raw, _SCOPE_KEYS, f"{path.name}.{scope.value}") + effects_raw = scope_raw.get("effects") + if not isinstance(effects_raw, list) or not effects_raw: + raise SpecError(f"{path.name}.{scope.value}.effects must be non-empty") + scopes[scope] = ScopeSpec( + effects=tuple( + _effect(item, f"{path.name}.{scope.value}.effects[{index}]") + for index, item in enumerate(effects_raw) + ), + install_mode=_command_mode( + scope_raw, + "install_mode", + f"{path.name}.{scope.value}.install_mode", + ), + uninstall_mode=_command_mode( + scope_raw, + "uninstall_mode", + f"{path.name}.{scope.value}.uninstall_mode", + ), + ) + + unsupported_raw = _mapping( + raw.get("unsupported", {}), f"{path.name}.unsupported" + ) + unsupported: dict[Scope, str] = {} + for scope_name, reason in unsupported_raw.items(): + try: + scope = Scope(scope_name) + except ValueError as exc: + raise SpecError( + f"{path.name}.unsupported has invalid scope {scope_name!r}" + ) from exc + if not isinstance(reason, str) or not reason.strip(): + raise SpecError( + f"{path.name}.unsupported.{scope.value} must explain why" + ) + if scope in scopes: + raise SpecError( + f"{path.name}.{scope.value} cannot be supported and unsupported" + ) + unsupported[scope] = reason + + covered = set(scopes) | set(unsupported) + if covered != set(Scope): + missing = sorted(scope.value for scope in set(Scope) - covered) + raise SpecError(f"{path.name} does not classify scopes: {', '.join(missing)}") + universal_uninstall_scopes = _scopes( + raw.get("universal_uninstall_scopes"), + f"{path.name}.universal_uninstall_scopes", + ) + unavailable_universal_scopes = universal_uninstall_scopes - set(scopes) + if unavailable_universal_scopes: + names = ", ".join( + sorted(scope.value for scope in unavailable_universal_scopes) + ) + raise SpecError( + f"{path.name}.universal_uninstall_scopes are not supported: {names}" + ) + return TargetSpec( + name=path.stem, + scopes=scopes, + unsupported=unsupported, + limitations=_strings(raw.get("limitations"), f"{path.name}.limitations"), + universal_uninstall_scopes=universal_uninstall_scopes, + ) + + +def catalog_names(spec_dir: Path) -> tuple[str, ...]: + """Return the target catalog declared by the YAML filenames.""" + return tuple( + path.stem + for path in sorted(spec_dir.glob("*.yaml"), key=lambda item: item.stem) + ) + + +def load_catalog(spec_dir: Path) -> dict[str, TargetSpec]: + paths = sorted(spec_dir.glob("*.yaml"), key=lambda item: item.stem) + return {path.stem: load_target(path) for path in paths} diff --git a/tools/install_sandbox/specs/README.md b/tools/install_sandbox/specs/README.md new file mode 100644 index 000000000..352efd04c --- /dev/null +++ b/tools/install_sandbox/specs/README.md @@ -0,0 +1,138 @@ +# Install sandbox spec authority + +This directory is the source-controlled target-fact catalog for the Tier 1 +install sandbox. This guide describes the current YAML/loader boundary; it +does not make the current fields or file shape permanent. + +## Data flow + +```text +specs/*.yaml + | + v +YAML raw mappings + | + v +validated typed catalog (TargetSpec, ScopeSpec, Effect) + | + v +target/scope and aggregate scenarios + | + v +generic lifecycle execution and validation +``` + +`yaml.safe_load()` produces ordinary mappings and lists. The loader validates +their vocabulary and values, applies generic defaults, and converts them to +typed models. `load_catalog()` keys those models by YAML filename stem. The +runner then iterates the typed catalog to create scenarios; lifecycle and +effect code interpret those scenarios without maintaining target-name tables. + +The YAML is the oracle for expected Graphify-owned file effects. It must not be +generated from the installer output or behavior it is meant to test. + +## Current ownership + +| Concern | Current owner | +| --- | --- | +| Catalog membership, target identity, and order | `*.yaml` filename stems, sorted lexically | +| Supported and unsupported scopes, expected effects, command-mode exceptions, limitations, and aggregate-uninstall eligibility | Each target's YAML | +| Allowed schema vocabulary, validation, typed conversion, and defaults | `specs.py` and `models.py` | +| Scenario construction, command derivation, lifecycle steps, filesystem checks, and reporting | Generic Python over the loaded catalog | + +Current loader defaults include `file` for an omitted effect kind, `exact` for +an omitted payload mode, and no direct command mode when `install_mode` or +`uninstall_mode` is omitted. Defaults are schema behavior, so they belong in +Python rather than being repeated in every target. + +JSON effects whose current installer deliberately creates and preserves the +pre-modification `.graphify-bak` file declare +`preserves_backup: true`. The target spec owns whether that artifact exists; +the loader derives its sibling path and the lifecycle validator proves that it +retains the seeded user JSON through uninstall. + +## Classify data before adding it + +Use one of these categories: + +1. **Irreducible target fact.** A fact that differs by target and cannot be + inferred safely, such as an expected path, payload source, marker, or + documented target limitation. Put it in that target's YAML. +2. **Deterministic derivation.** A value that follows from filename, scope, + existing facts, or a target-independent convention. Derive it in Python + instead of storing it. +3. **Cross-target harness policy.** A generic rule such as which lifecycle + phases to run or how to validate unexpected changes. Implement it once in + Python and apply it to the loaded catalog. Do not encode the policy with a + list of real target names. +4. **Product observation.** Something the current installer happens to do. + Record it as evidence or a product defect. Do not turn it into the expected + result merely because the harness observed it. + +An observation may motivate a spec correction, but the expected fact needs an +independent basis. Otherwise a product bug can redefine the oracle and make +its own test pass. + +## `universal_uninstall_scopes` today + +`universal_uninstall_scopes` is an explicit present-day policy fact: it states +which supported scopes for a target participate in the grouped +universal-uninstall scenarios. The runner obtains each group by filtering the +loaded catalog on this field. + +The field is explicit because eligibility is not currently safe to infer from +scope support, effect paths, or command mode. This is not a commitment to keep +the field forever. If a target-independent derivation becomes correct and +provable, move that rule to Python and remove the redundant declarations. + +Repeated values alone do not prove that an oracle fact is derivable. A common +value can still encode target-specific knowledge, and moving it into code +without a valid rule would only hide a target-name grouping. + +## Catalog-driven iteration + +Do not create a parallel catalog or grouping: + +```python +# Forbidden: placeholder names standing in for a manually maintained real list. +TARGETS = ("target_one", "target_two") +UNIVERSAL_USER_TARGETS = {"target_one"} +``` + +Iterate the authoritative catalog and select on typed facts: + +```python +catalog = load_catalog(spec_dir) + +for target in catalog.values(): + run_target(target) + +selected = [ + target + for target in catalog.values() + if scope in target.universal_uninstall_scopes +] +``` + +The same rule applies to tests. Unit tests may use small fictional catalogs, +but production target names must not become a second catalog or policy table +in Python. + +## Change checklist + +Before adding or changing target-related data: + +- Read the parent sandbox README and this guide. +- Classify the proposed data as a target fact, derivation, harness policy, or + product observation. +- For a proposed field, show why it cannot be derived safely from filename, + scope, existing facts, or a target-independent convention. +- Keep target-specific facts in YAML and schema vocabulary, validation, + defaults, and generic mechanics in Python. +- Confirm no Python collection of real target names was introduced for + catalog membership, grouping, or policy. +- Confirm expected results remain independent of the product being tested. +- Treat a failing product observation as a defect; do not weaken the oracle or + change production behavior unless that work was explicitly requested. +- Update this guide if the ownership boundary changes, without describing the + resulting schema as immutable. diff --git a/tools/install_sandbox/specs/agents.yaml b/tools/install_sandbox/specs/agents.yaml new file mode 100644 index 000000000..827614113 --- /dev/null +++ b/tools/install_sandbox/specs/agents.yaml @@ -0,0 +1,17 @@ +universal_uninstall_scopes: [user] +scopes: + user: + uninstall_mode: direct + effects: + - kind: skill + root: home + path: .agents/skills/graphify/SKILL.md + source: graphify/skill-agents.md + reference_bundle: agents + project: + effects: + - kind: skill + root: project + path: .agents/skills/graphify/SKILL.md + source: graphify/skill-agents.md + reference_bundle: agents diff --git a/tools/install_sandbox/specs/aider.yaml b/tools/install_sandbox/specs/aider.yaml new file mode 100644 index 000000000..0d020df9c --- /dev/null +++ b/tools/install_sandbox/specs/aider.yaml @@ -0,0 +1,19 @@ +universal_uninstall_scopes: [user] +scopes: + user: + effects: + - kind: skill + root: home + path: .aider/graphify/SKILL.md + source: graphify/skill-aider.md + project: + effects: + - kind: skill + root: project + path: .aider/graphify/SKILL.md + source: graphify/skill-aider.md + - kind: section + root: project + path: AGENTS.md + marker: "## graphify" + source: graphify/always_on/agents-md.md diff --git a/tools/install_sandbox/specs/amp.yaml b/tools/install_sandbox/specs/amp.yaml new file mode 100644 index 000000000..3789caa09 --- /dev/null +++ b/tools/install_sandbox/specs/amp.yaml @@ -0,0 +1,22 @@ +universal_uninstall_scopes: [user] +scopes: + user: + uninstall_mode: direct + effects: + - kind: skill + root: home + path: .config/agents/skills/graphify/SKILL.md + source: graphify/skill-amp.md + reference_bundle: amp + project: + effects: + - kind: skill + root: project + path: .agents/skills/graphify/SKILL.md + source: graphify/skill-amp.md + reference_bundle: amp + - kind: section + root: project + path: AGENTS.md + marker: "## graphify" + source: graphify/always_on/agents-md.md diff --git a/tools/install_sandbox/specs/antigravity-windows.yaml b/tools/install_sandbox/specs/antigravity-windows.yaml new file mode 100644 index 000000000..42413fd4d --- /dev/null +++ b/tools/install_sandbox/specs/antigravity-windows.yaml @@ -0,0 +1,17 @@ +limitations: + - Linux Docker verifies packaged Antigravity-Windows payload consistency only; Windows paths, permissions, shells, cleanup, and runtime discovery are not verified. +scopes: + user: + effects: + - kind: skill + root: home + path: .gemini/config/skills/graphify/SKILL.md + source: graphify/skill-windows.md + reference_bundle: windows + project: + effects: + - kind: skill + root: project + path: .agents/skills/graphify/SKILL.md + source: graphify/skill-windows.md + reference_bundle: windows diff --git a/tools/install_sandbox/specs/antigravity.yaml b/tools/install_sandbox/specs/antigravity.yaml new file mode 100644 index 000000000..309f55181 --- /dev/null +++ b/tools/install_sandbox/specs/antigravity.yaml @@ -0,0 +1,40 @@ +universal_uninstall_scopes: [user] +scopes: + user: + install_mode: direct + uninstall_mode: direct + effects: + - kind: skill + root: home + path: .gemini/config/skills/graphify/SKILL.md + source: graphify/skill.md + payload_mode: frontmatter-body + required_text: + - "name: graphify-manager" + - "description: Rebuild the code graph or perform manual CLI queries when MCP server is offline." + reference_bundle: claude + - root: user_cwd + path: .agents/rules/graphify.md + source: graphify/always_on/antigravity-rules.md + - kind: text + root: user_cwd + path: .agents/workflows/graphify.md + required_text: ["name: graphify", "Workflow: graphify"] + project: + effects: + - kind: skill + root: project + path: .agents/skills/graphify/SKILL.md + source: graphify/skill.md + payload_mode: frontmatter-body + required_text: + - "name: graphify-manager" + - "description: Rebuild the code graph or perform manual CLI queries when MCP server is offline." + reference_bundle: claude + - root: project + path: .agents/rules/graphify.md + source: graphify/always_on/antigravity-rules.md + - kind: text + root: project + path: .agents/workflows/graphify.md + required_text: ["name: graphify", "Workflow: graphify"] diff --git a/tools/install_sandbox/specs/claude.yaml b/tools/install_sandbox/specs/claude.yaml new file mode 100644 index 000000000..c4dfaeca1 --- /dev/null +++ b/tools/install_sandbox/specs/claude.yaml @@ -0,0 +1,48 @@ +universal_uninstall_scopes: [user, project] +scopes: + user: + uninstall_mode: direct + effects: + - kind: skill + root: home + path: .claude/skills/graphify/SKILL.md + source: graphify/skill.md + reference_bundle: claude + - kind: section + root: home + path: .claude/CLAUDE.md + marker: "# graphify" + required_text: + - "- **graphify**" + - "Trigger: `/graphify`" + - "~/.claude/skills/graphify/SKILL.md" + project: + effects: + - kind: skill + root: project + path: .claude/skills/graphify/SKILL.md + source: graphify/skill.md + reference_bundle: claude + - kind: section + root: project + path: .claude/CLAUDE.md + marker: "# graphify" + required_text: + - "- **graphify**" + - "Trigger: `/graphify`" + - ".claude/skills/graphify/SKILL.md" + - kind: section + root: project + path: CLAUDE.md + marker: "## graphify" + source: graphify/always_on/claude-md.md + - kind: json + root: project + path: .claude/settings.json + preserves_backup: true + entries: + hooks: + PreToolUse: + - matcher: Bash|Grep + - matcher: Read|Glob + required_text: [graphify, hook-guard] diff --git a/tools/install_sandbox/specs/claw.yaml b/tools/install_sandbox/specs/claw.yaml new file mode 100644 index 000000000..8b60c9d55 --- /dev/null +++ b/tools/install_sandbox/specs/claw.yaml @@ -0,0 +1,21 @@ +universal_uninstall_scopes: [user] +scopes: + user: + effects: + - kind: skill + root: home + path: .openclaw/skills/graphify/SKILL.md + source: graphify/skill-claw.md + reference_bundle: claw + project: + effects: + - kind: skill + root: project + path: .openclaw/skills/graphify/SKILL.md + source: graphify/skill-claw.md + reference_bundle: claw + - kind: section + root: project + path: AGENTS.md + marker: "## graphify" + source: graphify/always_on/agents-md.md diff --git a/tools/install_sandbox/specs/codebuddy.yaml b/tools/install_sandbox/specs/codebuddy.yaml new file mode 100644 index 000000000..5f5290154 --- /dev/null +++ b/tools/install_sandbox/specs/codebuddy.yaml @@ -0,0 +1,46 @@ +universal_uninstall_scopes: [user, project] +scopes: + user: + effects: + - kind: skill + root: home + path: .codebuddy/skills/graphify/SKILL.md + source: graphify/skill.md + reference_bundle: claude + - kind: section + root: home + path: .codebuddy/CODEBUDDY.md + marker: "## graphify" + source: graphify/always_on/claude-md.md + - kind: json + root: home + path: .codebuddy/settings.json + preserves_backup: true + entries: + hooks: + PreToolUse: + - matcher: Bash|Grep + - matcher: Read|Glob + required_text: [graphify, hook-guard] + project: + effects: + - kind: skill + root: project + path: .codebuddy/skills/graphify/SKILL.md + source: graphify/skill.md + reference_bundle: claude + - kind: section + root: project + path: CODEBUDDY.md + marker: "## graphify" + source: graphify/always_on/claude-md.md + - kind: json + root: project + path: .codebuddy/settings.json + preserves_backup: true + entries: + hooks: + PreToolUse: + - matcher: Bash|Grep + - matcher: Read|Glob + required_text: [graphify, hook-guard] diff --git a/tools/install_sandbox/specs/codex.yaml b/tools/install_sandbox/specs/codex.yaml new file mode 100644 index 000000000..3b915614d --- /dev/null +++ b/tools/install_sandbox/specs/codex.yaml @@ -0,0 +1,30 @@ +universal_uninstall_scopes: [user, project] +scopes: + user: + effects: + - kind: skill + root: home + path: .codex/skills/graphify/SKILL.md + source: graphify/skill-codex.md + reference_bundle: codex + project: + effects: + - kind: skill + root: project + path: .codex/skills/graphify/SKILL.md + source: graphify/skill-codex.md + reference_bundle: codex + - kind: section + root: project + path: AGENTS.md + marker: "## graphify" + source: graphify/always_on/agents-md.md + - kind: json + root: project + path: .codex/hooks.json + preserves_backup: true + entries: + hooks: + PreToolUse: + - matcher: Bash + required_text: [graphify, hook-check] diff --git a/tools/install_sandbox/specs/copilot.yaml b/tools/install_sandbox/specs/copilot.yaml new file mode 100644 index 000000000..f554d6be1 --- /dev/null +++ b/tools/install_sandbox/specs/copilot.yaml @@ -0,0 +1,17 @@ +scopes: + user: + uninstall_mode: direct + effects: + - kind: skill + root: home + path: .copilot/skills/graphify/SKILL.md + source: graphify/skill-copilot.md + reference_bundle: copilot + + project: + effects: + - kind: skill + root: project + path: .copilot/skills/graphify/SKILL.md + source: graphify/skill-copilot.md + reference_bundle: copilot diff --git a/tools/install_sandbox/specs/cursor.yaml b/tools/install_sandbox/specs/cursor.yaml new file mode 100644 index 000000000..ff881f8a2 --- /dev/null +++ b/tools/install_sandbox/specs/cursor.yaml @@ -0,0 +1,12 @@ +unsupported: + user: Cursor installs only a project-local .cursor rule in the current working directory. +universal_uninstall_scopes: [project] +scopes: + project: + install_mode: direct + uninstall_mode: direct + effects: + - kind: text + root: project + path: .cursor/rules/graphify.mdc + required_text: ["alwaysApply: true", "graphify query"] diff --git a/tools/install_sandbox/specs/devin.yaml b/tools/install_sandbox/specs/devin.yaml new file mode 100644 index 000000000..44f63b0c2 --- /dev/null +++ b/tools/install_sandbox/specs/devin.yaml @@ -0,0 +1,19 @@ +universal_uninstall_scopes: [user, project] +scopes: + user: + uninstall_mode: direct + effects: + - kind: skill + root: home + path: .config/devin/skills/graphify/SKILL.md + source: graphify/skill-devin.md + project: + effects: + - kind: skill + root: project + path: .devin/skills/graphify/SKILL.md + source: graphify/skill-devin.md + - kind: text + root: project + path: .windsurf/rules/graphify.md + required_text: ["## graphify", "graphify query"] diff --git a/tools/install_sandbox/specs/droid.yaml b/tools/install_sandbox/specs/droid.yaml new file mode 100644 index 000000000..7d14e0693 --- /dev/null +++ b/tools/install_sandbox/specs/droid.yaml @@ -0,0 +1,21 @@ +universal_uninstall_scopes: [user] +scopes: + user: + effects: + - kind: skill + root: home + path: .factory/skills/graphify/SKILL.md + source: graphify/skill-droid.md + reference_bundle: droid + project: + effects: + - kind: skill + root: project + path: .factory/skills/graphify/SKILL.md + source: graphify/skill-droid.md + reference_bundle: droid + - kind: section + root: project + path: AGENTS.md + marker: "## graphify" + source: graphify/always_on/agents-md.md diff --git a/tools/install_sandbox/specs/gemini.yaml b/tools/install_sandbox/specs/gemini.yaml new file mode 100644 index 000000000..dc6640a72 --- /dev/null +++ b/tools/install_sandbox/specs/gemini.yaml @@ -0,0 +1,45 @@ +universal_uninstall_scopes: [user, project] +scopes: + user: + uninstall_mode: direct + effects: + - kind: skill + root: home + path: .gemini/skills/graphify/SKILL.md + source: graphify/skill.md + reference_bundle: claude + - kind: section + root: user_cwd + path: GEMINI.md + marker: "## graphify" + source: graphify/always_on/gemini-md.md + - kind: json + root: user_cwd + path: .gemini/settings.json + preserves_backup: true + entries: + hooks: + BeforeTool: + - matcher: read_file|list_directory + required_text: [graphify, hook-guard] + project: + effects: + - kind: skill + root: project + path: .gemini/skills/graphify/SKILL.md + source: graphify/skill.md + reference_bundle: claude + - kind: section + root: project + path: GEMINI.md + marker: "## graphify" + source: graphify/always_on/gemini-md.md + - kind: json + root: project + path: .gemini/settings.json + preserves_backup: true + entries: + hooks: + BeforeTool: + - matcher: read_file|list_directory + required_text: [graphify, hook-guard] diff --git a/tools/install_sandbox/specs/hermes.yaml b/tools/install_sandbox/specs/hermes.yaml new file mode 100644 index 000000000..49ad3faff --- /dev/null +++ b/tools/install_sandbox/specs/hermes.yaml @@ -0,0 +1,23 @@ +limitations: + - Normal Linux Hermes behavior is validated; Windows %LOCALAPPDATA% installation is not verified. +universal_uninstall_scopes: [user] +scopes: + user: + effects: + - kind: skill + root: home + path: .hermes/skills/graphify/SKILL.md + source: graphify/skill-claw.md + reference_bundle: claw + project: + effects: + - kind: skill + root: project + path: .hermes/skills/graphify/SKILL.md + source: graphify/skill-claw.md + reference_bundle: claw + - kind: section + root: project + path: AGENTS.md + marker: "## graphify" + source: graphify/always_on/agents-md.md diff --git a/tools/install_sandbox/specs/kilo.yaml b/tools/install_sandbox/specs/kilo.yaml new file mode 100644 index 000000000..6d4921c41 --- /dev/null +++ b/tools/install_sandbox/specs/kilo.yaml @@ -0,0 +1,39 @@ +universal_uninstall_scopes: [user] +scopes: + user: + uninstall_mode: direct + effects: + - kind: skill + root: home + path: .config/kilo/skills/graphify/SKILL.md + source: graphify/skill-kilo.md + reference_bundle: kilo + - root: home + path: .config/kilo/command/graphify.md + source: graphify/command-kilo.md + project: + install_mode: direct + uninstall_mode: direct + effects: + - kind: skill + root: home + path: .config/kilo/skills/graphify/SKILL.md + source: graphify/skill-kilo.md + reference_bundle: kilo + - root: home + path: .config/kilo/command/graphify.md + source: graphify/command-kilo.md + - kind: section + root: project + path: AGENTS.md + marker: "## graphify" + source: graphify/always_on/agents-md.md + - kind: text + root: project + path: .kilo/plugins/graphify.js + required_text: [tool.execute.before, "[graphify]"] + - kind: json + root: project + path: .kilo/kilo.json + entries: + plugin: ["file://{project}/.kilo/plugins/graphify.js"] diff --git a/tools/install_sandbox/specs/kimi.yaml b/tools/install_sandbox/specs/kimi.yaml new file mode 100644 index 000000000..96fc00633 --- /dev/null +++ b/tools/install_sandbox/specs/kimi.yaml @@ -0,0 +1,16 @@ +universal_uninstall_scopes: [user] +scopes: + user: + effects: + - kind: skill + root: home + path: .kimi/skills/graphify/SKILL.md + source: graphify/skill.md + reference_bundle: claude + project: + effects: + - kind: skill + root: project + path: .kimi/skills/graphify/SKILL.md + source: graphify/skill.md + reference_bundle: claude diff --git a/tools/install_sandbox/specs/kiro.yaml b/tools/install_sandbox/specs/kiro.yaml new file mode 100644 index 000000000..020da820b --- /dev/null +++ b/tools/install_sandbox/specs/kiro.yaml @@ -0,0 +1,21 @@ +universal_uninstall_scopes: [user] +scopes: + user: + effects: + - kind: skill + root: home + path: .kiro/skills/graphify/SKILL.md + source: graphify/skill-kiro.md + reference_bundle: kiro + project: + install_mode: direct + uninstall_mode: direct + effects: + - kind: skill + root: project + path: .kiro/skills/graphify/SKILL.md + source: graphify/skill-kiro.md + reference_bundle: kiro + - root: project + path: .kiro/steering/graphify.md + source: graphify/always_on/kiro-steering.md diff --git a/tools/install_sandbox/specs/opencode.yaml b/tools/install_sandbox/specs/opencode.yaml new file mode 100644 index 000000000..ef882785b --- /dev/null +++ b/tools/install_sandbox/specs/opencode.yaml @@ -0,0 +1,41 @@ +universal_uninstall_scopes: [user] +scopes: + user: + effects: + - kind: skill + root: home + path: .config/opencode/skills/graphify/SKILL.md + source: graphify/skill-opencode.md + reference_bundle: opencode + - kind: reminder_plugin + root: user_cwd + path: .opencode/plugins/graphify.js + required_text: ["[graphify]", "run graphify query with your question"] + forbidden_text: ["`", "$(", "&&"] + - kind: json + root: user_cwd + path: .opencode/opencode.json + entries: + plugin: [.opencode/plugins/graphify.js] + project: + effects: + - kind: skill + root: project + path: .opencode/skills/graphify/SKILL.md + source: graphify/skill-opencode.md + reference_bundle: opencode + - kind: section + root: project + path: AGENTS.md + marker: "## graphify" + source: graphify/always_on/agents-md.md + - kind: reminder_plugin + root: project + path: .opencode/plugins/graphify.js + required_text: ["[graphify]", "run graphify query with your question"] + forbidden_text: ["`", "$(", "&&"] + - kind: json + root: project + path: .opencode/opencode.json + entries: + plugin: [.opencode/plugins/graphify.js] diff --git a/tools/install_sandbox/specs/pi.yaml b/tools/install_sandbox/specs/pi.yaml new file mode 100644 index 000000000..074537155 --- /dev/null +++ b/tools/install_sandbox/specs/pi.yaml @@ -0,0 +1,17 @@ +universal_uninstall_scopes: [user] +scopes: + user: + uninstall_mode: direct + effects: + - kind: skill + root: home + path: .pi/agent/skills/graphify/SKILL.md + source: graphify/skill-pi.md + reference_bundle: pi + project: + effects: + - kind: skill + root: project + path: .pi/agent/skills/graphify/SKILL.md + source: graphify/skill-pi.md + reference_bundle: pi diff --git a/tools/install_sandbox/specs/trae-cn.yaml b/tools/install_sandbox/specs/trae-cn.yaml new file mode 100644 index 000000000..c4ca872d1 --- /dev/null +++ b/tools/install_sandbox/specs/trae-cn.yaml @@ -0,0 +1,21 @@ +universal_uninstall_scopes: [user] +scopes: + user: + effects: + - kind: skill + root: home + path: .trae-cn/skills/graphify/SKILL.md + source: graphify/skill-trae.md + reference_bundle: trae + project: + effects: + - kind: skill + root: project + path: .trae-cn/skills/graphify/SKILL.md + source: graphify/skill-trae.md + reference_bundle: trae + - kind: section + root: project + path: AGENTS.md + marker: "## graphify" + source: graphify/always_on/agents-md.md diff --git a/tools/install_sandbox/specs/trae.yaml b/tools/install_sandbox/specs/trae.yaml new file mode 100644 index 000000000..ae8e959e0 --- /dev/null +++ b/tools/install_sandbox/specs/trae.yaml @@ -0,0 +1,21 @@ +universal_uninstall_scopes: [user] +scopes: + user: + effects: + - kind: skill + root: home + path: .trae/skills/graphify/SKILL.md + source: graphify/skill-trae.md + reference_bundle: trae + project: + effects: + - kind: skill + root: project + path: .trae/skills/graphify/SKILL.md + source: graphify/skill-trae.md + reference_bundle: trae + - kind: section + root: project + path: AGENTS.md + marker: "## graphify" + source: graphify/always_on/agents-md.md diff --git a/tools/install_sandbox/specs/vscode.yaml b/tools/install_sandbox/specs/vscode.yaml new file mode 100644 index 000000000..d3a91fcc7 --- /dev/null +++ b/tools/install_sandbox/specs/vscode.yaml @@ -0,0 +1,30 @@ +universal_uninstall_scopes: [user] +scopes: + user: + install_mode: direct + uninstall_mode: direct + effects: + - kind: skill + root: home + path: .copilot/skills/graphify/SKILL.md + source: graphify/skill-vscode.md + reference_bundle: vscode + - kind: section + root: user_cwd + path: .github/copilot-instructions.md + marker: "## graphify" + source: graphify/always_on/vscode-instructions.md + project: + install_mode: direct + uninstall_mode: direct + effects: + - kind: skill + root: home + path: .copilot/skills/graphify/SKILL.md + source: graphify/skill-vscode.md + reference_bundle: vscode + - kind: section + root: project + path: .github/copilot-instructions.md + marker: "## graphify" + source: graphify/always_on/vscode-instructions.md diff --git a/tools/install_sandbox/specs/windows.yaml b/tools/install_sandbox/specs/windows.yaml new file mode 100644 index 000000000..b354a98cb --- /dev/null +++ b/tools/install_sandbox/specs/windows.yaml @@ -0,0 +1,48 @@ +limitations: + - Linux Docker verifies packaged Windows payload consistency only; Windows paths, permissions, shells, cleanup, and runtime discovery are not verified. +scopes: + user: + effects: + - kind: skill + root: home + path: .claude/skills/graphify/SKILL.md + source: graphify/skill-windows.md + reference_bundle: windows + - kind: section + root: home + path: .claude/CLAUDE.md + marker: "# graphify" + required_text: + - "- **graphify**" + - "Trigger: `/graphify`" + - "~/.claude/skills/graphify/SKILL.md" + project: + effects: + - kind: skill + root: project + path: .claude/skills/graphify/SKILL.md + source: graphify/skill-windows.md + reference_bundle: windows + - kind: section + root: project + path: .claude/CLAUDE.md + marker: "# graphify" + required_text: + - "- **graphify**" + - "Trigger: `/graphify`" + - ".claude/skills/graphify/SKILL.md" + - kind: section + root: project + path: CLAUDE.md + marker: "## graphify" + source: graphify/always_on/claude-md.md + - kind: json + root: project + path: .claude/settings.json + preserves_backup: true + entries: + hooks: + PreToolUse: + - matcher: Bash|Grep + - matcher: Read|Glob + required_text: [graphify, hook-guard]