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
23 changes: 21 additions & 2 deletions loopx/runtime.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import os
import shutil
from contextlib import ExitStack
from datetime import datetime, timezone
Expand Down Expand Up @@ -32,6 +33,17 @@ def unique_archive_path(archive_root: Path, goal_id: str, timestamp: str) -> Pat
return candidate


def _validate_archive_destination(source: Path, destination: Path) -> None:
source_path = source.resolve()
destination_path = Path(os.path.abspath(destination))
if destination.resolve().is_relative_to(source_path) or any(
parent.resolve() == source_path for parent in destination_path.parents
):
raise ValueError(
"archive destination must be outside the runtime goal directory"
)


def _archive_source_registry_paths(
registry: dict[str, Any],
*,
Expand Down Expand Up @@ -71,7 +83,9 @@ def archive_runtime_goal(
registered_ids = {str(goal.get("id")) for goal in registry_goals(registry)}
registry_member = safe_goal_id in registered_ids
if registry_member and not allow_registered:
raise ValueError("goal exists in registry; pass --allow-registered only after confirming it is obsolete")
raise ValueError(
"goal exists in registry; pass --allow-registered only after confirming it is obsolete"
)

source = runtime_root / "goals" / safe_goal_id
if not source.exists():
Expand All @@ -84,8 +98,11 @@ def archive_runtime_goal(
raise ValueError("runtime goal directory resolved outside the goals root")

timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
archive_base = archive_root.expanduser() if archive_root else runtime_root / "archived-goals"
archive_base = (
archive_root.expanduser() if archive_root else runtime_root / "archived-goals"
)
destination = unique_archive_path(archive_base, safe_goal_id, timestamp)
_validate_archive_destination(source, destination)

if execute:
from .control_plane.coordination.shadow_management import (
Expand Down Expand Up @@ -163,6 +180,7 @@ def archive_runtime_goal(
safe_goal_id,
timestamp,
)
_validate_archive_destination(source, destination)
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(source), str(destination))

Expand All @@ -182,6 +200,7 @@ def archive_runtime_goal(
"goal id is a single path segment",
"source runtime goal directory exists",
"source resolves under runtime_root/goals",
"archive destination resolves outside the source directory",
"registry membership checked",
],
}
Expand Down
4 changes: 2 additions & 2 deletions loopx/semantics/project_registry_io_manifest_v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -2175,15 +2175,15 @@
},
{
"site": "loopx/runtime.py::<module>.archive_runtime_goal::codec_read:load_registry#1",
"line": 64,
"line": 76,
"column": 16,
"kind": "codec_read",
"api": "load_registry",
"classification": "codec_api"
},
{
"site": "loopx/runtime.py::<module>.archive_runtime_goal::codec_read:load_registry#2",
"line": 142,
"line": 159,
"column": 31,
"kind": "codec_read",
"api": "load_registry",
Expand Down
1 change: 1 addition & 0 deletions skills/loopx-self-repair/references/repair-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ teaches a reusable control-plane lesson.
| `receipt_freshness_commit_gap` | A receipt-bound follow-up passes its first freshness check, then commits an older Turn after a newer receipt arrives during lock contention or final persistence. An immediate retry correctly reports the old Turn as stale. | Ordered receipt events, the exact Turn ID, state-lock timing, commit result, and persisted state identity. | Receipt freshness and scheduler persistence did not hold the same cross-runtime append fence, so a newer receipt could arrive after the final check. | Keep the early check for fast rejection. Acquire the scheduler-state lock before the receipt-log mutation lock, then retain both locks from the final receipt read through legacy migration and the final state rename. Make every receipt writer use the same mutation lock. Return the existing typed missing or stale failure without mutation. Prove both lock-wait and read-to-rename interleavings with real writers, then scan for reverse lock order. |
| `notification_receipt_scope_and_frontier_gap` | A recovered blocker never notifies again, later notices starve, or a new channel claims delivery from an old receipt. | Canonical blocked/open/blocked transitions, full candidate frontier, destination-bound private receipts, and public readback. | Receipt lookup omitted destination/generation, recovery used an incomplete status list, or the batch limit was applied before delivered effects were removed. | Reuse canonical status and notice builders; retire only explicit recovery or supersession facts, preserve missing-row uncertainty, bind receipts to destination and durable generation, and budget pending effects with fair retries. Validate repeated same-clock recovery, more than one batch, persistent failures, target switches and default-off parity; keep historical receipts private. |
| `local_state_physical_route_guard_fragmentation` | Migration reviews repeatedly find a new symlink or Windows junction route after earlier target/backup fixes passed. | Every source, destination, backup, receipt, and rollback read/copy/rename path; the shared redirect predicate; preview and near-effect negative cases on the native OS. | Individual phases used `is_symlink()` or existence checks instead of one physical-route rule, so green happy-path receipts hid reads or moves outside the declared tree. | Reuse one symlink/junction/reparse classifier at each I/O boundary, recheck immediately before effects, and fail closed with the backup retained. Cover source, target, nested runtime content, backup snapshots and rollback using synthetic substitution plus native platform negatives; keep active user state untouched. |
| `directory_move_nested_destination` | A directory archive or cleanup preview reports success, but execution copies the source recursively into itself, fails with a path-too-long error, returns an archive path that disappears with the source, or leaves nested partial state. | Normalized lexical and resolved source and destination paths, source aliases, preview output, directory contents after failure, and the final move call. | A caller-controlled archive root placed the lexical or resolved destination inside the directory being moved, and preview did not share an effect-time containment check. | Reject a destination whose resolved path is inside the resolved source or whose lexical ancestor resolves to the source, and recheck immediately before the move. Cover source-as-root, direct-child, deeper-child, source-child symlink, and aliased-source destinations in preview and execute modes; prove rejection creates no directories and preserves source bytes. |
| `synthetic_profile_telemetry_leak` | Reporting installation IDs rise during tests, isolated installs or benchmark preparation. | Synthetic state with send receipts, collector matches when authorized, child environment builders and Agent shell policy. | Environment allowlists drop CI and telemetry opt-outs while fresh homes generate new IDs. | Force collection off at synthetic install/runtime/tool-shell boundaries and test/smoke entrypoints; exercise real child CLI and typed sender against a local collector with zero-request assertions. Keep normal user collection unchanged; do not infer or delete historical test samples from platform/channel heuristics. |
| `authority_cold_read_starves_runtime` | Unrelated pure Todo rules and ping time out while warm reads are fast. | Same-byte isolated cold/warm/alternating-Goal probes, original response budget, CPU profile and exact provider revision. | Synchronous retained-history verification monopolizes the shared event loop; allocation-heavy canonical key sorting amplifies it. | Preserve byte-level proof semantics, reduce codec allocations, yield between complete transaction proofs and share only identical in-flight proofs. Test real socket concurrency and late-history corruption; do not raise timeouts, weaken integrity or replay ambiguous writes. |
| `native_todo_status_index_schema_gap` | A Goal shows “status load failed / invalid response” after native Todos appear, while the scoped status endpoint returns valid JSON. | Exact scoped status response, Zod issue paths, Todo `todo_id` and `index` fields, native presentation contract, packaged Goal load. | The dashboard still requires every Todo to have a numeric source index, but native Todos deliberately use stable `todo_id` with absent or null index. One row rejects the entire Goal snapshot. | Accept nullable/absent index only when a nonempty stable Todo ID exists; keep numeric legacy indexes and reject anonymous or malformed rows. Render and act by Todo ID, then prove a mixed native/legacy scoped snapshot loads in the packaged UI. |
Expand Down
105 changes: 105 additions & 0 deletions tests/test_runtime_archive.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,108 @@ def test_archive_runtime_resolves_relative_root_from_registry(
assert not source.exists()
assert (archive_path / "expected.txt").read_text(encoding="utf-8") == "target"
assert (decoy / "decoy.txt").read_text(encoding="utf-8") == "unrelated"


@pytest.mark.parametrize(
"archive_suffix",
[
Path("."),
Path("archive"),
Path("archive/deeper"),
],
ids=["source", "direct-child", "nested-child"],
)
@pytest.mark.parametrize("execute", [False, True], ids=["dry-run", "execute"])
def test_archive_runtime_rejects_destination_inside_source(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
archive_suffix: Path,
execute: bool,
) -> None:
registry_path = tmp_path / ".loopx" / "registry.json"
source = tmp_path / ".loopx" / "runtime" / "goals" / "obsolete-goal"
source.mkdir(parents=True)
marker = source / "expected.txt"
marker.write_text("preserved", encoding="utf-8")
registry_path.write_text(
json.dumps(
{
"schema_version": 1,
"common_runtime_root": ".loopx/runtime",
"goals": [],
}
),
encoding="utf-8",
)

def fail_move(_source: str, _destination: str) -> None:
pytest.fail("invalid archive destination reached shutil.move")

monkeypatch.setattr("loopx.runtime.shutil.move", fail_move)

with pytest.raises(
ValueError,
match="archive destination must be outside the runtime goal directory",
):
archive_runtime_goal(
registry_path=registry_path,
runtime_root_override=None,
goal_id="obsolete-goal",
archive_root=source / archive_suffix,
allow_registered=False,
execute=execute,
)

assert marker.read_text(encoding="utf-8") == "preserved"
assert [path.relative_to(source) for path in source.rglob("*")] == [
Path("expected.txt")
]


@pytest.mark.parametrize("source_alias", [False, True], ids=["direct", "alias"])
@pytest.mark.parametrize("execute", [False, True], ids=["dry-run", "execute"])
def test_archive_runtime_rejects_source_child_symlink_to_external_archive(
tmp_path: Path,
source_alias: bool,
execute: bool,
) -> None:
registry_path = tmp_path / ".loopx" / "registry.json"
source = tmp_path / ".loopx" / "runtime" / "goals" / "obsolete-goal"
source.mkdir(parents=True)
marker = source / "expected.txt"
marker.write_text("preserved", encoding="utf-8")
registry_path.write_text(
json.dumps(
{
"schema_version": 1,
"common_runtime_root": ".loopx/runtime",
"goals": [],
}
),
encoding="utf-8",
)
external_archive = tmp_path / "external-archive"
external_archive.mkdir()
source_route = source
if source_alias:
source_route = tmp_path / "source-alias"
source_route.symlink_to(source, target_is_directory=True)
archive_root = source_route / "archive-link"
archive_root.symlink_to(external_archive, target_is_directory=True)

with pytest.raises(
ValueError,
match="archive destination must be outside the runtime goal directory",
):
archive_runtime_goal(
registry_path=registry_path,
runtime_root_override=None,
goal_id="obsolete-goal",
archive_root=archive_root,
allow_registered=False,
execute=execute,
)

assert marker.read_text(encoding="utf-8") == "preserved"
assert archive_root.resolve() == external_archive.resolve()
assert list(external_archive.iterdir()) == []
Loading