Fix guarded X11 text mapping without group switching

This commit is contained in:
Gerardo Betancourt committed 2026-10-28 09:07:05 -04:00
1 parent 7a27f7fd55
commit b283999f5a
7 files changed
+1857 -4

No files matched your search

+338
View File
@@ -0,0 +1,338 @@
"""Native-host TYPE interception tests with an entirely fake CUA/display.
No captured code, browser, display, library loader, or native input is used.
"""
import importlib.util
import ast
import ctypes as C
from pathlib import Path
import sys
import subprocess
from types import ModuleType, SimpleNamespace
import unittest
from unittest.mock import Mock
PACKAGE = "_framebridge_native_host_x11_test"
ROOT = Path(__file__).parent
if not (ROOT / "x11_text.py").is_file():
ROOT = ROOT.parent / "expanded/framebridge_expanded"
package = ModuleType(PACKAGE)
package.__path__ = [str(ROOT)]
display = ModuleType(PACKAGE + ".display_context")
class DisplayContextError(RuntimeError):
pass
display.DisplayContextError = DisplayContextError
display.check = lambda env: None
display.describe = lambda env: {"identity": "fixture-owner"}
sys.modules[PACKAGE] = package
sys.modules[display.__name__] = display
text_spec = importlib.util.spec_from_file_location(PACKAGE + ".x11_text", ROOT / "x11_text.py")
text_module = importlib.util.module_from_spec(text_spec)
sys.modules[text_spec.name] = text_module
text_spec.loader.exec_module(text_module)
spec = importlib.util.spec_from_file_location(PACKAGE + ".native_host", ROOT / "native_host.py")
native_host = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = native_host
spec.loader.exec_module(native_host)
class CuaTransportError(RuntimeError):
def __init__(self, code, detail=None):
super().__init__(code)
self.code = code
self.detail = detail
class FakeCua:
CuaTransportError = CuaTransportError
def __init__(self, *, with_base=False):
self.calls = []
self.context_calls = []
self.context = {"before_id": 101, "width": 1920, "height": 1080}
self.argv = None
self.mutate_context_after_capture = False
self.token = object()
if with_base:
self._run_v3_base = self._primitive
def _primitive(self, argv, timeout=15):
self.calls.append((list(argv), timeout))
return self.token
def _run(self, argv, timeout=15):
if hasattr(self, "_run_v3_base"):
return self._run_v3_base(argv, timeout=timeout)
return self._primitive(argv, timeout=timeout)
def _action_context(self, message, *args, **kwargs):
self.context_calls.append((message, args, kwargs))
return self.context
def handle_cua_message(self, message):
self._action_context(message)
if self.mutate_context_after_capture:
self.context.update(before_id=999, width=800, height=600)
argv = self.argv
if argv is None:
if message.get("action") == "TYPE":
argv = ["xdotool", "type", "--clearmodifiers", "--delay", "10", "--", message["text"]]
else:
argv = ["xdotool", "getactivewindow"]
return self._run(argv, timeout=7)
class NativeHostX11Tests(unittest.TestCase):
def install(self, cua=None, sender=None, checker=None):
cua = cua or FakeCua()
sender = sender or Mock(return_value=None)
checker = checker or Mock()
state = native_host.install_cua_guard(cua, env={"DISPLAY": ":fixture"},
checker=checker, text_sender=sender)
return cua, sender, checker, state
def test_exact_type_intercept_carries_original_context_and_timeout(self):
cua, sender, _, state = self.install()
result = cua.handle_cua_message({"action": "TYPE", "text": "-S /run/test"})
self.assertIsInstance(result, subprocess.CompletedProcess)
self.assertEqual(result.returncode, 0)
self.assertEqual(result.stdout, "")
self.assertEqual(result.stderr, "")
self.assertEqual(result.args, ["xdotool", "type", "--clearmodifiers", "--delay", "10", "--", "-S /run/test"])
self.assertEqual(cua.calls, [])
self.assertEqual(len(cua.context_calls), 1)
args, kwargs = sender.call_args
self.assertEqual(args, ("-S /run/test",))
self.assertEqual(kwargs["expected_window"], 101)
self.assertEqual(kwargs["expected_geometry"], (1920, 1080))
self.assertEqual(kwargs["timeout"], 7)
self.assertTrue(callable(kwargs["before_input"]))
self.assertFalse(state["input_started"])
def test_captured_context_is_a_snapshot_not_a_mutable_alias(self):
cua = FakeCua()
cua.mutate_context_after_capture = True
cua, sender, _, _ = self.install(cua)
cua.handle_cua_message({"action": "TYPE", "text": "/"})
self.assertEqual(sender.call_args.kwargs["expected_window"], 101)
self.assertEqual(sender.call_args.kwargs["expected_geometry"], (1920, 1080))
def test_intercepts_when_captured_runner_has_a_base_runner(self):
cua, sender, _, _ = self.install(FakeCua(with_base=True))
cua.handle_cua_message({"action": "TYPE", "text": "/"})
sender.assert_called_once()
self.assertEqual(cua.calls, [])
def test_unexpected_type_arguments_fail_closed(self):
forms = [
["xdotool", "type", "--delay", "10", "--", "/"],
["xdotool", "type", "--clearmodifiers", "--delay", "0", "--", "/"],
["xdotool", "type", "--clearmodifiers", "--delay", "10", "--", "/", "extra"],
["xdotool", "type", "--window", "999", "--", "/"],
["xdotool", "TYPE", "--clearmodifiers", "--delay", "10", "--", "/"],
]
for argv in forms:
with self.subTest(argv=argv):
cua = FakeCua()
cua.argv = argv
cua, sender, _, state = self.install(cua)
with self.assertRaises(CuaTransportError):
cua.handle_cua_message({"action": "TYPE", "text": "/"})
self.assertEqual(cua.calls, [])
sender.assert_not_called()
self.assertFalse(state["input_started"])
def test_missing_original_context_rejects_before_sender_or_runner(self):
cua = FakeCua()
cua.context = {}
cua, sender, _, state = self.install(cua)
with self.assertRaises(CuaTransportError):
cua.handle_cua_message({"action": "TYPE", "text": "/"})
sender.assert_not_called()
self.assertEqual(cua.calls, [])
self.assertFalse(state["input_started"])
def test_non_type_query_passes_through_and_stays_read_only(self):
cua, sender, _, state = self.install()
result = cua.handle_cua_message({"action": "QUERY"})
self.assertIs(result, cua.token)
self.assertEqual(cua.calls, [(["xdotool", "getactivewindow"], 7)])
sender.assert_not_called()
self.assertFalse(state["input_started"])
def test_non_type_query_with_invalid_action_context_still_passes_through(self):
cua = FakeCua()
cua.context = {"unrelated_query_context": True}
cua, sender, _, state = self.install(cua)
self.assertIs(cua.handle_cua_message({"action": "QUERY"}), cua.token)
self.assertEqual(cua.calls, [(["xdotool", "getactivewindow"], 7)])
sender.assert_not_called()
self.assertFalse(state["input_started"])
def test_search_literal_type_is_a_read_only_query(self):
cua = FakeCua()
cua.context = {"query_context": True}
cua.argv = ["xdotool", "search", "--name", "type"]
cua, sender, _, state = self.install(cua)
self.assertIs(cua.handle_cua_message({"action": "QUERY"}), cua.token)
self.assertEqual(cua.calls, [(["xdotool", "search", "--name", "type"], 7)])
sender.assert_not_called()
self.assertFalse(state["input_started"])
def test_clipboard_context_passes_through_without_clipboard_access(self):
cua = FakeCua()
cua.context = {"clipboard_context_is_not_type_keys_context": True}
cua.argv = ["xdotool", "key", "--clearmodifiers", "ctrl+v"]
cua, sender, _, state = self.install(cua)
result = cua.handle_cua_message({"action": "TYPE", "type_mode": "CLIPBOARD_EXACT", "text": "fixture"})
self.assertIs(result, cua.token)
self.assertEqual(cua.calls, [(["xdotool", "key", "--clearmodifiers", "ctrl+v"], 7)])
sender.assert_not_called()
self.assertTrue(state["input_started"])
def test_actual_frozen_type_consumes_completed_process_contract(self):
candidates = [Path(__file__).with_name("frozen_type_fixture.py")]
for parent in [ROOT, *ROOT.parents]:
candidates.append(parent / "sources/original-asus/framebridge-native/host/framebridge_cua_linux.py")
source = next((path for path in candidates if path.is_file()), None)
if source is None:
self.skipTest("captured original _type source is not available in this checkout")
tree = ast.parse(source.read_text())
definitions = [node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == "_type"]
self.assertTrue(definitions, str(source))
cua = FakeCua()
namespace = {"CuaTransportError": CuaTransportError,
"_run": lambda argv, timeout: cua._run(argv, timeout=timeout),
"_action_context": lambda message: cua._action_context(message),
"_evidence_lines": lambda context: []}
exec(compile(ast.Module(body=[definitions[-1]], type_ignores=[]), str(source), "exec"), namespace)
cua.handle_cua_message = namespace["_type"]
cua, sender, _, state = self.install(cua)
result = cua.handle_cua_message({"action": "TYPE", "text": "-S /run/fixture"})
self.assertTrue(result["ok"])
self.assertEqual(result["action"], "TYPE")
self.assertIn("RESULTADO_FINAL=CUA_TYPE_PASS", result["_frame_output"])
self.assertEqual(sender.call_args.kwargs["timeout"], 20)
self.assertEqual(cua.calls, [])
def test_non_type_input_keeps_existing_input_tracking(self):
cua = FakeCua()
cua.argv = ["xdotool", "key", "Return"]
cua, sender, _, state = self.install(cua)
self.assertIs(cua.handle_cua_message({"action": "KEY"}), cua.token)
self.assertEqual(cua.calls, [(["xdotool", "key", "Return"], 7)])
sender.assert_not_called()
self.assertTrue(state["input_started"])
def test_sender_preflight_failure_reports_no_input(self):
# Use the same error class the native sender exports, without loading
# or instantiating its native backend.
sender = Mock(side_effect=text_module.TextError("TYPE_TEXT_UNREPRESENTABLE_IN_CURRENT_GROUP"))
cua, _, _, state = self.install(sender=sender)
with self.assertRaises(CuaTransportError):
cua.handle_cua_message({"action": "TYPE", "text": "\u2603"})
self.assertFalse(state["input_started"])
self.assertEqual(cua.calls, [])
response = native_host.decorate_response({"executed": False}, state, {})
self.assertFalse(response["input_may_have_been_sent"])
self.assertFalse(response["do_not_replay"])
self.assertFalse(response["performed"])
def test_input_started_only_when_sender_calls_guard(self):
observed = []
holder = {}
def sender(text, **kwargs):
observed.append(holder["state"]["input_started"])
kwargs["before_input"]()
observed.append(holder["state"]["input_started"])
cua, _, _, state = self.install(sender=sender)
holder["state"] = state
cua.handle_cua_message({"action": "TYPE", "text": "a"})
self.assertEqual(observed, [False, True])
self.assertEqual(cua.calls, [])
def test_authority_failure_inside_sender_guard_sends_no_input(self):
gate = {"sender_active": False}
def checker(env):
if gate["sender_active"]:
raise DisplayContextError("IDENTITY_CHANGED")
def sender(text, **kwargs):
gate["sender_active"] = True
kwargs["before_input"]()
cua, _, _, state = self.install(sender=sender, checker=checker)
with self.assertRaises(CuaTransportError):
cua.handle_cua_message({"action": "TYPE", "text": "/"})
self.assertFalse(state["input_started"])
self.assertEqual(cua.calls, [])
def test_post_input_failure_has_do_not_replay_and_no_performed_false(self):
# The injected sender uses the module instance imported by native_host.
def sender(text, **kwargs):
kwargs["before_input"]()
raise text_module.TextError("TYPE_KEYMAP_CHANGED")
cua, _, _, state = self.install(sender=sender)
with self.assertRaises(CuaTransportError):
cua.handle_cua_message({"action": "TYPE", "text": "/"})
response = native_host.decorate_response({"executed": False}, state, {})
self.assertTrue(response["input_may_have_been_sent"])
self.assertTrue(response["do_not_replay"])
self.assertNotIn("performed", response)
self.assertEqual(cua.calls, [])
def test_successful_callback_marks_performed_in_existing_response(self):
sender = Mock(side_effect=lambda text, **kwargs: kwargs["before_input"]())
cua, _, _, state = self.install(sender=sender)
cua.handle_cua_message({"action": "TYPE", "text": "/"})
response = native_host.decorate_response({"executed": True}, state, {})
self.assertTrue(response["performed"])
self.assertTrue(response["input_may_have_been_sent"])
self.assertFalse(response["do_not_replay"])
def test_new_message_does_not_reuse_previous_action_context(self):
cua, sender, _, state = self.install()
cua.handle_cua_message({"action": "TYPE", "text": "first"})
native_host.decorate_response({"executed": True}, state, {})
cua.context = {}
with self.assertRaises(CuaTransportError):
cua.handle_cua_message({"action": "TYPE", "text": "second"})
self.assertEqual(sender.call_count, 1)
self.assertEqual(cua.calls, [])
def test_native_snapshot_rejects_held_shift_before_any_input(self):
backend = object.__new__(text_module.NativeBackend)
backend.display = 1
backend.context = 1
backend.modifier_codes = {50}
def keymap(display_id, buffer):
data = bytearray(32)
data[50 // 8] = 1 << (50 % 8)
C.memmove(buffer, bytes(data), 32)
return 1
def active_window(context, result):
result._obj.value = 101
return 0
backend.x11 = SimpleNamespace(XkbGetState=Mock(return_value=0),
XQueryKeymap=keymap, XDefaultScreen=Mock())
backend.xdo = SimpleNamespace(xdo_get_active_window=active_window)
backend.xtst = SimpleNamespace(XTestFakeKeyEvent=Mock())
with self.assertRaisesRegex(text_module.TextError, "^TYPE_HELD_MODIFIERS_UNSUPPORTED$"):
backend.snapshot()
backend.xtst.XTestFakeKeyEvent.assert_not_called()
backend.x11.XDefaultScreen.assert_not_called()
if __name__ == "__main__":
unittest.main()