213 lines
10 KiB
Python
213 lines
10 KiB
Python
"""Bot Desktop invariants: the byte-level RFB input gate follows the lease, and computer_use refuses
|
|
every action (capture included) while a human holds the screen."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from tools.bot_desktop import lease
|
|
from tools.bot_desktop.rfb_filter import RfbClientFilter
|
|
|
|
_HANDSHAKE = b"RFB 003.008\n" + b"\x01" + b"\x00"
|
|
_KEY = b"\x04\x01\x00\x00\x00\x00\x00\x61" # KeyEvent 'a' down
|
|
# QEMU Extended KeyEvent (type 255, sub 0): what noVNC sends once Xvnc advertises the pseudo-encoding.
|
|
_QEMU_KEY = bytes([255, 0, 0, 1]) + (0x65).to_bytes(4, "big") + (0x12).to_bytes(4, "big")
|
|
_POINTER = b"\x05\x01\x00\x10\x00\x10" # PointerEvent, button 1
|
|
_CUT = b"\x06\x00\x00\x00\x00\x00\x00\x02hi" # ClientCutText "hi"
|
|
_FBUR = b"\x03\x00" + b"\x00" * 8 # FramebufferUpdateRequest
|
|
_SETENC = b"\x02\x00\x00\x02" + b"\x00\x00\x00\x07" + b"\xff\xff\xff\x21" # SetEncodings x2
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _fresh_lease():
|
|
from tools.computer_use import tool
|
|
|
|
tool.reset_backend_for_tests()
|
|
lease._reset_for_tests()
|
|
yield
|
|
tool.reset_backend_for_tests()
|
|
lease._reset_for_tests()
|
|
|
|
|
|
def test_rfb_filter_forwards_input_only_from_the_lease_holder_across_arbitrary_chunking():
|
|
f = RfbClientFilter(lambda: lease.viewer_may_send_input("v1"))
|
|
head = f.feed(_HANDSHAKE)
|
|
assert head[-1:] == b"\x01", "ClientInit is forced shared so a viewer never kicks the agent's watcher"
|
|
|
|
# Agent holds: read-only messages pass, input is dropped, even when split byte by byte.
|
|
stream = _KEY + _FBUR + _POINTER + _SETENC + _CUT + _QEMU_KEY
|
|
out = b"".join(f.feed(stream[i:i + 1]) for i in range(len(stream)))
|
|
assert out == _FBUR + _SETENC
|
|
|
|
lease.acquire("v1")
|
|
assert f.feed(_KEY + _POINTER + _QEMU_KEY) == _KEY + _POINTER + _QEMU_KEY
|
|
|
|
lease.acquire("v2") # last writer wins: v1 is evicted from input on the very next message
|
|
assert f.feed(_KEY) == b""
|
|
assert lease.release("v1").holder == lease.HUMAN, "a stale viewer's release must not yank control from v2"
|
|
assert lease.release("v2").holder == lease.AGENT
|
|
|
|
|
|
def test_computer_use_refuses_every_action_while_a_human_holds_the_screen(monkeypatch):
|
|
from tools.computer_use import tool
|
|
|
|
calls = []
|
|
monkeypatch.setattr(tool, "_get_backend", lambda session_id="": calls.append(session_id) or object())
|
|
lease.acquire("human")
|
|
for action in ("capture", "click", "type", "list_windows"):
|
|
res = json.loads(tool.handle_computer_use({"action": action, "text": "pw"}))
|
|
assert res["code"] == "human_has_control", action
|
|
assert calls == [], "the driver is never touched while the human may be typing a credential"
|
|
lease.release("human")
|
|
assert lease.get().holder == lease.AGENT
|
|
|
|
|
|
def test_lease_authority_is_shared_across_processes(tmp_path):
|
|
"""The gateway that streams the screen and the process running the agent are different processes;
|
|
a human takeover in one must refuse actions in the other."""
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
|
|
lease.acquire("desktop-viewer")
|
|
probe = ("import sys; sys.path.insert(0, %r)\n"
|
|
"from tools.bot_desktop import lease\n"
|
|
"try:\n lease.assert_agent_may_act(); print('AGENT')\n"
|
|
"except lease.HumanHasControl:\n print('HUMAN')\n"
|
|
"lease.release('desktop-viewer')\n") % os.getcwd()
|
|
out = subprocess.run([sys.executable, "-c", probe], capture_output=True, text=True, encoding="utf-8", timeout=30,
|
|
stdin=subprocess.DEVNULL, env={**os.environ, "HERMES_HOME": os.environ["HERMES_HOME"]})
|
|
assert out.stdout.strip() == "HUMAN", out.stderr
|
|
assert lease.get().holder == lease.AGENT, "the other process's release is visible here"
|
|
|
|
|
|
def test_takeover_during_an_admitted_action_discards_its_result(monkeypatch):
|
|
"""Approval / backend start-up can take seconds; a human who takes over meanwhile must not have
|
|
their keystrokes captured by an action admitted before they did."""
|
|
from tools.computer_use import tool
|
|
|
|
monkeypatch.setattr(tool, "_new_backend", lambda mode: tool._NoopBackend())
|
|
|
|
def _dispatch_then_takeover(backend, action, args, **_):
|
|
lease.acquire("human") # a whole take-over / hand-back cycle inside the driver call:
|
|
lease.release("human") # control is back, but the frame is still the human's turn
|
|
return json.dumps({"ok": True, "action": action, "png_b64": "SECRET"})
|
|
|
|
monkeypatch.setattr(tool, "_dispatch", _dispatch_then_takeover)
|
|
res = json.loads(tool.handle_computer_use({"action": "capture"}))
|
|
assert res["code"] == "human_has_control" and "SECRET" not in json.dumps(res)
|
|
|
|
|
|
def test_takeover_handback_during_approval_does_not_start_the_device_op(monkeypatch):
|
|
"""A full take-over / hand-back during approval leaves holder=agent, so assert_agent_may_act
|
|
succeeds. Input still belongs to the human's turn: compare admitted.epoch before _dispatch,
|
|
not only after. Do not patch _dispatch — a recording backend must never be called."""
|
|
from tools.computer_use import tool
|
|
|
|
class Rec(tool._NoopBackend):
|
|
def __init__(self):
|
|
self.calls = []
|
|
|
|
def click(self, **_kw):
|
|
self.calls.append("click")
|
|
return json.dumps({"ok": True, "action": "click"})
|
|
|
|
rec = Rec()
|
|
monkeypatch.setattr(tool, "_new_backend", lambda mode: rec)
|
|
|
|
def _approval_cycles_the_lease(scope, args, session_id=""):
|
|
lease.acquire("human")
|
|
lease.release("human")
|
|
return None
|
|
|
|
monkeypatch.setattr(tool, "_request_approval", _approval_cycles_the_lease)
|
|
res = json.loads(tool.handle_computer_use({"action": "click", "coordinate": [1, 1]}))
|
|
assert rec.calls == []
|
|
assert res.get("code") == "human_has_control"
|
|
|
|
|
|
def test_unreadable_lease_file_fails_closed_and_takeover_keeps_the_agents_reason(tmp_path):
|
|
"""Missing file = fresh profile (agent). A file that exists but cannot be parsed must not read as
|
|
"agent holds": a torn write must never let the agent act on a human's screen. Taking over after a
|
|
request keeps the agent's reason so the human still sees WHY while they act."""
|
|
from hermes_constants import hermes_home_key
|
|
|
|
home = str(tmp_path)
|
|
assert lease.get(profile_key=home).holder == lease.AGENT
|
|
path = lease._path(home)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text("{ torn", encoding="utf-8")
|
|
assert lease.get(profile_key=home).holder == lease.HUMAN
|
|
lease.release(profile_key=home) # a successful write repairs it
|
|
assert lease.get(profile_key=home).holder == lease.AGENT
|
|
|
|
# Valid JSON of the wrong shape is just as untrustworthy as torn JSON: never read it as "agent holds".
|
|
for wrong_shape in ("[]", "null", "5", "{}", '{"holder": "root"}'):
|
|
path.write_text(wrong_shape, encoding="utf-8")
|
|
assert lease.get(profile_key=home).holder == lease.HUMAN, wrong_shape
|
|
lease.release(profile_key=home)
|
|
assert lease.get(profile_key=home).holder == lease.AGENT
|
|
|
|
held = lease.acquire("desk-1", reason="log in to the bank, 2FA on your phone", profile_key=home)
|
|
assert held.reason == "log in to the bank, 2FA on your phone"
|
|
assert hermes_home_key(home) # sanity: the key derivation used by the bridge is available
|
|
|
|
|
|
def test_lease_works_without_fcntl(tmp_path):
|
|
"""Windows and fcntl-less hosts: ``computer_use`` imports the lease on EVERY call, so a module-level
|
|
fcntl dependency turns every desktop action into ModuleNotFoundError there. The file semantics must
|
|
still work; only the cross-process lock degrades. Subprocess so the module cache is clean."""
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
|
|
probe = ("import sys; sys.modules['fcntl'] = None; sys.path.insert(0, %r)\n"
|
|
"from tools.bot_desktop import lease\n"
|
|
"import tools.computer_use.tool\n"
|
|
"assert lease.get().holder == lease.AGENT\n"
|
|
"assert lease.acquire('v1').holder == lease.HUMAN\n"
|
|
"assert lease.get().holder == lease.HUMAN\n"
|
|
"assert lease.release('v1').holder == lease.AGENT\n"
|
|
"print('OK')\n") % os.getcwd()
|
|
out = subprocess.run([sys.executable, "-c", probe], capture_output=True, text=True, encoding="utf-8", timeout=60,
|
|
stdin=subprocess.DEVNULL, env={**os.environ, "HERMES_HOME": str(tmp_path)})
|
|
assert out.stdout.strip() == "OK", out.stderr
|
|
|
|
|
|
@pytest.mark.platforms("linux")
|
|
def test_lease_files_are_private_even_when_the_lease_is_written_before_the_screen_exists(tmp_path, monkeypatch):
|
|
"""A takeover can be recorded before start() ever created bot-desktop/ 0700. The lease path then created
|
|
the directory and files with the umask (0755 / 0644): who holds the screen, and the lock the RFB bridge
|
|
serialises on, readable and clobberable by every other local user. Every piece must be owner-only."""
|
|
import os
|
|
import stat
|
|
|
|
home = tmp_path / "deep" / "home"
|
|
home.mkdir(parents=True)
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
monkeypatch.setattr(lease, "get_hermes_home", lambda: home)
|
|
old = os.umask(0o022)
|
|
try:
|
|
lease.acquire("v1")
|
|
finally:
|
|
os.umask(old)
|
|
sd = home / "bot-desktop"
|
|
for path in (sd, sd / "lease.json", sd / "lease.lock"):
|
|
assert path.exists(), path
|
|
assert stat.S_IMODE(path.stat().st_mode) & 0o077 == 0, f"{path.name} is {oct(path.stat().st_mode)}"
|
|
|
|
|
|
def test_no_op_transitions_do_not_bump_the_epoch():
|
|
"""Callers void an admitted in-flight action when the epoch moved. A release on an agent-held lease
|
|
(double-clicked Hand back, a stray CLI stop) or the same viewer re-acquiring changes nothing real, so
|
|
it must not make a legitimate agent action look overtaken."""
|
|
e0 = lease.get().epoch
|
|
assert lease.release().epoch == e0
|
|
got = lease.acquire("v1", reason="log in please")
|
|
assert got.epoch == e0 + 1
|
|
again = lease.acquire("v1")
|
|
assert again.epoch == got.epoch and again.reason == "log in please" and again.since == got.since
|
|
assert lease.acquire("v2").epoch == got.epoch + 1 # a different viewer IS a transition
|