mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-06 23:12:22 +02:00
feat(runtime): bind process and job resources to authority
This commit is contained in:
@@ -10,7 +10,7 @@ from src import containment
|
||||
def capture_owned_spawn(monkeypatch, tmp_path):
|
||||
captured = {}
|
||||
monkeypatch.setattr(containment, "CONTAINMENT_MODE", containment.MODE_REPORT_ONLY)
|
||||
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "grants.json")
|
||||
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path.parent / (tmp_path.name + "-control") / "grants.json")
|
||||
monkeypatch.setattr(containment, "_pgid_of", lambda pid: pid)
|
||||
|
||||
async def fake_exec(*argv, **kwargs):
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Explicit trusted producer fixtures; no production authority fallback."""
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import replace
|
||||
import json
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from src.agent_runtime.authority import ExactOperation, OperationGrant, RequestAuthority, bind_request_authority
|
||||
from src.agent_runtime.resources import BackgroundJobResource, NativeBackendResource, ProcessResource
|
||||
from src.agent_runtime.process_resources import bind_process_operation, resolve_process_operation, publish_launch
|
||||
from src.process_lifecycle import ProcessIdentity
|
||||
|
||||
|
||||
@contextmanager
|
||||
def launch_authority(content, workspace, *, tool="bash", owner="", session_id="chat", authority=None):
|
||||
authority = authority or RequestAuthority("producer-test", owner, session_id, str(workspace), (OperationGrant(tool),))
|
||||
bound = resolve_process_operation(authority, ExactOperation.normalize(tool, content), NativeBackendResource(tool))
|
||||
with bind_request_authority(authority), bind_process_operation(bound):
|
||||
yield authority, bound
|
||||
|
||||
|
||||
def launch(command, session_id="chat", *, cwd, **kwargs):
|
||||
from src import bg_jobs
|
||||
with launch_authority(command, cwd, session_id=session_id):
|
||||
return bg_jobs.launch(command, session_id, cwd=cwd, **kwargs)
|
||||
|
||||
|
||||
def identity(job_id):
|
||||
from src import bg_jobs
|
||||
from src.agent_runtime.process_resources import job_from_record
|
||||
return job_from_record(bg_jobs.peek(job_id))
|
||||
|
||||
|
||||
def get(job_id):
|
||||
from src import bg_jobs
|
||||
return bg_jobs.get(job_id, expected=identity(job_id))
|
||||
|
||||
|
||||
def kill(job_id):
|
||||
from src import bg_jobs
|
||||
return bg_jobs.kill(job_id, expected=identity(job_id))
|
||||
|
||||
|
||||
def seed_linkage(record, workspace, *, owner="", request_id="producer-test"):
|
||||
"""A fake server spawn record, with an explicit fake lifecycle observation."""
|
||||
from src import bg_jobs, containment
|
||||
from src.agent_runtime.authority import save_background_authority
|
||||
from src.agent_runtime.process_resources import resolve_process_operation
|
||||
authority = RequestAuthority(request_id, owner, record["session_id"], str(workspace), (OperationGrant("bash"),))
|
||||
bound = resolve_process_operation(authority, ExactOperation.normalize("bash", record["command"]), NativeBackendResource("bash"))
|
||||
receipt = uuid4().hex
|
||||
record.update(containment_id=receipt, start_token="test-boot:start", pgid=record["pid"])
|
||||
process = ProcessResource("native:bg_jobs", owner, request_id, record["session_id"],
|
||||
ProcessIdentity(record["pid"], record["start_token"], record["pgid"]), "supervisor", record["id"], receipt)
|
||||
resource = BackgroundJobResource("native:bg_jobs", record["id"], bound.launch.generation,
|
||||
owner, request_id, record["session_id"], receipt, (process,))
|
||||
record.update(resource_identity=resource.to_dict(), launch_resource=bound.launch.to_dict())
|
||||
from core.atomic_io import atomic_write_json
|
||||
receipts = containment._load_records()
|
||||
receipts[receipt] = {"id": receipt, "launch_generation": resource.generation,
|
||||
"owner": "bg:" + resource.thread_id, "supervisor_pid": process.identity.pid,
|
||||
"supervisor_token": process.identity.start_token, "mechanism": "process_group"}
|
||||
atomic_write_json(containment._store_path(), receipts)
|
||||
publish_launch(bound.launch, authority, receipt, job=resource, processes=(process,))
|
||||
save_background_authority(record["id"], authority, resource=resource)
|
||||
return resource
|
||||
|
||||
|
||||
def authorized_handler(handler, workspace):
|
||||
async def execute(content, ctx):
|
||||
from src.agent_runtime.process_resources import active_process_operation
|
||||
from src.agent_runtime.authority import active_request_authority
|
||||
if active_process_operation() is not None or active_request_authority() is not None:
|
||||
return await handler(content, ctx)
|
||||
tool = "python" if handler.__qualname__.startswith("PythonTool") else "bash"
|
||||
from src.agent_runtime.resources import FilesystemRoot
|
||||
from src.agent_runtime.process_resources import seal_launch_scope
|
||||
owner = str(ctx.get("owner") or "").casefold()
|
||||
authority = RequestAuthority("producer-test", owner, str(ctx.get("session_id") or ""), str(workspace), (OperationGrant(tool),))
|
||||
authority = replace(authority, launch_scopes=(seal_launch_scope(NativeBackendResource(tool),
|
||||
FilesystemRoot.seal(workspace, owner=owner), env=ctx.get("subproc_env")),))
|
||||
with launch_authority(content, workspace, tool=tool, authority=authority):
|
||||
return await handler(content, ctx)
|
||||
return execute
|
||||
|
||||
|
||||
def install_native_authority(monkeypatch, workspace):
|
||||
from src.agent_tools import subprocess_tools
|
||||
from src import tool_execution
|
||||
from src.constants import DATA_DIR
|
||||
for cls in (subprocess_tools.BashTool, subprocess_tools.PythonTool):
|
||||
original = cls.execute
|
||||
async def execute(self, content, ctx, _original=original):
|
||||
selected = Path(tool_execution.agent_cwd())
|
||||
if selected == Path(DATA_DIR):
|
||||
selected = Path(workspace)
|
||||
return await authorized_handler(_original.__get__(self), selected)(content, ctx)
|
||||
monkeypatch.setattr(cls, "execute", execute)
|
||||
@@ -15,6 +15,11 @@ def server_authorized_executor(executor):
|
||||
from src.tool_policy import known_tool_names
|
||||
from src.turn_contract import canonical_tool
|
||||
from src.agent_runtime.remote_resources import seal_backends
|
||||
from src.agent_runtime.resources import FilesystemRoot, NativeBackendResource, ProcessLaunchScope
|
||||
from src.containment import DEFAULT_REQUIRED
|
||||
from src.agent_runtime.process_resources import seal_launch_scope
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
call_signature = signature(executor)
|
||||
@wraps(executor)
|
||||
async def execute(*args, **kwargs):
|
||||
@@ -22,10 +27,19 @@ def server_authorized_executor(executor):
|
||||
parameters = bound.arguments
|
||||
grants = tuple(OperationGrant(name) for name in sorted(
|
||||
{canonical_tool(n) for n in known_tool_names()} | {"list_dir", "find_files"}))
|
||||
original = parameters.get("exact_approval")
|
||||
authority = original.pending.request_authority if original is not None else None
|
||||
if authority is not None:
|
||||
kwargs.setdefault("request_authority", authority)
|
||||
scratch = Path(tempfile.mkdtemp(prefix="odysseus-dispatch-fixture-"))
|
||||
launch_scopes = (None if parameters.get("workspace") else tuple(
|
||||
seal_launch_scope(NativeBackendResource(tool), FilesystemRoot.seal(scratch))
|
||||
for tool in ("bash", "python")))
|
||||
kwargs.setdefault("request_authority", RequestAuthority(
|
||||
"standalone-test-request", str(parameters.get("owner") or "").strip().casefold(),
|
||||
str(parameters.get("session_id") or ""), str(parameters.get("workspace") or ""),
|
||||
grants,
|
||||
launch_scopes=launch_scopes,
|
||||
backend_resources=seal_backends((g.tool for g in grants), context=parameters.get("client_runtime_context"),
|
||||
owner=str(parameters.get("owner") or "").strip().casefold()),
|
||||
))
|
||||
|
||||
@@ -18,7 +18,8 @@ async def test_a_chat_session_always_uses_the_owned_runner(monkeypatch, tmp_path
|
||||
async def forbidden(*args, **kwargs):
|
||||
pytest.fail("native Bash resurrected a persistent tmux shell")
|
||||
monkeypatch.setattr(subprocess_tools.asyncio, "create_subprocess_shell", forbidden)
|
||||
result = await subprocess_tools.BashTool().execute("printf ok", {"session_id": "same-chat"})
|
||||
from tests.process_resource_helpers import authorized_handler
|
||||
result = await authorized_handler(subprocess_tools.BashTool().execute, tmp_path)("printf ok", {"session_id": "same-chat"})
|
||||
assert result["output"] == "ok"
|
||||
assert result["teardown"]["dead"] is True
|
||||
assert "tmux_session" not in result
|
||||
|
||||
@@ -9,10 +9,15 @@ import pytest
|
||||
from src import bg_jobs, containment, process_ownership, process_reaper, tool_execution
|
||||
from src.tool_execution import NO_TOOL_SECURITY_CONTEXT
|
||||
from tests.runtime_evidence_helpers import server_authorized_executor
|
||||
from tests.process_resource_helpers import launch, get, kill
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def jobs(tmp_path, monkeypatch):
|
||||
from src.agent_runtime import process_resources
|
||||
monkeypatch.setattr(process_resources, "_LAUNCH_DIR", tmp_path / "private" / "launches")
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
monkeypatch.setattr(bg_jobs, "_JOBS_DIR", tmp_path / "jobs")
|
||||
monkeypatch.setattr(bg_jobs, "_STORE", tmp_path / "jobs.json")
|
||||
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "grants.json")
|
||||
@@ -20,11 +25,11 @@ def jobs(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(containment, "MECHANISMS", tuple(m for m in containment.MECHANISMS if m.name == "process_group"))
|
||||
monkeypatch.setattr(tool_execution, "_owner_is_admin", lambda owner: True)
|
||||
launched = []
|
||||
yield tmp_path, launched
|
||||
yield workspace, launched
|
||||
for record in launched:
|
||||
current = bg_jobs.get(record["id"])
|
||||
current = get(record["id"])
|
||||
if current and current["status"] == "running":
|
||||
bg_jobs.kill(record["id"])
|
||||
kill(record["id"])
|
||||
proc = bg_jobs._LIVE_PROCS.pop(record["pid"], None)
|
||||
if proc:
|
||||
proc.wait(timeout=8)
|
||||
@@ -33,7 +38,7 @@ def jobs(tmp_path, monkeypatch):
|
||||
def finished(job_id):
|
||||
deadline = time.monotonic() + 10
|
||||
while time.monotonic() < deadline:
|
||||
record = bg_jobs.get(job_id)
|
||||
record = get(job_id)
|
||||
if record["status"] != "running":
|
||||
return record
|
||||
time.sleep(0.03)
|
||||
@@ -42,7 +47,7 @@ def finished(job_id):
|
||||
|
||||
def test_detached_execution_owns_boundary_and_reports_death(jobs):
|
||||
path, launched = jobs
|
||||
record = bg_jobs.launch("printf captured", "chat", cwd=str(path))
|
||||
record = launch("printf captured", "chat", cwd=str(path))
|
||||
launched.append(record)
|
||||
result = finished(record["id"])
|
||||
assert result["output"] == "captured"
|
||||
@@ -73,7 +78,7 @@ def test_supervisor_setup_failure_closes_unstarted_grant(jobs):
|
||||
result = subprocess.run([sys.executable, str(worker)], input=json.dumps(payload),
|
||||
capture_output=True, text=True, timeout=10)
|
||||
assert result.returncode == 0 # Supervisor publishes the failed job result.
|
||||
assert "FileNotFoundError" in result.stderr
|
||||
assert "KeyError" in result.stderr # Legacy unlinked payload fails before execution.
|
||||
assert not (path / "must-not-exist").exists()
|
||||
assert containment.active_grants() == []
|
||||
assert (path / "exit").read_text() == "1"
|
||||
@@ -102,7 +107,7 @@ async def test_bg_marker_refuses_without_spawning_and_authority_still_gates(jobs
|
||||
|
||||
def test_detached_supervisor_enforces_timeout(jobs):
|
||||
path, launched = jobs
|
||||
record = bg_jobs.launch("sleep 60", "chat", cwd=str(path), max_runtime_s=1)
|
||||
record = launch("sleep 60", "chat", cwd=str(path), max_runtime_s=1)
|
||||
launched.append(record)
|
||||
result = finished(record["id"])
|
||||
assert result["timed_out"] is True
|
||||
@@ -111,11 +116,11 @@ def test_detached_supervisor_enforces_timeout(jobs):
|
||||
|
||||
def test_restart_keeps_verified_background_supervisor(jobs):
|
||||
path, launched = jobs
|
||||
record = bg_jobs.launch("sleep 60", "chat", cwd=str(path))
|
||||
record = launch("sleep 60", "chat", cwd=str(path))
|
||||
launched.append(record)
|
||||
report = process_reaper.reap_containment_grants()
|
||||
assert report["background_kept"] == 1
|
||||
killed = bg_jobs.kill(record["id"])
|
||||
killed = kill(record["id"])
|
||||
assert killed["killed"] is True
|
||||
assert killed["teardown"]["dead"] is True
|
||||
|
||||
@@ -126,16 +131,14 @@ def test_kill_never_marks_a_foreign_pid_killed(jobs, monkeypatch):
|
||||
bg_jobs._save({"stale": record})
|
||||
monkeypatch.setattr(process_ownership, "verify", lambda *args: process_ownership.FOREIGN)
|
||||
monkeypatch.setattr(bg_jobs, "_kill", lambda *args, **kwargs: pytest.fail("foreign process signalled"))
|
||||
result = bg_jobs.kill("stale")
|
||||
assert result["status"] == "running"
|
||||
assert result.get("killed") is not True
|
||||
assert result["teardown"]["dead"] is False
|
||||
result = bg_jobs._kill_record(record) # Service cleanup still refuses foreign identity.
|
||||
assert result.dead is False
|
||||
|
||||
|
||||
def test_running_detached_output_and_concurrent_grants_are_preserved(jobs):
|
||||
path, launched = jobs
|
||||
for number in range(3):
|
||||
launched.append(bg_jobs.launch(f"printf job-{number}; sleep 0.3", "chat", cwd=str(path)))
|
||||
launched.append(launch(f"printf job-{number}; sleep 0.3", "chat", cwd=str(path)))
|
||||
for number, record in enumerate(launched):
|
||||
assert finished(record["id"])["output"] == f"job-{number}"
|
||||
grants = containment._load_records()
|
||||
@@ -145,11 +148,11 @@ def test_running_detached_output_and_concurrent_grants_are_preserved(jobs):
|
||||
|
||||
def test_detached_output_is_available_while_running(jobs):
|
||||
path, launched = jobs
|
||||
record = bg_jobs.launch("printf progress; sleep 5", "chat", cwd=str(path))
|
||||
record = launch("printf progress; sleep 5", "chat", cwd=str(path))
|
||||
launched.append(record)
|
||||
deadline = time.monotonic() + 3
|
||||
while time.monotonic() < deadline:
|
||||
current = bg_jobs.get(record["id"])
|
||||
current = get(record["id"])
|
||||
if "progress" in current["output"]:
|
||||
assert current["status"] == "running"
|
||||
return
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
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")
|
||||
with pytest.raises(ResourceIdentityError):
|
||||
parent.intersect(child)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
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)
|
||||
@@ -13,10 +13,18 @@ import pytest
|
||||
|
||||
from src import bg_jobs, containment, process_ownership
|
||||
from src.agent_tools.bg_job_tools import ManageBgJobsTool
|
||||
from tests.process_resource_helpers import seed_linkage, get, kill
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path, monkeypatch):
|
||||
from src.agent_runtime import process_resources
|
||||
monkeypatch.setattr(process_resources, "_LAUNCH_DIR", tmp_path / "private" / "launches")
|
||||
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "private" / "receipts.json")
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
monkeypatch.setattr(bg_jobs, "_test_workspace", workspace, raising=False)
|
||||
monkeypatch.setattr(containment, "reap_record", lambda *a: containment.ReleaseOutcome(dead=True, escalated=False))
|
||||
jobs_dir = tmp_path / "bg_jobs"
|
||||
jobs_dir.mkdir()
|
||||
monkeypatch.setattr(bg_jobs, "_STORE", tmp_path / "bg_jobs.json")
|
||||
@@ -43,6 +51,7 @@ def _seed(session_id="sess-a", status="running", job_id="job0001", output="", pi
|
||||
}
|
||||
if output:
|
||||
(bg_jobs._JOBS_DIR / f"{job_id}.log").write_text(output, encoding="utf-8")
|
||||
seed_linkage(rec, bg_jobs._test_workspace)
|
||||
jobs = bg_jobs._load()
|
||||
jobs[job_id] = rec
|
||||
bg_jobs._save(jobs)
|
||||
@@ -50,14 +59,24 @@ def _seed(session_id="sess-a", status="running", job_id="job0001", output="", pi
|
||||
|
||||
|
||||
def _run(args, session_id="sess-a"):
|
||||
return asyncio.run(ManageBgJobsTool().execute(json.dumps(args), {"session_id": session_id, "owner": None}))
|
||||
from src.agent_runtime.authority import RequestAuthority, OperationGrant, ExactOperation, bind_request_authority
|
||||
from src.agent_runtime.resources import NativeBackendResource
|
||||
from src.agent_runtime.process_resources import resolve_process_operation, bind_process_operation
|
||||
content = json.dumps(args)
|
||||
authority = RequestAuthority("job-client-test", "", session_id, "", (OperationGrant("manage_bg_jobs"),))
|
||||
try:
|
||||
bound = resolve_process_operation(authority, ExactOperation.normalize("manage_bg_jobs", content), NativeBackendResource("manage_bg_jobs"))
|
||||
with bind_request_authority(authority), bind_process_operation(bound):
|
||||
return asyncio.run(ManageBgJobsTool().execute(content, {"session_id": session_id, "owner": None}))
|
||||
except (ValueError, OSError) as e:
|
||||
return {"error": str(e), "exit_code": 1}
|
||||
|
||||
|
||||
# ── bg_jobs.kill ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_kill_marks_killed_and_suppresses_followup(store):
|
||||
_seed(job_id="job0001", pid=4321)
|
||||
rec = bg_jobs.kill("job0001")
|
||||
rec = kill("job0001")
|
||||
assert rec["status"] == "failed"
|
||||
assert rec["killed"] is True
|
||||
assert rec["exit_code"] == -1
|
||||
@@ -67,20 +86,20 @@ def test_kill_marks_killed_and_suppresses_followup(store):
|
||||
|
||||
|
||||
def test_kill_unknown_job_returns_none(store):
|
||||
assert bg_jobs.kill("nope") is None
|
||||
assert bg_jobs.kill("nope", expected=None) is None
|
||||
|
||||
|
||||
def test_kill_finished_job_is_noop(store):
|
||||
_seed(job_id="done01", status="done")
|
||||
rec = bg_jobs.kill("done01")
|
||||
rec = kill("done01")
|
||||
assert rec["status"] == "done"
|
||||
assert store["killed"] == [] # no signal sent to an already-finished job
|
||||
|
||||
|
||||
def test_result_text_reports_killed(store):
|
||||
rec = _seed(job_id="job0001")
|
||||
bg_jobs.kill("job0001")
|
||||
assert "killed" in bg_jobs.result_text(bg_jobs.get("job0001")).lower()
|
||||
kill("job0001")
|
||||
assert "killed" in bg_jobs.result_text(get("job0001")).lower()
|
||||
|
||||
|
||||
# ── manage_bg_jobs tool ─────────────────────────────────────────────────────
|
||||
@@ -118,7 +137,7 @@ def test_kill_via_tool(store):
|
||||
out = _run({"action": "kill", "job_id": "job0001"})
|
||||
assert "Killed" in out["output"]
|
||||
assert store["killed"] == [999]
|
||||
assert bg_jobs.get("job0001")["killed"] is True
|
||||
assert get("job0001")["killed"] is True
|
||||
|
||||
|
||||
def test_kill_cross_session_denied(store):
|
||||
|
||||
@@ -17,6 +17,10 @@ def workspace(tmp_path, monkeypatch):
|
||||
path.mkdir()
|
||||
monkeypatch.setattr(tool_execution, "agent_cwd", lambda: str(path))
|
||||
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "grants.json")
|
||||
from tests.process_resource_helpers import install_native_authority
|
||||
from src.agent_runtime import process_resources
|
||||
monkeypatch.setattr(process_resources, "_LAUNCH_DIR", tmp_path / "private" / "launches")
|
||||
install_native_authority(monkeypatch, path)
|
||||
return path
|
||||
|
||||
|
||||
|
||||
@@ -1,22 +1,9 @@
|
||||
"""Stopping a Cookbook server, on a host with procfs and on one without.
|
||||
"""Cookbook selectors and OS observations never mint application authority.
|
||||
|
||||
The tmux kill is what actually stops the server; the pid sweep that follows it
|
||||
only catches model servers that survive the session's SIGHUP. Two invariants
|
||||
live here.
|
||||
|
||||
**The stop must not fail because the host cannot be inspected.** Letting a
|
||||
procfs scan raise on macOS turned a successful stop into a reported failure and
|
||||
skipped the state write that marks the session stopped for the Cookbook UI
|
||||
(ODY-94). Skipping the sweep silently fixed the crash and left the other half:
|
||||
the stop then claimed success without having looked at all. So the sweep now
|
||||
runs through ``ps`` where there is no procfs, and says so when it cannot look.
|
||||
|
||||
**The sweep signals only processes the session owns.** It used to kill anything
|
||||
whose full command line matched the tracked one. The Cookbook composed that
|
||||
command line, so an identical one is just as likely to be a server the user
|
||||
started by hand — killing it is indistinguishable from killing ours, which is
|
||||
the "stop only what we started" failure. Ownership now comes from the tmux
|
||||
pane's process tree, captured before the kill; a lookalike is reported instead.
|
||||
These legacy UI-backed targets have no authoritative launch registry. Local
|
||||
agent stops therefore fail closed before discovery, signalling or state writes,
|
||||
on both procfs and other hosts. Shared Wave 5B lifecycle mechanics are tested
|
||||
separately in test_process_lifecycle and test_process_ownership.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
@@ -160,7 +147,7 @@ def _install_effective_kill(monkeypatch, table):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_marks_session_stopped_when_the_host_has_no_procfs(
|
||||
async def test_unadmitted_stop_refused_when_the_host_has_no_procfs(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
"""The ODY-94 regression: no procfs must not turn a working stop into a failure."""
|
||||
@@ -176,13 +163,12 @@ async def test_stop_marks_session_stopped_when_the_host_has_no_procfs(
|
||||
json.dumps({"session_id": "serve-abc123"})
|
||||
)
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert result["output"].startswith("Stopped server serve-abc123")
|
||||
assert _stopped_statuses(posts, "serve-abc123") == ["stopped"]
|
||||
assert result["failure_kind"] == "resource_identity_denied"
|
||||
assert _stopped_statuses(posts, "serve-abc123") == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_says_so_when_the_session_cannot_be_inspected(
|
||||
async def test_unadmitted_stop_refused_when_the_session_cannot_be_inspected(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
"""A sweep that could not look must not read as a sweep that found nothing.
|
||||
@@ -209,15 +195,14 @@ async def test_stop_says_so_when_the_session_cannot_be_inspected(
|
||||
json.dumps({"session_id": "serve-abc123"})
|
||||
)
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert "could not identify the session's processes" in result["output"]
|
||||
assert result["failure_kind"] == "resource_identity_denied"
|
||||
assert signalled == []
|
||||
assert _stopped_statuses(posts, "serve-abc123") == ["stopped"]
|
||||
assert _stopped_statuses(posts, "serve-abc123") == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_kills_the_sessions_own_survivor(monkeypatch, tmp_path):
|
||||
"""A process under the session's pane is ours, so it gets signalled."""
|
||||
async def test_pane_descendant_is_not_application_owned(monkeypatch, tmp_path):
|
||||
"""A process under a named pane still requires prior application admission."""
|
||||
tracked_cmd = "python -m vllm.entrypoints.openai.api_server --model org/model"
|
||||
state = _tracked_state(cmd=tracked_cmd)
|
||||
posts = _install_httpx_client(monkeypatch, state)
|
||||
@@ -232,14 +217,13 @@ async def test_stop_kills_the_sessions_own_survivor(monkeypatch, tmp_path):
|
||||
json.dumps({"session_id": "serve-abc123"})
|
||||
)
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert (101, signal.SIGTERM) in signalled
|
||||
assert "killed 2 surviving process(es)" in result["output"]
|
||||
assert _stopped_statuses(posts, "serve-abc123") == ["stopped"]
|
||||
assert result["failure_kind"] == "resource_identity_denied"
|
||||
assert signalled == [] # OS lineage alone never establishes app ownership.
|
||||
assert _stopped_statuses(posts, "serve-abc123") == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_reports_a_command_line_lookalike_without_signalling_it(
|
||||
async def test_unadmitted_stop_never_signals_a_command_line_lookalike(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
"""The headline change: matching the command line is not owning the process.
|
||||
@@ -262,13 +246,9 @@ async def test_stop_reports_a_command_line_lookalike_without_signalling_it(
|
||||
json.dumps({"session_id": "serve-abc123"})
|
||||
)
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert result["failure_kind"] == "resource_identity_denied"
|
||||
assert not any(pid == 202 for pid, _sig in signalled)
|
||||
# Reported rather than silently dropped: the old behaviour acted on this
|
||||
# information, so giving it up entirely would be a regression of its own.
|
||||
assert "202" in result["output"]
|
||||
assert "not signalled" in result["output"]
|
||||
assert _stopped_statuses(posts, "serve-abc123") == ["stopped"]
|
||||
assert _stopped_statuses(posts, "serve-abc123") == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -302,10 +282,10 @@ async def test_stop_does_not_signal_a_pid_whose_identity_changed(
|
||||
json.dumps({"session_id": "serve-abc123"})
|
||||
)
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
# The pane shell is genuinely ours and is signalled; 101 never is.
|
||||
assert result["failure_kind"] == "resource_identity_denied"
|
||||
# Neither pane discovery nor a matching token creates application scope.
|
||||
assert not any(pid == 101 for pid, _sig in signalled)
|
||||
assert _stopped_statuses(posts, "serve-abc123") == ["stopped"]
|
||||
assert _stopped_statuses(posts, "serve-abc123") == []
|
||||
|
||||
|
||||
def test_model_process_scan_returns_empty_without_procfs(monkeypatch, tmp_path):
|
||||
@@ -323,8 +303,8 @@ def test_model_process_scan_returns_empty_without_procfs(monkeypatch, tmp_path):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_reports_a_survivor_it_can_no_longer_identify(monkeypatch, tmp_path):
|
||||
"""Captured as ours, unverifiable at sweep time: not signalled, and said so."""
|
||||
async def test_unadmitted_stop_refused_with_unverifiable_process(monkeypatch, tmp_path):
|
||||
"""An unverifiable OS observation cannot create an application grant."""
|
||||
from src import process_ownership
|
||||
|
||||
tracked_cmd = "python -m vllm.entrypoints.openai.api_server --model org/model"
|
||||
@@ -347,10 +327,9 @@ async def test_stop_reports_a_survivor_it_can_no_longer_identify(monkeypatch, tm
|
||||
|
||||
result = await tools.do_stop_served_model(json.dumps({"session_id": "serve-abc123"}))
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert result["failure_kind"] == "resource_identity_denied"
|
||||
assert not any(pid == 101 for pid, _sig in signalled)
|
||||
assert "could not be re-identified and were not signalled (pid 101)" in result["output"]
|
||||
assert _stopped_statuses(posts, "serve-abc123") == ["stopped"]
|
||||
assert _stopped_statuses(posts, "serve-abc123") == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -383,6 +362,6 @@ async def test_stop_never_signals_a_pid_reissued_between_the_table_and_its_captu
|
||||
|
||||
result = await tools.do_stop_served_model(json.dumps({"session_id": "serve-abc123"}))
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert result["failure_kind"] == "resource_identity_denied"
|
||||
assert not any(pid == 101 for pid, _sig in signalled)
|
||||
assert _stopped_statuses(posts, "serve-abc123") == ["stopped"]
|
||||
assert _stopped_statuses(posts, "serve-abc123") == []
|
||||
|
||||
@@ -11,13 +11,23 @@ from src.agent_tools import subprocess_tools
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def native_boundary(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(tool_execution, "agent_cwd", lambda: str(tmp_path))
|
||||
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "grants.json")
|
||||
from src.agent_runtime import process_resources
|
||||
from tests.process_resource_helpers import authorized_handler
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
monkeypatch.setattr(tool_execution, "agent_cwd", lambda: str(workspace))
|
||||
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "private" / "grants.json")
|
||||
monkeypatch.setattr(process_resources, "_LAUNCH_DIR", tmp_path / "private" / "launches")
|
||||
for cls in (subprocess_tools.BashTool, subprocess_tools.PythonTool):
|
||||
original = cls.execute
|
||||
async def execute(self, content, ctx, _original=original):
|
||||
return await authorized_handler(_original.__get__(self), workspace)(content, ctx)
|
||||
monkeypatch.setattr(cls, "execute", execute)
|
||||
monkeypatch.setattr(containment, "CONTAINMENT_MODE", containment.MODE_REPORT_ONLY)
|
||||
monkeypatch.setattr(containment, "MECHANISMS", tuple(
|
||||
m for m in containment.MECHANISMS if m.name == "process_group"
|
||||
))
|
||||
return tmp_path
|
||||
return workspace
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="real POSIX group teardown")
|
||||
|
||||
@@ -399,10 +399,16 @@ def test_already_finished_jobs_are_not_reconsidered(job_store, monkeypatch):
|
||||
assert bg_jobs.disown_unverified() == {"seen": 0, "retired": 0, "kept": 0}
|
||||
|
||||
|
||||
def test_a_launched_job_records_an_identity_next_to_its_pid(job_store):
|
||||
def test_a_launched_job_records_an_identity_next_to_its_pid(job_store, tmp_path, monkeypatch):
|
||||
"""Without this the record is unverifiable forever and the reaper can only
|
||||
refuse — the token has to be captured at launch or not at all."""
|
||||
record = bg_jobs.launch("true", "chat-1")
|
||||
from tests.process_resource_helpers import launch
|
||||
from src.agent_runtime import process_resources
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
monkeypatch.setattr(process_resources, "_LAUNCH_DIR", tmp_path / "private" / "launches")
|
||||
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "private" / "grants.json")
|
||||
record = launch("true", "chat-1", cwd=str(workspace))
|
||||
|
||||
assert "start_token" in record
|
||||
assert process_ownership.verify(record["pid"], record["start_token"]) in (
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
from dataclasses import replace
|
||||
import json
|
||||
import signal
|
||||
|
||||
import pytest
|
||||
|
||||
from src import process_ownership
|
||||
from src.process_lifecycle import ProcessIdentity, signal_identity
|
||||
from src.agent_runtime.authority import ExactOperation, OperationGrant, RequestAuthority
|
||||
from src.agent_runtime.resources import ProcessResource, NativeBackendResource, FilesystemRoot, ProcessLaunchScope, ResourceIdentityError
|
||||
from src.agent_runtime.process_resources import resolve_process_operation
|
||||
from src.containment import DEFAULT_REQUIRED
|
||||
|
||||
|
||||
def process():
|
||||
return ProcessResource("native:containment", "alice", "request", "thread", ProcessIdentity(4321, "boot:start", 4321), "leader", "job", "receipt")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("verdict", [process_ownership.FOREIGN, process_ownership.GONE, process_ownership.UNVERIFIABLE])
|
||||
def test_stale_reused_or_unverifiable_identity_cannot_be_admitted(monkeypatch, verdict):
|
||||
monkeypatch.setattr(process_ownership, "verify", lambda *a: verdict)
|
||||
with pytest.raises(ResourceIdentityError):
|
||||
process().validate()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field,value", [("pid", 0), ("pid", "4321"), ("pid", True), ("pgid", "4321"), ("start_token", None), ("start_token", ""), ("start_token", {})])
|
||||
def test_malformed_lifecycle_observations_fail_closed(field, value):
|
||||
record = process().to_dict()
|
||||
record["identity"][field] = value
|
||||
with pytest.raises((ValueError, TypeError)):
|
||||
ProcessResource.from_dict(record)
|
||||
|
||||
|
||||
def test_no_duplicate_lifecycle_fields_and_strict_restore():
|
||||
resource = process()
|
||||
record = resource.to_dict()
|
||||
assert ProcessResource.from_dict(record) == resource
|
||||
assert "pid" not in record and "start_token" not in record
|
||||
record["identity"]["incarnation"] = "invented"
|
||||
with pytest.raises(ValueError):
|
||||
ProcessResource.from_dict(record)
|
||||
|
||||
|
||||
def test_incarnation_is_not_application_ownership(monkeypatch):
|
||||
monkeypatch.setattr(process_ownership, "verify", lambda *a: process_ownership.OWNED)
|
||||
monkeypatch.setattr(ProcessIdentity, "exited", lambda self: False)
|
||||
resource = process()
|
||||
resource.validate()
|
||||
for field in ("namespace", "owner", "request_id", "thread_id", "role", "job_id", "containment_id"):
|
||||
if field in {"namespace", "role"}:
|
||||
with pytest.raises(ValueError):
|
||||
replace(resource, **{field: "supervisor" if field == "role" else "external:ssh"})
|
||||
continue
|
||||
changed = replace(resource, **{field: "supervisor" if field == "role" else "other"})
|
||||
assert changed != resource
|
||||
with pytest.raises(ValueError):
|
||||
RequestAuthority("request", "bob", "thread", "", process_resources=(resource,))
|
||||
with pytest.raises(ValueError):
|
||||
RequestAuthority("request", "alice", "other-thread", "", process_resources=(resource,))
|
||||
|
||||
|
||||
def test_pid_reuse_at_signal_boundary_uses_wave5b_engine(monkeypatch):
|
||||
verdicts = iter([process_ownership.OWNED, process_ownership.OWNED, process_ownership.FOREIGN])
|
||||
monkeypatch.setattr(process_ownership, "verify", lambda *a: next(verdicts))
|
||||
monkeypatch.setattr("src.process_lifecycle.is_zombie", lambda pid: False)
|
||||
monkeypatch.setattr("os.kill", lambda *a: pytest.fail("reused PID signalled"))
|
||||
target = process()
|
||||
target.validate()
|
||||
assert signal_identity(target.identity, signal.SIGTERM) is False
|
||||
|
||||
|
||||
def test_child_cannot_renew_replaced_parent_process(monkeypatch):
|
||||
old = process()
|
||||
fresh = replace(old, identity=replace(old.identity, start_token="boot:replacement"))
|
||||
monkeypatch.setattr(process_ownership, "verify", lambda pid, token: process_ownership.FOREIGN if token == "boot:start" else process_ownership.OWNED)
|
||||
parent = RequestAuthority("parent", "alice", "thread", "", process_resources=(old,))
|
||||
child = replace(parent, request_id="child", process_resources=(fresh,))
|
||||
with pytest.raises(ResourceIdentityError):
|
||||
parent.intersect(child)
|
||||
|
||||
|
||||
def test_legacy_authority_cannot_reconstruct_creation_scope(tmp_path):
|
||||
authority = RequestAuthority("request", "alice", "thread", str(tmp_path), (OperationGrant("bash"),))
|
||||
snapshot = authority.to_dict()
|
||||
snapshot["version"] = 3
|
||||
for field in ("launch_scopes", "process_resources", "job_resources"):
|
||||
snapshot.pop(field)
|
||||
restored = RequestAuthority.from_dict(snapshot)
|
||||
assert restored.launch_scopes == restored.process_resources == restored.job_resources == ()
|
||||
with pytest.raises(ResourceIdentityError):
|
||||
resolve_process_operation(restored, ExactOperation.normalize("bash", "pwd"), NativeBackendResource("bash"))
|
||||
|
||||
|
||||
def test_launch_is_server_generation_exact_operation_and_credential_free(tmp_path):
|
||||
authority = RequestAuthority("request", "alice", "thread", str(tmp_path), (OperationGrant("bash"),))
|
||||
operation = ExactOperation.normalize("bash", "printf secret-token")
|
||||
bound = resolve_process_operation(authority, operation, NativeBackendResource("bash"))
|
||||
assert "secret-token" not in json.dumps(bound.to_dict())
|
||||
assert len(bound.launch.generation) == 32
|
||||
assert bound.launch.scope.root == authority.resource_roots[0]
|
||||
with pytest.raises(ResourceIdentityError):
|
||||
resolve_process_operation(authority, ExactOperation.normalize("bash", "pwd"), NativeBackendResource("bash"), approved=bound, exact_admission=True)
|
||||
|
||||
|
||||
def test_child_launch_scope_can_narrow_but_cannot_broaden(tmp_path):
|
||||
sub = tmp_path / "child"
|
||||
sub.mkdir()
|
||||
parent = RequestAuthority("request", "alice", "thread", str(tmp_path), (OperationGrant("bash"),))
|
||||
smaller = ProcessLaunchScope(NativeBackendResource("bash"), FilesystemRoot.seal(sub, owner="alice"), DEFAULT_REQUIRED)
|
||||
child = replace(parent, launch_scopes=(smaller,))
|
||||
assert parent.intersect(child).launch_scopes == (smaller,)
|
||||
assert child.intersect(parent).launch_scopes == ()
|
||||
|
||||
|
||||
def test_child_launch_cannot_refresh_a_replaced_root(tmp_path):
|
||||
root = tmp_path / "root"
|
||||
root.mkdir()
|
||||
parent = RequestAuthority("request", "alice", "thread", str(root), (OperationGrant("bash"),))
|
||||
root.rename(tmp_path / "retired")
|
||||
root.mkdir()
|
||||
child = RequestAuthority("child", "alice", "thread", str(root), (OperationGrant("bash"),))
|
||||
with pytest.raises(ResourceIdentityError):
|
||||
parent.intersect(child)
|
||||
@@ -184,10 +184,14 @@ async def test_external_record_does_not_grant_authority(tmp_path):
|
||||
async def test_native_local_bash_python_behavior_unchanged(tmp_path, monkeypatch):
|
||||
"""4. Native local Bash/Python behavior is unchanged."""
|
||||
tool_bash = subprocess_tools.BashTool()
|
||||
from tests.process_resource_helpers import authorized_handler
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
monkeypatch.setattr(_te, "agent_cwd", lambda: str(workspace))
|
||||
ctx = {
|
||||
"session_id": "native-session",
|
||||
}
|
||||
result = await tool_bash.execute("echo 'native run'", ctx)
|
||||
result = await authorized_handler(tool_bash.execute, workspace)("echo 'native run'", ctx)
|
||||
assert result["exit_code"] == 0
|
||||
assert "native run" in result["output"]
|
||||
assert "containment" in result
|
||||
|
||||
@@ -158,15 +158,15 @@ async def test_missing_and_malformed_dispatch_authority_fail_closed(monkeypatch,
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_checks_grants_and_current_disabled_policy(monkeypatch):
|
||||
async def test_dispatch_checks_grants_and_current_disabled_policy(monkeypatch, tmp_path):
|
||||
from src import tool_execution as execution
|
||||
implementation = AsyncMock(return_value=("bash", {"exit_code": 0}))
|
||||
monkeypatch.setattr(execution, "_execute_tool_block_impl", implementation)
|
||||
for disabled in (set(), {"bash"}):
|
||||
_, result = await execution.execute_tool_block(ToolBlock("bash", "pwd"),
|
||||
owner="alice", session_id="s", disabled_tools=disabled,
|
||||
owner="alice", session_id="s", workspace=str(tmp_path), disabled_tools=disabled,
|
||||
security_context=execution.NO_TOOL_SECURITY_CONTEXT,
|
||||
request_authority=authority("bash"))
|
||||
request_authority=authority("bash", workspace=str(tmp_path)))
|
||||
assert result["exit_code"] == (1 if disabled else 0)
|
||||
assert implementation.await_count == 1
|
||||
|
||||
@@ -224,10 +224,11 @@ def test_background_snapshot_preserves_scope_and_rejects_other_session(monkeypat
|
||||
import src.constants
|
||||
monkeypatch.setattr(src.constants, "BG_JOBS_DIR", str(tmp_path))
|
||||
grant = authority("transcribe_media").restrict(disabled_tools={"bash"})
|
||||
save_background_authority("job1", grant)
|
||||
# Legacy authority-only snapshots have no exact job generation to restore.
|
||||
with pytest.raises(ValueError):
|
||||
save_background_authority("job1", grant)
|
||||
restored = restore_background_authority("job1", owner="alice", session_id="s")
|
||||
assert restored.request_id == grant.request_id
|
||||
assert restored.denied == frozenset({"bash"})
|
||||
assert restored.grants == ()
|
||||
assert not restored.permits(ExactOperation.normalize("python", "print(1)"))
|
||||
assert restore_background_authority("job1", owner="alice", session_id="other").grants == ()
|
||||
|
||||
@@ -243,8 +244,7 @@ async def test_only_server_background_launch_can_seal_job_authority(monkeypatch,
|
||||
owner="alice", session_id="s", security_context=execution.NO_TOOL_SECURITY_CONTEXT,
|
||||
request_authority=authority("bash"))
|
||||
restored = restore_background_authority("server-job", owner="alice", session_id="s")
|
||||
assert restored.request_id == "request-test"
|
||||
assert restored.permits(ExactOperation.normalize("bash", "printf trusted"))
|
||||
assert restored.grants == () # A launch double returning an ID cannot publish authority.
|
||||
handler = AsyncMock(return_value=("transcribe_media", {"bg_job_id": "forged-job", "exit_code": 0}))
|
||||
monkeypatch.setattr(execution, "_execute_tool_block_impl", handler)
|
||||
await execution.execute_tool_block(ToolBlock("transcribe_media", '{}'),
|
||||
@@ -254,12 +254,16 @@ async def test_only_server_background_launch_can_seal_job_authority(monkeypatch,
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exact_approval_grants_one_input_without_widening_continuation(monkeypatch):
|
||||
async def test_exact_approval_grants_one_input_without_widening_continuation(monkeypatch, tmp_path):
|
||||
from src import tool_execution as execution
|
||||
from src.tool_approvals import ToolApprovalStore
|
||||
from src.tool_capabilities import ToolRunSecurityContext, capabilities_for_action
|
||||
store = ToolApprovalStore()
|
||||
original = authority("transcribe_media")
|
||||
from src.agent_runtime.resources import ProcessLaunchScope, FilesystemRoot, NativeBackendResource
|
||||
from src.containment import DEFAULT_REQUIRED
|
||||
original = replace(original, launch_scopes=(ProcessLaunchScope(NativeBackendResource("bash"),
|
||||
FilesystemRoot.seal(tmp_path), DEFAULT_REQUIRED),))
|
||||
pending = store.create(owner="alice", session_id="s", origin_run_id="journal-parent",
|
||||
tool_name="bash", content="printf approved", workspace=None,
|
||||
external_untrusted_context_seen=True, capabilities=capabilities_for_action("bash", "printf approved"),
|
||||
|
||||
@@ -397,8 +397,10 @@ def test_task_and_background_continuations_keep_original_roots(tmp_path, monkeyp
|
||||
import src.constants
|
||||
monkeypatch.setattr(src.constants, "BG_JOBS_DIR", str(tmp_path))
|
||||
grant = authority(tmp_path, "read_file")
|
||||
save_background_authority("job", grant)
|
||||
assert restore_background_authority("job", owner="alice", session_id="s").resource_roots == grant.resource_roots
|
||||
# A roots-only sidecar is legacy state and cannot invent a job generation.
|
||||
with pytest.raises(ValueError):
|
||||
save_background_authority("job", grant)
|
||||
assert restore_background_authority("job", owner="alice", session_id="s").resource_roots == ()
|
||||
assert restore_background_authority("job", owner="bob", session_id="s").resource_roots == ()
|
||||
with bind_request_authority(grant):
|
||||
sealed = seal_task_authority("Read files in the workspace", "llm", None, owner="alice")
|
||||
@@ -708,10 +710,11 @@ def test_nonfilesystem_identities_are_inert_and_distinguish_producers_from_pages
|
||||
page = BrowserPageResource(producer, "page-1", 2, "https://example.test")
|
||||
assert replace(producer, incarnation="incarnation-2") != producer
|
||||
assert replace(page, navigation_generation=3) != page
|
||||
ProcessResource("local", "boot/process", "alice", 123, "boot:start", "job", "receipt", 124, "boot:init")
|
||||
from src.process_lifecycle import ProcessIdentity
|
||||
ProcessResource("native:containment", "alice", "request", "thread", ProcessIdentity(123, "boot:start"), "leader", "job", "receipt")
|
||||
OwnedResource("documents", "alice", "thread", "documents", "document", "revision")
|
||||
assert ExternalResource("mcp", "endpoint", "server", "tool", "connection").external is True
|
||||
with pytest.raises(ValueError):
|
||||
ExternalResource("mcp", "endpoint", "server", "tool", "connection", external=False)
|
||||
with pytest.raises(ValueError):
|
||||
ProcessResource("local", "incarnation", "alice", 123, "", containment_id="receipt")
|
||||
ProcessResource("native:containment", "alice", "request", "thread", ProcessIdentity(123, ""), "leader", containment_id="receipt")
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
import asyncio
|
||||
from dataclasses import replace
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from src import bg_jobs, containment, process_ownership, tool_execution
|
||||
from src.agent_runtime import process_resources as resources
|
||||
from src.agent_runtime.authority import ExactOperation, OperationGrant, RequestAuthority, bind_request_authority, create_request_authority
|
||||
from src.agent_runtime.resources import NativeBackendResource, ResourceIdentityError
|
||||
from src.agent_tools.subprocess_tools import BashTool
|
||||
from src.process_lifecycle import ProcessIdentity
|
||||
from src.tool_approvals import ToolApprovalStore
|
||||
from src.tool_capabilities import ToolRunSecurityContext, capabilities_for_action
|
||||
from src.tool_types import ToolBlock
|
||||
from tests.process_resource_helpers import launch_authority, seed_linkage
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace(tmp_path, monkeypatch):
|
||||
work = tmp_path / "workspace"
|
||||
work.mkdir()
|
||||
monkeypatch.setattr(resources, "_LAUNCH_DIR", tmp_path / "private" / "launches")
|
||||
monkeypatch.setattr(bg_jobs, "_STORE", tmp_path / "private" / "jobs.json")
|
||||
monkeypatch.setattr(bg_jobs, "_JOBS_DIR", tmp_path / "private" / "jobs")
|
||||
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "private" / "receipts.json")
|
||||
monkeypatch.setattr(containment, "CONTAINMENT_MODE", containment.MODE_REPORT_ONLY)
|
||||
monkeypatch.setattr(containment, "MECHANISMS", tuple(m for m in containment.MECHANISMS if m.name == "process_group"))
|
||||
monkeypatch.setattr(tool_execution, "_owner_is_admin", lambda owner: True)
|
||||
return work
|
||||
|
||||
|
||||
def authority(workspace, tool="bash"):
|
||||
return RequestAuthority("request", "alice", "thread", str(workspace), (OperationGrant(tool),))
|
||||
|
||||
|
||||
def approval_for(authority, tool, content):
|
||||
store = ToolApprovalStore()
|
||||
pending = store.create(owner=authority.owner, session_id=authority.session_id, origin_run_id="run",
|
||||
tool_name=tool, content=content, workspace=authority.workspace,
|
||||
capabilities=capabilities_for_action(tool, content), external_untrusted_context_seen=True,
|
||||
request_authority=authority)
|
||||
return store.consume(pending.approval_id, owner=authority.owner, session_id=authority.session_id, decision="approve")
|
||||
|
||||
|
||||
async def dispatch(authority, tool, content, approval=None):
|
||||
return await tool_execution.execute_tool_block(ToolBlock(tool, content), owner=authority.owner,
|
||||
session_id=authority.session_id, workspace=authority.workspace,
|
||||
security_context=ToolRunSecurityContext(external_untrusted_context_seen=bool(approval)),
|
||||
request_authority=authority, exact_approval=approval)
|
||||
|
||||
|
||||
async def test_native_producer_without_binding_cannot_spawn(workspace, monkeypatch):
|
||||
monkeypatch.setattr(asyncio, "create_subprocess_exec", lambda *a, **k: pytest.fail("unbound spawn"))
|
||||
result = await BashTool().execute("printf unsafe", {})
|
||||
assert result["failure_kind"] == "resource_identity_denied"
|
||||
|
||||
|
||||
async def test_producer_rejects_changed_command_after_admission(workspace, monkeypatch):
|
||||
with launch_authority("printf admitted", workspace):
|
||||
monkeypatch.setattr(asyncio, "create_subprocess_exec", lambda *a, **k: pytest.fail("retargeted spawn"))
|
||||
result = await BashTool().execute("printf changed", {})
|
||||
assert result["blocked"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ctx", [{"owner": "bob", "session_id": "thread"},
|
||||
{"owner": "alice", "session_id": "replacement"}])
|
||||
async def test_native_producer_rechecks_application_binding(workspace, monkeypatch, ctx):
|
||||
admitted = authority(workspace)
|
||||
operation = ExactOperation.normalize("bash", "printf admitted")
|
||||
bound = resources.resolve_process_operation(admitted, operation, NativeBackendResource("bash"))
|
||||
monkeypatch.setattr(containment, "acquire", lambda *a, **k: pytest.fail("Rebound producer acquired boundary"))
|
||||
with bind_request_authority(admitted), resources.bind_process_operation(bound):
|
||||
result = await BashTool().execute(operation.input, ctx)
|
||||
assert result["exit_code"] == 1 and "owner or session changed" in result["error"]
|
||||
|
||||
|
||||
async def test_scheduled_local_runner_uses_exact_launch_ceiling(workspace):
|
||||
from src import builtin_actions
|
||||
output, success = await builtin_actions.action_run_local("alice", script="printf scheduled")
|
||||
assert not success and "no server authority" in output
|
||||
admitted = replace(authority(workspace), grants=(OperationGrant("bash", inputs=frozenset({"printf scheduled"})),))
|
||||
with bind_request_authority(admitted):
|
||||
output, success = await builtin_actions.action_run_local("alice", script="printf scheduled")
|
||||
assert success and output == "scheduled"
|
||||
output, success = await builtin_actions.action_run_local("alice", script="printf changed")
|
||||
assert not success and "sealed operation" in output
|
||||
output, success = await builtin_actions.action_ssh_command("alice", command="printf scheduled", host="remote.example")
|
||||
assert not success and "external backend" in output
|
||||
|
||||
|
||||
async def test_attachment_failure_after_execution_does_not_claim_no_execution(workspace, monkeypatch):
|
||||
def failure(*args):
|
||||
raise OSError("attachment publication failed")
|
||||
monkeypatch.setattr(resources, "attach_containment_processes", failure)
|
||||
_, result = await dispatch(authority(workspace), "bash", "printf occurred > effect")
|
||||
assert (workspace / "effect").read_text() == "occurred"
|
||||
assert result["exit_code"] == 1 and result["failure_kind"] == "resource_linkage_unavailable"
|
||||
assert result["containment"]["executed"] is True and result["teardown"]["dead"] is True
|
||||
|
||||
|
||||
async def test_exact_launch_first_use_replay_and_empty_scope_restoration(workspace):
|
||||
original = authority(workspace)
|
||||
approval = approval_for(original, "bash", "printf exact")
|
||||
assert approval.pending.process_operation.launch is not None
|
||||
restored = replace(original, grants=(), resource_roots=(), backend_resources=(), launch_scopes=(), process_resources=(), job_resources=())
|
||||
_, first = await dispatch(restored, "bash", "printf exact", approval)
|
||||
assert first["exit_code"] == 0 and first["output"] == "exact"
|
||||
assert restored.launch_scopes == restored.job_resources == restored.process_resources == ()
|
||||
_, replay = await dispatch(restored, "bash", "printf exact", approval)
|
||||
assert replay["exit_code"] == 1
|
||||
_, sibling = await dispatch(restored, "bash", "printf sibling")
|
||||
assert sibling["failure_kind"] == "request_authority_denied"
|
||||
|
||||
|
||||
async def test_exact_job_first_use_replay_and_empty_scope_restoration(workspace, monkeypatch):
|
||||
bg_jobs._JOBS_DIR.mkdir(parents=True)
|
||||
record = {"id": "job", "session_id": "thread", "command": "printf history", "pid": 4321,
|
||||
"status": "done", "started_at": 1, "max_runtime_s": 3600,
|
||||
"log_path": str(bg_jobs._JOBS_DIR / "job.log")}
|
||||
seed_linkage(record, workspace, owner="alice")
|
||||
Path(record["log_path"]).write_text("historical result")
|
||||
bg_jobs._save({"job": record})
|
||||
original = authority(workspace, "manage_bg_jobs")
|
||||
content = '{"action":"output","job_id":"job"}'
|
||||
approval = approval_for(original, "manage_bg_jobs", content)
|
||||
restored = replace(original, grants=(), resource_roots=(), backend_resources=(),
|
||||
launch_scopes=(), process_resources=(), job_resources=())
|
||||
_, first = await dispatch(restored, "manage_bg_jobs", content, approval)
|
||||
assert first["exit_code"] == 0 and "historical result" in first["output"]
|
||||
_, replay = await dispatch(restored, "manage_bg_jobs", content, approval)
|
||||
assert replay["exit_code"] == 1
|
||||
_, unapproved = await dispatch(restored, "manage_bg_jobs", content)
|
||||
assert unapproved["failure_kind"] == "request_authority_denied"
|
||||
assert restored.process_resources == restored.job_resources == restored.launch_scopes == ()
|
||||
|
||||
|
||||
async def test_cancellation_at_native_spawn_restores_all_context(workspace, monkeypatch):
|
||||
entered = asyncio.Event()
|
||||
async def held_run(grant, command, **kwargs):
|
||||
assert resources.active_process_operation().launch is not None
|
||||
entered.set()
|
||||
try:
|
||||
await asyncio.Future()
|
||||
finally:
|
||||
containment.release(grant, grace_s=0)
|
||||
monkeypatch.setattr(containment, "run", held_run)
|
||||
async def invoke():
|
||||
try:
|
||||
await dispatch(authority(workspace), "bash", "sleep 60")
|
||||
finally:
|
||||
from src.agent_runtime.authority import active_request_authority
|
||||
assert resources.active_process_operation() is None
|
||||
assert active_request_authority() is None
|
||||
task = asyncio.create_task(invoke())
|
||||
await asyncio.wait_for(entered.wait(), timeout=5)
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
assert containment.active_grants() == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field,value", [("owner", "bob"), ("request_id", "replacement"), ("session_id", "other-thread")])
|
||||
async def test_exact_launch_binding_substitution_fails(workspace, field, value):
|
||||
original = authority(workspace)
|
||||
approval = approval_for(original, "bash", "printf exact")
|
||||
changed = replace(original, **{field: value}, resource_roots=None, backend_resources=None,
|
||||
owned_scopes=None, launch_scopes=None)
|
||||
_, denied = await dispatch(changed, "bash", "printf exact", approval)
|
||||
assert denied["exit_code"] == 1 and not approval._claimed
|
||||
|
||||
|
||||
async def test_exact_launch_replaced_workspace_fails_before_claim(workspace):
|
||||
original = authority(workspace)
|
||||
approval = approval_for(original, "bash", "pwd")
|
||||
workspace.rename(workspace.with_name("retired"))
|
||||
workspace.mkdir()
|
||||
_, result = await dispatch(original, "bash", "pwd", approval)
|
||||
assert result["failure_kind"] == "resource_identity_denied" and not approval._claimed
|
||||
|
||||
|
||||
@pytest.mark.parametrize("phase", ["success", "error", "cancel", "nested"])
|
||||
async def test_process_context_restores(workspace, phase):
|
||||
original = authority(workspace)
|
||||
bound = resources.resolve_process_operation(original, ExactOperation.normalize("bash", "pwd"), NativeBackendResource("bash"))
|
||||
async def call():
|
||||
with resources.bind_process_operation(bound):
|
||||
assert resources.active_process_operation() is bound
|
||||
if phase == "error":
|
||||
raise RuntimeError("ordinary")
|
||||
if phase == "cancel":
|
||||
raise asyncio.CancelledError()
|
||||
if phase == "nested":
|
||||
with resources.bind_process_operation(None):
|
||||
assert resources.active_process_operation() is None
|
||||
assert resources.active_process_operation() is bound
|
||||
try:
|
||||
await call()
|
||||
except (RuntimeError, asyncio.CancelledError):
|
||||
pass
|
||||
assert resources.active_process_operation() is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("publication", ["launch", "sidecar", "job"])
|
||||
def test_detached_publication_failure_cannot_release_workload(workspace, monkeypatch, publication):
|
||||
effect = workspace / "effect"
|
||||
if publication == "launch":
|
||||
monkeypatch.setattr(resources, "publish_launch", lambda *a, **k: (_ for _ in ()).throw(OSError("publication failed")))
|
||||
elif publication == "sidecar":
|
||||
monkeypatch.setattr("src.agent_runtime.authority.save_background_authority", lambda *a, **k: (_ for _ in ()).throw(OSError("sidecar failed")))
|
||||
else:
|
||||
monkeypatch.setattr(bg_jobs, "_save", lambda *a: (_ for _ in ()).throw(OSError("job failed")))
|
||||
with launch_authority("printf unsafe > effect", workspace):
|
||||
with pytest.raises(OSError):
|
||||
bg_jobs.launch("printf unsafe > effect", "chat", cwd=str(workspace))
|
||||
assert not effect.exists()
|
||||
assert containment.active_grants() == []
|
||||
|
||||
|
||||
def test_detached_release_observes_complete_durable_linkage(workspace, monkeypatch):
|
||||
real_popen = bg_jobs.subprocess.Popen
|
||||
observations = []
|
||||
def popen(*args, **kwargs):
|
||||
proc = real_popen(*args, **kwargs)
|
||||
original = proc.stdin
|
||||
class Gate:
|
||||
@property
|
||||
def closed(self):
|
||||
return original.closed
|
||||
def close(self):
|
||||
return original.close()
|
||||
def write(self, content):
|
||||
payload = json.loads(content)
|
||||
published = json.loads(Path(payload["launch_path"]).read_text())
|
||||
sidecar = json.loads(Path(payload["authority_path"]).read_text())
|
||||
rec = bg_jobs.peek(payload["job_id"])
|
||||
assert rec["resource_identity"] == published["job"] == sidecar["job"]
|
||||
assert sidecar["authority"] == published["authority"]
|
||||
observations.append(True)
|
||||
return original.write(content)
|
||||
proc.stdin = Gate()
|
||||
return proc
|
||||
monkeypatch.setattr(bg_jobs.subprocess, "Popen", popen)
|
||||
with launch_authority("printf released", workspace):
|
||||
rec = bg_jobs.launch("printf released", "chat", cwd=str(workspace))
|
||||
assert observations == [True]
|
||||
proc = bg_jobs._LIVE_PROCS.pop(rec["pid"])
|
||||
proc.wait(timeout=10)
|
||||
bg_jobs.refresh(rec["id"])
|
||||
assert bg_jobs.peek(rec["id"])["status"] == "done"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("replacement", ["pid", "job", "receipt", "role"])
|
||||
async def test_job_approval_revalidates_exact_resource_before_claim(workspace, monkeypatch, replacement):
|
||||
monkeypatch.setattr(process_ownership, "verify", lambda *a: process_ownership.OWNED)
|
||||
monkeypatch.setattr(ProcessIdentity, "exited", lambda self: False)
|
||||
bg_jobs._JOBS_DIR.mkdir(parents=True)
|
||||
record = {"id": "job", "session_id": "thread", "command": "sleep 60", "pid": 4321,
|
||||
"status": "running", "started_at": 1, "max_runtime_s": 3600,
|
||||
"exit_path": str(bg_jobs._JOBS_DIR / "job.exit"), "log_path": str(bg_jobs._JOBS_DIR / "job.log")}
|
||||
seed_linkage(record, workspace, owner="alice")
|
||||
bg_jobs._save({"job": record})
|
||||
admitted = authority(workspace, "manage_bg_jobs")
|
||||
content = '{"action":"kill","job_id":"job"}'
|
||||
approval = approval_for(admitted, "manage_bg_jobs", content)
|
||||
assert approval.pending.process_operation.jobs
|
||||
if replacement == "pid":
|
||||
monkeypatch.setattr(process_ownership, "verify", lambda *a: process_ownership.FOREIGN)
|
||||
else:
|
||||
jobs = bg_jobs._load()
|
||||
if replacement == "job":
|
||||
jobs["job"]["resource_identity"]["generation"] = "f" * 32
|
||||
elif replacement == "role":
|
||||
jobs["job"]["resource_identity"]["processes"][0]["role"] = "leader"
|
||||
else:
|
||||
jobs["job"]["containment_id"] = "replacement"
|
||||
bg_jobs._save(jobs)
|
||||
_, result = await dispatch(admitted, "manage_bg_jobs", content, approval)
|
||||
assert result["failure_kind"] == "resource_identity_denied" and not approval._claimed
|
||||
|
||||
|
||||
@pytest.mark.parametrize("request_text", ["Transcribe /workspace/audio.wav", "OCR this image", "List my tasks"])
|
||||
async def test_new_resources_do_not_expand_turn_contract_classes(workspace, request_text):
|
||||
admitted = create_request_authority(request_text, owner="alice", session_id="thread", workspace=str(workspace))
|
||||
_, denied = await dispatch(admitted, "bash", "pwd")
|
||||
assert denied["failure_kind"] == "request_authority_denied"
|
||||
|
||||
|
||||
def test_internal_shell_control_has_no_admin_floor_even_without_auth(monkeypatch):
|
||||
from routes import shell_routes
|
||||
from core.middleware import INTERNAL_TOOL_USER
|
||||
from fastapi import HTTPException
|
||||
request = SimpleNamespace(headers={}, state=SimpleNamespace(current_user=INTERNAL_TOOL_USER))
|
||||
monkeypatch.setattr(shell_routes, "_auth_disabled", lambda: True)
|
||||
with pytest.raises(HTTPException) as error:
|
||||
shell_routes._require_admin(request)
|
||||
assert error.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["auth_disabled", "missing_manager"])
|
||||
def test_unlabelled_loopback_cannot_gain_native_control(monkeypatch, mode):
|
||||
from routes import shell_routes
|
||||
from fastapi import HTTPException
|
||||
request = SimpleNamespace(headers={}, state=SimpleNamespace(current_user=None),
|
||||
app=SimpleNamespace(state=SimpleNamespace(auth_manager=None)))
|
||||
monkeypatch.setattr(shell_routes, "_auth_disabled", lambda: mode == "auth_disabled")
|
||||
with pytest.raises(HTTPException) as error:
|
||||
shell_routes._require_admin(request)
|
||||
assert error.value.status_code == 403
|
||||
|
||||
|
||||
def test_authenticated_human_administration_is_not_an_internal_tool_floor(monkeypatch):
|
||||
from routes import shell_routes
|
||||
request = SimpleNamespace(headers={}, state=SimpleNamespace(current_user="admin"),
|
||||
app=SimpleNamespace(state=SimpleNamespace(auth_manager=SimpleNamespace(is_admin=lambda u: u == "admin"))))
|
||||
monkeypatch.setattr(shell_routes, "_auth_disabled", lambda: False)
|
||||
shell_routes._require_admin(request)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path,payload", [("/api/cookbook/kill-pid", {"pid": 4321}),
|
||||
("/api/cookbook/state", {"tasks": []}), ("/api/model/serve", {}), ("/api/model/download", {})])
|
||||
async def test_anonymous_native_cookbook_control_rejected_before_producer(monkeypatch, path, payload):
|
||||
from routes import cookbook_routes, shell_routes
|
||||
from fastapi import FastAPI
|
||||
import httpx
|
||||
monkeypatch.setattr(shell_routes, "_auth_disabled", lambda: True)
|
||||
monkeypatch.setattr(asyncio, "create_subprocess_exec", lambda *a, **k: pytest.fail("Anonymous producer reached"))
|
||||
monkeypatch.setattr(asyncio, "create_subprocess_shell", lambda *a, **k: pytest.fail("Anonymous producer reached"))
|
||||
app = FastAPI()
|
||||
app.include_router(cookbook_routes.setup_cookbook_routes())
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://local") as client:
|
||||
result = await client.post(path, json=payload)
|
||||
assert result.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["/api/shell/exec", "/api/model/serve", "/api/cookbook/kill-pid", "/api/cookbook/state", "/api/shell/../cookbook/kill-pid"])
|
||||
def test_generic_loopback_cannot_bypass_process_resources(path):
|
||||
from src.agent_runtime.owned_resources import needs_owned_binding
|
||||
with pytest.raises(ResourceIdentityError):
|
||||
needs_owned_binding(ExactOperation.normalize("app_api", json.dumps({"path": path})))
|
||||
|
||||
|
||||
async def test_direct_local_cookbook_control_does_not_enroll_discovered_processes(monkeypatch):
|
||||
from src.tools import cookbook
|
||||
async def state():
|
||||
return {}
|
||||
monkeypatch.setattr(cookbook, "_capture_session_processes", lambda *a: pytest.fail("discovery enrolled as ownership"))
|
||||
monkeypatch.setattr(asyncio, "create_subprocess_exec", lambda *a, **k: pytest.fail("unbound Cookbook control"))
|
||||
# No server session registry exists for this selector; observation cannot
|
||||
# mint a process resource even when the UI supplies a matching name.
|
||||
result = await cookbook._cookbook_kill_session("serve-unowned")
|
||||
assert result["failure_kind"] == "resource_identity_denied"
|
||||
@@ -32,6 +32,15 @@ def _pending(store, **overrides):
|
||||
"capabilities": capabilities_for_action("bash", "printf exact"),
|
||||
}
|
||||
values.update(overrides)
|
||||
if "request_authority" not in values:
|
||||
import tempfile
|
||||
from src.agent_runtime.authority import RequestAuthority, OperationGrant
|
||||
from src.agent_runtime.resources import ProcessLaunchScope, FilesystemRoot, NativeBackendResource
|
||||
from src.containment import DEFAULT_REQUIRED
|
||||
tool = values["tool_name"]
|
||||
scopes = (ProcessLaunchScope(NativeBackendResource(tool), FilesystemRoot.seal(tempfile.mkdtemp(prefix="w3-approval-fixture-")), DEFAULT_REQUIRED),) if tool in {"bash", "python"} else ()
|
||||
values["request_authority"] = RequestAuthority("standalone-test-request", str(values["owner"]).casefold(),
|
||||
str(values["session_id"] or ""), str(values["workspace"] or ""), (OperationGrant(tool),), launch_scopes=scopes)
|
||||
return store.create(**values)
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,19 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def native_resource_authority(tmp_path, monkeypatch):
|
||||
from tests.process_resource_helpers import install_native_authority
|
||||
from src.agent_runtime import process_resources
|
||||
from src import containment
|
||||
workspace = tmp_path / "native-workspace"
|
||||
workspace.mkdir()
|
||||
control = tmp_path.parent / (tmp_path.name + "-control")
|
||||
monkeypatch.setattr(process_resources, "_LAUNCH_DIR", control / "launches")
|
||||
monkeypatch.setattr(containment, "_store_path", lambda: control / "grants.json")
|
||||
install_native_authority(monkeypatch, workspace)
|
||||
|
||||
|
||||
def test_unoffered_artifact_recovery_is_bounded():
|
||||
from src.agent_loop import _artifact_unoffered_recovery_exhausted
|
||||
|
||||
|
||||
Reference in New Issue
Block a user