sdks/python: mirror Elydora-Python-SDK 2.0.2
Rebuilt from the standalone 54e65eb tree (durable transactions, Kiro IDE adapter, hook retry with a bounded chain lock, version 2.0.2) with the Open-Source API trims re-applied: no token rotation, token issuance, or agent integration updates, and the mirror's registration contract and OpenCode plugin tests kept. Claude-Session: https://claude.ai/code/session_01QoUjdc7HGKTsgj5GhG8HvF
This commit is contained in:
1 parent
b00932d047
commit
1656a94811
56 files changed
+12203
-588
No files matched your search
@@ -53,17 +53,21 @@ def test_transaction_rolls_back_all_seven_files(
|
||||
) -> None:
|
||||
original = '{"telemetryEnabled":true}\n'
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path, existing_settings=original)
|
||||
real_replace = _transaction.os.replace
|
||||
real_replace = _transaction._replace_physical
|
||||
failed = False
|
||||
|
||||
def fail_settings_once(source: str, destination: str) -> None:
|
||||
def fail_settings_once(
|
||||
source: str,
|
||||
destination: str,
|
||||
directory: _transaction.PinnedDirectory,
|
||||
) -> None:
|
||||
nonlocal failed
|
||||
if os.path.abspath(destination) == str(fixture.config_path) and not failed:
|
||||
failed = True
|
||||
raise OSError("injected Auggie settings failure")
|
||||
real_replace(source, destination)
|
||||
real_replace(source, destination, directory)
|
||||
|
||||
monkeypatch.setattr(_transaction.os, "replace", fail_settings_once)
|
||||
monkeypatch.setattr(_transaction, "_replace_physical", fail_settings_once)
|
||||
with pytest.raises(OSError, match="injected Auggie settings failure"):
|
||||
fixture.install()
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from threading import Thread
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -12,6 +17,7 @@ from claudecode_support import (
|
||||
run_handler,
|
||||
server_base_url,
|
||||
start_api_server,
|
||||
write_json,
|
||||
)
|
||||
|
||||
|
||||
@@ -144,3 +150,190 @@ def test_runtime_failures_are_observable_and_fail_open(
|
||||
log = (fixture.agent_dir / "error.log").read_text(encoding="utf-8")
|
||||
assert "invalid JSON" in log
|
||||
assert "refused" in log.lower() or "urlopen" in log.lower()
|
||||
|
||||
|
||||
class _MismatchApi(BaseHTTPRequestHandler):
|
||||
submissions: list[dict[str, Any]] = []
|
||||
rejections = 1
|
||||
delay = 0.0
|
||||
state_path: Path | None = None
|
||||
expected = "Rxlf4j36C3KvIQ3hWuOkX698BR5iDypUFuB70JjEuvM"
|
||||
|
||||
@classmethod
|
||||
def reset(cls, rejections: int, delay: float = 0.0) -> None:
|
||||
cls.submissions = []
|
||||
cls.rejections = rejections
|
||||
cls.delay = delay
|
||||
cls.state_path = None
|
||||
|
||||
def _respond(self, status: int, value: object) -> None:
|
||||
body = json.dumps(value).encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
self._respond(200, {"agent": {"status": "active"}})
|
||||
|
||||
def do_POST(self) -> None:
|
||||
raw = self.rfile.read(int(self.headers.get("Content-Length", "0")))
|
||||
cls = type(self)
|
||||
cls.submissions.append(json.loads(raw))
|
||||
count = len(cls.submissions)
|
||||
if cls.delay:
|
||||
time.sleep(cls.delay)
|
||||
if cls.state_path is not None:
|
||||
write_json(cls.state_path, {"prev_chain_hash": "B" * 43})
|
||||
if count > cls.rejections:
|
||||
self._respond(202, {"receipt": {"seq_no": count}})
|
||||
return
|
||||
expected = cls.expected if cls.rejections == 1 else str(count).rjust(43, "A")
|
||||
self._respond(400, {"error": {
|
||||
"code": "PREV_HASH_MISMATCH",
|
||||
"message": f'Expected prev_chain_hash "{expected}", got "x".',
|
||||
}})
|
||||
|
||||
def log_message(self, _format: str, *_args: object) -> None:
|
||||
return
|
||||
|
||||
|
||||
def _mismatch_server(rejections: int, delay: float = 0.0) -> ThreadingHTTPServer:
|
||||
_MismatchApi.reset(rejections, delay)
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), _MismatchApi)
|
||||
Thread(target=server.serve_forever, daemon=True).start()
|
||||
return server
|
||||
|
||||
|
||||
def test_audit_retries_rejected_prev_chain_hash_with_server_value(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
server = _mismatch_server(1)
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path, base_url=server_base_url(server))
|
||||
try:
|
||||
fixture.install()
|
||||
audit = managed_handler(fixture.settings(), "PostToolUse")
|
||||
result = run_handler(
|
||||
audit, encoded(official_payload("PostToolUse", tool_response={"stdout": "ok"})), fixture
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
submissions = _MismatchApi.submissions
|
||||
assert len(submissions) == 2
|
||||
assert submissions[1]["prev_chain_hash"] == _MismatchApi.expected
|
||||
assert submissions[1]["operation_id"] != submissions[0]["operation_id"]
|
||||
assert submissions[1]["nonce"] != submissions[0]["nonce"]
|
||||
state = json.loads((fixture.agent_dir / "chain-state.json").read_text(encoding="utf-8"))
|
||||
assert state["prev_chain_hash"] == submissions[1]["chain_hash"]
|
||||
assert "resynced to server: Rxlf" in (fixture.agent_dir / "error.log").read_text(encoding="utf-8")
|
||||
finally:
|
||||
server.shutdown()
|
||||
|
||||
|
||||
def test_audit_stops_after_five_rejected_prev_chain_hash_attempts(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
server = _mismatch_server(99)
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path, base_url=server_base_url(server))
|
||||
try:
|
||||
fixture.install()
|
||||
audit = managed_handler(fixture.settings(), "PostToolUse")
|
||||
result = run_handler(
|
||||
audit, encoded(official_payload("PostToolUse", tool_response={"stdout": "ok"})), fixture
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert len(_MismatchApi.submissions) == 5
|
||||
assert _MismatchApi.submissions[4]["prev_chain_hash"] == "4".rjust(43, "A")
|
||||
assert "rejected prev_chain_hash 5 times" in (fixture.agent_dir / "error.log").read_text(encoding="utf-8")
|
||||
finally:
|
||||
server.shutdown()
|
||||
|
||||
|
||||
def test_audit_stops_retrying_when_the_submit_budget_is_spent(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
server = _mismatch_server(99, delay=2.6)
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path, base_url=server_base_url(server))
|
||||
try:
|
||||
fixture.install()
|
||||
audit = managed_handler(fixture.settings(), "PostToolUse")
|
||||
started = time.monotonic()
|
||||
result = run_handler(
|
||||
audit, encoded(official_payload("PostToolUse", tool_response={"stdout": "ok"})), fixture
|
||||
)
|
||||
elapsed = time.monotonic() - started
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert elapsed < 7.5, elapsed
|
||||
assert 2 <= len(_MismatchApi.submissions) <= 3
|
||||
log = (fixture.agent_dir / "error.log").read_text(encoding="utf-8").lower()
|
||||
assert "timed out" in log or "retry budget exhausted" in log
|
||||
finally:
|
||||
server.shutdown()
|
||||
|
||||
|
||||
def test_audit_does_not_regress_a_chain_head_advanced_by_a_concurrent_hook(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
server = _mismatch_server(0)
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path, base_url=server_base_url(server))
|
||||
try:
|
||||
fixture.install()
|
||||
_MismatchApi.state_path = fixture.agent_dir / "chain-state.json"
|
||||
audit = managed_handler(fixture.settings(), "PostToolUse")
|
||||
result = run_handler(
|
||||
audit, encoded(official_payload("PostToolUse", tool_response={"stdout": "ok"})), fixture
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert _MismatchApi.submissions[0]["prev_chain_hash"] == "A" * 43
|
||||
state = json.loads((fixture.agent_dir / "chain-state.json").read_text(encoding="utf-8"))
|
||||
assert state["prev_chain_hash"] == "B" * 43
|
||||
finally:
|
||||
server.shutdown()
|
||||
|
||||
|
||||
def test_audit_clears_a_stale_chain_state_lock_and_proceeds(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
server = _mismatch_server(0)
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path, base_url=server_base_url(server))
|
||||
try:
|
||||
fixture.install()
|
||||
lock_path = fixture.agent_dir / "chain-state.json.lock"
|
||||
lock_path.touch(mode=0o600)
|
||||
stale = time.time() - 10
|
||||
os.utime(lock_path, (stale, stale))
|
||||
audit = managed_handler(fixture.settings(), "PostToolUse")
|
||||
result = run_handler(
|
||||
audit, encoded(official_payload("PostToolUse", tool_response={"stdout": "ok"})), fixture
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert len(_MismatchApi.submissions) == 1
|
||||
assert not lock_path.exists()
|
||||
state = json.loads((fixture.agent_dir / "chain-state.json").read_text(encoding="utf-8"))
|
||||
assert state["prev_chain_hash"] == _MismatchApi.submissions[0]["chain_hash"]
|
||||
finally:
|
||||
server.shutdown()
|
||||
|
||||
|
||||
def test_audit_does_not_reclaim_a_lock_whose_owner_is_alive(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
server = _mismatch_server(0)
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path, base_url=server_base_url(server))
|
||||
try:
|
||||
fixture.install()
|
||||
lock_path = fixture.agent_dir / "chain-state.json.lock"
|
||||
lock_path.touch(mode=0o600)
|
||||
lock_path.write_text(str(os.getpid()), encoding="ascii")
|
||||
stale = time.time() - 10
|
||||
os.utime(lock_path, (stale, stale))
|
||||
audit = managed_handler(fixture.settings(), "PostToolUse")
|
||||
result = run_handler(
|
||||
audit, encoded(official_payload("PostToolUse", tool_response={"stdout": "ok"})), fixture
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert len(_MismatchApi.submissions) == 1
|
||||
assert lock_path.exists()
|
||||
assert "lock timed out" in (fixture.agent_dir / "error.log").read_text(encoding="utf-8")
|
||||
finally:
|
||||
server.shutdown()
|
||||
@@ -69,17 +69,21 @@ def test_transaction_rolls_back_all_five_files(
|
||||
fixture = prepare_fixture(
|
||||
monkeypatch, tmp_path, existing_settings=original
|
||||
)
|
||||
real_replace = _transaction.os.replace
|
||||
real_replace = _transaction._replace_physical
|
||||
failed = False
|
||||
|
||||
def fail_settings_once(source: str, destination: str) -> None:
|
||||
def fail_settings_once(
|
||||
source: str,
|
||||
destination: str,
|
||||
directory: _transaction.PinnedDirectory,
|
||||
) -> None:
|
||||
nonlocal failed
|
||||
if os.path.abspath(destination) == str(fixture.config_path) and not failed:
|
||||
failed = True
|
||||
raise OSError("injected Claude settings failure")
|
||||
real_replace(source, destination)
|
||||
real_replace(source, destination, directory)
|
||||
|
||||
monkeypatch.setattr(_transaction.os, "replace", fail_settings_once)
|
||||
monkeypatch.setattr(_transaction, "_replace_physical", fail_settings_once)
|
||||
|
||||
with pytest.raises(OSError, match="injected Claude settings failure"):
|
||||
fixture.install()
|
||||
@@ -118,19 +122,23 @@ def test_recovery_preserves_original_after_committed_runtime_changes(
|
||||
fixture.config["org_id"] = "org-updated"
|
||||
fixture.config["private_key"] = base64url_encode(bytes(range(31, -1, -1)))
|
||||
changes = prepare_installation(fixture)
|
||||
real_replace = _transaction.os.replace
|
||||
real_replace = _transaction._replace_physical
|
||||
calls = 0
|
||||
|
||||
def change_first_then_fail(source: str, destination: str) -> None:
|
||||
def change_first_then_fail(
|
||||
source: str,
|
||||
destination: str,
|
||||
directory: _transaction.PinnedDirectory,
|
||||
) -> None:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
real_replace(source, destination)
|
||||
real_replace(source, destination, directory)
|
||||
write_text(Path(destination), "external change\n")
|
||||
return
|
||||
raise OSError("injected later commit failure")
|
||||
|
||||
monkeypatch.setattr(_transaction.os, "replace", change_first_then_fail)
|
||||
monkeypatch.setattr(_transaction, "_replace_physical", change_first_then_fail)
|
||||
|
||||
with pytest.raises(OSError, match="original content preserved at"):
|
||||
claudecode_installation.commit_claude_installation(changes)
|
||||
|
||||
@@ -17,7 +17,6 @@ from elydora.plugins._file_io import write_text_atomic
|
||||
from elydora.plugins import (
|
||||
copilot,
|
||||
cursor,
|
||||
kiroide,
|
||||
opencode,
|
||||
)
|
||||
from elydora.plugins.hook_template import generate_hook_script
|
||||
@@ -373,7 +372,6 @@ def test_atomic_secret_write_preserves_existing_file_on_replace_failure(
|
||||
[
|
||||
(copilot, copilot.CopilotPlugin, "copilot"),
|
||||
(cursor, cursor.CursorPlugin, "cursor"),
|
||||
(kiroide, kiroide.KiroIdePlugin, "kiroide"),
|
||||
(opencode, opencode.OpenCodePlugin, "opencode"),
|
||||
],
|
||||
)
|
||||
@@ -402,8 +400,6 @@ def test_legacy_plugins_persist_one_owner_only_private_key(
|
||||
"SETTINGS_PATH",
|
||||
str(case_root / "provider" / "settings.json"),
|
||||
)
|
||||
elif module is kiroide:
|
||||
monkeypatch.setattr(module, "KIRO_HOOK_DIR", str(case_root / "kiro-hooks"))
|
||||
elif module is opencode:
|
||||
monkeypatch.setattr(module, "PLUGIN_DIR", str(case_root / "plugins"))
|
||||
elif module is copilot:
|
||||
|
||||
@@ -37,17 +37,21 @@ def test_transaction_rolls_back_all_six_files(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
real_replace = _transaction.os.replace
|
||||
real_replace = _transaction._replace_physical
|
||||
failed = False
|
||||
|
||||
def fail_audit_once(source: str, destination: str) -> None:
|
||||
def fail_audit_once(
|
||||
source: str,
|
||||
destination: str,
|
||||
directory: _transaction.PinnedDirectory,
|
||||
) -> None:
|
||||
nonlocal failed
|
||||
if os.path.abspath(destination) == str(fixture.audit_wrapper) and not failed:
|
||||
failed = True
|
||||
raise OSError("injected Cline audit hook failure")
|
||||
real_replace(source, destination)
|
||||
real_replace(source, destination, directory)
|
||||
|
||||
monkeypatch.setattr(_transaction.os, "replace", fail_audit_once)
|
||||
monkeypatch.setattr(_transaction, "_replace_physical", fail_audit_once)
|
||||
with pytest.raises(OSError, match="injected Cline audit hook failure"):
|
||||
fixture.install()
|
||||
|
||||
@@ -64,17 +68,21 @@ def test_concurrent_hook_replacement_is_detected_before_final_commit(
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
changes = prepare_installation(fixture)
|
||||
concurrent = "// concurrently replaced\n"
|
||||
real_replace = _transaction.os.replace
|
||||
real_replace = _transaction._replace_physical
|
||||
injected = False
|
||||
|
||||
def inject_replacement(source: str, destination: str) -> None:
|
||||
def inject_replacement(
|
||||
source: str,
|
||||
destination: str,
|
||||
directory: _transaction.PinnedDirectory,
|
||||
) -> None:
|
||||
nonlocal injected
|
||||
if not injected:
|
||||
injected = True
|
||||
write_text(fixture.audit_wrapper, concurrent, 0o700)
|
||||
real_replace(source, destination)
|
||||
real_replace(source, destination, directory)
|
||||
|
||||
monkeypatch.setattr(_transaction.os, "replace", inject_replacement)
|
||||
monkeypatch.setattr(_transaction, "_replace_physical", inject_replacement)
|
||||
with pytest.raises(OSError, match="changed during installation"):
|
||||
cline_installation.commit_cline_installation(changes)
|
||||
|
||||
@@ -229,17 +237,21 @@ def test_uninstall_restores_both_hooks_when_second_removal_fails(
|
||||
),
|
||||
AGENT_ID,
|
||||
)
|
||||
real_replace = _transaction.os.replace
|
||||
real_capture = _transaction._capture_physical
|
||||
failed = False
|
||||
|
||||
def fail_audit_removal(source: str, destination: str) -> None:
|
||||
def fail_audit_removal(
|
||||
source: str,
|
||||
destination: str,
|
||||
directory: _transaction.PinnedDirectory,
|
||||
) -> None:
|
||||
nonlocal failed
|
||||
if os.path.abspath(source) == str(fixture.audit_wrapper) and not failed:
|
||||
failed = True
|
||||
raise OSError("injected Cline uninstall failure")
|
||||
real_replace(source, destination)
|
||||
real_capture(source, destination, directory)
|
||||
|
||||
monkeypatch.setattr(_transaction.os, "replace", fail_audit_removal)
|
||||
monkeypatch.setattr(_transaction, "_capture_physical", fail_audit_removal)
|
||||
with pytest.raises(OSError, match="injected Cline uninstall failure"):
|
||||
cline_installation.commit_cline_uninstall(changes)
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ from codex_support import (
|
||||
write_json,
|
||||
write_text,
|
||||
)
|
||||
from elydora.plugins import codex
|
||||
from elydora.plugins import _transaction, codex
|
||||
|
||||
|
||||
def assert_no_transaction_files(root: Path) -> None:
|
||||
@@ -34,17 +34,21 @@ def test_transaction_rolls_back_all_five_files_when_hooks_commit_fails(
|
||||
existing_config={"hooks": {"SessionStart": []}},
|
||||
)
|
||||
original = fixture.config_path.read_text(encoding="utf-8")
|
||||
real_replace = os.replace
|
||||
real_replace = _transaction._replace_physical
|
||||
failed = False
|
||||
|
||||
def fail_hooks_once(source: str, destination: str) -> None:
|
||||
def fail_hooks_once(
|
||||
source: str,
|
||||
destination: str,
|
||||
directory: _transaction.PinnedDirectory,
|
||||
) -> None:
|
||||
nonlocal failed
|
||||
if os.path.abspath(destination) == str(fixture.config_path) and not failed:
|
||||
failed = True
|
||||
raise OSError("injected hooks commit failure")
|
||||
real_replace(source, destination)
|
||||
real_replace(source, destination, directory)
|
||||
|
||||
monkeypatch.setattr(os, "replace", fail_hooks_once)
|
||||
monkeypatch.setattr(_transaction, "_replace_physical", fail_hooks_once)
|
||||
|
||||
with pytest.raises(OSError, match="injected hooks commit failure"):
|
||||
fixture.install()
|
||||
|
||||
@@ -13,6 +13,7 @@ from cursor_support import (
|
||||
write_json,
|
||||
write_text,
|
||||
)
|
||||
from elydora.plugins import _transaction
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -194,17 +195,21 @@ def test_transaction_rolls_back_all_files_when_config_commit_fails(
|
||||
existing_config={"version": 1, "hooks": {"sessionStart": []}},
|
||||
)
|
||||
original = fixture.config_path.read_text(encoding="utf-8")
|
||||
real_replace = os.replace
|
||||
real_replace = _transaction._replace_physical
|
||||
failed = False
|
||||
|
||||
def fail_config_once(source: str, destination: str) -> None:
|
||||
def fail_config_once(
|
||||
source: str,
|
||||
destination: str,
|
||||
directory: _transaction.PinnedDirectory,
|
||||
) -> None:
|
||||
nonlocal failed
|
||||
if os.path.abspath(destination) == str(fixture.config_path) and not failed:
|
||||
failed = True
|
||||
raise OSError("injected config commit failure")
|
||||
real_replace(source, destination)
|
||||
real_replace(source, destination, directory)
|
||||
|
||||
monkeypatch.setattr(os, "replace", fail_config_once)
|
||||
monkeypatch.setattr(_transaction, "_replace_physical", fail_config_once)
|
||||
|
||||
with pytest.raises(OSError, match="injected config commit failure"):
|
||||
fixture.install()
|
||||
|
||||
@@ -225,10 +225,16 @@ def test_guard_blocks_and_audit_preserves_native_payload(
|
||||
guard = managed_handler(hooks, "PreToolUse", "guard.py")
|
||||
audit = managed_handler(hooks, "PostToolUse", "hook.py")
|
||||
assert guard is not None and audit is not None
|
||||
write_json(fixture.agent_dir / "status-cache.json", {
|
||||
"status": "frozen",
|
||||
"cached_at": time.time(),
|
||||
})
|
||||
status_cache_path = fixture.agent_dir / "status-cache.json"
|
||||
write_json(
|
||||
status_cache_path,
|
||||
{
|
||||
"status": "frozen",
|
||||
"cached_at": time.time(),
|
||||
},
|
||||
)
|
||||
if os.name != "nt":
|
||||
status_cache_path.chmod(0o600)
|
||||
pre_payload = {
|
||||
"session_id": "session-1",
|
||||
"transcript_path": str(fixture.home_dir / "transcript.jsonl"),
|
||||
|
||||
@@ -63,17 +63,21 @@ def test_install_rolls_back_runtime_after_late_hook_commit_failure(
|
||||
) -> None:
|
||||
fixture = DroidFixture(monkeypatch, tmp_path)
|
||||
prepared = _prepare_installation(fixture)
|
||||
real_replace = _transaction.os.replace
|
||||
real_replace = _transaction._replace_physical
|
||||
failed = False
|
||||
|
||||
def fail_hook_commit(source: Any, destination: Any) -> None:
|
||||
def fail_hook_commit(
|
||||
source: str,
|
||||
destination: str,
|
||||
directory: _transaction.PinnedDirectory,
|
||||
) -> None:
|
||||
nonlocal failed
|
||||
if not failed and Path(destination) == fixture.root_path:
|
||||
failed = True
|
||||
raise OSError("injected Droid hook commit failure")
|
||||
real_replace(source, destination)
|
||||
real_replace(source, destination, directory)
|
||||
|
||||
monkeypatch.setattr(_transaction.os, "replace", fail_hook_commit)
|
||||
monkeypatch.setattr(_transaction, "_replace_physical", fail_hook_commit)
|
||||
with pytest.raises(OSError, match="injected Droid hook commit failure"):
|
||||
commit_droid_installation(prepared)
|
||||
assert failed is True
|
||||
@@ -200,17 +204,21 @@ def test_uninstall_rolls_back_all_hook_documents(
|
||||
for document in source_documents(sources)
|
||||
]
|
||||
prepared = prepare_droid_uninstall(rendered)
|
||||
real_replace = _transaction.os.replace
|
||||
real_replace = _transaction._replace_physical
|
||||
failed = False
|
||||
|
||||
def fail_second_source(source: Any, destination: Any) -> None:
|
||||
def fail_second_source(
|
||||
source: str,
|
||||
destination: str,
|
||||
directory: _transaction.PinnedDirectory,
|
||||
) -> None:
|
||||
nonlocal failed
|
||||
if not failed and Path(destination) == fixture.settings_path:
|
||||
failed = True
|
||||
raise OSError("injected Droid uninstall failure")
|
||||
real_replace(source, destination)
|
||||
real_replace(source, destination, directory)
|
||||
|
||||
monkeypatch.setattr(_transaction.os, "replace", fail_second_source)
|
||||
monkeypatch.setattr(_transaction, "_replace_physical", fail_second_source)
|
||||
with pytest.raises(OSError, match="injected Droid uninstall failure"):
|
||||
commit_droid_uninstall(prepared)
|
||||
assert failed is True
|
||||
@@ -229,6 +237,8 @@ def test_cli_preflight_blocks_disabled_hooks_before_runtime_creation(
|
||||
VALID_PRIVATE_KEY,
|
||||
encoding="utf-8",
|
||||
)
|
||||
if os.name != "nt":
|
||||
key_path.chmod(0o600)
|
||||
environment = {
|
||||
**os.environ,
|
||||
"HOME": str(home_dir),
|
||||
|
||||
@@ -63,17 +63,21 @@ def test_transaction_rolls_back_all_five_files(
|
||||
fixture = prepare_fixture(
|
||||
monkeypatch, tmp_path, existing_settings=original
|
||||
)
|
||||
real_replace = _transaction.os.replace
|
||||
real_replace = _transaction._replace_physical
|
||||
failed = False
|
||||
|
||||
def fail_settings_once(source: str, destination: str) -> None:
|
||||
def fail_settings_once(
|
||||
source: str,
|
||||
destination: str,
|
||||
directory: _transaction.PinnedDirectory,
|
||||
) -> None:
|
||||
nonlocal failed
|
||||
if os.path.abspath(destination) == str(fixture.config_path) and not failed:
|
||||
failed = True
|
||||
raise OSError("injected Gemini settings failure")
|
||||
real_replace(source, destination)
|
||||
real_replace(source, destination, directory)
|
||||
|
||||
monkeypatch.setattr(_transaction.os, "replace", fail_settings_once)
|
||||
monkeypatch.setattr(_transaction, "_replace_physical", fail_settings_once)
|
||||
|
||||
with pytest.raises(OSError, match="injected Gemini settings failure"):
|
||||
fixture.install()
|
||||
|
||||
@@ -73,17 +73,21 @@ def test_install_restores_config_and_four_runtimes_when_final_commit_fails(
|
||||
fixture = prepare_fixture(
|
||||
monkeypatch, tmp_path, existing_config=original
|
||||
)
|
||||
real_replace = _transaction.os.replace
|
||||
real_replace = _transaction._replace_physical
|
||||
failed = False
|
||||
|
||||
def fail_config_once(source: str, destination: str) -> None:
|
||||
def fail_config_once(
|
||||
source: str,
|
||||
destination: str,
|
||||
directory: _transaction.PinnedDirectory,
|
||||
) -> None:
|
||||
nonlocal failed
|
||||
if os.path.abspath(destination) == str(fixture.config_path) and not failed:
|
||||
failed = True
|
||||
raise OSError("injected Grok config failure")
|
||||
real_replace(source, destination)
|
||||
real_replace(source, destination, directory)
|
||||
|
||||
monkeypatch.setattr(_transaction.os, "replace", fail_config_once)
|
||||
monkeypatch.setattr(_transaction, "_replace_physical", fail_config_once)
|
||||
|
||||
with pytest.raises(OSError, match="injected Grok config failure"):
|
||||
fixture.install()
|
||||
@@ -132,10 +136,14 @@ def test_uninstall_preserves_config_when_commit_fails(
|
||||
)
|
||||
changes = grok_installation.prepare_grok_uninstall(rendered)
|
||||
|
||||
def fail_replace(_source: str, _destination: str) -> None:
|
||||
def fail_replace(
|
||||
_source: str,
|
||||
_destination: str,
|
||||
_directory: _transaction.PinnedDirectory,
|
||||
) -> None:
|
||||
raise OSError("injected Grok uninstall failure")
|
||||
|
||||
monkeypatch.setattr(_transaction.os, "replace", fail_replace)
|
||||
monkeypatch.setattr(_transaction, "_replace_physical", fail_replace)
|
||||
|
||||
with pytest.raises(OSError, match="injected Grok uninstall failure"):
|
||||
grok_installation.commit_grok_uninstall(changes)
|
||||
@@ -152,20 +160,24 @@ def test_recovery_preserves_original_after_committed_file_changes(
|
||||
original = fixture.runtime_config_path.read_text(encoding="utf-8")
|
||||
fixture.config["org_id"] = "org-updated"
|
||||
changes = prepare_installation(fixture)
|
||||
real_replace = _transaction.os.replace
|
||||
real_replace = _transaction._replace_physical
|
||||
calls = 0
|
||||
|
||||
def change_first_then_fail(source: str, destination: str) -> None:
|
||||
def change_first_then_fail(
|
||||
source: str,
|
||||
destination: str,
|
||||
directory: _transaction.PinnedDirectory,
|
||||
) -> None:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
real_replace(source, destination)
|
||||
real_replace(source, destination, directory)
|
||||
write_text(Path(destination), "external change\n")
|
||||
return
|
||||
raise OSError("injected later commit failure")
|
||||
|
||||
monkeypatch.setattr(
|
||||
_transaction.os, "replace", change_first_then_fail
|
||||
_transaction, "_replace_physical", change_first_then_fail
|
||||
)
|
||||
|
||||
with pytest.raises(OSError, match="original content preserved at"):
|
||||
@@ -189,21 +201,29 @@ def test_recovery_preserves_original_after_restore_failure(
|
||||
bytes(range(31, -1, -1))
|
||||
)
|
||||
changes = prepare_installation(fixture)
|
||||
real_replace = _transaction.os.replace
|
||||
real_replace = _transaction._replace_physical
|
||||
commits = 0
|
||||
|
||||
def fail_commit_and_restore(source: str, destination: str) -> None:
|
||||
def fail_later_commit(
|
||||
source: str,
|
||||
destination: str,
|
||||
directory: _transaction.PinnedDirectory,
|
||||
) -> None:
|
||||
nonlocal commits
|
||||
if source.endswith(".rollback"):
|
||||
raise OSError("injected rollback failure")
|
||||
commits += 1
|
||||
if commits == 2:
|
||||
raise OSError("injected later commit failure")
|
||||
real_replace(source, destination)
|
||||
real_replace(source, destination, directory)
|
||||
|
||||
monkeypatch.setattr(
|
||||
_transaction.os, "replace", fail_commit_and_restore
|
||||
)
|
||||
def fail_restore(
|
||||
_source: str,
|
||||
_destination: str,
|
||||
_directory: _transaction.PinnedDirectory,
|
||||
) -> None:
|
||||
raise OSError("injected rollback failure")
|
||||
|
||||
monkeypatch.setattr(_transaction, "_replace_physical", fail_later_commit)
|
||||
monkeypatch.setattr(_transaction, "_restore_physical", fail_restore)
|
||||
|
||||
with pytest.raises(OSError, match="original content preserved at"):
|
||||
grok_installation.commit_grok_installation(changes)
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from elydora.plugins.hook_template import generate_hook_script
|
||||
|
||||
|
||||
MAX_ERROR_LOG_BYTES = 2 * 1024 * 1024
|
||||
LOG_RUNNER = """
|
||||
import sys
|
||||
|
||||
namespace = {"__name__": "elydora_generated_hook_test"}
|
||||
with open(sys.argv[1], encoding="utf-8") as source:
|
||||
exec(compile(source.read(), sys.argv[1], "exec"), namespace)
|
||||
namespace["log_error"](RuntimeError(sys.argv[2]))
|
||||
"""
|
||||
LARGE_LOG_RUNNER = """
|
||||
import sys
|
||||
|
||||
namespace = {"__name__": "elydora_generated_hook_test"}
|
||||
with open(sys.argv[1], encoding="utf-8") as source:
|
||||
exec(compile(source.read(), sys.argv[1], "exec"), namespace)
|
||||
namespace["log_error"](RuntimeError("x" * (128 * 1024)))
|
||||
"""
|
||||
|
||||
|
||||
def prepare_hook(tmp_path: Path) -> tuple[Path, Path, dict[str, str]]:
|
||||
agent_directory = tmp_path / ".elydora" / "agent-1"
|
||||
agent_directory.mkdir(parents=True)
|
||||
script_path = tmp_path / "hook.py"
|
||||
script_path.write_text(
|
||||
generate_hook_script(
|
||||
org_id="org-1",
|
||||
agent_id="agent-1",
|
||||
kid="kid-1",
|
||||
base_url="https://api.elydora.test",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
environment = {
|
||||
**os.environ,
|
||||
"HOME": str(tmp_path),
|
||||
"USERPROFILE": str(tmp_path),
|
||||
}
|
||||
return script_path, agent_directory / "error.log", environment
|
||||
|
||||
|
||||
def run_log_error(
|
||||
script_path: Path,
|
||||
environment: dict[str, str],
|
||||
message: str,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[sys.executable, "-c", LOG_RUNNER, str(script_path), message],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
env=environment,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def write_private_log(path: Path, contents: bytes) -> None:
|
||||
path.write_bytes(contents)
|
||||
path.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
||||
|
||||
|
||||
def test_error_log_truncates_at_the_limit_and_records_the_current_error(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
script_path, error_log, environment = prepare_hook(tmp_path)
|
||||
write_private_log(error_log, b"old error\n" + b"x" * MAX_ERROR_LOG_BYTES)
|
||||
|
||||
result = run_log_error(script_path, environment, "current-delivery-error")
|
||||
|
||||
assert result.returncode == 0
|
||||
assert result.stderr == ""
|
||||
contents = error_log.read_bytes()
|
||||
assert len(contents) <= MAX_ERROR_LOG_BYTES
|
||||
assert b"old error" not in contents
|
||||
assert b"Error log truncated at the 2097152-byte limit" in contents
|
||||
assert b"current-delivery-error" in contents
|
||||
|
||||
|
||||
def test_error_log_rolls_over_before_a_new_entry_would_cross_the_limit(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
script_path, error_log, environment = prepare_hook(tmp_path)
|
||||
write_private_log(error_log, b"x" * (MAX_ERROR_LOG_BYTES - 8))
|
||||
|
||||
result = run_log_error(script_path, environment, "newest-error")
|
||||
|
||||
assert result.returncode == 0
|
||||
assert result.stderr == ""
|
||||
contents = error_log.read_bytes()
|
||||
assert len(contents) <= MAX_ERROR_LOG_BYTES
|
||||
assert contents.startswith(b"[")
|
||||
assert b"Error log truncated at the 2097152-byte limit" in contents
|
||||
assert b"newest-error" in contents
|
||||
|
||||
|
||||
def test_concurrent_error_writers_keep_the_log_bounded(tmp_path: Path) -> None:
|
||||
script_path, error_log, environment = prepare_hook(tmp_path)
|
||||
write_private_log(error_log, b"x" * (MAX_ERROR_LOG_BYTES - 64))
|
||||
processes = [
|
||||
subprocess.Popen(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
LOG_RUNNER,
|
||||
str(script_path),
|
||||
f"concurrent-error-{index}",
|
||||
],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
env=environment,
|
||||
text=True,
|
||||
)
|
||||
for index in range(12)
|
||||
]
|
||||
|
||||
results = [process.communicate(timeout=15) for process in processes]
|
||||
|
||||
assert all(process.returncode == 0 for process in processes)
|
||||
assert all(stderr == "" for _stdout, stderr in results)
|
||||
contents = error_log.read_bytes()
|
||||
assert len(contents) <= MAX_ERROR_LOG_BYTES
|
||||
assert b"Error log truncated at the 2097152-byte limit" in contents
|
||||
for index in range(12):
|
||||
assert f"concurrent-error-{index}".encode() in contents
|
||||
|
||||
|
||||
def test_single_error_entry_is_truncated_to_a_bounded_utf8_record(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
script_path, error_log, environment = prepare_hook(tmp_path)
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", LARGE_LOG_RUNNER, str(script_path)],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
env=environment,
|
||||
text=True,
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert result.stderr == ""
|
||||
contents = error_log.read_bytes()
|
||||
assert len(contents) == 64 * 1024
|
||||
assert contents.decode("utf-8").endswith("... [error entry truncated]\n")
|
||||
@@ -43,17 +43,21 @@ def test_install_restores_both_configs_when_final_commit_fails(
|
||||
stable_config=stable,
|
||||
legacy_config=legacy,
|
||||
)
|
||||
real_replace = _transaction.os.replace
|
||||
real_replace = _transaction._replace_physical
|
||||
failed = False
|
||||
|
||||
def fail_legacy_once(source: str, destination: str) -> None:
|
||||
def fail_legacy_once(
|
||||
source: str,
|
||||
destination: str,
|
||||
directory: _transaction.PinnedDirectory,
|
||||
) -> None:
|
||||
nonlocal failed
|
||||
if os.path.abspath(destination) == str(fixture.legacy_path) and not failed:
|
||||
failed = True
|
||||
raise OSError("injected legacy config failure")
|
||||
real_replace(source, destination)
|
||||
real_replace(source, destination, directory)
|
||||
|
||||
monkeypatch.setattr(_transaction.os, "replace", fail_legacy_once)
|
||||
monkeypatch.setattr(_transaction, "_replace_physical", fail_legacy_once)
|
||||
|
||||
with pytest.raises(OSError, match="injected legacy config failure"):
|
||||
fixture.install()
|
||||
@@ -107,18 +111,22 @@ def test_uninstall_restores_first_config_when_second_removal_fails(
|
||||
fixture.legacy_path,
|
||||
]
|
||||
before = snapshot_files(paths)
|
||||
real_replace = _transaction.os.replace
|
||||
real_capture = _transaction._capture_physical
|
||||
removal_commits = 0
|
||||
|
||||
def fail_second_removal(source: str, destination: str) -> None:
|
||||
def fail_second_removal(
|
||||
source: str,
|
||||
destination: str,
|
||||
directory: _transaction.PinnedDirectory,
|
||||
) -> None:
|
||||
nonlocal removal_commits
|
||||
if destination.endswith(".rollback"):
|
||||
removal_commits += 1
|
||||
if removal_commits == 2:
|
||||
raise OSError("injected uninstall failure")
|
||||
real_replace(source, destination)
|
||||
real_capture(source, destination, directory)
|
||||
|
||||
monkeypatch.setattr(_transaction.os, "replace", fail_second_removal)
|
||||
monkeypatch.setattr(_transaction, "_capture_physical", fail_second_removal)
|
||||
|
||||
with pytest.raises(OSError, match="injected uninstall failure"):
|
||||
fixture.plugin.uninstall(AGENT_ID)
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -3,8 +3,6 @@ from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from elydora.plugins import _transaction
|
||||
@@ -56,17 +54,21 @@ def test_transaction_rolls_back_every_runtime_and_settings_change(
|
||||
monkeypatch, tmp_path, global_settings={"owner": "user"}
|
||||
)
|
||||
original = fixture.source()
|
||||
real_replace = _transaction.os.replace
|
||||
real_replace = _transaction._replace_physical
|
||||
failed = False
|
||||
|
||||
def fail_settings_commit(source: Any, destination: Any) -> None:
|
||||
def fail_settings_commit(
|
||||
source: str,
|
||||
destination: str,
|
||||
directory: _transaction.PinnedDirectory,
|
||||
) -> None:
|
||||
nonlocal failed
|
||||
if not failed and Path(destination) == fixture.global_path:
|
||||
failed = True
|
||||
raise OSError("simulated Letta settings commit failure")
|
||||
real_replace(source, destination)
|
||||
real_replace(source, destination, directory)
|
||||
|
||||
monkeypatch.setattr(_transaction.os, "replace", fail_settings_commit)
|
||||
monkeypatch.setattr(_transaction, "_replace_physical", fail_settings_commit)
|
||||
with pytest.raises(OSError, match="Install Letta Code hooks"):
|
||||
fixture.install()
|
||||
assert failed
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
|
||||
import pytest
|
||||
|
||||
from elydora.plugins import _private_artifact
|
||||
from elydora.plugins import _pinned_directory
|
||||
from elydora.plugins._managed_files import DirectorySnapshot
|
||||
from elydora.plugins._pinned_directory import PinnedDirectory
|
||||
|
||||
|
||||
def test_darwin_private_directory_removal_uses_stdlib_dir_fd(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
calls: list[tuple[str, int | None]] = []
|
||||
|
||||
def record_rmdir(path: str, *, dir_fd: int | None = None) -> None:
|
||||
calls.append((path, dir_fd))
|
||||
|
||||
monkeypatch.setattr(_private_artifact.os, "rmdir", record_rmdir)
|
||||
monkeypatch.setattr(_private_artifact.os, "supports_dir_fd", {record_rmdir})
|
||||
|
||||
_private_artifact._remove_posix_private_directory(41, "private-container")
|
||||
|
||||
assert calls == [("private-container", 41)]
|
||||
|
||||
|
||||
def test_assert_path_stable_detects_visible_identity_change(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
directory = tmp_path / "managed"
|
||||
directory.mkdir()
|
||||
pinned = PinnedDirectory.open(str(directory), "managed directory")
|
||||
changed = DirectorySnapshot(
|
||||
pinned.snapshot.device + 1,
|
||||
pinned.snapshot.inode,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
_pinned_directory,
|
||||
"read_physical_directory",
|
||||
lambda _path, _label: changed,
|
||||
)
|
||||
|
||||
try:
|
||||
with pytest.raises(OSError, match="changed during test operation"):
|
||||
pinned.assert_path_stable("test operation")
|
||||
finally:
|
||||
pinned.close()
|
||||
|
||||
|
||||
def test_closed_pin_rejects_file_operations(tmp_path: Path) -> None:
|
||||
directory = tmp_path / "managed"
|
||||
directory.mkdir()
|
||||
pinned = PinnedDirectory.open(str(directory), "managed directory")
|
||||
pinned.close()
|
||||
|
||||
with pytest.raises(OSError, match="is closed"):
|
||||
pinned.stat_file("state.json")
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "nt", reason="Windows handle contract")
|
||||
def test_windows_pin_blocks_directory_swap_and_allows_child_replace(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
directory = tmp_path / "managed"
|
||||
directory.mkdir()
|
||||
(directory / "source").write_text("new", encoding="utf-8")
|
||||
(directory / "target").write_text("old", encoding="utf-8")
|
||||
moved = tmp_path / "moved"
|
||||
pinned = PinnedDirectory.open(str(directory), "managed directory")
|
||||
|
||||
try:
|
||||
pinned.replace_file("source", "target")
|
||||
assert (directory / "target").read_text(encoding="utf-8") == "new"
|
||||
with pytest.raises(OSError):
|
||||
directory.rename(moved)
|
||||
pinned.assert_path_stable("replace test")
|
||||
finally:
|
||||
pinned.close()
|
||||
|
||||
directory.rename(moved)
|
||||
moved.rename(directory)
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "nt", reason="Windows handle contract")
|
||||
def test_windows_pin_compares_volume_and_inode(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
directory = tmp_path / "managed"
|
||||
directory.mkdir()
|
||||
real_open = _pinned_directory._open_windows_directory
|
||||
|
||||
def open_with_changed_volume(
|
||||
path: str, label: str
|
||||
) -> tuple[int, DirectorySnapshot]:
|
||||
descriptor, snapshot = real_open(path, label)
|
||||
return descriptor, DirectorySnapshot(
|
||||
snapshot.device + 1,
|
||||
snapshot.inode,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
_pinned_directory,
|
||||
"_open_windows_directory",
|
||||
open_with_changed_volume,
|
||||
)
|
||||
|
||||
with pytest.raises(OSError, match="changed while pinning"):
|
||||
PinnedDirectory.open(str(directory), "managed directory")
|
||||
|
||||
moved = tmp_path / "moved"
|
||||
directory.rename(moved)
|
||||
moved.rename(directory)
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "nt", reason="Windows handle contract")
|
||||
def test_windows_descriptor_chmod_fallback_changes_open_file_mode(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
target = tmp_path / "secret"
|
||||
target.write_text("secret", encoding="utf-8")
|
||||
descriptor = os.open(
|
||||
target,
|
||||
os.O_RDWR | getattr(os, "O_BINARY", 0),
|
||||
)
|
||||
try:
|
||||
_pinned_directory._chmod_windows_descriptor(descriptor, stat.S_IREAD)
|
||||
assert not os.fstat(descriptor).st_mode & stat.S_IWRITE
|
||||
_pinned_directory._chmod_windows_descriptor(
|
||||
descriptor,
|
||||
stat.S_IREAD | stat.S_IWRITE,
|
||||
)
|
||||
assert os.fstat(descriptor).st_mode & stat.S_IWRITE
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "nt", reason="Windows handle contract")
|
||||
def test_windows_chmod_file_uses_the_open_descriptor(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
directory = tmp_path / "managed"
|
||||
directory.mkdir()
|
||||
target = directory / "secret"
|
||||
target.write_text("secret", encoding="utf-8")
|
||||
pinned = PinnedDirectory.open(str(directory), "managed directory")
|
||||
snapshot = pinned.read_file("secret", "secret", 1024)
|
||||
assert snapshot is not None
|
||||
restore_descriptor = os.open(
|
||||
target,
|
||||
os.O_RDWR | getattr(os, "O_BINARY", 0),
|
||||
)
|
||||
|
||||
def reject_path_chmod(*_args: object, **_kwargs: object) -> None:
|
||||
raise AssertionError("path chmod was called")
|
||||
|
||||
monkeypatch.setattr(_pinned_directory.os, "chmod", reject_path_chmod)
|
||||
try:
|
||||
pinned.chmod_file(
|
||||
"secret",
|
||||
stat.S_IREAD,
|
||||
(snapshot.device, snapshot.inode),
|
||||
)
|
||||
assert not target.stat().st_mode & stat.S_IWRITE
|
||||
_pinned_directory.chmod_open_file(
|
||||
restore_descriptor,
|
||||
stat.S_IREAD | stat.S_IWRITE,
|
||||
)
|
||||
finally:
|
||||
os.close(restore_descriptor)
|
||||
pinned.close()
|
||||
@@ -1,10 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from elydora import cli
|
||||
@@ -315,17 +312,21 @@ def test_transaction_rolls_back_every_runtime_and_settings_change(
|
||||
monkeypatch, tmp_path, existing_settings={"owner": "user"}
|
||||
)
|
||||
original = fixture.source()
|
||||
real_replace = _transaction.os.replace
|
||||
real_replace = _transaction._replace_physical
|
||||
failed = False
|
||||
|
||||
def fail_settings_commit(source: Any, destination: Any) -> None:
|
||||
def fail_settings_commit(
|
||||
source: str,
|
||||
destination: str,
|
||||
directory: _transaction.PinnedDirectory,
|
||||
) -> None:
|
||||
nonlocal failed
|
||||
if not failed and Path(destination) == fixture.config_path:
|
||||
failed = True
|
||||
raise OSError("simulated settings commit failure")
|
||||
real_replace(source, destination)
|
||||
real_replace(source, destination, directory)
|
||||
|
||||
monkeypatch.setattr(_transaction.os, "replace", fail_settings_commit)
|
||||
monkeypatch.setattr(_transaction, "_replace_physical", fail_settings_commit)
|
||||
with pytest.raises(OSError, match="Install Qwen Code hooks"):
|
||||
fixture.install()
|
||||
assert failed
|
||||
|
||||
@@ -0,0 +1,792 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from elydora import cli
|
||||
from elydora.plugins import (
|
||||
_directory_removal,
|
||||
_opaque_cleanup,
|
||||
_opaque_removal,
|
||||
_transaction,
|
||||
)
|
||||
from elydora.plugins._runtime_removal import (
|
||||
commit_runtime_removal,
|
||||
finalize_runtime_removal,
|
||||
prepare_runtime_removal,
|
||||
)
|
||||
|
||||
|
||||
AGENT_ID = "agent-1"
|
||||
AGENT_NAME = "opencode"
|
||||
PRIVATE_KEY = base64.urlsafe_b64encode(bytes([19]) * 32).rstrip(b"=").decode()
|
||||
|
||||
|
||||
def _set_home(monkeypatch: pytest.MonkeyPatch, home: Path) -> None:
|
||||
monkeypatch.setenv("HOME", str(home))
|
||||
monkeypatch.setenv("USERPROFILE", str(home))
|
||||
|
||||
|
||||
def _write_json(path: Path, value: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def _write_secret(path: Path, value: str) -> None:
|
||||
path.write_text(value, encoding="utf-8")
|
||||
if os.name != "nt":
|
||||
path.chmod(0o600)
|
||||
|
||||
|
||||
def _write_error_log(path: Path, chunks: int = 1) -> str:
|
||||
digest = hashlib.sha256()
|
||||
block = b"opaque historical error\n" * 4096
|
||||
with path.open("wb") as file:
|
||||
for _index in range(chunks):
|
||||
file.write(block)
|
||||
digest.update(block)
|
||||
if os.name != "nt":
|
||||
path.chmod(0o600)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _stream_digest(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as file:
|
||||
while chunk := file.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _runtime(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
*,
|
||||
agent_name: str = AGENT_NAME,
|
||||
) -> tuple[Path, Path]:
|
||||
home = tmp_path / "home"
|
||||
root = home / ".elydora"
|
||||
agent = root / AGENT_ID
|
||||
_set_home(monkeypatch, home)
|
||||
_write_json(
|
||||
agent / "config.json",
|
||||
{"agent_id": AGENT_ID, "agent_name": agent_name},
|
||||
)
|
||||
return root, agent
|
||||
|
||||
|
||||
def _uninstall_args(agent_name: str = AGENT_NAME) -> argparse.Namespace:
|
||||
return cli.build_parser().parse_args(
|
||||
[
|
||||
"uninstall",
|
||||
"--agent",
|
||||
agent_name,
|
||||
"--agent_id",
|
||||
AGENT_ID,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("entry_kind", ["file", "directory", "symlink"])
|
||||
def test_cli_runtime_removal_rejects_unknown_entries_before_provider_mutation(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
entry_kind: str,
|
||||
) -> None:
|
||||
_root, agent = _runtime(monkeypatch, tmp_path)
|
||||
unknown = agent / "user-owned"
|
||||
if entry_kind == "file":
|
||||
unknown.write_text("preserve", encoding="utf-8")
|
||||
elif entry_kind == "directory":
|
||||
unknown.mkdir()
|
||||
(unknown / "marker").write_text("preserve", encoding="utf-8")
|
||||
else:
|
||||
target = tmp_path / "symlink-target"
|
||||
target.write_text("preserve", encoding="utf-8")
|
||||
try:
|
||||
unknown.symlink_to(target)
|
||||
except OSError as error:
|
||||
pytest.skip(f"File symbolic links are unavailable: {error}")
|
||||
|
||||
provider_calls: list[str] = []
|
||||
|
||||
class Plugin:
|
||||
def uninstall(self, agent_id: str = "") -> None:
|
||||
provider_calls.append(agent_id)
|
||||
|
||||
monkeypatch.setattr(cli, "_get_plugin", lambda _name: Plugin())
|
||||
|
||||
with pytest.raises(OSError, match="unmanaged entries"):
|
||||
cli.cmd_uninstall(_uninstall_args())
|
||||
|
||||
assert provider_calls == []
|
||||
assert unknown.exists() or unknown.is_symlink()
|
||||
assert (agent / "config.json").is_file()
|
||||
|
||||
|
||||
def test_cli_skips_generic_cleanup_for_plugin_managed_runtime_removal(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_root, agent = _runtime(monkeypatch, tmp_path, agent_name="kiroide")
|
||||
provider_calls: list[str] = []
|
||||
|
||||
class Plugin:
|
||||
manages_runtime_removal = True
|
||||
|
||||
def uninstall(self, agent_id: str = "") -> None:
|
||||
provider_calls.append(agent_id)
|
||||
|
||||
def reject_generic_prepare(*_args: object, **_kwargs: object) -> None:
|
||||
raise AssertionError("generic runtime removal must stay unused")
|
||||
|
||||
monkeypatch.setattr(cli, "_get_plugin", lambda _name: Plugin())
|
||||
monkeypatch.setattr(cli, "prepare_runtime_removal", reject_generic_prepare)
|
||||
|
||||
cli.cmd_uninstall(_uninstall_args("kiroide"))
|
||||
|
||||
assert provider_calls == [AGENT_ID]
|
||||
assert (agent / "config.json").is_file()
|
||||
|
||||
|
||||
def test_runtime_removal_rejects_config_identity_drift(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_root, agent = _runtime(monkeypatch, tmp_path)
|
||||
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
|
||||
assert plan is not None
|
||||
concurrent = {"agent_id": AGENT_ID, "agent_name": "cursor"}
|
||||
_write_json(agent / "config.json", concurrent)
|
||||
|
||||
with pytest.raises(OSError, match="changed"):
|
||||
commit_runtime_removal(plan)
|
||||
|
||||
assert json.loads((agent / "config.json").read_text(encoding="utf-8")) == (
|
||||
concurrent
|
||||
)
|
||||
|
||||
|
||||
def test_runtime_removal_treats_other_plugin_wrappers_as_unmanaged(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_root, agent = _runtime(monkeypatch, tmp_path)
|
||||
wrapper_name = "augment-guard.cmd" if os.name == "nt" else "augment-guard.sh"
|
||||
user_file = agent / wrapper_name
|
||||
user_file.write_text("user-owned payload", encoding="utf-8")
|
||||
|
||||
with pytest.raises(OSError, match="unmanaged entries"):
|
||||
prepare_runtime_removal(AGENT_ID, AGENT_NAME)
|
||||
|
||||
assert user_file.read_text(encoding="utf-8") == "user-owned payload"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("swap", ["agent", "root"])
|
||||
def test_runtime_removal_rejects_directory_swaps_without_deleting_replacements(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
swap: str,
|
||||
) -> None:
|
||||
root, agent = _runtime(monkeypatch, tmp_path)
|
||||
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
|
||||
assert plan is not None
|
||||
|
||||
if swap == "agent":
|
||||
original = root / "original-agent"
|
||||
agent.rename(original)
|
||||
agent.mkdir()
|
||||
replacement = agent
|
||||
else:
|
||||
original = root.with_name("original-root")
|
||||
root.rename(original)
|
||||
replacement = root / AGENT_ID
|
||||
replacement.mkdir(parents=True)
|
||||
marker = replacement / "replacement-marker"
|
||||
marker.write_text("preserve", encoding="utf-8")
|
||||
|
||||
with pytest.raises(OSError, match="changed"):
|
||||
commit_runtime_removal(plan)
|
||||
|
||||
assert marker.read_text(encoding="utf-8") == "preserve"
|
||||
assert (original / "config.json").is_file() if swap == "agent" else (
|
||||
original / AGENT_ID / "config.json"
|
||||
).is_file()
|
||||
|
||||
|
||||
def test_runtime_removal_rejects_concurrent_creation_of_missing_managed_file(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_root, agent = _runtime(monkeypatch, tmp_path)
|
||||
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
|
||||
assert plan is not None
|
||||
concurrent = agent / "status-cache.json"
|
||||
concurrent.write_text('{"state":"concurrent"}\n', encoding="utf-8")
|
||||
|
||||
with pytest.raises(OSError, match="changed"):
|
||||
commit_runtime_removal(plan)
|
||||
|
||||
assert concurrent.read_text(encoding="utf-8") == (
|
||||
'{"state":"concurrent"}\n'
|
||||
)
|
||||
assert (agent / "config.json").is_file()
|
||||
|
||||
|
||||
def test_runtime_removal_preserves_unknown_entry_created_before_finalize(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_root, agent = _runtime(monkeypatch, tmp_path)
|
||||
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
|
||||
assert plan is not None
|
||||
_transaction.write_changes(
|
||||
plan.changes,
|
||||
"Remove test runtime",
|
||||
plan.file_preconditions,
|
||||
plan.directory_preconditions,
|
||||
)
|
||||
concurrent = agent / "concurrent-user-file"
|
||||
concurrent.write_text("preserve", encoding="utf-8")
|
||||
|
||||
with pytest.raises(OSError, match="contains entries after managed cleanup"):
|
||||
finalize_runtime_removal(plan)
|
||||
|
||||
assert concurrent.read_text(encoding="utf-8") == "preserve"
|
||||
assert agent.is_dir()
|
||||
|
||||
|
||||
def test_runtime_removal_deletes_all_known_dynamic_files(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
root, agent = _runtime(monkeypatch, tmp_path)
|
||||
dynamic = {
|
||||
"chain-state.json": '{"sequence":1}\n',
|
||||
"status-cache.json": '{"status":"active"}\n',
|
||||
"error.log": "recorded delivery error\n",
|
||||
}
|
||||
for name, contents in dynamic.items():
|
||||
(agent / name).write_text(contents, encoding="utf-8")
|
||||
if os.name != "nt":
|
||||
(agent / "error.log").chmod(0o600)
|
||||
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
|
||||
assert plan is not None
|
||||
|
||||
commit_runtime_removal(plan)
|
||||
|
||||
assert not agent.exists()
|
||||
assert root.is_dir()
|
||||
|
||||
|
||||
def test_runtime_removal_streams_oversized_error_log(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
root, agent = _runtime(monkeypatch, tmp_path)
|
||||
expected_digest = _write_error_log(agent / "error.log", chunks=32)
|
||||
assert (agent / "error.log").stat().st_size > 2 * 1024 * 1024
|
||||
|
||||
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
|
||||
assert plan is not None
|
||||
assert plan.opaque_removals[0].original is not None
|
||||
assert plan.opaque_removals[0].original.sha256 == expected_digest
|
||||
|
||||
commit_runtime_removal(plan)
|
||||
|
||||
assert not agent.exists()
|
||||
assert root.is_dir()
|
||||
|
||||
|
||||
def test_oversized_error_log_rolls_back_after_later_text_failure(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_root, agent = _runtime(monkeypatch, tmp_path)
|
||||
error_log = agent / "error.log"
|
||||
expected_digest = _write_error_log(error_log, chunks=32)
|
||||
expected_mode = stat.S_IMODE(error_log.stat().st_mode)
|
||||
original_commit = _transaction._commit
|
||||
|
||||
def fail_first_text_commit(_staged: object) -> None:
|
||||
raise OSError("injected text commit failure")
|
||||
|
||||
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
|
||||
assert plan is not None
|
||||
monkeypatch.setattr(_transaction, "_commit", fail_first_text_commit)
|
||||
|
||||
with pytest.raises(OSError, match="injected text commit failure"):
|
||||
commit_runtime_removal(plan)
|
||||
|
||||
monkeypatch.setattr(_transaction, "_commit", original_commit)
|
||||
assert _stream_digest(error_log) == expected_digest
|
||||
assert stat.S_IMODE(error_log.stat().st_mode) == expected_mode
|
||||
assert (agent / "config.json").is_file()
|
||||
assert list(agent.glob("*.opaque-rollback")) == []
|
||||
|
||||
|
||||
def test_process_exit_after_opaque_removal_restores_error_log_on_restart(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_root, agent = _runtime(monkeypatch, tmp_path)
|
||||
error_log = agent / "error.log"
|
||||
expected_digest = _write_error_log(error_log, chunks=4)
|
||||
script = """
|
||||
import os
|
||||
import sys
|
||||
from elydora.plugins import _transaction
|
||||
from elydora.plugins._runtime_removal import prepare_runtime_removal, commit_runtime_removal
|
||||
|
||||
agent_id, agent_name = sys.argv[1:]
|
||||
plan = prepare_runtime_removal(agent_id, agent_name)
|
||||
assert plan is not None
|
||||
_transaction._commit = lambda staged: os._exit(78)
|
||||
commit_runtime_removal(plan)
|
||||
"""
|
||||
environment = os.environ.copy()
|
||||
environment["HOME"] = os.environ["HOME"]
|
||||
environment["USERPROFILE"] = os.environ["USERPROFILE"]
|
||||
|
||||
completed = subprocess.run(
|
||||
[sys.executable, "-c", script, AGENT_ID, AGENT_NAME],
|
||||
cwd=Path(__file__).resolve().parents[1],
|
||||
env=environment,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert completed.returncode == 78
|
||||
assert not error_log.exists()
|
||||
_transaction.recover_pending_transactions()
|
||||
assert _stream_digest(error_log) == expected_digest
|
||||
assert (agent / "config.json").is_file()
|
||||
assert list(agent.glob(".*.opaque-rollback")) == []
|
||||
|
||||
|
||||
def test_error_log_append_at_capture_boundary_fails_and_preserves_append(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_root, agent = _runtime(monkeypatch, tmp_path)
|
||||
error_log = agent / "error.log"
|
||||
_write_error_log(error_log)
|
||||
appended = b"concurrent append\n"
|
||||
original_capture = _opaque_removal._capture_physical
|
||||
|
||||
def append_then_capture(staged: Any) -> None:
|
||||
with error_log.open("ab") as file:
|
||||
file.write(appended)
|
||||
file.flush()
|
||||
os.fsync(file.fileno())
|
||||
original_capture(staged)
|
||||
|
||||
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
|
||||
assert plan is not None
|
||||
monkeypatch.setattr(
|
||||
_opaque_removal,
|
||||
"_capture_physical",
|
||||
append_then_capture,
|
||||
)
|
||||
|
||||
with pytest.raises(OSError, match="changed at commit boundary"):
|
||||
commit_runtime_removal(plan)
|
||||
|
||||
assert error_log.read_bytes().endswith(appended)
|
||||
assert (agent / "config.json").is_file()
|
||||
|
||||
|
||||
def test_error_log_replace_at_capture_boundary_preserves_both_files(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_root, agent = _runtime(monkeypatch, tmp_path)
|
||||
error_log = agent / "error.log"
|
||||
original_digest = _write_error_log(error_log)
|
||||
displaced = agent / "displaced-error.log"
|
||||
replacement = b"concurrent replacement\n"
|
||||
original_capture = _opaque_removal._capture_physical
|
||||
|
||||
def replace_then_capture(staged: Any) -> None:
|
||||
error_log.rename(displaced)
|
||||
error_log.write_bytes(replacement)
|
||||
if os.name != "nt":
|
||||
error_log.chmod(0o600)
|
||||
original_capture(staged)
|
||||
|
||||
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
|
||||
assert plan is not None
|
||||
monkeypatch.setattr(
|
||||
_opaque_removal,
|
||||
"_capture_physical",
|
||||
replace_then_capture,
|
||||
)
|
||||
|
||||
with pytest.raises(OSError, match="changed at commit boundary"):
|
||||
commit_runtime_removal(plan)
|
||||
|
||||
assert error_log.read_bytes() == replacement
|
||||
assert _stream_digest(displaced) == original_digest
|
||||
assert (agent / "config.json").is_file()
|
||||
|
||||
|
||||
def test_error_log_rollback_destination_collision_preserves_victim(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_root, agent = _runtime(monkeypatch, tmp_path)
|
||||
error_log = agent / "error.log"
|
||||
original_digest = _write_error_log(error_log)
|
||||
victim = b"concurrent opaque rollback destination\n"
|
||||
rollback_path: Path | None = None
|
||||
original_capture = _opaque_removal._capture_physical
|
||||
|
||||
def collide_with_rollback_destination(staged: Any) -> None:
|
||||
nonlocal rollback_path
|
||||
assert staged.rollback_path is not None
|
||||
rollback_path = Path(staged.rollback_path)
|
||||
rollback_path.write_bytes(victim)
|
||||
if os.name != "nt":
|
||||
rollback_path.chmod(0o600)
|
||||
original_capture(staged)
|
||||
|
||||
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
|
||||
assert plan is not None
|
||||
monkeypatch.setattr(
|
||||
_opaque_removal,
|
||||
"_capture_physical",
|
||||
collide_with_rollback_destination,
|
||||
)
|
||||
|
||||
with pytest.raises(OSError, match="exist"):
|
||||
commit_runtime_removal(plan)
|
||||
|
||||
assert rollback_path is not None
|
||||
assert rollback_path.read_bytes() == victim
|
||||
assert _stream_digest(error_log) == original_digest
|
||||
assert (agent / "config.json").is_file()
|
||||
|
||||
|
||||
def test_error_log_cleanup_boundary_append_is_preserved_and_reported(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_root, agent = _runtime(monkeypatch, tmp_path)
|
||||
error_log = agent / "error.log"
|
||||
_write_error_log(error_log)
|
||||
appended = b"cleanup boundary append\n"
|
||||
original_capture = _opaque_cleanup._capture_cleanup_physical
|
||||
|
||||
def append_then_capture(
|
||||
staged: Any,
|
||||
source_path: str,
|
||||
) -> None:
|
||||
with Path(source_path).open("ab") as file:
|
||||
file.write(appended)
|
||||
file.flush()
|
||||
os.fsync(file.fileno())
|
||||
original_capture(staged, source_path)
|
||||
|
||||
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
|
||||
assert plan is not None
|
||||
monkeypatch.setattr(
|
||||
_opaque_cleanup,
|
||||
"_capture_cleanup_physical",
|
||||
append_then_capture,
|
||||
)
|
||||
|
||||
with pytest.raises(OSError, match="changed at cleanup boundary"):
|
||||
commit_runtime_removal(plan)
|
||||
|
||||
preserved = list(agent.glob(".error.log.*.opaque-rollback"))
|
||||
assert len(preserved) == 1
|
||||
assert preserved[0].read_bytes().endswith(appended)
|
||||
assert list(agent.glob(".*.elydora-quarantine")) == []
|
||||
|
||||
|
||||
def test_error_log_cleanup_replacement_preserves_both_identities(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_root, agent = _runtime(monkeypatch, tmp_path)
|
||||
error_log = agent / "error.log"
|
||||
original_digest = _write_error_log(error_log)
|
||||
replacement = b"concurrent cleanup replacement\n"
|
||||
displaced = agent / "displaced-opaque-rollback"
|
||||
rollback_path: Path | None = None
|
||||
original_capture = _opaque_cleanup._capture_cleanup_physical
|
||||
|
||||
def replace_before_cleanup_capture(staged: Any, source_path: str) -> None:
|
||||
nonlocal rollback_path
|
||||
rollback_path = Path(source_path)
|
||||
rollback_path.rename(displaced)
|
||||
rollback_path.write_bytes(replacement)
|
||||
if os.name != "nt":
|
||||
rollback_path.chmod(0o600)
|
||||
original_capture(staged, source_path)
|
||||
|
||||
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
|
||||
assert plan is not None
|
||||
monkeypatch.setattr(
|
||||
_opaque_cleanup,
|
||||
"_capture_cleanup_physical",
|
||||
replace_before_cleanup_capture,
|
||||
)
|
||||
|
||||
with pytest.raises(OSError, match="changed at cleanup boundary") as raised:
|
||||
commit_runtime_removal(plan)
|
||||
|
||||
assert rollback_path is not None
|
||||
assert f"concurrent data preserved at {rollback_path}" in str(raised.value)
|
||||
assert rollback_path.read_bytes() == replacement
|
||||
assert _stream_digest(displaced) == original_digest
|
||||
assert list(agent.glob(".*.elydora-quarantine")) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("entry_kind", ["directory", "symlink"])
|
||||
def test_error_log_rejects_nonphysical_entries_before_runtime_mutation(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
entry_kind: str,
|
||||
) -> None:
|
||||
_root, agent = _runtime(monkeypatch, tmp_path)
|
||||
error_log = agent / "error.log"
|
||||
original_config = (agent / "config.json").read_bytes()
|
||||
if entry_kind == "directory":
|
||||
error_log.mkdir()
|
||||
(error_log / "marker").write_text("preserve", encoding="utf-8")
|
||||
else:
|
||||
target = tmp_path / "external-error.log"
|
||||
target.write_text("preserve", encoding="utf-8")
|
||||
try:
|
||||
error_log.symlink_to(target)
|
||||
except OSError as error:
|
||||
pytest.skip(f"File symbolic links are unavailable: {error}")
|
||||
|
||||
with pytest.raises(OSError, match="not a physical file"):
|
||||
prepare_runtime_removal(AGENT_ID, AGENT_NAME)
|
||||
|
||||
assert (agent / "config.json").read_bytes() == original_config
|
||||
assert error_log.exists() or error_log.is_symlink()
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX owner-only contract")
|
||||
def test_error_log_requires_owner_only_permissions_during_prepare(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_root, agent = _runtime(monkeypatch, tmp_path)
|
||||
error_log = agent / "error.log"
|
||||
error_log.write_text("historical error\n", encoding="utf-8")
|
||||
error_log.chmod(0o640)
|
||||
|
||||
with pytest.raises(PermissionError, match="accessible only by its owner"):
|
||||
prepare_runtime_removal(AGENT_ID, AGENT_NAME)
|
||||
|
||||
assert error_log.read_text(encoding="utf-8") == "historical error\n"
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX parent-dirfd contract")
|
||||
def test_posix_runtime_directory_removal_cleans_exact_quarantine(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_root, agent = _runtime(monkeypatch, tmp_path)
|
||||
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
|
||||
assert plan is not None
|
||||
original_rename = _directory_removal._rename_posix_into_quarantine
|
||||
calls: list[tuple[int, str, int, str, str]] = []
|
||||
|
||||
def record_rename(
|
||||
parent_descriptor: int,
|
||||
source_name: str,
|
||||
quarantine_descriptor: int,
|
||||
source_path: str,
|
||||
destination_path: str,
|
||||
) -> None:
|
||||
calls.append(
|
||||
(
|
||||
parent_descriptor,
|
||||
source_name,
|
||||
quarantine_descriptor,
|
||||
source_path,
|
||||
destination_path,
|
||||
)
|
||||
)
|
||||
original_rename(
|
||||
parent_descriptor,
|
||||
source_name,
|
||||
quarantine_descriptor,
|
||||
source_path,
|
||||
destination_path,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
_directory_removal,
|
||||
"_rename_posix_into_quarantine",
|
||||
record_rename,
|
||||
)
|
||||
|
||||
commit_runtime_removal(plan)
|
||||
|
||||
assert len(calls) == 1
|
||||
assert isinstance(calls[0][0], int)
|
||||
assert calls[0][1] == AGENT_ID
|
||||
assert isinstance(calls[0][2], int)
|
||||
assert Path(calls[0][3]) == agent
|
||||
assert Path(calls[0][4]).name == "entry"
|
||||
quarantined = list(
|
||||
Path(plan.runtime_root).glob(
|
||||
f".{AGENT_ID}.*.elydora-removed/entry"
|
||||
)
|
||||
)
|
||||
assert quarantined == []
|
||||
assert list(Path(plan.runtime_root).glob(f".{AGENT_ID}.*.elydora-removed")) == []
|
||||
assert not agent.exists()
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX quarantine contract")
|
||||
def test_posix_runtime_quarantine_preserves_atomic_boundary_swap(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
root, agent = _runtime(monkeypatch, tmp_path)
|
||||
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
|
||||
assert plan is not None
|
||||
replacement = root / "replacement"
|
||||
replacement.mkdir()
|
||||
marker = replacement / "user-owned"
|
||||
marker.write_text("preserve", encoding="utf-8")
|
||||
original = root / "agent-original"
|
||||
real_rename = _directory_removal._rename_posix_into_quarantine
|
||||
|
||||
def swap_then_quarantine(
|
||||
parent_descriptor: int,
|
||||
source_name: str,
|
||||
quarantine_descriptor: int,
|
||||
source_path: str,
|
||||
destination_path: str,
|
||||
) -> None:
|
||||
agent.rename(original)
|
||||
replacement.rename(agent)
|
||||
real_rename(
|
||||
parent_descriptor,
|
||||
source_name,
|
||||
quarantine_descriptor,
|
||||
source_path,
|
||||
destination_path,
|
||||
)
|
||||
|
||||
real_rmdir = _directory_removal.os.rmdir
|
||||
|
||||
def reject_path_rmdir(
|
||||
path: str,
|
||||
*,
|
||||
dir_fd: int | None = None,
|
||||
) -> None:
|
||||
if dir_fd is None:
|
||||
raise AssertionError("POSIX runtime cleanup must use a parent dirfd")
|
||||
real_rmdir(path, dir_fd=dir_fd)
|
||||
|
||||
monkeypatch.setattr(
|
||||
_directory_removal,
|
||||
"_rename_posix_into_quarantine",
|
||||
swap_then_quarantine,
|
||||
)
|
||||
monkeypatch.setattr(_directory_removal.os, "rmdir", reject_path_rmdir)
|
||||
|
||||
with pytest.raises(OSError, match="changed while quarantining"):
|
||||
commit_runtime_removal(plan)
|
||||
|
||||
quarantined_markers = list(
|
||||
root.glob(f".{AGENT_ID}.*.elydora-removed/entry/user-owned")
|
||||
)
|
||||
assert len(quarantined_markers) == 1
|
||||
assert quarantined_markers[0].read_text(encoding="utf-8") == "preserve"
|
||||
assert original.is_dir()
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "nt", reason="Windows exact-handle contract")
|
||||
def test_windows_runtime_directory_removal_marks_exact_pinned_handle(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_root, agent = _runtime(monkeypatch, tmp_path)
|
||||
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
|
||||
assert plan is not None
|
||||
original_mark = _directory_removal._mark_windows_directory_for_deletion
|
||||
observed: list[tuple[int, int]] = []
|
||||
|
||||
def record_mark(directory: Any) -> None:
|
||||
assert directory.descriptor is not None
|
||||
opened = os.fstat(directory.descriptor)
|
||||
observed.append((opened.st_dev, opened.st_ino))
|
||||
assert observed[-1] == (
|
||||
plan.agent_snapshot.device,
|
||||
plan.agent_snapshot.inode,
|
||||
)
|
||||
original_mark(directory)
|
||||
|
||||
monkeypatch.setattr(
|
||||
_directory_removal,
|
||||
"_mark_windows_directory_for_deletion",
|
||||
record_mark,
|
||||
)
|
||||
|
||||
commit_runtime_removal(plan)
|
||||
|
||||
assert observed == [(plan.agent_snapshot.device, plan.agent_snapshot.inode)]
|
||||
assert not agent.exists()
|
||||
|
||||
|
||||
def test_kiroide_cli_install_failure_leaves_no_runtime_root(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
home = tmp_path / "home"
|
||||
workspace = tmp_path / "workspace"
|
||||
home.mkdir()
|
||||
workspace.mkdir()
|
||||
_set_home(monkeypatch, home)
|
||||
monkeypatch.chdir(workspace)
|
||||
key_path = tmp_path / "install-private.key"
|
||||
_write_secret(key_path, PRIVATE_KEY)
|
||||
args = cli.build_parser().parse_args(
|
||||
[
|
||||
"install",
|
||||
"--agent",
|
||||
"kiroide",
|
||||
"--org_id",
|
||||
"org-1",
|
||||
"--agent_id",
|
||||
AGENT_ID,
|
||||
"--private_key_file",
|
||||
str(key_path),
|
||||
"--kid",
|
||||
"kid-1",
|
||||
]
|
||||
)
|
||||
|
||||
def fail_commit(_staged: object) -> None:
|
||||
raise OSError("injected Kiro IDE CLI commit failure")
|
||||
|
||||
monkeypatch.setattr(_transaction, "_commit", fail_commit)
|
||||
|
||||
with pytest.raises(OSError, match="injected Kiro IDE CLI commit failure"):
|
||||
cli.cmd_install(args)
|
||||
|
||||
# The durable transaction state directory survives failures by design;
|
||||
# everything else under the runtime root must be rolled back.
|
||||
elydora_root = home / ".elydora"
|
||||
if elydora_root.exists():
|
||||
assert [entry.name for entry in elydora_root.iterdir()] == ["transactions"]
|
||||
assert not (workspace / ".kiro").exists()
|
||||
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from elydora.plugins import _transaction
|
||||
|
||||
|
||||
def _subprocess_environment(home: Path) -> dict[str, str]:
|
||||
environment = os.environ.copy()
|
||||
environment["HOME"] = str(home)
|
||||
environment["USERPROFILE"] = str(home)
|
||||
return environment
|
||||
|
||||
|
||||
def test_process_exit_after_secret_staging_recovers_owned_artifact(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
target = tmp_path / "target.txt"
|
||||
target.write_bytes(b"old\n")
|
||||
monkeypatch.setenv("HOME", str(home))
|
||||
monkeypatch.setenv("USERPROFILE", str(home))
|
||||
script = """
|
||||
import os
|
||||
import sys
|
||||
from elydora.plugins import _transaction, _transaction_staging
|
||||
|
||||
target = sys.argv[1]
|
||||
change = _transaction.source_change(target, "target", "old\\n", "secret-new\\n", 0o600)
|
||||
assert change is not None
|
||||
real_write_staged = _transaction_staging.write_staged
|
||||
|
||||
def terminate_after_secret_staging(*args, **kwargs):
|
||||
real_write_staged(*args, **kwargs)
|
||||
os._exit(78)
|
||||
|
||||
_transaction_staging.write_staged = terminate_after_secret_staging
|
||||
_transaction.write_changes([change], "initialization crash recovery")
|
||||
"""
|
||||
|
||||
completed = subprocess.run(
|
||||
[sys.executable, "-c", script, str(target)],
|
||||
cwd=Path(__file__).resolve().parents[1],
|
||||
env=_subprocess_environment(home),
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert completed.returncode == 78
|
||||
staged = list(tmp_path.glob(".target.txt.*.tmp"))
|
||||
assert len(staged) == 1
|
||||
assert staged[0].read_text(encoding="utf-8") == "secret-new\n"
|
||||
try:
|
||||
_transaction.recover_pending_transactions()
|
||||
assert target.read_text(encoding="utf-8") == "old\n"
|
||||
assert list(tmp_path.glob(".target.txt.*.tmp")) == []
|
||||
finally:
|
||||
for artifact in tmp_path.glob(".target.txt.*.tmp"):
|
||||
artifact.unlink()
|
||||
@@ -0,0 +1,137 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from elydora.plugins import _transaction
|
||||
from elydora.plugins._pinned_directory import PinnedDirectory
|
||||
from elydora.plugins._transaction_journal import active_journal_path
|
||||
|
||||
|
||||
def _change(path: Path, original: str, next_source: str) -> _transaction.FileChange:
|
||||
change = _transaction.source_change(
|
||||
str(path),
|
||||
path.name,
|
||||
original,
|
||||
next_source,
|
||||
0o600,
|
||||
)
|
||||
assert change is not None
|
||||
return change
|
||||
|
||||
|
||||
def _no_op_file_boundary(
|
||||
source: str,
|
||||
destination: str,
|
||||
directory: PinnedDirectory,
|
||||
) -> None:
|
||||
del source, destination, directory
|
||||
|
||||
|
||||
def test_durable_commit_marker_keeps_change_after_post_commit_failure(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
target = tmp_path / "managed.json"
|
||||
target.write_bytes(b"old\n")
|
||||
|
||||
def fail_after_marker() -> None:
|
||||
raise OSError("injected post-commit failure")
|
||||
|
||||
monkeypatch.setattr(_transaction, "_after_journal_commit", fail_after_marker)
|
||||
|
||||
with pytest.raises(OSError, match="committed before post-commit failure"):
|
||||
_transaction.write_changes(
|
||||
[_change(target, "old\n", "new\n")],
|
||||
"post-commit boundary",
|
||||
)
|
||||
|
||||
assert target.read_text(encoding="utf-8") == "new\n"
|
||||
assert not Path(active_journal_path()).exists()
|
||||
assert not list(tmp_path.glob(".*.rollback"))
|
||||
|
||||
|
||||
def test_failed_in_process_recovery_preserves_journal_for_restart(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
first = tmp_path / "first.json"
|
||||
second = tmp_path / "second.json"
|
||||
first.write_bytes(b"first-old\n")
|
||||
second.write_bytes(b"second-old\n")
|
||||
|
||||
def fail_second_commit(
|
||||
source: str,
|
||||
destination: str,
|
||||
directory: PinnedDirectory,
|
||||
) -> None:
|
||||
del source, directory
|
||||
if Path(destination) == second:
|
||||
raise OSError("injected second commit failure")
|
||||
|
||||
def fail_first_restore(
|
||||
source: str,
|
||||
destination: str,
|
||||
directory: PinnedDirectory,
|
||||
) -> None:
|
||||
del source, destination, directory
|
||||
raise OSError("injected restoration failure")
|
||||
|
||||
monkeypatch.setattr(_transaction, "_replace_physical", fail_second_commit)
|
||||
monkeypatch.setattr(_transaction, "_restore_physical", fail_first_restore)
|
||||
|
||||
with pytest.raises(OSError, match="recovery journal preserved"):
|
||||
_transaction.write_changes(
|
||||
[
|
||||
_change(first, "first-old\n", "first-new\n"),
|
||||
_change(second, "second-old\n", "second-new\n"),
|
||||
],
|
||||
"recoverable in-process failure",
|
||||
)
|
||||
|
||||
assert Path(active_journal_path()).is_file()
|
||||
assert first.read_text(encoding="utf-8") == "first-new\n"
|
||||
assert second.read_text(encoding="utf-8") == "second-old\n"
|
||||
|
||||
monkeypatch.setattr(_transaction, "_restore_physical", _no_op_file_boundary)
|
||||
_transaction.recover_pending_transactions()
|
||||
|
||||
assert first.read_text(encoding="utf-8") == "first-old\n"
|
||||
assert second.read_text(encoding="utf-8") == "second-old\n"
|
||||
assert not Path(active_journal_path()).exists()
|
||||
|
||||
|
||||
def test_committed_cleanup_failure_leaves_restart_record(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
target = tmp_path / "managed.json"
|
||||
target.write_bytes(b"old\n")
|
||||
real_cleanup = _transaction.cleanup_staged
|
||||
calls = 0
|
||||
|
||||
def fail_first_cleanup(staged: _transaction.StagedChange) -> None:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
raise OSError("injected committed cleanup failure")
|
||||
real_cleanup(staged)
|
||||
|
||||
monkeypatch.setattr(_transaction, "cleanup_staged", fail_first_cleanup)
|
||||
|
||||
with pytest.raises(OSError, match="cleanup pending"):
|
||||
_transaction.write_changes(
|
||||
[_change(target, "old\n", "new\n")],
|
||||
"committed cleanup failure",
|
||||
)
|
||||
|
||||
assert target.read_text(encoding="utf-8") == "new\n"
|
||||
assert Path(active_journal_path()).is_file()
|
||||
|
||||
monkeypatch.setattr(_transaction, "cleanup_staged", real_cleanup)
|
||||
_transaction.recover_pending_transactions()
|
||||
|
||||
assert target.read_text(encoding="utf-8") == "new\n"
|
||||
assert not Path(active_journal_path()).exists()
|
||||
assert not list(tmp_path.glob(".*.rollback"))
|
||||
@@ -0,0 +1,169 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from elydora.plugins import _transaction_lock
|
||||
|
||||
|
||||
if os.name == "nt":
|
||||
from elydora.plugins._windows_security import verify_private_path
|
||||
|
||||
|
||||
def _use_isolated_state(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
root: Path,
|
||||
) -> None:
|
||||
monkeypatch.setenv("HOME", str(root))
|
||||
monkeypatch.setenv("USERPROFILE", str(root))
|
||||
|
||||
|
||||
def test_transaction_state_directory_has_owner_boundary(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_use_isolated_state(monkeypatch, tmp_path)
|
||||
|
||||
state = Path(_transaction_lock.transaction_state_directory())
|
||||
|
||||
assert state.is_dir()
|
||||
if os.name == "nt":
|
||||
verify_private_path(str(state), directory=True)
|
||||
else:
|
||||
metadata = state.stat()
|
||||
assert metadata.st_uid == getattr(os, "geteuid")()
|
||||
assert stat.S_IMODE(metadata.st_mode) == 0o700
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "posix", reason="POSIX owner contract")
|
||||
def test_transaction_state_directory_rejects_foreign_owner(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_use_isolated_state(monkeypatch, tmp_path)
|
||||
state = Path(_transaction_lock.transaction_state_directory())
|
||||
owner = state.stat().st_uid
|
||||
monkeypatch.setattr(os, "geteuid", lambda: owner + 1)
|
||||
|
||||
with pytest.raises(PermissionError, match="owned by the current user"):
|
||||
_transaction_lock.transaction_state_directory()
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "posix", reason="POSIX owner contract")
|
||||
def test_posix_lock_rejects_foreign_owner(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
lock_path = tmp_path / "transaction.lock"
|
||||
lock_path.write_bytes(b"")
|
||||
owner = lock_path.stat().st_uid
|
||||
monkeypatch.setattr(
|
||||
_transaction_lock,
|
||||
"transaction_state_directory",
|
||||
lambda: str(tmp_path),
|
||||
)
|
||||
monkeypatch.setattr(os, "geteuid", lambda: owner + 1)
|
||||
|
||||
with pytest.raises(PermissionError, match="owned by the current user"):
|
||||
_transaction_lock._PosixLock().__enter__()
|
||||
|
||||
|
||||
def test_transaction_lock_rejects_reparse_path(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_use_isolated_state(monkeypatch, tmp_path)
|
||||
state = Path(_transaction_lock.transaction_state_directory())
|
||||
target = tmp_path / "target.lock"
|
||||
target.write_bytes(b"")
|
||||
lock_path = state / "transaction.lock"
|
||||
try:
|
||||
lock_path.symlink_to(target)
|
||||
except OSError as error:
|
||||
pytest.skip(f"Symbolic links unavailable: {error}")
|
||||
|
||||
with pytest.raises(OSError, match="physical file"):
|
||||
with _transaction_lock.serialized_transactions():
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "nt", reason="Windows DACL contract")
|
||||
def test_windows_lock_file_has_owner_only_protected_dacl(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_use_isolated_state(monkeypatch, tmp_path)
|
||||
state = Path(_transaction_lock.transaction_state_directory())
|
||||
|
||||
with _transaction_lock.serialized_transactions():
|
||||
lock_path = state / "transaction.lock"
|
||||
assert lock_path.is_file()
|
||||
verify_private_path(str(lock_path), directory=False)
|
||||
|
||||
|
||||
def test_transaction_lock_serializes_independent_processes(tmp_path: Path) -> None:
|
||||
state_root = tmp_path / "state-root"
|
||||
state_root.mkdir()
|
||||
entered = tmp_path / "entered"
|
||||
release = tmp_path / "release"
|
||||
second_entered = tmp_path / "second-entered"
|
||||
script = """
|
||||
import pathlib
|
||||
import sys
|
||||
import time
|
||||
from elydora.plugins._transaction_lock import serialized_transactions
|
||||
|
||||
entered, release = map(pathlib.Path, sys.argv[1:3])
|
||||
with serialized_transactions():
|
||||
entered.write_text("entered", encoding="utf-8")
|
||||
if len(sys.argv) == 4:
|
||||
deadline = time.monotonic() + 10
|
||||
while not release.exists():
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError("release gate timed out")
|
||||
time.sleep(0.01)
|
||||
"""
|
||||
environment = os.environ.copy()
|
||||
environment["HOME"] = str(state_root)
|
||||
environment["USERPROFILE"] = str(state_root)
|
||||
repository = Path(__file__).resolve().parents[1]
|
||||
first = subprocess.Popen( # nosec B603
|
||||
[sys.executable, "-c", script, str(entered), str(release), "wait"],
|
||||
cwd=repository,
|
||||
env=environment,
|
||||
)
|
||||
second: subprocess.Popen[bytes] | None = None
|
||||
try:
|
||||
deadline = time.monotonic() + 10
|
||||
while not entered.exists() and first.poll() is None:
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError("first process did not acquire the lock")
|
||||
time.sleep(0.01)
|
||||
assert first.poll() is None
|
||||
|
||||
second = subprocess.Popen( # nosec B603
|
||||
[sys.executable, "-c", script, str(second_entered), str(release)],
|
||||
cwd=repository,
|
||||
env=environment,
|
||||
)
|
||||
time.sleep(0.25)
|
||||
assert second.poll() is None
|
||||
assert not second_entered.exists()
|
||||
|
||||
release.write_text("release", encoding="utf-8")
|
||||
assert first.wait(timeout=10) == 0
|
||||
assert second.wait(timeout=10) == 0
|
||||
assert second_entered.read_text(encoding="utf-8") == "entered"
|
||||
finally:
|
||||
if first.poll() is None:
|
||||
first.kill()
|
||||
first.wait()
|
||||
if second is not None and second.poll() is None:
|
||||
second.kill()
|
||||
second.wait()
|
||||
@@ -0,0 +1,112 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import TracebackType
|
||||
from typing import BinaryIO, Optional, Type
|
||||
|
||||
import pytest
|
||||
|
||||
from elydora.plugins import _managed_files
|
||||
from elydora.plugins._pinned_directory import PinnedDirectory
|
||||
from elydora.plugins._private_artifact import QuarantinedFile
|
||||
|
||||
|
||||
class _MutatingReader:
|
||||
def __init__(self, file: BinaryIO, target: Path, replacement: bytes) -> None:
|
||||
self._file = file
|
||||
self._target = target
|
||||
self._replacement = replacement
|
||||
|
||||
def __enter__(self) -> "_MutatingReader":
|
||||
self._file.__enter__()
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: Optional[Type[BaseException]],
|
||||
exc_value: Optional[BaseException],
|
||||
traceback: Optional[TracebackType],
|
||||
) -> Optional[bool]:
|
||||
return self._file.__exit__(exc_type, exc_value, traceback)
|
||||
|
||||
def fileno(self) -> int:
|
||||
return self._file.fileno()
|
||||
|
||||
def read(self, size: int = -1) -> bytes:
|
||||
value = self._file.read(size)
|
||||
self._target.write_bytes(self._replacement)
|
||||
return value
|
||||
|
||||
|
||||
def _mutate_during_read(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
target: Path,
|
||||
replacement: bytes,
|
||||
) -> None:
|
||||
real_fdopen = os.fdopen
|
||||
|
||||
def mutating_fdopen(
|
||||
descriptor: int,
|
||||
mode: str = "r",
|
||||
*args: object,
|
||||
**kwargs: object,
|
||||
) -> _MutatingReader:
|
||||
file = real_fdopen(descriptor, mode, *args, **kwargs)
|
||||
return _MutatingReader(file, target, replacement) # type: ignore[arg-type]
|
||||
|
||||
monkeypatch.setattr(_managed_files.os, "fdopen", mutating_fdopen)
|
||||
|
||||
|
||||
def test_read_physical_file_rejects_in_place_change_during_read(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
target = tmp_path / "managed.json"
|
||||
target.write_bytes(b"original")
|
||||
_mutate_during_read(monkeypatch, target, b"replaced")
|
||||
|
||||
with pytest.raises(OSError, match="changed while reading"):
|
||||
_managed_files.read_physical_file(str(target), "managed file")
|
||||
|
||||
|
||||
def test_pinned_read_rejects_in_place_change_during_read(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
target = tmp_path / "managed.json"
|
||||
target.write_bytes(b"original")
|
||||
directory = PinnedDirectory.open(str(tmp_path), "managed directory")
|
||||
try:
|
||||
_mutate_during_read(monkeypatch, target, b"replaced")
|
||||
with pytest.raises(OSError, match="changed while reading"):
|
||||
directory.read_file(target.name, "managed file", 1024)
|
||||
finally:
|
||||
directory.close()
|
||||
|
||||
|
||||
def test_private_capture_restores_target_when_parent_boundary_changes(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
target = tmp_path / "managed.json"
|
||||
target.write_text("original", encoding="utf-8")
|
||||
directory = PinnedDirectory.open(str(tmp_path), "managed directory")
|
||||
real_assert = directory.assert_path_stable
|
||||
calls = 0
|
||||
|
||||
def fail_after_capture(operation: str = "transaction") -> None:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 2:
|
||||
raise OSError("injected parent boundary change")
|
||||
real_assert(operation)
|
||||
|
||||
monkeypatch.setattr(directory, "assert_path_stable", fail_after_capture)
|
||||
try:
|
||||
with pytest.raises(OSError, match="injected parent boundary change"):
|
||||
QuarantinedFile.capture(directory, str(target), "managed file")
|
||||
assert target.read_text(encoding="utf-8") == "original"
|
||||
assert not list(tmp_path.glob("*.elydora-quarantine"))
|
||||
finally:
|
||||
directory.close()
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user