Files
odysseus/tests/test_background_resource_identity.py
T
Alexandre Teixeira ab89e3274a fix(runtime): exclude stale process resources during authority intersection
Catch ResourceIdentityError during intersection so that normal process exit
or background job termination does not crash child authority creation.
Stale or unverifiable observations are conservatively excluded from the
resulting authority while maintaining identity verification and preventing
PID reuse or renewal.
2026-10-02 18:54:09 +01:00

279 lines
13 KiB
Python

from dataclasses import replace
import json
import os
import time
import pytest
from src import bg_jobs, containment, process_ownership
from src.agent_runtime import process_resources as resources
from src.agent_runtime.authority import RequestAuthority, OperationGrant, ExactOperation, restore_background_authority
from src.agent_runtime.resources import NativeBackendResource, ResourceIdentityError, BackgroundJobResource, FilesystemRoot, FilesystemResource
from src.process_lifecycle import ProcessIdentity
from tests.process_resource_helpers import seed_linkage, launch_authority
@pytest.fixture
def store(tmp_path, monkeypatch):
workspace = tmp_path / "workspace"
workspace.mkdir()
private = tmp_path / "private"
monkeypatch.setattr(resources, "_LAUNCH_DIR", private / "launches")
monkeypatch.setattr(bg_jobs, "_STORE", private / "jobs.json")
monkeypatch.setattr(bg_jobs, "_JOBS_DIR", private / "jobs")
monkeypatch.setattr(containment, "_store_path", lambda: private / "receipts.json")
monkeypatch.setattr(process_ownership, "verify", lambda *a: process_ownership.OWNED)
monkeypatch.setattr(ProcessIdentity, "exited", lambda self: False)
monkeypatch.setattr(bg_jobs, "_pid_alive", lambda pid: True)
return workspace
def seed(workspace, job_id="job", status="running"):
bg_jobs._JOBS_DIR.mkdir(parents=True, exist_ok=True)
record = {"id": job_id, "session_id": "thread", "command": "printf output", "pid": 4321,
"status": status, "started_at": time.time(), "max_runtime_s": 3600,
"exit_path": str(bg_jobs._JOBS_DIR / (job_id + ".exit")),
"result_path": str(bg_jobs._JOBS_DIR / (job_id + ".result.json")),
"log_path": str(bg_jobs._JOBS_DIR / (job_id + ".log"))}
resource = seed_linkage(record, workspace, owner="alice", request_id="origin")
jobs = bg_jobs._load()
jobs[job_id] = record
bg_jobs._save(jobs)
return resource, record
@pytest.mark.parametrize("field,value", [("job_id", "sibling"), ("generation", "f" * 32), ("containment_id", "other-receipt"),
("owner", "bob"), ("request_id", "other-request"), ("thread_id", "other-thread")])
def test_job_substitution_fails_closed(store, field, value):
resource, _ = seed(store)
changed = resource.to_dict()
changed[field] = value
for process in changed["processes"]:
if field in process:
process[field] = value
expected = BackgroundJobResource.from_dict(changed)
with pytest.raises((ResourceIdentityError, OSError)):
resources.validate_job(expected)
@pytest.mark.parametrize("field,value", [("role", "leader"), ("namespace", "external:ssh"), ("identity", {"pid": 4321, "start_token": "replacement", "pgid": 4321})])
def test_role_producer_and_process_replacement_fail(store, field, value):
resource, _ = seed(store)
changed = resource.to_dict()
changed["processes"][0][field] = value
with pytest.raises((ValueError, OSError)):
resources.validate_job(BackgroundJobResource.from_dict(changed))
def test_completed_history_does_not_target_reused_process(store, monkeypatch):
resource, rec = seed(store, status="done")
with open(rec["log_path"], "w") as log:
log.write("historical output")
monkeypatch.setattr(process_ownership, "verify", lambda *a: process_ownership.FOREIGN)
monkeypatch.setattr(bg_jobs, "_kill", lambda *a, **k: pytest.fail("historical process targeted"))
assert bg_jobs.get("job", expected=resource)["output"] == "historical output"
assert bg_jobs.kill("job", expected=resource)["status"] == "done"
def test_same_id_new_generation_does_not_inherit_authority(store):
old, _ = seed(store)
seed(store) # Same store key, new trusted launch generation.
with pytest.raises(ResourceIdentityError):
bg_jobs.kill("job", expected=old)
with pytest.raises(ResourceIdentityError):
bg_jobs.get("job", expected=old)
def test_receipt_substitution_is_revalidated_before_mutation(store, monkeypatch):
resource, _ = seed(store)
receipts = containment._load_records()
receipts[resource.containment_id]["launch_generation"] = "replacement"
from core.atomic_io import atomic_write_json
atomic_write_json(containment._store_path(), receipts)
monkeypatch.setattr(bg_jobs, "_kill_record", lambda *a: pytest.fail("replaced receipt used"))
with pytest.raises(ResourceIdentityError):
bg_jobs.kill("job", expected=resource)
def test_result_publication_cannot_overwrite_authoritative_fields(store):
resource, rec = seed(store)
report = {"resource_identity": resource.to_dict(), "containment": {"id": resource.containment_id},
"owner": "bob", "pid": 9999, "start_token": "replacement", "id": "other",
"launch_resource": {}, "session_id": "other", "containment_id": "fake"}
from pathlib import Path
Path(rec["result_path"]).write_text(json.dumps(report))
Path(rec["exit_path"]).write_text("0")
final = bg_jobs.refresh("job")["job"]
assert resources.job_from_record(final) == resource
assert final["pid"] == rec["pid"] and final["session_id"] == "thread"
def test_resolution_and_lookup_do_not_reap_unrelated_jobs(store, monkeypatch):
resource, _ = seed(store, status="done")
sibling, rec = seed(store, "sibling")
jobs = bg_jobs._load()
jobs["sibling"]["started_at"] = 0
bg_jobs._save(jobs)
monkeypatch.setattr(bg_jobs, "_kill_record", lambda *a: pytest.fail("unrelated job reaped"))
authority = RequestAuthority("lookup", "alice", "thread", "", (OperationGrant("manage_bg_jobs"),))
bound = resources.resolve_process_operation(authority, ExactOperation.normalize("manage_bg_jobs", '{"action":"output","job_id":"job"}'), NativeBackendResource("manage_bg_jobs"))
assert bound.jobs == (resource,)
bg_jobs.get("job", expected=resource)
assert bg_jobs.peek("sibling")["status"] == "running"
def test_child_cannot_target_sibling_or_replaced_job(store):
first, _ = seed(store, "first")
second, _ = seed(store, "second")
parent = RequestAuthority("parent", "alice", "thread", "", (OperationGrant("manage_bg_jobs"),), job_resources=(first,))
child = replace(parent, job_resources=(second,))
inherited = parent.intersect(child)
assert inherited.job_resources == ()
with pytest.raises(ResourceIdentityError):
resources.resolve_process_operation(inherited, ExactOperation.normalize("manage_bg_jobs", '{"action":"kill","job_id":"second"}'), NativeBackendResource("manage_bg_jobs"))
seed(store, "first")
assert parent.intersect(child).job_resources == ()
@pytest.mark.parametrize("field,value", [("generation", "f" * 32), ("owner", "bob"), ("request_id", "other"), ("thread_id", "other")])
def test_continuation_sidecar_mismatch_fails_closed(store, field, value):
resource, _ = seed(store, status="done")
sidecar = bg_jobs._JOBS_DIR / "job.authority.json"
data = json.loads(sidecar.read_text())
data["job"][field] = value
sidecar.write_text(json.dumps(data))
assert restore_background_authority("job", owner="alice", session_id="thread").grants == ()
def test_matching_continuation_preserves_original_authority(store):
seed(store, status="done")
authority = restore_background_authority("job", owner="alice", session_id="thread")
assert authority.request_id == "origin" and authority.inherited
assert authority.permits(ExactOperation.normalize("bash", "printf output"))
assert restore_background_authority("job", owner="bob", session_id="thread").grants == ()
@pytest.mark.parametrize("alias", ["direct", "symlink", "hardlink"])
@pytest.mark.parametrize("state", ["launch", "job_store", "sidecar", "receipt"])
def test_launch_and_job_control_files_are_protected(store, tmp_path, alias, state):
resource, _ = seed(store)
control = {"launch": resources.launch_path(resource.generation), "job_store": bg_jobs._STORE,
"sidecar": bg_jobs._JOBS_DIR / "job.authority.json", "receipt": containment._store_path()}[state]
target = control
if alias == "symlink":
target = store / "alias"
target.symlink_to(control)
elif alias == "hardlink":
target = store / "alias"
try:
os.link(control, target)
except OSError as e:
pytest.skip(f"hardlinks unavailable: {e}")
root = FilesystemRoot.seal(tmp_path)
with pytest.raises(ValueError):
FilesystemResource.resolve(root, str(target))
with pytest.raises(ResourceIdentityError):
resources.guard_launch_workspace(root)
if alias != "direct":
with pytest.raises(ResourceIdentityError):
resources.guard_launch_workspace(FilesystemRoot.seal(store))
def test_external_jobs_cannot_become_local_or_attest_containment(store):
resource, _ = seed(store)
external = resource.to_dict()
external["namespace"] = "external:ssh"
with pytest.raises(ValueError):
BackgroundJobResource.from_dict(external)
external = resource.to_dict()
external["contained"] = True
with pytest.raises(ValueError):
BackgroundJobResource.from_dict(external)
@pytest.mark.parametrize("field,value", [("external", True), ("mechanism", "external_bridge"),
("supervisor_token", "reused"), ("supervisor_pid", 9876), ("owner", "bg:other")])
def test_receipt_cannot_replace_producer_or_claim_external_containment(store, field, value):
resource, _ = seed(store, status="done")
receipts = containment._load_records()
receipts[resource.containment_id][field] = value
from core.atomic_io import atomic_write_json
atomic_write_json(containment._store_path(), receipts)
with pytest.raises(ResourceIdentityError):
bg_jobs.get("job", expected=resource)
with pytest.raises(ResourceIdentityError):
bg_jobs.mark_followed_up("job", expected=resource)
def test_target_lookup_does_not_wait_on_unrelated_live_handle(store, monkeypatch):
resource, _ = seed(store, status="done")
class OtherProcess:
def poll(self):
pytest.fail("Unrelated producer was reaped during lookup")
monkeypatch.setattr(bg_jobs, "_LIVE_PROCS", {9876: OtherProcess()})
bg_jobs.get("job", expected=resource)
@pytest.mark.parametrize("status", ["done", "running"])
@pytest.mark.parametrize("verdict", [process_ownership.FOREIGN, process_ownership.UNVERIFIABLE])
def test_historical_lookup_does_not_reap_reused_pid_handle(store, monkeypatch, status, verdict):
resource, rec = seed(store, status=status)
from pathlib import Path
Path(rec["exit_path"]).write_text("0")
Path(rec["result_path"]).write_text(json.dumps({
"resource_identity": resource.to_dict(),
"containment": {"id": resource.containment_id},
}))
monkeypatch.setattr(process_ownership, "verify", lambda *a: verdict)
class ReplacementProcess:
def poll(self):
pytest.fail("Historical lookup reaped the replacement incarnation")
replacement = ReplacementProcess()
monkeypatch.setattr(bg_jobs, "_LIVE_PROCS", {rec["pid"]: replacement})
assert bg_jobs.get("job", expected=resource)["status"] == "done"
assert bg_jobs._LIVE_PROCS[rec["pid"]] is replacement
def test_service_refresh_still_reaps_finished_handles(store, monkeypatch):
class FinishedProcess:
def poll(self):
return 0
monkeypatch.setattr(bg_jobs, "_LIVE_PROCS", {4321: FinishedProcess()})
bg_jobs.refresh()
assert bg_jobs._LIVE_PROCS == {}
def test_completed_result_outlives_lifecycle_receipt_without_signalling(store, monkeypatch):
resource, rec = seed(store, status="done")
from pathlib import Path
Path(rec["log_path"]).write_text("retained historical output")
from core.atomic_io import atomic_write_json
atomic_write_json(containment._store_path(), {})
monkeypatch.setattr(bg_jobs, "_kill_record", lambda *a: pytest.fail("Historical resource was signalled"))
assert bg_jobs.get("job", expected=resource)["output"] == "retained historical output"
assert bg_jobs.kill("job", expected=resource)["status"] == "done"
bg_jobs.mark_followed_up("job", expected=resource)
jobs = bg_jobs._load()
jobs["job"]["status"] = "running"
bg_jobs._save(jobs)
with pytest.raises(ResourceIdentityError):
bg_jobs.kill("job", expected=resource)
@pytest.mark.parametrize("state", ["unknown_status", "malformed_sidecar", "missing_publication"])
def test_unresolved_or_malformed_authoritative_state_fails_closed(store, state):
resource, _ = seed(store, status="done")
if state == "unknown_status":
jobs = bg_jobs._load()
jobs["job"]["status"] = "unknown"
bg_jobs._save(jobs)
elif state == "malformed_sidecar":
(bg_jobs._JOBS_DIR / "job.authority.json").write_text("[]")
else:
resources.launch_path(resource.generation).unlink()
with pytest.raises(ResourceIdentityError):
bg_jobs.get("job", expected=resource)