Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .autoskillit/.gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
temp/
.state.yaml
.secrets.yaml
.onboarded
sync_manifest.json
Expand Down
7 changes: 5 additions & 2 deletions src/autoskillit/cli/_init_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -669,6 +671,7 @@ def _register_all(
colors = _colors()

ensure_project_temp(project_dir)
ensure_project_gitignore(project_dir)

_cfg = None
try:
Expand Down
5 changes: 3 additions & 2 deletions src/autoskillit/cli/install/_marketplace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/autoskillit/core/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/autoskillit/core/io/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -131,6 +132,7 @@
"destination_location",
"directory_tree_digest",
"dump_yaml_str",
"ensure_project_gitignore",
"ensure_project_temp",
"ensure_self_ignored",
"extract_section",
Expand Down
45 changes: 20 additions & 25 deletions src/autoskillit/core/io/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -707,6 +708,7 @@ def _reset_schema_drift_logged_for_tests() -> None:

_AUTOSKILLIT_GITIGNORE_ENTRIES = [
"temp/",
".state.yaml",
".secrets.yaml",
".onboarded",
"sync_manifest.json",
Expand Down Expand Up @@ -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
34 changes: 33 additions & 1 deletion src/autoskillit/fleet/_outcome.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from pathlib import Path
from typing import Any, TypedDict

import regex as re

from autoskillit.core import (
FleetErrorCode,
InfraExitCategory,
Expand Down Expand Up @@ -49,6 +51,23 @@ class _DispatchCommonFields(TypedDict):
"attempt_id",
)

_MANAGED_LINEAGE_PATH_RE = re.compile(
r"(?<![\w./-])(?:(?:/(?:[^/\\\r\n\"'<>|,;:(){}\[\]]+/)*)|"
r"(?:[^/\s\\\r\n\"'<>|,;:(){}\[\]]+/)+)?"
r"\.autoskillit/managed-headless-session-lineage"
r"(?=$|[/\s\"'<>|,;:(){}\[\]])(?:/[^\\\s\"'<>|,;:(){}\[\]]*)?",
re.IGNORECASE,
)
_MANAGED_CAPTURE_FIELD_ASSIGNMENT_RE = re.compile(
r"(?<![\w./-])(?P<quote>[\"']?)"
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"(?:<redacted>|[\s\p{P}])*", re.IGNORECASE)


def _is_managed_capture_diagnostic_text(value: str) -> bool:
"""Return whether text belongs only in managed-lineage diagnostics."""
Expand All @@ -67,6 +86,17 @@ def _sanitize_managed_capture_diagnostics(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("<redacted>", value)
normalized = _MANAGED_CAPTURE_FIELD_ASSIGNMENT_RE.sub("<redacted>", 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:
Expand Down Expand Up @@ -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(
_sanitize_failed_managed_capture_reason(raw_reason)
)
if raw_reason and not reason:
reason = FleetErrorCode.FLEET_L3_STARTUP_OR_CRASH
return DispatchStatus.FAILURE, reason
Expand Down
2 changes: 1 addition & 1 deletion tests/_ambient_env_surface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
1 change: 1 addition & 0 deletions tests/arch/_core_gateway_snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
56 changes: 41 additions & 15 deletions tests/arch/test_autoskillit_root_children_registered.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}


Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
)

Expand Down
18 changes: 10 additions & 8 deletions tests/cli/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions tests/cli/test_init_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading