feat(python): durable transaction subsystem and Kiro IDE 1.0 adapter
Land the crash-recoverable managed-file transaction engine (journal, staging, commit, restart recovery, opaque snapshots, private artifacts, Windows DACL hardening) and the Kiro IDE 1.0 workspace hook plugin with legacy migration. Repair five defects that kept the suite red (426 failures to zero): - Anchor the transaction state directory at ~/.elydora/transactions instead of the shared system temp directory. The recovery journal now shares fate with the runtimes it repairs, survives temp cleaners, and isolates naturally under per-test fake homes. - Skip pending-transaction recovery when no state directory exists so rejected installs never create runtime directories. - Create the runtime root's parent before hardening it, matching the Go SDK's expand behavior for fresh homes. - Stop using file timestamps as identity evidence: Windows exposes mtime/ctime with delayed cross-handle visibility. Reads now prove stability through a verification re-read; snapshots compare device, inode, size, and SHA-256. - Stop comparing synthetic Windows permission bits across stat boundaries: path stats derive execute bits from file extensions while handle stats do not, which falsely flagged every managed .cmd wrapper. Snapshots store raw modes for restoration and compare portable bits. Reservation cleanup now verifies content before discarding so a concurrent writer's file at a rollback path is preserved, and the install-failure tests assert the precise contract: no agent runtime residue, with the durable state directory allowed to remain. Gates: pytest 807 passed, mypy clean, pip check clean, wheel builds.
This commit is contained in:
1 parent
dce7d1288a
commit
e37418c489
53 files changed
+11855
-531
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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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