From c43ad8aef7d11165bb4bedeb974523606f376e8a Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 10 Oct 2026 23:22:37 -0700 Subject: [PATCH 1/5] fix: separate runtime ignore ownership from project setup --- .autoskillit/.gitignore | 1 + src/autoskillit/cli/_init_helpers.py | 7 +- src/autoskillit/cli/install/_marketplace.py | 5 +- src/autoskillit/core/__init__.pyi | 1 + src/autoskillit/core/io/__init__.py | 2 + src/autoskillit/core/io/io.py | 45 ++++++------ tests/arch/_core_gateway_snapshot.py | 1 + ...st_autoskillit_root_children_registered.py | 56 +++++++++++---- tests/cli/test_init.py | 12 ++-- tests/cli/test_init_helpers.py | 2 + tests/cli/test_init_helpers_state.py | 52 +++++++++++++- tests/cli/test_install.py | 4 +- .../test_ensure_project_temp_with_config.py | 71 +++++++++++++++++-- .../test_clone_gate_runtime_state.py | 43 +++++++++-- 14 files changed, 236 insertions(+), 66 deletions(-) diff --git a/.autoskillit/.gitignore b/.autoskillit/.gitignore index e2a673f82f..2bf53cfb58 100644 --- a/.autoskillit/.gitignore +++ b/.autoskillit/.gitignore @@ -1,4 +1,5 @@ temp/ +.state.yaml .secrets.yaml .onboarded sync_manifest.json diff --git a/src/autoskillit/cli/_init_helpers.py b/src/autoskillit/cli/_init_helpers.py index 010d5d03cb..f3e94607a9 100644 --- a/src/autoskillit/cli/_init_helpers.py +++ b/src/autoskillit/cli/_init_helpers.py @@ -278,8 +278,10 @@ def _log_secret_scan_bypass(project_dir: Path) -> None: logger.warning("corrupt_state_yaml", path=str(state_path), error=str(exc)) msg = f"Cannot update {state_path}: file has YAML syntax errors. Delete the file to reset." raise SystemExit(msg) from exc + from autoskillit.core import ensure_project_gitignore + data.setdefault("safety", {})["secret_scan_bypass_accepted"] = datetime.now(UTC).isoformat() - state_path.parent.mkdir(exist_ok=True) + ensure_project_gitignore(project_dir) atomic_write(state_path, dump_yaml_str(data, default_flow_style=False, allow_unicode=True)) @@ -640,7 +642,7 @@ def _register_all( import autoskillit.core.paths as _core_paths from autoskillit.cli._hooks import sweep_all_scopes_for_orphans from autoskillit.config import load_config - from autoskillit.core import ensure_project_temp + from autoskillit.core import ensure_project_gitignore, ensure_project_temp from autoskillit.execution import get_backend # Refuse to register from inside the autoskillit source tree — this would @@ -669,6 +671,7 @@ def _register_all( colors = _colors() ensure_project_temp(project_dir) + ensure_project_gitignore(project_dir) _cfg = None try: diff --git a/src/autoskillit/cli/install/_marketplace.py b/src/autoskillit/cli/install/_marketplace.py index a6ebdfe824..cdcccabefa 100644 --- a/src/autoskillit/cli/install/_marketplace.py +++ b/src/autoskillit/cli/install/_marketplace.py @@ -151,11 +151,12 @@ def _ensure_workspace_ready(*, cwd: Path | None = None) -> None: Called after the CLAUDECODE guard — only when the actual install proceeds. Idempotent: safe to call on any project state. """ - from autoskillit.core import ensure_project_temp + from autoskillit.core import ensure_project_gitignore, ensure_project_temp project_dir = Path.cwd() if cwd is None else Path(cwd) - # Repair .autoskillit/.gitignore and ensure temp/ exists + # Repair project runtime ignores and ensure temp/ exists. if (project_dir / ".autoskillit").is_dir(): + ensure_project_gitignore(project_dir) ensure_project_temp(project_dir) # Migrate legacy .autoskillit/scripts/ to .autoskillit/recipes/ if present diff --git a/src/autoskillit/core/__init__.pyi b/src/autoskillit/core/__init__.pyi index 90dd3594b6..6acedcaa42 100644 --- a/src/autoskillit/core/__init__.pyi +++ b/src/autoskillit/core/__init__.pyi @@ -145,6 +145,7 @@ from .io import default_log_dir as default_log_dir from .io import destination_location as destination_location from .io import directory_tree_digest as directory_tree_digest from .io import dump_yaml_str as dump_yaml_str +from .io import ensure_project_gitignore as ensure_project_gitignore from .io import ensure_project_temp as ensure_project_temp from .io import ensure_self_ignored as ensure_self_ignored from .io import extract_section as extract_section diff --git a/src/autoskillit/core/io/__init__.py b/src/autoskillit/core/io/__init__.py index 04428257be..c3f1e19f4a 100644 --- a/src/autoskillit/core/io/__init__.py +++ b/src/autoskillit/core/io/__init__.py @@ -37,6 +37,7 @@ atomic_write, decode_versioned_json_bytes, directory_tree_digest, + ensure_project_gitignore, ensure_project_temp, ensure_self_ignored, is_python_bytecode_path, @@ -131,6 +132,7 @@ "destination_location", "directory_tree_digest", "dump_yaml_str", + "ensure_project_gitignore", "ensure_project_temp", "ensure_self_ignored", "extract_section", diff --git a/src/autoskillit/core/io/io.py b/src/autoskillit/core/io/io.py index 2fbaf4fe3a..7fa095d094 100644 --- a/src/autoskillit/core/io/io.py +++ b/src/autoskillit/core/io/io.py @@ -48,6 +48,7 @@ def __init__(self, path: Path, cause: OSError) -> None: "TreeVanishedError", "atomic_write", "decode_versioned_json_bytes", + "ensure_project_gitignore", "ensure_project_temp", "ensure_self_ignored", "is_python_bytecode_path", @@ -707,6 +708,7 @@ def _reset_schema_drift_logged_for_tests() -> None: _AUTOSKILLIT_GITIGNORE_ENTRIES = [ "temp/", + ".state.yaml", ".secrets.yaml", ".onboarded", "sync_manifest.json", @@ -738,35 +740,28 @@ def ensure_self_ignored(directory: Path) -> None: def ensure_project_temp(project_dir: Path, override: str | None = None) -> Path: - """Create the autoskillit temp directory with a self-gitignore; idempotent. - - Uses the pytest/mypy self-gitignoring directory pattern: the temp directory - owns a ``.gitignore`` containing ``*`` — no mutation of the project root - ``.gitignore``. Works identically for default, custom-relative, and absolute - external overrides. - - Also maintains ``.autoskillit/.gitignore`` covering session artefacts that - land alongside ``config.yaml`` (``.secrets.yaml``, ``.onboarded``, - ``sync_manifest.json``) when the project uses the canonical ``.autoskillit`` - directory. - """ + """Create the project temp directory with its own gitignore.""" temp_dir = resolve_temp_dir(project_dir, override) temp_dir.mkdir(parents=True, exist_ok=True) ensure_self_ignored(temp_dir) + return temp_dir + + +def ensure_project_gitignore(project_dir: Path) -> None: + """Create or update the canonical project runtime-artifact ignore file.""" autoskillit_dir = project_dir / ".autoskillit" - if autoskillit_dir.is_dir(): - autoskillit_gitignore = autoskillit_dir / ".gitignore" - if not autoskillit_gitignore.exists(): + autoskillit_dir.mkdir(parents=True, exist_ok=True) + autoskillit_gitignore = autoskillit_dir / ".gitignore" + if not autoskillit_gitignore.exists(): + atomic_write( + autoskillit_gitignore, + "\n".join(_AUTOSKILLIT_GITIGNORE_ENTRIES) + "\n", + ) + else: + existing = autoskillit_gitignore.read_text(encoding="utf-8") + missing = [e for e in _AUTOSKILLIT_GITIGNORE_ENTRIES if e not in existing.splitlines()] + if missing: atomic_write( autoskillit_gitignore, - "\n".join(_AUTOSKILLIT_GITIGNORE_ENTRIES) + "\n", + existing.rstrip("\n") + "\n" + "\n".join(missing) + "\n", ) - else: - existing = autoskillit_gitignore.read_text(encoding="utf-8") - missing = [e for e in _AUTOSKILLIT_GITIGNORE_ENTRIES if e not in existing.splitlines()] - if missing: - atomic_write( - autoskillit_gitignore, - existing.rstrip("\n") + "\n" + "\n".join(missing) + "\n", - ) - return temp_dir diff --git a/tests/arch/_core_gateway_snapshot.py b/tests/arch/_core_gateway_snapshot.py index 03914be0ca..bab33a9846 100644 --- a/tests/arch/_core_gateway_snapshot.py +++ b/tests/arch/_core_gateway_snapshot.py @@ -985,6 +985,7 @@ "due_retiring_records", "dump_yaml_str", "encode_stored_context_admission_envelope", + "ensure_project_gitignore", "ensure_project_temp", "entrypoint_shim_path", "executable_binding_matches_current_file", diff --git a/tests/arch/test_autoskillit_root_children_registered.py b/tests/arch/test_autoskillit_root_children_registered.py index eba43e4568..c865e74668 100644 --- a/tests/arch/test_autoskillit_root_children_registered.py +++ b/tests/arch/test_autoskillit_root_children_registered.py @@ -14,25 +14,22 @@ _STR_RX = re.compile(r"(?:^|[/\"'`\s(~])\.autoskillit/([A-Za-z0-9._-]+)") # These names are accounted for elsewhere: home-level artifacts, a read-only -# legacy path, a runtime directory with a real-git proof, and a tracked gap. -_CLASSIFIED_ELSEWHERE: set[str] = { +# legacy path, and a runtime directory with a real-git proof. +_CLASSIFIED_ELSEWHERE: dict[str, int] = { # Home-level hook state: hooks/guards/mcp_health_advisor.py:36. - "active_kitchens.json", + "active_kitchens.json": 1, # Home-level marketplace shape: core/types/constants/_type_constants_retirements.py:309. - "marketplace", + "marketplace": 1, # Home-level plugin storage: cli/doctor/_doctor_install.py:355. - "plugin-generations", + "plugin-generations": 1, # Home-level hook projection repair: workspace/_projected_artifact/_hook_repair.py:538. - "plugin-projections", + "plugin-projections": 1, # Home-level dismissal state: cli/update/_update_checks.py:79. - "update_check.json", + "update_check.json": 1, # Legacy path is read only for migration: cli/install/_marketplace.py:162. - "scripts", + "scripts": 1, # Runtime self-ignore is proven by tests/workspace/test_clone_gate_runtime_state.py. - "managed-headless-session-lineage", - # Known gap: init writes this without a keep-out rule; tracked by #5258 - # recommendation 4. - ".state.yaml", + "managed-headless-session-lineage": 1, } @@ -119,15 +116,44 @@ def test_every_autoskillit_child_named_in_src_is_classified() -> None: for child in _children_named_in(ast.parse(path.read_text(encoding="utf-8"))) } ignored_names = {entry.rstrip("/") for entry in _AUTOSKILLIT_GITIGNORE_ENTRIES} + classified_names = { + name for name, classification in _CLASSIFIED_ELSEWHERE.items() if classification == 1 + } unclassified = sorted( - named - set(_COMMITTED_BY_DESIGN) - ignored_names - {".gitignore"} - _CLASSIFIED_ELSEWHERE + named - set(_COMMITTED_BY_DESIGN) - ignored_names - {".gitignore"} - classified_names ) assert not unclassified, ( f"Unclassified .autoskillit children named in src/autoskillit: {unclassified}. " f"Commit a persistent child by adding it to _COMMITTED_BY_DESIGN; list a file " - f"in _AUTOSKILLIT_GITIGNORE_ENTRIES only when a setup command writes it, since " - f"init and ensure_project_temp maintain the tracked .gitignore. For a runtime " + f"in _AUTOSKILLIT_GITIGNORE_ENTRIES only when explicit setup writes it through " + f"ensure_project_gitignore; runtime ensure_project_temp does not maintain the " + f"tracked parent .gitignore. For a runtime " f"directory, call ensure_self_ignored when creating it, prove the behavior with " f"a real-git test, and name that test in an adjacent justification comment." ) + + +def test_zero_classification_flag_is_reported(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setitem(_CLASSIFIED_ELSEWHERE, "managed-headless-session-lineage", 0) + + with pytest.raises(AssertionError, match="managed-headless-session-lineage"): + test_every_autoskillit_child_named_in_src_is_classified() + + +def test_classification_map_matches_policy_checker_contract() -> None: + from tests.arch.test_acceptance_policy_relaxation_gate import check + + surface = check.PolicySurface( + "tests/arch/test_autoskillit_root_children_registered.py", + "_CLASSIFIED_ELSEWHERE", + "int_map", + default=0, + ) + set_source = "_CLASSIFIED_ELSEWHERE: set[str] = {'managed-headless-session-lineage'}" + with pytest.raises(check.UnsupportedSurfaceShape): + check.extract_surface_values(set_source, surface) + + actual_source = Path(__file__).read_text(encoding="utf-8") + actual = check.extract_surface_values(actual_source, surface) + assert {name: value.limit for name, value in actual.items()} == _CLASSIFIED_ELSEWHERE diff --git a/tests/cli/test_init.py b/tests/cli/test_init.py index edea9acadb..8ffa90a4cc 100644 --- a/tests/cli/test_init.py +++ b/tests/cli/test_init.py @@ -423,10 +423,10 @@ def test_ensure_project_temp_writes_self_gitignore(self, tmp_path): assert temp_gitignore.exists() assert "*" in temp_gitignore.read_text() - def test_ensure_project_temp_writes_autoskillit_gitignore_with_secrets(self, tmp_path): - from autoskillit.core.io import ensure_project_temp + def test_ensure_project_gitignore_writes_autoskillit_gitignore_with_secrets(self, tmp_path): + from autoskillit.core.io import ensure_project_gitignore - ensure_project_temp(tmp_path) + ensure_project_gitignore(tmp_path) gitignore = tmp_path / ".autoskillit" / ".gitignore" content = gitignore.read_text() assert ".secrets.yaml" in content @@ -438,15 +438,15 @@ def test_ensure_project_temp_is_idempotent(self, tmp_path): ensure_project_temp(tmp_path) # second call must not raise assert (tmp_path / ".autoskillit" / "temp").is_dir() - def test_ensure_project_temp_backfills_secrets_into_existing_gitignore(self, tmp_path): - from autoskillit.core.io import ensure_project_temp + def test_ensure_project_gitignore_backfills_secrets_into_existing_gitignore(self, tmp_path): + from autoskillit.core.io import ensure_project_gitignore # Simulate a pre-fix .gitignore without .secrets.yaml autoskillit_dir = tmp_path / ".autoskillit" autoskillit_dir.mkdir() (autoskillit_dir / ".gitignore").write_text(".onboarded\n") - ensure_project_temp(tmp_path) + ensure_project_gitignore(tmp_path) content = (autoskillit_dir / ".gitignore").read_text() assert ".secrets.yaml" in content assert ".onboarded" in content diff --git a/tests/cli/test_init_helpers.py b/tests/cli/test_init_helpers.py index cdc3902ac7..f4d5aa37b7 100644 --- a/tests/cli/test_init_helpers.py +++ b/tests/cli/test_init_helpers.py @@ -86,6 +86,8 @@ def test_register_all_accepts_backend_kwarg( mock_backend = MagicMock() mock_backend.capabilities.mcp_config_capable = False _register_all("user", tmp_path, backend=mock_backend) + ignore_lines = (tmp_path / ".autoskillit" / ".gitignore").read_text().splitlines() + assert ".state.yaml" in ignore_lines class TestInitBackendResolution: diff --git a/tests/cli/test_init_helpers_state.py b/tests/cli/test_init_helpers_state.py index 0b43deac8d..7186dfc9ff 100644 --- a/tests/cli/test_init_helpers_state.py +++ b/tests/cli/test_init_helpers_state.py @@ -28,15 +28,61 @@ def test_log_secret_scan_bypass_refuses_overwrite_on_corrupt_yaml( _log_secret_scan_bypass(tmp_path) assert state_path.read_text() == corrupt_content + assert not (state_path.parent / ".gitignore").exists() - def test_log_secret_scan_bypass_works_on_missing_file(self, tmp_path: Path) -> None: - from autoskillit.cli._init_helpers import _log_secret_scan_bypass + def test_log_secret_scan_bypass_works_on_missing_file( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + from autoskillit.cli import _init_helpers state_path = tmp_path / ".autoskillit" / ".state.yaml" assert not state_path.exists() - _log_secret_scan_bypass(tmp_path) + write_state = _init_helpers.atomic_write + + def write_after_ignore(path: Path, content: str) -> None: + assert ".state.yaml" in (state_path.parent / ".gitignore").read_text().splitlines() + write_state(path, content) + + monkeypatch.setattr(_init_helpers, "atomic_write", write_after_ignore) + _init_helpers._log_secret_scan_bypass(tmp_path) assert state_path.exists() content = state_path.read_text() assert "secret_scan_bypass_accepted" in content + ignore_lines = (state_path.parent / ".gitignore").read_text().splitlines() + assert ".state.yaml" in ignore_lines + + def test_log_secret_scan_bypass_preserves_unknown_state_and_config( + self, tmp_path: Path + ) -> None: + from autoskillit.cli._init_helpers import _log_secret_scan_bypass + from autoskillit.core import load_yaml + + state_path = tmp_path / ".autoskillit" / ".state.yaml" + state_path.parent.mkdir(parents=True) + state_path.write_text("custom: keep\nsafety:\n other: keep\n", encoding="utf-8") + config_path = state_path.parent / "config.yaml" + config_path.write_bytes(b"config: unchanged\n") + + _log_secret_scan_bypass(tmp_path) + + state = load_yaml(state_path) + assert state["custom"] == "keep" + assert state["safety"]["other"] == "keep" + assert config_path.read_bytes() == b"config: unchanged\n" + + def test_log_secret_scan_bypass_does_not_update_ignore_on_scalar_safety( + self, tmp_path: Path + ) -> None: + from autoskillit.cli._init_helpers import _log_secret_scan_bypass + + state_path = tmp_path / ".autoskillit" / ".state.yaml" + scalar_safety = b"safety: scalar\n" + _write_state(state_path, scalar_safety.decode()) + + with pytest.raises(TypeError): + _log_secret_scan_bypass(tmp_path) + + assert state_path.read_bytes() == scalar_safety + assert not (state_path.parent / ".gitignore").exists() diff --git a/tests/cli/test_install.py b/tests/cli/test_install.py index 8b72816059..b83177fc78 100644 --- a/tests/cli/test_install.py +++ b/tests/cli/test_install.py @@ -1137,7 +1137,7 @@ def test_install_sweeps_all_scopes_for_orphans( def test_install_creates_autoskillit_gitignore( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """After install(), .autoskillit/.gitignore must exist (ensure_project_temp was called).""" + """install() ensures runtime artifacts are ignored in the project directory.""" import importlib as _importlib from autoskillit.cli.install._marketplace import install as _install @@ -1163,6 +1163,8 @@ def test_install_creates_autoskillit_gitignore( assert (tmp_path / ".autoskillit" / ".gitignore").exists(), ( ".autoskillit/.gitignore must be created by install(), not just by init()" ) + ignore_lines = (tmp_path / ".autoskillit" / ".gitignore").read_text().splitlines() + assert ".state.yaml" in ignore_lines def test_install_calls_upgrade_when_scripts_dir_exists( diff --git a/tests/core/test_ensure_project_temp_with_config.py b/tests/core/test_ensure_project_temp_with_config.py index f13b6a297b..9543c65336 100644 --- a/tests/core/test_ensure_project_temp_with_config.py +++ b/tests/core/test_ensure_project_temp_with_config.py @@ -2,18 +2,21 @@ from __future__ import annotations +import subprocess from pathlib import Path import pytest -from autoskillit.core.io import ensure_project_temp +from autoskillit.core.io import ( + _AUTOSKILLIT_GITIGNORE_ENTRIES, + ensure_project_gitignore, + ensure_project_temp, +) -pytestmark = [pytest.mark.layer("core"), pytest.mark.small] +pytestmark = [pytest.mark.layer("core"), pytest.mark.medium] def test_repository_profile_artifact_is_project_gitignored() -> None: - from autoskillit.core.io import _AUTOSKILLIT_GITIGNORE_ENTRIES - assert "repository-profile.v1.json" in _AUTOSKILLIT_GITIGNORE_ENTRIES @@ -69,5 +72,63 @@ def test_ensure_project_temp_idempotent_on_existing_gitignore(tmp_path: Path) -> ensure_project_temp(tmp_path) ensure_project_temp(tmp_path) - assert (target / ".gitignore").read_text() == custom + + +@pytest.mark.parametrize("existing", [None, "# keep this comment\ncustom/\n"]) +def test_ensure_project_temp_leaves_project_gitignore_untouched( + tmp_path: Path, existing: str | None +) -> None: + autoskillit_dir = tmp_path / ".autoskillit" + autoskillit_dir.mkdir() + gitignore = autoskillit_dir / ".gitignore" + if existing is not None: + gitignore.write_text(existing, encoding="utf-8") + root_gitignore = tmp_path / ".gitignore" + root_gitignore.write_text("root rules\n", encoding="utf-8") + + ensure_project_temp(tmp_path) + + actual = gitignore.read_text(encoding="utf-8") if gitignore.exists() else None + assert actual == existing + assert root_gitignore.read_text(encoding="utf-8") == "root rules\n" + + +def test_ensure_project_gitignore_preserves_rules_and_is_idempotent(tmp_path: Path) -> None: + ensure_project_gitignore(tmp_path) + gitignore = tmp_path / ".autoskillit" / ".gitignore" + assert set(_AUTOSKILLIT_GITIGNORE_ENTRIES).issubset(gitignore.read_text().splitlines()) + + gitignore.write_text("# user comment\ncustom/\ntemp/\n", encoding="utf-8") + ensure_project_gitignore(tmp_path) + content = gitignore.read_text(encoding="utf-8") + assert content.startswith("# user comment\ncustom/\ntemp/\n") + assert set(_AUTOSKILLIT_GITIGNORE_ENTRIES).issubset(content.splitlines()) + + ensure_project_gitignore(tmp_path) + assert gitignore.read_text(encoding="utf-8") == content + + +def test_runtime_state_is_ignored_in_a_git_checkout(tmp_path: Path) -> None: + from tests._git_topology import commit_file, init_checkout + + repo = init_checkout(tmp_path / "repo") + ensure_project_gitignore(repo) + gitignore = repo / ".autoskillit" / ".gitignore" + commit_file( + repo, + ".autoskillit/.gitignore", + gitignore.read_text(), + "ignore runtime state", + ) + (gitignore.parent / ".state.yaml").write_text("runtime: true\n", encoding="utf-8") + + status = subprocess.run( + ["git", "status", "--porcelain", "--untracked-files=all"], + cwd=repo, + check=True, + capture_output=True, + text=True, + timeout=10, + ) + assert status.stdout == "" diff --git a/tests/workspace/test_clone_gate_runtime_state.py b/tests/workspace/test_clone_gate_runtime_state.py index 33a1c5d2d2..2f304579ab 100644 --- a/tests/workspace/test_clone_gate_runtime_state.py +++ b/tests/workspace/test_clone_gate_runtime_state.py @@ -24,13 +24,21 @@ ] -def _initialized(repo: Path) -> Path: - commit_file( - repo, - ".autoskillit/.gitignore", - "\n".join(_AUTOSKILLIT_GITIGNORE_ENTRIES) + "\n", - "add AutoSkillit ignore entries", - ) +_CANONICAL_PARENT_GITIGNORE = "\n".join(_AUTOSKILLIT_GITIGNORE_ENTRIES) + "\n" + + +def _initialized( + repo: Path, + *, + parent_gitignore: str | None = _CANONICAL_PARENT_GITIGNORE, +) -> Path: + if parent_gitignore is not None: + commit_file( + repo, + ".autoskillit/.gitignore", + parent_gitignore, + "add AutoSkillit ignore entries", + ) commit_file(repo, ".autoskillit/config.yaml", "project: probe\n", "add project config") return repo @@ -60,6 +68,27 @@ def test_launch_sequence_leaves_checkout_clean(git_repo: Path) -> None: assert gitignore_path.read_bytes() == committed_gitignore +@pytest.mark.parametrize( + "parent_gitignore", + [None, "# user rule retained\nprivate-artifacts/\n"], + ids=["absent", "tracked-stale"], +) +def test_launch_sequence_preserves_parent_gitignore_state( + git_repo: Path, parent_gitignore: str | None +) -> None: + repo = _initialized(git_repo, parent_gitignore=parent_gitignore) + gitignore_path = repo / ".autoskillit" / ".gitignore" + + ensure_project_temp(repo) + _create_record(repo) + + assert detect_uncommitted_changes(str(repo)) == [] + if parent_gitignore is None: + assert not gitignore_path.exists() + else: + assert gitignore_path.read_bytes() == parent_gitignore.encode() + + def test_user_edits_still_block_after_launch(git_repo: Path) -> None: repo = _initialized(git_repo) gitignore_path = repo / ".autoskillit" / ".gitignore" From 5beaf51a45132a7e84e6e3f4667e6ea65dc5f941 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 10 Oct 2026 23:24:30 -0700 Subject: [PATCH 2/5] fix: retain fleet failure causes while redacting capture details --- src/autoskillit/fleet/_outcome.py | 34 ++++++- tests/fleet/test_dispatch_envelope_fields.py | 22 ++++- .../fleet/test_dispatch_outcome_classifier.py | 99 +++++++++++++++++++ 3 files changed, 153 insertions(+), 2 deletions(-) diff --git a/src/autoskillit/fleet/_outcome.py b/src/autoskillit/fleet/_outcome.py index 8651dd22dc..8b02bdc36a 100644 --- a/src/autoskillit/fleet/_outcome.py +++ b/src/autoskillit/fleet/_outcome.py @@ -5,6 +5,8 @@ from pathlib import Path from typing import Any, TypedDict +import regex as re + from autoskillit.core import ( FleetErrorCode, InfraExitCategory, @@ -49,6 +51,23 @@ class _DispatchCommonFields(TypedDict): "attempt_id", ) +_MANAGED_LINEAGE_PATH_RE = re.compile( + r"(?|,;:(){}\[\]]+/)*)|" + r"(?:[^/\s\\\r\n\"'<>|,;:(){}\[\]]+/)+)?" + r"\.autoskillit/managed-headless-session-lineage" + r"(?=$|[/\s\"'<>|,;:(){}\[\]])(?:/[^\\\s\"'<>|,;:(){}\[\]]*)?", + re.IGNORECASE, +) +_MANAGED_CAPTURE_FIELD_ASSIGNMENT_RE = re.compile( + r"(?[\"']?)" + r"(?:attempt_id|launch_id|requested_mode|effective_mode|lineage[-_ ]status|" + r"native[-_ ]shell[-_ ]capture|managed[-_ ]headless[-_ ]session[-_ ]lineage|" + r"managed[-_ ]lineage|lineage[-_ ]validation)" + r"(?P=quote)\s*[=:]\s*(?:\"[^\"]*\"|'[^']*'|[^\s,;]+)", + re.IGNORECASE, +) +_REDACTED_ONLY_LINE_RE = re.compile(r"(?:|[\s\p{P}])*", re.IGNORECASE) + def _is_managed_capture_diagnostic_text(value: str) -> bool: """Return whether text belongs only in managed-lineage diagnostics.""" @@ -67,6 +86,17 @@ def _sanitize_managed_capture_diagnostics(value: str) -> str: ) +def _normalize_failed_managed_capture_reason(value: str) -> str: + """Redact private lineage details while retaining failure causes.""" + normalized = _MANAGED_LINEAGE_PATH_RE.sub("", value) + normalized = _MANAGED_CAPTURE_FIELD_ASSIGNMENT_RE.sub("", normalized) + return "".join( + line + for line in normalized.splitlines(keepends=True) + if not _REDACTED_ONLY_LINE_RE.fullmatch(line) + ) + + def _is_abandon_reason(skill_result: SkillResult) -> bool: """Return True when the kill reason indicates resume would be futile.""" if skill_result.retry_reason in _ABANDON_REASONS: @@ -153,7 +183,9 @@ def classify_dispatch_outcome( return DispatchStatus.RESUMABLE, FleetErrorCode.FLEET_QUOTA_EXHAUSTED if parsed.outcome == "completed_clean": raw_reason = str(parsed.payload.get("reason", "")) if parsed.payload else "" - reason = _sanitize_managed_capture_diagnostics(raw_reason) + reason = _sanitize_managed_capture_diagnostics( + _normalize_failed_managed_capture_reason(raw_reason) + ) if raw_reason and not reason: reason = FleetErrorCode.FLEET_L3_STARTUP_OR_CRASH return DispatchStatus.FAILURE, reason diff --git a/tests/fleet/test_dispatch_envelope_fields.py b/tests/fleet/test_dispatch_envelope_fields.py index e87992f5fb..2f409b6f67 100755 --- a/tests/fleet/test_dispatch_envelope_fields.py +++ b/tests/fleet/test_dispatch_envelope_fields.py @@ -87,7 +87,14 @@ async def test_failure_payload_retains_exact_optional_evidence(self, tool_ctx, m _setup_dispatch(tool_ctx, monkeypatch) payload = { "success": False, - "reason": "invariant violated: exact write evidence was not retained", + "reason": ( + "uncommitted_changes: bootstrap_clone failed at " + "/tmp/project/.autoskillit/managed-headless-session-lineage/records/" + "0123456789abcdef0123456789abcdef.json " + "attempt_id=deaddeaddeaddeaddeaddeaddeaddead " + "launch_id=feedfacefeedfacefeedfacefeedface " + "requested_mode=direct effective_mode=capture lineage_status=corrupt" + ), "summary": "Outcome invariant failed.", "reason_kind": "outcome_invariant", "outcome_fields": {"write_count": 0, "required_writes": 1}, @@ -110,6 +117,19 @@ async def test_failure_payload_retains_exact_optional_evidence(self, tool_ctx, m assert result["success"] is False assert result["dispatch_status"] == "failure" assert result["l3_parse_source"] == "stdout" + assert "uncommitted_changes" in result["reason"] + assert "bootstrap_clone" in result["reason"] + for private_detail in ( + "managed-headless-session-lineage", + "0123456789abcdef", + "attempt_id", + "launch_id", + "requested_mode", + "effective_mode", + "lineage_status", + ): + assert private_detail not in result["reason"] + assert private_detail not in result["error"] # Independent assertions: a silent drop or rename of any single # evidence field names the regression, instead of passing through a # wholesale equality check that lets transforms hide. diff --git a/tests/fleet/test_dispatch_outcome_classifier.py b/tests/fleet/test_dispatch_outcome_classifier.py index d72b006de6..0b316ebd5c 100644 --- a/tests/fleet/test_dispatch_outcome_classifier.py +++ b/tests/fleet/test_dispatch_outcome_classifier.py @@ -215,6 +215,105 @@ def test_diagnostic_payload_reason_uses_standard_fleet_failure_wording(self): assert reason == FleetErrorCode.FLEET_L3_STARTUP_OR_CRASH assert "feedface" not in reason + @pytest.mark.parametrize( + "private_record", + [ + ".autoskillit/managed-headless-session-lineage/", + "/tmp/project/.autoskillit/managed-headless-session-lineage/records/" + "0123456789abcdef0123456789abcdef.json", + ], + ) + def test_failed_reason_preserves_uncommitted_changes_cause(self, private_record): + diagnostic = ( + f"uncommitted_changes: bootstrap_clone failed at {private_record}; " + "cache path /tmp/project/cache/clone.lock" + ) + parsed = L3ParseResult( + outcome="completed_clean", + payload={"success": False, "reason": diagnostic}, + raw_body=None, + parse_error=None, + source="stdout", + ) + + status, reason = classify_dispatch_outcome( + parsed, + dataclasses.replace(_DEFAULT_SKILL_RESULT, success=False), + ) + + assert status == DispatchStatus.FAILURE + assert reason == ( + "uncommitted_changes: bootstrap_clone failed at ; " + "cache path /tmp/project/cache/clone.lock" + ) + assert private_record not in reason + + def test_failed_reason_redacts_inline_capture_fields_but_keeps_cause(self): + private_record = ( + "/tmp/project/.autoskillit/managed-headless-session-lineage/records/" + "abcdef0123456789abcdef0123456789.json" + ) + diagnostic = ( + f"bootstrap_clone failed at {private_record} " + "attempt_id=deaddeaddeaddeaddeaddeaddeaddead " + "launch_id=feedfacefeedfacefeedfacefeedface " + "requested_mode=direct effective_mode=capture " + "lineage_status=corrupt native_shell_capture=enabled" + ) + parsed = L3ParseResult( + outcome="completed_clean", + payload={"success": False, "reason": diagnostic}, + raw_body=None, + parse_error=None, + source="stdout", + ) + + status, reason = classify_dispatch_outcome( + parsed, + dataclasses.replace(_DEFAULT_SKILL_RESULT, success=False), + ) + + assert status == DispatchStatus.FAILURE + assert reason == ( + "bootstrap_clone failed at " + " " + ) + for private_value in ( + private_record, + "deaddead", + "feedface", + "direct", + "capture", + "corrupt", + "enabled", + ): + assert private_value not in reason + + @pytest.mark.parametrize( + "private_record", + [ + ".autoskillit/managed-headless-session-lineage/", + "/tmp/project/.autoskillit/managed-headless-session-lineage/records/" + "0123456789abcdef0123456789abcdef.json", + ], + ) + def test_private_path_only_uses_standard_startup_failure(self, private_record): + parsed = L3ParseResult( + outcome="completed_clean", + payload={"success": False, "reason": private_record}, + raw_body=None, + parse_error=None, + source="stdout", + ) + + status, reason = classify_dispatch_outcome( + parsed, + dataclasses.replace(_DEFAULT_SKILL_RESULT, success=False), + ) + + assert status == DispatchStatus.FAILURE + assert reason == FleetErrorCode.FLEET_L3_STARTUP_OR_CRASH + class TestReasonAwareResumePolicy: def test_idle_stall_with_progress_is_failure(self): From 75aca3232309c25ea9f266e59206b2b7131d5b22 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 10 Oct 2026 23:28:17 -0700 Subject: [PATCH 3/5] test: use explicit setup for project ignore coverage --- tests/cli/test_init.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/cli/test_init.py b/tests/cli/test_init.py index 8ffa90a4cc..fb3cfb495a 100644 --- a/tests/cli/test_init.py +++ b/tests/cli/test_init.py @@ -539,11 +539,13 @@ def test_init_all_created_files_covered_by_gitignore(tmp_path: Path) -> None: from autoskillit.cli._init_helpers import _create_secrets_template from autoskillit.core.io import ( _COMMITTED_BY_DESIGN, + ensure_project_gitignore, ensure_project_temp, is_self_ignored, ) ensure_project_temp(tmp_path) + ensure_project_gitignore(tmp_path) _create_secrets_template(tmp_path) autoskillit_dir = tmp_path / ".autoskillit" @@ -570,9 +572,9 @@ def test_init_all_created_files_covered_by_gitignore(tmp_path: Path) -> None: def test_secrets_template_gitignore_comment_is_true(tmp_path: Path) -> None: """The comment in .secrets.yaml claiming it is gitignored must be actually true.""" from autoskillit.cli._init_helpers import _create_secrets_template - from autoskillit.core.io import ensure_project_temp + from autoskillit.core.io import ensure_project_gitignore - ensure_project_temp(tmp_path) + ensure_project_gitignore(tmp_path) _create_secrets_template(tmp_path) autoskillit_dir = tmp_path / ".autoskillit" From 5854498a6f980782aa0b9f883b1e90a282c7f8f3 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 10 Oct 2026 23:41:19 -0700 Subject: [PATCH 4/5] test: synchronize moved CLI source inventories --- tests/_ambient_env_surface.py | 2 +- tests/arch/test_no_new_exception_path_autoskillit_imports.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/_ambient_env_surface.py b/tests/_ambient_env_surface.py index 947b7371a1..9ea1ef46bf 100644 --- a/tests/_ambient_env_surface.py +++ b/tests/_ambient_env_surface.py @@ -992,7 +992,7 @@ class AmbientEnvDisposition: } FORWARDING_SITES: dict[str, str] = { - "cli/install/_marketplace.py:357": ( + "cli/install/_marketplace.py:358": ( "Unfiltered dict(os.environ) snapshot (`ambient_env`) used as the base for an" "install/publish subprocess env; intentional wholesale forward for a maintenance-style" "operation." diff --git a/tests/arch/test_no_new_exception_path_autoskillit_imports.py b/tests/arch/test_no_new_exception_path_autoskillit_imports.py index 3f29b3f42d..646a1e92fa 100644 --- a/tests/arch/test_no_new_exception_path_autoskillit_imports.py +++ b/tests/arch/test_no_new_exception_path_autoskillit_imports.py @@ -28,7 +28,7 @@ # the failure it is inside of. ("pipeline/background.py", 81), # Config re-read on a config-load failure path. - ("cli/_init_helpers.py", 687), + ("cli/_init_helpers.py", 690), } ) From d1fce52b7ba9d15777cdc0854b2cb0f4e9cda437 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 11 Oct 2026 09:11:00 -0700 Subject: [PATCH 5/5] fix(review): name failed capture redaction helper consistently --- src/autoskillit/fleet/_outcome.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/autoskillit/fleet/_outcome.py b/src/autoskillit/fleet/_outcome.py index 8b02bdc36a..66ec770214 100644 --- a/src/autoskillit/fleet/_outcome.py +++ b/src/autoskillit/fleet/_outcome.py @@ -86,7 +86,7 @@ def _sanitize_managed_capture_diagnostics(value: str) -> str: ) -def _normalize_failed_managed_capture_reason(value: str) -> str: +def _sanitize_failed_managed_capture_reason(value: str) -> str: """Redact private lineage details while retaining failure causes.""" normalized = _MANAGED_LINEAGE_PATH_RE.sub("", value) normalized = _MANAGED_CAPTURE_FIELD_ASSIGNMENT_RE.sub("", normalized) @@ -184,7 +184,7 @@ def classify_dispatch_outcome( if parsed.outcome == "completed_clean": raw_reason = str(parsed.payload.get("reason", "")) if parsed.payload else "" reason = _sanitize_managed_capture_diagnostics( - _normalize_failed_managed_capture_reason(raw_reason) + _sanitize_failed_managed_capture_reason(raw_reason) ) if raw_reason and not reason: reason = FleetErrorCode.FLEET_L3_STARTUP_OR_CRASH