fix(agent_runtime): fail closed when Git identity disappears (#7407)
Fail closed when the Agent Git identity disappears or is unreadable, including when the revision read itself raises — a missing revision is indistinguishable from changed source and must never be treated as proof the loaded code is current. Co-authored-by: LeonardoLGDS <LeonardoLGDS@users.noreply.github.com>
This commit is contained in:
1 parent
d98c9be39e
commit
81f18a1f72
2 files changed
+113
-11
No files matched your search
+22
-11
@@ -397,18 +397,29 @@ def ensure_agent_runtime_current() -> None:
|
||||
"""Reject a known Git checkout change instead of mixing Python modules."""
|
||||
if _AGENT_REVISION is None:
|
||||
return
|
||||
if (
|
||||
_read_agent_revision(_AGENT_SOURCE_DIR, module_path=_AGENT_MODULE_PATH)
|
||||
!= _AGENT_REVISION
|
||||
):
|
||||
# Automatic restart needs an Agent-owned success receipt bound to this
|
||||
# transaction, final revision and healthy environment, plus an atomic
|
||||
# handoff excluding mutations across replacement. Marker polling and a
|
||||
# final revision read supply neither contract. Keep this path manual.
|
||||
raise AgentRuntimeChangedError(
|
||||
_RESTART_REQUIRED_MESSAGE,
|
||||
agent_update_state=_agent_update_transaction_state(),
|
||||
fresh_revision = None
|
||||
try:
|
||||
fresh_revision = _read_agent_revision(
|
||||
_AGENT_SOURCE_DIR, module_path=_AGENT_MODULE_PATH
|
||||
)
|
||||
except Exception:
|
||||
# An unreadable revision is indistinguishable from a changed one, so a
|
||||
# failed read must fail CLOSED like every other identity-loss shape
|
||||
# (deleted, permission-denied, empty, corrupt, removed directory).
|
||||
# Letting the raw exception escape would surface as an HTTP 500 instead
|
||||
# of the typed stale-runtime response the barrier is meant to produce.
|
||||
fresh_revision = None
|
||||
if fresh_revision == _AGENT_REVISION:
|
||||
return
|
||||
|
||||
# Automatic restart needs an Agent-owned success receipt bound to this
|
||||
# transaction, final revision and healthy environment, plus an atomic
|
||||
# handoff excluding mutations across replacement. Marker polling and a
|
||||
# final revision read supply neither contract. Keep this path manual.
|
||||
raise AgentRuntimeChangedError(
|
||||
_RESTART_REQUIRED_MESSAGE,
|
||||
agent_update_state=_agent_update_transaction_state(),
|
||||
)
|
||||
|
||||
|
||||
def require_ai_agent_class():
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Git identity loss must not fall back to forgeable source-file metadata."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str) -> str:
|
||||
result = subprocess.run(
|
||||
["git", "-c", "user.name=Test", "-c", "user.email=test@example.invalid", *args],
|
||||
cwd=repo,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def test_git_identity_loss_rejects_changed_source_with_preserved_metadata(
|
||||
monkeypatch, tmp_path: Path
|
||||
):
|
||||
"""A known Git identity cannot fall back to forgeable file metadata."""
|
||||
from api import agent_runtime
|
||||
|
||||
source_dir = tmp_path / "loaded-agent"
|
||||
source_dir.mkdir()
|
||||
module_file = source_dir / "run_agent.py"
|
||||
module_file.write_bytes(b"class AIAgent: pass\n")
|
||||
_git(source_dir, "init", "-q")
|
||||
_git(source_dir, "add", "run_agent.py")
|
||||
_git(source_dir, "commit", "-qm", "loaded agent")
|
||||
|
||||
loaded_module = types.ModuleType("run_agent")
|
||||
loaded_module.__file__ = str(module_file)
|
||||
monkeypatch.setitem(sys.modules, "run_agent", loaded_module)
|
||||
revision = _git(source_dir, "rev-parse", "HEAD")
|
||||
original_stat = module_file.stat()
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_SOURCE_DIR", source_dir.resolve())
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_MODULE_PATH", module_file.resolve())
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_REVISION", revision)
|
||||
monkeypatch.setattr(
|
||||
agent_runtime, "_AGENT_MODULE_MTIME_NS", original_stat.st_mtime_ns,
|
||||
raising=False,
|
||||
)
|
||||
|
||||
(source_dir / ".git").rename(tmp_path / "hidden-agent-git")
|
||||
module_file.write_bytes(b"class AIAgent: gasp\n")
|
||||
os.utime(module_file, ns=(original_stat.st_atime_ns, original_stat.st_mtime_ns))
|
||||
fresh_stat = module_file.stat()
|
||||
assert fresh_stat.st_size == original_stat.st_size
|
||||
assert fresh_stat.st_mtime_ns == original_stat.st_mtime_ns
|
||||
|
||||
with pytest.raises(agent_runtime.AgentRuntimeChangedError):
|
||||
agent_runtime.ensure_agent_runtime_current()
|
||||
|
||||
|
||||
def test_raising_revision_reader_fails_closed(monkeypatch, tmp_path: Path):
|
||||
"""A revision read that RAISES must fail closed, not escape as a 500.
|
||||
|
||||
Every other identity-loss shape (deleted, permission-denied, empty,
|
||||
whitespace-only, corrupt, removed directory) already raises
|
||||
AgentRuntimeChangedError. A reader that throws is the same situation --
|
||||
the revision is unreadable, which is indistinguishable from changed --
|
||||
so it must produce the same typed error rather than propagating a raw
|
||||
OSError that the request layer would surface as an HTTP 500 instead of
|
||||
the intended stale-runtime response.
|
||||
"""
|
||||
from api import agent_runtime
|
||||
|
||||
source_dir = tmp_path / "raising-agent"
|
||||
source_dir.mkdir()
|
||||
module_file = source_dir / "run_agent.py"
|
||||
module_file.write_bytes(b"class AIAgent: pass\n")
|
||||
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_SOURCE_DIR", source_dir.resolve())
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_MODULE_PATH", module_file.resolve())
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_REVISION", "0" * 40)
|
||||
|
||||
def _boom(*_args, **_kwargs):
|
||||
raise PermissionError("revision unreadable")
|
||||
|
||||
monkeypatch.setattr(agent_runtime, "_read_agent_revision", _boom)
|
||||
|
||||
with pytest.raises(agent_runtime.AgentRuntimeChangedError):
|
||||
agent_runtime.ensure_agent_runtime_current()
|
||||
Reference in new issue
Block a user