FrameBridge 0.3.1 community source release
This commit is contained in:
commit
b23819fff1
1354 files changed
+343716
No files matched your search
@@ -0,0 +1,158 @@
|
||||
"""Adaptive text/graphical selection; inert CUA stubs never send user input."""
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from framebridge_expanded import adapter
|
||||
from framebridge_expanded import config
|
||||
from framebridge_expanded import display_context as dc
|
||||
from framebridge_expanded import runtime
|
||||
|
||||
|
||||
def physical(**changes):
|
||||
data=dict(kind='physical-x11',physical=True,session_id='fixture-session',uid=os.getuid(),
|
||||
leader=123,active_vt='tty7',seat='seat0',vtnr=7,display=':0',authority='/private/fixture.Xauthority')
|
||||
data.update(changes)
|
||||
return data
|
||||
|
||||
|
||||
def capture_output(valid=True):
|
||||
if not valid: return 'VISUAL_CAPTURE_GATE=PASS\nMIME=image/jpeg\nBASE64=invalid'
|
||||
pixels=b'\xff\xd8synthetic-unit-fixture\xff\xd9'
|
||||
return '\n'.join(['RESULTADO_FINAL=CUA_VISUAL_OBSERVE_PASS','VISUAL_CAPTURE_GATE=PASS','MIME=image/jpeg','BYTES='+str(len(pixels)),
|
||||
'SHA256='+hashlib.sha256(pixels).hexdigest(),'BASE64='+base64.b64encode(pixels).decode()])
|
||||
|
||||
|
||||
class AdaptiveDisplayTests(unittest.TestCase):
|
||||
def test_auto_missing_graphical_tools_selects_text_and_clears_inherited_display(self):
|
||||
env={'DISPLAY':':99','XAUTHORITY':'/unrelated'}
|
||||
with patch.object(dc,'_physical',return_value=physical()), patch.object(dc,'missing_graphical_commands',return_value=['xdotool']):
|
||||
dc.configure(env,'auto','/profile')
|
||||
self.assertEqual(dc.describe(env)['kind'],'text')
|
||||
self.assertNotIn('DISPLAY',env)
|
||||
self.assertNotIn('XAUTHORITY',env)
|
||||
self.assertFalse(dc.private_display_required(env,'auto',False))
|
||||
|
||||
def test_text_to_graphical_and_back_preserves_profile_and_requires_new_observation(self):
|
||||
env={};dc.bind_text(env,'/profile')
|
||||
with patch.object(dc,'_physical',return_value=physical()),patch.object(dc,'missing_graphical_commands',return_value=[]):
|
||||
dc.prepare(env)
|
||||
self.assertEqual(dc.check(env)['kind'],'physical-x11')
|
||||
self.assertTrue(dc.describe(env)['needs_observation'])
|
||||
dc.prepare(env)
|
||||
self.assertTrue(dc.describe(env)['needs_observation'])
|
||||
with patch.object(dc,'_physical',side_effect=dc.DisplayContextError('NO_SESSION')):
|
||||
dc.prepare(env)
|
||||
self.assertEqual(dc.describe(env)['kind'],'text')
|
||||
self.assertEqual(dc.describe(env)['profile'],'/profile')
|
||||
with self.assertRaisesRegex(dc.DisplayContextError,'CUA_DISPLAY_UNAVAILABLE'):
|
||||
dc.check(env)
|
||||
|
||||
def stub(self,env,valid=True):
|
||||
calls=[]
|
||||
engine=SimpleNamespace()
|
||||
def native(payload,timeout_s=60):
|
||||
calls.append(payload)
|
||||
return {'executed':True,'output':capture_output(valid)}
|
||||
engine.native_cua_roundtrip=native
|
||||
def handle(request,job):
|
||||
response=engine.native_cua_roundtrip(request.get('cua',{}))
|
||||
return {'ok':response['executed']}
|
||||
engine.handle_cua_request=handle
|
||||
adapter.install_adapter_guard(engine,env=env)
|
||||
return engine,calls
|
||||
|
||||
def test_transition_blocks_input_until_successful_observation_then_allows_input(self):
|
||||
env={};dc.bind_text(env,'/profile');engine,calls=self.stub(env)
|
||||
with patch.object(dc,'_physical',return_value=physical()),patch.object(dc,'missing_graphical_commands',return_value=[]):
|
||||
blocked=engine.handle_cua_request({'cua':{'action':'CLICK'}},'job1')
|
||||
self.assertFalse(blocked['performed']);self.assertEqual(calls,[])
|
||||
self.assertEqual(blocked['error'],'CUA_DISPLAY_CHANGED_OBSERVE_REQUIRED')
|
||||
observed=engine.handle_cua_request({'cua':{'action':'OBSERVE'}},'job2')
|
||||
self.assertTrue(observed['ok']);self.assertFalse(dc.describe(env).get('needs_observation',False))
|
||||
self.assertTrue(engine.handle_cua_request({'cua':{'action':'CLICK'}},'job3')['ok'])
|
||||
self.assertEqual(len(calls),2)
|
||||
|
||||
def test_invalid_observation_does_not_unlock_input_after_transition(self):
|
||||
env={};dc.bind_text(env,'/profile');engine,calls=self.stub(env,valid=False)
|
||||
with patch.object(dc,'_physical',return_value=physical()),patch.object(dc,'missing_graphical_commands',return_value=[]):
|
||||
engine.handle_cua_request({'cua':{'action':'OBSERVE'}},'job1')
|
||||
self.assertTrue(dc.describe(env)['needs_observation'])
|
||||
blocked=engine.handle_cua_request({'cua':{'action':'CLICK'}},'job2')
|
||||
self.assertFalse(blocked['performed']);self.assertEqual(len(calls),1)
|
||||
|
||||
def test_session_changes_during_observation_keep_input_locked(self):
|
||||
env={};dc.bind_text(env,'/profile');engine,calls=self.stub(env)
|
||||
with patch.object(dc,'_physical',side_effect=[physical(),physical(),physical(session_id='different')]),patch.object(dc,'missing_graphical_commands',return_value=[]):
|
||||
result=engine.handle_cua_request({'cua':{'action':'OBSERVE'}},'job1')
|
||||
self.assertFalse(result['ok'])
|
||||
self.assertEqual(result['error'],'CUA_FOREGROUND_SESSION_CHANGED')
|
||||
self.assertTrue(dc.describe(env)['needs_observation'])
|
||||
|
||||
def test_session_change_at_observation_release_returns_safe_block_and_keeps_input_locked(self):
|
||||
env={};dc.bind_text(env,'/profile');engine,calls=self.stub(env)
|
||||
with patch.object(dc,'_physical',side_effect=[physical(),physical(),physical(),physical(session_id='different')]),patch.object(dc,'missing_graphical_commands',return_value=[]):
|
||||
result=engine.handle_cua_request({'cua':{'action':'OBSERVE'}},'job1')
|
||||
self.assertFalse(result['ok'])
|
||||
self.assertFalse(result['performed'])
|
||||
self.assertFalse(result['input_may_have_been_sent'])
|
||||
self.assertEqual(result['error'],'CUA_FOREGROUND_SESSION_CHANGED')
|
||||
self.assertTrue(dc.describe(env)['needs_observation'])
|
||||
with patch.object(dc,'_physical',return_value=physical(session_id='different')),patch.object(dc,'missing_graphical_commands',return_value=[]):
|
||||
blocked=engine.handle_cua_request({'cua':{'action':'CLICK'}},'job2')
|
||||
self.assertFalse(blocked['performed'])
|
||||
self.assertEqual(blocked['error'],'CUA_DISPLAY_CHANGED_OBSERVE_REQUIRED')
|
||||
self.assertEqual(len(calls),1)
|
||||
|
||||
|
||||
class AdaptiveCoreTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp=tempfile.TemporaryDirectory(prefix='fb-adaptive-test-')
|
||||
self.state=Path(self.temp.name)
|
||||
config.save_config(self.state,config.default_config())
|
||||
self.env={'PATH':'/fixture/bin'}
|
||||
dc.bind_text(self.env,self.state)
|
||||
|
||||
def tearDown(self): self.temp.cleanup()
|
||||
|
||||
def doctor(self,missing):
|
||||
def which(name,path=None): return None if name in missing else '/fixture/bin/'+name
|
||||
with patch.object(runtime,'environment',return_value=dict(self.env)),patch.object(runtime.shutil,'which',side_effect=which),patch('framebridge_expanded.network.inspect',return_value={}):
|
||||
return runtime.doctor(self.state)
|
||||
|
||||
def test_optional_gui_dependencies_do_not_block_text_tools(self):
|
||||
result=self.doctor(set(dc.GRAPHICAL_COMMANDS+dc.VIRTUAL_COMMANDS+('magick','convert','gnome-terminal','xterm')))
|
||||
self.assertTrue(result['ok'])
|
||||
self.assertEqual(result['missing_commands'],[])
|
||||
self.assertTrue(result['capabilities']['fbexec'])
|
||||
self.assertTrue(result['capabilities']['fbterm'])
|
||||
self.assertTrue(result['capabilities']['cua_tty_module'])
|
||||
self.assertFalse(result['capabilities']['graphical_cua'])
|
||||
self.assertTrue(result['optional_missing_commands']['graphical_cua'])
|
||||
|
||||
def test_missing_core_command_still_blocks_startup(self):
|
||||
result=self.doctor({'bash'})
|
||||
self.assertFalse(result['ok']);self.assertIn('bash',result['missing_commands'])
|
||||
|
||||
def test_explicit_private_desktop_requires_its_optional_components(self):
|
||||
current=config.load_config(self.state);current['headless']='yes';config.save_config(self.state,current)
|
||||
result=self.doctor({'Xvfb'})
|
||||
self.assertFalse(result['ok']);self.assertIn('Xvfb',result['missing_commands'])
|
||||
|
||||
def test_text_manager_starts_core_session_without_xvfb_or_window_manager(self):
|
||||
spawned=[]
|
||||
def spawn(command,env,state,role):
|
||||
spawned.append((command,role))
|
||||
return SimpleNamespace(poll=lambda:None),{'role':role}
|
||||
with patch.object(runtime,'environment',return_value=dict(self.env)),patch.object(runtime,'_display_ok',return_value=False),patch.object(runtime,'_spawn',side_effect=spawn),patch.object(runtime,'_atomic'),patch.object(runtime,'_terminate'),patch.object(runtime.signal,'signal'),patch.object(runtime,'_STOP',True):
|
||||
runtime._manager(self.state)
|
||||
self.assertEqual([role for _,role in spawned],['private-session'])
|
||||
self.assertEqual(spawned[0][0][0],'dbus-run-session')
|
||||
|
||||
|
||||
if __name__=='__main__': unittest.main()
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Pure guide/catalog checks; no server, connection or node is started."""
|
||||
import ast
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from framebridge_expanded import __version__
|
||||
from framebridge_expanded import guide
|
||||
|
||||
SOURCE=Path(__file__).resolve().parents[1]
|
||||
FRONT=SOURCE/'portable/front_door_mcp.py'
|
||||
|
||||
class FakeMCP:
|
||||
def __init__(self,name,**kwargs):
|
||||
self.name=name;self.options=kwargs
|
||||
self._mcp_server=SimpleNamespace(version=None)
|
||||
self._tool_manager=SimpleNamespace(_tools={})
|
||||
self.annotations={}
|
||||
def tool(self,**kwargs):
|
||||
def register(fn):
|
||||
self._tool_manager._tools[fn.__name__]=fn
|
||||
self.annotations[fn.__name__]=kwargs.get('annotations')
|
||||
return fn
|
||||
return register
|
||||
|
||||
def guide_surface():
|
||||
tree=ast.parse(FRONT.read_text())
|
||||
names={'_registered_operations','framebridge_help','framebridge_status'}
|
||||
keep=[]
|
||||
for node in tree.body:
|
||||
if isinstance(node,ast.Assign) and (
|
||||
any(isinstance(t,ast.Name) and t.id=='mcp' for t in node.targets)
|
||||
or any(isinstance(t,ast.Attribute) and t.attr=='version' for t in node.targets)):
|
||||
keep.append(copy.deepcopy(node))
|
||||
elif isinstance(node,ast.FunctionDef) and node.name in names:
|
||||
keep.append(copy.deepcopy(node))
|
||||
ns={'FastMCP':FakeMCP,'ToolAnnotations':lambda **kw:kw,'PRODUCT_VERSION':__version__,
|
||||
'GUIDE_SCHEMA':guide.GUIDE_SCHEMA,'get_guide':guide.get_guide,
|
||||
'socket':SimpleNamespace(gethostname=lambda:'fixture-receiver'),'HOST':'127.0.0.1','PORT':20865,
|
||||
'ADAPTER_SOCKET':SimpleNamespace(is_socket=lambda:False)}
|
||||
exec(compile(ast.fix_missing_locations(ast.Module(body=keep,type_ignores=[])),str(FRONT),'exec'),ns)
|
||||
return ns
|
||||
|
||||
class GuideTests(unittest.TestCase):
|
||||
def test_guide_versions_topics_and_bounds(self):
|
||||
self.assertEqual(len(guide.topics()),10)
|
||||
for topic in (*guide.topics(),'all'):
|
||||
value=guide.get_guide(topic)
|
||||
self.assertTrue(value['ok'])
|
||||
self.assertEqual(value['version'],__version__)
|
||||
self.assertTrue(value['read_only'])
|
||||
self.assertFalse(value['capabilities_verified'])
|
||||
self.assertLess(len(json.dumps(value).encode()),65536)
|
||||
self.assertRegex(value['guide_sha256'],r'^[a-f0-9]{64}$')
|
||||
def test_guide_reads_no_profile_or_network(self):
|
||||
with mock.patch('builtins.open',side_effect=AssertionError('no file reads')), \
|
||||
mock.patch('subprocess.run',side_effect=AssertionError('no processes')), \
|
||||
mock.patch('socket.socket',side_effect=AssertionError('no sockets')):
|
||||
self.assertTrue(guide.get_guide('start')['ok'])
|
||||
def test_invalid_topic_is_bounded_actionable(self):
|
||||
self.assertEqual(guide.get_guide('missing')['error'],'HELP_TOPIC_UNKNOWN')
|
||||
self.assertEqual(guide.get_guide('x'*65)['error'],'HELP_TOPIC_INVALID')
|
||||
with self.assertRaises(ValueError):guide.render_text('missing')
|
||||
def test_guides_have_no_fixed_owner_paths_or_account(self):
|
||||
text=json.dumps(guide.export_guide())
|
||||
for marker in ['/home/gerabh','/home/gera/','gerabh1128@','GERAMEG','ASUS','GERANAS']:
|
||||
self.assertNotIn(marker,text)
|
||||
def test_human_agent_and_transcript_boundary_are_explicit(self):
|
||||
text=guide.render_text('all')
|
||||
self.assertIn('human operators and authorized agents',text)
|
||||
self.assertIn('terminal transcript',text)
|
||||
self.assertIn('not an authentic full-screen image',text)
|
||||
self.assertIn('normal operating-system authentication',text)
|
||||
self.assertIn('does not restrict GNU GPL freedoms',text)
|
||||
def test_export_matches_installed_web_inputs(self):
|
||||
data=json.loads((SOURCE/'docs/COMMAND-GUIDE.json').read_text())
|
||||
self.assertEqual(data,guide.export_guide())
|
||||
self.assertIn('FrameBridge '+__version__,(SOURCE/'docs/framebridge.1').read_text())
|
||||
self.assertNotIn('Reconnecting requires entering the runtime key again',
|
||||
(SOURCE/'docs/framebridge.1').read_text())
|
||||
def test_development_guidance_preserves_license_freedom(self):
|
||||
text=guide.render_text('security')
|
||||
self.assertIn('separate source clone',text)
|
||||
self.assertIn('do not present a modified build as an official release',text)
|
||||
self.assertIn('GNU GPLv3-only',text)
|
||||
|
||||
class MCPGuideSurfaceTests(unittest.TestCase):
|
||||
def test_init_invites_learning_and_has_product_version(self):
|
||||
ns=guide_surface();mcp=ns['mcp']
|
||||
self.assertIn("framebridge_help(topic='start')",mcp.options['instructions'])
|
||||
self.assertEqual(mcp._mcp_server.version,__version__)
|
||||
self.assertEqual(mcp.name,'FrameBridge')
|
||||
def test_help_is_registered_readonly_and_bounded(self):
|
||||
ns=guide_surface();value=ns['framebridge_help']('start')
|
||||
self.assertTrue(value['ok']);self.assertEqual(value['receiver'],'fixture-receiver')
|
||||
self.assertIn('framebridge_help',value['registered_operations'])
|
||||
self.assertTrue(ns['mcp'].annotations['framebridge_help']['readOnlyHint'])
|
||||
self.assertNotIn('adapter_socket',value)
|
||||
def test_status_query_success_is_not_adapter_readiness(self):
|
||||
ns=guide_surface();value=ns['framebridge_status']()
|
||||
self.assertTrue(value['query_success'])
|
||||
self.assertFalse(value['adapter_socket_present'])
|
||||
self.assertEqual(value['adapter_ready'],'not_checked')
|
||||
self.assertEqual(value['version'],__version__)
|
||||
self.assertNotIn('adapter_socket',value)
|
||||
def test_historical_lab_tools_are_not_publicly_decorated(self):
|
||||
tree=ast.parse(FRONT.read_text())
|
||||
byname={n.name:n for n in tree.body if isinstance(n,(ast.FunctionDef,ast.AsyncFunctionDef))}
|
||||
for name in ['framebridge_exec_geranas','framebridge_cloud_dsm']:
|
||||
self.assertFalse(byname[name].decorator_list)
|
||||
self.assertTrue(byname['framebridge_exec'].decorator_list)
|
||||
self.assertTrue(byname['framebridge_help'].decorator_list)
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,660 @@
|
||||
import fcntl
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from framebridge_expanded import autostart, config, runtime, connection_background
|
||||
|
||||
ID = "tunnel_0123456789abcdef0123456789abcdef"
|
||||
MARKER = "FRAMEBRIDGE-SYNTHETIC-NOT-A-REAL-KEY-AUTOSTART"
|
||||
|
||||
|
||||
class AutostartTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.home = self.root / "home"
|
||||
self.home.mkdir(mode=0o700)
|
||||
self.state = self.home / "profile"
|
||||
self.settings = config.default_config()
|
||||
self.settings.update(tunnel_id=ID, headless="no")
|
||||
config.save_config(self.state, self.settings)
|
||||
self.addCleanup(mock.patch.stopall)
|
||||
mock.patch.object(Path, "home", return_value=self.home).start()
|
||||
self.calls = []
|
||||
self.system_calls = []
|
||||
self.command = mock.patch.object(autostart, "_command", side_effect=self.fake_command).start()
|
||||
mock.patch.object(autostart, "_systemctl", side_effect=self.fake_systemctl).start()
|
||||
mock.patch.object(autostart, "_linger", return_value=False).start()
|
||||
mock.patch.object(autostart, "_enabled_units", return_value=[]).start()
|
||||
mock.patch.object(autostart.os, "geteuid", return_value=1000).start()
|
||||
mock.patch.object(autostart.resource, "setrlimit").start()
|
||||
self.inputs = mock.patch("builtins.input", side_effect=["y"]).start()
|
||||
self.key_prompt = mock.patch.object(autostart.getpass, "getpass", return_value=MARKER).start()
|
||||
mock.patch.object(autostart, "status", return_value={"configured": True}).start()
|
||||
mock.patch("sys.stdout", new_callable=io.StringIO).start()
|
||||
|
||||
def fake_command(self, args, **kwargs):
|
||||
self.calls.append((args, kwargs))
|
||||
if args[:2] == ["systemd-creds", "--version"]:
|
||||
return subprocess.CompletedProcess(args, 0, b"systemd 257\n", b"")
|
||||
if "encrypt" in args:
|
||||
return subprocess.CompletedProcess(args, 0, b"CIPHERTEXT-ONLY-" * 20, b"")
|
||||
return subprocess.CompletedProcess(args, 0, b"", b"")
|
||||
|
||||
def fake_systemctl(self, *args, **kwargs):
|
||||
self.system_calls.append(args)
|
||||
return subprocess.CompletedProcess(args, 0, b"", b"")
|
||||
|
||||
def test_enable_passes_key_only_by_anonymous_input_and_enables_boot_fallback(self):
|
||||
before = (self.state / "config.json").read_bytes()
|
||||
autostart.enable(self.state)
|
||||
state, directory, unit = autostart._paths(self.state)
|
||||
self.assertNotEqual(before, (self.state / "config.json").read_bytes())
|
||||
self.assertEqual("auto", config.load_config(self.state)["headless"])
|
||||
encryption = [(a, kw) for a, kw in self.calls if "encrypt" in a]
|
||||
self.assertEqual(1, len(encryption))
|
||||
args, options = encryption[0]
|
||||
self.assertEqual(MARKER.encode(), options["data"])
|
||||
self.assertNotIn(MARKER, " ".join(args))
|
||||
self.assertIn("--with-key=host", args)
|
||||
self.assertIn("--uid=" + str(os.getuid()), args)
|
||||
for path in [directory / "credential.cred", directory / "installation.json", unit]:
|
||||
self.assertEqual(0o600, stat.S_IMODE(path.stat().st_mode))
|
||||
self.assertNotIn(MARKER.encode(), path.read_bytes())
|
||||
self.assertNotIn("LoadCredentialEncrypted=", unit.read_text())
|
||||
self.assertIn("StandardInput=null", unit.read_text())
|
||||
self.assertIn(("enable", "--now", unit.name), self.system_calls)
|
||||
autostart.enable(self.state)
|
||||
self.assertEqual(1, self.key_prompt.call_count)
|
||||
self.inputs.assert_called_once_with("Enable automatic startup? [y/N]: ")
|
||||
|
||||
def test_boot_fallback_restores_only_unchanged_profile_on_disable(self):
|
||||
self.inputs.side_effect = ["y"]
|
||||
autostart.enable(self.state)
|
||||
self.assertEqual("auto", config.load_config(self.state)["headless"])
|
||||
result = autostart.disable(self.state)
|
||||
self.assertTrue(result["desktop_setting_restored"])
|
||||
self.assertTrue(result["linger_restored"])
|
||||
self.assertEqual("no", config.load_config(self.state)["headless"])
|
||||
_, directory, unit = autostart._paths(self.state)
|
||||
self.assertFalse(unit.exists())
|
||||
self.assertFalse((directory / "credential.cred").exists())
|
||||
|
||||
def test_disable_keeps_desktop_changes_made_after_enable(self):
|
||||
self.inputs.side_effect = ["y"]
|
||||
autostart.enable(self.state)
|
||||
settings = config.load_config(self.state)
|
||||
settings["name"] = "changed-later"
|
||||
config.save_config(self.state, settings)
|
||||
result = autostart.disable(self.state)
|
||||
self.assertFalse(result["desktop_setting_restored"])
|
||||
self.assertEqual("changed-later", config.load_config(self.state)["name"])
|
||||
self.assertEqual("auto", config.load_config(self.state)["headless"])
|
||||
|
||||
def test_modified_unit_is_not_stopped_or_removed(self):
|
||||
autostart.enable(self.state)
|
||||
_, _, unit = autostart._paths(self.state)
|
||||
unit.write_text(unit.read_text() + "# external edit\n")
|
||||
self.system_calls.clear()
|
||||
with self.assertRaisesRegex(RuntimeError, "changed outside"):
|
||||
autostart.disable(self.state)
|
||||
self.assertEqual([], self.system_calls)
|
||||
self.assertTrue(unit.exists())
|
||||
|
||||
def test_encryption_failure_never_creates_unit_or_disables_an_existing_service(self):
|
||||
def failing(args, **kwargs):
|
||||
if "encrypt" in args:
|
||||
raise RuntimeError("Credential encryption failed.")
|
||||
return self.fake_command(args, **kwargs)
|
||||
self.command.side_effect = failing
|
||||
with self.assertRaisesRegex(RuntimeError, "encryption failed"):
|
||||
autostart.enable(self.state)
|
||||
_, directory, unit = autostart._paths(self.state)
|
||||
self.assertFalse(unit.exists())
|
||||
self.assertFalse((directory / "credential.cred").exists())
|
||||
self.assertFalse(any(x[0] == "disable" for x in self.system_calls))
|
||||
|
||||
def test_cancel_never_requests_credentials_or_privileges(self):
|
||||
self.inputs.side_effect = ["cancel"]
|
||||
result = autostart.enable(self.state)
|
||||
self.assertEqual("cancelled", result["state"])
|
||||
self.key_prompt.assert_not_called()
|
||||
self.assertFalse(any(a[0] == "sudo" for a, _ in self.calls))
|
||||
|
||||
def test_default_no_keeps_profile_unchanged_without_privileges_or_key(self):
|
||||
before = (self.state / "config.json").read_bytes()
|
||||
self.inputs.side_effect = [""]
|
||||
result = autostart.enable(self.state)
|
||||
self.assertEqual({"configured": False, "state": "cancelled"}, result)
|
||||
self.assertEqual(before, (self.state / "config.json").read_bytes())
|
||||
self.key_prompt.assert_not_called()
|
||||
self.assertFalse(any(args[0] == "sudo" for args, _ in self.calls))
|
||||
self.assertEqual([], self.system_calls)
|
||||
_, directory, unit = autostart._paths(self.state)
|
||||
self.assertFalse(unit.exists())
|
||||
self.assertFalse((directory / "credential.cred").exists())
|
||||
|
||||
def test_natural_confirmation_is_case_insensitive_and_requested_once(self):
|
||||
self.inputs.side_effect = [" YeS "]
|
||||
autostart.enable(self.state)
|
||||
autostart.enable(self.state)
|
||||
self.inputs.assert_called_once_with("Enable automatic startup? [y/N]: ")
|
||||
self.key_prompt.assert_called_once_with("Connection key (hidden): ")
|
||||
|
||||
def test_setup_shows_bounded_progress_without_claiming_connected(self):
|
||||
output = io.StringIO()
|
||||
with mock.patch("sys.stdout", output):
|
||||
autostart.enable(self.state)
|
||||
text = output.getvalue()
|
||||
self.assertLess(text.index("[1/3]"), text.index("[2/3]"))
|
||||
self.assertLess(text.index("[2/3]"), text.index("[3/3]"))
|
||||
self.assertLessEqual(len(text.splitlines()), 6)
|
||||
self.assertTrue(all(len(line) <= 80 for line in text.splitlines()))
|
||||
self.assertNotIn(MARKER, text)
|
||||
self.assertNotIn("connected", text.lower())
|
||||
self.assertNotIn("systemd", text)
|
||||
self.assertNotIn("physical-only", text)
|
||||
self.assertNotIn("Root access", text)
|
||||
|
||||
def test_eof_or_interrupt_at_confirmation_cancels_without_authentication(self):
|
||||
for cancellation in (EOFError, KeyboardInterrupt):
|
||||
with self.subTest(cancellation=cancellation.__name__):
|
||||
self.inputs.side_effect = cancellation
|
||||
result = autostart.enable(self.state)
|
||||
self.assertEqual("cancelled", result["state"])
|
||||
self.assertFalse(result["configured"])
|
||||
self.key_prompt.assert_not_called()
|
||||
self.assertFalse(any(args[0] == "sudo" for args, _ in self.calls))
|
||||
self.assertEqual([], self.system_calls)
|
||||
|
||||
def _durable_settings(self, identifier=ID):
|
||||
return {"account_label": "owner@example.test", "tunnel_id": identifier,
|
||||
"control_plane_organization_id": "", "control_plane_url_path": ""}
|
||||
|
||||
def _durable_connect(self, key=MARKER, settings=None):
|
||||
with mock.patch.object(connection_background, "status", return_value={"alive": False}), mock.patch.object(connection_background, "disconnect", return_value={"ok": True, "alive": False}):
|
||||
return autostart.configure_connection(self.state, settings or self._durable_settings(), key)
|
||||
|
||||
def test_durable_connection_uses_supplied_key_once_without_extra_questions(self):
|
||||
self._durable_connect()
|
||||
self.inputs.assert_not_called()
|
||||
self.key_prompt.assert_not_called()
|
||||
encryptions = [(args, kw) for args, kw in self.calls if "encrypt" in args]
|
||||
self.assertEqual(1, len(encryptions))
|
||||
self.assertEqual(MARKER.encode(), encryptions[0][1]["data"])
|
||||
self.assertNotIn(MARKER, " ".join(encryptions[0][0]))
|
||||
_, directory, unit = autostart._paths(self.state)
|
||||
for path in (self.state / "config.json", directory / "credential.cred", directory / "installation.json", unit):
|
||||
self.assertNotIn(MARKER.encode(), path.read_bytes())
|
||||
self.assertEqual("auto", config.load_config(self.state)["headless"])
|
||||
self.assertIn(("enable", "--now", unit.name), self.system_calls)
|
||||
|
||||
def test_interrupt_reading_status_after_commit_keeps_enabled_service_and_reports_it(self):
|
||||
with mock.patch.object(autostart, "status", side_effect=KeyboardInterrupt):
|
||||
result = self._durable_connect()
|
||||
self.assertEqual({"configured": True, "enabled": True, "interrupted_after_commit": True}, result)
|
||||
_, directory, unit = autostart._paths(self.state)
|
||||
self.assertTrue(unit.exists())
|
||||
self.assertTrue((directory / "credential.cred").exists())
|
||||
self.assertTrue((directory / "installation.json").exists())
|
||||
self.assertIn(("enable", "--now", unit.name), self.system_calls)
|
||||
self.assertNotIn(("disable", "--now", unit.name), self.system_calls)
|
||||
|
||||
def test_durable_connection_can_create_default_profile_in_the_transaction(self):
|
||||
(self.state / "config.json").unlink()
|
||||
self._durable_connect()
|
||||
selected = config.load_config(self.state)
|
||||
self.assertEqual("standalone", selected["role"])
|
||||
self.assertEqual("auto", selected["headless"])
|
||||
self.assertEqual("owner@example.test", selected["account_label"])
|
||||
self.assertEqual(ID, selected["tunnel_id"])
|
||||
|
||||
def test_durable_encryption_failure_leaves_current_profile_and_service_alone(self):
|
||||
original = (self.state / "config.json").read_bytes()
|
||||
def failed(args, **kwargs):
|
||||
if "encrypt" in args:
|
||||
raise RuntimeError("Protection failed.")
|
||||
return self.fake_command(args, **kwargs)
|
||||
self.command.side_effect = failed
|
||||
with mock.patch.object(connection_background, "disconnect") as disconnect:
|
||||
with self.assertRaisesRegex(RuntimeError, "Protection failed"):
|
||||
self._durable_connect()
|
||||
self.assertEqual(original, (self.state / "config.json").read_bytes())
|
||||
self.assertFalse(any(args[0] in {"stop", "enable", "disable"} for args in self.system_calls))
|
||||
_, directory, unit = autostart._paths(self.state)
|
||||
self.assertFalse(unit.exists())
|
||||
self.assertFalse((directory / "credential.cred").exists())
|
||||
|
||||
def test_durable_replacement_encrypts_before_stop_and_replaces_public_identity(self):
|
||||
self._durable_connect()
|
||||
events = []
|
||||
original_command = self.fake_command
|
||||
def command(args, **kwargs):
|
||||
if "encrypt" in args:
|
||||
events.append("encrypt")
|
||||
return subprocess.CompletedProcess(args, 0, b"REPLACEMENT-CIPHERTEXT-" * 20, b"")
|
||||
return original_command(args, **kwargs)
|
||||
def service(*args, **kwargs):
|
||||
events.append(args[0])
|
||||
if args[0] == "show":
|
||||
return subprocess.CompletedProcess(args, 0, b"ActiveState=active\nUnitFileState=enabled\n", b"")
|
||||
return self.fake_systemctl(*args, **kwargs)
|
||||
live = {"alive": True}
|
||||
def disconnect(state):
|
||||
events.append("disconnect")
|
||||
live["alive"] = False
|
||||
return {"ok": True, "alive": False}
|
||||
self.command.side_effect = command
|
||||
new_id = "tunnel_replacement0123456789"
|
||||
with mock.patch.object(autostart, "_systemctl", side_effect=service), mock.patch.object(connection_background, "status", side_effect=lambda state: dict(live)), mock.patch.object(connection_background, "disconnect", side_effect=disconnect):
|
||||
autostart.configure_connection(self.state, self._durable_settings(new_id), MARKER + "-NEW")
|
||||
self.assertLess(events.index("encrypt"), events.index("stop"))
|
||||
self.assertLess(events.index("disconnect"), events.index("enable"))
|
||||
self.assertEqual(new_id, config.load_config(self.state)["tunnel_id"])
|
||||
_, directory, _ = autostart._paths(self.state)
|
||||
self.assertEqual(b"REPLACEMENT-CIPHERTEXT-" * 20, (directory / "credential.cred").read_bytes())
|
||||
|
||||
def test_durable_replacement_failure_restores_exact_files_and_restarts_old_unit(self):
|
||||
self._durable_connect()
|
||||
_, directory, unit = autostart._paths(self.state)
|
||||
paths = (self.state / "config.json", unit, directory / "credential.cred", directory / "installation.json", directory / "config-before.json")
|
||||
originals = {p: p.read_bytes() for p in paths}
|
||||
calls = []
|
||||
def failed(*args, **kwargs):
|
||||
calls.append(args)
|
||||
if args[0] == "show":
|
||||
return subprocess.CompletedProcess(args, 0, b"ActiveState=active\nUnitFileState=enabled\n", b"")
|
||||
if args[:2] == ("enable", "--now"):
|
||||
raise RuntimeError("New service did not start.")
|
||||
return self.fake_systemctl(*args, **kwargs)
|
||||
with mock.patch.object(autostart, "_systemctl", side_effect=failed):
|
||||
with self.assertRaisesRegex(RuntimeError, "New service did not start"):
|
||||
self._durable_connect(MARKER + "-NEW", self._durable_settings("tunnel_changed0123456789"))
|
||||
self.assertEqual(originals, {p: p.read_bytes() for p in paths})
|
||||
self.assertIn(("start", unit.name), calls)
|
||||
self.assertIn(("enable", unit.name), calls)
|
||||
|
||||
def test_durable_connection_rejects_changed_existing_service_before_authentication(self):
|
||||
self._durable_connect()
|
||||
_, _, unit = autostart._paths(self.state)
|
||||
unit.write_bytes(unit.read_bytes() + b"# external change\n")
|
||||
self.calls.clear()
|
||||
self.system_calls.clear()
|
||||
with self.assertRaisesRegex(RuntimeError, "changed outside"):
|
||||
self._durable_connect(MARKER + "-NEW")
|
||||
self.assertEqual([], self.calls)
|
||||
self.assertEqual([], self.system_calls)
|
||||
|
||||
def test_durable_invalid_key_or_public_fields_have_no_authentication_side_effect(self):
|
||||
for key, settings in (("bad key", self._durable_settings()), (MARKER + "é", self._durable_settings()), (MARKER, {**self._durable_settings(), "private_key": "not-accepted"})):
|
||||
with self.subTest(key_length=len(key)):
|
||||
with self.assertRaises(ValueError):
|
||||
self._durable_connect(key, settings)
|
||||
self.assertEqual([], self.calls)
|
||||
self.assertEqual([], self.system_calls)
|
||||
|
||||
def test_durable_failure_reports_previous_manual_connection_needs_reconnecting(self):
|
||||
live = {"alive": True}
|
||||
def disconnect(state):
|
||||
live["alive"] = False
|
||||
return {"ok": True, "alive": False}
|
||||
def failed(*args, **kwargs):
|
||||
if args[:2] == ("enable", "--now"):
|
||||
raise RuntimeError("Start failed.")
|
||||
return self.fake_systemctl(*args, **kwargs)
|
||||
before = (self.state / "config.json").read_bytes()
|
||||
with mock.patch.object(connection_background, "status", side_effect=lambda state: dict(live)), mock.patch.object(connection_background, "disconnect", side_effect=disconnect), mock.patch.object(autostart, "_systemctl", side_effect=failed):
|
||||
with self.assertRaisesRegex(RuntimeError, "previous manually started connection was stopped"):
|
||||
autostart.configure_connection(self.state, self._durable_settings(), MARKER)
|
||||
self.assertEqual(before, (self.state / "config.json").read_bytes())
|
||||
|
||||
def test_durable_rotation_then_disable_preserves_new_connection_settings(self):
|
||||
self._durable_connect()
|
||||
replacement = self._durable_settings("tunnel_next0123456789")
|
||||
replacement["account_label"] = "new@example.test"
|
||||
self._durable_connect(MARKER + "-NEW", replacement)
|
||||
autostart.disable(self.state)
|
||||
selected = config.load_config(self.state)
|
||||
self.assertEqual("no", selected["headless"])
|
||||
self.assertEqual(replacement["tunnel_id"], selected["tunnel_id"])
|
||||
self.assertEqual(replacement["account_label"], selected["account_label"])
|
||||
|
||||
def test_durable_rollback_attempts_old_restart_even_after_disable_failure(self):
|
||||
self._durable_connect()
|
||||
_, _, unit = autostart._paths(self.state)
|
||||
calls = []
|
||||
def failed(*args, **kwargs):
|
||||
calls.append(args)
|
||||
if args[0] == "show":
|
||||
return subprocess.CompletedProcess(args, 0, b"ActiveState=active\nUnitFileState=enabled\n", b"")
|
||||
if args[:2] == ("enable", "--now"):
|
||||
raise RuntimeError("Start failed.")
|
||||
return subprocess.CompletedProcess(args, 1 if args[:2] == ("disable", "--now") else 0, b"", b"")
|
||||
with mock.patch.object(autostart, "_systemctl", side_effect=failed):
|
||||
with self.assertRaisesRegex(RuntimeError, "restoration could not be fully verified"):
|
||||
self._durable_connect(MARKER + "-NEW")
|
||||
self.assertIn(("start", unit.name), calls)
|
||||
self.assertIn(("enable", unit.name), calls)
|
||||
|
||||
def test_resume_saved_stopped_connection_asks_for_no_key_or_privilege_when_linger_exists(self):
|
||||
self._durable_connect()
|
||||
_, directory, unit = autostart._paths(self.state)
|
||||
old_cipher = (directory / "credential.cred").read_bytes()
|
||||
old_unit = unit.read_bytes()
|
||||
self.calls.clear()
|
||||
self.system_calls.clear()
|
||||
with mock.patch.object(autostart, "_linger", return_value=True), mock.patch.object(connection_background, "disconnect", return_value={"ok": True, "alive": False}), mock.patch.object(connection_background, "status", return_value={"alive": False}):
|
||||
autostart.resume_connection(self.state)
|
||||
self.inputs.assert_not_called()
|
||||
self.key_prompt.assert_not_called()
|
||||
self.assertEqual([], self.calls)
|
||||
self.assertEqual(old_cipher, (directory / "credential.cred").read_bytes())
|
||||
self.assertEqual(old_unit, unit.read_bytes())
|
||||
self.assertIn(("enable", "--now", unit.name), self.system_calls)
|
||||
|
||||
def test_resume_active_verified_service_returns_without_restarting(self):
|
||||
self._durable_connect()
|
||||
self.calls.clear()
|
||||
self.system_calls.clear()
|
||||
ready = {"configured": True, "enabled": True, "service_active": True, "runtime": {"running": True, "ready": True}, "connection": {"alive": True, "verified": True}}
|
||||
with mock.patch.object(autostart, "_linger", return_value=True), mock.patch.object(autostart, "status", return_value=ready):
|
||||
self.assertEqual(ready, autostart.resume_connection(self.state))
|
||||
self.assertEqual([], self.calls)
|
||||
self.assertEqual([], self.system_calls)
|
||||
|
||||
def test_resume_rejects_public_tunnel_change_before_service_actions(self):
|
||||
self._durable_connect()
|
||||
changed = config.load_config(self.state)
|
||||
changed["tunnel_id"] = "tunnel_changed0123456789"
|
||||
config.save_config(self.state, changed)
|
||||
self.calls.clear()
|
||||
self.system_calls.clear()
|
||||
with self.assertRaisesRegex(RuntimeError, "--replace"):
|
||||
autostart.resume_connection(self.state)
|
||||
self.assertEqual([], self.calls)
|
||||
self.assertEqual([], self.system_calls)
|
||||
|
||||
def test_resume_rejects_changed_cipher_before_service_actions(self):
|
||||
self._durable_connect()
|
||||
_, directory, _ = autostart._paths(self.state)
|
||||
cipher = directory / "credential.cred"
|
||||
cipher.write_bytes(cipher.read_bytes() + b"changed")
|
||||
self.calls.clear()
|
||||
self.system_calls.clear()
|
||||
with self.assertRaisesRegex(RuntimeError, "--replace"):
|
||||
autostart.resume_connection(self.state)
|
||||
self.assertEqual([], self.calls)
|
||||
self.assertEqual([], self.system_calls)
|
||||
|
||||
def test_resume_legacy_without_any_profile_binding_requires_replace(self):
|
||||
self._durable_connect()
|
||||
_, directory, _ = autostart._paths(self.state)
|
||||
record = autostart._manifest(directory)
|
||||
record.pop("connection_settings_sha256")
|
||||
record.pop("changed_config_sha256")
|
||||
autostart._json(directory / "installation.json", record, replace=True)
|
||||
self.calls.clear()
|
||||
self.system_calls.clear()
|
||||
with self.assertRaisesRegex(RuntimeError, "--replace"):
|
||||
autostart.resume_connection(self.state)
|
||||
self.assertEqual([], self.calls)
|
||||
self.assertEqual([], self.system_calls)
|
||||
|
||||
def test_resume_legacy_exact_profile_binding_can_be_verified_without_new_key(self):
|
||||
self._durable_connect()
|
||||
_, directory, _ = autostart._paths(self.state)
|
||||
record = autostart._manifest(directory)
|
||||
record.pop("connection_settings_sha256")
|
||||
autostart._json(directory / "installation.json", record, replace=True)
|
||||
ready = {"configured": True, "enabled": True, "service_active": True, "runtime": {"running": True, "ready": True}, "connection": {"alive": True, "verified": True}}
|
||||
with mock.patch.object(autostart, "_linger", return_value=True), mock.patch.object(autostart, "status", return_value=ready):
|
||||
self.assertEqual(ready, autostart.resume_connection(self.state))
|
||||
self.key_prompt.assert_not_called()
|
||||
|
||||
def test_node_autostart_needs_no_chatgpt_key(self):
|
||||
settings = config.default_config(role="node")
|
||||
settings.update(headless="yes", broker_url="https://example.test:20878")
|
||||
config.save_config(self.state, settings)
|
||||
self.inputs.side_effect = ["yes"]
|
||||
autostart.enable(self.state)
|
||||
_, directory, unit = autostart._paths(self.state)
|
||||
self.key_prompt.assert_not_called()
|
||||
self.assertNotIn("LoadCredentialEncrypted", unit.read_text())
|
||||
self.assertFalse((directory / "credential.cred").exists())
|
||||
|
||||
def test_private_file_reader_rejects_symlink_hardlink_fifo_and_public_file(self):
|
||||
original = self.root / "original"
|
||||
original.write_bytes(b"safe")
|
||||
original.chmod(0o600)
|
||||
linked = self.root / "linked"
|
||||
linked.symlink_to(original)
|
||||
with self.assertRaises(OSError):
|
||||
autostart._read(linked)
|
||||
linked.unlink()
|
||||
os.link(original, linked)
|
||||
with self.assertRaises(RuntimeError):
|
||||
autostart._read(original)
|
||||
linked.unlink()
|
||||
os.mkfifo(linked, 0o600)
|
||||
with self.assertRaises(RuntimeError):
|
||||
autostart._read(linked)
|
||||
original.chmod(0o644)
|
||||
with self.assertRaises(RuntimeError):
|
||||
autostart._read(original)
|
||||
|
||||
def test_configuration_lock_rejects_concurrent_enable(self):
|
||||
_, directory, _ = autostart._paths(self.state)
|
||||
fd = os.open(directory / "configuration.lock", os.O_RDWR | os.O_CREAT, 0o600)
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
with self.assertRaisesRegex(RuntimeError, "already in progress"):
|
||||
autostart.enable(self.state)
|
||||
self.key_prompt.assert_not_called()
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
def test_unit_quotes_specifiers_and_dollars_without_shell_or_missing_package_loop(self):
|
||||
text = autostart.unit_text(Path('/tmp/a % b ${HOME} "quoted"'), Path('/tmp/key % file'))
|
||||
self.assertIn("%%", text)
|
||||
self.assertIn("$${HOME}", text)
|
||||
self.assertIn("ConditionPathExists=", text)
|
||||
self.assertIn("LimitCORE=0", text)
|
||||
self.assertIn("RestartSec=15", text)
|
||||
self.assertNotIn("/bin/sh", text)
|
||||
with self.assertRaises(ValueError):
|
||||
autostart.unit_text(Path("/tmp/a\nExecStart=bad"), Path("/tmp/key"))
|
||||
|
||||
def test_encrypted_credential_uses_os_broker_pipe_without_plaintext_files(self):
|
||||
import hashlib
|
||||
_, directory, _ = autostart._paths(self.state)
|
||||
cipher = b"SYNTHETIC-CIPHERTEXT-ONLY" * 8
|
||||
autostart._write(directory / "credential.cred", cipher)
|
||||
autostart._json(directory / "installation.json", {"schema": 1, "node": False, "credential_sha256": hashlib.sha256(cipher).hexdigest()})
|
||||
def decrypt(args, **kwargs):
|
||||
self.assertEqual(["systemd-creds", "decrypt", "--user", "--name=chatgpt-runtime-key", "-", "-"], args)
|
||||
self.assertEqual(cipher, kwargs["data"])
|
||||
self.assertEqual(15, kwargs["timeout"])
|
||||
self.assertNotIn(MARKER, " ".join(args))
|
||||
return subprocess.CompletedProcess(args, 0, MARKER.encode(), b"")
|
||||
self.command.side_effect = decrypt
|
||||
self.assertEqual(MARKER, autostart._credential(self.state))
|
||||
for path in directory.iterdir():
|
||||
if path.is_file():
|
||||
self.assertNotIn(MARKER.encode(), path.read_bytes())
|
||||
|
||||
def test_changed_cipher_is_rejected_before_decryption(self):
|
||||
import hashlib
|
||||
_, directory, _ = autostart._paths(self.state)
|
||||
cipher = b"SYNTHETIC-CIPHERTEXT-ONLY" * 8
|
||||
autostart._write(directory / "credential.cred", cipher)
|
||||
autostart._json(directory / "installation.json", {"schema": 1, "node": False, "credential_sha256": hashlib.sha256(cipher).hexdigest()})
|
||||
(directory / "credential.cred").write_bytes(cipher + b"changed")
|
||||
self.command.reset_mock()
|
||||
with self.assertRaisesRegex(RuntimeError, "changed outside"):
|
||||
autostart._credential(self.state)
|
||||
self.command.assert_not_called()
|
||||
|
||||
def test_decrypt_failure_reports_no_plaintext_or_broker_output(self):
|
||||
import hashlib
|
||||
_, directory, _ = autostart._paths(self.state)
|
||||
cipher = b"SYNTHETIC-CIPHERTEXT-ONLY" * 8
|
||||
autostart._write(directory / "credential.cred", cipher)
|
||||
autostart._json(directory / "installation.json", {"schema": 1, "node": False, "credential_sha256": hashlib.sha256(cipher).hexdigest()})
|
||||
self.command.side_effect = RuntimeError("Autostart command failed: systemd-creds. No command output was exported.")
|
||||
with self.assertRaisesRegex(RuntimeError, "No command output was exported"):
|
||||
autostart._credential(self.state)
|
||||
|
||||
|
||||
def test_disconnect_pauses_worker_without_stopping_local_services_until_unit_stop(self):
|
||||
settings = config.load_config(self.state)
|
||||
settings["headless"] = "auto"
|
||||
config.save_config(self.state, settings)
|
||||
states = []
|
||||
checks = 0
|
||||
def wait(delay):
|
||||
nonlocal checks
|
||||
checks += 1
|
||||
runtime_stop.assert_not_called()
|
||||
self.assertIn("disconnected", states)
|
||||
autostart._STOP = True
|
||||
with mock.patch.object(runtime, "start", return_value={"running": True, "ready": True}), mock.patch.object(runtime, "status", return_value={"running": True}), mock.patch.object(runtime, "stop") as runtime_stop, mock.patch.object(connection_background, "start", return_value=1) as start, mock.patch.object(connection_background, "status", return_value={"alive": False, "verified": False, "state": "stopped"}), mock.patch.object(connection_background, "disconnect") as disconnect, mock.patch.object(autostart, "_credential", return_value=MARKER), mock.patch.object(autostart, "_worker_status", side_effect=lambda directory, name: states.append(name)), mock.patch.object(autostart.time, "sleep", side_effect=wait):
|
||||
self.assertEqual(0, autostart.worker(self.state))
|
||||
self.assertEqual(1, checks)
|
||||
self.assertEqual(1, start.call_args.kwargs["wait_timeout"])
|
||||
runtime_stop.assert_called_once()
|
||||
disconnect.assert_called_once()
|
||||
|
||||
def test_failed_tunnel_requests_service_restart_and_cleans_up(self):
|
||||
settings = config.load_config(self.state)
|
||||
settings["headless"] = "auto"
|
||||
config.save_config(self.state, settings)
|
||||
with mock.patch.object(runtime, "start", return_value={"running": True, "ready": True}), mock.patch.object(runtime, "stop") as runtime_stop, mock.patch.object(connection_background, "start", return_value=1), mock.patch.object(connection_background, "status", return_value={"alive": False, "state": "error"}), mock.patch.object(connection_background, "disconnect") as disconnect, mock.patch.object(autostart, "_credential", return_value=MARKER):
|
||||
self.assertEqual(1, autostart.worker(self.state))
|
||||
runtime_stop.assert_called_once()
|
||||
disconnect.assert_called_once()
|
||||
|
||||
def test_physical_desktop_wait_requires_no_new_key_prompt(self):
|
||||
from framebridge_expanded.display_context import DisplayContextError
|
||||
states = []
|
||||
def wait(delay):
|
||||
autostart._STOP = True
|
||||
with mock.patch.object(runtime, "start", side_effect=DisplayContextError("PHYSICAL_X11_UNAVAILABLE_NO_VIRTUAL_FALLBACK")), mock.patch.object(runtime, "stop"), mock.patch.object(autostart, "_credential") as credential, mock.patch.object(autostart, "_worker_status", side_effect=lambda directory, name: states.append(name)), mock.patch.object(autostart.time, "sleep", side_effect=wait):
|
||||
self.assertEqual(0, autostart.worker(self.state))
|
||||
self.assertIn("waiting_for_graphical_login", states)
|
||||
credential.assert_not_called()
|
||||
self.key_prompt.assert_not_called()
|
||||
|
||||
def test_enable_interrupt_rolls_back_unit_blob_and_exact_profile(self):
|
||||
before = (self.state / "config.json").read_bytes()
|
||||
def interrupted(*args, **kwargs):
|
||||
if args[:2] == ("enable", "--now"):
|
||||
raise KeyboardInterrupt()
|
||||
return self.fake_systemctl(*args, **kwargs)
|
||||
with mock.patch.object(autostart, "_systemctl", side_effect=interrupted):
|
||||
result = autostart.enable(self.state)
|
||||
self.assertEqual({"configured": False, "state": "cancelled"}, result)
|
||||
_, directory, unit = autostart._paths(self.state)
|
||||
self.assertEqual(before, (self.state / "config.json").read_bytes())
|
||||
self.assertFalse(unit.exists())
|
||||
self.assertFalse((directory / "credential.cred").exists())
|
||||
self.assertFalse((directory / "installation.json").exists())
|
||||
|
||||
def test_disable_privilege_cancellation_preserves_complete_installation(self):
|
||||
autostart.enable(self.state)
|
||||
_, directory, unit = autostart._paths(self.state)
|
||||
unit_before = unit.read_bytes()
|
||||
cipher_before = (directory / "credential.cred").read_bytes()
|
||||
def cancelled(args, **kwargs):
|
||||
if args == ["sudo", "-v"]:
|
||||
raise KeyboardInterrupt()
|
||||
return self.fake_command(args, **kwargs)
|
||||
self.command.side_effect = cancelled
|
||||
with self.assertRaises(KeyboardInterrupt):
|
||||
autostart.disable(self.state)
|
||||
self.assertEqual(unit_before, unit.read_bytes())
|
||||
self.assertEqual(cipher_before, (directory / "credential.cred").read_bytes())
|
||||
self.assertTrue((directory / "installation.json").exists())
|
||||
|
||||
def test_unconfigured_manual_stop_does_not_create_autostart_directory(self):
|
||||
other = self.home / "other-profile"
|
||||
config.save_config(other, config.default_config())
|
||||
self.assertFalse(autostart.mark_manual_stop(other))
|
||||
self.assertFalse((other / "autostart").exists())
|
||||
|
||||
def test_runtime_crash_requests_service_restart(self):
|
||||
with mock.patch.object(runtime, "start", return_value={"running": True, "ready": True}), mock.patch.object(runtime, "status", return_value={"running": False}), mock.patch.object(runtime, "stop") as runtime_stop, mock.patch.object(connection_background, "start", return_value=0), mock.patch.object(connection_background, "status", return_value={"alive": True, "verified": True, "state": "connected"}), mock.patch.object(connection_background, "disconnect") as disconnect, mock.patch.object(autostart, "_credential", return_value=MARKER):
|
||||
self.assertEqual(1, autostart.worker(self.state))
|
||||
runtime_stop.assert_called_once()
|
||||
disconnect.assert_called_once()
|
||||
|
||||
def test_explicit_stop_intent_prevents_automatic_restart(self):
|
||||
_, directory, _ = autostart._paths(self.state)
|
||||
autostart._json(directory / "installation.json", {"schema": 1, "node": False})
|
||||
def explicitly_stopped(state):
|
||||
self.assertTrue(autostart.mark_manual_stop(state))
|
||||
return {"running": False}
|
||||
with mock.patch.object(runtime, "start", return_value={"running": True, "ready": True}), mock.patch.object(runtime, "status", side_effect=explicitly_stopped), mock.patch.object(runtime, "stop") as runtime_stop, mock.patch.object(connection_background, "start", return_value=0), mock.patch.object(connection_background, "status", return_value={"alive": True, "verified": True, "state": "connected"}), mock.patch.object(connection_background, "disconnect"), mock.patch.object(autostart, "_credential", return_value=MARKER):
|
||||
self.assertEqual(0, autostart.worker(self.state))
|
||||
runtime_stop.assert_called_once()
|
||||
self.assertTrue(autostart.manual_stop_requested(self.state))
|
||||
|
||||
def test_stale_manual_stop_is_cleared_when_a_new_worker_starts(self):
|
||||
_, directory, _ = autostart._paths(self.state)
|
||||
autostart._json(directory / "installation.json", {"schema": 1, "node": False})
|
||||
self.assertTrue(autostart.mark_manual_stop(self.state))
|
||||
self.assertTrue(autostart.manual_stop_requested(self.state))
|
||||
with mock.patch.object(runtime, "start", return_value={"running": True, "ready": True}) as start, mock.patch.object(runtime, "status", return_value={"running": False}), mock.patch.object(runtime, "stop"), mock.patch.object(connection_background, "start", return_value=0), mock.patch.object(connection_background, "status", return_value={"alive": True, "verified": True, "state": "connected"}), mock.patch.object(connection_background, "disconnect"), mock.patch.object(autostart, "_credential", return_value=MARKER):
|
||||
self.assertEqual(1, autostart.worker(self.state))
|
||||
start.assert_called_once()
|
||||
self.assertFalse(autostart.manual_stop_requested(self.state))
|
||||
|
||||
def test_stop_for_current_boot_cancels_restart_job_but_preserves_unit_and_credential(self):
|
||||
autostart.enable(self.state)
|
||||
_, directory, unit = autostart._paths(self.state)
|
||||
cipher = (directory / "credential.cred").read_bytes()
|
||||
original_unit = unit.read_bytes()
|
||||
self.system_calls.clear()
|
||||
result = autostart.stop_for_current_boot(self.state)
|
||||
self.assertEqual("stopped_for_current_boot", result["state"])
|
||||
self.assertEqual([("stop", unit.name)], self.system_calls)
|
||||
self.assertEqual(original_unit, unit.read_bytes())
|
||||
self.assertEqual(cipher, (directory / "credential.cred").read_bytes())
|
||||
self.assertTrue((directory / "installation.json").exists())
|
||||
|
||||
def test_stop_for_current_boot_refuses_changed_unit(self):
|
||||
autostart.enable(self.state)
|
||||
_, _, unit = autostart._paths(self.state)
|
||||
unit.write_text(unit.read_text() + "# foreign change\n")
|
||||
self.system_calls.clear()
|
||||
with self.assertRaisesRegex(RuntimeError, "changed outside"):
|
||||
autostart.stop_for_current_boot(self.state)
|
||||
self.assertEqual([], self.system_calls)
|
||||
self.assertTrue(unit.exists())
|
||||
|
||||
def test_stop_for_current_boot_unconfigured_does_not_create_directories(self):
|
||||
other = self.home / "unmanaged"
|
||||
config.save_config(other, config.default_config())
|
||||
self.assertFalse(autostart.stop_for_current_boot(other)["configured"])
|
||||
self.assertFalse((other / "autostart").exists())
|
||||
self.assertEqual([], self.system_calls)
|
||||
|
||||
def test_cli_stop_cancels_service_restart_before_stopping_runtime(self):
|
||||
from framebridge_expanded import cli
|
||||
events = []
|
||||
def stop_unit(state):
|
||||
events.append("unit-stop")
|
||||
def stop_runtime(state):
|
||||
self.assertEqual(["unit-stop"], events)
|
||||
events.append("runtime-stop")
|
||||
return {"running": False}
|
||||
with mock.patch.object(autostart, "stop_for_current_boot", side_effect=stop_unit), mock.patch.object(runtime, "stop", side_effect=stop_runtime), mock.patch.object(cli, "_show"):
|
||||
self.assertEqual(0, cli.main(["--state-dir", str(self.state), "stop"]))
|
||||
self.assertEqual(["unit-stop", "runtime-stop"], events)
|
||||
@@ -0,0 +1,353 @@
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from framebridge_expanded import cli
|
||||
from framebridge_expanded.config import default_config, load_config, save_config
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class CliTests(unittest.TestCase):
|
||||
def directory(self):
|
||||
return tempfile.TemporaryDirectory(prefix="cli-test-", dir=ROOT / "qa")
|
||||
|
||||
def answers(self, role="standalone", confirm="yes"):
|
||||
def ask(prompt):
|
||||
if prompt.startswith("Role "):
|
||||
return role
|
||||
if prompt.startswith("Computer name"):
|
||||
return "expanded-test"
|
||||
if prompt.startswith("ChatGPT email"):
|
||||
return "tester@example.com"
|
||||
if prompt.startswith("TLS certificate setup"):
|
||||
return "existing"
|
||||
if prompt.startswith("Central broker URL"):
|
||||
return "https://central.example"
|
||||
if prompt.startswith("Save this configuration?"):
|
||||
return confirm
|
||||
return ""
|
||||
return ask
|
||||
|
||||
def test_help_has_english_commands_and_never_loads_runtime(self):
|
||||
output = io.StringIO()
|
||||
with contextlib.redirect_stdout(output), mock.patch.object(cli, "_module") as modules:
|
||||
with self.assertRaises(SystemExit) as exited:
|
||||
cli.main(["--help"])
|
||||
self.assertEqual(exited.exception.code, 0)
|
||||
self.assertIn("setup", output.getvalue())
|
||||
self.assertIn("connect", output.getvalue())
|
||||
modules.assert_not_called()
|
||||
|
||||
def test_no_argument_menu_uses_real_newlines(self):
|
||||
output = io.StringIO()
|
||||
with contextlib.redirect_stdout(output), mock.patch.object(cli.sys.stdin, "isatty", return_value=True), mock.patch("builtins.input", return_value="0"):
|
||||
self.assertEqual(cli.main([]), 0)
|
||||
self.assertIn("FrameBridge\n1. Connect\n2. Status\n3. Doctor", output.getvalue())
|
||||
self.assertNotIn("\\n", output.getvalue())
|
||||
|
||||
def test_noninteractive_no_arguments_is_read_only_help(self):
|
||||
with mock.patch.object(cli.sys.stdin, "isatty", return_value=False), mock.patch.object(cli, "_module") as modules, contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(cli.main([]), 0)
|
||||
modules.assert_not_called()
|
||||
|
||||
def test_state_environment_priority_and_alias(self):
|
||||
with mock.patch.dict(os.environ, {"FRAMEBRIDGE_EXPANDED_STATE": "/tmp/canonical", "FRAMEBRIDGE_STATE_DIR": "/tmp/alias"}):
|
||||
self.assertEqual(cli.resolve_state(), Path("/tmp/canonical"))
|
||||
self.assertEqual(cli.resolve_state("/tmp/explicit"), Path("/tmp/explicit"))
|
||||
with mock.patch.dict(os.environ, {}, clear=True):
|
||||
os.environ["FRAMEBRIDGE_STATE_DIR"] = "/tmp/alias"
|
||||
self.assertEqual(cli.resolve_state(), Path("/tmp/alias"))
|
||||
|
||||
def test_setup_roles_save_public_settings_without_start(self):
|
||||
for role in ("standalone", "central", "node"):
|
||||
with self.subTest(role=role), self.directory() as td, contextlib.redirect_stdout(io.StringIO()), mock.patch.object(cli, "_module") as modules:
|
||||
state = Path(td) / "state"
|
||||
self.assertEqual(cli.setup(state, ask=self.answers(role)), 0)
|
||||
config = load_config(state)
|
||||
self.assertEqual(config["role"], role)
|
||||
self.assertEqual(config["account_label"], "tester@example.com")
|
||||
self.assertEqual(config["ports"]["mcp"], 20865)
|
||||
self.assertEqual((state / "config.json").stat().st_mode & 0o777, 0o600)
|
||||
modules.assert_not_called()
|
||||
serialized = (state / "config.json").read_text()
|
||||
self.assertNotIn("api_key", serialized)
|
||||
self.assertNotIn("runtime_key", serialized)
|
||||
|
||||
def test_setup_new_profile_selects_auto_without_a_desktop_question(self):
|
||||
prompts = []
|
||||
answer = self.answers()
|
||||
def ask(prompt):
|
||||
prompts.append(prompt)
|
||||
return answer(prompt)
|
||||
with self.directory() as td, contextlib.redirect_stdout(io.StringIO()) as output:
|
||||
state = Path(td) / "state"
|
||||
self.assertEqual(cli.setup(state, ask=ask), 0)
|
||||
self.assertEqual(load_config(state)["headless"], "auto")
|
||||
self.assertFalse(any("desktop" in prompt.lower() for prompt in prompts))
|
||||
self.assertIn("Desktop: automatic detection.", output.getvalue())
|
||||
|
||||
def test_setup_keeps_existing_explicit_desktop_setting(self):
|
||||
for mode in ("yes", "no", True, False):
|
||||
with self.subTest(mode=mode), self.directory() as td, contextlib.redirect_stdout(io.StringIO()) as output:
|
||||
state = Path(td) / "state"
|
||||
settings = default_config(); settings["headless"] = mode
|
||||
save_config(state, settings)
|
||||
runtime = mock.Mock(); runtime.status.return_value = {"running": False, "processes": []}
|
||||
with mock.patch.object(cli, "_module", return_value=runtime):
|
||||
self.assertEqual(cli.setup(state, ask=self.answers()), 0)
|
||||
self.assertEqual(load_config(state)["headless"], mode)
|
||||
self.assertIn("setting preserved.", output.getvalue())
|
||||
self.assertNotIn("Desktop: automatic detection.", output.getvalue())
|
||||
|
||||
def autostart_enable_output(self, result):
|
||||
autostart = mock.Mock(); autostart.enable.return_value = result
|
||||
with mock.patch.object(cli, "_module", return_value=autostart), contextlib.redirect_stdout(io.StringIO()) as output:
|
||||
code = cli.main(["autostart", "enable"])
|
||||
return code, output.getvalue()
|
||||
|
||||
def test_autostart_enable_distinguishes_configured_from_connected(self):
|
||||
result = {"configured": True, "enabled": True, "service_state": "activating",
|
||||
"runtime": {"ready": False, "display_context": {"kind": "text"}},
|
||||
"connection": {"state": "checking", "verified": False}}
|
||||
code, output = self.autostart_enable_output(result)
|
||||
self.assertEqual(code, 0)
|
||||
self.assertIn("Automatic startup enabled.", output)
|
||||
self.assertIn("Tools: starting.", output)
|
||||
self.assertIn("Desktop: text mode.", output)
|
||||
self.assertIn("Connection: checking in the background.", output)
|
||||
self.assertNotIn("Connection: ready.", output)
|
||||
self.assertNotIn("CUA ready", output)
|
||||
self.assertNotIn('"configured"', output)
|
||||
|
||||
def test_autostart_enable_reports_verified_graphical_capabilities(self):
|
||||
result = {"configured": True, "enabled": True, "service_state": "active",
|
||||
"runtime": {"ready": True, "display_context": {"kind": "physical-x11"},
|
||||
"capabilities": {"fbexec": True, "fbterm": True, "cua_tty_module": True, "graphical_cua": True}},
|
||||
"connection": {"state": "ready", "verified": True}}
|
||||
code, output = self.autostart_enable_output(result)
|
||||
self.assertEqual(code, 0)
|
||||
self.assertIn("Tools: FBEXEC, FBTERM, CUA-TTY ready.", output)
|
||||
self.assertIn("Desktop: detected; CUA ready.", output)
|
||||
self.assertIn("Connection: ready.", output)
|
||||
|
||||
def test_autostart_enable_failed_service_is_not_a_success_exit(self):
|
||||
code, output = self.autostart_enable_output({"configured": True, "enabled": True,
|
||||
"service_state": "failed", "runtime": {"status_unavailable": True},
|
||||
"connection": {"state": "error", "verified": False}})
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn("Service: failed", output)
|
||||
self.assertIn("Tools: status unavailable.", output)
|
||||
self.assertIn("Connection: needs attention.", output)
|
||||
self.assertNotIn("CUA ready", output)
|
||||
|
||||
def test_autostart_enable_cancel_has_no_success_summary(self):
|
||||
code, output = self.autostart_enable_output({"state": "cancelled"})
|
||||
self.assertEqual(code, 130)
|
||||
self.assertEqual(output, "Automatic startup was not enabled.\n")
|
||||
|
||||
def test_setup_decline_does_not_create_profile(self):
|
||||
with self.directory() as td, contextlib.redirect_stdout(io.StringIO()):
|
||||
state = Path(td) / "state"
|
||||
self.assertEqual(cli.setup(state, ask=self.answers(confirm="no")), 0)
|
||||
self.assertFalse((state / "config.json").exists())
|
||||
|
||||
def test_setup_rejects_running_profile_before_prompts(self):
|
||||
with self.directory() as td:
|
||||
state = Path(td)
|
||||
save_config(state, default_config(name="existing"))
|
||||
before = (state / "config.json").read_bytes()
|
||||
runtime = mock.Mock()
|
||||
runtime.status.return_value = {"running": True, "processes": []}
|
||||
ask = mock.Mock()
|
||||
with mock.patch.object(cli, "_module", return_value=runtime):
|
||||
with self.assertRaisesRegex(RuntimeError, "Stop this profile"):
|
||||
cli.setup(state, ask=ask)
|
||||
ask.assert_not_called()
|
||||
self.assertEqual((state / "config.json").read_bytes(), before)
|
||||
|
||||
def connection_runtime(self, base):
|
||||
runtime = mock.Mock()
|
||||
runtime.PREFIX = Path(base) / "payload"
|
||||
binary = runtime.PREFIX / "original/geranas/FrameBridge-MCP-Private/bin/tunnel-client"
|
||||
binary.parent.mkdir(parents=True)
|
||||
binary.write_bytes(b"synthetic test binary")
|
||||
runtime.status.return_value = {"running": False, "ready": False}
|
||||
return runtime
|
||||
|
||||
def test_connection_rejects_another_profiles_listening_port(self):
|
||||
with self.directory() as td:
|
||||
state = Path(td) / "profile"
|
||||
save_config(state, default_config(name="stopped-profile"))
|
||||
before = (state / "config.json").read_bytes()
|
||||
runtime = self.connection_runtime(td)
|
||||
automatic = mock.Mock()
|
||||
listener = mock.MagicMock()
|
||||
listener.__enter__.return_value.bind.side_effect = OSError("address in use")
|
||||
def connect(*args, **kwargs):
|
||||
return kwargs["configure_connection"](state, {"account_label": "test@example.com", "tunnel_id": "tunnel_new"}, "SYNTHETIC-MCP-ACCESS-KEY")
|
||||
with mock.patch.object(cli, "_module", side_effect=lambda name: runtime if name == "runtime" else automatic), mock.patch.object(cli.socket, "socket", return_value=listener), mock.patch.object(cli.tunnel, "connect_chatgpt", side_effect=connect):
|
||||
with self.assertRaisesRegex(RuntimeError, "MCP port 20865 is already in use"):
|
||||
cli.connect_chatgpt(state)
|
||||
automatic.configure_connection.assert_not_called()
|
||||
self.assertEqual(before, (state / "config.json").read_bytes())
|
||||
|
||||
def test_new_connection_needs_no_prior_profile_or_runtime_start(self):
|
||||
with self.directory() as td:
|
||||
state = Path(td) / "new-profile"
|
||||
runtime = self.connection_runtime(td)
|
||||
with mock.patch.object(cli, "_module", return_value=runtime), mock.patch.object(cli.tunnel, "connect_chatgpt", return_value=130) as connect:
|
||||
self.assertEqual(130, cli.connect_chatgpt(state))
|
||||
self.assertFalse(state.exists())
|
||||
runtime.start.assert_not_called()
|
||||
runtime.status.assert_not_called()
|
||||
public = connect.call_args.args[3]
|
||||
self.assertEqual("standalone", public["role"])
|
||||
self.assertEqual("auto", public["headless"])
|
||||
self.assertTrue(callable(connect.call_args.kwargs["configure_connection"]))
|
||||
|
||||
def test_cancel_new_profile_at_hidden_key_leaves_no_profile_or_services(self):
|
||||
with self.directory() as td:
|
||||
state = Path(td) / "new-profile"
|
||||
runtime = self.connection_runtime(td)
|
||||
automatic = mock.Mock()
|
||||
with mock.patch.object(cli, "_module", side_effect=lambda name: runtime if name == "runtime" else automatic), mock.patch.object(cli.tunnel.sys.stdin, "isatty", return_value=True), mock.patch("builtins.input", return_value="test@example.com"), mock.patch.object(cli.tunnel, "_hidden", side_effect=["tunnel_valid", KeyboardInterrupt]), contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(130, cli.connect_chatgpt(state))
|
||||
self.assertFalse(state.exists())
|
||||
automatic.configure_connection.assert_not_called()
|
||||
runtime.start.assert_not_called()
|
||||
|
||||
def test_existing_managed_connection_dispatches_resume_without_wizard(self):
|
||||
with self.directory() as td:
|
||||
state = Path(td) / "profile"
|
||||
save_config(state, default_config())
|
||||
(state / "autostart").mkdir()
|
||||
(state / "autostart/installation.json").write_text("{}")
|
||||
runtime = self.connection_runtime(td)
|
||||
automatic = mock.Mock()
|
||||
with mock.patch.object(cli, "_module", side_effect=lambda name: runtime if name == "runtime" else automatic), mock.patch.object(cli.tunnel, "resume_managed_connection", return_value=0) as resume, mock.patch.object(cli.tunnel, "connect_chatgpt") as wizard:
|
||||
self.assertEqual(0, cli.connect_chatgpt(state))
|
||||
resume.assert_called_once_with(state, automatic.resume_connection)
|
||||
wizard.assert_not_called()
|
||||
|
||||
def test_replace_bypasses_resume_and_uses_normal_three_field_wizard(self):
|
||||
with self.directory() as td:
|
||||
state = Path(td) / "profile"
|
||||
save_config(state, default_config())
|
||||
(state / "autostart").mkdir()
|
||||
(state / "autostart/installation.json").write_text("{}")
|
||||
runtime = self.connection_runtime(td)
|
||||
with mock.patch.object(cli, "_module", return_value=runtime), mock.patch.object(cli.tunnel, "resume_managed_connection") as resume, mock.patch.object(cli.tunnel, "connect_chatgpt", return_value=0) as wizard:
|
||||
self.assertEqual(0, cli.connect_chatgpt(state, replace=True))
|
||||
resume.assert_not_called()
|
||||
self.assertTrue(callable(wizard.call_args.kwargs["configure_connection"]))
|
||||
|
||||
def test_replace_dispatch_and_foreground_are_mutually_exclusive(self):
|
||||
with contextlib.redirect_stdout(io.StringIO()), mock.patch.object(cli, "connect_chatgpt", return_value=0) as connect:
|
||||
self.assertEqual(0, cli.main(["--state-dir", "/tmp/profile", "connect", "chatgpt", "--replace"]))
|
||||
connect.assert_called_once_with(Path("/tmp/profile"), foreground=False, replace=True)
|
||||
with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as error:
|
||||
cli.main(["connect", "chatgpt", "--replace", "--foreground"])
|
||||
self.assertEqual(2, error.exception.code)
|
||||
|
||||
def test_node_profile_cannot_tunnel_a_central_profiles_port(self):
|
||||
with self.directory() as td:
|
||||
state = Path(td)
|
||||
save_config(state, default_config("node", "node-profile"))
|
||||
with mock.patch.object(cli, "_module") as modules, mock.patch.object(cli.tunnel, "connect_chatgpt") as connect:
|
||||
with self.assertRaisesRegex(RuntimeError, "central profile"):
|
||||
cli.connect_chatgpt(state)
|
||||
modules.assert_not_called()
|
||||
connect.assert_not_called()
|
||||
|
||||
def test_lifecycle_dispatch_receives_explicit_profile(self):
|
||||
runtime = mock.Mock()
|
||||
runtime.status.return_value = {"ok": True, "running": False}
|
||||
with contextlib.redirect_stdout(io.StringIO()), mock.patch.object(cli, "_module", return_value=runtime):
|
||||
self.assertEqual(cli.main(["--state-dir", str(ROOT / "qa/example"), "status"]), 0)
|
||||
runtime.status.assert_called_once_with(ROOT / "qa/example")
|
||||
|
||||
def test_tty_dispatch_uses_authorized_typed_payload(self):
|
||||
tty = mock.Mock()
|
||||
tty.request.return_value = {"ok": True}
|
||||
with contextlib.redirect_stdout(io.StringIO()), mock.patch.object(cli, "_module", return_value=tty):
|
||||
self.assertEqual(cli.main(["--state-dir", str(ROOT / "qa/example"), "tty", "key", "session-1", "Enter"]), 0)
|
||||
tty.request.assert_called_once_with(ROOT / "qa/example", {"authorized": True, "op": "key.send", "session_id": "session-1", "key": "Enter"})
|
||||
|
||||
def test_node_enrollment_does_not_start_agent(self):
|
||||
with self.directory() as td:
|
||||
state = Path(td)
|
||||
config = default_config("node", "node-test")
|
||||
config["broker_url"] = "https://central.example"
|
||||
save_config(state, config)
|
||||
node = mock.Mock()
|
||||
node.enroll.return_value = {"ok": True, "state": "pending"}
|
||||
with contextlib.redirect_stdout(io.StringIO()), mock.patch.object(cli, "_module", return_value=node):
|
||||
self.assertEqual(cli.main(["--state-dir", str(state), "nodes", "enroll"]), 0)
|
||||
node.enroll.assert_called_once_with("https://central.example", "node-test", state_dir=state)
|
||||
node.run.assert_not_called()
|
||||
|
||||
def test_nodes_list_scopes_and_restores_environment(self):
|
||||
with self.directory() as td:
|
||||
state = Path(td)
|
||||
save_config(state, default_config(name="list-test"))
|
||||
broker = mock.Mock()
|
||||
fleet = mock.Mock()
|
||||
def discover():
|
||||
self.assertEqual(os.environ["FRAMEBRIDGE_EXPANDED_STATE"], str(state))
|
||||
return {"ok": True, "nodes": []}
|
||||
fleet.discover_nodes.side_effect = discover
|
||||
before = os.environ.get("FRAMEBRIDGE_EXPANDED_STATE")
|
||||
with contextlib.redirect_stdout(io.StringIO()), mock.patch.object(cli, "_module", side_effect=lambda name: broker if name == "broker" else fleet):
|
||||
self.assertEqual(cli.main(["--state-dir", str(state), "nodes", "list"]), 0)
|
||||
broker.configure.assert_called_once_with(None, state_dir=state)
|
||||
self.assertEqual(os.environ.get("FRAMEBRIDGE_EXPANDED_STATE"), before)
|
||||
|
||||
def test_nodes_approval_requires_expected_uuid(self):
|
||||
parser = cli.build_parser()
|
||||
with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
|
||||
parser.parse_args(["nodes", "approve", "request-1"])
|
||||
|
||||
|
||||
def test_central_wizard_creates_lan_tls_only_after_review(self):
|
||||
with self.directory() as td, contextlib.redirect_stdout(io.StringIO()) as output:
|
||||
state = Path(td) / "state"
|
||||
original = self.answers("central")
|
||||
def ask(prompt):
|
||||
if prompt.startswith("Broker listen host"):
|
||||
return "0.0.0.0"
|
||||
if prompt.startswith("TLS certificate setup"):
|
||||
return "generate"
|
||||
if prompt.startswith("Certificate DNS name"):
|
||||
return "central.example.test"
|
||||
if prompt.startswith("Advertised broker IP address"):
|
||||
return "192.0.2.20"
|
||||
return original(prompt)
|
||||
self.assertEqual(cli.setup(state, ask=ask), 0)
|
||||
config = load_config(state)
|
||||
self.assertEqual(config["broker_url"], "https://192.0.2.20:20878")
|
||||
self.assertEqual(config["broker_control_url"], "https://127.0.0.1:20878")
|
||||
self.assertEqual(config["broker_listen_host"], "0.0.0.0")
|
||||
self.assertTrue(Path(config["tls_ca"]).is_file())
|
||||
self.assertFalse((state / "manager.json").exists())
|
||||
self.assertIn("No service or ChatGPT connection was started.", output.getvalue())
|
||||
self.assertNotIn("PRIVATE KEY", output.getvalue())
|
||||
|
||||
def test_certificates_create_dispatch_uses_explicit_profile(self):
|
||||
with self.directory() as td, contextlib.redirect_stdout(io.StringIO()) as output:
|
||||
state = Path(td)
|
||||
save_config(state, default_config("central", "cert-cli"))
|
||||
self.assertEqual(cli.main(["--state-dir", str(state), "certificates", "create",
|
||||
"--host", "cert.example.test", "--address", "192.0.2.30",
|
||||
"--listen-host", "0.0.0.0"]), 0)
|
||||
self.assertEqual(load_config(state)["broker_url"], "https://192.0.2.30:20878")
|
||||
self.assertIn("ca_fingerprint_sha256", output.getvalue())
|
||||
self.assertNotIn("PRIVATE KEY", output.getvalue())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Command contract tests: mocked interfaces only, no external connections."""
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from framebridge_expanded import cli
|
||||
from framebridge_expanded.config import default_config, save_config
|
||||
|
||||
|
||||
class CommunityCliContractTests(unittest.TestCase):
|
||||
def call(self, args, modules=None):
|
||||
stdout, stderr = io.StringIO(), io.StringIO()
|
||||
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
|
||||
if modules is None:
|
||||
code = cli.main(args)
|
||||
else:
|
||||
with mock.patch.object(cli, "_module", side_effect=lambda name: modules[name]):
|
||||
code = cli.main(args)
|
||||
return code, stdout.getvalue(), stderr.getvalue()
|
||||
|
||||
def aggregate(self):
|
||||
return {"ok": True, "runtime":{"role":"standalone", "ready":True, "running":True},
|
||||
"connection":{"ok":True,"verified":False,"state":"closed"},
|
||||
"boot":{"configured":True,"enabled":True,"linger":True,"encrypted_credential_present":True,
|
||||
"service_active":True,"service_state":"active"},
|
||||
"desired_connection":"disconnected"}
|
||||
|
||||
def test_short_and_legacy_connect_share_flags(self):
|
||||
for args in (["connect", "--replace"], ["connect", "chatgpt", "--replace"]):
|
||||
value=cli.build_parser().parse_args(args)
|
||||
self.assertTrue(value.replace)
|
||||
self.assertFalse(value.foreground)
|
||||
|
||||
def test_common_json_and_profile_options_survive_nested_parsers(self):
|
||||
for args in (["--json","--state-dir","/qa/custom","service","boot","disable"],
|
||||
["service","boot","disable","--json","--state-dir","/qa/custom"]):
|
||||
value=cli.build_parser().parse_args(args)
|
||||
self.assertTrue(value.json)
|
||||
self.assertEqual(value.state_dir,"/qa/custom")
|
||||
self.assertEqual(value.boot_action,"disable")
|
||||
|
||||
def test_plain_status_separates_live_connection_and_boot(self):
|
||||
life=SimpleNamespace(status=mock.Mock(return_value=self.aggregate()))
|
||||
code,text,_=self.call(["status"],{"lifecycle":life})
|
||||
self.assertEqual(code,0)
|
||||
self.assertIn("Local tools: ready",text)
|
||||
self.assertIn("ChatGPT connection: closed",text)
|
||||
self.assertIn("Automatic startup: enabled",text)
|
||||
self.assertNotIn("connected (verified)",text)
|
||||
|
||||
def test_unavailable_status_is_not_presented_as_stopped_or_disabled(self):
|
||||
value={"ok":True,"runtime":{"status_unavailable":True},"connection":{"state":"unavailable"},"boot":{},"desired_connection":"unavailable"}
|
||||
life=SimpleNamespace(status=mock.Mock(return_value=value))
|
||||
code,text,_=self.call(["status"],{"lifecycle":life})
|
||||
self.assertEqual(code,0)
|
||||
self.assertIn("Local tools: unavailable",text)
|
||||
self.assertIn("Automatic startup: unavailable",text)
|
||||
self.assertNotIn("Local tools: stopped",text)
|
||||
|
||||
def test_json_status_is_one_versioned_object(self):
|
||||
life=SimpleNamespace(status=mock.Mock(return_value=self.aggregate()))
|
||||
code,text,_=self.call(["status","--json"],{"lifecycle":life})
|
||||
value=json.loads(text)
|
||||
self.assertEqual(code,0)
|
||||
self.assertEqual(value["schema_version"],1)
|
||||
self.assertEqual(value["version"],"0.3.1")
|
||||
self.assertEqual(value["result"]["desired_connection"],"disconnected")
|
||||
self.assertFalse(value["result"]["connection"]["verified"])
|
||||
|
||||
def test_scoped_status_uses_same_model_and_includes_intent(self):
|
||||
life=SimpleNamespace(status=mock.Mock(return_value=self.aggregate()))
|
||||
code,text,_=self.call(["--json","status","connection"],{"lifecycle":life})
|
||||
data=json.loads(text)["result"]
|
||||
self.assertEqual(code,0)
|
||||
self.assertEqual(data["state"],"closed")
|
||||
self.assertEqual(data["desired_connection"],"disconnected")
|
||||
self.assertNotIn("runtime",data)
|
||||
|
||||
def test_doctor_is_distinct_and_does_not_use_status(self):
|
||||
runtime=SimpleNamespace(doctor=mock.Mock(return_value={"ok":False,"compatible_debian_13_amd64":True,
|
||||
"display":"text tools","missing_commands":["tmux"],"issues":[]}))
|
||||
code,text,_=self.call(["doctor"],{"runtime":runtime})
|
||||
self.assertEqual(code,1)
|
||||
self.assertIn("Missing command: tmux",text)
|
||||
self.assertIn("use fb status",text)
|
||||
|
||||
def test_json_new_connect_never_prompts_or_starts_services(self):
|
||||
with tempfile.TemporaryDirectory() as td, mock.patch("builtins.input") as ask, \
|
||||
mock.patch.object(cli,"connect_chatgpt") as connect, mock.patch.object(cli,"_module") as modules:
|
||||
code,text,_=self.call(["--state-dir",td,"connect","--json"])
|
||||
self.assertEqual(code,2)
|
||||
self.assertEqual(json.loads(text)["result"]["error"],"INTERACTION_REQUIRED")
|
||||
ask.assert_not_called();connect.assert_not_called();modules.assert_not_called()
|
||||
|
||||
def test_structured_foreground_next_action_preserves_temporary_mode(self):
|
||||
with tempfile.TemporaryDirectory() as td,mock.patch.object(cli,"connect_chatgpt") as connect:
|
||||
code,text,_=self.call(["--state-dir",td,"connect","--foreground","--json"])
|
||||
self.assertEqual(code,2)
|
||||
self.assertTrue(json.loads(text)["result"]["next_action"].endswith(" connect --foreground"))
|
||||
connect.assert_not_called()
|
||||
|
||||
def test_menu_cancel_returns_cleanly_without_dispatch(self):
|
||||
with mock.patch.object(cli.sys.stdin,"isatty",return_value=True),mock.patch("builtins.input",side_effect=KeyboardInterrupt):
|
||||
code,text,errors=self.call([])
|
||||
self.assertEqual(code,130)
|
||||
self.assertIn("Cancelled.",errors)
|
||||
|
||||
def test_json_setup_reports_interaction_without_reading_prompts(self):
|
||||
with mock.patch("builtins.input") as ask,mock.patch.object(cli,"setup") as setup:
|
||||
code,text,_=self.call(["setup","--json"])
|
||||
self.assertEqual(code,2)
|
||||
self.assertEqual(json.loads(text)["result"]["error"],"INTERACTION_REQUIRED")
|
||||
ask.assert_not_called();setup.assert_not_called()
|
||||
|
||||
def test_saved_json_connect_keeps_human_progress_off_stdout(self):
|
||||
life=SimpleNamespace(status=mock.Mock(return_value=self.aggregate()))
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
state=Path(td);(state/"autostart").mkdir();(state/"autostart/installation.json").write_text("{}")
|
||||
def connect(*args,**kwargs):
|
||||
print("Checking the saved connection.")
|
||||
return 0
|
||||
with mock.patch.object(cli,"connect_chatgpt",side_effect=connect):
|
||||
code,text,errors=self.call(["--state-dir",td,"connect","--json"],{"lifecycle":life})
|
||||
self.assertEqual(code,0)
|
||||
self.assertEqual(json.loads(text)["result"]["runtime"]["ready"],True)
|
||||
self.assertIn("Checking the saved connection.",errors)
|
||||
self.assertNotIn("Checking",text)
|
||||
|
||||
def test_canonical_disconnect_and_legacy_have_explicit_different_routes(self):
|
||||
canonical=SimpleNamespace(disconnect=mock.Mock(return_value={"ok":True}))
|
||||
legacy=SimpleNamespace(disconnect=mock.Mock(return_value={"ok":True}))
|
||||
modules={"lifecycle":canonical,"connection_background":legacy}
|
||||
self.assertEqual(self.call(["disconnect"],modules)[0],0)
|
||||
canonical.disconnect.assert_called_once();legacy.disconnect.assert_not_called()
|
||||
self.assertEqual(self.call(["disconnect","chatgpt"],modules)[0],0)
|
||||
legacy.disconnect.assert_called_once()
|
||||
|
||||
def test_noninteractive_service_start_does_not_invent_authentication_gate(self):
|
||||
life=SimpleNamespace(service_start=mock.Mock(return_value={"ok":True,"state":"started","scope":"local_tools"}))
|
||||
code,text,_=self.call(["service","start","--json"],{"lifecycle":life})
|
||||
self.assertEqual(code,0)
|
||||
self.assertEqual(json.loads(text)["result"]["scope"],"local_tools")
|
||||
life.service_start.assert_called_once()
|
||||
|
||||
def test_first_node_service_start_reports_interaction_before_provisioning(self):
|
||||
life=SimpleNamespace(status=mock.Mock(return_value={"boot":{"configured":False}}),service_start=mock.Mock())
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
state=Path(td);config=default_config();config["role"]="node";save_config(state,config)
|
||||
code,text,_=self.call(["--state-dir",td,"service","start","--json"],{"lifecycle":life})
|
||||
self.assertEqual(code,2)
|
||||
self.assertEqual(json.loads(text)["result"]["error"],"INTERACTION_REQUIRED")
|
||||
life.service_start.assert_not_called()
|
||||
|
||||
def test_configured_node_service_start_remains_noninteractive(self):
|
||||
life=SimpleNamespace(status=mock.Mock(return_value={"boot":{"configured":True}}),service_start=mock.Mock(return_value={"ok":True,"state":"start_requested"}))
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
state=Path(td);config=default_config();config["role"]="node";save_config(state,config)
|
||||
code,text,_=self.call(["--state-dir",td,"service","start","--json"],{"lifecycle":life})
|
||||
self.assertEqual(code,0)
|
||||
self.assertEqual(json.loads(text)["result"]["state"],"start_requested")
|
||||
life.service_start.assert_called_once()
|
||||
|
||||
def test_boot_disable_and_legacy_autostart_disable_are_not_retargeted(self):
|
||||
life=SimpleNamespace(boot_disable=mock.Mock(return_value={"ok":True,"state":"boot-disabled"}))
|
||||
auto=SimpleNamespace(disable=mock.Mock(return_value={"ok":True,"state":"disabled"}),
|
||||
status=mock.Mock(return_value={"service_state":"inactive"}))
|
||||
modules={"lifecycle":life,"autostart":auto}
|
||||
self.call(["service","boot","disable"],modules)
|
||||
life.boot_disable.assert_called_once();auto.disable.assert_not_called()
|
||||
self.call(["autostart","disable"],modules)
|
||||
auto.disable.assert_called_once()
|
||||
|
||||
def test_forget_requires_explicit_confirmation_without_calling_destructive_api(self):
|
||||
life=SimpleNamespace(forget_credentials=mock.Mock())
|
||||
code,text,_=self.call(["service","credentials","forget","--json"],{"lifecycle":life})
|
||||
self.assertEqual(code,2)
|
||||
self.assertEqual(json.loads(text)["result"]["error"],"CONFIRMATION_REQUIRED")
|
||||
life.forget_credentials.assert_not_called()
|
||||
|
||||
def test_forget_confirmation_instruction_preserves_custom_profile(self):
|
||||
life=SimpleNamespace(forget_credentials=mock.Mock())
|
||||
code,text,_=self.call(["--state-dir","/qa/profile with spaces","service","credentials","forget","--json"],{"lifecycle":life})
|
||||
self.assertEqual(code,2)
|
||||
self.assertEqual(json.loads(text)["result"]["next_action"],"fb --state-dir '/qa/profile with spaces' service credentials forget --confirm")
|
||||
life.forget_credentials.assert_not_called()
|
||||
|
||||
def test_explicit_confirm_forget_calls_one_narrow_api(self):
|
||||
life=SimpleNamespace(forget_credentials=mock.Mock(return_value={"ok":True,"state":"forgotten"}))
|
||||
self.assertEqual(self.call(["service","credentials","forget","--confirm","--json"],{"lifecycle":life})[0],0)
|
||||
life.forget_credentials.assert_called_once()
|
||||
|
||||
def test_console_login_subcommand_calls_only_login_policy(self):
|
||||
startup=SimpleNamespace(status=mock.Mock(return_value={"ok":True,"enabled":False}))
|
||||
code,text,_=self.call(["console","login","status","--json"],{"console_startup":startup})
|
||||
self.assertEqual(code,0)
|
||||
self.assertFalse(json.loads(text)["result"]["enabled"])
|
||||
startup.status.assert_called_once()
|
||||
|
||||
def test_terminal_service_group_routes_same_explicit_target(self):
|
||||
args=cli.build_parser().parse_args(["tty","service","status","--node","N2"])
|
||||
with mock.patch.object(cli,"_target_tty",return_value={"ok":True}) as target,contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(cli.tty_command(args,Path("/qa/profile")),0)
|
||||
target.assert_called_once_with(Path("/qa/profile"),"N2",service="status")
|
||||
|
||||
def test_noninteractive_typed_text_without_text_never_prompts(self):
|
||||
with mock.patch("builtins.input") as ask,mock.patch.object(cli,"tty_command") as tty:
|
||||
code,text,_=self.call(["tty","type","a"*32,"--json"])
|
||||
self.assertEqual(code,2)
|
||||
self.assertEqual(json.loads(text)["result"]["error"],"INTERACTION_REQUIRED")
|
||||
ask.assert_not_called();tty.assert_not_called()
|
||||
|
||||
def test_help_uses_same_versioned_guide_without_runtime(self):
|
||||
guide=SimpleNamespace(get_guide=mock.Mock(return_value={"ok":True,"topic":"commands","text":"Command guide"}))
|
||||
code,text,_=self.call(["help","commands","--json"],{"guide":guide})
|
||||
self.assertEqual(code,0)
|
||||
self.assertEqual(json.loads(text)["result"]["topic"],"commands")
|
||||
guide.get_guide.assert_called_once_with("commands")
|
||||
|
||||
def test_failed_pause_does_not_claim_success(self):
|
||||
life=SimpleNamespace(disconnect=mock.Mock(return_value={"ok":False,"error":"TEST_FAILURE"}))
|
||||
code,text,errors=self.call(["disconnect"],{"lifecycle":life})
|
||||
self.assertEqual(code,1)
|
||||
self.assertNotIn("paused until",text)
|
||||
self.assertIn("TEST_FAILURE",errors)
|
||||
|
||||
|
||||
if __name__=="__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Routing regression tests for package CLI additions; no external services."""
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from framebridge_expanded import cli
|
||||
|
||||
class CliExtensionTests(unittest.TestCase):
|
||||
SID = "c" * 32
|
||||
|
||||
def parsed(self, *parts):
|
||||
return cli.build_parser().parse_args(list(parts))
|
||||
|
||||
def test_sudo_parser_keeps_node_and_console_variables(self):
|
||||
for mode in ("on", "status", "off"):
|
||||
args = self.parsed("tty", "sudo", mode, self.SID, "--node", "NodeCase")
|
||||
self.assertEqual((args.tty_command, args.sudo_mode, args.session_id, args.node),
|
||||
("sudo", mode, self.SID, "NodeCase"))
|
||||
|
||||
def test_sudo_command_routes_exact_session_and_target(self):
|
||||
args = self.parsed("tty", "sudo", "on", self.SID, "--node", "NodeCase")
|
||||
with mock.patch.object(cli, "_target_tty", return_value={"ok": True}) as target, \
|
||||
contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(cli.tty_command(args, Path("/qa/profile")), 0)
|
||||
target.assert_called_once_with(Path("/qa/profile"), "NodeCase",
|
||||
payload={"authorized": True, "op": "sudo", "session_id": self.SID, "mode": "on"})
|
||||
|
||||
def test_remote_request_uses_profile_and_existing_fleet_target(self):
|
||||
seen = []
|
||||
def execute_node(node, **kwargs):
|
||||
seen.append((node, os.environ.get("FRAMEBRIDGE_EXPANDED_STATE"), kwargs))
|
||||
return {"ok": True}
|
||||
def request(execute, target, payload):
|
||||
return execute(target=target, script="printf bounded", authorized=True,
|
||||
timeout_s=30, job_id="qa", sha256="d" * 64, include_stdout=True)
|
||||
modules = {
|
||||
"fleet": SimpleNamespace(execute_node=execute_node),
|
||||
"tty_mcp": SimpleNamespace(_remote_request=request),
|
||||
}
|
||||
with mock.patch.dict(os.environ, {"FRAMEBRIDGE_EXPANDED_STATE": "/previous/profile"}), \
|
||||
mock.patch.object(cli, "_module", side_effect=lambda name: modules[name]):
|
||||
value = cli._target_tty(Path("/qa/explicit"), "NodeCase", payload={"op": "observe"})
|
||||
self.assertEqual(os.environ["FRAMEBRIDGE_EXPANDED_STATE"], "/previous/profile")
|
||||
self.assertTrue(value["ok"])
|
||||
self.assertEqual(seen[0][:2], ("NodeCase", "/qa/explicit"))
|
||||
self.assertNotIn("target", seen[0][2])
|
||||
|
||||
def test_remote_transport_failure_restores_absent_profile_without_local_fallback(self):
|
||||
modules = {
|
||||
"fleet": SimpleNamespace(execute_node=mock.Mock()),
|
||||
"tty_mcp": SimpleNamespace(_remote_request=mock.Mock(side_effect=RuntimeError("transport failed"))),
|
||||
}
|
||||
with mock.patch.dict(os.environ, {}, clear=True), \
|
||||
mock.patch.object(cli, "_module", side_effect=lambda name: modules[name]):
|
||||
with self.assertRaisesRegex(RuntimeError, "transport failed"):
|
||||
cli._target_tty(Path("/qa/explicit"), "NodeCase", payload={"op": "status"})
|
||||
self.assertNotIn("FRAMEBRIDGE_EXPANDED_STATE", os.environ)
|
||||
modules["fleet"].execute_node.assert_not_called()
|
||||
|
||||
def test_remote_service_action_uses_wire_cli_instead_of_local_daemon(self):
|
||||
seen = []
|
||||
def wire(execute, target, arguments):
|
||||
seen.append((target, arguments, os.environ.get("FRAMEBRIDGE_EXPANDED_STATE")))
|
||||
return {"ok": True}
|
||||
modules = {
|
||||
"fleet": SimpleNamespace(execute_node=mock.Mock()),
|
||||
"tty_mcp": SimpleNamespace(_exec_json=wire),
|
||||
}
|
||||
with mock.patch.object(cli, "_module", side_effect=lambda name: modules[name]):
|
||||
cli._target_tty(Path("/qa/profile"), "NodeCase", service="status")
|
||||
self.assertEqual(seen, [("NodeCase", ["status"], "/qa/profile")])
|
||||
|
||||
def test_local_request_keeps_explicit_profile(self):
|
||||
request = mock.Mock(return_value={"ok": True})
|
||||
with mock.patch.object(cli, "_module", return_value=SimpleNamespace(request=request)):
|
||||
cli._target_tty(Path("/qa/profile"), "LOCAL", payload={"op": "status"})
|
||||
request.assert_called_once_with(Path("/qa/profile"), {"op": "status"})
|
||||
|
||||
def test_failed_target_result_is_not_reported_as_success(self):
|
||||
args = self.parsed("tty", "close", self.SID, "--node", "NodeCase")
|
||||
with mock.patch.object(cli, "_target_tty", return_value={"ok": False, "error": "REMOTE_FAILED"}), \
|
||||
contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(cli.tty_command(args, Path("/qa/profile")), 1)
|
||||
|
||||
def test_text_transcript_observe_retains_node_argument(self):
|
||||
args = self.parsed("tty", "observe", self.SID, "--node", "NodeCase")
|
||||
with mock.patch.object(cli, "_target_tty", return_value={"ok": True}) as target, \
|
||||
contextlib.redirect_stdout(io.StringIO()):
|
||||
cli.tty_command(args, Path("/qa/profile"))
|
||||
self.assertEqual(target.call_args.kwargs["payload"]["op"], "observe")
|
||||
self.assertEqual(target.call_args.args[1], "NodeCase")
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,204 @@
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import pty
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from framebridge_expanded import connection_background as background
|
||||
from framebridge_expanded import tunnel
|
||||
|
||||
ID = "tunnel_background_fixture"
|
||||
MCP = "http://127.0.0.1:20865/mcp"
|
||||
FAKE = "SYNTHETIC_BACKGROUND_ONLY_0123456789"
|
||||
CLIENT = r'''
|
||||
import datetime,http.server,json,os,sys,time
|
||||
from pathlib import Path
|
||||
started=datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||||
identifier=sys.argv[sys.argv.index('--control-plane.tunnel-id')+1]
|
||||
mcp=sys.argv[sys.argv.index('--mcp.server-url')+1].removeprefix('url=')
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def log_message(self,*args): pass
|
||||
def do_GET(self):
|
||||
if self.path=='/readyz': body=b'ready'
|
||||
elif self.path=='/healthz': body=b'live'
|
||||
elif self.path=='/api/status': body=json.dumps({'control_plane_tunnel_id':identifier,'mcp_server_url':mcp,'started_at':started,'tunnel_metadata':{'ID':identifier}}).encode()
|
||||
elif self.path=='/metrics': body=('commands_poll_last_successful_timestamp_seconds '+str(time.time())+'\n').encode()
|
||||
else: self.send_response(404);self.end_headers();return
|
||||
self.send_response(200);self.end_headers();self.wfile.write(body)
|
||||
server=http.server.HTTPServer(('127.0.0.1',0),Handler)
|
||||
Path(sys.argv[sys.argv.index('--health.url-file')+1]).write_text('http://127.0.0.1:'+str(server.server_port))
|
||||
print('TECHNICAL_FIXTURE '+os.environ['FRAMEBRIDGE_EXPANDED_RUNTIME_KEY'],flush=True)
|
||||
server.serve_forever()
|
||||
'''
|
||||
|
||||
|
||||
def command(code=CLIENT):
|
||||
return [sys.executable, "-u", "-c", code, "--control-plane.tunnel-id", ID,
|
||||
"--mcp.server-url", "url=" + MCP]
|
||||
|
||||
|
||||
class BackgroundTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory(prefix="fb-background-test-")
|
||||
self.state = Path(self.temp.name)
|
||||
|
||||
def tearDown(self):
|
||||
background.disconnect(self.state)
|
||||
self.temp.cleanup()
|
||||
|
||||
def start(self, wait=10):
|
||||
output = io.StringIO()
|
||||
rc = background.start(command(), FAKE, self.state, output=output, wait_timeout=wait)
|
||||
self.assertEqual(rc, 0, output.getvalue())
|
||||
return output.getvalue()
|
||||
|
||||
def test_real_detached_worker_ready_private_logs_and_no_credential_on_disk(self):
|
||||
output = self.start()
|
||||
current = background.status(self.state)
|
||||
self.assertTrue(current["alive"])
|
||||
self.assertTrue(current["verified"])
|
||||
self.assertIn("terminal can be closed", output)
|
||||
self.assertNotIn(FAKE, output)
|
||||
self.assertNotIn("TECHNICAL_FIXTURE", output)
|
||||
worker = Path('/proc') / str(current['pid'])
|
||||
self.assertNotIn(FAKE.encode(), (worker / 'cmdline').read_bytes())
|
||||
self.assertNotIn(FAKE.encode(), (worker / 'environ').read_bytes())
|
||||
log = self.state / 'logs/chatgpt/connection.log'
|
||||
deadline = time.monotonic()+3
|
||||
while 'TECHNICAL_FIXTURE' not in log.read_text() and time.monotonic()<deadline:
|
||||
time.sleep(.05)
|
||||
self.assertIn('TECHNICAL_FIXTURE [REDACTED]', log.read_text())
|
||||
for path in self.state.rglob('*'):
|
||||
if path.is_file():
|
||||
self.assertNotIn(FAKE.encode(), path.read_bytes(), str(path))
|
||||
self.assertEqual((self.state / 'connection').stat().st_mode & 0o777, 0o700)
|
||||
for name in ('owner.lock','status.json'):
|
||||
self.assertEqual((self.state / 'connection' / name).stat().st_mode & 0o777, 0o600)
|
||||
stopped = background.disconnect(self.state)
|
||||
self.assertTrue(stopped['ok'])
|
||||
self.assertFalse(background.status(self.state)['alive'])
|
||||
self.assertTrue(background.disconnect(self.state)['ok'])
|
||||
|
||||
def test_second_connect_reuses_owned_profile_without_duplicate_worker(self):
|
||||
self.start()
|
||||
pid = background.status(self.state)['pid']
|
||||
output = io.StringIO()
|
||||
self.assertEqual(background.start(command(), FAKE, self.state, output=output, wait_timeout=2),0)
|
||||
self.assertEqual(background.status(self.state)['pid'],pid)
|
||||
self.assertIn('already owns this profile',output.getvalue())
|
||||
|
||||
def test_startup_failure_never_reports_ready_and_releases_lock(self):
|
||||
output = io.StringIO()
|
||||
rc = background.start(command('raise SystemExit(7)'), FAKE, self.state, output=output, wait_timeout=5)
|
||||
self.assertEqual(rc,1)
|
||||
self.assertNotIn('[OK]',output.getvalue())
|
||||
self.assertFalse(background.status(self.state)['alive'])
|
||||
self.start()
|
||||
|
||||
def test_pending_is_explicit_and_keeps_background_worker(self):
|
||||
output=io.StringIO()
|
||||
rc=background.start(command('import time;time.sleep(60)'),FAKE,self.state,output=output,wait_timeout=.7)
|
||||
self.assertEqual(rc,1)
|
||||
self.assertIn('continues retrying in the background',output.getvalue())
|
||||
self.assertTrue(background.status(self.state)['alive'])
|
||||
self.assertFalse(background.status(self.state)['verified'])
|
||||
|
||||
def test_closed_controlling_terminal_does_not_stop_verified_tunnel(self):
|
||||
master,slave=pty.openpty()
|
||||
root=Path(background.__file__).resolve().parents[1]
|
||||
code=("import os,fcntl,termios,sys,signal;os.setsid();fcntl.ioctl(0,termios.TIOCSCTTY,0);"
|
||||
+"sys.path.insert(0,"+repr(str(root))+");from framebridge_expanded import connection_background as b;"
|
||||
+"rc=b.start("+repr(command())+","+repr(FAKE)+","+repr(str(self.state))+",wait_timeout=10);"
|
||||
+"signal.pause()")
|
||||
parent=subprocess.Popen([sys.executable,'-c',code],stdin=slave,stdout=slave,stderr=slave,close_fds=True)
|
||||
os.close(slave)
|
||||
try:
|
||||
deadline=time.monotonic()+12
|
||||
while time.monotonic()<deadline and not background.status(self.state)['verified']:
|
||||
self.assertIsNone(parent.poll())
|
||||
time.sleep(.1)
|
||||
self.assertTrue(background.status(self.state)['verified'])
|
||||
os.close(master);master=None
|
||||
parent.wait(timeout=3)
|
||||
time.sleep(.3)
|
||||
self.assertTrue(background.status(self.state)['verified'])
|
||||
finally:
|
||||
if master is not None: os.close(master)
|
||||
if parent.poll() is None: parent.terminate();parent.wait(timeout=3)
|
||||
|
||||
def test_stale_heartbeat_is_not_verified_and_forged_message_is_not_printed(self):
|
||||
self.start()
|
||||
pid=background.status(self.state)['pid']
|
||||
os.kill(pid,signal.SIGSTOP)
|
||||
try:
|
||||
path=self.state/'connection/status.json'
|
||||
record=json.loads(path.read_text());record['verified_at']=time.time()-30
|
||||
record['message']='\x1b[31m'+FAKE
|
||||
background._write(path,record)
|
||||
current=background.status(self.state)
|
||||
self.assertFalse(current['verified'])
|
||||
self.assertEqual(current['state'],'retrying')
|
||||
self.assertNotIn(FAKE,current['message'])
|
||||
self.assertNotIn('\x1b',current['message'])
|
||||
finally:
|
||||
os.kill(pid,signal.SIGCONT)
|
||||
|
||||
def test_forged_identity_does_not_signal_unrelated_process(self):
|
||||
child=subprocess.Popen([sys.executable,'-c','import time;time.sleep(30)'],start_new_session=True)
|
||||
try:
|
||||
directory=background._directory(self.state)
|
||||
lock=background._checked_fd(directory/'owner.lock',os.O_RDWR,create=True)
|
||||
try:
|
||||
record=background._identity(child.pid,self.state,lock)
|
||||
record.update(state='ready',verified_at=time.time())
|
||||
background._write(directory/'status.json',record)
|
||||
with mock.patch.object(signal,'pidfd_send_signal') as send:
|
||||
self.assertTrue(background.disconnect(self.state)['ok'])
|
||||
send.assert_not_called()
|
||||
self.assertIsNone(child.poll())
|
||||
self.assertFalse(background.status(self.state)['verified'])
|
||||
finally: os.close(lock)
|
||||
finally:
|
||||
child.terminate();child.wait(timeout=3)
|
||||
|
||||
def test_foreground_mode_cannot_overlap_owned_background_connection(self):
|
||||
self.start()
|
||||
with self.assertRaisesRegex(RuntimeError, 'already owns'):
|
||||
background.run_foreground(command(),FAKE,self.state,output=io.StringIO())
|
||||
self.assertTrue(background.status(self.state)['verified'])
|
||||
|
||||
def test_ctrl_c_before_published_state_reaps_owned_supervisor(self):
|
||||
real_spawn=subprocess.Popen
|
||||
seen=[]
|
||||
def spawn(*args,**kwargs):
|
||||
child=real_spawn(*args,**kwargs)
|
||||
seen.append(child)
|
||||
return child
|
||||
real_sleep=time.sleep
|
||||
interrupted=[]
|
||||
def interrupt_once(seconds):
|
||||
if not interrupted:
|
||||
interrupted.append(True)
|
||||
raise KeyboardInterrupt
|
||||
return real_sleep(seconds)
|
||||
with mock.patch.object(background.subprocess,'Popen',side_effect=spawn), mock.patch.object(background.time,'sleep',side_effect=interrupt_once):
|
||||
rc=background.start(command(),FAKE,self.state,output=io.StringIO(),wait_timeout=5)
|
||||
self.assertEqual(rc,130)
|
||||
self.assertTrue(seen)
|
||||
self.assertIsNotNone(seen[0].poll())
|
||||
self.assertFalse(background.status(self.state)['alive'])
|
||||
|
||||
def test_interactive_background_does_not_prompt_when_already_running(self):
|
||||
self.start()
|
||||
with mock.patch.object(tunnel,'_hidden') as prompt:
|
||||
self.assertEqual(tunnel.connect_chatgpt('/unused',self.state,MCP,output=io.StringIO()),0)
|
||||
prompt.assert_not_called()
|
||||
|
||||
|
||||
if __name__=='__main__': unittest.main()
|
||||
@@ -0,0 +1,280 @@
|
||||
import datetime
|
||||
import http.server
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from framebridge_expanded import connection_monitor as monitor
|
||||
from framebridge_expanded import tunnel
|
||||
|
||||
ID = "tunnel_synthetic_connection_test"
|
||||
MCP = "http://127.0.0.1:20865/mcp"
|
||||
|
||||
|
||||
class HTTPFixture:
|
||||
def __init__(self, root):
|
||||
self.started = time.time()
|
||||
self.ready = "ready"
|
||||
self.ready_code = 200
|
||||
self.metadata_error = ""
|
||||
self.poll = self.started
|
||||
self.tunnel = ID
|
||||
self.mcp = MCP
|
||||
self.hits = []
|
||||
fixture = self
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
fixture.hits.append(self.path)
|
||||
if self.path == "/readyz":
|
||||
code, data = fixture.ready_code, fixture.ready.encode()
|
||||
elif self.path == "/api/status":
|
||||
code = 200
|
||||
data = json.dumps({"control_plane_tunnel_id": fixture.tunnel,
|
||||
"mcp_server_url": fixture.mcp,
|
||||
"started_at": datetime.datetime.fromtimestamp(fixture.started, datetime.timezone.utc).isoformat(),
|
||||
"tunnel_metadata": {"ID": fixture.tunnel},
|
||||
"tunnel_metadata_error": fixture.metadata_error}).encode()
|
||||
else:
|
||||
code, data = 200, ("commands_poll_last_successful_timestamp_seconds " + str(fixture.poll) + "\n").encode()
|
||||
self.send_response(code)
|
||||
if code == 302:
|
||||
self.send_header("Location", "http://192.0.2.1/forbidden")
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
self.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
self.file = Path(root) / "health.url"
|
||||
self.file.write_text("http://127.0.0.1:" + str(self.server.server_port))
|
||||
|
||||
def probe(self):
|
||||
return monitor.probe(self.file, ID, MCP, self.started)
|
||||
|
||||
def close(self):
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join()
|
||||
|
||||
|
||||
class ReadinessTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory(prefix="fb-health-test-")
|
||||
self.fixture = HTTPFixture(self.temp.name)
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.addCleanup(self.fixture.close)
|
||||
|
||||
def test_ready_requires_local_mcp_identity_and_authenticated_poll(self):
|
||||
self.assertEqual(self.fixture.probe()[0], "ready")
|
||||
self.assertEqual(self.fixture.hits, ["/readyz", "/api/status", "/metrics"])
|
||||
|
||||
def test_no_success_for_live_client_without_successful_poll(self):
|
||||
self.fixture.poll = 0
|
||||
self.assertEqual(self.fixture.probe()[0], "access")
|
||||
|
||||
def test_no_success_for_stale_or_future_poll(self):
|
||||
self.fixture.poll = self.fixture.started - 200
|
||||
self.assertEqual(self.fixture.probe()[0], "access")
|
||||
self.fixture.poll = self.fixture.started + 200
|
||||
self.assertEqual(self.fixture.probe()[0], "access")
|
||||
|
||||
def test_partial_ready_auth_and_timeout_are_not_full_ready(self):
|
||||
for suffix in [" (mcp initialize requires auth: synthetic)", " (mcp startup probe timed out: synthetic)"]:
|
||||
self.fixture.ready = "ready" + suffix
|
||||
self.assertEqual(self.fixture.probe()[0], "checking")
|
||||
self.assertNotIn("/metrics", self.fixture.hits)
|
||||
|
||||
def test_changed_identity_or_old_startup_not_accepted(self):
|
||||
self.fixture.tunnel = "tunnel_other"
|
||||
self.assertEqual(self.fixture.probe()[0], "checking")
|
||||
self.fixture.tunnel = ID
|
||||
self.assertEqual(monitor.probe(self.fixture.file, ID, MCP, self.fixture.started + 50)[0], "checking")
|
||||
|
||||
def test_metadata_error_is_not_connected(self):
|
||||
self.fixture.metadata_error = "HTTP 403 synthetic fixture"
|
||||
self.assertEqual(self.fixture.probe()[0], "rejected")
|
||||
|
||||
def test_redirects_and_proxy_environment_do_not_redirect_probe(self):
|
||||
self.fixture.ready_code = 302
|
||||
with mock.patch.dict(os.environ, {"HTTP_PROXY": "http://192.0.2.2:1", "NO_PROXY": ""}):
|
||||
self.assertEqual(self.fixture.probe()[0], "checking")
|
||||
self.assertEqual(self.fixture.hits, ["/readyz"])
|
||||
|
||||
def test_health_file_rejects_remote_credentials_query_and_symlink(self):
|
||||
for value in ["http://192.0.2.1:8", "http://user:pw@127.0.0.1:8", "http://127.0.0.1:8/?token=x", "http://localhost:8", "https://127.0.0.1:8"]:
|
||||
self.fixture.file.write_text(value)
|
||||
with self.assertRaises(monitor.ConnectionMonitorError):
|
||||
monitor.health_endpoint(self.fixture.file)
|
||||
self.fixture.file.unlink()
|
||||
self.fixture.file.symlink_to(Path(self.temp.name) / "missing")
|
||||
with self.assertRaises(OSError):
|
||||
monitor.health_endpoint(self.fixture.file)
|
||||
|
||||
|
||||
class PrivateLogTests(unittest.TestCase):
|
||||
def test_rotation_is_bounded_and_preserves_only_redacted_private_records(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
log = monitor.PrivateLog(td, "SYNTHETIC_SECRET", max_bytes=220)
|
||||
for _ in range(20):
|
||||
log.write("event SYNTHETIC_SECRET " + "x" * 55)
|
||||
log.close()
|
||||
files = list((Path(td) / "logs/chatgpt").glob("connection.log*"))
|
||||
self.assertLessEqual(len(files), 3)
|
||||
for p in files:
|
||||
self.assertLessEqual(p.stat().st_size, 220)
|
||||
self.assertEqual(p.stat().st_mode & 0o777, 0o600)
|
||||
self.assertNotIn("SYNTHETIC_SECRET", p.read_text())
|
||||
|
||||
def test_no_symlink_hardlink_or_permissive_log_is_written(self):
|
||||
for kind in ["symlink", "hardlink", "permissions"]:
|
||||
with self.subTest(kind=kind), tempfile.TemporaryDirectory() as td:
|
||||
log = monitor.PrivateLog(td, "fixture")
|
||||
p = log.path
|
||||
target = Path(td) / "untouched"
|
||||
target.write_text("unchanged")
|
||||
target.chmod(0o600)
|
||||
if kind == "symlink":
|
||||
p.symlink_to(target)
|
||||
elif kind == "hardlink":
|
||||
os.link(target, p)
|
||||
else:
|
||||
p.write_text("unchanged")
|
||||
p.chmod(0o644)
|
||||
with self.assertRaises((OSError, monitor.ConnectionMonitorError)):
|
||||
log.write("must not be stored")
|
||||
self.assertEqual(target.read_text(), "unchanged")
|
||||
log.close()
|
||||
|
||||
def test_fifo_paths_are_rejected_without_waiting_for_a_writer(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
log = monitor.PrivateLog(td, "fixture")
|
||||
os.mkfifo(log.path, 0o600)
|
||||
with self.assertRaises((OSError, monitor.ConnectionMonitorError)):
|
||||
log.write("must not block")
|
||||
log.close()
|
||||
endpoint = Path(td) / "health.url"
|
||||
os.mkfifo(endpoint, 0o600)
|
||||
with self.assertRaises((OSError, monitor.ConnectionMonitorError)):
|
||||
monitor.health_endpoint(endpoint)
|
||||
|
||||
def test_capture_redacts_secret_split_across_reads_and_drops_oversize_line(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
secret = "FAKE_CROSS_CHUNK_CREDENTIAL"
|
||||
log = monitor.PrivateLog(td, secret)
|
||||
read_fd, write_fd = os.pipe()
|
||||
errors, stop = [], threading.Event()
|
||||
stream = os.fdopen(read_fd, "rb", buffering=0)
|
||||
worker = threading.Thread(target=monitor.capture_output, args=(stream, log, errors, stop))
|
||||
worker.start()
|
||||
for part in [b"debug " + secret[:9].encode(), secret[9:].encode() + b"\n", b"A" * (monitor.MAX_LINE + 100), b"\n"]:
|
||||
os.write(write_fd, part)
|
||||
os.close(write_fd)
|
||||
worker.join(timeout=4)
|
||||
self.assertFalse(worker.is_alive())
|
||||
self.assertFalse(errors)
|
||||
data = log.path.read_text()
|
||||
self.assertNotIn(secret, data)
|
||||
self.assertIn("debug [REDACTED]", data)
|
||||
self.assertIn("Oversized client output line omitted", data)
|
||||
stream.close()
|
||||
log.close()
|
||||
|
||||
def test_secret_headers_and_quoted_values_are_not_partially_persisted(self):
|
||||
for text in ['Cookie: first=abc; second=def', 'Authorization: Bearer abc xyz',
|
||||
'\"password\": \"secret with spaces\"', 'url?access_token=abc&next=def']:
|
||||
clean = monitor.redact(text, "fixture")
|
||||
for fragment in ["abc", "def", "secret with spaces", "first=", "second="]:
|
||||
self.assertNotIn(fragment, clean)
|
||||
self.assertIn("[REDACTED]", clean)
|
||||
|
||||
def test_normalization_cannot_reconstruct_a_secret_after_redaction(self):
|
||||
secret = "FAKE_CREDENTIAL"
|
||||
for middle in ["\x1b[31m", "\x01", "\u200b"]:
|
||||
value = "FAKE_" + middle + "CREDENTIAL"
|
||||
clean = monitor.redact(value, secret)
|
||||
self.assertNotIn(secret, clean)
|
||||
self.assertNotIn("CREDENTIAL", clean)
|
||||
self.assertIn("[REDACTED]", clean)
|
||||
|
||||
def test_proxy_url_userinfo_is_redacted(self):
|
||||
for value in ["proxy http://user:FAKE_PASSWORD@proxy.example:80/path", "https://name:p%40ss@host.example/next"]:
|
||||
clean = monitor.redact(value, "different_runtime_fixture")
|
||||
for fragment in ["user:", "FAKE_PASSWORD", "name:", "p%40ss"]:
|
||||
self.assertNotIn(fragment, clean)
|
||||
self.assertIn("[REDACTED]@", clean)
|
||||
|
||||
def test_non_tty_progress_does_not_animate_or_repeat_same_state(self):
|
||||
out = io.StringIO()
|
||||
progress = monitor.Progress(out)
|
||||
for _ in range(8):
|
||||
progress.change("checking", "Checking connection")
|
||||
progress.tick()
|
||||
self.assertEqual(out.getvalue(), "Checking connection\n")
|
||||
|
||||
|
||||
class ForegroundFailureTests(unittest.TestCase):
|
||||
def command(self, code):
|
||||
return [sys.executable, "-u", "-c", code,
|
||||
"--control-plane.tunnel-id", ID, "--mcp.server-url", "url=" + MCP]
|
||||
|
||||
def test_early_failure_has_clear_error_and_no_technical_terminal_dump(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
out = io.StringIO()
|
||||
result = tunnel.run_foreground(self.command("import sys;print('TECHNICAL_PRIVATE_DETAIL');sys.exit(3)"),
|
||||
"SYNTHETIC_KEY", state=td, output=out)
|
||||
self.assertEqual(result, 3)
|
||||
self.assertIn("[ERROR]", out.getvalue())
|
||||
self.assertNotIn("[OK]", out.getvalue())
|
||||
self.assertNotIn("TECHNICAL_PRIVATE_DETAIL", out.getvalue())
|
||||
self.assertIn("TECHNICAL_PRIVATE_DETAIL", (Path(td) / "logs/chatgpt/connection.log").read_text())
|
||||
|
||||
def test_exit_before_verified_state_does_not_report_success(self):
|
||||
seen = {}
|
||||
def spawn(*args, **kwargs):
|
||||
seen["child"] = subprocess.Popen(*args, **kwargs)
|
||||
return seen["child"]
|
||||
def probe(*args):
|
||||
seen["child"].wait(timeout=3)
|
||||
return "ready", "synthetic readiness arrived after exit"
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
out = io.StringIO()
|
||||
with mock.patch.object(monitor, "probe", side_effect=probe):
|
||||
result = tunnel.run_foreground(self.command("pass"), "SYNTHETIC_KEY", state=td, output=out, popen=spawn)
|
||||
self.assertEqual(result, 1)
|
||||
self.assertNotIn("[OK]", out.getvalue())
|
||||
|
||||
def test_cleanup_failure_does_not_skip_signal_restoration(self):
|
||||
import signal
|
||||
before = {sig: signal.getsignal(sig) for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP)}
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
out = io.StringIO()
|
||||
with mock.patch.object(tunnel, "_stop_owned", side_effect=OSError("synthetic cleanup failure")):
|
||||
rc = tunnel.run_foreground(self.command("pass"), "SYNTHETIC_KEY", state=td, output=out)
|
||||
self.assertEqual(rc, 1)
|
||||
self.assertIn("cleanup could not be fully verified", out.getvalue())
|
||||
self.assertEqual({sig: signal.getsignal(sig) for sig in before}, before)
|
||||
|
||||
def test_verified_connection_loss_is_explicit_and_ctrl_c_still_cleans_up(self):
|
||||
states = iter([("ready", "synthetic verified"), ("access", "synthetic poll lost")])
|
||||
def probe(*args):
|
||||
try:
|
||||
return next(states)
|
||||
except StopIteration:
|
||||
raise KeyboardInterrupt
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
out = io.StringIO()
|
||||
with mock.patch.object(monitor, "probe", side_effect=probe):
|
||||
result = tunnel.run_foreground(self.command("import time;time.sleep(60)"), "SYNTHETIC_KEY", state=td, output=out)
|
||||
self.assertEqual(result, 130)
|
||||
self.assertIn("[RETRYING]", out.getvalue())
|
||||
self.assertIn("[STOPPED]", out.getvalue())
|
||||
self.assertNotIn("Traceback", out.getvalue())
|
||||
@@ -0,0 +1,104 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from framebridge_expanded import console
|
||||
|
||||
class ConsoleTests(unittest.TestCase):
|
||||
def test_remote_alias_cannot_create_a_local_console(self):
|
||||
with tempfile.TemporaryDirectory() as path, mock.patch.object(Path, "mkdir") as mkdir:
|
||||
with self.assertRaisesRegex(RuntimeError, "REMOTE_CONSOLE_SSH_CONFIGURATION_REQUIRED"):
|
||||
console.context(Path(path) / "profile", "a-different-node", create=True)
|
||||
mkdir.assert_not_called()
|
||||
|
||||
def test_profile_socket_and_job_roots_are_isolated(self):
|
||||
with tempfile.TemporaryDirectory() as path:
|
||||
one = console.context(Path(path) / "one")
|
||||
two = console.context(Path(path) / "two")
|
||||
self.assertNotEqual(one["socket"], two["socket"])
|
||||
self.assertNotEqual(one["session"], two["session"])
|
||||
self.assertEqual(one["jobs_root"], Path(path) / "one/home/.local/share/framebridge/fbterm")
|
||||
self.assertLess(len(str(one["socket"]).encode()), 108)
|
||||
|
||||
def desktop(self, *, display=":0", vt="7", user=None):
|
||||
return SimpleNamespace(returncode=0, stdout=(
|
||||
"Type=x11\nUser=" + str(os.getuid() if user is None else user)
|
||||
+ "\nActive=yes\nRemote=no\nDisplay=" + display + "\nVTNr=" + vt + "\nTTY=\nLeader=3668\n"))
|
||||
|
||||
def test_lightdm_empty_tty_does_not_hide_active_user_desktop(self):
|
||||
with mock.patch.object(console.subprocess, "run", return_value=self.desktop()), \
|
||||
mock.patch.object(Path, "read_text", return_value="tty7\n"):
|
||||
self.assertTrue(console._active_graphical_display({"DISPLAY": ":0", "XDG_SESSION_ID": "19"}))
|
||||
|
||||
def test_private_virtual_display_is_not_chosen_as_user_desktop(self):
|
||||
with mock.patch.object(console.subprocess, "run", return_value=self.desktop()), \
|
||||
mock.patch.object(Path, "read_text", return_value="tty7\n"):
|
||||
self.assertFalse(console._active_graphical_display({"DISPLAY": ":99", "XDG_SESSION_ID": "19"}))
|
||||
|
||||
def test_changed_foreground_vt_prevents_gui_auto_open(self):
|
||||
with mock.patch.object(console.subprocess, "run", return_value=self.desktop()), \
|
||||
mock.patch.object(Path, "read_text", return_value="tty1\n"):
|
||||
self.assertFalse(console._active_graphical_display({"DISPLAY": ":0", "XDG_SESSION_ID": "19"}))
|
||||
|
||||
def test_foreign_user_session_is_not_selected(self):
|
||||
with mock.patch.object(console.subprocess, "run", return_value=self.desktop(user=os.getuid() + 1)), \
|
||||
mock.patch.object(Path, "read_text", return_value="tty7\n"):
|
||||
self.assertFalse(console._active_graphical_display({"DISPLAY": ":0", "XDG_SESSION_ID": "19"}))
|
||||
|
||||
def test_noninteractive_terminal_fails_before_starting_a_console(self):
|
||||
with tempfile.TemporaryDirectory() as path:
|
||||
ctx = console.context(Path(path) / "one")
|
||||
with mock.patch.object(console, "context", return_value=ctx), \
|
||||
mock.patch.object(console, "_active_graphical_display", return_value=False), \
|
||||
mock.patch.object(console.os, "isatty", return_value=False), \
|
||||
mock.patch.object(console.subprocess, "run") as run:
|
||||
with self.assertRaisesRegex(RuntimeError, "CONSOLE_INTERACTIVE_TERMINAL_REQUIRED"):
|
||||
console.run(ctx["state"])
|
||||
run.assert_not_called()
|
||||
|
||||
def test_terminal_attach_keeps_shared_identity_and_profile_jobs(self):
|
||||
with tempfile.TemporaryDirectory() as path:
|
||||
ctx = console.context(Path(path) / "one")
|
||||
with mock.patch.object(console, "context", return_value=ctx), \
|
||||
mock.patch.object(console, "_active_graphical_display", return_value=False), \
|
||||
mock.patch.object(console.os, "isatty", return_value=True), \
|
||||
mock.patch.object(console.subprocess, "run", return_value=SimpleNamespace(returncode=0)), \
|
||||
mock.patch.object(console.os, "execve") as execute:
|
||||
self.assertEqual(console.run(ctx["state"], inside=True), 0)
|
||||
program, args, env = execute.call_args.args
|
||||
self.assertEqual(program, "/usr/bin/tmux")
|
||||
self.assertIn(str(ctx["socket"]), args)
|
||||
self.assertIn("=" + ctx["session"], args)
|
||||
self.assertEqual(env["FRAMEBRIDGE_FBTERM_ROOT"], str(ctx["jobs_root"]))
|
||||
self.assertNotIn("sudo", args)
|
||||
|
||||
def test_layout_failure_does_not_attach_or_replace_a_shell(self):
|
||||
with tempfile.TemporaryDirectory() as path:
|
||||
ctx = console.context(Path(path) / "one")
|
||||
with mock.patch.object(console, "context", return_value=ctx), \
|
||||
mock.patch.object(console, "_active_graphical_display", return_value=False), \
|
||||
mock.patch.object(console.os, "isatty", return_value=True), \
|
||||
mock.patch.object(console.subprocess, "run", return_value=SimpleNamespace(returncode=1)), \
|
||||
mock.patch.object(console.os, "execve") as execute:
|
||||
with self.assertRaisesRegex(RuntimeError, "CONSOLE_LAYOUT_FAILED"):
|
||||
console.run(ctx["state"], inside=True)
|
||||
execute.assert_not_called()
|
||||
|
||||
def test_dangling_socket_symlink_is_refused(self):
|
||||
with tempfile.TemporaryDirectory() as path:
|
||||
sock = Path(path) / "console.sock"
|
||||
sock.symlink_to(Path(path) / "missing")
|
||||
with self.assertRaisesRegex(RuntimeError, "CONSOLE_SOCKET_UNTRUSTED"):
|
||||
console._socket_exists(sock)
|
||||
|
||||
def test_public_runtime_directory_is_refused(self):
|
||||
with tempfile.TemporaryDirectory() as path:
|
||||
os.chmod(path, 0o755)
|
||||
with self.assertRaisesRegex(RuntimeError, "CONSOLE_RUNTIME_DIRECTORY_UNTRUSTED"):
|
||||
console._private_runtime(Path(path))
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,122 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from framebridge_expanded import console
|
||||
|
||||
|
||||
class ConsoleNestingTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.directory.cleanup)
|
||||
root = Path(self.directory.name)
|
||||
self.ctx = {
|
||||
"state": root / "profile", "node": "test-node", "runtime": root,
|
||||
"socket": root / "console.sock", "session": "FB-test-profile",
|
||||
"jobs_root": root / "profile/home/.local/share/framebridge/fbterm",
|
||||
}
|
||||
|
||||
def environment(self, *, socket=None, pane="%0"):
|
||||
return {"TMUX": str(socket or self.ctx["socket"]) + ",1234,0",
|
||||
"TMUX_PANE": pane}
|
||||
|
||||
def invoke(self, env, query=None, *, graphical=False):
|
||||
def respond(args, **kwargs):
|
||||
if "list-panes" in args:
|
||||
if isinstance(query, Exception):
|
||||
raise query
|
||||
return query or SimpleNamespace(returncode=0, stdout="%0\n%1\n")
|
||||
return SimpleNamespace(returncode=0, stdout="")
|
||||
with mock.patch.dict(os.environ, env, clear=True), \
|
||||
mock.patch.object(console, "context", return_value=self.ctx), \
|
||||
mock.patch.object(console, "_active_graphical_display", return_value=graphical), \
|
||||
mock.patch.object(console.os, "isatty", return_value=True), \
|
||||
mock.patch.object(console.shutil, "which", return_value="/usr/bin/gnome-terminal"), \
|
||||
mock.patch.object(console.subprocess, "run", side_effect=respond) as run, \
|
||||
mock.patch.object(console.os, "execve") as execute:
|
||||
try:
|
||||
result = console.run(self.ctx["state"], mode="gui" if graphical else "tty")
|
||||
except RuntimeError as error:
|
||||
result = error
|
||||
return result, run, execute
|
||||
|
||||
def assert_blocked_before_layout(self, result, run, execute, error):
|
||||
self.assertIsInstance(result, RuntimeError)
|
||||
self.assertIn(error, str(result))
|
||||
execute.assert_not_called()
|
||||
for call in run.call_args_list:
|
||||
self.assertIn("list-panes", call.args[0])
|
||||
self.assertNotIn("--apply", call.args[0])
|
||||
|
||||
def test_same_console_pane_is_rejected_before_layout_and_attach(self):
|
||||
result, run, execute = self.invoke(self.environment())
|
||||
self.assert_blocked_before_layout(result, run, execute, "CONSOLE_ALREADY_ATTACHED")
|
||||
run.assert_called_once()
|
||||
args = run.call_args.args[0]
|
||||
self.assertIn("=" + self.ctx["session"], args)
|
||||
self.assertEqual(args[-2:], ["-F", "#{pane_id}"])
|
||||
|
||||
def test_socket_path_alias_does_not_bypass_self_attach_check(self):
|
||||
alias = str(self.ctx["socket"].parent) + "/./console.sock"
|
||||
env = {"TMUX": alias + ",1234,0", "TMUX_PANE": "%0"}
|
||||
result, run, execute = self.invoke(env)
|
||||
self.assert_blocked_before_layout(result, run, execute, "CONSOLE_ALREADY_ATTACHED")
|
||||
|
||||
def test_unrelated_profile_can_attach_normally(self):
|
||||
result, run, execute = self.invoke(self.environment(socket=self.ctx["socket"].with_name("other.sock")))
|
||||
self.assertEqual(result, 0)
|
||||
execute.assert_called_once()
|
||||
run.assert_called_once()
|
||||
self.assertIn("--apply", run.call_args.args[0])
|
||||
|
||||
def test_other_session_without_shared_panes_can_attach(self):
|
||||
result, run, execute = self.invoke(self.environment(pane="%9"))
|
||||
self.assertEqual(result, 0)
|
||||
execute.assert_called_once()
|
||||
self.assertEqual(run.call_count, 2)
|
||||
self.assertIn("--apply", run.call_args.args[0])
|
||||
|
||||
def test_plain_terminal_can_attach_normally(self):
|
||||
result, run, execute = self.invoke({})
|
||||
self.assertEqual(result, 0)
|
||||
execute.assert_called_once()
|
||||
run.assert_called_once()
|
||||
self.assertIn("--apply", run.call_args.args[0])
|
||||
|
||||
def test_failed_membership_query_does_not_assume_attach_is_safe(self):
|
||||
result, run, execute = self.invoke(
|
||||
self.environment(), SimpleNamespace(returncode=1, stdout=""))
|
||||
self.assert_blocked_before_layout(result, run, execute, "CONSOLE_TMUX_CONTEXT_UNVERIFIED")
|
||||
|
||||
def test_query_timeout_does_not_attach(self):
|
||||
result, run, execute = self.invoke(
|
||||
self.environment(), subprocess.TimeoutExpired(["tmux"], 5))
|
||||
self.assert_blocked_before_layout(result, run, execute, "CONSOLE_TMUX_CONTEXT_UNVERIFIED")
|
||||
|
||||
def test_invalid_pane_metadata_does_not_attach(self):
|
||||
result, run, execute = self.invoke(
|
||||
self.environment(), SimpleNamespace(returncode=0, stdout="not-a-pane\n"))
|
||||
self.assert_blocked_before_layout(result, run, execute, "CONSOLE_TMUX_CONTEXT_UNVERIFIED")
|
||||
|
||||
def test_malformed_matching_context_does_not_run_tmux(self):
|
||||
result, run, execute = self.invoke(self.environment(pane="invalid"))
|
||||
self.assert_blocked_before_layout(result, run, execute, "CONSOLE_TMUX_CONTEXT_UNVERIFIED")
|
||||
run.assert_not_called()
|
||||
|
||||
def test_independent_gui_window_keeps_clean_environment(self):
|
||||
result, run, execute = self.invoke(self.environment(), graphical=True)
|
||||
self.assertEqual(result, 0)
|
||||
execute.assert_not_called()
|
||||
run.assert_called_once()
|
||||
args, kwargs = run.call_args
|
||||
self.assertEqual(args[0][0], "/usr/bin/gnome-terminal")
|
||||
self.assertNotIn("TMUX", kwargs["env"])
|
||||
self.assertNotIn("TMUX_PANE", kwargs["env"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Opt-in startup tests use only owned temporary homes and harmless stubs."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "expanded"))
|
||||
from framebridge_expanded import console_startup as startup
|
||||
|
||||
|
||||
@unittest.skipIf(os.geteuid() == 0, "Unprivileged user profile tests")
|
||||
class ConsoleStartupTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory(prefix="fb-login-startup-")
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.home = self.root / "home"
|
||||
self.home.mkdir(mode=0o700)
|
||||
self.state = self.root / "state"
|
||||
|
||||
def existing(self, name=".profile", data=b"# user's existing startup\n", mode=0o640):
|
||||
path = self.home / name
|
||||
path.write_bytes(data)
|
||||
path.chmod(mode)
|
||||
return path
|
||||
|
||||
def assert_ok(self, value):
|
||||
self.assertTrue(value.get("ok"), value)
|
||||
return value
|
||||
|
||||
def manifest(self):
|
||||
return json.loads((self.state / "console-login/manifest.json").read_text())
|
||||
|
||||
def test_existing_bytes_mode_and_exact_private_backup_restore(self):
|
||||
data = b"printf '%s' 'existing login content'"
|
||||
path = self.existing(".bash_profile", data)
|
||||
result = self.assert_ok(startup.enable(self.state, home=self.home))
|
||||
self.assertTrue(result["enabled"])
|
||||
self.assertEqual(result["startup_file"], str(path))
|
||||
self.assertTrue(path.read_bytes().startswith(data + b"\n"))
|
||||
self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o640)
|
||||
record = self.manifest()
|
||||
backup = self.state / "console-login" / record["backup_name"]
|
||||
self.assertEqual(backup.read_bytes(), data)
|
||||
self.assertEqual(stat.S_IMODE(backup.stat().st_mode), 0o600)
|
||||
self.assertTrue(self.assert_ok(startup.status(self.state, home=self.home))["enabled"])
|
||||
result = self.assert_ok(startup.disable(self.state, home=self.home))
|
||||
self.assertFalse(result["enabled"])
|
||||
self.assertEqual(path.read_bytes(), data)
|
||||
self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o640)
|
||||
|
||||
def test_effective_bash_startup_priority(self):
|
||||
self.existing(".profile", b"# lower priority\n")
|
||||
login = self.existing(".bash_login", b"# selected login\n")
|
||||
result = self.assert_ok(startup.enable(self.state, home=self.home))
|
||||
self.assertEqual(result["startup_file"], str(login))
|
||||
self.assertEqual((self.home / ".profile").read_bytes(), b"# lower priority\n")
|
||||
|
||||
def test_new_profile_is_removed_on_disable(self):
|
||||
self.assertTrue(self.assert_ok(startup.enable(self.state, home=self.home))["changed"])
|
||||
path = self.home / ".profile"
|
||||
self.assertTrue(path.is_file())
|
||||
self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600)
|
||||
self.assert_ok(startup.disable(self.state, home=self.home))
|
||||
self.assertFalse(path.exists())
|
||||
|
||||
def test_enable_and_disable_are_idempotent(self):
|
||||
path = self.existing()
|
||||
self.assert_ok(startup.enable(self.state, home=self.home))
|
||||
installed = path.read_bytes()
|
||||
again = self.assert_ok(startup.enable(self.state, home=self.home))
|
||||
self.assertFalse(again["changed"])
|
||||
self.assertEqual(path.read_bytes(), installed)
|
||||
self.assertEqual(len(list((self.state / "console-login").glob("backup-*.bin"))), 1)
|
||||
self.assert_ok(startup.disable(self.state, home=self.home))
|
||||
self.assertFalse(self.assert_ok(startup.disable(self.state, home=self.home))["changed"])
|
||||
|
||||
def test_reenable_preserves_previous_recovery_history(self):
|
||||
path = self.existing(data=b"# baseline one\n")
|
||||
self.assert_ok(startup.enable(self.state, home=self.home))
|
||||
self.assert_ok(startup.disable(self.state, home=self.home))
|
||||
path.write_bytes(b"# baseline two\n")
|
||||
self.assert_ok(startup.enable(self.state, home=self.home))
|
||||
self.assertEqual(len(list((self.state / "console-login").glob("backup-*.bin"))), 2)
|
||||
self.assertEqual(len(list((self.state / "console-login").glob("history-*.json"))), 1)
|
||||
self.assert_ok(startup.disable(self.state, home=self.home))
|
||||
self.assertEqual(path.read_bytes(), b"# baseline two\n")
|
||||
|
||||
def test_later_edits_are_never_overwritten(self):
|
||||
path = self.existing()
|
||||
self.assert_ok(startup.enable(self.state, home=self.home))
|
||||
path.write_bytes(path.read_bytes() + b"# later user change\n")
|
||||
changed = path.read_bytes()
|
||||
for operation in (startup.disable, startup.enable, startup.status):
|
||||
reply = operation(self.state, home=self.home)
|
||||
self.assertFalse(reply["ok"])
|
||||
self.assertEqual(reply["error"], "CONSOLE_LOGIN_STARTUP_CHANGED")
|
||||
self.assertEqual(path.read_bytes(), changed)
|
||||
|
||||
def test_symlink_and_nonregular_startup_are_refused(self):
|
||||
target = self.root / "outside"
|
||||
target.write_bytes(b"# unchanged outside\n")
|
||||
(self.home / ".bash_profile").symlink_to(target)
|
||||
reply = startup.enable(self.state, home=self.home)
|
||||
self.assertFalse(reply["ok"])
|
||||
self.assertEqual(reply["error"], "CONSOLE_LOGIN_SYMLINK_REFUSED")
|
||||
self.assertEqual(target.read_bytes(), b"# unchanged outside\n")
|
||||
(self.home / ".bash_profile").unlink()
|
||||
(self.home / ".bash_profile").mkdir()
|
||||
self.assertEqual(startup.enable(self.state, home=self.home)["error"],
|
||||
"CONSOLE_LOGIN_FILE_NOT_REGULAR")
|
||||
|
||||
def test_competing_profile_is_refused(self):
|
||||
path = self.existing()
|
||||
self.assert_ok(startup.enable(self.state, home=self.home))
|
||||
installed = path.read_bytes()
|
||||
other = self.root / "other-state"
|
||||
reply = startup.enable(other, home=self.home)
|
||||
self.assertEqual(reply["error"], "CONSOLE_LOGIN_OTHER_PROFILE_ENABLED")
|
||||
self.assertEqual(path.read_bytes(), installed)
|
||||
self.assertFalse((other / "console-login/manifest.json").exists())
|
||||
|
||||
def test_foreign_untracked_block_is_refused(self):
|
||||
path = self.existing(data=b"# BEGIN FRAMEBRIDGE CONSOLE LOGIN v1 foreign\n")
|
||||
before = path.read_bytes()
|
||||
reply = startup.enable(self.state, home=self.home)
|
||||
self.assertEqual(reply["error"], "CONSOLE_LOGIN_OTHER_PROFILE_ENABLED")
|
||||
self.assertEqual(path.read_bytes(), before)
|
||||
|
||||
def test_priority_change_is_reported_but_own_hook_can_be_removed(self):
|
||||
path = self.existing(data=b"# restored profile\n")
|
||||
self.assert_ok(startup.enable(self.state, home=self.home))
|
||||
higher = self.existing(".bash_profile", b"# newly preferred file\n")
|
||||
reply = startup.status(self.state, home=self.home)
|
||||
self.assertEqual(reply["error"], "CONSOLE_LOGIN_STARTUP_PRIORITY_CHANGED")
|
||||
self.assert_ok(startup.disable(self.state, home=self.home))
|
||||
self.assertEqual(path.read_bytes(), b"# restored profile\n")
|
||||
self.assertEqual(higher.read_bytes(), b"# newly preferred file\n")
|
||||
|
||||
def test_modified_backup_refuses_restore(self):
|
||||
path = self.existing()
|
||||
self.assert_ok(startup.enable(self.state, home=self.home))
|
||||
installed = path.read_bytes()
|
||||
record = self.manifest()
|
||||
backup = self.state / "console-login" / record["backup_name"]
|
||||
backup.write_bytes(b"# tampered recovery\n")
|
||||
reply = startup.disable(self.state, home=self.home)
|
||||
self.assertEqual(reply["error"], "CONSOLE_LOGIN_BACKUP_MISMATCH")
|
||||
self.assertEqual(path.read_bytes(), installed)
|
||||
|
||||
def test_state_symlink_is_refused(self):
|
||||
real = self.root / "real-state"
|
||||
real.mkdir(mode=0o700)
|
||||
self.state.symlink_to(real, target_is_directory=True)
|
||||
reply = startup.enable(self.state, home=self.home)
|
||||
self.assertEqual(reply["error"], "CONSOLE_LOGIN_SYMLINK_REFUSED")
|
||||
self.assertFalse((real / "console-login").exists())
|
||||
|
||||
def test_status_has_no_write_side_effects(self):
|
||||
reply = self.assert_ok(startup.status(self.state, home=self.home))
|
||||
self.assertFalse(reply["enabled"])
|
||||
self.assertFalse(self.state.exists())
|
||||
self.assertFalse((self.home / ".framebridge-console-login.lock").exists())
|
||||
|
||||
def test_default_home_uses_passwd_not_profile_environment_home(self):
|
||||
with mock.patch.object(startup.pwd, "getpwuid",
|
||||
return_value=type("Identity", (), {"pw_dir": str(self.home)})()):
|
||||
with mock.patch.dict(os.environ, {"HOME": str(self.root / "wrong-home")}):
|
||||
reply = self.assert_ok(startup.enable(self.state))
|
||||
self.assertEqual(reply["startup_file"], str(self.home / ".profile"))
|
||||
|
||||
def test_login_guards_and_quoted_state_are_executed_in_fixture_only(self):
|
||||
fixture_bin = self.root / "bin"
|
||||
fixture_bin.mkdir()
|
||||
tty = fixture_bin / "tty"
|
||||
tty.write_text("#!/bin/sh\nprintf '%s\\n' \"$" + "{FB_TEST_TTY-/dev/tty1}\"\n")
|
||||
tty.chmod(0o700)
|
||||
marker = self.root / "invocation.json"
|
||||
stub = fixture_bin / "fb-stub"
|
||||
stub.write_text("#!/usr/bin/python3\nimport json,sys\nfrom pathlib import Path\n"
|
||||
+ "Path(" + repr(str(marker)) + ").write_text(json.dumps(sys.argv[1:]))\n")
|
||||
stub.chmod(0o700)
|
||||
weird = self.root / "state with ' quote $(touch SHOULD_NOT_EXIST)"
|
||||
with mock.patch.object(startup, "FB_EXECUTABLE", str(stub)):
|
||||
self.assert_ok(startup.enable(weird, home=self.home))
|
||||
profile = self.home / ".profile"
|
||||
subprocess.run(["/bin/sh", "-n", str(profile)], check=True)
|
||||
base_env = dict(os.environ, PATH=str(fixture_bin) + ":" + os.environ["PATH"],
|
||||
SSH_TTY="", TMUX="", FB_TEST_TTY="/dev/tty1")
|
||||
command = ["/bin/bash", "--noprofile", "--norc", "--login", "-c",
|
||||
". " + shlex.quote(str(profile))]
|
||||
for override in ({"SSH_TTY": "/dev/pts/9"}, {"TMUX": "test-client"},
|
||||
{"FB_TEST_TTY": "/dev/tty2"}):
|
||||
subprocess.run(command, env=dict(base_env, **override), check=True,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
||||
self.assertFalse(marker.exists())
|
||||
nonlogin = ["/bin/bash", "--noprofile", "--norc", "-c",
|
||||
". " + shlex.quote(str(profile))]
|
||||
subprocess.run(nonlogin, env=base_env, check=True,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
||||
self.assertFalse(marker.exists())
|
||||
subprocess.run(command, env=base_env, check=True,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
||||
self.assertEqual(json.loads(marker.read_text()),
|
||||
["--state-dir", str(weird), "console", "--mode", "tty"])
|
||||
self.assertFalse((self.root / "SHOULD_NOT_EXIST").exists())
|
||||
|
||||
|
||||
class UserOnlyTests(unittest.TestCase):
|
||||
def test_root_context_is_rejected_before_any_path_change(self):
|
||||
with mock.patch.object(startup.os, "geteuid", return_value=0):
|
||||
reply = startup.enable("/does/not/exist", home="/also/not/used")
|
||||
self.assertEqual(reply["error"], "CONSOLE_LOGIN_USER_REQUIRED")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Source-availability diagnostics for the read-only FBTERM catalogue."""
|
||||
import errno
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from framebridge_expanded.console_ui import fbterm_dashboard as dashboard
|
||||
|
||||
|
||||
class DashboardSourcesTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.root = Path(temporary.name)
|
||||
|
||||
def legacy_job(self):
|
||||
job = self.root / "jobs" / "legacy-job"
|
||||
job.mkdir(parents=True)
|
||||
(job / "exit.code").write_text("0\n")
|
||||
return job
|
||||
|
||||
def scan(self):
|
||||
catalogue = dashboard.Catalogue(self.root)
|
||||
catalogue.scan()
|
||||
return catalogue
|
||||
|
||||
def test_missing_optional_receipts_keeps_legacy_exit_without_read_errors(self):
|
||||
self.legacy_job()
|
||||
catalogue = self.scan()
|
||||
self.assertEqual(catalogue.errors, 0)
|
||||
self.assertEqual(catalogue.total, 1)
|
||||
self.assertEqual(catalogue.counts, {"EXIT": 1})
|
||||
self.assertEqual(catalogue.recent[0].exit_code, 0)
|
||||
self.assertFalse((self.root / "receipts").exists())
|
||||
|
||||
def test_receipts_permission_denied_remains_a_source_error(self):
|
||||
self.legacy_job()
|
||||
receipts = self.root / "receipts"
|
||||
receipts.mkdir()
|
||||
real_scandir = dashboard.os.scandir
|
||||
|
||||
def scandir(path):
|
||||
if Path(path) == receipts:
|
||||
raise PermissionError(errno.EACCES, "fixture denied", str(path))
|
||||
return real_scandir(path)
|
||||
|
||||
with mock.patch.object(dashboard.os, "scandir", side_effect=scandir):
|
||||
catalogue = self.scan()
|
||||
self.assertEqual(catalogue.errors, 1)
|
||||
self.assertEqual(catalogue.counts, {"EXIT": 1})
|
||||
|
||||
def test_receipts_that_is_not_a_directory_remains_a_source_error(self):
|
||||
self.legacy_job()
|
||||
(self.root / "receipts").write_text("not a directory")
|
||||
catalogue = self.scan()
|
||||
self.assertEqual(catalogue.errors, 1)
|
||||
self.assertEqual(catalogue.counts, {"EXIT": 1})
|
||||
|
||||
def test_missing_required_jobs_remains_a_source_error(self):
|
||||
catalogue = self.scan()
|
||||
self.assertEqual(catalogue.errors, 1)
|
||||
self.assertEqual(catalogue.total, 0)
|
||||
self.assertFalse((self.root / "jobs").exists())
|
||||
self.assertFalse((self.root / "receipts").exists())
|
||||
|
||||
def test_receipt_entry_disappearing_during_iteration_remains_a_read_error(self):
|
||||
self.legacy_job()
|
||||
receipts = self.root / "receipts"
|
||||
receipts.mkdir()
|
||||
entry = mock.Mock()
|
||||
entry.name = "vanished.json"
|
||||
entry.is_file.side_effect = FileNotFoundError(
|
||||
errno.ENOENT, "fixture entry disappeared")
|
||||
iterator = mock.MagicMock()
|
||||
iterator.__enter__.return_value = iter([entry])
|
||||
real_scandir = dashboard.os.scandir
|
||||
|
||||
def scandir(path):
|
||||
return iterator if Path(path) == receipts else real_scandir(path)
|
||||
|
||||
with mock.patch.object(dashboard.os, "scandir", side_effect=scandir):
|
||||
catalogue = self.scan()
|
||||
self.assertEqual(catalogue.errors, 1)
|
||||
self.assertEqual(catalogue.counts, {"EXIT": 1})
|
||||
|
||||
def test_completed_receipt_without_job_is_mismatch_not_a_source_error(self):
|
||||
(self.root / "jobs").mkdir()
|
||||
receipts = self.root / "receipts"
|
||||
receipts.mkdir()
|
||||
receipt = {
|
||||
"schema": 4, "job_id": "completed-tombstone", "phase": "COMPLETED",
|
||||
"started_at": 1, "finished_at": 2, "exit_code": 0,
|
||||
}
|
||||
(receipts / "completed-tombstone.json").write_text(json.dumps(receipt))
|
||||
catalogue = self.scan()
|
||||
self.assertEqual(catalogue.errors, 0)
|
||||
self.assertEqual(catalogue.total, 1)
|
||||
self.assertEqual(catalogue.counts, {"MISMATCH": 1})
|
||||
self.assertIsNone(catalogue.recent[0].exit_code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,134 @@
|
||||
import json
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from framebridge_expanded import display_context as dc
|
||||
|
||||
class DisplayBindingTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
tools = patch.object(dc, "missing_graphical_commands", return_value=[])
|
||||
tools.start()
|
||||
self.addCleanup(tools.stop)
|
||||
|
||||
def context(self, **changes):
|
||||
value = dict(kind="physical-x11", physical=True, session_id="c1", uid=1000,
|
||||
leader=123, active_vt="tty7", seat="seat0", vtnr=7,
|
||||
display=":0", authority="/private/user/.Xauthority")
|
||||
value.update(changes)
|
||||
return value
|
||||
|
||||
def bound(self, **changes):
|
||||
context = self.context(**changes)
|
||||
context["profile"] = "/profile/a"
|
||||
return {"DISPLAY": context["display"], dc.BINDING: json.dumps(context)}
|
||||
|
||||
def test_auto_prefers_verified_physical_over_inherited_display(self):
|
||||
env = {"DISPLAY": ":199", "XAUTHORITY": "/other/authority"}
|
||||
with patch.object(dc, "_physical", return_value=self.context()):
|
||||
dc.configure(env, "auto", "/profile/a")
|
||||
self.assertEqual(env["DISPLAY"], ":0")
|
||||
self.assertEqual(env["XDG_SESSION_TYPE"], "x11")
|
||||
self.assertEqual(env["GDK_BACKEND"], "x11")
|
||||
self.assertEqual(env["XAUTHORITY"], "/private/user/.Xauthority")
|
||||
self.assertTrue(dc.describe(env)["physical"])
|
||||
|
||||
def test_explicit_virtual_does_not_resolve_physical(self):
|
||||
env = {}
|
||||
with patch.object(dc, "_physical") as physical:
|
||||
dc.configure(env, "yes", "/profile/a")
|
||||
physical.assert_not_called()
|
||||
dc.bind_virtual(env, "/profile/a", explicit=True)
|
||||
self.assertFalse(dc.describe(env)["physical"])
|
||||
self.assertEqual(dc.describe(env)["kind"], "virtual-x11")
|
||||
|
||||
def test_auto_without_physical_session_is_text_not_virtual(self):
|
||||
env = {"DISPLAY": ":199"}
|
||||
with patch.object(dc, "_physical", side_effect=dc.DisplayContextError("NO_SESSION")):
|
||||
dc.configure(env, "auto", "/profile/a")
|
||||
self.assertEqual(dc.describe(env)["kind"], "text")
|
||||
self.assertFalse(dc.describe(env)["physical"])
|
||||
self.assertFalse(dc.describe(env)["explicit"])
|
||||
|
||||
def test_no_headless_requires_verified_session(self):
|
||||
env = {"DISPLAY": ":199"}
|
||||
with patch.object(dc, "_physical", side_effect=dc.DisplayContextError("NO_SESSION")):
|
||||
dc.configure(env, "no", "/profile/a")
|
||||
self.assertEqual(dc.describe(env)["kind"], "unavailable")
|
||||
with self.assertRaisesRegex(dc.DisplayContextError, "CUA_DISPLAY_UNAVAILABLE"):
|
||||
dc.check(env)
|
||||
|
||||
def test_changed_vt_session_or_user_is_rejected(self):
|
||||
for changed in (dict(active_vt="tty1", vtnr=1),
|
||||
dict(session_id="c2"), dict(uid=1001), dict(leader=124)):
|
||||
with self.subTest(changed=changed):
|
||||
with patch.object(dc, "_physical", return_value=self.context(**changed)):
|
||||
with self.assertRaisesRegex(dc.DisplayContextError, "CUA_FOREGROUND_SESSION_CHANGED"):
|
||||
dc.check(self.bound())
|
||||
|
||||
def test_input_display_must_match_physical_binding(self):
|
||||
env = self.bound()
|
||||
env["DISPLAY"] = ":199"
|
||||
with patch.object(dc, "_physical", return_value=self.context()):
|
||||
with self.assertRaisesRegex(dc.DisplayContextError, "CUA_DISPLAY_BINDING_MISMATCH"):
|
||||
dc.check(env)
|
||||
|
||||
def test_virtual_display_cannot_silently_switch(self):
|
||||
env = {"DISPLAY": ":140"}
|
||||
dc.bind_virtual(env, "/profile/a", explicit=True)
|
||||
dc.check(env)
|
||||
env["DISPLAY"] = ":0"
|
||||
with self.assertRaisesRegex(dc.DisplayContextError, "VIRTUAL_DISPLAY_BINDING_MISMATCH"):
|
||||
dc.check(env)
|
||||
|
||||
def test_public_metadata_omits_authority_reference(self):
|
||||
context = dc.describe(self.bound())
|
||||
self.assertNotIn("authority", context)
|
||||
self.assertTrue(context["physical"])
|
||||
|
||||
def test_other_profile_binding_is_not_adopted(self):
|
||||
env = self.bound()
|
||||
with patch.object(dc, "_physical", return_value=self.context(session_id="c2")):
|
||||
dc.configure(env, "auto", "/profile/b")
|
||||
self.assertEqual(dc.describe(env)["session_id"], "c2")
|
||||
self.assertEqual(dc.describe(env)["profile"], "/profile/b")
|
||||
|
||||
def test_invalid_binding_is_rejected(self):
|
||||
with self.assertRaisesRegex(dc.DisplayContextError, "DISPLAY_BINDING_INVALID"):
|
||||
dc.check({dc.BINDING: "invalid"})
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
class DisplayBindingAuditTests(unittest.TestCase):
|
||||
def test_headless_no_late_failure_refuses_virtual_fallback(self):
|
||||
env = {dc.BINDING: json.dumps({"kind": "physical-x11", "physical": True})}
|
||||
with self.assertRaisesRegex(dc.DisplayContextError, "NO_VIRTUAL_FALLBACK"):
|
||||
dc.private_display_required(env, "no", False)
|
||||
self.assertFalse(dc.private_display_required(env, "no", True))
|
||||
|
||||
def test_auto_late_failure_does_not_start_virtual_desktop(self):
|
||||
env = {dc.BINDING: json.dumps({"kind": "physical-x11", "physical": True})}
|
||||
self.assertFalse(dc.private_display_required(env, "auto", False))
|
||||
|
||||
def test_headless_no_ignores_inherited_virtual_binding(self):
|
||||
env = {dc.BINDING: json.dumps({"kind": "virtual-x11", "profile": "/fixture"}),
|
||||
"DISPLAY": ":199"}
|
||||
with patch.object(dc, "_physical", side_effect=dc.DisplayContextError("NO_SESSION")):
|
||||
dc.configure(env, "no", "/fixture")
|
||||
self.assertEqual(dc.describe(env)["kind"], "unavailable")
|
||||
|
||||
def test_explicit_virtual_ignores_inherited_physical_binding(self):
|
||||
env = {dc.BINDING: json.dumps({"kind": "physical-x11", "profile": "/fixture"})}
|
||||
with patch.object(dc, "_physical") as physical:
|
||||
dc.configure(env, "yes", "/fixture")
|
||||
physical.assert_not_called()
|
||||
self.assertEqual(dc.describe(env)["kind"], "virtual-x11")
|
||||
self.assertTrue(dc.describe(env)["explicit"])
|
||||
|
||||
def test_non_object_binding_is_invalid_and_can_be_reconfigured(self):
|
||||
env = {dc.BINDING: "[]"}
|
||||
with self.assertRaisesRegex(dc.DisplayContextError, "DISPLAY_BINDING_INVALID"):
|
||||
dc.check(env)
|
||||
self.assertEqual(dc.describe(env)["kind"], "unavailable")
|
||||
with patch.object(dc, "_physical", side_effect=dc.DisplayContextError("NO_SESSION")):
|
||||
dc.configure(env, "auto", "/fixture")
|
||||
self.assertEqual(dc.describe(env)["kind"], "text")
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Real FBPAIR/1 identities and sockets in temporary, isolated profiles."""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from framebridge_expanded import broker, node
|
||||
|
||||
|
||||
class BrokerNodeTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory(prefix="framebridge-expanded-broker-test-")
|
||||
self.root = Path(self.temp.name)
|
||||
self.central = self.root / "central"
|
||||
self.central.mkdir(mode=0o700)
|
||||
with socket.socket() as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
self.port = sock.getsockname()[1]
|
||||
self.url = "http://127.0.0.1:" + str(self.port)
|
||||
(self.central / "config.json").write_text(json.dumps({
|
||||
"schema": 1, "role": "central", "name": "central-test", "node_id": str(uuid.uuid4()),
|
||||
"ports": {"broker": self.port}, "broker_url": self.url}))
|
||||
self.server = broker.make_server(port=self.port, state_dir=self.central)
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
broker.configure(state_dir=self.central)
|
||||
|
||||
def tearDown(self):
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(2)
|
||||
self.temp.cleanup()
|
||||
|
||||
def make_node(self, label):
|
||||
state = self.root / label
|
||||
state.mkdir(mode=0o700)
|
||||
(state / "config.json").write_text(json.dumps({
|
||||
"schema": 1, "role": "node", "name": label, "node_id": str(uuid.uuid4()),
|
||||
"broker_url": self.url}))
|
||||
return state
|
||||
|
||||
def pair(self, label):
|
||||
state = self.make_node(label)
|
||||
public = node.enroll(self.url, label, state)
|
||||
pending = next(p for p in broker.list_pending() if p["node_id"] == public["node_id"])
|
||||
response = broker.approve(pending["request_id"], public["node_id"])
|
||||
self.assertTrue(response["ok"])
|
||||
paired = node.poll_enrollment(state)
|
||||
self.assertEqual(paired["state"], "paired")
|
||||
self.assertFalse(node.status(state)["broker_confirm_required"])
|
||||
return state, public["node_id"]
|
||||
|
||||
def test_http_remote_and_unauthenticated_control_are_rejected(self):
|
||||
for value in ("http://10.0.0.53:1234", "http://example.com", "https://u:p@example.com", "https://example.com/#x"):
|
||||
with self.assertRaises(broker.BrokerError):
|
||||
broker.validate_endpoint(value)
|
||||
with self.assertRaises(broker.BrokerError):
|
||||
broker.post_json(self.url + "/fbpair/v1/control/nodes", {}, state_dir=self.central)
|
||||
|
||||
def test_approval_requires_exact_uuid_and_operator_auth(self):
|
||||
state = self.make_node("node-a")
|
||||
pending = node.enroll(self.url, "node-a", state)
|
||||
item = broker.list_pending()[0]
|
||||
self.assertEqual(item["node_id"], pending["node_id"])
|
||||
self.assertTrue(item["fingerprint"])
|
||||
self.assertEqual(item["fingerprint"], pending["fingerprint"])
|
||||
with self.assertRaises(broker.BrokerError):
|
||||
broker.approve(item["request_id"], str(uuid.uuid4()))
|
||||
self.assertEqual(broker.list_confirmed_nodes(), [])
|
||||
|
||||
def test_two_nodes_identity_confirmation_heartbeat_and_real_read(self):
|
||||
one, first = self.pair("node-a")
|
||||
two, second = self.pair("node-b")
|
||||
self.assertNotEqual(first, second)
|
||||
before = broker.list_confirmed_nodes()
|
||||
self.assertEqual(len(before), 2)
|
||||
self.assertTrue(all(n["ready"] is False for n in before))
|
||||
for state in (one, two):
|
||||
self.assertTrue(node.run(state, once=True)["idle"])
|
||||
self.assertTrue(all(n["ready"] is True for n in broker.list_confirmed_nodes()))
|
||||
self.assertEqual(node.status(one)["node_id"], first)
|
||||
stopped = threading.Event()
|
||||
errors = []
|
||||
|
||||
def pump():
|
||||
while not stopped.is_set():
|
||||
try:
|
||||
node.run(one, once=True)
|
||||
except Exception as exc:
|
||||
errors.append(type(exc).__name__)
|
||||
return
|
||||
stopped.wait(0.05)
|
||||
|
||||
worker = threading.Thread(target=pump, daemon=True)
|
||||
worker.start()
|
||||
try:
|
||||
result = broker.request(first, "system.status", {}, timeout_s=5)
|
||||
self.assertTrue(result["ok"], result)
|
||||
self.assertIsInstance(result["result"], dict)
|
||||
self.assertEqual(result["node_id"], first)
|
||||
finally:
|
||||
stopped.set()
|
||||
worker.join(2)
|
||||
self.assertFalse(errors)
|
||||
|
||||
def test_dispatch_rejects_wrong_node_and_bad_digest_before_adapter(self):
|
||||
state, ident = self.pair("node-a")
|
||||
called = []
|
||||
script = "printf harmless"
|
||||
request = {"request_id": "request-test", "node_id": str(uuid.uuid4()),
|
||||
"expires_at": time.time() + 30, "operation": "admin.exec", "payload": {
|
||||
"authorized": True, "job_id": "test-job", "timeout_s": 10,
|
||||
"script": script, "sha256": hashlib.sha256(script.encode()).hexdigest()}}
|
||||
self.assertEqual(node.dispatch_request(request, called.append, state)["error"], "REQUEST_NODE_MISMATCH")
|
||||
request["node_id"] = ident
|
||||
request["payload"]["sha256"] = "0" * 64
|
||||
self.assertEqual(node.dispatch_request(request, called.append, state)["error"], "SCRIPT_SHA256_MISMATCH")
|
||||
self.assertEqual(called, [])
|
||||
|
||||
def test_confirmed_identity_survives_broker_restart_but_readiness_does_not(self):
|
||||
state, ident = self.pair("node-a")
|
||||
node.run(state, once=True)
|
||||
self.assertTrue(broker.list_confirmed_nodes()[0]["ready"])
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(2)
|
||||
self.server = broker.make_server(port=self.port, state_dir=self.central)
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
nodes = broker.list_confirmed_nodes()
|
||||
self.assertEqual(nodes[0]["node_id"], ident)
|
||||
self.assertFalse(nodes[0]["ready"])
|
||||
node.run(state, once=True)
|
||||
self.assertTrue(broker.list_confirmed_nodes()[0]["ready"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Routing contract tests; original read provider is exercised locally."""
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "expanded"))
|
||||
from framebridge_expanded import fleet
|
||||
|
||||
LOCAL_ID = "c6d827a4-219f-48fc-82d8-f410176313ca"
|
||||
REMOTE_ID = "6d88c5ae-343e-4413-a202-ccae2ae7694a"
|
||||
|
||||
|
||||
class FleetRoutingTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.config = {"schema_version": 1, "role": "central", "name": "test-central", "node_id": LOCAL_ID}
|
||||
self.local_config = mock.patch.object(fleet, "local_config", return_value=self.config)
|
||||
self.local_config.start()
|
||||
self.remote = {"alias": "test-node", "node_id": REMOTE_ID, "hostname": "test-node",
|
||||
"transport": "broker_outbound", "target": "test-node",
|
||||
"source": "confirmed_pairing", "capability_profile": "original-readonly",
|
||||
"operational_transport_ready": True}
|
||||
self.remote_nodes = mock.patch.object(fleet, "_remote_descriptors", return_value=[self.remote])
|
||||
self.remote_nodes.start()
|
||||
|
||||
def tearDown(self):
|
||||
self.remote_nodes.stop()
|
||||
self.local_config.stop()
|
||||
|
||||
def test_original_local_read_provider(self):
|
||||
value = fleet.node_read("LOCAL", "system.status", {})
|
||||
self.assertTrue(value["ok"])
|
||||
self.assertEqual(value["result"]["hostname"], socket.gethostname())
|
||||
self.assertEqual(value["node"], "test-central")
|
||||
self.assertEqual(value["transport"], "local")
|
||||
|
||||
def test_original_closed_read_schema(self):
|
||||
value = fleet.node_read("LOCAL", "system.status", {"command": "uname"})
|
||||
self.assertFalse(value["ok"])
|
||||
self.assertEqual(value["error"], "FLEET_READ_ARGUMENTS_INVALID")
|
||||
self.assertFalse(fleet.node_read("LOCAL", "exec", {})["ok"])
|
||||
|
||||
def test_discovery_identity_and_alias_routing(self):
|
||||
value = fleet.discover_nodes()
|
||||
self.assertTrue(value["ok"])
|
||||
self.assertEqual({n["node_id"] for n in value["nodes"]}, {LOCAL_ID, REMOTE_ID})
|
||||
self.assertEqual(fleet.resolve_node("test-node")["node_id"], REMOTE_ID)
|
||||
self.assertEqual(fleet.resolve_node(REMOTE_ID)["alias"], "test-node")
|
||||
self.assertEqual(fleet.resolve_node("LOCAL")["node_id"], LOCAL_ID)
|
||||
|
||||
def test_alias_collision_fails_closed(self):
|
||||
self.remote["alias"] = "test-central"
|
||||
value = fleet.discover_nodes()
|
||||
self.assertFalse(value["ok"])
|
||||
self.assertEqual(value["error"], "FLEET_IDENTITY_COLLISION")
|
||||
|
||||
def test_exec_guards_run_before_node_lookup(self):
|
||||
with mock.patch.object(fleet, "resolve_node", side_effect=AssertionError("must not resolve")):
|
||||
cases = [
|
||||
({"authorized": False}, "AUTHORIZED_TRUE_REQUIRED"),
|
||||
({"sha256": "0" * 64}, "SCRIPT_SHA256_MISMATCH"),
|
||||
({"job_id": "../escape"}, "INVALID_JOB_ID"),
|
||||
({"timeout_s": 0}, "INVALID_TIMEOUT"),
|
||||
({"timeout_s": 601}, "INVALID_TIMEOUT"),
|
||||
]
|
||||
for extra, expected in cases:
|
||||
kwargs = {"alias": "test-node", "script": "printf ok", "authorized": True}
|
||||
kwargs.update(extra)
|
||||
self.assertEqual(fleet.execute_node(**kwargs)["error"], expected)
|
||||
|
||||
def test_exec_remote_binding(self):
|
||||
fake = types.ModuleType("framebridge_expanded.broker")
|
||||
calls = []
|
||||
def request(node_id, capability, args, timeout_s):
|
||||
calls.append((node_id, capability, args, timeout_s))
|
||||
return {"ok": True, "exit_code": "0", "job_id": args["job_id"],
|
||||
"command_sha256": args["sha256"], "stdout": "safe"}
|
||||
fake.request = request
|
||||
with mock.patch("framebridge_expanded.broker", fake, create=True), mock.patch.dict(sys.modules, {"framebridge_expanded.broker": fake}):
|
||||
from framebridge_expanded import broker
|
||||
with mock.patch.object(broker, "request", side_effect=request):
|
||||
value = fleet.execute_node("test-node", "printf ok", True, job_id="test-job", include_stdout=True)
|
||||
self.assertTrue(value["ok"])
|
||||
self.assertEqual(value["target"], "test-node")
|
||||
self.assertEqual(value["mcp_script_sha256"], hashlib.sha256(b"printf ok").hexdigest())
|
||||
self.assertEqual(calls[0][0:2], (REMOTE_ID, "admin.exec"))
|
||||
|
||||
def test_exec_rejects_mismatched_result(self):
|
||||
fake = types.ModuleType("framebridge_expanded.broker")
|
||||
fake.request = lambda *a, **k: {"ok": True, "job_id": "wrong", "command_sha256": "0"*64}
|
||||
with mock.patch("framebridge_expanded.broker", fake, create=True), mock.patch.dict(sys.modules, {"framebridge_expanded.broker": fake}):
|
||||
value = fleet.execute_node("test-node", "printf ok", True, job_id="bound-job")
|
||||
self.assertFalse(value["ok"])
|
||||
self.assertEqual(value["error"], "NODE_EXEC_JOB_BINDING_INVALID")
|
||||
|
||||
def test_cua_remote_original_wire_contract(self):
|
||||
fake = types.ModuleType("framebridge_expanded.broker")
|
||||
calls = []
|
||||
def request(node_id, capability, args, timeout_s):
|
||||
calls.append((node_id, capability, args, timeout_s))
|
||||
return {"ok": True, "job_id": args["job_id"], "frame_output": ""}
|
||||
fake.request = request
|
||||
with mock.patch("framebridge_expanded.broker", fake, create=True), mock.patch.dict(sys.modules, {"framebridge_expanded.broker": fake}):
|
||||
value = fleet.cua_node("test-node", {"action": "OBSERVE"}, True, job_id="cua-bound")
|
||||
self.assertTrue(value["ok"])
|
||||
self.assertEqual(calls[0][1], "admin.cua")
|
||||
self.assertEqual(calls[0][2]["mode"], "EXEC")
|
||||
self.assertEqual(calls[0][2]["request_type"], "cua")
|
||||
self.assertEqual(calls[0][2]["job_id"], "cua-bound")
|
||||
|
||||
def test_local_alias_uses_original_adapter_without_network_envelope(self):
|
||||
def adapter(payload):
|
||||
return {"ok": True, "job_id": payload["job_id"],
|
||||
"command_sha256": payload.get("sha256"), "stdout": "local"}
|
||||
with mock.patch("framebridge_expanded.runtime.adapter_request", side_effect=adapter) as call:
|
||||
result = fleet.execute_node("test-central", "printf ok", True, job_id="local-alias")
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertEqual(result["target"], "LOCAL")
|
||||
self.assertEqual(call.call_args.args[0]["mode"], "EXEC")
|
||||
self.assertNotIn("request_id", call.call_args.args[0])
|
||||
|
||||
def test_cua_limits_preserved(self):
|
||||
self.assertEqual(fleet.cua_node("test-node", {"action": "OBSERVE"}, True, 181)["error"],
|
||||
"INVALID_CUA_TIMEOUT")
|
||||
self.assertEqual(fleet.cua_node("test-node", {"action": "OBSERVE"}, False)["error"],
|
||||
"AUTHORIZED_TRUE_REQUIRED")
|
||||
|
||||
def test_offline_node_never_queues_work(self):
|
||||
self.remote["operational_transport_ready"] = False
|
||||
self.assertEqual(fleet.execute_node("test-node", "printf ok", True)["error"], "NODE_OFFLINE")
|
||||
self.assertEqual(fleet.node_read("test-node", "system.status", {})["error"], "NODE_OFFLINE")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Legacy broker peer policy checks without sockets, desktops or live profiles."""
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from framebridge_expanded import broker
|
||||
|
||||
|
||||
CONTROL_PATHS = (
|
||||
'/fbpair/v1/control/node-status',
|
||||
'/fbpair/v1/control/request',
|
||||
'/fbpair/v1/control/admin-exec',
|
||||
'/fbpair/v1/control/admin-cua',
|
||||
'/fbpair/v1/control/operation',
|
||||
'/fbpair/v1/control/result',
|
||||
'/dev/authenticate-and-approve',
|
||||
'/dev/approve-authenticated',
|
||||
)
|
||||
|
||||
|
||||
class BodyReadReached(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class NoStoreCalls:
|
||||
def __getattr__(self, name):
|
||||
raise AssertionError('The peer gate must run before Store access')
|
||||
|
||||
|
||||
class LegacyBrokerPeerTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.original = broker.original_modules()
|
||||
|
||||
def request_fixture(self, handler, path, peer):
|
||||
request = object.__new__(handler)
|
||||
request.path = path
|
||||
request.client_address = (peer, 12345)
|
||||
# Forwarding headers must never replace the actual transport peer.
|
||||
request.headers = {'X-Forwarded-For': '127.0.0.1', 'X-Real-IP': '127.0.0.1'}
|
||||
request.read_json = mock.Mock(side_effect=BodyReadReached)
|
||||
request.send_json = mock.Mock()
|
||||
return request
|
||||
|
||||
def test_default_policy_preserves_only_the_original_loopback_peer(self):
|
||||
allowed = self.original.parse_control_peers()
|
||||
self.assertEqual(len(allowed), 1)
|
||||
self.assertTrue(self.original.control_peer_allowed('127.0.0.1', allowed))
|
||||
for peer in ('192.0.2.15', '198.51.100.18', 'localhost', '', None):
|
||||
with self.subTest(kind=type(peer).__name__):
|
||||
self.assertFalse(self.original.control_peer_allowed(peer, allowed))
|
||||
|
||||
def test_explicit_literal_peers_are_canonical_and_narrow(self):
|
||||
allowed = self.original.parse_control_peers(['192.0.2.15', '2001:0db8:0:0::15'])
|
||||
self.assertEqual(len(allowed), 3)
|
||||
self.assertTrue(self.original.control_peer_allowed('192.0.2.15', allowed))
|
||||
self.assertTrue(self.original.control_peer_allowed('2001:db8::15', allowed))
|
||||
self.assertTrue(self.original.control_peer_allowed('127.0.0.1', allowed))
|
||||
self.assertFalse(self.original.control_peer_allowed('192.0.2.16', allowed))
|
||||
|
||||
def test_malformed_or_implicit_peer_configuration_is_rejected(self):
|
||||
invalid = ('192.0.2.15', ['', '192.0.2.15'], ['*'], ['0.0.0.0/0'], ['0.0.0.0'], ['::'],
|
||||
['example.com'], ['[::1]'], ['fe80::1%eth0'], [' 192.0.2.15'], [None], [123])
|
||||
for value in invalid:
|
||||
with self.subTest(configuration_type=type(value).__name__):
|
||||
with self.assertRaisesRegex(ValueError, '^CONTROL_PEER_CONFIG_INVALID$'):
|
||||
self.original.parse_control_peers(value)
|
||||
|
||||
def test_cli_rejects_invalid_configuration_before_store_or_listener(self):
|
||||
with mock.patch.object(self.original, 'Store') as store, \
|
||||
mock.patch.object(self.original, 'ThreadingHTTPServer') as server, \
|
||||
mock.patch('sys.argv', ['legacy-broker', '--control-peer', 'example.com']), \
|
||||
contextlib.redirect_stderr(io.StringIO()) as errors:
|
||||
with self.assertRaises(SystemExit) as caught:
|
||||
self.original.main()
|
||||
self.assertEqual(caught.exception.code, 2)
|
||||
self.assertIn('CONTROL_PEER_CONFIG_INVALID', errors.getvalue())
|
||||
self.assertNotIn('example.com', errors.getvalue())
|
||||
store.assert_not_called()
|
||||
server.assert_not_called()
|
||||
|
||||
def test_repeated_cli_peers_reach_the_handler_only_when_explicit(self):
|
||||
with mock.patch.object(self.original, 'Store'), \
|
||||
mock.patch.object(self.original, 'ThreadingHTTPServer') as server, \
|
||||
mock.patch.object(self.original, 'make_handler') as make_handler, \
|
||||
mock.patch('sys.argv', ['legacy-broker', '--control-peer', '192.0.2.15',
|
||||
'--control-peer', '2001:db8::15']), contextlib.redirect_stdout(io.StringIO()):
|
||||
self.original.main()
|
||||
allowed = make_handler.call_args.kwargs['control_peers']
|
||||
self.assertEqual(len(allowed), 3)
|
||||
self.assertTrue(self.original.control_peer_allowed('192.0.2.15', allowed))
|
||||
self.assertTrue(self.original.control_peer_allowed('2001:db8::15', allowed))
|
||||
server.return_value.serve_forever.assert_called_once()
|
||||
|
||||
def test_all_eight_routes_deny_an_unconfigured_peer_before_body_or_store(self):
|
||||
handler = self.original.make_handler(NoStoreCalls(), 'http://localhost:18765')
|
||||
for path in CONTROL_PATHS:
|
||||
with self.subTest(path=path):
|
||||
request = self.request_fixture(handler, path, '192.0.2.15')
|
||||
handler.do_POST(request)
|
||||
request.read_json.assert_not_called()
|
||||
request.send_json.assert_called_once_with(403, {'ok': False, 'error': 'local_only'})
|
||||
|
||||
def test_loopback_and_explicit_peers_pass_the_gate_for_all_eight_routes(self):
|
||||
handler = self.original.make_handler(NoStoreCalls(), 'http://localhost:18765',
|
||||
control_peers=['192.0.2.15'])
|
||||
for path in CONTROL_PATHS:
|
||||
for peer in ('127.0.0.1', '192.0.2.15'):
|
||||
with self.subTest(path=path, source_kind='loopback' if peer.startswith('127.') else 'explicit'):
|
||||
request = self.request_fixture(handler, path, peer)
|
||||
with self.assertRaises(BodyReadReached):
|
||||
handler.do_POST(request)
|
||||
request.read_json.assert_called_once()
|
||||
|
||||
def test_expanded_control_keeps_authentication_and_blocks_development_routes(self):
|
||||
def no_socket_server(address, handler):
|
||||
return types.SimpleNamespace(RequestHandlerClass=handler)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix='framebridge-broker-policy-') as state, \
|
||||
mock.patch.object(broker, '_config', return_value={}), \
|
||||
mock.patch.object(broker, '_control_secret', return_value='fixture-control'), \
|
||||
mock.patch.object(broker, 'ThreadingHTTPServer', side_effect=no_socket_server), \
|
||||
mock.patch.object(self.original, 'control_peer_allowed', side_effect=AssertionError('legacy gate reached')):
|
||||
server = broker.make_server(host='localhost', state_dir=state)
|
||||
handler = server.RequestHandlerClass
|
||||
for path, status, error in (
|
||||
('/fbpair/v1/control/nodes', 403, 'CONTROL_AUTH_REQUIRED'),
|
||||
('/dev/authenticate-and-approve', 404, 'ENDPOINT_NOT_AVAILABLE'),
|
||||
('/dev/approve-authenticated', 404, 'ENDPOINT_NOT_AVAILABLE'),
|
||||
):
|
||||
with self.subTest(path=path):
|
||||
request = self.request_fixture(handler, path, '127.0.0.1')
|
||||
handler.do_POST(request)
|
||||
request.read_json.assert_not_called()
|
||||
request.send_json.assert_called_once_with(status, {'ok': False, 'error': error})
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,375 @@
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from framebridge_expanded import autostart, config, lifecycle, runtime, connection_background
|
||||
|
||||
|
||||
class LifecycleContractTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.home = Path(self.tmp.name) / "home"
|
||||
self.home.mkdir(mode=0o700)
|
||||
self.state = self.home / "profile"
|
||||
self.addCleanup(mock.patch.stopall)
|
||||
mock.patch.object(Path, "home", return_value=self.home).start()
|
||||
self.settings = config.default_config()
|
||||
self.settings["tunnel_id"] = "tunnel_0123456789abcdef0123456789abcdef"
|
||||
config.save_config(self.state, self.settings)
|
||||
_, self.directory, self.unit = autostart._paths(self.state)
|
||||
self.unit.parent.mkdir(mode=0o700, parents=True)
|
||||
unit = autostart.unit_text(self.state, self.directory / "credential.cred").encode()
|
||||
autostart._write(self.unit, unit)
|
||||
cipher = b"SYNTHETIC-ENCRYPTED-FIXTURE-NOT-A-CREDENTIAL"
|
||||
autostart._write(self.directory / "credential.cred", cipher)
|
||||
self.record = {"schema": 1, "unit": self.unit.name,
|
||||
"unit_sha256": hashlib.sha256(unit).hexdigest(),
|
||||
"credential_sha256": hashlib.sha256(cipher).hexdigest(),
|
||||
"connection_settings_sha256": autostart._connection_public_digest(self.settings),
|
||||
"node": False, "headless_changed": False}
|
||||
autostart._json(self.directory / "installation.json", self.record)
|
||||
self.enabled = True
|
||||
self.active = True
|
||||
self.calls = []
|
||||
self.system = mock.patch.object(autostart, "_systemctl", side_effect=self.systemctl).start()
|
||||
self.command = mock.patch.object(autostart, "_command", side_effect=AssertionError("No OS authentication or encryption command belongs in these unit fixtures.")).start()
|
||||
mock.patch.object(autostart, "_linger", return_value=True).start()
|
||||
self.runtime_start = mock.patch.object(runtime, "start", return_value={"running": True, "ready": True}).start()
|
||||
self.runtime_status = mock.patch.object(runtime, "status", return_value={"running": True, "ready": True}).start()
|
||||
self.runtime_stop = mock.patch.object(runtime, "stop", return_value={"ok": True, "running": False}).start()
|
||||
self.background_status = mock.patch.object(connection_background, "status", return_value={"ok": True, "alive": False, "verified": False, "state": "stopped"}).start()
|
||||
self.background_stop = mock.patch.object(connection_background, "disconnect", return_value={"ok": True, "alive": False, "state": "stopped"}).start()
|
||||
|
||||
def systemctl(self, *args, **kwargs):
|
||||
self.calls.append(args)
|
||||
if args[0] == "enable":
|
||||
self.enabled = True
|
||||
if args[0] == "disable":
|
||||
self.enabled = False
|
||||
if args[0] == "start" or "--now" in args and args[0] == "enable":
|
||||
self.active = True
|
||||
if args[0] == "stop" or "--now" in args and args[0] == "disable":
|
||||
self.active = False
|
||||
body = ("ActiveState=" + ("active" if self.active else "inactive")
|
||||
+ "\nSubState=" + ("running" if self.active else "dead")
|
||||
+ "\nUnitFileState=" + ("enabled" if self.enabled else "disabled") + "\n").encode()
|
||||
return subprocess.CompletedProcess(args, 0, body, b"")
|
||||
|
||||
def test_boot_disable_preserves_execution_cipher_and_profile(self):
|
||||
paths = [self.state / "config.json", self.directory / "credential.cred", self.directory / "installation.json", self.unit]
|
||||
before = {p: p.read_bytes() for p in paths}
|
||||
result = lifecycle.boot_disable(self.state)
|
||||
self.assertEqual("boot_disabled", result["state"])
|
||||
self.assertTrue(self.active)
|
||||
self.assertFalse(self.enabled)
|
||||
self.assertEqual(before, {p: p.read_bytes() for p in paths})
|
||||
self.assertIn(("disable", self.unit.name), self.calls)
|
||||
self.runtime_stop.assert_not_called()
|
||||
self.background_stop.assert_not_called()
|
||||
|
||||
def test_boot_enable_does_not_start_an_inactive_stack(self):
|
||||
self.active = self.enabled = False
|
||||
result = lifecycle.boot_enable(self.state)
|
||||
self.assertTrue(result["current_execution_unchanged"])
|
||||
self.assertTrue(self.enabled)
|
||||
self.assertFalse(self.active)
|
||||
self.assertIn(("enable", self.unit.name), self.calls)
|
||||
self.runtime_start.assert_not_called()
|
||||
|
||||
def test_service_stop_preserves_boot_and_material(self):
|
||||
before = (self.directory / "credential.cred").read_bytes()
|
||||
result = lifecycle.service_stop(self.state)
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertTrue(self.enabled)
|
||||
self.assertFalse(self.active)
|
||||
self.assertEqual(before, (self.directory / "credential.cred").read_bytes())
|
||||
self.assertTrue(autostart.manual_stop_requested(self.state))
|
||||
self.assertTrue(autostart.connection_requested(self.state))
|
||||
self.assertNotIn(("disable", self.unit.name), self.calls)
|
||||
|
||||
def test_service_start_retains_disabled_boot_policy(self):
|
||||
self.active = self.enabled = False
|
||||
autostart.mark_manual_stop(self.state)
|
||||
result = lifecycle.service_start(self.state)
|
||||
self.assertEqual("start_requested", result["state"])
|
||||
self.assertFalse(self.enabled)
|
||||
self.assertTrue(self.active)
|
||||
self.assertFalse(autostart.manual_stop_requested(self.state))
|
||||
self.assertNotIn(("enable", self.unit.name), self.calls)
|
||||
|
||||
def test_disconnect_persists_intent_without_stopping_local_tools(self):
|
||||
result = lifecycle.disconnect(self.state)
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertFalse(autostart.connection_requested(self.state))
|
||||
self.assertEqual("until_explicit_connect", result["scope"])
|
||||
self.runtime_stop.assert_not_called()
|
||||
self.assertFalse(any(x[0] in {"stop", "disable"} for x in self.calls))
|
||||
self.assertEqual("disconnected", lifecycle.status(self.state)["desired_connection"])
|
||||
|
||||
def test_connection_intent_is_not_tied_to_a_boot_identifier(self):
|
||||
lifecycle.disconnect(self.state)
|
||||
record = json.loads((self.directory / "connection-intent.json").read_text())
|
||||
self.assertNotIn("boot_id", record)
|
||||
self.assertFalse(autostart.connection_requested(self.state))
|
||||
revision = autostart.connection_request_revision(self.state)
|
||||
autostart._set_connection_requested(self.state, True)
|
||||
self.assertTrue(autostart.connection_requested(self.state))
|
||||
self.assertGreater(autostart.connection_request_revision(self.state), revision)
|
||||
|
||||
def test_saved_resume_wakes_transport_without_stopping_local_tools(self):
|
||||
lifecycle.disconnect(self.state)
|
||||
self.background_stop.reset_mock()
|
||||
self.calls.clear()
|
||||
current = {"configured": True, "enabled": True, "service_active": True,
|
||||
"runtime": {"running": True, "ready": True},
|
||||
"connection": {"alive": False, "verified": False},
|
||||
"worker": {"state": "disconnected"}}
|
||||
with mock.patch.object(autostart, "status", return_value=current):
|
||||
autostart.resume_connection(self.state)
|
||||
self.assertTrue(autostart.connection_requested(self.state))
|
||||
self.assertFalse(any(x[0] in {"stop", "start"} for x in self.calls))
|
||||
self.background_stop.assert_not_called()
|
||||
self.runtime_stop.assert_not_called()
|
||||
self.command.assert_not_called()
|
||||
|
||||
def test_existing_legacy_enable_repairs_a_disabled_unit(self):
|
||||
self.enabled = self.active = False
|
||||
result = autostart.enable(self.state)
|
||||
self.assertTrue(self.enabled)
|
||||
self.assertTrue(self.active)
|
||||
self.assertIn(("enable", "--now", self.unit.name), self.calls)
|
||||
|
||||
def test_changed_unit_is_rejected_before_boot_or_stop_actions(self):
|
||||
self.unit.write_text("changed")
|
||||
self.unit.chmod(0o600)
|
||||
self.calls.clear()
|
||||
with self.assertRaisesRegex(RuntimeError, "changed outside"):
|
||||
lifecycle.boot_disable(self.state)
|
||||
with self.assertRaisesRegex(RuntimeError, "changed outside"):
|
||||
lifecycle.service_stop(self.state)
|
||||
self.assertEqual([], self.calls)
|
||||
|
||||
def test_forget_removes_managed_connection_but_retains_profile_and_linger(self):
|
||||
before = (self.state / "config.json").read_bytes()
|
||||
autostart._set_connection_requested(self.state, False)
|
||||
result = lifecycle.forget_credentials(self.state)
|
||||
self.assertTrue(result["profile_preserved"])
|
||||
self.assertTrue(result["linger_preserved"])
|
||||
self.assertEqual(before, (self.state / "config.json").read_bytes())
|
||||
self.assertFalse(self.unit.exists())
|
||||
self.assertFalse((self.directory / "credential.cred").exists())
|
||||
self.assertFalse((self.directory / "installation.json").exists())
|
||||
self.assertFalse((self.directory / "connection-intent.json").exists())
|
||||
self.assertEqual("not_configured", lifecycle.status(self.state)["desired_connection"])
|
||||
self.command.assert_not_called()
|
||||
|
||||
def test_forget_refuses_changed_cipher_without_removing_anything(self):
|
||||
p = self.directory / "credential.cred"
|
||||
p.write_bytes(b"changed")
|
||||
p.chmod(0o600)
|
||||
self.calls.clear()
|
||||
with self.assertRaisesRegex(RuntimeError, "changed outside"):
|
||||
lifecycle.forget_credentials(self.state)
|
||||
self.assertTrue(self.unit.exists())
|
||||
self.assertTrue(p.exists())
|
||||
self.assertTrue((self.directory / "installation.json").exists())
|
||||
self.assertEqual([], self.calls)
|
||||
|
||||
def test_paused_worker_never_decrypts_or_starts_a_transport(self):
|
||||
lifecycle.disconnect(self.state)
|
||||
with mock.patch.object(autostart, "_credential") as credential, \
|
||||
mock.patch.object(connection_background, "start") as launch, \
|
||||
mock.patch.object(autostart.time, "sleep", side_effect=lambda _: setattr(autostart, "_STOP", True)):
|
||||
self.assertEqual(0, autostart.worker(self.state))
|
||||
credential.assert_not_called()
|
||||
launch.assert_not_called()
|
||||
self.assertFalse(autostart.connection_requested(self.state))
|
||||
|
||||
def test_explicit_connect_resumes_paused_worker_without_runtime_restart(self):
|
||||
lifecycle.disconnect(self.state)
|
||||
sleeps = 0
|
||||
def sleep(_):
|
||||
nonlocal sleeps
|
||||
sleeps += 1
|
||||
if sleeps == 1:
|
||||
autostart._set_connection_requested(self.state, True)
|
||||
else:
|
||||
autostart._STOP = True
|
||||
with mock.patch.object(autostart, "_credential", return_value="SYNTHETIC-NOT-A-REAL-KEY") as credential, \
|
||||
mock.patch.object(connection_background, "start") as launch, \
|
||||
mock.patch.object(autostart.time, "sleep", side_effect=sleep):
|
||||
self.assertEqual(0, autostart.worker(self.state))
|
||||
self.assertEqual(1, launch.call_count)
|
||||
self.assertEqual(1, credential.call_count)
|
||||
self.assertEqual(1, self.runtime_start.call_count)
|
||||
self.assertEqual(1, self.runtime_stop.call_count) # Final simulated unit stop only.
|
||||
|
||||
def test_legacy_current_only_disconnect_is_not_restarted_in_same_worker(self):
|
||||
sleeps = 0
|
||||
def sleep(_):
|
||||
nonlocal sleeps
|
||||
sleeps += 1
|
||||
if sleeps == 2:
|
||||
autostart._STOP = True
|
||||
states = [{"alive": False, "state": "stopped"},
|
||||
{"alive": True, "verified": True, "state": "ready"},
|
||||
{"alive": False, "state": "stopped"}]
|
||||
self.background_status.side_effect = states
|
||||
with mock.patch.object(autostart, "_credential", return_value="SYNTHETIC-NOT-A-REAL-KEY"), \
|
||||
mock.patch.object(connection_background, "start") as launch, \
|
||||
mock.patch.object(autostart.time, "sleep", side_effect=sleep):
|
||||
self.assertEqual(0, autostart.worker(self.state))
|
||||
self.assertEqual(1, launch.call_count)
|
||||
self.assertEqual(1, self.runtime_start.call_count)
|
||||
|
||||
def test_transport_error_retries_without_restarting_local_runtime(self):
|
||||
self.background_status.return_value = {"alive": False, "state": "error"}
|
||||
sleeps = 0
|
||||
clock = [0.0]
|
||||
def sleep(_):
|
||||
nonlocal sleeps
|
||||
sleeps += 1
|
||||
clock[0] += 15
|
||||
if sleeps == 2:
|
||||
autostart._STOP = True
|
||||
with mock.patch.object(autostart, "_credential", return_value="SYNTHETIC-NOT-A-REAL-KEY"), \
|
||||
mock.patch.object(connection_background, "start") as launch, \
|
||||
mock.patch.object(autostart.time, "sleep", side_effect=sleep), \
|
||||
mock.patch.object(autostart.time, "monotonic", side_effect=lambda: clock[0]):
|
||||
self.assertEqual(0, autostart.worker(self.state))
|
||||
self.assertEqual(2, launch.call_count)
|
||||
self.assertEqual(1, self.runtime_start.call_count)
|
||||
|
||||
|
||||
def test_connect_respects_an_explicit_disabled_boot_policy(self):
|
||||
lifecycle.boot_disable(self.state)
|
||||
self.calls.clear()
|
||||
current = {"configured": True, "enabled": False, "service_active": True,
|
||||
"runtime": {"running": True, "ready": True},
|
||||
"connection": {"alive": False, "verified": False},
|
||||
"worker": {"state": "disconnected"}}
|
||||
with mock.patch.object(autostart, "status", return_value=current):
|
||||
autostart.resume_connection(self.state)
|
||||
self.assertFalse(autostart.boot_requested(self.state))
|
||||
self.assertFalse(any(x[0] == "enable" for x in self.calls))
|
||||
|
||||
def test_connect_starts_disabled_inactive_service_without_enabling_boot(self):
|
||||
lifecycle.boot_disable(self.state)
|
||||
self.active = False
|
||||
self.calls.clear()
|
||||
current = {"configured": True, "enabled": False, "service_active": False,
|
||||
"runtime": {"running": False, "ready": False}, "worker": {"state": "stopped"}}
|
||||
with mock.patch.object(autostart, "status", return_value=current):
|
||||
autostart.resume_connection(self.state)
|
||||
self.assertIn(("start", self.unit.name), self.calls)
|
||||
self.assertFalse(self.enabled)
|
||||
self.assertFalse(any(x[0] == "enable" for x in self.calls))
|
||||
|
||||
def test_failed_boot_policy_action_restores_previous_intent(self):
|
||||
lifecycle.boot_enable(self.state)
|
||||
before = (self.directory / "boot-policy.json").read_bytes()
|
||||
with mock.patch.object(autostart, "_systemctl", side_effect=RuntimeError("synthetic service failure")):
|
||||
with self.assertRaisesRegex(RuntimeError, "synthetic"):
|
||||
lifecycle.boot_disable(self.state)
|
||||
self.assertEqual(before, (self.directory / "boot-policy.json").read_bytes())
|
||||
self.assertTrue(autostart.boot_requested(self.state))
|
||||
|
||||
def _new_node(self):
|
||||
state = self.home / "new-node"
|
||||
settings = config.default_config(role="node")
|
||||
settings.update(headless="auto", broker_url="https://example.test:20878")
|
||||
config.save_config(state, settings)
|
||||
self.active = self.enabled = False
|
||||
def command(args, **kwargs):
|
||||
return subprocess.CompletedProcess(args, 0, b"systemd 257\n" if args[:2] == ["systemd-creds", "--version"] else b"", b"")
|
||||
self.command.side_effect = command
|
||||
return state
|
||||
|
||||
def test_initial_node_service_start_needs_no_chatgpt_key_or_extra_question(self):
|
||||
state = self._new_node()
|
||||
with mock.patch("builtins.input", side_effect=AssertionError("No redundant startup question")), \
|
||||
mock.patch.object(autostart.getpass, "getpass", side_effect=AssertionError("Node has no ChatGPT key")), \
|
||||
mock.patch.object(autostart, "_enabled_units", return_value=[]):
|
||||
result = lifecycle.service_start(state)
|
||||
_, directory, unit = autostart._paths(state)
|
||||
self.assertTrue(result["initial_configuration"])
|
||||
self.assertTrue(result["boot_policy_changed"])
|
||||
self.assertIn(("enable", "--now", unit.name), self.calls)
|
||||
self.assertFalse((directory / "credential.cred").exists())
|
||||
self.assertTrue(json.loads((directory / "installation.json").read_text())["node"])
|
||||
|
||||
def test_initial_node_boot_enable_does_not_start_current_execution(self):
|
||||
state = self._new_node()
|
||||
with mock.patch("builtins.input", side_effect=AssertionError("No redundant startup question")), \
|
||||
mock.patch.object(autostart.getpass, "getpass", side_effect=AssertionError("Node has no ChatGPT key")), \
|
||||
mock.patch.object(autostart, "_enabled_units", return_value=[]):
|
||||
result = lifecycle.boot_enable(state)
|
||||
_, directory, unit = autostart._paths(state)
|
||||
self.assertTrue(result["initial_configuration"])
|
||||
self.assertTrue(result["current_execution_unchanged"])
|
||||
self.assertTrue(autostart.boot_requested(state))
|
||||
self.assertFalse(self.active)
|
||||
self.assertNotIn(("enable", "--now", unit.name), self.calls)
|
||||
self.assertFalse((directory / "credential.cred").exists())
|
||||
|
||||
|
||||
|
||||
def test_status_handles_a_profile_with_an_empty_autostart_directory(self):
|
||||
state = self.home / "not-configured"
|
||||
config.save_config(state, config.default_config())
|
||||
(state / "autostart").mkdir(mode=0o700)
|
||||
result = lifecycle.status(state)
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertFalse(result["boot"]["configured"])
|
||||
self.assertEqual("not_configured", result["desired_connection"])
|
||||
self.assertEqual("not_configured", autostart.status(state)["desired_connection"])
|
||||
|
||||
def test_persistent_transport_error_respects_retry_delay(self):
|
||||
self.background_status.return_value = {"alive": False, "state": "error"}
|
||||
sleeps = 0
|
||||
def sleep(_):
|
||||
nonlocal sleeps
|
||||
sleeps += 1
|
||||
if sleeps == 2:
|
||||
autostart._STOP = True
|
||||
with mock.patch.object(autostart, "_credential", return_value="SYNTHETIC-NOT-A-REAL-KEY"), \
|
||||
mock.patch.object(connection_background, "start") as launch, \
|
||||
mock.patch.object(autostart.time, "sleep", side_effect=sleep), \
|
||||
mock.patch.object(autostart.time, "monotonic", return_value=0.0):
|
||||
self.assertEqual(0, autostart.worker(self.state))
|
||||
self.assertEqual(1, launch.call_count)
|
||||
self.assertEqual(1, self.runtime_start.call_count)
|
||||
|
||||
|
||||
|
||||
def test_service_stop_does_not_confuse_query_success_with_termination(self):
|
||||
results = [{"ok": True, "running": True, "processes": []},
|
||||
{"ok": True, "running": False, "processes": [{"alive": True}]}]
|
||||
for observed in results:
|
||||
with self.subTest(observed=observed), \
|
||||
mock.patch.object(runtime, "stop", return_value=observed), \
|
||||
mock.patch.object(lifecycle.time, "monotonic", side_effect=[0.0, 3.0]):
|
||||
result = lifecycle.service_stop(self.state)
|
||||
self.assertFalse(result["ok"])
|
||||
self.assertEqual("stop_incomplete", result["state"])
|
||||
self.assertTrue((self.directory / "credential.cred").exists())
|
||||
|
||||
def test_forget_retains_material_if_local_tools_remain_running(self):
|
||||
before = (self.directory / "credential.cred").read_bytes()
|
||||
with mock.patch.object(runtime, "stop", return_value={"ok": True, "running": True}), \
|
||||
mock.patch.object(lifecycle.time, "monotonic", side_effect=[0.0, 3.0]):
|
||||
with self.assertRaisesRegex(RuntimeError, "material was retained"):
|
||||
lifecycle.forget_credentials(self.state)
|
||||
self.assertEqual(before, (self.directory / "credential.cred").read_bytes())
|
||||
self.assertTrue(self.unit.exists())
|
||||
self.assertTrue((self.directory / "installation.json").exists())
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Dispatch-race and uncertain-outcome tests using inert CUA engines only."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from framebridge_expanded import display_context as dc
|
||||
from framebridge_expanded import native_host as nh
|
||||
from framebridge_expanded import adapter as ad
|
||||
|
||||
class CuaError(RuntimeError):
|
||||
def __init__(self, code, message=None):
|
||||
self.code = code
|
||||
super().__init__(message or code)
|
||||
|
||||
class NativeDispatchTests(unittest.TestCase):
|
||||
def fake(self, calls, action=None):
|
||||
cua = SimpleNamespace(CuaTransportError=CuaError)
|
||||
def run(argv, timeout=15):
|
||||
calls.append(list(argv))
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
cua._run = run
|
||||
cua.handle_cua_message = lambda msg: action(cua) if action else {"ok": True}
|
||||
return cua
|
||||
|
||||
def checker(self, fail_at):
|
||||
state = {"count": 0}
|
||||
def check(env):
|
||||
state["count"] += 1
|
||||
if state["count"] >= fail_at:
|
||||
raise dc.DisplayContextError("CUA_FOREGROUND_SESSION_CHANGED")
|
||||
return {}
|
||||
return check
|
||||
|
||||
def test_queue_then_context_change_blocks_first_primitive(self):
|
||||
calls = []
|
||||
cua = self.fake(calls, lambda engine: engine._run(["xdotool", "key", "a"]))
|
||||
state = nh.install_cua_guard(cua, env={}, checker=self.checker(2))
|
||||
with self.assertRaises(CuaError):
|
||||
cua.handle_cua_message({})
|
||||
self.assertEqual(calls, [])
|
||||
result = nh.decorate_response({"executed": False}, state, {})
|
||||
self.assertFalse(result["input_may_have_been_sent"])
|
||||
self.assertFalse(result["performed"])
|
||||
self.assertFalse(result["do_not_replay"])
|
||||
|
||||
def test_context_change_between_query_and_input_blocks_input(self):
|
||||
calls = []
|
||||
def action(cua):
|
||||
cua._run(["xdotool", "getactivewindow"])
|
||||
cua._run(["xdotool", "click", "1"])
|
||||
cua = self.fake(calls, action)
|
||||
state = nh.install_cua_guard(cua, env={}, checker=self.checker(3))
|
||||
with self.assertRaises(CuaError):
|
||||
cua.handle_cua_message({})
|
||||
self.assertEqual(calls, [["xdotool", "getactivewindow"]])
|
||||
self.assertFalse(state["input_started"])
|
||||
|
||||
def test_postflight_failure_after_input_retains_uncertain_outcome(self):
|
||||
calls = []
|
||||
cua = self.fake(calls, lambda engine: engine._run(["xdotool", "type", "x"]))
|
||||
state = nh.install_cua_guard(cua, env={}, checker=self.checker(3))
|
||||
with self.assertRaises(CuaError):
|
||||
cua.handle_cua_message({})
|
||||
result = nh.decorate_response({"executed": True, "performed": True}, state, {})
|
||||
self.assertFalse(result["executed"])
|
||||
self.assertTrue(result["input_may_have_been_sent"])
|
||||
self.assertTrue(result["do_not_replay"])
|
||||
self.assertNotEqual(result.get("performed"), False)
|
||||
self.assertTrue(json.loads(result["output"])["result_discarded"])
|
||||
|
||||
def test_observe_late_failure_does_not_claim_input(self):
|
||||
calls = []
|
||||
cua = self.fake(calls, lambda engine: engine._run(["import", "-window", "root", "fixture.png"]))
|
||||
state = nh.install_cua_guard(cua, env={}, checker=self.checker(3))
|
||||
with self.assertRaises(CuaError):
|
||||
cua.handle_cua_message({})
|
||||
result = nh.decorate_response({"executed": False}, state, {})
|
||||
self.assertFalse(result["input_may_have_been_sent"])
|
||||
self.assertFalse(result["performed"])
|
||||
|
||||
def test_successful_input_is_reported_performed(self):
|
||||
calls = []
|
||||
cua = self.fake(calls, lambda engine: engine._run(["/usr/bin/xdotool", "key", "a"]))
|
||||
state = nh.install_cua_guard(cua, env={}, checker=lambda env: {})
|
||||
cua.handle_cua_message({})
|
||||
result = nh.decorate_response({"executed": True}, state, {})
|
||||
self.assertTrue(result["performed"])
|
||||
self.assertTrue(result["input_may_have_been_sent"])
|
||||
self.assertFalse(result["do_not_replay"])
|
||||
|
||||
def test_captured_pointer_helper_rechecks_after_its_internal_query(self):
|
||||
calls = []
|
||||
cua = self.fake(calls)
|
||||
cua._run_v3_base = cua._run
|
||||
def helper(argv, timeout=15):
|
||||
cua._run_v3_base(["xdotool", "getmouselocation", "--shell"])
|
||||
return cua._run_v3_base(argv, timeout=timeout)
|
||||
cua._run = helper
|
||||
cua.handle_cua_message = lambda msg: cua._run(["xdotool", "mousemove", "1", "1"])
|
||||
state = nh.install_cua_guard(cua, env={}, checker=self.checker(4))
|
||||
with self.assertRaises(CuaError):
|
||||
cua.handle_cua_message({})
|
||||
self.assertEqual(calls, [["xdotool", "getmouselocation", "--shell"]])
|
||||
self.assertFalse(state["input_started"])
|
||||
|
||||
def test_metadata_does_not_leak_to_next_non_cua_response(self):
|
||||
state = {"active": True, "input_started": True, "failed_code": ""}
|
||||
first = nh.decorate_response({"executed": True}, state, {})
|
||||
self.assertTrue(first["input_may_have_been_sent"])
|
||||
second = {"executed": True, "channel": "CONTROL-DIRECT"}
|
||||
self.assertEqual(nh.decorate_response(second, state, {}), second)
|
||||
self.assertNotIn("input_may_have_been_sent", second)
|
||||
|
||||
class NativeFramingTests(unittest.TestCase):
|
||||
def test_captured_host_protocol_rejects_unbound_display_without_input(self):
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
with tempfile.TemporaryDirectory(prefix="fb-native-guard-") as home:
|
||||
env = {"HOME": home, "PATH": os.environ.get("PATH", "/usr/bin:/bin"),
|
||||
"PYTHONDONTWRITEBYTECODE": "1", "LC_ALL": "C.UTF-8",
|
||||
"FRAMEBRIDGE_EXPANDED_PREFIX": str(root), dc.BINDING: "{}"}
|
||||
message = json.dumps({"op": "cua", "action": "OBSERVE", "authorized": "YES",
|
||||
"target": "ASUS", "id": "r2-native-test"}).encode()
|
||||
proc = subprocess.run([sys.executable, str(root / "packaging/framebridge-native-expanded")],
|
||||
input=struct.pack("<I", len(message)) + message,
|
||||
env=env, capture_output=True, timeout=15)
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr.decode(errors="replace")[-500:])
|
||||
self.assertGreaterEqual(len(proc.stdout), 4)
|
||||
length = struct.unpack("<I", proc.stdout[:4])[0]
|
||||
self.assertEqual(len(proc.stdout), length + 4)
|
||||
result = json.loads(proc.stdout[4:])
|
||||
self.assertFalse(result["executed"])
|
||||
self.assertFalse(result["performed"])
|
||||
self.assertFalse(result["input_may_have_been_sent"])
|
||||
self.assertFalse(result["do_not_replay"])
|
||||
self.assertEqual(json.loads(result["output"])["error_code"], "CUA_DISPLAY_UNAVAILABLE")
|
||||
|
||||
class AdapterDispatchTests(unittest.TestCase):
|
||||
def adapter(self, response=None, error=None):
|
||||
adapter = SimpleNamespace()
|
||||
def roundtrip(payload, timeout_s=60):
|
||||
if error:
|
||||
raise error
|
||||
return dict(response or {"executed": True})
|
||||
def handle(request, job_id):
|
||||
native = adapter.native_cua_roundtrip(request["cua"], timeout_s=60)
|
||||
return {"ok": bool(native.get("executed")), "mode": "CUA"}
|
||||
adapter.native_cua_roundtrip = roundtrip
|
||||
adapter.handle_cua_request = handle
|
||||
return adapter
|
||||
|
||||
def test_native_metadata_survives_original_adapter_mapping(self):
|
||||
adapter = self.adapter({"executed": False, "input_may_have_been_sent": True,
|
||||
"do_not_replay": True})
|
||||
ad.install_adapter_guard(adapter, env={}, checker=lambda env: {})
|
||||
result = adapter.handle_cua_request({"cua": {"action": "KEY"}}, "fixture")
|
||||
self.assertTrue(result["input_may_have_been_sent"])
|
||||
self.assertTrue(result["do_not_replay"])
|
||||
|
||||
def test_before_dispatch_failure_claims_no_input(self):
|
||||
adapter = self.adapter()
|
||||
def check(env):
|
||||
raise dc.DisplayContextError("CUA_FOREGROUND_SESSION_CHANGED")
|
||||
ad.install_adapter_guard(adapter, env={}, checker=check)
|
||||
result = adapter.handle_cua_request({"cua": {"action": "KEY"}}, "fixture")
|
||||
self.assertFalse(result["performed"])
|
||||
self.assertFalse(result["do_not_replay"])
|
||||
|
||||
def test_timeout_after_dispatch_never_invites_input_replay(self):
|
||||
adapter = self.adapter(error=TimeoutError("native transport timed out"))
|
||||
ad.install_adapter_guard(adapter, env={}, checker=lambda env: {})
|
||||
result = adapter.handle_cua_request({"cua": {"action": "KEY"}}, "fixture")
|
||||
self.assertFalse(result["ok"])
|
||||
self.assertTrue(result["input_may_have_been_sent"])
|
||||
self.assertTrue(result["do_not_replay"])
|
||||
self.assertNotIn("performed", result)
|
||||
|
||||
def test_observe_timeout_has_no_input_outcome(self):
|
||||
adapter = self.adapter(error=TimeoutError("native transport timed out"))
|
||||
ad.install_adapter_guard(adapter, env={}, checker=lambda env: {})
|
||||
result = adapter.handle_cua_request({"cua": {"action": "OBSERVE"}}, "fixture")
|
||||
self.assertFalse(result["input_may_have_been_sent"])
|
||||
self.assertFalse(result["performed"])
|
||||
|
||||
def test_adapter_postflight_preserves_explicit_no_input(self):
|
||||
adapter = self.adapter({"executed": False, "input_may_have_been_sent": False})
|
||||
count = [0]
|
||||
def check(env):
|
||||
count[0] += 1
|
||||
if count[0] == 2:
|
||||
raise dc.DisplayContextError("CUA_FOREGROUND_SESSION_CHANGED")
|
||||
ad.install_adapter_guard(adapter, env={}, checker=check)
|
||||
result = adapter.handle_cua_request({"cua": {"action": "KEY"}}, "fixture")
|
||||
self.assertFalse(result["input_may_have_been_sent"])
|
||||
self.assertFalse(result["performed"])
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,83 @@
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from unittest import mock
|
||||
from framebridge_expanded import network
|
||||
|
||||
def interface(name="eth0", *, address=True, flags=None, scope="global"):
|
||||
return {"ifname": name, "flags": ["UP", "LOWER_UP"] if flags is None else flags,
|
||||
"operstate": "UP", "addr_info": [
|
||||
{"family": "inet", "local": "192.0.2.20", "prefixlen": 24, "scope": scope}
|
||||
] if address else []}
|
||||
|
||||
def route(name="eth0"):
|
||||
return {"dst": "default", "dev": name, "gateway": "192.0.2.1"}
|
||||
|
||||
class NetworkTests(unittest.TestCase):
|
||||
def test_working_ifupdown_is_not_misreported_from_nm_unmanaged(self):
|
||||
value = network.summarize([interface()], {"ipv4": [route()]},
|
||||
{"eth0": {"managed": False, "state": "10 (unmanaged)"}}, {"eth0"})
|
||||
self.assertEqual(value["connectivity"], "LINK_ADDRESS_DEFAULT_ROUTE")
|
||||
self.assertEqual(value["interfaces"][0]["manager"], "ifupdown")
|
||||
self.assertFalse(value["internet_verified"])
|
||||
self.assertEqual(value["warnings"][0]["code"], "CONNECTED_INTERFACE_NOT_MANAGED_BY_NETWORKMANAGER")
|
||||
|
||||
def test_manager_connected_state_does_not_substitute_for_an_address(self):
|
||||
value = network.summarize([interface(address=False)], {"ipv4": [route()]},
|
||||
{"eth0": {"managed": True, "state": "100 (connected)"}})
|
||||
self.assertEqual(value["connectivity"], "NOT_ESTABLISHED")
|
||||
|
||||
def test_address_without_default_route_is_not_internet_claim(self):
|
||||
value = network.summarize([interface()], {"ipv4": []})
|
||||
self.assertEqual(value["connectivity"], "NOT_ESTABLISHED")
|
||||
self.assertFalse(value["internet_verified"])
|
||||
|
||||
def test_loopback_does_not_establish_external_connectivity(self):
|
||||
value = network.summarize([interface("lo")], {"ipv4": [route("lo")]},
|
||||
{"lo": {"managed": True, "state": "100 (connected (externally))"}})
|
||||
self.assertEqual(value["connectivity"], "NOT_ESTABLISHED")
|
||||
|
||||
def test_interface_down_is_not_ready_even_with_residual_routes(self):
|
||||
value = network.summarize([interface(flags=[])], {"ipv4": [route()]})
|
||||
self.assertFalse(value["interfaces"][0]["local_route_ready"])
|
||||
|
||||
def test_ipv6_global_address_and_route_are_supported(self):
|
||||
row = interface()
|
||||
row["addr_info"] = [{"family": "inet6", "local": "2001:db8::20", "prefixlen": 64, "scope": "global"}]
|
||||
value = network.summarize([row], {"ipv6": [{"dst": "default", "dev": "eth0", "gateway": "fe80::1"}]})
|
||||
self.assertEqual(value["connectivity"], "LINK_ADDRESS_DEFAULT_ROUTE")
|
||||
|
||||
def test_unmanaged_is_not_automatically_named_ifupdown(self):
|
||||
value = network.summarize([interface()], {"ipv4": [route()]},
|
||||
{"eth0": {"managed": False, "state": "10 (unmanaged)"}})
|
||||
self.assertEqual(value["interfaces"][0]["manager"], "unverified")
|
||||
|
||||
def test_missing_kernel_data_returns_explicit_failure(self):
|
||||
with mock.patch.object(network, "_json", return_value=None), \
|
||||
mock.patch.object(network.shutil, "which", return_value=None), \
|
||||
mock.patch.object(network, "_active", return_value=False):
|
||||
value = network.inspect()
|
||||
self.assertFalse(value["ok"])
|
||||
self.assertEqual(value["error"], "NETWORK_KERNEL_STATUS_UNAVAILABLE")
|
||||
self.assertFalse(value["internet_verified"])
|
||||
|
||||
def test_ifupdown_state_contains_names_only_and_rejects_symlinks(self):
|
||||
with tempfile.TemporaryDirectory() as path:
|
||||
state = Path(path) / "ifstate"
|
||||
state.write_text("lo=lo\neth0=eth0\nunsafe command=value\n")
|
||||
state.chmod(0o600)
|
||||
self.assertEqual(network._ifupdown(state), {"lo", "eth0"})
|
||||
link = Path(path) / "link"
|
||||
link.symlink_to(state)
|
||||
self.assertEqual(network._ifupdown(link), set())
|
||||
|
||||
def test_nm_parser_accepts_only_device_metadata(self):
|
||||
output = ("GENERAL.DEVICE:eth0\nGENERAL.STATE:10 (unmanaged)\nGENERAL.NM-MANAGED:no\n"
|
||||
"GENERAL.DEVICE:eth\\:bad\nGENERAL.STATE:100 (connected)\nGENERAL.NM-MANAGED:yes\n")
|
||||
with mock.patch.object(network, "_command", return_value=(0, output)) as command:
|
||||
devices = network._nm_devices()
|
||||
self.assertEqual(devices, {"eth0": {"managed": False, "state": "10 (unmanaged)"}})
|
||||
self.assertNotIn("connection", command.call_args.args[0])
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Exercise the actual portable CUA routing function without importing its MCP server."""
|
||||
import ast
|
||||
import copy
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
def actual_cua(screen, dispatch):
|
||||
source = ROOT / "portable/front_door_mcp.py"
|
||||
tree = ast.parse(source.read_text())
|
||||
function = copy.deepcopy(next(node for node in tree.body
|
||||
if isinstance(node, ast.FunctionDef) and node.name == "framebridge_cua"))
|
||||
function.decorator_list = []
|
||||
module = ast.fix_missing_locations(ast.Module(body=[function], type_ignores=[]))
|
||||
namespace = {"_real_screen_client": screen, "dispatch_cua_request": dispatch}
|
||||
exec(compile(module, str(source), "exec"), namespace)
|
||||
return namespace["framebridge_cua"]
|
||||
|
||||
class PortableCaptureScopeTests(unittest.TestCase):
|
||||
def test_node_scope_preserves_alias_case_and_uses_real_screen_client(self):
|
||||
screen = mock.Mock()
|
||||
screen.call.return_value = {"fixture": "actual-client-route-only"}
|
||||
dispatch = mock.Mock(side_effect=AssertionError("legacy CUA is not the physical node route"))
|
||||
cua = actual_cua(screen, dispatch)
|
||||
value = cua("observe", True, capture_scope=" NoDe:CaseSensitive-Node ", target="UNRELATED")
|
||||
self.assertEqual(value, screen.call.return_value)
|
||||
screen.call.assert_called_once_with("observe", "CaseSensitive-Node", True)
|
||||
dispatch.assert_not_called()
|
||||
|
||||
def test_node_scope_input_is_rejected_without_hidden_or_graphical_fallback(self):
|
||||
for action in ["TYPE", "KEY", "CLICK", "DRAG"]:
|
||||
with self.subTest(action=action):
|
||||
screen, dispatch = mock.Mock(), mock.Mock()
|
||||
cua = actual_cua(screen, dispatch)
|
||||
value = cua(action, True, capture_scope="node:CaseSensitive-Node", text="not executed")
|
||||
self.assertEqual(value["error"], "NODE_SCREEN_SCOPE_OBSERVE_ONLY")
|
||||
screen.call.assert_not_called()
|
||||
dispatch.assert_not_called()
|
||||
|
||||
def test_authorization_precedes_physical_capture_dispatch(self):
|
||||
screen, dispatch = mock.Mock(), mock.Mock()
|
||||
cua = actual_cua(screen, dispatch)
|
||||
value = cua("OBSERVE", False, capture_scope="node:CaseSensitive-Node")
|
||||
self.assertEqual(value["error"], "AUTHORIZED_TRUE_REQUIRED")
|
||||
screen.call.assert_not_called()
|
||||
dispatch.assert_not_called()
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Regression checks for portable helpers and removed implicit lab routing."""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
SOURCE = Path(__file__).resolve().parents[1]
|
||||
FRONT_DOORS = (
|
||||
SOURCE / 'portable/gerameg_cua_frontdoor.py',
|
||||
SOURCE / 'sources/original-geranas/FrameBridge-Front-Door/bin/gerameg_cua_frontdoor.py',
|
||||
)
|
||||
RPC = SOURCE / 'sources/original-gerameg/cua_tty_rpc.py'
|
||||
HOST = SOURCE / 'sources/original-asus/framebridge-native/host/framebridge_host.py'
|
||||
LAUNCHER = SOURCE / 'packaging/framebridge-original'
|
||||
PROGRESS_CONSOLE = SOURCE / 'sources/original-asus/framebridge-native/host/open-progress-console.sh'
|
||||
|
||||
|
||||
def load_module(path, name):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class PublicReleasePrivacyTests(unittest.TestCase):
|
||||
def test_captured_journal_uses_account_state_without_payload_contents(self):
|
||||
# Invoke only the journal writer with synthetic metadata in a temporary HOME.
|
||||
tree = ast.parse(HOST.read_text())
|
||||
journal = next(n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == '_cua_runtime_journal')
|
||||
namespace = {'Path': Path, 'json': json}
|
||||
exec(compile(ast.Module(body=[journal], type_ignores=[]), str(HOST), 'exec'), namespace)
|
||||
with tempfile.TemporaryDirectory(prefix='framebridge-journal-') as temporary:
|
||||
home = Path(temporary) / 'different account'
|
||||
with mock.patch.object(Path, 'home', return_value=home):
|
||||
namespace['_cua_runtime_journal']('synthetic_event',
|
||||
{'id': 'example-request', 'action': 'STATUS',
|
||||
'script': 'private-example-input-not-to-log'}, rc=0)
|
||||
log = home / '.local/state/framebridge/cua-runtime/cua-runtime.log'
|
||||
records = log.read_text().splitlines()
|
||||
self.assertEqual(len(records), 1)
|
||||
record = json.loads(records[0])
|
||||
self.assertEqual(record['event'], 'synthetic_event')
|
||||
self.assertEqual(record['id'], 'example-request')
|
||||
self.assertNotIn('private-example-input-not-to-log', records[0])
|
||||
self.assertEqual([path.name for path in home.iterdir()], ['.local'])
|
||||
|
||||
def test_captured_launcher_keeps_progress_inside_generic_profile_state(self):
|
||||
# settings() computes paths; no process or UI is started by this check.
|
||||
import hashlib
|
||||
tree = ast.parse(LAUNCHER.read_text())
|
||||
settings = next(n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == 'settings')
|
||||
with tempfile.TemporaryDirectory(prefix='framebridge-profile-') as temporary:
|
||||
home = Path(temporary) / 'different account'
|
||||
state = home / 'selected profile'
|
||||
namespace = {'Path': Path, 'os': os, 'hashlib': hashlib,
|
||||
'REAL_HOME': home, 'PREFIX': Path('/opt/framebridge-original'),
|
||||
'LAYOUT': {'fbterm_bin': 'fbterm', 'native_host': 'host.py'}}
|
||||
exec(compile(ast.Module(body=[settings], type_ignores=[]), str(LAUNCHER), 'exec'), namespace)
|
||||
args = types.SimpleNamespace(state_dir=str(state), port=12345, hub_port=12346, broker_port=12347)
|
||||
with mock.patch.dict(os.environ, {'HOME': str(home)}, clear=True):
|
||||
actual_state, profile_home, environment = namespace['settings'](args)
|
||||
self.assertEqual(actual_state, state)
|
||||
self.assertEqual(profile_home, state / 'home')
|
||||
progress = Path(environment['FRAMEBRIDGE_PROGRESS_DIR'])
|
||||
self.assertEqual(progress, profile_home / '.local/state/framebridge/progress')
|
||||
self.assertEqual(Path(environment['FRAMEBRIDGE_PROGRESS_LOG']), progress / 'framebridge-progress.log')
|
||||
self.assertFalse(state.exists())
|
||||
|
||||
def test_progress_console_uses_account_state_and_honors_explicit_overrides(self):
|
||||
# Execute the two variable assignments only, never the graphical launcher.
|
||||
assignments = [line for line in PROGRESS_CONSOLE.read_text().splitlines()
|
||||
if line.startswith(('LOG_DIR=', 'LOG='))]
|
||||
self.assertEqual(len(assignments), 2)
|
||||
script = '\n'.join(assignments) + '\nprintf "%s\\0%s" "$LOG_DIR" "$LOG"\n'
|
||||
with tempfile.TemporaryDirectory(prefix='framebridge-progress-') as temporary:
|
||||
home = Path(temporary) / 'different account'
|
||||
cases = [({}, home / '.local/state/framebridge/progress', None),
|
||||
({'FRAMEBRIDGE_PROGRESS_DIR': str(home / 'custom directory')}, home / 'custom directory', None),
|
||||
({'FRAMEBRIDGE_PROGRESS_LOG': str(home / 'specific file.log')},
|
||||
home / '.local/state/framebridge/progress', home / 'specific file.log')]
|
||||
for overrides, expected_directory, expected_file in cases:
|
||||
with self.subTest(overrides=tuple(overrides)):
|
||||
completed = subprocess.run(['/bin/bash', '-c', script],
|
||||
env={'HOME': str(home), **overrides}, capture_output=True, timeout=10, check=True)
|
||||
directory, log = completed.stdout.decode().split('\0')
|
||||
self.assertEqual(Path(directory), expected_directory)
|
||||
self.assertEqual(Path(log), expected_file or expected_directory / 'framebridge-progress.log')
|
||||
self.assertFalse(home.exists())
|
||||
|
||||
def test_legacy_rpc_resolves_home_in_execution_account_and_quotes_arguments(self):
|
||||
# Execute only our synthetic stub, not the captured CUA client or server.
|
||||
with tempfile.TemporaryDirectory(prefix='framebridge-public-check-') as temporary:
|
||||
remote_home = Path(temporary) / 'different user home'
|
||||
stub = remote_home / '.local/lib/framebridge-gerameg-original/cua_tty_rpc.py'
|
||||
stub.parent.mkdir(parents=True)
|
||||
stub.write_text('import json, os, sys\nprint(json.dumps({"home": os.environ["HOME"], "args": sys.argv[1:]}))\n')
|
||||
marker = Path(temporary) / 'must-not-exist'
|
||||
arguments = ['request', 'spaces and quotes \' "', '$(touch ' + str(marker) + ')', '; false']
|
||||
for index, path in enumerate(FRONT_DOORS):
|
||||
with self.subTest(front_door=index):
|
||||
module = load_module(path, 'public_front_door_' + str(index))
|
||||
with mock.patch.object(Path, 'home', return_value=Path('/unrelated/central/home')):
|
||||
command = module._remote_script(arguments)
|
||||
completed = subprocess.run(['/bin/sh', '-c', command],
|
||||
env={**os.environ, 'HOME': str(remote_home)},
|
||||
text=True, capture_output=True, timeout=10, check=True)
|
||||
result = json.loads(completed.stdout)
|
||||
self.assertEqual(result['home'], str(remote_home))
|
||||
self.assertEqual(result['args'], arguments)
|
||||
self.assertFalse(marker.exists())
|
||||
|
||||
def test_legacy_rpc_requires_a_remote_home(self):
|
||||
for index, path in enumerate(FRONT_DOORS):
|
||||
module = load_module(path, 'public_front_door_no_home_' + str(index))
|
||||
environment = dict(os.environ)
|
||||
environment.pop('HOME', None)
|
||||
completed = subprocess.run(['/bin/sh', '-c', module._remote_script(['request'])],
|
||||
env=environment, text=True, capture_output=True, timeout=10)
|
||||
self.assertNotEqual(completed.returncode, 0)
|
||||
|
||||
def test_captured_rpc_uses_accounts_home_and_retains_identity_guard(self):
|
||||
with tempfile.TemporaryDirectory(prefix='framebridge-owner-home-') as temporary:
|
||||
home = Path(temporary)
|
||||
with mock.patch.object(Path, 'home', return_value=home):
|
||||
module = load_module(RPC, 'public_captured_rpc')
|
||||
self.assertEqual(module.HOME, home)
|
||||
with mock.patch.object(module.os, 'getuid', return_value=module.UID), \
|
||||
mock.patch.object(module.os, 'geteuid', return_value=module.UID), \
|
||||
mock.patch.object(module.socket, 'gethostname', return_value=module.HOSTNAME):
|
||||
module.guard_runtime()
|
||||
with mock.patch.object(module.os, 'geteuid', return_value=module.UID + 1):
|
||||
with self.assertRaisesRegex(module.RpcError, 'UID_MISMATCH'):
|
||||
module.guard_runtime()
|
||||
with mock.patch.object(Path, 'home', return_value=home / 'other'):
|
||||
with self.assertRaisesRegex(module.RpcError, 'HOME_MISMATCH'):
|
||||
module.guard_runtime()
|
||||
|
||||
def test_no_implicit_lab_node_is_reachable_through_classic_routes(self):
|
||||
# Compile the early-routing functions only: no native host launch or CUA.
|
||||
tree = ast.parse(HOST.read_text())
|
||||
selected = []
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Assign) and any(isinstance(t, ast.Name) and t.id == 'CLASSIC_NODE_TARGETS' for t in node.targets):
|
||||
selected.append(node)
|
||||
if isinstance(node, ast.FunctionDef) and node.name in ('route_classic_node', 'route_classic_plan'):
|
||||
selected.append(node)
|
||||
namespace = {'NODE_TARGET_RE': re.compile(r'[A-Z0-9_-]+')}
|
||||
exec(compile(ast.Module(body=selected, type_ignores=[]), str(HOST), 'exec'), namespace)
|
||||
self.assertEqual(namespace['CLASSIC_NODE_TARGETS'], {})
|
||||
for target in ('FRESH-MANUAL', 'USER-WORKER'):
|
||||
message = {'target': target, 'mode': 'EXEC', 'lang': 'BASH', 'script': 'printf example'}
|
||||
result = namespace['route_classic_node'](message)
|
||||
self.assertFalse(result['executed'])
|
||||
self.assertEqual(result['error'], 'CLASSIC_NODE_TARGET_UNKNOWN')
|
||||
result = namespace['route_classic_plan'](message)
|
||||
self.assertFalse(result['executed'])
|
||||
self.assertEqual(result['error'], 'NODEPLAN_TARGET_UNKNOWN')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Exercise the real start preflight with disposable TCP sockets, without spawning services."""
|
||||
import errno
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from framebridge_expanded import runtime
|
||||
|
||||
|
||||
class SpawnBoundaryReached(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@unittest.skipUnless(sys.platform.startswith("linux"), "Linux TCP restart regression")
|
||||
class RuntimePortPreflightTests(unittest.TestCase):
|
||||
def start_until_spawn(self, state, ports):
|
||||
settings = {"role": "central", "ports": ports}
|
||||
with patch.object(runtime, "state_path", return_value=state), \
|
||||
patch.object(runtime, "load_config", return_value=settings), \
|
||||
patch.object(runtime, "status", return_value={"running": False}), \
|
||||
patch.object(runtime, "doctor", return_value={"ok": True}), \
|
||||
patch.object(runtime, "environment", return_value={}), \
|
||||
patch.object(runtime, "_spawn", side_effect=SpawnBoundaryReached) as spawn:
|
||||
with self.assertRaises(SpawnBoundaryReached):
|
||||
runtime.start(state)
|
||||
spawn.assert_called_once()
|
||||
|
||||
def close_server_first(self):
|
||||
with socket.socket() as listener, socket.socket() as client:
|
||||
listener.settimeout(2)
|
||||
client.settimeout(2)
|
||||
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
port = listener.getsockname()[1]
|
||||
listener.listen(1)
|
||||
client.connect(("127.0.0.1", port))
|
||||
with listener.accept()[0] as accepted:
|
||||
accepted.settimeout(2)
|
||||
accepted.shutdown(socket.SHUT_WR)
|
||||
self.assertEqual(client.recv(1), b"")
|
||||
client.close()
|
||||
self.assertEqual(accepted.recv(1), b"")
|
||||
return port
|
||||
|
||||
def test_closed_connections_do_not_block_central_restart(self):
|
||||
ports = {name: self.close_server_first() for name in ("mcp", "hub", "broker")}
|
||||
self.assertEqual(len(set(ports.values())), 3)
|
||||
# Establish that each port has the regression condition, not an active listener.
|
||||
for port in ports.values():
|
||||
with socket.socket() as original_probe:
|
||||
with self.assertRaises(OSError) as blocked:
|
||||
original_probe.bind(("127.0.0.1", port))
|
||||
self.assertEqual(blocked.exception.errno, errno.EADDRINUSE)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
self.start_until_spawn(Path(directory), ports)
|
||||
|
||||
def test_active_loopback_and_wildcard_listeners_still_block_start(self):
|
||||
for address in ("127.0.0.1", "0.0.0.0"):
|
||||
with self.subTest(address=address), socket.socket() as listener, \
|
||||
tempfile.TemporaryDirectory() as directory:
|
||||
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
listener.bind((address, 0))
|
||||
listener.listen(1)
|
||||
port = listener.getsockname()[1]
|
||||
# The first preflight port is occupied, so no process may be spawned.
|
||||
ports = {name: port for name in ("mcp", "hub", "broker")}
|
||||
with self.assertRaises(OSError) as blocked:
|
||||
self.start_until_spawn(Path(directory), ports)
|
||||
self.assertEqual(blocked.exception.errno, errno.EADDRINUSE)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,346 @@
|
||||
"""Framebuffer policy regressions: no real device, ACL, udev or privilege changes."""
|
||||
import contextlib
|
||||
from contextlib import ExitStack
|
||||
import io
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import struct
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from framebridge_expanded import cli, screen_access as sa
|
||||
|
||||
|
||||
def acl(entries):
|
||||
return struct.pack("<I", 2) + b"".join(struct.pack("<HHI", *item) for item in entries)
|
||||
|
||||
|
||||
U = sa.UNDEFINED
|
||||
BASE = [(1, 6, U), (4, 6, U), (32, 0, U)]
|
||||
EXTENDED = [(1, 6, U), (2, 7, 1001), (4, 6, U), (8, 2, 99), (16, 6, U), (32, 0, U)]
|
||||
META = {"dev": 1, "inode": 2, "rdev": 7424, "uid": 0, "gid": 44,
|
||||
"mode": 0o660, "boot": "test-boot"}
|
||||
POLICY = {"schema": 1, "enabled": True, "device": "/dev/fb0", "user": "fbtest", "uid": 1000}
|
||||
|
||||
|
||||
class AclPlanTests(unittest.TestCase):
|
||||
def test_plain_mode_adds_only_named_read_and_same_mask(self):
|
||||
result = sa.acl_entries(sa.read_only_acl(None, 0o660, 1000), 0o660)
|
||||
self.assertEqual(result, [(1, 6, U), (2, 4, 1000), (4, 6, U), (16, 6, U), (32, 0, U)])
|
||||
|
||||
def test_existing_acl_other_users_groups_mask_unchanged(self):
|
||||
original = acl(EXTENDED)
|
||||
result = sa.acl_entries(sa.read_only_acl(original, 0o660, 1000), 0o660)
|
||||
self.assertEqual([e for e in result if e != (2, 4, 1000)], EXTENDED)
|
||||
self.assertEqual(next(p for t, p, u in result if t == 16), 6)
|
||||
|
||||
def test_mask_without_read_is_rejected_instead_of_broadened(self):
|
||||
raw = acl([(1, 6, U), (2, 7, 1001), (4, 6, U), (16, 2, U), (32, 0, U)])
|
||||
with self.assertRaisesRegex(RuntimeError, "ACL_MASK_HAS_NO_READ"):
|
||||
sa.read_only_acl(raw, 0o620, 1000)
|
||||
with self.assertRaisesRegex(RuntimeError, "ACL_MASK_HAS_NO_READ"):
|
||||
sa.read_only_acl(None, 0o600, 1000)
|
||||
|
||||
def test_existing_read_acl_is_unmodified_and_not_claimed(self):
|
||||
raw = acl([(1, 6, U), (2, 4, 1000), (4, 6, U), (16, 6, U), (32, 0, U)])
|
||||
self.assertEqual(sa.read_only_acl(raw, 0o660, 1000), raw)
|
||||
|
||||
def test_existing_user_write_acl_requires_review(self):
|
||||
for permission in (0, 2, 6, 7):
|
||||
raw = acl([(1, 6, U), (2, permission, 1000), (4, 6, U), (16, 6, U), (32, 0, U)])
|
||||
with self.assertRaisesRegex(RuntimeError, "EXISTING_USER_ACL"):
|
||||
sa.read_only_acl(raw, 0o660, 1000)
|
||||
|
||||
def test_corrupt_acl_duplicate_user_and_missing_mask_rejected(self):
|
||||
for raw in (b"invalid", acl([(1, 6, U), (4, 6, U), (32, 0, U), (2, 4, 1000)]),
|
||||
acl([(1, 6, U), (2, 4, 1000), (2, 4, 1000), (4, 6, U), (16, 6, U), (32, 0, U)])):
|
||||
with self.assertRaises(RuntimeError):
|
||||
sa.read_only_acl(raw, 0o660, 1000)
|
||||
|
||||
|
||||
class ProtectedFilesystemTests(unittest.TestCase):
|
||||
def test_only_root_owned_regular_nonwritable_files_accepted(self):
|
||||
sa.protected_stat(SimpleNamespace(st_mode=stat.S_IFREG | 0o644, st_uid=0))
|
||||
for kind, perm, owner in ((stat.S_IFLNK, 0o777, 0), (stat.S_IFREG, 0o664, 0),
|
||||
(stat.S_IFREG, 0o644, 1000), (stat.S_IFDIR, 0o755, 0)):
|
||||
with self.assertRaises(RuntimeError):
|
||||
sa.protected_stat(SimpleNamespace(st_mode=kind | perm, st_uid=owner))
|
||||
|
||||
def test_corrupt_backup_identity_is_refused_without_silently_revoking_record(self):
|
||||
record = {"schema": 1, "uid": 1000, "meta": META, "before_acl": None,
|
||||
"expected_acl": sa.encode(sa.read_only_acl(None, 0o660, 1000))}
|
||||
with mock.patch.object(sa, "read_json", return_value=record):
|
||||
self.assertEqual(sa.load_record(), record)
|
||||
for changed in ({k: v for k, v in META.items() if k != "inode"},
|
||||
dict(META, rdev=sa.os.makedev(29, 1)), dict(META, mode=True)):
|
||||
with mock.patch.object(sa, "read_json", return_value=dict(record, meta=changed)):
|
||||
with self.assertRaisesRegex(RuntimeError, "BACKUP_IDENTITY_INVALID"):
|
||||
sa.load_record()
|
||||
|
||||
def test_private_backup_rejects_world_readability(self):
|
||||
with self.assertRaisesRegex(RuntimeError, "PRIVATE_PATH_MODE"):
|
||||
sa.protected_stat(SimpleNamespace(st_mode=stat.S_IFREG | 0o644, st_uid=0), private=True)
|
||||
|
||||
def test_secure_directory_rejects_symlink_ancestor(self):
|
||||
root = SimpleNamespace(st_mode=stat.S_IFDIR | 0o755, st_uid=0)
|
||||
link = SimpleNamespace(st_mode=stat.S_IFLNK | 0o777, st_uid=0)
|
||||
with mock.patch.object(Path, "lstat", side_effect=[root, link]):
|
||||
with self.assertRaisesRegex(RuntimeError, "PROTECTED_PATH_TYPE"):
|
||||
sa.secure_directory(Path("/etc/framebridge-expanded"))
|
||||
|
||||
def test_nonroot_enable_disable_apply_never_open_device(self):
|
||||
with mock.patch.object(sa.os, "geteuid", return_value=1000), \
|
||||
mock.patch.object(sa, "account", return_value={"user": "fbtest", "uid": 1000}), \
|
||||
mock.patch.object(sa.os, "open") as opened:
|
||||
for action in (lambda: sa.enable("fbtest"), sa.disable, sa.apply_device):
|
||||
with self.assertRaisesRegex(RuntimeError, "ROOT_REQUIRED"):
|
||||
action()
|
||||
opened.assert_not_called()
|
||||
|
||||
def test_root_target_account_is_rejected(self):
|
||||
with mock.patch.object(sa.pwd, "getpwnam", return_value=SimpleNamespace(pw_uid=0, pw_name="root")):
|
||||
with self.assertRaisesRegex(RuntimeError, "ROOT_ACCOUNT_NOT_ALLOWED"):
|
||||
sa.account("root")
|
||||
|
||||
def test_policy_remains_revocable_after_account_is_removed(self):
|
||||
with mock.patch.object(sa.pwd, "getpwnam", side_effect=KeyError("deleted")) as lookup:
|
||||
self.assertEqual(sa.validate_policy(POLICY), POLICY)
|
||||
lookup.assert_not_called()
|
||||
with mock.patch.object(sa.pwd, "getpwnam", side_effect=KeyError("deleted")):
|
||||
with self.assertRaisesRegex(RuntimeError, "ACCOUNT_NOT_FOUND"):
|
||||
sa.apply_locked(POLICY)
|
||||
|
||||
def test_uid_reuse_name_change_is_rejected(self):
|
||||
with mock.patch.object(sa.pwd, "getpwnam", return_value=SimpleNamespace(pw_uid=1002, pw_name="fbtest")):
|
||||
with self.assertRaisesRegex(RuntimeError, "ACCOUNT_IDENTITY_CHANGED"):
|
||||
sa.account("fbtest", 1000)
|
||||
|
||||
|
||||
class RestoreTests(unittest.TestCase):
|
||||
def record(self, before=None):
|
||||
return {"schema": 1, "uid": 1000, "meta": META,
|
||||
"before_acl": sa.encode(before), "expected_acl": sa.encode(sa.read_only_acl(before, 0o660, 1000))}
|
||||
|
||||
def test_revoke_plain_acl_restores_absent_xattr(self):
|
||||
record = self.record()
|
||||
with mock.patch.object(sa, "device_snapshot", side_effect=[(META, sa.decode(record["expected_acl"])), (META, None)]), \
|
||||
mock.patch.object(sa.os, "removexattr") as removed, mock.patch.object(sa.os, "setxattr") as written:
|
||||
self.assertEqual(sa.restore_owned(9, record), "ORIGINAL_ACL_RESTORED")
|
||||
removed.assert_called_once_with(9, sa.XATTR)
|
||||
written.assert_not_called()
|
||||
|
||||
def test_revoke_restores_exact_original_extended_acl(self):
|
||||
before = acl(EXTENDED)
|
||||
record = self.record(before)
|
||||
with mock.patch.object(sa, "device_snapshot", side_effect=[(META, sa.decode(record["expected_acl"])), (META, before)]), \
|
||||
mock.patch.object(sa.os, "setxattr") as written:
|
||||
sa.restore_owned(9, record)
|
||||
written.assert_called_once_with(9, sa.XATTR, before, sa.os.XATTR_REPLACE)
|
||||
|
||||
def test_external_acl_change_is_not_overwritten(self):
|
||||
with mock.patch.object(sa, "device_snapshot", return_value=(META, b"external")), \
|
||||
mock.patch.object(sa.os, "removexattr") as removed, mock.patch.object(sa.os, "setxattr") as written:
|
||||
with self.assertRaisesRegex(RuntimeError, "ACL_CHANGED_REVIEW"):
|
||||
sa.restore_owned(9, self.record())
|
||||
removed.assert_not_called()
|
||||
written.assert_not_called()
|
||||
|
||||
def test_chmod_chgrp_or_mask_change_of_same_device_requires_review(self):
|
||||
for changed in (dict(META, gid=55), dict(META, mode=0o640), dict(META, uid=1)):
|
||||
with mock.patch.object(sa, "device_snapshot", return_value=(changed, sa.decode(self.record()["expected_acl"]))), \
|
||||
mock.patch.object(sa.os, "removexattr") as removed, mock.patch.object(sa.os, "setxattr") as written:
|
||||
with self.assertRaisesRegex(RuntimeError, "METADATA_CHANGED_REVIEW"):
|
||||
sa.restore_owned(9, self.record())
|
||||
removed.assert_not_called()
|
||||
written.assert_not_called()
|
||||
|
||||
def test_recreated_device_is_not_touched_by_old_backup(self):
|
||||
with mock.patch.object(sa, "device_snapshot", return_value=(dict(META, boot="new-boot"), None)), \
|
||||
mock.patch.object(sa.os, "removexattr") as removed:
|
||||
self.assertEqual(sa.restore_owned(9, self.record()), "DEVICE_RECREATED_NO_OLD_GRANT")
|
||||
removed.assert_not_called()
|
||||
|
||||
def test_crash_before_acl_write_and_repeated_restore_are_idempotent(self):
|
||||
with mock.patch.object(sa, "device_snapshot", return_value=(META, None)), \
|
||||
mock.patch.object(sa.os, "removexattr") as removed:
|
||||
self.assertEqual(sa.restore_owned(9, self.record()), "ORIGINAL_ACL_ALREADY_RESTORED")
|
||||
removed.assert_not_called()
|
||||
|
||||
|
||||
class TransactionTests(unittest.TestCase):
|
||||
@contextlib.contextmanager
|
||||
def simulated(self, *, initial=None, record=None):
|
||||
state = {"acl": initial, "record": record, "writes": []}
|
||||
def save(path, value, **kwargs):
|
||||
state["writes"].append((path.name, value))
|
||||
if path.name == "active.json":
|
||||
state["record"] = value
|
||||
def set_acl(fd, name, value, flags):
|
||||
state["acl"] = value
|
||||
def remove_acl(fd, name):
|
||||
state["acl"] = None
|
||||
with ExitStack() as stack:
|
||||
for name, value in [
|
||||
("account", mock.Mock(return_value={"user": "fbtest", "uid": 1000})),
|
||||
("device_snapshot", mock.Mock(side_effect=lambda fd: (META, state["acl"]))),
|
||||
("load_record", mock.Mock(side_effect=lambda: state["record"])),
|
||||
("write_json", mock.Mock(side_effect=save)),
|
||||
("probe_user", mock.Mock()),
|
||||
]:
|
||||
state[name] = stack.enter_context(mock.patch.object(sa, name, value))
|
||||
state["opened"] = stack.enter_context(mock.patch.object(sa.os, "open", return_value=9))
|
||||
stack.enter_context(mock.patch.object(sa.os, "close"))
|
||||
state["set"] = stack.enter_context(mock.patch.object(sa.os, "setxattr", side_effect=set_acl))
|
||||
state["remove"] = stack.enter_context(mock.patch.object(sa.os, "removexattr", side_effect=remove_acl))
|
||||
yield state
|
||||
|
||||
def test_apply_backs_up_before_write_and_remains_read_only(self):
|
||||
with self.simulated() as state:
|
||||
answer = sa.apply_locked(POLICY)
|
||||
self.assertEqual(answer["status"], "ENABLED_READ_ONLY")
|
||||
self.assertEqual(state["writes"][0][0][:7], "backup-")
|
||||
self.assertEqual(state["writes"][1][0], "active.json")
|
||||
self.assertIn((2, 4, 1000), sa.acl_entries(state["acl"], 0o660))
|
||||
self.assertEqual(state["set"].call_args.args[-1], sa.os.XATTR_CREATE)
|
||||
|
||||
def test_second_apply_is_idempotent_and_does_not_rebackup(self):
|
||||
with self.simulated() as state:
|
||||
sa.apply_locked(POLICY)
|
||||
state["set"].reset_mock()
|
||||
state["writes"].clear()
|
||||
self.assertFalse(sa.apply_locked(POLICY)["changed"])
|
||||
state["set"].assert_not_called()
|
||||
self.assertEqual(state["writes"], [])
|
||||
|
||||
def test_reapply_never_adopts_changed_metadata_as_fresh_baseline(self):
|
||||
with self.simulated() as state:
|
||||
sa.apply_locked(POLICY)
|
||||
state["set"].reset_mock()
|
||||
state["writes"].clear()
|
||||
state["device_snapshot"].side_effect = lambda fd: (dict(META, mode=0o640), state["acl"])
|
||||
with self.assertRaisesRegex(RuntimeError, "METADATA_CHANGED_REVIEW"):
|
||||
sa.apply_locked(POLICY)
|
||||
state["set"].assert_not_called()
|
||||
self.assertEqual(state["writes"], [])
|
||||
|
||||
def test_probe_failure_rolls_back_exact_original_acl(self):
|
||||
with self.simulated() as state:
|
||||
state["probe_user"].side_effect = RuntimeError("PROBE_TEST_FAILURE")
|
||||
with self.assertRaisesRegex(RuntimeError, "PROBE_TEST_FAILURE"):
|
||||
sa.apply_locked(POLICY)
|
||||
self.assertIsNone(state["acl"])
|
||||
state["remove"].assert_called_once()
|
||||
self.assertEqual(sa.restore_owned(9, state["record"]), "ORIGINAL_ACL_ALREADY_RESTORED")
|
||||
|
||||
def test_external_acl_change_blocks_reapply(self):
|
||||
with self.simulated() as state:
|
||||
sa.apply_locked(POLICY)
|
||||
state["acl"] = b"external"
|
||||
state["set"].reset_mock()
|
||||
with self.assertRaisesRegex(RuntimeError, "ACL_CHANGED_REVIEW"):
|
||||
sa.apply_locked(POLICY)
|
||||
state["set"].assert_not_called()
|
||||
|
||||
def test_disabled_udev_invocation_does_not_open_device(self):
|
||||
with mock.patch.object(sa, "locked", contextlib.nullcontext), \
|
||||
mock.patch.object(sa, "load_policy", return_value=dict(POLICY, enabled=False)), \
|
||||
mock.patch.object(sa, "apply_locked") as apply:
|
||||
self.assertEqual(sa.apply_device()["status"], "DISABLED")
|
||||
apply.assert_not_called()
|
||||
|
||||
def test_disable_without_policy_is_successful_idempotent(self):
|
||||
with mock.patch.object(sa, "locked", contextlib.nullcontext), \
|
||||
mock.patch.object(sa, "load_policy", return_value=None), \
|
||||
mock.patch.object(sa, "reload_rules") as reload, mock.patch.object(sa.os, "open") as opened:
|
||||
self.assertFalse(sa.disable()["changed"])
|
||||
reload.assert_not_called()
|
||||
opened.assert_not_called()
|
||||
|
||||
def test_disable_conflict_deactivates_policy_preserves_backup_and_tombstone(self):
|
||||
record = {"schema": 1, "uid": 1000, "meta": META, "before_acl": None, "expected_acl": "AA=="}
|
||||
with mock.patch.object(sa, "locked", contextlib.nullcontext), \
|
||||
mock.patch.object(sa, "load_policy", return_value=POLICY), \
|
||||
mock.patch.object(sa, "reload_rules"), mock.patch.object(sa, "load_record", return_value=record), \
|
||||
mock.patch.object(sa, "write_json") as written, \
|
||||
mock.patch.object(sa.os, "open", return_value=9), mock.patch.object(sa.os, "close"), \
|
||||
mock.patch.object(sa, "restore_owned", side_effect=RuntimeError("FRAMEBUFFER_ACL_CHANGED_REVIEW_REQUIRED")), \
|
||||
mock.patch.object(Path, "unlink") as removed:
|
||||
with self.assertRaisesRegex(RuntimeError, "ACL_CHANGED_REVIEW"):
|
||||
sa.disable()
|
||||
self.assertFalse(written.call_args.args[1]["enabled"])
|
||||
removed.assert_not_called()
|
||||
|
||||
|
||||
class ProbeAndStatusTests(unittest.TestCase):
|
||||
def test_read_probe_drops_all_privileges_without_pam_or_shell(self):
|
||||
user = SimpleNamespace(pw_uid=1000, pw_gid=1000, pw_name="fbtest")
|
||||
with mock.patch.object(sa.pwd, "getpwnam", return_value=user), \
|
||||
mock.patch.object(sa.os, "getgrouplist", return_value=[1000, 27]), \
|
||||
mock.patch.object(sa.subprocess, "run", return_value=SimpleNamespace(returncode=0, stdout=b'{"read_open":true,"write_access":false}')) as run:
|
||||
sa.probe_user(POLICY)
|
||||
self.assertEqual(run.call_args.kwargs["user"], 1000)
|
||||
self.assertEqual(run.call_args.kwargs["group"], 1000)
|
||||
self.assertEqual(run.call_args.kwargs["extra_groups"], [1000, 27])
|
||||
self.assertEqual(run.call_args.args[0][:3], ["/usr/bin/python3", "-I", "-S"])
|
||||
self.assertNotIn("shell", run.call_args.kwargs)
|
||||
|
||||
def test_probe_rejects_write_permission(self):
|
||||
user = SimpleNamespace(pw_uid=1000, pw_gid=1000, pw_name="fbtest")
|
||||
with mock.patch.object(sa.pwd, "getpwnam", return_value=user), \
|
||||
mock.patch.object(sa.os, "getgrouplist", return_value=[1000]), \
|
||||
mock.patch.object(sa.subprocess, "run", return_value=SimpleNamespace(returncode=0, stdout=b'{"read_open":true,"write_access":true}')):
|
||||
with self.assertRaisesRegex(RuntimeError, "ACCESS_NOT_READ_ONLY"):
|
||||
sa.probe_user(POLICY)
|
||||
|
||||
def test_status_never_locks_writes_or_changes_permissions(self):
|
||||
with mock.patch.object(sa, "load_policy", return_value=POLICY), \
|
||||
mock.patch.object(sa, "locked") as lock, \
|
||||
mock.patch.object(sa, "write_json") as write, \
|
||||
mock.patch.object(sa.os, "setxattr") as aclwrite:
|
||||
result = sa.status()
|
||||
self.assertTrue(result["enabled"])
|
||||
lock.assert_not_called()
|
||||
write.assert_not_called()
|
||||
aclwrite.assert_not_called()
|
||||
|
||||
def test_invalid_device_is_refused_before_reading_acl(self):
|
||||
for mode, dev in ((stat.S_IFREG | 0o660, 0), (stat.S_IFCHR | 0o660, sa.os.makedev(29, 1))):
|
||||
info = SimpleNamespace(st_mode=mode, st_rdev=dev, st_uid=0)
|
||||
with mock.patch.object(sa.os, "fstat", return_value=info), \
|
||||
mock.patch.object(sa.os, "getxattr") as get:
|
||||
with self.assertRaisesRegex(RuntimeError, "FRAMEBUFFER_DEVICE_INVALID"):
|
||||
sa.device_snapshot(9)
|
||||
get.assert_not_called()
|
||||
|
||||
|
||||
class CliAndRuleTests(unittest.TestCase):
|
||||
def test_status_dispatch_ignores_user_profile_state(self):
|
||||
with mock.patch.object(cli, "resolve_state", side_effect=AssertionError("must not resolve profile")), \
|
||||
mock.patch.object(cli, "_module", return_value=SimpleNamespace(command=lambda args: {"enabled": False})), \
|
||||
contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(cli.main(["screen-access", "status"]), 0)
|
||||
|
||||
def test_enable_requires_explicit_account_and_parser_has_no_owner_or_pam_flow(self):
|
||||
args = cli.build_parser().parse_args(["screen-access", "enable", "--user", "fbtest"])
|
||||
self.assertEqual((args.screen_access_command, args.user), ("enable", "fbtest"))
|
||||
with contextlib.redirect_stderr(io.StringIO()):
|
||||
with self.assertRaises(SystemExit):
|
||||
cli.build_parser().parse_args(["screen-access", "enable"])
|
||||
|
||||
def test_rule_matches_only_fb0_and_is_inactive_without_policy(self):
|
||||
source = Path(__file__).resolve().parents[1]
|
||||
text = (source / "packaging/70-framebridge-screen.rules").read_text()
|
||||
lines = [line for line in text.splitlines() if line and not line.startswith("#")]
|
||||
self.assertEqual(len(lines), 2)
|
||||
for line in lines:
|
||||
self.assertIn('SUBSYSTEM=="graphics"', line)
|
||||
self.assertIn('KERNEL=="fb0"', line)
|
||||
self.assertIn('TEST=="/etc/framebridge-expanded/screen-access.json"', line)
|
||||
self.assertIn("framebridge-screen-access apply-device", line)
|
||||
self.assertNotIn("uaccess", text)
|
||||
self.assertNotIn("video", text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,475 @@
|
||||
"""Real-pixel capture and immutable-artifact regression tests (no display needed)."""
|
||||
import base64
|
||||
from dataclasses import replace
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
import zlib
|
||||
|
||||
from framebridge_expanded.real_screen import screen_capture as screen
|
||||
|
||||
|
||||
def geometry(**changes):
|
||||
base = screen.Geometry(width=2, height=1, virtual_width=2, virtual_height=1,
|
||||
xoffset=0, yoffset=0, stride=8, bpp=32,
|
||||
red=(16, 8), green=(8, 8), blue=(0, 8),
|
||||
memory_bytes=8, driver="unit-test", byteorder="little")
|
||||
return replace(base, **changes)
|
||||
|
||||
|
||||
def png_pixels(data):
|
||||
"""Parse PNG framing/CRC and decode exact RGB or packed palette pixel rows."""
|
||||
assert data[:8] == b"\x89PNG\r\n\x1a\n"
|
||||
offset = 8
|
||||
rows = b""
|
||||
size = None
|
||||
palette = None
|
||||
depth = color = None
|
||||
seen_end = False
|
||||
while offset < len(data):
|
||||
length = struct.unpack(">I", data[offset:offset + 4])[0]
|
||||
kind = data[offset + 4:offset + 8]
|
||||
body = data[offset + 8:offset + 8 + length]
|
||||
crc = struct.unpack(">I", data[offset + 8 + length:offset + 12 + length])[0]
|
||||
assert crc == zlib.crc32(kind + body) & 0xffffffff
|
||||
if kind == b"IHDR":
|
||||
w, h, depth, color, compression, filtering, interlace = struct.unpack(">IIBBBBB", body)
|
||||
assert (compression, filtering, interlace) == (0, 0, 0)
|
||||
assert (color == 2 and depth == 8) or (color == 3 and depth in (1, 2, 4, 8))
|
||||
size = (w, h)
|
||||
elif kind == b"PLTE":
|
||||
assert len(body) > 0 and len(body) % 3 == 0
|
||||
palette = [body[i:i + 3] for i in range(0, len(body), 3)]
|
||||
elif kind == b"IDAT":
|
||||
rows += body
|
||||
elif kind == b"IEND":
|
||||
assert body == b""
|
||||
seen_end = True
|
||||
offset += length + 12
|
||||
assert offset == len(data) and size and seen_end
|
||||
scan = zlib.decompress(rows)
|
||||
width, height = size
|
||||
row_bytes = width * 3 if color == 2 else (width * depth + 7) // 8
|
||||
assert len(scan) == height * (1 + row_bytes)
|
||||
pixels = bytearray()
|
||||
for y in range(height):
|
||||
start = y * (1 + row_bytes)
|
||||
assert scan[start] == 0
|
||||
row = scan[start + 1:start + 1 + row_bytes]
|
||||
if color == 2:
|
||||
pixels.extend(row)
|
||||
else:
|
||||
assert palette is not None and len(palette) <= 1 << depth
|
||||
for x in range(width):
|
||||
shift = 8 - depth * (1 + x % (8 // depth))
|
||||
index = (row[x // (8 // depth)] >> shift) & ((1 << depth) - 1)
|
||||
assert index < len(palette)
|
||||
pixels.extend(palette[index])
|
||||
return size, bytes(pixels)
|
||||
|
||||
|
||||
class CaptureTests(unittest.TestCase):
|
||||
def assert_code(self, code, function, *args, **kwargs):
|
||||
with self.assertRaises(screen.CaptureError) as caught:
|
||||
function(*args, **kwargs)
|
||||
self.assertEqual(caught.exception.code, code)
|
||||
|
||||
def test_real_bgra_viewport_ignores_padding_and_hidden_pixels(self):
|
||||
g = geometry(width=2, height=2, virtual_width=4, virtual_height=4,
|
||||
xoffset=1, yoffset=1, stride=20, memory_bytes=80)
|
||||
raw = bytearray(b"\xa5" * g.memory_bytes)
|
||||
expected = bytearray()
|
||||
for y in range(4):
|
||||
for x in range(4):
|
||||
r, green, b = 10 * y + x, 20 * y + x, 30 * y + x
|
||||
start = y * g.stride + x * 4
|
||||
raw[start:start + 4] = bytes((b, green, r, 255))
|
||||
if 1 <= y <= 2 and 1 <= x <= 2:
|
||||
expected.extend((r, green, b))
|
||||
self.assertEqual(screen.decode_framebuffer(bytes(raw), g), bytes(expected))
|
||||
|
||||
def test_real_big_endian_rgb24_with_row_padding(self):
|
||||
g = geometry(width=2, height=2, virtual_width=2, virtual_height=2,
|
||||
bpp=24, stride=8, memory_bytes=16, byteorder="big")
|
||||
raw = bytes((1, 2, 3, 4, 5, 6, 0xa5, 0xa5, 7, 8, 9, 10, 11, 12, 0xa5, 0xa5))
|
||||
self.assertEqual(screen.decode_framebuffer(raw, g), bytes(range(1, 13)))
|
||||
|
||||
def test_rgb565_channel_scaling(self):
|
||||
g = geometry(width=4, virtual_width=4, bpp=16, stride=8, memory_bytes=8,
|
||||
red=(11, 5), green=(5, 6), blue=(0, 5))
|
||||
raw = struct.pack("<4H", 0xf800, 0x07e0, 0x001f, 0xffff)
|
||||
self.assertEqual(screen.decode_framebuffer(raw, g),
|
||||
bytes((255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255)))
|
||||
|
||||
def test_partial_framebuffer_is_rejected(self):
|
||||
self.assert_code("FRAMEBUFFER_READ_SHORT", screen.decode_framebuffer, b"\0" * 7, geometry())
|
||||
|
||||
def test_overlapping_rgb_fields_are_rejected(self):
|
||||
self.assert_code("COLOR_LAYOUT_OVERLAP", screen.validate_geometry,
|
||||
geometry(green=(16, 8)))
|
||||
|
||||
def test_ten_bit_channels_are_rejected_instead_of_silently_quantized(self):
|
||||
self.assert_code("COLOR_LAYOUT_UNSUPPORTED", screen.decode_framebuffer, b"\xff" * 8,
|
||||
geometry(red=(20, 10), green=(10, 10), blue=(0, 10)))
|
||||
|
||||
def test_viewport_outside_virtual_screen_is_rejected(self):
|
||||
self.assert_code("VIEWPORT_INVALID", screen.validate_geometry,
|
||||
geometry(xoffset=1))
|
||||
|
||||
def test_stride_cannot_hide_missing_pixels(self):
|
||||
self.assert_code("FRAMEBUFFER_LAYOUT_INVALID", screen.validate_geometry,
|
||||
geometry(stride=4))
|
||||
|
||||
def test_png_preserves_each_pixel_without_overlays(self):
|
||||
pixels = bytes((1, 2, 3, 255, 0, 0, 0, 255, 0, 0, 0, 255))
|
||||
self.assertEqual(png_pixels(screen.encode_png(pixels, 2, 2)), ((2, 2), pixels))
|
||||
|
||||
def test_png_supports_more_than_256_distinct_colors(self):
|
||||
pixels = b"".join(bytes((i % 256, i // 256, i % 113)) for i in range(1024))
|
||||
self.assertGreater(len({pixels[i:i + 3] for i in range(0, len(pixels), 3)}), 256)
|
||||
self.assertEqual(png_pixels(screen.encode_png(pixels, 32, 32)), ((32, 32), pixels))
|
||||
|
||||
def assert_indexed_pixels(self, color_count, expected_depth):
|
||||
# Odd row widths exercise unused packed bits and independent row starts.
|
||||
width = color_count + (2 if color_count % 2 else 1)
|
||||
height = 2
|
||||
colors = [bytes((i, 255 - i, i * 37 % 256)) for i in range(color_count)]
|
||||
pixels = b"".join(colors[i % color_count] for i in range(width * height))
|
||||
png = screen.encode_png(pixels, width, height, prefer_indexed=True)
|
||||
self.assertEqual((png[24], png[25]), (expected_depth, 3))
|
||||
self.assertEqual(png_pixels(png), ((width, height), pixels))
|
||||
|
||||
def test_palette_one_bit_storage_preserves_one_and_two_colors(self):
|
||||
for count in (1, 2):
|
||||
with self.subTest(count=count):
|
||||
self.assert_indexed_pixels(count, 1)
|
||||
|
||||
def test_palette_two_bit_storage_preserves_three_and_four_colors(self):
|
||||
for count in (3, 4):
|
||||
with self.subTest(count=count):
|
||||
self.assert_indexed_pixels(count, 2)
|
||||
|
||||
def test_palette_four_bit_storage_preserves_five_and_sixteen_colors(self):
|
||||
for count in (5, 16):
|
||||
with self.subTest(count=count):
|
||||
self.assert_indexed_pixels(count, 4)
|
||||
|
||||
def test_palette_eight_bit_storage_preserves_seventeen_and_256_colors(self):
|
||||
for count in (17, 256):
|
||||
with self.subTest(count=count):
|
||||
self.assert_indexed_pixels(count, 8)
|
||||
|
||||
def test_palette_attempt_at_257_colors_falls_back_without_pixel_loss(self):
|
||||
pixels = b"".join(bytes((i % 256, i // 256, i * 37 % 256)) for i in range(257))
|
||||
self.assertEqual(len({pixels[i:i + 3] for i in range(0, len(pixels), 3)}), 257)
|
||||
png = screen.encode_png(pixels, 257, 1, prefer_indexed=True)
|
||||
self.assertEqual((png[24], png[25]), (8, 2))
|
||||
self.assertEqual(png_pixels(png), ((257, 1), pixels))
|
||||
|
||||
def test_default_rgb_format_stays_truecolor_even_for_palette_sized_input(self):
|
||||
pixels = bytes((1, 2, 3, 4, 5, 6))
|
||||
png = screen.encode_png(pixels, 2, 1)
|
||||
self.assertEqual((png[24], png[25]), (8, 2))
|
||||
self.assertEqual(png_pixels(png), ((2, 1), pixels))
|
||||
|
||||
def tty_session(self, **changes):
|
||||
s = {"Id": "2", "User": "1000", "Leader": "1234", "Active": "yes", "Remote": "no",
|
||||
"Type": "tty", "Seat": "seat0", "VTNr": "1", "TTY": "tty1"}
|
||||
s.update(changes)
|
||||
return s
|
||||
|
||||
def test_foreground_identifies_actual_active_vt(self):
|
||||
s = self.tty_session()
|
||||
selected = screen.choose_foreground("tty1", [s, self.tty_session(Id="3", Active="no")], "seat0")
|
||||
self.assertEqual((selected.active_vt, selected.session_id, selected.leader), ("tty1", "2", 1234))
|
||||
|
||||
def test_active_graphical_session_never_uses_framebuffer_fallback(self):
|
||||
for mode in ("x11", "wayland"):
|
||||
with self.subTest(mode=mode):
|
||||
self.assert_code("GRAPHICAL_CAPTURE_REQUIRED", screen.choose_foreground,
|
||||
"tty1", [self.tty_session(Type=mode)], "seat0")
|
||||
|
||||
def test_unmatched_vt_is_rejected(self):
|
||||
self.assert_code("FOREGROUND_SESSION_UNVERIFIED", screen.choose_foreground,
|
||||
"tty2", [self.tty_session()], "seat0")
|
||||
|
||||
def test_remote_session_does_not_bind_local_display(self):
|
||||
self.assert_code("FOREGROUND_SESSION_UNVERIFIED", screen.choose_foreground,
|
||||
"tty1", [self.tty_session(Remote="yes")], "seat0")
|
||||
|
||||
def test_foreground_switch_during_capture_is_rejected(self):
|
||||
before = screen.choose_foreground("tty1", [self.tty_session()], "seat0")
|
||||
screen.require_unchanged(before, before)
|
||||
self.assert_code("FOREGROUND_CHANGED_DURING_CAPTURE", screen.require_unchanged,
|
||||
before, replace(before, session_id="4"))
|
||||
|
||||
def x11_session(self, **changes):
|
||||
s = self.tty_session(Type="x11", User=str(os.getuid()), TTY="tty7", VTNr="7", Display=":0")
|
||||
s.update(changes)
|
||||
return s
|
||||
|
||||
def test_display_selector_accepts_same_user_active_local_x11_vt(self):
|
||||
f = screen.choose_display_foreground("tty7", [self.x11_session()], "seat0")
|
||||
self.assertEqual((f.session_type, f.uid, f.display, f.vtnr), ("x11", os.getuid(), ":0", 7))
|
||||
|
||||
def test_lightdm_empty_tty_keeps_verified_numeric_vt(self):
|
||||
f = screen.choose_display_foreground("tty7", [self.x11_session(TTY="")], "seat0")
|
||||
self.assertEqual((f.active_vt, f.tty, f.vtnr, f.display), ("tty7", "", 7, ":0"))
|
||||
|
||||
def test_lightdm_empty_tty_never_accepts_wrong_vt_user_or_remote_session(self):
|
||||
cases = [({"VTNr": "8"}, "GRAPHICAL_VT_UNVERIFIED"),
|
||||
({"User": str(os.getuid() + 1)}, "X11_SESSION_IDENTITY_MISMATCH"),
|
||||
({"Remote": "yes"}, "FOREGROUND_SESSION_UNVERIFIED")]
|
||||
for changes, code in cases:
|
||||
with self.subTest(changes=changes):
|
||||
s = self.x11_session(TTY="", **changes)
|
||||
self.assert_code(code, screen.choose_display_foreground, "tty7", [s], "seat0")
|
||||
|
||||
def test_lightdm_explicit_contradictory_tty_is_rejected(self):
|
||||
self.assert_code("GRAPHICAL_VT_UNVERIFIED", screen.choose_display_foreground,
|
||||
"tty7", [self.x11_session(TTY="tty8")], "seat0")
|
||||
|
||||
def test_lightdm_missing_tty_metadata_is_not_treated_as_empty(self):
|
||||
s = self.x11_session()
|
||||
del s["TTY"]
|
||||
self.assert_code("GRAPHICAL_VT_UNVERIFIED", screen.choose_display_foreground,
|
||||
"tty7", [s], "seat0")
|
||||
|
||||
def test_lightdm_capture_identity_rejects_vt_switch_or_session_replacement(self):
|
||||
before = screen.choose_display_foreground("tty7", [self.x11_session(TTY="")], "seat0")
|
||||
for after in [replace(before, active_vt="tty8", vtnr=8),
|
||||
replace(before, session_id="replacement"),
|
||||
replace(before, display=":1"), replace(before, uid=before.uid + 1)]:
|
||||
with self.subTest(after=after):
|
||||
self.assert_code("FOREGROUND_CHANGED_DURING_CAPTURE",
|
||||
screen.require_unchanged, before, after)
|
||||
|
||||
def test_display_selector_keeps_text_vt_identity(self):
|
||||
f = screen.choose_display_foreground("tty1", [self.tty_session()], "seat0")
|
||||
self.assertEqual((f.session_type, f.display, f.active_vt), ("tty", "", "tty1"))
|
||||
|
||||
def test_display_selector_refuses_wayland_instead_of_stale_fbdev(self):
|
||||
self.assert_code("WAYLAND_CAPTURE_UNSUPPORTED", screen.choose_display_foreground,
|
||||
"tty7", [self.x11_session(Type="wayland")], "seat0")
|
||||
|
||||
def test_display_selector_refuses_network_and_invalid_display_names(self):
|
||||
for display in ("host:0", "localhost:0", "tcp/localhost:0", ":0;anything", ""):
|
||||
with self.subTest(display=display):
|
||||
self.assert_code("X11_DISPLAY_UNVERIFIED", screen.choose_display_foreground,
|
||||
"tty7", [self.x11_session(Display=display)], "seat0")
|
||||
|
||||
def test_display_selector_requires_matching_vt_and_identity(self):
|
||||
self.assert_code("GRAPHICAL_VT_UNVERIFIED", screen.choose_display_foreground,
|
||||
"tty8", [self.x11_session()], "seat0")
|
||||
self.assert_code("X11_SESSION_IDENTITY_MISMATCH", screen.choose_display_foreground,
|
||||
"tty7", [self.x11_session(User=str(os.getuid() + 1))], "seat0")
|
||||
|
||||
def test_display_selector_requires_one_active_seat_session(self):
|
||||
self.assert_code("FOREGROUND_SESSION_UNVERIFIED", screen.choose_display_foreground,
|
||||
"tty7", [self.x11_session(), self.x11_session(Id="3")], "seat0")
|
||||
|
||||
def test_x11_color_masks_accept_contiguous_fields_only(self):
|
||||
self.assertEqual(screen._mask_field(0xff0000), (16, 8))
|
||||
self.assertEqual(screen._mask_field(0x07e0), (5, 6))
|
||||
self.assertEqual(screen._mask_field(1), (0, 1))
|
||||
for mask in (0, -1, 0b101, 0xff00ff):
|
||||
with self.subTest(mask=mask):
|
||||
self.assert_code("X11_COLOR_MASK_INVALID", screen._mask_field, mask)
|
||||
|
||||
def test_root_display_manager_environment_is_never_read(self):
|
||||
f = screen.Foreground("tty7", "19", 1000, 3668, "x11", "", "seat0", 7, ":0")
|
||||
leader = MagicMock()
|
||||
leader.stat.return_value = SimpleNamespace(st_uid=0)
|
||||
with patch.object(screen, "Path") as path:
|
||||
path.return_value.__truediv__.return_value = leader
|
||||
self.assertEqual(screen._x11_leader_environment(f, ":0"), {})
|
||||
leader.__truediv__.assert_not_called()
|
||||
|
||||
def test_foreign_user_session_leader_is_rejected(self):
|
||||
f = screen.Foreground("tty7", "19", 1000, 3668, "x11", "", "seat0", 7, ":0")
|
||||
leader = MagicMock()
|
||||
leader.stat.return_value = SimpleNamespace(st_uid=1001)
|
||||
with patch.object(screen, "Path") as path:
|
||||
path.return_value.__truediv__.return_value = leader
|
||||
self.assert_code("X11_LEADER_IDENTITY_MISMATCH",
|
||||
screen._x11_leader_environment, f, ":0")
|
||||
leader.__truediv__.assert_not_called()
|
||||
|
||||
def test_user_leader_context_rejects_a_different_display(self):
|
||||
f = screen.Foreground("tty7", "19", os.getuid(), 3668, "x11", "", "seat0", 7, ":0")
|
||||
leader = MagicMock()
|
||||
leader.stat.return_value = SimpleNamespace(st_uid=f.uid)
|
||||
leader.__truediv__ = Mock(return_value=SimpleNamespace(read_bytes=lambda: b"DISPLAY=:1\0"))
|
||||
with patch.object(screen, "Path") as path:
|
||||
path.return_value.__truediv__.return_value = leader
|
||||
self.assert_code("X11_DISPLAY_IDENTITY_MISMATCH",
|
||||
screen._x11_leader_environment, f, ":0")
|
||||
|
||||
def private_home_context(self, home, **changes):
|
||||
f = screen.choose_display_foreground("tty7", [self.x11_session(TTY="")], "seat0")
|
||||
f = replace(f, **changes)
|
||||
account = SimpleNamespace(pw_uid=f.uid, pw_dir=str(home))
|
||||
return f, account
|
||||
|
||||
def test_lightdm_root_leader_falls_back_to_validated_same_user_authority(self):
|
||||
with tempfile.TemporaryDirectory() as home:
|
||||
authority = Path(home) / ".Xauthority"
|
||||
authority.touch(mode=0o600)
|
||||
f, account = self.private_home_context(home)
|
||||
with patch.dict(os.environ, {}, clear=True), \
|
||||
patch.object(screen, "_x11_leader_environment", return_value={}), \
|
||||
patch.object(screen.pwd, "getpwuid", return_value=account):
|
||||
self.assertEqual(screen._x11_context(f, None, None), (":0", str(authority)))
|
||||
|
||||
def test_canonical_home_fallback_ignores_callers_home(self):
|
||||
with tempfile.TemporaryDirectory() as home:
|
||||
authority = Path(home) / ".Xauthority"
|
||||
authority.touch(mode=0o600)
|
||||
f, account = self.private_home_context(home)
|
||||
with patch.dict(os.environ, {"HOME": "/unrelated-user"}, clear=True), \
|
||||
patch.object(screen, "_x11_leader_environment", return_value={}), \
|
||||
patch.object(screen.pwd, "getpwuid", return_value=account):
|
||||
self.assertEqual(screen._x11_context(f, None, None)[1], str(authority))
|
||||
|
||||
def test_authority_fallback_rejects_missing_world_readable_or_symlink_file(self):
|
||||
with tempfile.TemporaryDirectory() as home:
|
||||
authority = Path(home) / ".Xauthority"
|
||||
target = Path(home) / "target"
|
||||
f, account = self.private_home_context(home)
|
||||
with patch.dict(os.environ, {}, clear=True), \
|
||||
patch.object(screen, "_x11_leader_environment", return_value={}), \
|
||||
patch.object(screen.pwd, "getpwuid", return_value=account):
|
||||
self.assert_code("X11_AUTHORITY_UNAVAILABLE", screen._x11_context, f, None, None)
|
||||
authority.touch(mode=0o644)
|
||||
# Ensure the negative fixture stays world-readable under a private umask.
|
||||
authority.chmod(0o644)
|
||||
self.assert_code("X11_AUTHORITY_UNTRUSTED", screen._x11_context, f, None, None)
|
||||
authority.unlink()
|
||||
target.touch(mode=0o600)
|
||||
authority.symlink_to(target)
|
||||
self.assert_code("X11_AUTHORITY_UNTRUSTED", screen._x11_context, f, None, None)
|
||||
|
||||
def test_authority_fallback_rejects_foreign_file_owner(self):
|
||||
with tempfile.TemporaryDirectory() as home:
|
||||
authority = Path(home) / ".Xauthority"
|
||||
authority.touch(mode=0o600)
|
||||
f, account = self.private_home_context(home, uid=os.getuid() + 1)
|
||||
with patch.dict(os.environ, {}, clear=True), \
|
||||
patch.object(screen.os, "getuid", return_value=f.uid), \
|
||||
patch.object(screen, "_x11_leader_environment", return_value={}), \
|
||||
patch.object(screen.pwd, "getpwuid", return_value=account):
|
||||
self.assert_code("X11_AUTHORITY_UNTRUSTED", screen._x11_context, f, None, None)
|
||||
|
||||
def test_authority_fallback_rejects_missing_account_or_relative_home(self):
|
||||
f, account = self.private_home_context("relative-home")
|
||||
with patch.dict(os.environ, {}, clear=True), \
|
||||
patch.object(screen, "_x11_leader_environment", return_value={}):
|
||||
with patch.object(screen.pwd, "getpwuid", side_effect=KeyError):
|
||||
self.assert_code("X11_AUTHORITY_UNAVAILABLE", screen._x11_context, f, None, None)
|
||||
with patch.object(screen.pwd, "getpwuid", return_value=account):
|
||||
self.assert_code("X11_AUTHORITY_PATH_INVALID", screen._x11_context, f, None, None)
|
||||
|
||||
def test_context_rejects_explicit_display_or_user_mismatch(self):
|
||||
f, _ = self.private_home_context("/unused")
|
||||
self.assert_code("X11_DISPLAY_IDENTITY_MISMATCH", screen._x11_context, f, ":1", None)
|
||||
self.assert_code("X11_SESSION_IDENTITY_MISMATCH", screen._x11_context,
|
||||
replace(f, uid=f.uid + 1), None, None)
|
||||
|
||||
def artifact(self, directory, data=b"0123456789"):
|
||||
cid = "1" * 32
|
||||
digest = hashlib.sha256(data).hexdigest()
|
||||
manifest = {"capture_id": cid, "sha256": digest, "bytes": len(data),
|
||||
"synthetic": False, "pixel_source_verified": True}
|
||||
screen._write_new(Path(directory) / (cid + ".png"), data)
|
||||
screen._write_new(Path(directory) / (cid + ".json"), json.dumps(manifest).encode())
|
||||
return cid, digest, manifest
|
||||
|
||||
def test_artifact_write_cannot_overwrite_existing_file(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "one.png"
|
||||
screen._write_new(path, b"first")
|
||||
self.assert_code("CAPTURE_ARTIFACT_CREATE_FAILED", screen._write_new, path, b"second")
|
||||
self.assertEqual(path.read_bytes(), b"first")
|
||||
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
|
||||
|
||||
def test_chunks_reassemble_exact_immutable_bytes_and_digest(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
cid, digest, _ = self.artifact(directory)
|
||||
results = [screen.read_chunk(directory, cid, i, chunk_bytes=4, expected_sha256=digest)
|
||||
for i in range(3)]
|
||||
self.assertEqual(b"".join(base64.b64decode(p["data"], validate=True) for p in results),
|
||||
b"0123456789")
|
||||
self.assertEqual([p["chunk_bytes"] for p in results], [4, 4, 2])
|
||||
self.assertEqual([p["more"] for p in results], [True, True, False])
|
||||
self.assertTrue(all(p["sha256"] == digest and p["total_chunks"] == 3 for p in results))
|
||||
|
||||
def test_chunk_index_and_boolean_are_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
cid, _, _ = self.artifact(directory)
|
||||
self.assert_code("CHUNK_INDEX_OUT_OF_RANGE", screen.read_chunk, directory, cid, 1)
|
||||
self.assert_code("CHUNK_BOUNDS_INVALID", screen.read_chunk, directory, cid, True)
|
||||
self.assert_code("CHUNK_BOUNDS_INVALID", screen.read_chunk, directory, cid, 0,
|
||||
chunk_bytes=screen.MAX_CHUNK_BYTES + 1)
|
||||
|
||||
def test_capture_id_cannot_escape_artifact_directory(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
self.assert_code("CAPTURE_ID_INVALID", screen.read_chunk, directory, "../target", 0)
|
||||
|
||||
def test_mutated_image_bytes_are_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
cid, _, _ = self.artifact(directory)
|
||||
(Path(directory) / (cid + ".png")).write_bytes(b"changed")
|
||||
self.assert_code("CAPTURE_SHA256_MISMATCH", screen.read_chunk, directory, cid, 0)
|
||||
|
||||
def test_expected_digest_is_enforced(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
cid, _, _ = self.artifact(directory)
|
||||
self.assert_code("EXPECTED_SHA256_MISMATCH", screen.read_chunk, directory, cid, 0,
|
||||
expected_sha256="0" * 64)
|
||||
|
||||
def test_image_symlink_is_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
cid, _, _ = self.artifact(directory)
|
||||
p = Path(directory) / (cid + ".png")
|
||||
target = Path(directory) / "target.png"
|
||||
p.rename(target)
|
||||
p.symlink_to(target)
|
||||
self.assert_code("CAPTURE_ARTIFACT_UNAVAILABLE", screen.read_chunk, directory, cid, 0)
|
||||
|
||||
def test_hard_linked_image_is_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
cid, _, _ = self.artifact(directory)
|
||||
os.link(Path(directory) / (cid + ".png"), Path(directory) / "alias.png")
|
||||
self.assert_code("CAPTURE_ARTIFACT_UNTRUSTED", screen.read_chunk, directory, cid, 0)
|
||||
|
||||
def test_artifact_directory_cannot_be_a_symlink(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
target = Path(directory) / "real"
|
||||
target.mkdir(mode=0o700)
|
||||
link = Path(directory) / "linked"
|
||||
link.symlink_to(target, target_is_directory=True)
|
||||
self.assert_code("CAPTURE_DIRECTORY_UNTRUSTED", screen._directory, link)
|
||||
|
||||
def test_synthetic_manifest_is_not_accepted_as_real_image(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
cid, _, manifest = self.artifact(directory)
|
||||
manifest["synthetic"] = True
|
||||
(Path(directory) / (cid + ".json")).write_text(json.dumps(manifest))
|
||||
self.assert_code("CAPTURE_MANIFEST_INVALID", screen.read_chunk, directory, cid, 0)
|
||||
|
||||
def test_manifest_requires_json_object_not_null_or_array(self):
|
||||
for invalid in (None, [], ["capture_id"], "text", 3):
|
||||
with self.subTest(invalid=invalid), tempfile.TemporaryDirectory() as directory:
|
||||
cid, _, _ = self.artifact(directory)
|
||||
(Path(directory) / (cid + ".json")).write_text(json.dumps(invalid))
|
||||
self.assert_code("CAPTURE_MANIFEST_INVALID", screen.read_chunk, directory, cid, 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,163 @@
|
||||
import copy
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from framebridge_expanded.real_screen import screen_control as m
|
||||
|
||||
|
||||
class ScreenControlTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.path = Path(self.temp.name) / "screen.png"
|
||||
self.path.write_bytes(b"immutable-observed-frame-fixture")
|
||||
self.binding = {"version": 1, "backend": "visible-tmux", "node": "TESTNODE",
|
||||
"uid": os.getuid(), "active_vt": "tty1", "client_tty": "/dev/tty1",
|
||||
"socket": {"path": "/run/user/1000/console.sock", "inode": 12},
|
||||
"server": {"pid": 100, "start_ticks": 20},
|
||||
"client": {"pid": 101, "start_ticks": 21},
|
||||
"session_id": "$0", "window_id": "@0", "pane_id": "%1",
|
||||
"pane": {"pid": 102, "start_ticks": 22},
|
||||
"pane_geometry": [120, 0, 120, 66], "pane_in_mode": False}
|
||||
self.manifest = {"capture_id": "a" * 32, "source": "linux-framebuffer",
|
||||
"screen_type": "tty", "active_vt": "tty1", "node": "TESTNODE",
|
||||
"uid": os.getuid(), "synthetic": False, "pixel_source_verified": True,
|
||||
"path": str(self.path), "sha256": hashlib.sha256(self.path.read_bytes()).hexdigest(),
|
||||
"completed_at": datetime.now(timezone.utc).isoformat(),
|
||||
"binding": copy.deepcopy(self.binding)}
|
||||
|
||||
def tearDown(self):
|
||||
self.temp.cleanup()
|
||||
|
||||
def refused(self, change, code="SCREEN_BINDING_CHANGED"):
|
||||
different = copy.deepcopy(self.binding)
|
||||
change(different)
|
||||
with patch.object(m, "snapshot_binding", return_value=different), patch.object(m, "_tmux") as send:
|
||||
with self.assertRaises(m.ControlError) as e:
|
||||
m.act(self.manifest, "key.send", key="ENTER")
|
||||
self.assertEqual(e.exception.code, code)
|
||||
send.assert_not_called()
|
||||
|
||||
def test_bind_returns_copy(self):
|
||||
with patch.object(m, "snapshot_binding", return_value=self.binding):
|
||||
result = m.bind_screen(self.manifest)
|
||||
self.assertIsNot(result, self.manifest)
|
||||
self.assertEqual(result["binding"], self.binding)
|
||||
|
||||
def test_bind_vt_mismatch(self):
|
||||
self.manifest["active_vt"] = "tty2"
|
||||
with patch.object(m, "snapshot_binding", return_value=self.binding):
|
||||
with self.assertRaises(m.ControlError) as e:
|
||||
m.bind_screen(self.manifest)
|
||||
self.assertEqual(e.exception.code, "SCREEN_CAPTURE_VT_MISMATCH")
|
||||
|
||||
def test_bind_node_mismatch(self):
|
||||
self.manifest["node"] = "OTHER"
|
||||
with patch.object(m, "snapshot_binding", return_value=self.binding):
|
||||
with self.assertRaises(m.ControlError) as e:
|
||||
m.bind_screen(self.manifest)
|
||||
self.assertEqual(e.exception.code, "SCREEN_CAPTURE_NODE_MISMATCH")
|
||||
|
||||
def test_wrong_source(self):
|
||||
self.manifest["source"] = "rendered-text"
|
||||
with self.assertRaises(m.ControlError) as e:
|
||||
m.bind_screen(self.manifest)
|
||||
self.assertEqual(e.exception.code, "SCREEN_CONTROL_MODE_UNSUPPORTED")
|
||||
|
||||
def test_literal_text_no_shell_or_enter(self):
|
||||
text = "printf '%s' '$(touch never)'"
|
||||
with patch.object(m, "snapshot_binding", return_value=self.binding), patch.object(m, "_tmux", return_value="") as send:
|
||||
result = m.act(self.manifest, "text.type", text=text)
|
||||
self.assertTrue(result["performed"])
|
||||
send.assert_called_once_with(self.binding["socket"]["path"], "send-keys", "-t", "%1", "-l", "--", text)
|
||||
|
||||
def test_explicit_key(self):
|
||||
with patch.object(m, "snapshot_binding", return_value=self.binding), patch.object(m, "_tmux", return_value="") as send:
|
||||
result = m.act(self.manifest, "key.send", key="ENTER")
|
||||
self.assertTrue(result["performed"])
|
||||
send.assert_called_once_with(self.binding["socket"]["path"], "send-keys", "-t", "%1", "Enter")
|
||||
|
||||
def test_text_newline_rejected(self):
|
||||
with patch.object(m, "_tmux") as send:
|
||||
with self.assertRaises(m.ControlError) as e:
|
||||
m.act(self.manifest, "text.type", text="pwd\n")
|
||||
self.assertEqual(e.exception.code, "SCREEN_TEXT_CONTROL_CHARACTER")
|
||||
send.assert_not_called()
|
||||
|
||||
def test_unknown_key_rejected(self):
|
||||
with patch.object(m, "_tmux") as send:
|
||||
with self.assertRaises(m.ControlError):
|
||||
m.act(self.manifest, "key.send", key="ALT_F2")
|
||||
send.assert_not_called()
|
||||
|
||||
def test_changed_vt_no_input(self):
|
||||
self.refused(lambda b: b.update(active_vt="tty2"))
|
||||
|
||||
def test_changed_pane_no_input(self):
|
||||
self.refused(lambda b: b["pane"].update(pid=222))
|
||||
|
||||
def test_pid_reuse_no_input(self):
|
||||
self.refused(lambda b: b["pane"].update(start_ticks=23))
|
||||
|
||||
def test_changed_socket_no_input(self):
|
||||
self.refused(lambda b: b["socket"].update(inode=13))
|
||||
|
||||
def test_changed_geometry_no_input(self):
|
||||
self.refused(lambda b: b.update(pane_geometry=[0, 0, 240, 66]))
|
||||
|
||||
def test_hidden_client_no_fallback(self):
|
||||
identity = {"path": "/run/user/1000/console.sock", "inode": 12}
|
||||
client_row = "101|/dev/tty2|$0|240|67|0\n"
|
||||
with patch.object(m, "_active_vt", return_value="tty1"), patch.object(m, "_server_sockets", return_value=[(identity, {"pid": 100})]), patch.object(m, "_tmux", return_value=client_row) as run:
|
||||
with self.assertRaises(m.ControlError) as e:
|
||||
m.snapshot_binding()
|
||||
self.assertEqual(e.exception.code, "SCREEN_VISIBLE_TMUX_NOT_FOUND")
|
||||
self.assertEqual(run.call_count, 1)
|
||||
|
||||
def test_postflight_change_reports_performed(self):
|
||||
other = copy.deepcopy(self.binding)
|
||||
other["pane_id"] = "%2"
|
||||
with patch.object(m, "snapshot_binding", side_effect=[self.binding, self.binding, other]), patch.object(m, "_tmux", return_value="") as send:
|
||||
with self.assertRaises(m.ControlError) as e:
|
||||
m.act(self.manifest, "key.send", key="ENTER")
|
||||
self.assertEqual(e.exception.code, "SCREEN_CHANGED_AFTER_INPUT")
|
||||
self.assertTrue(e.exception.details["performed"])
|
||||
send.assert_called_once()
|
||||
|
||||
def test_tampered_capture_no_input(self):
|
||||
self.path.write_bytes(b"changed")
|
||||
with patch.object(m, "_tmux") as send:
|
||||
with self.assertRaises(m.ControlError) as e:
|
||||
m.act(self.manifest, "key.send", key="ENTER")
|
||||
self.assertEqual(e.exception.code, "SCREEN_CAPTURE_SHA256_MISMATCH")
|
||||
send.assert_not_called()
|
||||
|
||||
def test_capture_id_required(self):
|
||||
self.manifest.pop("capture_id")
|
||||
with self.assertRaises(m.ControlError) as e:
|
||||
m.act(self.manifest, "key.send", key="ENTER")
|
||||
self.assertEqual(e.exception.code, "SCREEN_CAPTURE_ID_REQUIRED")
|
||||
|
||||
def test_copy_mode_does_not_type_into_hidden_application(self):
|
||||
self.binding["pane_in_mode"] = True
|
||||
self.manifest["binding"] = copy.deepcopy(self.binding)
|
||||
self.refused(lambda b: None, "SCREEN_TMUX_MODE_INPUT_UNSUPPORTED")
|
||||
|
||||
def test_optional_explicit_age_limit(self):
|
||||
self.manifest["completed_at"] = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
|
||||
with self.assertRaises(m.ControlError) as e:
|
||||
m.act(self.manifest, "key.send", key="ENTER", max_age_s=30)
|
||||
self.assertEqual(e.exception.code, "SCREEN_CAPTURE_EXPIRED")
|
||||
|
||||
def test_no_arbitrary_age_expiry_by_default(self):
|
||||
self.manifest["completed_at"] = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
|
||||
with patch.object(m, "snapshot_binding", return_value=self.binding), patch.object(m, "_tmux", return_value=""):
|
||||
self.assertTrue(m.act(self.manifest, "key.send", key="ENTER")["performed"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,315 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import math
|
||||
import random
|
||||
import shlex
|
||||
import struct
|
||||
import unittest
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
from framebridge_expanded.real_screen.screen_frontdoor import CHUNK_BYTES, ScreenClient, ScreenError, register_framebridge_screen_tools
|
||||
|
||||
|
||||
CAPTURE_ID = "1" * 32
|
||||
SESSION_ID = "screen_" + "2" * 32
|
||||
NODE_ID = "12345678-1234-1234-1234-123456789abc"
|
||||
OTHER_NODE_ID = "87654321-4321-4321-4321-cba987654321"
|
||||
|
||||
|
||||
def png(width=100, height=40):
|
||||
def chunk(kind, data):
|
||||
return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data))
|
||||
source = random.Random(20261022)
|
||||
scanlines = b"".join(b"\0" + source.randbytes(width * 3) for _ in range(height))
|
||||
return (b"\x89PNG\r\n\x1a\n" +
|
||||
chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)) +
|
||||
chunk(b"IDAT", zlib.compress(scanlines)) + chunk(b"IEND", b""))
|
||||
|
||||
|
||||
class Image:
|
||||
def __init__(self, data, format):
|
||||
self.data = data
|
||||
self.format = format
|
||||
|
||||
|
||||
class MCP:
|
||||
def __init__(self):
|
||||
self.tools = {}
|
||||
self.annotations = {}
|
||||
|
||||
def tool(self, annotations):
|
||||
def register(fn):
|
||||
self.tools[fn.__name__] = fn
|
||||
self.annotations[fn.__name__] = annotations
|
||||
return fn
|
||||
return register
|
||||
|
||||
|
||||
class Executor:
|
||||
def __init__(self, variant=None, hostname="fb-central-lab"):
|
||||
self.data = png()
|
||||
self.variant = variant
|
||||
self.hostname = hostname
|
||||
self.requests = []
|
||||
|
||||
def __call__(self, **kwargs):
|
||||
request = json.loads(base64.b64decode(shlex.split(kwargs["script"])[-1], validate=True))
|
||||
self.requests.append(request)
|
||||
digest = hashlib.sha256(self.data).hexdigest()
|
||||
manifest = {"capture_id": CAPTURE_ID, "node": self.hostname,
|
||||
"bytes": len(self.data), "sha256": digest, "width": 100, "height": 40,
|
||||
"source": "linux-framebuffer", "screen_type": "tty", "active_vt": "tty1",
|
||||
"session_id": SESSION_ID, "full_screen": True, "authentic_pixels": True,
|
||||
"path": "/private/internal.png"}
|
||||
if request.get("session_id"):
|
||||
manifest["requested_session_visible"] = request["session_id"] == SESSION_ID
|
||||
if self.variant == "node":
|
||||
manifest["node"] = "invalid hostname"
|
||||
elif self.variant == "source":
|
||||
manifest["source"] = "terminal-text-renderer"
|
||||
elif self.variant == "provenance":
|
||||
manifest["authentic_pixels"] = False
|
||||
elif self.variant == "dimensions":
|
||||
manifest["width"] = 99
|
||||
elif self.variant == "x11":
|
||||
manifest["source"] = "x11-root-XGetImage"
|
||||
manifest["screen_type"] = "x11"
|
||||
if request["op"] == "read_chunk":
|
||||
index = request["index"]
|
||||
data = self.data[index * CHUNK_BYTES:(index + 1) * CHUNK_BYTES]
|
||||
result = {"capture_id": CAPTURE_ID, "index": index,
|
||||
"total_chunks": math.ceil(len(self.data) / CHUNK_BYTES),
|
||||
"bytes": len(self.data), "sha256": digest, "encoding": "base64",
|
||||
"data": base64.b64encode(data).decode(),
|
||||
"more": (index + 1) * CHUNK_BYTES < len(self.data)}
|
||||
if self.variant == "chunk_index":
|
||||
result["index"] = index + 1
|
||||
elif self.variant == "chunk_hash":
|
||||
result["sha256"] = "0" * 64
|
||||
elif self.variant == "chunk_more":
|
||||
result["more"] = not result["more"]
|
||||
elif self.variant == "chunk_encoding":
|
||||
result["data"] = "?invalid?"
|
||||
elif self.variant == "chunk_length":
|
||||
result["data"] = base64.b64encode(data[:-1]).decode()
|
||||
elif self.variant == "image_hash":
|
||||
changed = bytes([data[0] ^ 1]) + data[1:]
|
||||
result["data"] = base64.b64encode(changed).decode()
|
||||
else:
|
||||
result = manifest
|
||||
error = (self.variant in ("error_exit1", "uncertain_input") or
|
||||
request["op"] in ("key", "text") and
|
||||
request.get("session_id", SESSION_ID) != SESSION_ID)
|
||||
envelope = {"ok": False, "error": {"code": "SESSION_NOT_VISIBLE"}} if error else {"ok": True, "result": result}
|
||||
if self.variant == "uncertain_input":
|
||||
envelope["error"] = {"code": "INPUT_SENT_OBSERVE_FAILED", "performed": True,
|
||||
"capture_consumed": True, "observe_again": True,
|
||||
"capture_id": CAPTURE_ID, "cause": "SCREEN_IO_FAILED",
|
||||
"text": "MUST NOT ESCAPE"}
|
||||
outer = {"ok": not error, "exit_code": "1" if error else "0",
|
||||
"target": kwargs["target"], "node_id": NODE_ID, "mcp_job_id": kwargs["job_id"],
|
||||
"mcp_script_sha256": kwargs["sha256"],
|
||||
"stdout": json.dumps(envelope), "stdout_truncated": self.variant == "truncated"}
|
||||
if self.variant == "outer_job":
|
||||
outer["mcp_job_id"] = "different"
|
||||
elif self.variant == "outer_hash":
|
||||
outer["mcp_script_sha256"] = "0" * 64
|
||||
elif self.variant == "outer_target":
|
||||
outer["target"] = "OTHER"
|
||||
elif self.variant == "outer_target_chunk" and request["op"] == "read_chunk":
|
||||
outer["target"] = "OTHER"
|
||||
elif self.variant == "outer_node_id_chunk" and request["op"] == "read_chunk":
|
||||
outer["node_id"] = OTHER_NODE_ID
|
||||
elif self.variant == "outer_node_id_missing":
|
||||
outer.pop("node_id")
|
||||
elif self.variant == "outer_node_id_invalid":
|
||||
outer["node_id"] = "invalid"
|
||||
return outer
|
||||
|
||||
|
||||
class ScreenTests(unittest.TestCase):
|
||||
def client(self, variant=None):
|
||||
executor = Executor(variant)
|
||||
return ScreenClient(executor, Image), executor
|
||||
|
||||
def test_native_image_return_preserves_bytes_and_omits_internal_path(self):
|
||||
client, executor = self.client()
|
||||
result = client.call("observe", "gerameg", True)
|
||||
self.assertEqual(result[0]["node"], "GERAMEG")
|
||||
self.assertEqual(result[1].data, executor.data)
|
||||
self.assertEqual(result[1].format, "png")
|
||||
self.assertNotIn("path", result[0]["result"])
|
||||
self.assertNotIn("image_base64", json.dumps(result[0]))
|
||||
self.assertGreater(len(executor.requests), 2)
|
||||
|
||||
def test_authorization_required_before_any_exec(self):
|
||||
client, executor = self.client()
|
||||
self.assertEqual(client.call("observe", "GERAMEG", False)["error"], "AUTHORIZED_TRUE_REQUIRED")
|
||||
self.assertEqual(executor.requests, [])
|
||||
|
||||
def test_invalid_node_cannot_reach_shell(self):
|
||||
client, executor = self.client()
|
||||
self.assertEqual(client.call("observe", "GERAMEG;id", True)["error"], "SCREEN_NODE_INVALID")
|
||||
self.assertEqual(executor.requests, [])
|
||||
|
||||
def test_visible_action_requires_capture_id(self):
|
||||
client, executor = self.client()
|
||||
self.assertEqual(client.call("key", "GERAMEG", True, key="ENTER")["error"], "SCREEN_CAPTURE_ID_REQUIRED")
|
||||
self.assertEqual(executor.requests, [])
|
||||
|
||||
def test_hidden_reference_observe_shows_full_screen_and_visibility_false(self):
|
||||
client, executor = self.client()
|
||||
result = client.call("observe", "GERAMEG", True, session_id="hidden_virtual_session")
|
||||
self.assertFalse(result[0]["result"]["requested_session_visible"])
|
||||
self.assertIsInstance(result[1], Image)
|
||||
|
||||
def test_hidden_action_error_survives_exit1_without_image(self):
|
||||
client, executor = self.client()
|
||||
result = client.call("key", "GERAMEG", True, capture_id=CAPTURE_ID,
|
||||
session_id="hidden_virtual_session", key="ENTER")
|
||||
self.assertEqual(result["error"], "SESSION_NOT_VISIBLE")
|
||||
self.assertEqual(len(executor.requests), 1)
|
||||
|
||||
def test_backend_structured_error_survives_failure_flag_and_exit1(self):
|
||||
client, executor = self.client("error_exit1")
|
||||
self.assertEqual(client.call("observe", "GERAMEG", True)["error"], "SESSION_NOT_VISIBLE")
|
||||
|
||||
def test_key_action_returns_new_real_image(self):
|
||||
client, executor = self.client()
|
||||
result = client.call("key", "GERAMEG", True, capture_id=CAPTURE_ID, key="CTRL_L")
|
||||
self.assertEqual(executor.requests[0]["op"], "key")
|
||||
self.assertEqual(result[1].data, executor.data)
|
||||
|
||||
def test_uncertain_input_details_preserved_without_payload(self):
|
||||
client, executor = self.client("uncertain_input")
|
||||
result = client.call("key", "GERAMEG", True, capture_id=CAPTURE_ID, key="ENTER")
|
||||
self.assertEqual(result["error"], "INPUT_SENT_OBSERVE_FAILED")
|
||||
self.assertIs(result["performed"], True)
|
||||
self.assertIs(result["capture_consumed"], True)
|
||||
self.assertIs(result["observe_again"], True)
|
||||
self.assertEqual(result["capture_id"], CAPTURE_ID)
|
||||
self.assertNotIn("text", result)
|
||||
self.assertEqual(len(executor.requests), 1)
|
||||
|
||||
def test_utf8_text_limit_matches_backend(self):
|
||||
client, executor = self.client()
|
||||
result = client.call("text", "GERAMEG", True, capture_id=CAPTURE_ID, text="界" * 1400)
|
||||
self.assertEqual(result["error"], "SCREEN_TEXT_INVALID")
|
||||
self.assertEqual(executor.requests, [])
|
||||
|
||||
def test_text_is_base64_transport_and_not_shell_interpolation(self):
|
||||
client, executor = self.client()
|
||||
text = "literal $(touch /tmp/no) `id` ' quote"
|
||||
result = client.call("text", "GERAMEG", True, capture_id=CAPTURE_ID, text=text)
|
||||
self.assertEqual(executor.requests[0]["text"], text)
|
||||
self.assertIsInstance(result[1], Image)
|
||||
|
||||
def test_integrity_rejections(self):
|
||||
expected = {"outer_job": "SCREEN_EXEC_BINDING_INVALID",
|
||||
"outer_hash": "SCREEN_EXEC_BINDING_INVALID",
|
||||
"outer_target": "SCREEN_EXEC_BINDING_INVALID",
|
||||
"truncated": "SCREEN_STDOUT_INVALID", "node": "SCREEN_IMAGE_HOSTNAME_INVALID",
|
||||
"outer_target_chunk": "SCREEN_EXEC_BINDING_INVALID",
|
||||
"outer_node_id_chunk": "SCREEN_CHUNK_NODE_MISMATCH",
|
||||
"outer_node_id_missing": "SCREEN_EXEC_NODE_ID_INVALID",
|
||||
"outer_node_id_invalid": "SCREEN_EXEC_NODE_ID_INVALID",
|
||||
"source": "SCREEN_PIXEL_SOURCE_INVALID", "provenance": "SCREEN_PIXEL_PROVENANCE_INVALID",
|
||||
"dimensions": "SCREEN_PNG_INVALID", "chunk_index": "SCREEN_CHUNK_BINDING_INVALID",
|
||||
"chunk_hash": "SCREEN_CHUNK_MANIFEST_INVALID", "chunk_more": "SCREEN_CHUNK_MANIFEST_INVALID",
|
||||
"chunk_encoding": "SCREEN_CHUNK_INVALID", "chunk_length": "SCREEN_CHUNK_LENGTH_INVALID",
|
||||
"image_hash": "SCREEN_IMAGE_HASH_MISMATCH"}
|
||||
for variant, code in expected.items():
|
||||
with self.subTest(variant=variant):
|
||||
client, _ = self.client(variant)
|
||||
self.assertEqual(client.call("observe", "GERAMEG", True)["error"], code)
|
||||
|
||||
def test_generic_node_is_passed_to_existing_executor(self):
|
||||
client, executor = self.client()
|
||||
self.assertEqual(client.call("observe", "ASUS", True)[0]["node"], "ASUS")
|
||||
|
||||
def test_exact_x11_capture_source_is_accepted(self):
|
||||
client, executor = self.client("x11")
|
||||
result = client.call("observe", "ASUS", True)
|
||||
self.assertEqual(result[0]["result"]["source"], "x11-root-XGetImage")
|
||||
self.assertEqual(result[1].data, executor.data)
|
||||
|
||||
def test_trusted_alias_or_uuid_resolver_is_injected(self):
|
||||
executor = Executor()
|
||||
client = ScreenClient(executor, Image, lambda node: "GERAMEG")
|
||||
self.assertEqual(client.call("observe", "node-alias", True)[0]["node"], "GERAMEG")
|
||||
|
||||
def test_registration_annotations_and_callable_functions(self):
|
||||
mcp = MCP()
|
||||
register_framebridge_screen_tools(mcp, Executor(), image_factory=Image)
|
||||
self.assertEqual(set(mcp.tools), {"framebridge_screen_observe", "framebridge_screen_key", "framebridge_screen_text"})
|
||||
self.assertTrue(mcp.annotations["framebridge_screen_observe"]["readOnlyHint"])
|
||||
self.assertFalse(mcp.annotations["framebridge_screen_key"]["readOnlyHint"])
|
||||
self.assertIsInstance(mcp.tools["framebridge_screen_observe"]("GERAMEG", True)[1], Image)
|
||||
|
||||
def test_local_route_preserves_distinct_real_hostname_and_uuid(self):
|
||||
executor = Executor(hostname="fb-central-lab")
|
||||
client = ScreenClient(executor, Image, lambda alias: "LOCAL")
|
||||
result = client.call("observe", "LOCAL", True)
|
||||
self.assertEqual(result[0]["node"], "LOCAL")
|
||||
self.assertEqual(result[0]["hostname"], "fb-central-lab")
|
||||
self.assertEqual(result[0]["node_id"], NODE_ID)
|
||||
self.assertEqual(result[0]["result"]["node"], "fb-central-lab")
|
||||
self.assertEqual(result[0]["result"]["transport_node"], "LOCAL")
|
||||
self.assertEqual(result[1].data, executor.data)
|
||||
|
||||
def test_remote_uuid_alias_and_hostname_remain_distinct(self):
|
||||
executor = Executor(hostname="fb-node-lab")
|
||||
client = ScreenClient(executor, Image, lambda alias: "fb-node")
|
||||
result = client.call("observe", NODE_ID, True)
|
||||
self.assertEqual(result[0]["requested_node"], NODE_ID)
|
||||
self.assertEqual(result[0]["node"], "fb-node")
|
||||
self.assertEqual(result[0]["hostname"], "fb-node-lab")
|
||||
self.assertEqual(result[0]["result"]["node"], "fb-node-lab")
|
||||
self.assertEqual(result[0]["result"]["transport_node"], "fb-node")
|
||||
self.assertEqual(result[1].data, executor.data)
|
||||
|
||||
def test_screen_action_keeps_capture_and_session_with_distinct_hostname(self):
|
||||
executor = Executor(hostname="fb-node-lab")
|
||||
client = ScreenClient(executor, Image, lambda alias: "fb-node")
|
||||
result = client.call("key", "fb-node", True, capture_id=CAPTURE_ID,
|
||||
session_id=SESSION_ID, key="ENTER")
|
||||
self.assertEqual(executor.requests[0]["capture_id"], CAPTURE_ID)
|
||||
self.assertEqual(executor.requests[0]["session_id"], SESSION_ID)
|
||||
self.assertEqual(result[0]["hostname"], "fb-node-lab")
|
||||
self.assertEqual(result[1].data, executor.data)
|
||||
|
||||
def test_arbitrary_dictionary_cannot_be_treated_as_authenticated_manifest(self):
|
||||
client, _ = self.client()
|
||||
verified = client.remote("LOCAL", {"op": "observe"})
|
||||
with self.assertRaisesRegex(ScreenError, "SCREEN_IMAGE_NODE_MISMATCH"):
|
||||
client.image("LOCAL", dict(verified))
|
||||
|
||||
def test_another_client_cannot_supply_authenticated_manifest(self):
|
||||
first, _ = self.client()
|
||||
second, _ = self.client()
|
||||
verified = first.remote("LOCAL", {"op": "observe"})
|
||||
with self.assertRaisesRegex(ScreenError, "SCREEN_IMAGE_NODE_MISMATCH"):
|
||||
second.image("LOCAL", verified)
|
||||
|
||||
def test_authenticated_hostname_cannot_be_changed_after_transport_validation(self):
|
||||
client, _ = self.client()
|
||||
verified = client.remote("LOCAL", {"op": "observe"})
|
||||
verified["node"] = "forged-hostname"
|
||||
with self.assertRaisesRegex(ScreenError, "SCREEN_IMAGE_NODE_MISMATCH"):
|
||||
client.image("LOCAL", verified)
|
||||
|
||||
def test_hostname_must_be_a_bounded_nonempty_string(self):
|
||||
for hostname in ("", None, 123, "invalid hostname", "x" * 81):
|
||||
with self.subTest(hostname=hostname):
|
||||
executor = Executor(hostname=hostname)
|
||||
client = ScreenClient(executor, Image, lambda alias: "LOCAL")
|
||||
self.assertEqual(client.call("observe", "LOCAL", True)["error"],
|
||||
"SCREEN_IMAGE_HOSTNAME_INVALID")
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Exercise original-artifact replay and the actual bundled MCP SDK contract."""
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from mcp.server.fastmcp import FastMCP, Image
|
||||
from mcp.types import CallToolResult
|
||||
|
||||
from framebridge_expanded.real_screen import screen_rpc as rpc
|
||||
from framebridge_expanded.real_screen.screen_capture import CaptureError
|
||||
from framebridge_expanded.real_screen.screen_frontdoor import ScreenClient
|
||||
from framebridge_expanded.real_screen.screen_presentation import (
|
||||
TEMPLATE_URI, VIEWER_HTML, register_screen_presentation,
|
||||
)
|
||||
from test_screen_frontdoor import Executor, CAPTURE_ID, NODE_ID, png
|
||||
|
||||
|
||||
class SavedCaptureRpcTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.directory = Path(self.temp.name)
|
||||
self.data = png()
|
||||
self.manifest = {
|
||||
"capture_id": CAPTURE_ID, "node": "fb-central-lab", "uid": os.getuid(),
|
||||
"source": "linux-framebuffer", "screen_type": "tty", "active_vt": "tty1",
|
||||
"bytes": len(self.data), "sha256": hashlib.sha256(self.data).hexdigest(),
|
||||
"width": 100, "height": 40, "synthetic": False, "pixel_source_verified": True,
|
||||
"full_screen": True, "authentic_pixels": True, "control_available": True,
|
||||
"_action_consumed": True, "captured_at": "2026-10-24T12:00:00Z",
|
||||
}
|
||||
self.png = self.directory / (CAPTURE_ID + ".png")
|
||||
self.png.write_bytes(self.data)
|
||||
self.png.chmod(0o600)
|
||||
for suffix in (".json", ".bound.json"):
|
||||
rpc.write_record(self.directory / (CAPTURE_ID + suffix), self.manifest)
|
||||
self.initial = {p.name: (p.read_bytes(), p.stat().st_mtime_ns)
|
||||
for p in self.directory.iterdir()}
|
||||
|
||||
def test_replay_survives_client_restart_and_consumed_input_without_observation(self):
|
||||
with patch.object(rpc, "observe", side_effect=AssertionError("new capture forbidden")), \
|
||||
patch.object(rpc, "act", side_effect=AssertionError("input forbidden")):
|
||||
result = rpc.dispatch({"op": "read_capture", "capture_id": CAPTURE_ID}, self.directory)
|
||||
self.assertTrue(result["ok"])
|
||||
result = result["result"]
|
||||
self.assertEqual(result["sha256"], self.manifest["sha256"])
|
||||
self.assertEqual(result["captured_at"], self.manifest["captured_at"])
|
||||
self.assertTrue(result["historical_capture"])
|
||||
self.assertFalse(result["control_available"])
|
||||
self.assertNotIn("_action_consumed", result)
|
||||
self.assertNotIn("path", result)
|
||||
self.assertEqual(self.initial, {p.name: (p.read_bytes(), p.stat().st_mtime_ns)
|
||||
for p in self.directory.iterdir()})
|
||||
|
||||
def test_replay_rejects_changed_png(self):
|
||||
self.png.write_bytes(self.data + b"corruption")
|
||||
with self.assertRaisesRegex(CaptureError, "CAPTURE_SHA256_MISMATCH"):
|
||||
rpc.dispatch({"op": "read_capture", "capture_id": CAPTURE_ID}, self.directory)
|
||||
|
||||
def test_replay_rejects_symlink_record(self):
|
||||
record = self.directory / (CAPTURE_ID + ".bound.json")
|
||||
record.unlink()
|
||||
record.symlink_to(self.directory / (CAPTURE_ID + ".json"))
|
||||
with self.assertRaises((OSError, rpc.ScreenRpcError)):
|
||||
rpc.dispatch({"op": "read_capture", "capture_id": CAPTURE_ID}, self.directory)
|
||||
|
||||
def test_replay_rejects_unrecognized_fields_and_unknown_reference(self):
|
||||
with self.assertRaisesRegex(rpc.ScreenRpcError, "UNKNOWN_REQUEST_FIELDS"):
|
||||
rpc.validate_request({"op": "read_capture", "capture_id": CAPTURE_ID, "text": "x"})
|
||||
with self.assertRaises((OSError, rpc.ScreenRpcError)):
|
||||
rpc.dispatch({"op": "read_capture", "capture_id": "f" * 32}, self.directory)
|
||||
|
||||
|
||||
class ScreenPresentationSdkTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.executor = Executor()
|
||||
self.client = ScreenClient(self.executor, Image, lambda name: name)
|
||||
self.mcp = FastMCP("screen-presentation-test")
|
||||
self.show = register_screen_presentation(self.mcp, self.client)
|
||||
|
||||
def test_actual_sdk_preserves_original_png_and_private_ui_metadata(self):
|
||||
response = asyncio.run(self.mcp.call_tool(
|
||||
"framebridge_screen_show",
|
||||
{"node": "LOCAL", "capture_id": CAPTURE_ID, "authorized": True}))
|
||||
self.assertIsInstance(response, CallToolResult)
|
||||
images = [c for c in response.content if c.type == "image"]
|
||||
self.assertEqual(len(images), 1)
|
||||
self.assertEqual(images[0].mimeType, "image/png")
|
||||
self.assertEqual(base64.b64decode(images[0].data), self.executor.data)
|
||||
self.assertEqual(response.meta["framebridgeImage"]["data"], images[0].data)
|
||||
self.assertEqual(response.structuredContent["node_id"], NODE_ID)
|
||||
self.assertEqual(response.structuredContent["result"]["capture_id"], CAPTURE_ID)
|
||||
self.assertEqual(response.structuredContent["result"]["sha256"],
|
||||
hashlib.sha256(self.executor.data).hexdigest())
|
||||
self.assertEqual(self.executor.requests[0],
|
||||
{"op": "read_capture", "capture_id": CAPTURE_ID})
|
||||
self.assertTrue(all(r["op"] in {"read_capture", "read_chunk"} for r in self.executor.requests))
|
||||
|
||||
def test_actual_descriptor_declares_ui_readonly_and_structured_schema(self):
|
||||
tools = asyncio.run(self.mcp.list_tools())
|
||||
self.assertEqual(len(tools), 1)
|
||||
tool = tools[0]
|
||||
self.assertEqual(tool.meta["ui"]["resourceUri"], TEMPLATE_URI)
|
||||
self.assertEqual(tool.meta["openai/outputTemplate"], TEMPLATE_URI)
|
||||
self.assertTrue(tool.annotations.readOnlyHint)
|
||||
self.assertFalse(tool.annotations.destructiveHint)
|
||||
self.assertIn("ok", tool.outputSchema["properties"])
|
||||
self.assertIn("result", tool.outputSchema["properties"])
|
||||
|
||||
def test_resource_contains_offline_viewer_and_integrity_validation(self):
|
||||
resources = asyncio.run(self.mcp.list_resources())
|
||||
self.assertEqual(len(resources), 1)
|
||||
self.assertEqual(str(resources[0].uri), TEMPLATE_URI)
|
||||
contents = list(asyncio.run(self.mcp.read_resource(TEMPLATE_URI)))
|
||||
self.assertEqual(contents[0].mime_type, "text/html;profile=mcp-app")
|
||||
self.assertEqual(contents[0].content, VIEWER_HTML)
|
||||
self.assertEqual(contents[0].meta["ui"]["csp"]["connectDomains"], [])
|
||||
self.assertNotIn("https://", VIEWER_HTML)
|
||||
self.assertNotIn("fetch(", VIEWER_HTML)
|
||||
self.assertIn('crypto.subtle.digest("SHA-256",bytes)', VIEWER_HTML)
|
||||
self.assertIn("ui/notifications/tool-result", VIEWER_HTML)
|
||||
self.assertIn("window.openai", VIEWER_HTML)
|
||||
self.assertIn("This saved capture does not show subsequent screen changes", VIEWER_HTML)
|
||||
|
||||
def test_unauthorized_replay_never_calls_backend(self):
|
||||
response = self.show("LOCAL", CAPTURE_ID, False)
|
||||
self.assertTrue(response.isError)
|
||||
self.assertEqual(response.structuredContent["error"], "AUTHORIZED_TRUE_REQUIRED")
|
||||
self.assertEqual(self.executor.requests, [])
|
||||
|
||||
def test_replay_requires_exact_capture_reference(self):
|
||||
response = self.show("LOCAL", "f" * 32, True)
|
||||
self.assertTrue(response.isError)
|
||||
self.assertEqual(response.structuredContent["error"], "SCREEN_CAPTURE_REFERENCE_MISMATCH")
|
||||
self.assertEqual(len(self.executor.requests), 1)
|
||||
|
||||
def test_replay_rejects_invalid_capture_before_backend(self):
|
||||
response = self.show("LOCAL", "../invalid", True)
|
||||
self.assertTrue(response.isError)
|
||||
self.assertEqual(response.structuredContent["error"], "SCREEN_CAPTURE_ID_REQUIRED")
|
||||
self.assertEqual(self.executor.requests, [])
|
||||
|
||||
def test_replay_preserves_existing_uuid_binding_validation(self):
|
||||
def corrupted(**kwargs):
|
||||
result = self.executor(**kwargs)
|
||||
result.pop("node_id", None)
|
||||
return result
|
||||
response = register_screen_presentation(FastMCP("binding-test"), ScreenClient(corrupted, Image))(
|
||||
"LOCAL", CAPTURE_ID, True)
|
||||
self.assertTrue(response.isError)
|
||||
self.assertFalse(response.structuredContent["ok"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,23 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from framebridge_expanded import fleet
|
||||
from framebridge_expanded.real_screen.screen_frontdoor import resolve_profile_node, ScreenError
|
||||
|
||||
class CanonicalScreenNodeTests(unittest.TestCase):
|
||||
def test_remote_uuid_is_bound_to_confirmed_executor_alias(self):
|
||||
uid = "12345678-1234-1234-1234-123456789abc"
|
||||
with patch.object(fleet, "resolve_node", return_value={"transport": "broker_outbound", "alias": "worker-1", "node_id": uid}) as resolve:
|
||||
self.assertEqual(resolve_profile_node(uid), "worker-1")
|
||||
resolve.assert_called_once_with(uid)
|
||||
|
||||
def test_receiver_identity_is_canonical_local(self):
|
||||
with patch.object(fleet, "resolve_node", return_value={"transport": "local", "alias": "central"}):
|
||||
self.assertEqual(resolve_profile_node("central"), "LOCAL")
|
||||
|
||||
def test_unknown_alias_has_no_hostname_or_ssh_fallback(self):
|
||||
with patch.object(fleet, "resolve_node", side_effect=fleet.FleetError("NODE_NOT_FOUND")):
|
||||
with self.assertRaisesRegex(ScreenError, "NODE_NOT_FOUND"):
|
||||
resolve_profile_node("unconfirmed-host")
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Exercise the actual portable screen registration callback and its UUID contract.
|
||||
|
||||
The bounded executor is a fixture. This is not VM, GUI, TTY or input acceptance.
|
||||
"""
|
||||
import ast
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
from framebridge_expanded.real_screen.screen_frontdoor import register_framebridge_screen_tools
|
||||
from test_screen_frontdoor import Executor, Image, MCP, NODE_ID
|
||||
|
||||
|
||||
PORTABLE = Path(__file__).resolve().parents[1] / "portable/front_door_mcp.py"
|
||||
|
||||
|
||||
class PortableScreenRegistrationTests(unittest.TestCase):
|
||||
def registration(self, target="LOCAL", corrupt=None):
|
||||
fixture = Executor(hostname="fb-central-lab" if target == "LOCAL" else "fb-node-lab")
|
||||
calls, legacy_calls = [], []
|
||||
|
||||
def expanded(alias, script, authorized, timeout_s=120, job_id="", sha256="", include_stdout=False):
|
||||
args = dict(script=script, authorized=authorized, timeout_s=timeout_s,
|
||||
job_id=job_id, sha256=sha256, include_stdout=include_stdout, target=alias)
|
||||
calls.append(args)
|
||||
result = fixture(**args)
|
||||
if corrupt == "uuid":
|
||||
result.pop("node_id")
|
||||
elif corrupt == "hash":
|
||||
result["mcp_script_sha256"] = "0" * 64
|
||||
return result
|
||||
|
||||
def legacy(**kwargs):
|
||||
# Model the real legacy LOCAL envelope which lacks the Fleet UUID.
|
||||
legacy_calls.append(kwargs)
|
||||
if kwargs["target"] != "LOCAL":
|
||||
remote = dict(kwargs)
|
||||
return expanded(remote.pop("target"), **remote)
|
||||
result = fixture(**kwargs)
|
||||
result.pop("node_id")
|
||||
return result
|
||||
|
||||
tree = ast.parse(PORTABLE.read_text())
|
||||
definitions = [item for item in tree.body
|
||||
if isinstance(item, ast.FunctionDef) and item.name == "_screen_exec_request"]
|
||||
registrations = [item for item in tree.body
|
||||
if isinstance(item, ast.Assign) and any(
|
||||
isinstance(name, ast.Name) and name.id == "_real_screen_client"
|
||||
for name in item.targets)]
|
||||
self.assertEqual(len(registrations), 1)
|
||||
scope = dict(mcp=MCP(), Image=Image, expanded_execute_node=expanded, expanded_execute_screen_node=expanded,
|
||||
framebridge_exec=legacy,
|
||||
register_framebridge_screen_tools=register_framebridge_screen_tools,
|
||||
resolve_profile_node=lambda node: node)
|
||||
# Load the definition and registration from production source rather
|
||||
# than recreating the adapter inside this test.
|
||||
module = ast.Module(body=definitions + registrations, type_ignores=[])
|
||||
exec(compile(ast.fix_missing_locations(module), str(PORTABLE), "exec"), scope)
|
||||
return scope["mcp"], fixture, calls, legacy_calls, scope["_real_screen_client"]
|
||||
|
||||
def assert_observe(self, target):
|
||||
mcp, fixture, calls, legacy_calls, _ = self.registration(target)
|
||||
response = mcp.tools["framebridge_screen_observe"](target, True)
|
||||
self.assertIsInstance(response, list)
|
||||
metadata, image = response
|
||||
self.assertTrue(metadata["ok"])
|
||||
self.assertEqual(metadata["requested_node"], target)
|
||||
self.assertEqual(metadata["node_id"], NODE_ID)
|
||||
self.assertEqual(metadata["hostname"], fixture.hostname)
|
||||
self.assertEqual(image.data, fixture.data)
|
||||
self.assertEqual(image.format, "png")
|
||||
if target == "LOCAL":
|
||||
self.assertFalse(legacy_calls)
|
||||
self.assertGreater(len(calls), 1) # Manifest plus bound image chunks.
|
||||
self.assertTrue(all(item["target"] == target for item in calls))
|
||||
self.assertTrue(all(item["authorized"] is True and item["include_stdout"] is True
|
||||
and item["timeout_s"] == 30 for item in calls))
|
||||
|
||||
def test_actual_registration_preserves_local_fleet_uuid(self):
|
||||
self.assert_observe("LOCAL")
|
||||
|
||||
def test_actual_registration_preserves_remote_alias_and_hostname(self):
|
||||
self.assert_observe("fb-node")
|
||||
|
||||
def test_actual_registration_still_rejects_missing_uuid(self):
|
||||
mcp, _, _, _, _ = self.registration(corrupt="uuid")
|
||||
response = mcp.tools["framebridge_screen_observe"]("LOCAL", True)
|
||||
self.assertFalse(response["ok"])
|
||||
self.assertEqual(response["error"], "SCREEN_EXEC_NODE_ID_INVALID")
|
||||
|
||||
def test_actual_registration_still_rejects_wrong_script_binding(self):
|
||||
mcp, _, _, _, _ = self.registration(corrupt="hash")
|
||||
response = mcp.tools["framebridge_screen_observe"]("LOCAL", True)
|
||||
self.assertFalse(response["ok"])
|
||||
self.assertEqual(response["error"], "SCREEN_EXEC_BINDING_INVALID")
|
||||
|
||||
def test_unauthorized_observation_never_reaches_executor(self):
|
||||
mcp, _, calls, legacy_calls, _ = self.registration()
|
||||
response = mcp.tools["framebridge_screen_observe"]("LOCAL", False)
|
||||
self.assertFalse(response["ok"])
|
||||
self.assertEqual(response["error"], "AUTHORIZED_TRUE_REQUIRED")
|
||||
self.assertFalse(calls)
|
||||
self.assertFalse(legacy_calls)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,127 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from framebridge_expanded.real_screen import screen_rpc as rpc
|
||||
|
||||
|
||||
class ScreenRpcTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.directory = Path(self.temp.name)
|
||||
self.counter = 0
|
||||
self.binding = {'backend': 'visible-tmux', 'pane_id': '%1', 'node': 'TEST', 'uid': os.getuid()}
|
||||
self.actions = []
|
||||
self.patches = [
|
||||
patch.object(rpc, 'snapshot_binding', return_value=self.binding),
|
||||
patch.object(rpc, 'capture_screen', side_effect=self.capture),
|
||||
patch.object(rpc, 'bind_screen', side_effect=lambda m: {**m, 'binding': self.binding}),
|
||||
patch.object(rpc, 'read_chunk', side_effect=self.chunk),
|
||||
patch.object(rpc, 'act', side_effect=self.action),
|
||||
patch.object(rpc.time, 'sleep'),
|
||||
]
|
||||
for p in self.patches:
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
|
||||
def capture(self, directory):
|
||||
self.counter += 1
|
||||
capture_id = f'{self.counter:032x}'
|
||||
data = b'actual-pixel-test-fixture-' + capture_id.encode()
|
||||
path = directory / (capture_id + '.png')
|
||||
path.write_bytes(data)
|
||||
return {'capture_id': capture_id, 'source': 'linux-framebuffer', 'screen_type': 'tty',
|
||||
'node': 'TEST', 'uid': os.getuid(), 'width': 1920, 'height': 1080,
|
||||
'path': str(path), 'bytes': len(data), 'sha256': hashlib.sha256(data).hexdigest(),
|
||||
'active_vt': 'tty1', 'synthetic': False, 'pixel_source_verified': True}
|
||||
|
||||
def chunk(self, directory, capture_id, index, **kwargs):
|
||||
data = (directory / (capture_id + '.png')).read_bytes()
|
||||
digest = hashlib.sha256(data).hexdigest()
|
||||
if digest != kwargs['expected_sha256']:
|
||||
raise rpc.CaptureError('CAPTURE_SHA256_MISMATCH')
|
||||
return {'capture_id': capture_id, 'index': index, 'bytes': len(data), 'sha256': digest,
|
||||
'total_chunks': 1, 'encoding': 'base64', 'data': base64.b64encode(data).decode(), 'more': False}
|
||||
|
||||
def action(self, manifest, kind, **kwargs):
|
||||
self.actions.append((manifest['capture_id'], kind, kwargs))
|
||||
return {'performed': True}
|
||||
|
||||
def observe(self, **kwargs):
|
||||
return rpc.dispatch({'op': 'observe', **kwargs}, self.directory)['result']
|
||||
|
||||
def test_observation_uses_capture_pixels_not_terminal_text(self):
|
||||
m = self.observe(session_id='hidden_terminal')
|
||||
self.assertTrue(m['full_screen'] and m['authentic_pixels'])
|
||||
self.assertFalse(m['requested_session_visible'])
|
||||
self.assertNotIn('path', m)
|
||||
self.assertTrue(m['control_available'])
|
||||
self.assertEqual(m['width'], 1920)
|
||||
|
||||
def test_hidden_terminal_id_cannot_receive_input_for_visible_image(self):
|
||||
m = self.observe()
|
||||
with self.assertRaisesRegex(rpc.ScreenRpcError, 'SESSION_NOT_VISIBLE'):
|
||||
rpc.dispatch({'op': 'key', 'capture_id': m['capture_id'], 'session_id': 'hidden_terminal', 'key': 'ENTER'}, self.directory)
|
||||
self.assertEqual(self.actions, [])
|
||||
|
||||
def test_input_returns_new_observation_and_cannot_replay_old_capture(self):
|
||||
m = self.observe()
|
||||
after = rpc.dispatch({'op': 'text', 'capture_id': m['capture_id'], 'session_id': m['session_id'], 'text': 'visible marker'}, self.directory)['result']
|
||||
self.assertNotEqual(after['capture_id'], m['capture_id'])
|
||||
self.assertEqual(len(self.actions), 1)
|
||||
with self.assertRaisesRegex(rpc.ScreenRpcError, 'CAPTURE_ALREADY_USED'):
|
||||
rpc.dispatch({'op': 'key', 'capture_id': m['capture_id'], 'key': 'ENTER'}, self.directory)
|
||||
self.assertEqual(len(self.actions), 1)
|
||||
|
||||
def test_unknown_send_result_does_not_replay_input(self):
|
||||
m = self.observe()
|
||||
with patch.object(rpc, 'act', side_effect=rpc.ControlError('SCREEN_BINDING_CHANGED')):
|
||||
with self.assertRaisesRegex(rpc.ScreenRpcError, 'SCREEN_BINDING_CHANGED'):
|
||||
rpc.dispatch({'op': 'key', 'capture_id': m['capture_id'], 'key': 'ENTER'}, self.directory)
|
||||
with self.assertRaisesRegex(rpc.ScreenRpcError, 'CAPTURE_ALREADY_USED'):
|
||||
rpc.dispatch({'op': 'key', 'capture_id': m['capture_id'], 'key': 'ENTER'}, self.directory)
|
||||
|
||||
def test_target_change_during_capture_returns_no_bound_image(self):
|
||||
with patch.object(rpc, 'bind_screen', side_effect=lambda m: {**m, 'binding': {**self.binding, 'pane_id': '%0'}}):
|
||||
with self.assertRaisesRegex(rpc.ScreenRpcError, 'VISIBLE_TARGET_CHANGED_DURING_CAPTURE'):
|
||||
self.observe()
|
||||
self.assertEqual(list(self.directory.glob('*.bound.json')), [])
|
||||
|
||||
def test_sent_input_is_reported_if_following_capture_fails(self):
|
||||
m = self.observe()
|
||||
with patch.object(rpc, 'capture_screen', side_effect=rpc.CaptureError('ACTIVE_VT_CHANGED')):
|
||||
with self.assertRaises(rpc.ScreenRpcError) as caught:
|
||||
rpc.dispatch({'op': 'key', 'capture_id': m['capture_id'], 'key': 'ENTER'}, self.directory)
|
||||
self.assertEqual(caught.exception.code, 'INPUT_SENT_OBSERVE_FAILED')
|
||||
self.assertTrue(caught.exception.details['performed'])
|
||||
self.assertEqual(len(self.actions), 1)
|
||||
|
||||
def test_view_can_succeed_without_safe_input_target(self):
|
||||
with patch.object(rpc, 'snapshot_binding', side_effect=rpc.ControlError('SCREEN_VISIBLE_TMUX_NOT_FOUND')):
|
||||
m = self.observe()
|
||||
self.assertFalse(m['control_available'])
|
||||
with self.assertRaisesRegex(rpc.ScreenRpcError, 'VISIBLE_INPUT_UNAVAILABLE'):
|
||||
rpc.dispatch({'op': 'key', 'capture_id': m['capture_id'], 'key': 'ENTER'}, self.directory)
|
||||
self.assertEqual(self.actions, [])
|
||||
|
||||
def test_changed_png_cannot_drive_input(self):
|
||||
m = self.observe()
|
||||
(self.directory / (m['capture_id'] + '.png')).write_bytes(b'different pixels')
|
||||
with self.assertRaisesRegex(rpc.CaptureError, 'CAPTURE_SHA256_MISMATCH'):
|
||||
rpc.dispatch({'op': 'key', 'capture_id': m['capture_id'], 'key': 'ENTER'}, self.directory)
|
||||
self.assertEqual(self.actions, [])
|
||||
|
||||
def test_read_requires_expected_image_hash(self):
|
||||
m = self.observe()
|
||||
with self.assertRaisesRegex(rpc.ScreenRpcError, 'CAPTURE_SHA256_MISMATCH'):
|
||||
rpc.dispatch({'op': 'read_chunk', 'capture_id': m['capture_id'], 'index': 0, 'expected_sha256': '0' * 64}, self.directory)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,16 @@
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from framebridge_expanded.real_screen import screen_rpc as rpc
|
||||
|
||||
class X11InputRoutingTests(unittest.TestCase):
|
||||
def test_x11_input_is_rejected_with_graphical_cua_instruction(self):
|
||||
for op, value in (("text", {"text": "test"}), ("key", {"key": "ENTER"})):
|
||||
request = {"op": op, "capture_id": "a" * 32, **value}
|
||||
with self.subTest(op=op), patch.object(rpc, "load_capture", return_value={"screen_type": "x11"}), patch.object(rpc, "act") as hidden_action:
|
||||
with self.assertRaisesRegex(rpc.ScreenRpcError, "USE_GRAPHICAL_CUA_FOR_X11_INPUT"):
|
||||
rpc.dispatch(request, Path("/unused/fixture"))
|
||||
hidden_action.assert_not_called()
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,379 @@
|
||||
"""Regression tests for the explicit typed screen RPC transport."""
|
||||
import ast
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
PORTABLE = Path(__file__).resolve().parents[1] / "portable/front_door_mcp.py"
|
||||
|
||||
class PortableTypedRouteTests(unittest.TestCase):
|
||||
def test_screen_callback_uses_explicit_typed_route(self):
|
||||
calls = []
|
||||
def typed(*args):
|
||||
calls.append(("typed", args))
|
||||
return {"typed": True}
|
||||
def generic(*args):
|
||||
calls.append(("generic", args))
|
||||
return {"typed": False}
|
||||
source = ast.parse(PORTABLE.read_text())
|
||||
definitions = [n for n in source.body if isinstance(n, ast.FunctionDef) and n.name == "_screen_exec_request"]
|
||||
self.assertEqual(len(definitions), 1)
|
||||
scope = {"expanded_execute_node": generic, "expanded_execute_screen_node": typed}
|
||||
exec(compile(ast.Module(body=definitions, type_ignores=[]), str(PORTABLE), "exec"), scope)
|
||||
result = scope["_screen_exec_request"](script="canonical-screen-fixture", authorized=True, timeout_s=30,
|
||||
job_id="screen-fixture", sha256="fixture-hash",
|
||||
include_stdout=True, target="worker-1")
|
||||
self.assertEqual([c[0] for c in calls], ["typed"])
|
||||
self.assertTrue(result["typed"])
|
||||
|
||||
|
||||
|
||||
import base64
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from framebridge_expanded import fleet, broker, node
|
||||
from framebridge_expanded.real_screen import screen_transport as transport
|
||||
from framebridge_expanded.real_screen.screen_frontdoor import ScreenClient, ScreenError, RPC_COMMAND
|
||||
from framebridge_expanded.real_screen.screen_rpc import encode
|
||||
|
||||
NODE_ID = "12345678-1234-1234-1234-123456789abc"
|
||||
CAPTURE_ID = "a" * 32
|
||||
|
||||
|
||||
def make_payload(request=None):
|
||||
request = {"op": "observe"} if request is None else request
|
||||
argument = base64.b64encode(encode(request)).decode("ascii")
|
||||
script = RPC_COMMAND + argument + "\n"
|
||||
return {"version": 1, "mode": "EXEC", "authorized": True,
|
||||
"request_type": "screen_rpc", "script": script,
|
||||
"sha256": hashlib.sha256(script.encode()).hexdigest(),
|
||||
"job_id": "screen-fixture", "timeout_s": 1, "include_stdout": True}
|
||||
|
||||
|
||||
def response(payload, envelope=None):
|
||||
envelope = {"ok": True, "result": {"fixture": True}} if envelope is None else envelope
|
||||
return {"ok": envelope["ok"], "job_id": payload["job_id"],
|
||||
"command_sha256": payload["sha256"], "exit_code": "0" if envelope["ok"] else "1",
|
||||
"stdout": encode(envelope).decode() + "\n", "stdout_truncated": False}
|
||||
|
||||
|
||||
class TypedPayloadTests(unittest.TestCase):
|
||||
def test_canonical_operation_roundtrips_without_shell(self):
|
||||
payload = make_payload({"op": "status"})
|
||||
request, argument = transport.validate_payload(payload)
|
||||
self.assertEqual(request, {"op": "status"})
|
||||
self.assertEqual(base64.b64decode(argument), encode(request))
|
||||
|
||||
def test_common_authorization_and_shape_rejections(self):
|
||||
valid = make_payload()
|
||||
mutations = [{"authorized": False}, {"version": True}, {"version": 2},
|
||||
{"mode": "CUA"}, {"job_id": "invalid job"}, {"timeout_s": True},
|
||||
{"timeout_s": 31}, {"timeout_s": 0}, {"include_stdout": False},
|
||||
{"sha256": "0" * 64}, {"request_type": "SCREEN_RPC"},
|
||||
{"unexpected": "not accepted"}]
|
||||
for mutation in mutations:
|
||||
with self.subTest(mutation=mutation):
|
||||
payload = dict(valid, **mutation)
|
||||
with self.assertRaises(transport.ScreenTransportError):
|
||||
transport.validate_payload(payload)
|
||||
payload = dict(valid)
|
||||
del payload["authorized"]
|
||||
with self.assertRaises(transport.ScreenTransportError):
|
||||
transport.validate_payload(payload)
|
||||
|
||||
def test_no_noncanonical_script_or_unknown_operation(self):
|
||||
prefix = RPC_COMMAND
|
||||
bad = ["echo arbitrary\n", prefix + "not-base64\n",
|
||||
make_payload()["script"] + "echo extra\n",
|
||||
make_payload()["script"].rstrip("\n") + " extra\n",
|
||||
prefix + base64.b64encode(b'{ "op" : "status" }').decode() + "\n",
|
||||
prefix + base64.b64encode(encode({"op": "exec", "script": "echo bad"})).decode() + "\n",
|
||||
prefix + base64.b64encode(encode({"op": "status", "path": "/tmp/other"})).decode() + "\n",
|
||||
prefix + "'" + base64.b64encode(encode({"op": "status"})).decode() + "'\n",
|
||||
prefix + base64.b64encode(b"x" * 16385).decode() + "\n"]
|
||||
for script in bad:
|
||||
with self.subTest(script=script[:100]):
|
||||
payload = make_payload()
|
||||
payload.update(script=script, sha256=hashlib.sha256(script.encode()).hexdigest())
|
||||
with self.assertRaises(transport.ScreenTransportError):
|
||||
transport.validate_payload(payload)
|
||||
|
||||
def test_existing_input_schema_is_preserved(self):
|
||||
for request in [{"op": "key", "capture_id": CAPTURE_ID, "key": "ENTER"},
|
||||
{"op": "text", "capture_id": CAPTURE_ID, "text": "printf"},
|
||||
{"op": "read_chunk", "capture_id": CAPTURE_ID, "index": 0, "expected_sha256": "f" * 64}]:
|
||||
with self.subTest(op=request["op"]):
|
||||
parsed, _ = transport.validate_payload(make_payload(request))
|
||||
self.assertEqual(parsed, request)
|
||||
with self.assertRaises(transport.ScreenTransportError):
|
||||
transport.validate_payload(make_payload({"op": "text", "capture_id": CAPTURE_ID, "text": "\x00"}))
|
||||
|
||||
def test_adapter_intercepts_only_explicit_type(self):
|
||||
original = Mock(return_value={"visible_engine": True})
|
||||
adapter = SimpleNamespace(handle=original)
|
||||
transport.install_adapter_route(adapter)
|
||||
generic = make_payload()
|
||||
del generic["request_type"]
|
||||
self.assertEqual(adapter.handle(generic), {"visible_engine": True})
|
||||
original.assert_called_once_with(generic)
|
||||
original.reset_mock()
|
||||
with self.assertRaises(transport.ScreenTransportError):
|
||||
adapter.handle(dict(make_payload(), authorized=False))
|
||||
original.assert_not_called()
|
||||
|
||||
def test_malformed_typed_case_cannot_fall_back(self):
|
||||
original = Mock(side_effect=AssertionError("no visible FBTERM fallback"))
|
||||
adapter = SimpleNamespace(handle=original)
|
||||
transport.install_adapter_route(adapter)
|
||||
for mutation in [{"request_type": " SCREEN_RPC "}, {"script": "echo arbitrary\n"},
|
||||
{"sha256": "0" * 64}, {"mode": "INVALID"}, {"version": False}]:
|
||||
with self.subTest(mutation=mutation):
|
||||
with self.assertRaises(transport.ScreenTransportError):
|
||||
adapter.handle(dict(make_payload(), **mutation))
|
||||
original.assert_not_called()
|
||||
|
||||
def test_success_and_child_error_envelopes_remain_exact(self):
|
||||
payload = make_payload()
|
||||
good = {"ok": True, "result": {"fixture": True}}
|
||||
error = {"ok": False, "error": {"code": "INPUT_SENT_OBSERVE_FAILED", "performed": True,
|
||||
"capture_consumed": True, "observe_again": True,
|
||||
"capture_id": CAPTURE_ID, "cause": "SCREEN_IO_FAILED"}}
|
||||
for envelope in [good, error]:
|
||||
with self.subTest(ok=envelope["ok"]):
|
||||
runner = Mock(return_value=(0 if envelope["ok"] else 1, encode(envelope) + b"\n", b""))
|
||||
result = transport.handle_request(payload, runner=runner)
|
||||
self.assertEqual(result["stdout"].encode(), encode(envelope) + b"\n")
|
||||
self.assertEqual(result["job_id"], payload["job_id"])
|
||||
self.assertEqual(result["command_sha256"], payload["sha256"])
|
||||
self.assertEqual(result["ok"], envelope["ok"])
|
||||
runner.assert_called_once_with(transport.validate_payload(payload)[1], 1)
|
||||
|
||||
def test_input_timeout_is_unknown_and_never_replayed(self):
|
||||
payload = make_payload({"op": "key", "capture_id": CAPTURE_ID, "key": "ENTER"})
|
||||
runner = Mock(side_effect=transport.ScreenProcessError("SCREEN_RPC_TIMEOUT", started=True))
|
||||
result = transport.handle_request(payload, runner=runner)
|
||||
error = json.loads(result["stdout"])["error"]
|
||||
self.assertEqual(error["code"], "SCREEN_RPC_TIMEOUT")
|
||||
self.assertTrue(error["observe_again"])
|
||||
self.assertEqual(error["capture_id"], CAPTURE_ID)
|
||||
self.assertNotIn("performed", error)
|
||||
self.assertNotIn("capture_consumed", error)
|
||||
runner.assert_called_once()
|
||||
|
||||
def test_prelaunch_or_readonly_timeout_does_not_invent_input(self):
|
||||
for request, started in [({"op": "observe"}, True),
|
||||
({"op": "key", "capture_id": CAPTURE_ID, "key": "ENTER"}, False)]:
|
||||
with self.subTest(request=request, started=started):
|
||||
runner = Mock(side_effect=transport.ScreenProcessError("SCREEN_RPC_PROCESS_FAILED", started=started))
|
||||
result = transport.handle_request(make_payload(request), runner=runner)
|
||||
error = json.loads(result["stdout"])["error"]
|
||||
self.assertNotIn("performed", error)
|
||||
self.assertNotIn("capture_consumed", error)
|
||||
self.assertNotIn("observe_again", error)
|
||||
runner.assert_called_once()
|
||||
|
||||
def test_invalid_or_oversized_input_reply_is_uncertain(self):
|
||||
payload = make_payload({"op": "text", "capture_id": CAPTURE_ID, "text": "printf"})
|
||||
for output in [b"not JSON\n", b"x" * 4097]:
|
||||
with self.subTest(size=len(output)):
|
||||
runner = Mock(return_value=(0, output, b""))
|
||||
result = transport.handle_request(payload, runner=runner)
|
||||
error = json.loads(result["stdout"])["error"]
|
||||
self.assertTrue(error["observe_again"])
|
||||
self.assertNotIn("performed", error)
|
||||
self.assertNotIn("capture_consumed", error)
|
||||
self.assertLess(len(result["stdout"].encode()), 4096)
|
||||
runner.assert_called_once()
|
||||
|
||||
|
||||
class BoundedSubprocessTests(unittest.TestCase):
|
||||
def launch_fixture(self, text, argument, timeout=1):
|
||||
real_popen = subprocess.Popen
|
||||
children, calls = [], []
|
||||
def fixture(argv, **kwargs):
|
||||
calls.append((argv, kwargs))
|
||||
child = real_popen(["/usr/bin/python3", "-B", "-c", text], **kwargs)
|
||||
children.append(child)
|
||||
return child
|
||||
with patch.object(transport.subprocess, "Popen", side_effect=fixture):
|
||||
try:
|
||||
result = transport._run_rpc(argument, timeout)
|
||||
return result, children, calls
|
||||
finally:
|
||||
self.assertTrue(all(child.poll() is not None for child in children))
|
||||
|
||||
def test_fixed_argv_inherits_normal_environment_without_shell(self):
|
||||
argument = transport.validate_payload(make_payload())[1]
|
||||
output = encode({"ok": True, "result": {"fixture": True}}) + b"\n"
|
||||
result, children, calls = self.launch_fixture("import sys;sys.stdout.buffer.write(" + repr(output) + ")", argument)
|
||||
self.assertEqual(result, (0, output, b""))
|
||||
argv, kwargs = calls[0]
|
||||
self.assertEqual(argv, ["/usr/bin/python3", "-B", "-m", "framebridge_expanded.real_screen.screen_rpc", argument])
|
||||
self.assertNotIn("shell", kwargs)
|
||||
self.assertNotIn("env", kwargs)
|
||||
self.assertTrue(kwargs["start_new_session"])
|
||||
self.assertTrue(kwargs["close_fds"])
|
||||
self.assertEqual(len(children), 1)
|
||||
|
||||
def test_output_limit_does_not_accumulate_or_return_large_output(self):
|
||||
for stream in ["stdout", "stderr"]:
|
||||
with self.subTest(stream=stream):
|
||||
with self.assertRaisesRegex(transport.ScreenProcessError, "SCREEN_RPC_OUTPUT_LIMIT"):
|
||||
self.launch_fixture("import sys;sys." + stream + ".buffer.write(b'x'*100000);sys." + stream + ".flush()", "fixture")
|
||||
|
||||
def test_wall_timeout_kills_and_reaps_single_child(self):
|
||||
started = time.monotonic()
|
||||
with self.assertRaisesRegex(transport.ScreenProcessError, "SCREEN_RPC_TIMEOUT"):
|
||||
self.launch_fixture("import time;time.sleep(5)", "fixture", timeout=1)
|
||||
self.assertLess(time.monotonic() - started, 3)
|
||||
|
||||
def test_timeout_cleans_owned_group_after_leader_exits(self):
|
||||
import signal
|
||||
import tempfile
|
||||
real_popen = subprocess.Popen
|
||||
children = []
|
||||
with tempfile.TemporaryDirectory(prefix="framebridge-screen-test-") as folder:
|
||||
pidfile = Path(folder) / "descendant.pid"
|
||||
script = ("import os,time\npid=os.fork()\n"
|
||||
"if pid==0:\n time.sleep(5)\n os._exit(0)\n"
|
||||
"open(" + repr(str(pidfile)) + ",'w').write(str(pid))\nos._exit(0)\n")
|
||||
def launch(argv, **kwargs):
|
||||
child = real_popen(["/usr/bin/python3", "-B", "-c", script], **kwargs)
|
||||
children.append(child)
|
||||
return child
|
||||
try:
|
||||
with patch.object(transport.subprocess, "Popen", side_effect=launch):
|
||||
with self.assertRaisesRegex(transport.ScreenProcessError, "SCREEN_RPC_TIMEOUT"):
|
||||
transport._run_rpc("fixture", 1)
|
||||
descendant = int(pidfile.read_text())
|
||||
def state():
|
||||
try:
|
||||
return (Path("/proc") / str(descendant) / "stat").read_text().rsplit(")", 1)[1].split()[0]
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
deadline = time.monotonic() + 0.5
|
||||
while state() not in (None, "Z") and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
self.assertIn(state(), (None, "Z"), "owned descendant survived the RPC timeout")
|
||||
finally:
|
||||
# Keep the deliberately failing pre-fix regression free of live orphans.
|
||||
for child in children:
|
||||
try:
|
||||
os.killpg(child.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
child.wait(timeout=1)
|
||||
|
||||
|
||||
|
||||
class TypedFleetRouteTests(unittest.TestCase):
|
||||
def local_node(self):
|
||||
return {"transport": "local", "alias": "central", "node_id": NODE_ID}
|
||||
|
||||
def remote_node(self):
|
||||
return {"transport": "broker_outbound", "alias": "worker-1", "node_id": NODE_ID,
|
||||
"operational_transport_ready": True}
|
||||
|
||||
def execute(self, payload):
|
||||
return fleet.execute_screen_node("worker-1", payload["script"], True, payload["timeout_s"],
|
||||
payload["job_id"], payload["sha256"], True)
|
||||
|
||||
def test_local_uses_typed_adapter_and_keeps_job_hash_uuid(self):
|
||||
payload = make_payload({"op": "status"})
|
||||
def adapter(value):
|
||||
self.assertEqual(value, payload)
|
||||
return response(value)
|
||||
with patch.object(fleet, "resolve_node", return_value=self.local_node()), \
|
||||
patch("framebridge_expanded.runtime.adapter_request", side_effect=adapter) as call:
|
||||
result = self.execute(payload)
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertEqual(result["target"], "LOCAL")
|
||||
self.assertEqual(result["node_id"], NODE_ID)
|
||||
self.assertEqual(result["mcp_job_id"], payload["job_id"])
|
||||
self.assertEqual(result["mcp_script_sha256"], payload["sha256"])
|
||||
call.assert_called_once()
|
||||
|
||||
def test_remote_admin_exec_preserves_type_through_broker_node(self):
|
||||
payload = make_payload({"op": "status"})
|
||||
calls = []
|
||||
def adapter(value):
|
||||
self.assertEqual(value, payload)
|
||||
return response(value)
|
||||
def request(node_id, capability, value, timeout_s):
|
||||
calls.append((node_id, capability, value, timeout_s))
|
||||
req = {"node_id": node_id, "expires_at": time.time() + 30,
|
||||
"request_id": "request-fixture", "operation": capability, "payload": value}
|
||||
with patch.object(node, "_context", return_value=(None, None, None, {"node_id": NODE_ID})):
|
||||
return node.dispatch_request(req, adapter_request=adapter)
|
||||
with patch.object(fleet, "resolve_node", return_value=self.remote_node()), \
|
||||
patch.object(broker, "request", side_effect=request):
|
||||
result = self.execute(payload)
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertEqual(result["node_id"], NODE_ID)
|
||||
self.assertEqual(result["target"], "worker-1")
|
||||
self.assertEqual(len(calls), 1)
|
||||
self.assertEqual(calls[0][1], "admin.exec")
|
||||
self.assertEqual(calls[0][2]["request_type"], "screen_rpc")
|
||||
|
||||
def test_old_adapter_is_reported_without_shell_retry(self):
|
||||
remote = self.remote_node()
|
||||
legacy = {"ok": False, "adapter_error": "ValueError:UNSUPPORTED_REQUEST_TYPE"}
|
||||
def execute(**kwargs):
|
||||
target = kwargs.pop("target")
|
||||
return fleet.execute_screen_node(target, **kwargs)
|
||||
client = ScreenClient(execute, image_factory=lambda **kwargs: None)
|
||||
with patch.object(fleet, "resolve_node", return_value=remote), \
|
||||
patch.object(broker, "request", return_value=legacy) as request, \
|
||||
patch.object(fleet, "execute_node", side_effect=AssertionError("no legacy fallback")) as generic:
|
||||
with self.assertRaisesRegex(ScreenError, "SCREEN_RPC_UNSUPPORTED_ON_NODE"):
|
||||
client.remote("worker-1", {"op": "status"})
|
||||
request.assert_called_once()
|
||||
generic.assert_not_called()
|
||||
|
||||
def test_bad_job_or_hash_response_is_rejected(self):
|
||||
payload = make_payload()
|
||||
for field in ["job_id", "command_sha256"]:
|
||||
with self.subTest(field=field):
|
||||
result = response(payload)
|
||||
result[field] = "invalid"
|
||||
with patch.object(fleet, "resolve_node", return_value=self.remote_node()), \
|
||||
patch.object(broker, "request", return_value=result):
|
||||
final = self.execute(payload)
|
||||
self.assertFalse(final["ok"])
|
||||
self.assertIn("BINDING_INVALID", final["error"])
|
||||
|
||||
def test_offline_or_malformed_typed_payload_never_routes(self):
|
||||
payload = make_payload()
|
||||
for malformed in [dict(payload, script="echo not a screen RPC\n"),
|
||||
dict(payload, sha256="0" * 64)]:
|
||||
with self.subTest(malformed=malformed), patch.object(fleet, "resolve_node") as resolve:
|
||||
result = self.execute(malformed)
|
||||
self.assertFalse(result["ok"])
|
||||
resolve.assert_not_called()
|
||||
with patch.object(fleet, "resolve_node", return_value=dict(self.remote_node(), operational_transport_ready=False)), \
|
||||
patch.object(broker, "request") as request:
|
||||
result = self.execute(payload)
|
||||
self.assertEqual(result["error"], "NODE_OFFLINE")
|
||||
request.assert_not_called()
|
||||
|
||||
def test_generic_execute_remains_without_typed_marker(self):
|
||||
payload = make_payload({"op": "status"})
|
||||
calls = []
|
||||
def adapter(value):
|
||||
calls.append(value)
|
||||
self.assertNotIn("request_type", value)
|
||||
return response(value)
|
||||
with patch.object(fleet, "resolve_node", return_value=self.local_node()), \
|
||||
patch("framebridge_expanded.runtime.adapter_request", side_effect=adapter):
|
||||
result = fleet.execute_node("LOCAL", payload["script"], True, 1, payload["job_id"], payload["sha256"], True)
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertEqual(len(calls), 1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Run the actual viewer JS under GJS with a small DOM fixture.
|
||||
|
||||
This tests JS state and integrity handling, not browser decoding, CSP or ChatGPT UI.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
from framebridge_expanded.real_screen.screen_presentation import VIEWER_HTML
|
||||
from test_screen_frontdoor import png
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("gjs"), "GJS is not installed on this test host")
|
||||
class ScreenViewerJavaScriptTests(unittest.TestCase):
|
||||
def test_original_bytes_recovery_errors_and_inflight_teardown(self):
|
||||
data = png()
|
||||
import hashlib
|
||||
payload = {
|
||||
"structuredContent": {
|
||||
"ok": True, "node": "LOCAL", "hostname": "test-fixture",
|
||||
"result": {
|
||||
"capture_id": "1" * 32, "sha256": hashlib.sha256(data).hexdigest(),
|
||||
"bytes": len(data), "width": 100, "height": 40,
|
||||
"captured_at": "2026-10-24T12:00:00Z",
|
||||
},
|
||||
},
|
||||
"_meta": {"framebridgeImage": {"mimeType": "image/png", "data": base64.b64encode(data).decode()}},
|
||||
}
|
||||
script = VIEWER_HTML.split("<script>", 1)[1].split("</script>", 1)[0]
|
||||
bootstrap = r"""
|
||||
const GLib=imports.gi.GLib;
|
||||
const listeners={},elements={};
|
||||
class Element {
|
||||
constructor(){this.hidden=true;this.disabled=true;this.textContent="";this.attrs={};const classes=new Set();this.classList={add:x=>classes.add(x),remove:x=>classes.delete(x),contains:x=>classes.has(x)};}
|
||||
setAttribute(k,v){this.attrs[k]=v;} removeAttribute(k){delete this.attrs[k];if(k==="src"||k==="href")this[k]="";}
|
||||
async decode(){const b=GLib.base64_decode(this.src.split(",")[1]);const d=new DataView(b.buffer,b.byteOffset,b.byteLength);this.naturalWidth=d.getUint32(16);this.naturalHeight=d.getUint32(20);}
|
||||
}
|
||||
const fakeDocument={getElementById:id=>elements[id]||(elements[id]=new Element()),body:{scrollHeight:640}};
|
||||
const fakeWindow={parent:{postMessage:()=>{}},addEventListener:(name,fn)=>{listeners[name]=fn;}};
|
||||
globalThis.setTimeout=()=>0;
|
||||
globalThis.atob=value=>Array.from(GLib.base64_decode(value),x=>String.fromCharCode(x)).join("");
|
||||
async function realDigest(name,bytes){if(name!=="SHA-256")throw Error("wrong algorithm");const hex=GLib.compute_checksum_for_data(GLib.ChecksumType.SHA256,bytes);return Uint8Array.from(hex.match(/../g),x=>parseInt(x,16)).buffer;}
|
||||
globalThis.crypto={subtle:{digest:realDigest}};
|
||||
let checks=0;
|
||||
function check(v,m){if(!v)throw Error(m);checks++;}
|
||||
"""
|
||||
cases = r"""
|
||||
return (async()=>{
|
||||
const fixture=PAYLOAD,original="data:image/png;base64,"+fixture._meta.framebridgeImage.data;
|
||||
await render(fixture);
|
||||
check(img.src===original,"original bytes changed");check(!img.hidden&&!viewport.hidden,"valid image hidden");
|
||||
check(statusEl.textContent.includes("Original PNG verified"),"integrity success missing");
|
||||
check(details.textContent.includes(fixture.structuredContent.result.sha256),"hash not displayed");
|
||||
check(download.href===original,"download bytes changed");
|
||||
native.onclick();check(viewport.classList.contains("native"),"native size unavailable");
|
||||
fit.onclick();check(!viewport.classList.contains("native"),"fit unavailable");
|
||||
await render({structuredContent:{ok:false,error:"TEST_ERROR"}});
|
||||
check(img.hidden&&viewport.hidden,"error did not hide image");check(active===null,"error retained active capture");
|
||||
check(!img.src&&!download.href&&!details.textContent,"error retained stale image data");
|
||||
await render(fixture);check(!img.hidden&&img.src===original,"same original did not recover after error");
|
||||
const bad=JSON.parse(JSON.stringify(fixture));bad.structuredContent.result.sha256="0".repeat(64);
|
||||
await render(bad);check(img.hidden&&statusEl.textContent.includes("SHA-256 mismatch"),"corrupt digest not rejected");
|
||||
await render(fixture);check(!img.hidden,"valid image did not recover after bad digest");
|
||||
const slow=JSON.parse(JSON.stringify(fixture));slow.structuredContent.result.capture_id="2".repeat(32);
|
||||
let finish;crypto.subtle.digest=(...args)=>new Promise(resolve=>{finish=()=>realDigest(...args).then(resolve);});
|
||||
const inflight=render(slow);check(typeof finish==="function","digest did not start");
|
||||
listeners.message({source:window.parent,data:{jsonrpc:"2.0",method:"ui/notifications/teardown",params:{}}});
|
||||
await finish();await inflight;
|
||||
check(img.hidden&&viewport.hidden&&!img.src,"inflight render revived after teardown");
|
||||
check(fit.disabled&&native.disabled&&full.disabled,"teardown left controls enabled");
|
||||
return checks;
|
||||
})();
|
||||
"""
|
||||
executable = bootstrap + "\nconst run = function(window,document,crypto,atob,setTimeout){" + script + cases.replace("PAYLOAD", json.dumps(payload)) + "};\n"
|
||||
executable += 'const loop=new GLib.MainLoop(null,false);let failed=false;run(fakeWindow,fakeDocument,globalThis.crypto,globalThis.atob,globalThis.setTimeout).then(n=>{print("PASS "+n+" viewer state assertions");loop.quit();}).catch(e=>{failed=true;printerr(String(e.stack||e));loop.quit();});loop.run();if(failed)imports.system.exit(1);'
|
||||
result = subprocess.run([shutil.which("gjs"), "-c", executable],
|
||||
capture_output=True, text=True, timeout=8)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn("PASS 16 viewer state assertions", result.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Real TLS and certificate-bootstrap checks in isolated application profiles."""
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import ssl
|
||||
import stat
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from framebridge_expanded import broker, node, tls
|
||||
from framebridge_expanded.config import default_config, load_config, save_config
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class CertificateTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory(prefix="tls-test-", dir=ROOT / "qa")
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
|
||||
def profile(self, name, role="central"):
|
||||
state = self.root / name
|
||||
save_config(state, default_config(role, name))
|
||||
return state
|
||||
|
||||
def test_certificate_sans_fingerprint_private_modes_and_config(self):
|
||||
state = self.profile("cert-central")
|
||||
result = tls.create_certificates(state, "central.example.test", "192.0.2.10")
|
||||
cfg = load_config(state)
|
||||
x509, hashes, serialization, *_ = tls._crypto()
|
||||
ca = x509.load_pem_x509_certificate(Path(result["ca_public_file"]).read_bytes())
|
||||
cert = x509.load_pem_x509_certificate(Path(result["server_certificate_file"]).read_bytes())
|
||||
self.assertEqual(result["ca_fingerprint_sha256"], ca.fingerprint(hashes.SHA256()).hex())
|
||||
self.assertEqual(result["server_fingerprint_sha256"], cert.fingerprint(hashes.SHA256()).hex())
|
||||
san = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName).value
|
||||
self.assertEqual(set(san.get_values_for_type(x509.DNSName)), {"central.example.test", "localhost"})
|
||||
self.assertEqual(set(san.get_values_for_type(x509.IPAddress)), {
|
||||
ipaddress.ip_address("192.0.2.10"), ipaddress.ip_address("127.0.0.1"), ipaddress.ip_address("::1")})
|
||||
self.assertTrue(ca.extensions.get_extension_for_class(x509.BasicConstraints).value.ca)
|
||||
self.assertFalse(cert.extensions.get_extension_for_class(x509.BasicConstraints).value.ca)
|
||||
self.assertEqual(stat.S_IMODE((state / "tls").stat().st_mode), 0o700)
|
||||
for path in (state / "tls").iterdir():
|
||||
expected = 0o644 if path.name == "ca-public.pem" else 0o600
|
||||
self.assertEqual(stat.S_IMODE(path.stat().st_mode), expected, path.name)
|
||||
self.assertEqual(cfg["tls_ca"], result["ca_public_file"])
|
||||
self.assertEqual(cfg["broker_url"], "https://192.0.2.10:20878")
|
||||
self.assertEqual(cfg["broker_control_url"], "https://127.0.0.1:20878")
|
||||
self.assertEqual(cfg["broker_listen_host"], "127.0.0.1")
|
||||
self.assertFalse(result["private_key_values_exported"])
|
||||
self.assertFalse(result["openai_credentials_created_or_saved"])
|
||||
self.assertFalse(any("private.key" in key for key in result))
|
||||
|
||||
def test_existing_material_is_never_overwritten(self):
|
||||
state = self.profile("no-overwrite")
|
||||
tls.create_certificates(state, "central.test", "127.0.0.1")
|
||||
before = {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in (state / "tls").iterdir()}
|
||||
with self.assertRaises(FileExistsError):
|
||||
tls.create_certificates(state, "replacement.test", "127.0.0.1")
|
||||
after = {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in (state / "tls").iterdir()}
|
||||
self.assertEqual(before, after)
|
||||
|
||||
def test_invalid_or_active_profile_never_creates_material(self):
|
||||
standalone = self.profile("standalone", "standalone")
|
||||
with self.assertRaises(ValueError):
|
||||
tls.create_certificates(standalone, "central.test", "127.0.0.1")
|
||||
state = self.profile("active")
|
||||
with mock.patch("framebridge_expanded.runtime.status", return_value={"running": True}):
|
||||
with self.assertRaises(RuntimeError):
|
||||
tls.create_certificates(state, "central.test", "127.0.0.1")
|
||||
with self.assertRaises(ValueError):
|
||||
tls.create_certificates(state, "bad host", "127.0.0.1")
|
||||
with self.assertRaises(ValueError):
|
||||
tls.create_certificates(state, "central.test", "0.0.0.0")
|
||||
self.assertFalse((state / "tls").exists())
|
||||
|
||||
def test_real_https_node_enrollment_and_wrong_ca_rejection(self):
|
||||
# Override only with a local, assigned address to exercise an actual LAN route.
|
||||
address = os.environ.get("FRAMEBRIDGE_TLS_TEST_ADDRESS", "127.0.0.1")
|
||||
ipaddress.ip_address(address)
|
||||
with socket.socket() as probe:
|
||||
probe.bind(("0.0.0.0", 0))
|
||||
port = probe.getsockname()[1]
|
||||
central = self.profile("https-central")
|
||||
cfg = load_config(central)
|
||||
cfg["ports"]["broker"] = port
|
||||
if port in (cfg["ports"]["mcp"], cfg["ports"]["hub"]):
|
||||
self.skipTest("Ephemeral broker port overlaps a default service port.")
|
||||
save_config(central, cfg)
|
||||
public = tls.create_certificates(central, "central.test", address, listen_host="0.0.0.0")
|
||||
server = broker.make_server("0.0.0.0", port, central)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
joining = self.profile("https-node", "node")
|
||||
nc = load_config(joining)
|
||||
nc.update(broker_url=public["broker_url"], tls_ca=public["ca_public_file"])
|
||||
save_config(joining, nc)
|
||||
enrolled = node.enroll(public["broker_url"], "https-node", joining)
|
||||
broker.configure(state_dir=central)
|
||||
pending = broker.list_pending()
|
||||
entry = next(p for p in pending if p["node_id"] == enrolled["node_id"])
|
||||
self.assertEqual(entry["fingerprint"], enrolled["fingerprint"])
|
||||
approved = broker.approve(entry["request_id"], enrolled["node_id"], central)
|
||||
polled = node.poll_enrollment(joining)
|
||||
self.assertEqual(polled["state"], "paired")
|
||||
self.assertFalse(polled.get("broker_confirm_required", False))
|
||||
self.assertTrue(any(p["node_id"] == enrolled["node_id"] for p in broker.list_confirmed_nodes()))
|
||||
|
||||
wrong = self.profile("wrong-authority")
|
||||
wrong_public = tls.create_certificates(wrong, "other.test", address)
|
||||
rejected = self.profile("wrong-ca-node", "node")
|
||||
rc = load_config(rejected)
|
||||
rc.update(broker_url=public["broker_url"], tls_ca=wrong_public["ca_public_file"])
|
||||
save_config(rejected, rc)
|
||||
before = broker.list_pending()
|
||||
with self.assertRaises(Exception) as failure:
|
||||
node.enroll(public["broker_url"], "wrong-ca-node", rejected)
|
||||
# Check the verification error, rather than accepting any transport failure.
|
||||
chain = []
|
||||
error = failure.exception
|
||||
for _ in range(6):
|
||||
if error is None:
|
||||
break
|
||||
chain.append(str(error))
|
||||
error = error.__cause__ or error.__context__
|
||||
self.assertIn("CERTIFICATE_VERIFY_FAILED", " ".join(chain))
|
||||
self.assertEqual(before, broker.list_pending())
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=5)
|
||||
self.assertFalse(thread.is_alive())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,297 @@
|
||||
"""Real MCP transport tests with real local Bash consoles and a bounded executor boundary.
|
||||
|
||||
The TEST_NODE executor runs on this QA host in another private profile; this
|
||||
tests remote RPC framing, not a second machine. Actual multi-node VM coverage is separate.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
|
||||
WORK = Path(__file__).resolve().parents[1]
|
||||
VENDOR = WORK / "sources/original-geranas/FrameBridge-Fleet-Hub/vendor"
|
||||
sys.path.insert(0, str(WORK / "expanded"))
|
||||
sys.path.insert(0, str(VENDOR))
|
||||
os.environ["PYTHONPATH"] = str(WORK / "expanded") + os.pathsep + str(VENDOR)
|
||||
os.environ["FRAMEBRIDGE_EXPANDED_PREFIX"] = str(WORK)
|
||||
from mcp import ClientSession
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
from framebridge_expanded import tty
|
||||
from framebridge_expanded.tty_mcp import TOOL_NAMES, _remote_request, register_tools
|
||||
from unittest import mock
|
||||
from framebridge_expanded.real_screen.screen_frontdoor import ScreenClient
|
||||
|
||||
|
||||
SERVER = r'''
|
||||
from pathlib import Path
|
||||
import json,os,subprocess
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from framebridge_expanded.tty_mcp import register_tools
|
||||
port=int(os.environ["QA_TTY_MCP_PORT"])
|
||||
mcp=FastMCP("Real CUA-TTY QA",host="127.0.0.1",port=port,streamable_http_path="/mcp")
|
||||
def execute(script,authorized,timeout_s,job_id,sha256,include_stdout,target):
|
||||
if authorized is not True or target!="TEST_NODE":
|
||||
return {"ok":False,"error":"QA_TARGET_INVALID"}
|
||||
env=os.environ.copy()
|
||||
env["FRAMEBRIDGE_EXPANDED_STATE"]=env["QA_TTY_REMOTE_STATE"]
|
||||
p=subprocess.run(["/bin/bash","--noprofile","--norc","-c",script],env=env,
|
||||
stdin=subprocess.DEVNULL,capture_output=True,text=True,timeout=timeout_s)
|
||||
with open(os.environ["QA_TTY_CALL_LOG"],"a") as log:
|
||||
log.write(json.dumps({"read":" framebridge_expanded.tty read " in (" "+script+" "),
|
||||
"stdout_bytes":len(p.stdout.encode())})+"\n")
|
||||
return {"ok":True,"stdout":p.stdout[:4096],"stdout_truncated":len(p.stdout)>4096,
|
||||
"exit_code":str(p.returncode)}
|
||||
class ScreenFixture:
|
||||
def call(self, op, target, authorized, **arguments):
|
||||
return {"ok":True,"test_fixture":"screen-delegation-only",
|
||||
"op":op,"target":target,"arguments":arguments}
|
||||
register_tools(mcp,execute,screen_client=ScreenFixture())
|
||||
mcp.run(transport="streamable-http")
|
||||
'''
|
||||
|
||||
|
||||
class RealMcpTtyTests(unittest.IsolatedAsyncioTestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.root = Path(tempfile.mkdtemp(prefix="tty-mcp-", dir=WORK / "qa"))
|
||||
cls.local = cls.root / "local"
|
||||
cls.remote = cls.root / "remote"
|
||||
cls.local.mkdir(mode=0o700)
|
||||
cls.remote.mkdir(mode=0o700)
|
||||
assert tty.start(cls.local).get("ok")
|
||||
assert tty.start(cls.remote).get("ok")
|
||||
with socket.socket() as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
cls.port = sock.getsockname()[1]
|
||||
child = cls.root / "server.py"
|
||||
child.write_text(SERVER)
|
||||
env = os.environ.copy()
|
||||
env["PYTHONPATH"] = str(WORK / "expanded") + os.pathsep + str(VENDOR)
|
||||
env.pop("DISPLAY", None)
|
||||
env.pop("XDG_SESSION_TYPE", None)
|
||||
env["FRAMEBRIDGE_EXPANDED_STATE"] = str(cls.local)
|
||||
env["QA_TTY_REMOTE_STATE"] = str(cls.remote)
|
||||
env["QA_TTY_MCP_PORT"] = str(cls.port)
|
||||
cls.call_log = cls.root / "executor-calls.jsonl"
|
||||
env["QA_TTY_CALL_LOG"] = str(cls.call_log)
|
||||
log = (cls.root / "mcp-server.log").open("ab")
|
||||
cls.server = subprocess.Popen([sys.executable, str(child)], env=env,
|
||||
stdin=subprocess.DEVNULL, stdout=log,
|
||||
stderr=subprocess.STDOUT)
|
||||
log.close()
|
||||
ready = False
|
||||
deadline = time.monotonic() + 8
|
||||
while cls.server.poll() is None and time.monotonic() < deadline:
|
||||
with socket.socket() as sock:
|
||||
if sock.connect_ex(("127.0.0.1", cls.port)) == 0:
|
||||
ready = True
|
||||
break
|
||||
time.sleep(0.05)
|
||||
if not ready:
|
||||
tty.stop(cls.local)
|
||||
tty.stop(cls.remote)
|
||||
cls.server.wait(timeout=2)
|
||||
raise AssertionError("Private MCP server failed to start")
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.server.terminate()
|
||||
try:
|
||||
cls.server.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
cls.server.kill()
|
||||
cls.server.wait(timeout=2)
|
||||
assert tty.stop(cls.local).get("ok")
|
||||
assert tty.stop(cls.remote).get("ok")
|
||||
|
||||
async def call(self, session, name, args):
|
||||
result = await session.call_tool(name, args)
|
||||
self.assertFalse(result.isError)
|
||||
value = getattr(result, "structuredContent", None)
|
||||
if not isinstance(value, dict):
|
||||
value = next(json.loads(c.text) for c in result.content if c.type == "text")
|
||||
return value
|
||||
|
||||
def remote_request(self, payload):
|
||||
def execute(script, authorized, timeout_s, job_id, sha256, include_stdout, target):
|
||||
if authorized is not True or target != "TEST_NODE":
|
||||
return {"ok": False, "error": "QA_TARGET_INVALID"}
|
||||
env = os.environ.copy()
|
||||
env["FRAMEBRIDGE_EXPANDED_STATE"] = str(self.remote)
|
||||
process = subprocess.run(["/bin/bash", "--noprofile", "--norc", "-c", script],
|
||||
env=env, stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=timeout_s)
|
||||
with self.call_log.open("a") as log:
|
||||
log.write(json.dumps({"read": " framebridge_expanded.tty read " in (" " + script + " "),
|
||||
"stdout_bytes": len(process.stdout.encode())}) + "\n")
|
||||
return {"ok": True, "stdout": process.stdout[:4096],
|
||||
"stdout_truncated": len(process.stdout) > 4096, "exit_code": str(process.returncode)}
|
||||
return _remote_request(execute, "TEST_NODE", payload)
|
||||
|
||||
async def test_lifecycle_over_mcp_and_persistent_bash_over_raw_rpc(self):
|
||||
async with streamablehttp_client("http://127.0.0.1:" + str(self.port) + "/mcp") as streams:
|
||||
async with ClientSession(streams[0], streams[1]) as session:
|
||||
await session.initialize()
|
||||
listing = await session.list_tools()
|
||||
self.assertEqual(sorted(t.name for t in listing.tools), sorted(TOOL_NAMES))
|
||||
denied = await self.call(session, "cua_tty_open", {"authorized": False})
|
||||
self.assertEqual(denied["error"], "AUTHORIZED_TRUE_REQUIRED")
|
||||
status = await self.call(session, "cua_tty_status", {"authorized": True})
|
||||
self.assertTrue(status["ok"])
|
||||
opened = await self.call(session, "cua_tty_open", {"authorized": True})
|
||||
self.assertTrue(opened["ok"])
|
||||
sid = opened["result"]["session_id"]
|
||||
try:
|
||||
marker = self.local / "mcp-local-marker.txt"
|
||||
command = "printf local_mcp_real > " + shlex.quote(str(marker))
|
||||
typed = tty.request(self.local, {"op": "text.type", "authorized": True,
|
||||
"session_id": sid, "text": command})
|
||||
self.assertTrue(typed["ok"])
|
||||
keyed = tty.request(self.local, {"op": "key.send", "authorized": True,
|
||||
"session_id": sid, "key": "ENTER"})
|
||||
self.assertTrue(keyed["ok"])
|
||||
deadline = time.monotonic() + 3
|
||||
while not marker.exists() and time.monotonic() < deadline:
|
||||
await asyncio.sleep(0.05)
|
||||
self.assertEqual(marker.read_text(), "local_mcp_real")
|
||||
resized = await self.call(session, "cua_tty_resize",
|
||||
{"authorized": True, "session_id": sid, "rows": 33, "cols": 120})
|
||||
self.assertTrue(resized["ok"])
|
||||
observed = await self.call(session, "cua_tty_observe",
|
||||
{"authorized": True, "session_id": sid})
|
||||
self.assertEqual(observed["test_fixture"], "screen-delegation-only")
|
||||
self.assertEqual(observed["op"], "observe")
|
||||
self.assertEqual(observed["arguments"]["session_id"], sid)
|
||||
transcript = tty.request(self.local, {"op": "observe", "authorized": True, "session_id": sid})
|
||||
self.assertTrue(transcript["result"]["real_terminal"])
|
||||
self.assertEqual(transcript["result"]["screen"]["rows"], 33)
|
||||
finally:
|
||||
closed = await self.call(session, "cua_tty_close",
|
||||
{"authorized": True, "session_id": sid})
|
||||
self.assertTrue(closed["result"]["cleanup_verified"])
|
||||
|
||||
async def test_remote_executor_framing_preserves_screen_larger_than_stdout_limit(self):
|
||||
async with streamablehttp_client("http://127.0.0.1:" + str(self.port) + "/mcp") as streams:
|
||||
async with ClientSession(streams[0], streams[1]) as session:
|
||||
await session.initialize()
|
||||
opened = await self.call(session, "cua_tty_open",
|
||||
{"authorized": True, "target": "TEST_NODE", "rows": 32, "cols": 160})
|
||||
self.assertTrue(opened["ok"], opened)
|
||||
sid = opened["result"]["session_id"]
|
||||
try:
|
||||
marker = self.remote / "random-screen-done.txt"
|
||||
code = ("import random,string; from pathlib import Path; r=random.Random(23); "
|
||||
"[print(''.join(r.choice(string.ascii_letters+string.digits) for _ in range(150))) "
|
||||
"for _ in range(28)]; Path(" + repr(str(marker)) + ").write_text('done')")
|
||||
command = "python3 -c " + shlex.quote(code)
|
||||
self.assertTrue(self.remote_request(
|
||||
{"op": "text.type", "authorized": True, "session_id": sid, "text": command})["ok"])
|
||||
self.assertTrue(self.remote_request(
|
||||
{"op": "key.send", "authorized": True, "session_id": sid, "key": "ENTER"})["ok"])
|
||||
deadline = time.monotonic() + 3
|
||||
while not marker.exists() and time.monotonic() < deadline:
|
||||
await asyncio.sleep(0.05)
|
||||
self.assertEqual(marker.read_text(), "done")
|
||||
# Raw observe is a terminal text transcript. Physical MCP observe
|
||||
# is separately delegated to ScreenClient and never used here.
|
||||
value = self.remote_request({"op": "observe", "authorized": True, "session_id": sid})
|
||||
self.assertTrue(value["ok"], value)
|
||||
self.assertTrue(value["result"]["real_terminal"])
|
||||
self.assertGreater(len(json.dumps(value)), 4096)
|
||||
records = [json.loads(line) for line in self.call_log.read_text().splitlines()]
|
||||
self.assertTrue(any(r["read"] for r in records))
|
||||
self.assertTrue(all(r["stdout_bytes"] < 4096 for r in records))
|
||||
finally:
|
||||
closed = await self.call(session, "cua_tty_close",
|
||||
{"authorized": True, "target": "TEST_NODE", "session_id": sid})
|
||||
self.assertTrue(closed["result"]["cleanup_verified"])
|
||||
|
||||
|
||||
class FixtureMcp:
|
||||
def __init__(self):
|
||||
self.tools = {}
|
||||
|
||||
def tool(self, **_kwargs):
|
||||
def register(function):
|
||||
self.tools[function.__name__] = function
|
||||
return function
|
||||
return register
|
||||
|
||||
class McpScreenBindingTests(unittest.TestCase):
|
||||
SID = "a" * 32
|
||||
CAPTURE = "b" * 32
|
||||
|
||||
def tools(self, error="SESSION_NOT_VISIBLE", *, wrong_target=False):
|
||||
calls = []
|
||||
def execute(**kwargs):
|
||||
calls.append(kwargs)
|
||||
return {"ok": True, "exit_code": "0", "stdout_truncated": False,
|
||||
"target": "different-node" if wrong_target else kwargs["target"],
|
||||
"node_id": "12345678-1234-1234-1234-123456789abc",
|
||||
"mcp_job_id": kwargs["job_id"], "mcp_script_sha256": kwargs["sha256"],
|
||||
"stdout": json.dumps({"ok": False, "error": error})}
|
||||
client = ScreenClient(execute, image_factory=lambda **kwargs: kwargs, resolve_node=lambda node: node)
|
||||
mcp = FixtureMcp()
|
||||
register_tools(mcp, execute, screen_client=client)
|
||||
return mcp.tools, calls
|
||||
|
||||
def test_seven_existing_names_remain_registered(self):
|
||||
tools, _calls = self.tools()
|
||||
self.assertEqual(set(tools), set(TOOL_NAMES))
|
||||
|
||||
def test_missing_capture_rejected_before_any_backend_execution(self):
|
||||
tools, calls = self.tools()
|
||||
with mock.patch.object(tty, "request") as raw:
|
||||
value = tools["cua_tty_key_send"](self.SID, "ENTER", True, "NodeCase")
|
||||
self.assertEqual(value["error"], "SCREEN_CAPTURE_ID_REQUIRED")
|
||||
self.assertEqual(calls, [])
|
||||
raw.assert_not_called()
|
||||
|
||||
def test_unapproved_screen_observation_does_not_reach_backend(self):
|
||||
tools, calls = self.tools()
|
||||
value = tools["cua_tty_observe"](self.SID, False, "NodeCase")
|
||||
self.assertEqual(value["error"], "AUTHORIZED_TRUE_REQUIRED")
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_hidden_session_error_has_no_raw_pty_fallback(self):
|
||||
tools, calls = self.tools()
|
||||
with mock.patch.object(tty, "request") as raw:
|
||||
value = tools["cua_tty_text_type"](self.SID, "not executed", True, "NodeCase", self.CAPTURE)
|
||||
self.assertEqual(value["error"], "SESSION_NOT_VISIBLE")
|
||||
self.assertEqual(calls[0]["target"], "NodeCase")
|
||||
raw.assert_not_called()
|
||||
|
||||
def test_mismatched_executor_target_cannot_satisfy_screen_request(self):
|
||||
tools, _calls = self.tools(wrong_target=True)
|
||||
value = tools["cua_tty_key_send"](self.SID, "ENTER", True, "NodeCase", self.CAPTURE)
|
||||
self.assertEqual(value["error"], "SCREEN_EXEC_BINDING_INVALID")
|
||||
|
||||
def test_graphical_input_rejection_is_preserved_without_hidden_fallback(self):
|
||||
tools, _calls = self.tools(error="USE_GRAPHICAL_CUA")
|
||||
with mock.patch.object(tty, "request") as raw:
|
||||
value = tools["cua_tty_key_send"](self.SID, "ENTER", True, "NodeCase", self.CAPTURE)
|
||||
self.assertEqual(value["error"], "USE_GRAPHICAL_CUA")
|
||||
raw.assert_not_called()
|
||||
|
||||
def test_observe_delegation_preserves_requested_session_and_node(self):
|
||||
record = []
|
||||
class ScreenFixture:
|
||||
def call(self, *args, **kwargs):
|
||||
record.append((args, kwargs))
|
||||
return {"test_fixture": "delegation-only"}
|
||||
mcp = FixtureMcp()
|
||||
register_tools(mcp, lambda **_kw: self.fail("raw execute is not the observe path"),
|
||||
screen_client=ScreenFixture())
|
||||
value = mcp.tools["cua_tty_observe"](self.SID, True, "NodeCase")
|
||||
self.assertEqual(value, {"test_fixture": "delegation-only"})
|
||||
self.assertEqual(record, [(("observe", "NodeCase", True), {"session_id": self.SID})])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Real headless Bash/tmux integration tests; only test-owned profiles are closed."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
|
||||
WORK = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(WORK / "expanded"))
|
||||
os.environ["PYTHONPATH"] = str(WORK / "expanded")
|
||||
os.environ["FRAMEBRIDGE_EXPANDED_PREFIX"] = str(WORK)
|
||||
from framebridge_expanded import tty
|
||||
|
||||
|
||||
class RealTtyTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.state = Path(tempfile.mkdtemp(prefix="tty-test-", dir=WORK / "qa"))
|
||||
self.sessions = []
|
||||
self.previous_display = os.environ.pop("DISPLAY", None)
|
||||
self.previous_x11 = os.environ.pop("XDG_SESSION_TYPE", None)
|
||||
self.assertTrue(tty.start(self.state).get("ok"))
|
||||
|
||||
def tearDown(self):
|
||||
for sid in self.sessions:
|
||||
tty.request(self.state, {"op": "close", "authorized": True, "session_id": sid})
|
||||
self.assertTrue(tty.stop(self.state).get("ok"))
|
||||
self.assertFalse((tty.runtime_path(self.state) / "agent.sock").exists())
|
||||
if self.previous_display is not None:
|
||||
os.environ["DISPLAY"] = self.previous_display
|
||||
if self.previous_x11 is not None:
|
||||
os.environ["XDG_SESSION_TYPE"] = self.previous_x11
|
||||
|
||||
def call(self, op, **args):
|
||||
return tty.request(self.state, {"op": op, "authorized": True, **args})
|
||||
|
||||
def open(self, **args):
|
||||
value = self.call("open", **args)
|
||||
self.assertTrue(value.get("ok"), value)
|
||||
self.sessions.append(value["result"]["session_id"])
|
||||
return value["result"]
|
||||
|
||||
def enter(self, sid, text):
|
||||
self.assertTrue(self.call("text.type", session_id=sid, text=text).get("ok"))
|
||||
self.assertTrue(self.call("key.send", session_id=sid, key="ENTER").get("ok"))
|
||||
|
||||
def await_file(self, path):
|
||||
deadline = time.monotonic() + 3
|
||||
while not path.exists() and time.monotonic() < deadline:
|
||||
time.sleep(0.05)
|
||||
self.assertTrue(path.is_file(), str(path))
|
||||
return path.read_text()
|
||||
|
||||
def test_real_persistent_bash_and_unicode_without_x11(self):
|
||||
self.assertNotIn("DISPLAY", os.environ)
|
||||
opened = self.open(rows=30, cols=100)
|
||||
self.assertTrue(opened["real_terminal"])
|
||||
self.assertEqual(opened["backend"], "tmux")
|
||||
self.assertTrue(Path(opened["tty"]).exists())
|
||||
self.assertGreater(opened["pid"], 1)
|
||||
sid = opened["session_id"]
|
||||
directory = self.state / "working-directory"
|
||||
directory.mkdir()
|
||||
self.enter(sid, "export FRAMEBRIDGE_TTY_VALUE=" + shlex.quote("persistent ñ 漢字")
|
||||
+ "; cd " + shlex.quote(str(directory)))
|
||||
marker = self.state / "unicode-and-state.txt"
|
||||
self.enter(sid, 'printf %s "$FRAMEBRIDGE_TTY_VALUE" > ' + shlex.quote(str(marker)))
|
||||
self.assertEqual(self.await_file(marker), "persistent ñ 漢字")
|
||||
observed = self.call("observe", session_id=sid)
|
||||
self.assertTrue(observed["ok"])
|
||||
self.assertEqual(observed["result"]["pid"], opened["pid"])
|
||||
self.assertEqual(observed["result"]["cwd"], str(directory))
|
||||
self.assertGreaterEqual(observed["result"]["sequence"], 4)
|
||||
|
||||
def test_control_c_interrupts_a_real_foreground_process(self):
|
||||
sid = self.open()["session_id"]
|
||||
self.enter(sid, "sleep 30")
|
||||
deadline = time.monotonic() + 2
|
||||
sleeping = False
|
||||
while time.monotonic() < deadline:
|
||||
value = self.call("observe", session_id=sid)
|
||||
if value["result"]["current_command"] == "sleep":
|
||||
sleeping = True
|
||||
break
|
||||
time.sleep(0.05)
|
||||
self.assertTrue(sleeping)
|
||||
self.assertTrue(self.call("key.send", session_id=sid, key="CTRL_C")["ok"])
|
||||
marker = self.state / "interrupted.txt"
|
||||
self.enter(sid, "printf interrupted > " + shlex.quote(str(marker)))
|
||||
self.assertEqual(self.await_file(marker), "interrupted")
|
||||
|
||||
def test_real_resize_can_exceed_the_old_machine_specific_bounds(self):
|
||||
sid = self.open()["session_id"]
|
||||
self.assertTrue(self.call("resize", session_id=sid, rows=110, cols=240)["ok"])
|
||||
observed = self.call("observe", session_id=sid)["result"]
|
||||
self.assertEqual((observed["screen"]["rows"], observed["screen"]["cols"]), (110, 240))
|
||||
|
||||
def test_authorization_and_socket_identity_are_explicit(self):
|
||||
before = self.call("status")["result"]["active_sessions"]
|
||||
denied = tty.request(self.state, {"op": "open", "authorized": False})
|
||||
self.assertEqual(denied.get("error"), "AUTHORIZED_TRUE_REQUIRED")
|
||||
denied = tty.request(self.state, {"op": "open", "authorized": 1})
|
||||
self.assertEqual(denied.get("error"), "AUTHORIZED_TRUE_REQUIRED")
|
||||
self.assertEqual(self.call("status")["result"]["active_sessions"], before)
|
||||
path = tty.runtime_path(self.state) / "agent.sock"
|
||||
st = path.stat()
|
||||
self.assertTrue(stat.S_ISSOCK(st.st_mode))
|
||||
self.assertEqual(stat.S_IMODE(st.st_mode), 0o600)
|
||||
self.assertEqual(st.st_uid, os.geteuid())
|
||||
status = self.call("status")["result"]
|
||||
self.assertEqual(status["input_policy"], "same-uid-authorized")
|
||||
self.assertNotIn("owner_device_connected", status)
|
||||
self.assertNotIn("owner_signature_required", status)
|
||||
|
||||
def test_errors_do_not_expose_arbitrary_payloads_or_paths(self):
|
||||
self.assertEqual(self.call("open", program="demo-readonly").get("error"),
|
||||
"PROGRAM_NOT_SUPPORTED")
|
||||
self.assertEqual(self.call("observe", session_id="../../outside").get("error"),
|
||||
"SESSION_ID_INVALID")
|
||||
sid = self.open()["session_id"]
|
||||
self.assertEqual(self.call("key.send", session_id=sid, key="INVALID_PRIVATE_TEXT").get("error"),
|
||||
"KEY_NOT_ALLOWLISTED")
|
||||
closed = self.call("close", session_id=sid)
|
||||
self.assertTrue(closed["result"]["cleanup_verified"])
|
||||
self.sessions.remove(sid)
|
||||
self.assertEqual(self.call("observe", session_id=sid).get("error"), "SESSION_NOT_FOUND")
|
||||
|
||||
def test_configurable_session_limit_replaces_the_old_constructor_cap(self):
|
||||
self.assertTrue(tty.stop(self.state)["ok"])
|
||||
(self.state / "tty.json").write_text(json.dumps({"limits": {"max_sessions": 5}}))
|
||||
self.assertTrue(tty.start(self.state)["ok"])
|
||||
for _ in range(5):
|
||||
self.open(rows=10, cols=40)
|
||||
self.assertEqual(self.call("status")["result"]["active_sessions"], 5)
|
||||
self.assertEqual(self.call("open").get("error"), "SESSION_LIMIT")
|
||||
|
||||
def test_configured_input_limit_is_reported_and_enforced(self):
|
||||
self.assertTrue(tty.stop(self.state)["ok"])
|
||||
(self.state / "tty.json").write_text(json.dumps({"limits": {"max_text_bytes": 100}}))
|
||||
self.assertTrue(tty.start(self.state)["ok"])
|
||||
sid = self.open()["session_id"]
|
||||
self.assertEqual(self.call("status")["result"]["limits"]["max_text_bytes"], 100)
|
||||
self.assertEqual(self.call("text.type", session_id=sid, text="x" * 101).get("error"),
|
||||
"TEXT_INVALID_OR_TOO_LARGE")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,372 @@
|
||||
"""Owned transient-unit tests; never invokes sudo or changes system policy."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from unittest import mock
|
||||
import uuid
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "expanded"))
|
||||
os.environ["FRAMEBRIDGE_EXPANDED_PREFIX"] = str(ROOT)
|
||||
from framebridge_expanded import tty_sudo_control as control
|
||||
|
||||
|
||||
class SudoControlTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
runtime = Path("/run/user") / str(os.getuid())
|
||||
self.temp = tempfile.TemporaryDirectory(prefix="fb-sudo-qa-", dir=runtime)
|
||||
self.base = Path(self.temp.name)
|
||||
self.controller = control.SudoConsoleController(self.base, timeout=5)
|
||||
self.records = []
|
||||
self.addCleanup(self.cleanup)
|
||||
|
||||
def cleanup(self):
|
||||
for record in reversed(self.records):
|
||||
try:
|
||||
self.controller.close(record)
|
||||
except control.SudoControlError as error:
|
||||
if error.code not in {"TTY_SUDO_BACKEND_FAILED", "TTY_SUDO_PROCESS_NOT_FOUND",
|
||||
"TTY_SUDO_CONSOLE_EXITED", "TTY_SUDO_UNIT_NOT_RUNNING"}:
|
||||
raise
|
||||
self.temp.cleanup()
|
||||
|
||||
def launch(self, **kwargs):
|
||||
record = self.controller.launch(uuid.uuid4().hex, **kwargs)
|
||||
self.records.append(record)
|
||||
return record
|
||||
|
||||
def wait(self, record, predicate, seconds=2):
|
||||
deadline = time.monotonic() + seconds
|
||||
while time.monotonic() < deadline:
|
||||
value = self.controller.status(record)
|
||||
if predicate(value):
|
||||
return value
|
||||
time.sleep(0.03)
|
||||
self.fail("Expected controlled console state was not reached.")
|
||||
|
||||
def send(self, record, command):
|
||||
self.controller._tmux(record, "send-keys", "-t", record["pane"], "-l", command)
|
||||
self.controller._tmux(record, "send-keys", "-t", record["pane"], "Enter")
|
||||
|
||||
def test_roundtrip_real_nnp_and_recreation_preserves_cwd_size(self):
|
||||
cwd = self.base / "directory"
|
||||
cwd.mkdir()
|
||||
off = self.launch(cwd=cwd, rows=31, cols=97)
|
||||
self.assertEqual((off["no_new_privs"], off["server_no_new_privs"]), (1, 1))
|
||||
self.assertTrue(off["prompt_verified"])
|
||||
on = self.controller.set_mode(off, "on")
|
||||
self.assertEqual((on["no_new_privs"], on["server_no_new_privs"]), (0, 0))
|
||||
self.assertNotEqual(on["pid"], off["pid"])
|
||||
self.assertEqual((on["cwd"], on["rows"], on["cols"]), (str(cwd), 31, 97))
|
||||
self.assertIsNone(control._proc(off["pid"]))
|
||||
blocked = self.controller.set_mode(on, "off")
|
||||
self.assertEqual((blocked["no_new_privs"], blocked["server_no_new_privs"]), (1, 1))
|
||||
self.assertNotEqual(blocked["pid"], on["pid"])
|
||||
self.assertFalse(blocked["sudo_authorization_granted"])
|
||||
self.assertEqual(blocked["sudo_authentication"], "system_policy")
|
||||
|
||||
def test_same_mode_is_idempotent_for_managed_console(self):
|
||||
record = self.launch()
|
||||
same = self.controller.set_mode(record, "off")
|
||||
self.assertFalse(same["recreated"])
|
||||
self.assertEqual(record["pid"], same["pid"])
|
||||
|
||||
def test_builtin_read_is_busy_without_a_child_process(self):
|
||||
record = self.launch()
|
||||
self.send(record, "read -r __fb_public_test")
|
||||
busy = self.wait(record, lambda v: v["busy"] and not v["prompt_verified"])
|
||||
self.assertFalse(busy["busy_pids"])
|
||||
with self.assertRaises(control.SudoControlError) as error:
|
||||
self.controller.set_mode(record, "on")
|
||||
self.assertEqual(error.exception.code, "TTY_SUDO_PROMPT_UNVERIFIED")
|
||||
self.controller._tmux(record, "send-keys", "-t", record["pane"], "C-c")
|
||||
self.wait(record, lambda v: not v["busy"])
|
||||
|
||||
def test_background_job_blocks_transition(self):
|
||||
record = self.launch()
|
||||
self.send(record, "sleep 30 &")
|
||||
self.wait(record, lambda v: bool(v["busy_pids"]))
|
||||
with self.assertRaises(control.SudoControlError) as error:
|
||||
self.controller.set_mode(record, "on")
|
||||
self.assertEqual(error.exception.code, "TTY_SUDO_CONSOLE_BUSY")
|
||||
|
||||
def test_reparented_setsid_disowned_job_is_found_by_cgroup(self):
|
||||
record = self.launch()
|
||||
self.send(record, "setsid -f /bin/sleep 30 </dev/null >/dev/null 2>&1 & disown")
|
||||
busy = self.wait(record, lambda v: bool(v["busy_pids"]))
|
||||
processes = control._processes()
|
||||
descendants = control._descendants(processes, record["pid"])
|
||||
self.assertTrue(set(busy["busy_pids"]) - descendants)
|
||||
with self.assertRaises(control.SudoControlError):
|
||||
self.controller.set_mode(record, "on")
|
||||
|
||||
def test_stopped_job_blocks_transition(self):
|
||||
record = self.launch()
|
||||
self.send(record, "sleep 30")
|
||||
self.wait(record, lambda v: bool(v["busy_pids"]))
|
||||
self.controller._tmux(record, "send-keys", "-t", record["pane"], "C-z")
|
||||
busy = self.wait(record, lambda v: any(
|
||||
control._proc(p) and control._proc(p)["state"] == "T" for p in v["busy_pids"]))
|
||||
with self.assertRaises(control.SudoControlError):
|
||||
self.controller.set_mode(record, "on")
|
||||
self.assertTrue(busy["busy"])
|
||||
|
||||
def test_candidate_is_rolled_back_if_original_becomes_busy(self):
|
||||
record = self.launch()
|
||||
original = self.controller._idle
|
||||
calls = 0
|
||||
def idle(value):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 2:
|
||||
raise control.SudoControlError("TTY_SUDO_CONSOLE_BUSY")
|
||||
return original(value)
|
||||
with mock.patch.object(self.controller, "_idle", side_effect=idle):
|
||||
with self.assertRaises(control.SudoControlError):
|
||||
self.controller.set_mode(record, "on")
|
||||
current = self.controller.status(record)
|
||||
self.assertEqual((current["pid"], current["mode"]), (record["pid"], "off"))
|
||||
self.assertEqual(len(list((self.base / "sudo").glob("*.sock"))), 1)
|
||||
|
||||
def test_postkill_cleanup_failure_keeps_verified_replacement(self):
|
||||
record = self.launch()
|
||||
original = self.controller._close
|
||||
def close(value, require_idle=False):
|
||||
result = original(value, require_idle=require_idle)
|
||||
if value["pid"] == record["pid"]:
|
||||
raise control.SudoControlError("TTY_SUDO_BACKEND_TIMEOUT")
|
||||
return result
|
||||
with mock.patch.object(self.controller, "_close", side_effect=close):
|
||||
replacement = self.controller.set_mode(record, "on")
|
||||
self.assertTrue(replacement["cleanup_pending"])
|
||||
self.assertFalse(replacement["transition_complete"])
|
||||
self.assertEqual(replacement["cleanup_error"], "TTY_SUDO_BACKEND_TIMEOUT")
|
||||
self.assertIsNone(control._proc(record["pid"]))
|
||||
self.assertIsNotNone(control._proc(replacement["pid"]))
|
||||
self.assertEqual(replacement["no_new_privs"], 0)
|
||||
with mock.patch.object(self.controller, "_old_cleanup_complete", return_value=False):
|
||||
state = self.controller.status(record)
|
||||
self.assertTrue(state["cleanup_pending"])
|
||||
self.assertEqual(state["pid"], replacement["pid"])
|
||||
with self.assertRaises(control.SudoControlError) as error:
|
||||
self.controller.set_mode(record, "off")
|
||||
self.assertEqual(error.exception.code, "TTY_SUDO_TRANSITION_CLEANUP_PENDING")
|
||||
with self.assertRaises(control.SudoControlError):
|
||||
self.controller.close(record)
|
||||
state = self.controller.status(record)
|
||||
self.assertFalse(state["cleanup_pending"])
|
||||
self.assertTrue(state["transition_complete"])
|
||||
self.assertNotIn("pending_old_record", state)
|
||||
|
||||
def test_legacy_status_does_not_fake_prompt_verification(self):
|
||||
record = self.launch()
|
||||
legacy = {key: record[key] for key in ("name", "pane")}
|
||||
legacy.update(session_id=uuid.uuid4().hex, legacy_socket=record["tmux_socket"])
|
||||
state = self.controller.status(legacy)
|
||||
self.assertEqual(state["no_new_privs"], 1)
|
||||
self.assertFalse(state["prompt_verified"])
|
||||
with self.assertRaises(control.SudoControlError) as error:
|
||||
self.controller.set_mode(legacy, "on")
|
||||
self.assertEqual(error.exception.code, "TTY_SUDO_PROMPT_UNVERIFIED")
|
||||
|
||||
def test_only_own_unit_names_are_accepted(self):
|
||||
with self.assertRaises(control.SudoControlError) as error:
|
||||
self.controller._unit("unrelated-existing.service")
|
||||
self.assertEqual(error.exception.code, "TTY_SUDO_UNIT_NOT_OWNED")
|
||||
|
||||
def test_pid_birth_change_fails_closed(self):
|
||||
record = self.launch()
|
||||
self.controller._records[record["session_id"]]["birth"] = "not-the-birth"
|
||||
try:
|
||||
with self.assertRaises(control.SudoControlError) as error:
|
||||
self.controller.status(record)
|
||||
self.assertEqual(error.exception.code, "TTY_SUDO_PROCESS_IDENTITY_CHANGED")
|
||||
finally:
|
||||
self.controller._records[record["session_id"]]["birth"] = record["birth"]
|
||||
|
||||
def test_elevated_process_metadata_refuses_change_and_close(self):
|
||||
record = self.launch()
|
||||
original = control._processes
|
||||
def processes():
|
||||
data = original()
|
||||
data[987654321] = {"pid": 987654321, "ppid": record["pid"],
|
||||
"sid": 987654321, "tty": 0, "uids": (0, 0, 0, 0)}
|
||||
return data
|
||||
with mock.patch.object(control, "_processes", side_effect=processes):
|
||||
with self.assertRaises(control.SudoControlError) as error:
|
||||
self.controller.set_mode(record, "on")
|
||||
self.assertEqual(error.exception.code, "TTY_SUDO_ELEVATED_PROCESS_PRESENT")
|
||||
with self.assertRaises(control.SudoControlError):
|
||||
self.controller.close(record)
|
||||
|
||||
def test_marker_contains_only_generation_and_state(self):
|
||||
record = self.launch()
|
||||
self.send(record, "printf '%s\\n' PUBLIC_INPUT_MARKER")
|
||||
self.wait(record, lambda v: not v["busy"])
|
||||
value = Path(record["prompt_marker"]).read_text()
|
||||
self.assertEqual(value, record["generation"] + ":idle\n")
|
||||
rc = Path(record["rcfile"]).read_text()
|
||||
self.assertNotIn("PUBLIC_INPUT_MARKER", rc)
|
||||
self.assertNotIn("BASH_COMMAND", rc)
|
||||
self.assertEqual(Path(record["rcfile"]).stat().st_mode & 0o777, 0o600)
|
||||
|
||||
def test_exit_still_cleans_up_owned_server_and_scope(self):
|
||||
record = self.launch()
|
||||
self.send(record, "exit")
|
||||
deadline = time.monotonic() + 2
|
||||
while time.monotonic() < deadline:
|
||||
process = control._proc(record["pid"])
|
||||
if process is None or process["state"] in {"Z", "X"}:
|
||||
break
|
||||
time.sleep(0.03)
|
||||
result = self.controller.close(record)
|
||||
self.assertTrue(result["closed"])
|
||||
self.assertFalse(Path(record["tmux_socket"]).exists())
|
||||
self.assertIsNone(control._proc(record["server_pid"]))
|
||||
self.records.remove(record)
|
||||
|
||||
def test_invalid_mode_does_not_launch(self):
|
||||
with self.assertRaises(control.SudoControlError):
|
||||
self.controller.launch(uuid.uuid4().hex, mode="automatic-root")
|
||||
self.assertFalse(list((self.base / "sudo").glob("*.sock")))
|
||||
|
||||
|
||||
|
||||
class PortableSudoLimitsTests(SudoControlTests):
|
||||
# Reuse lifecycle helpers, but do not repeat the inherited test methods.
|
||||
def test_expanded_large_geometry_survives_mode_change(self):
|
||||
record = self.launch(rows=500, cols=1000)
|
||||
self.assertEqual((record["rows"], record["cols"]), (500, 1000))
|
||||
enabled = self.controller.set_mode(record, "on")
|
||||
self.assertEqual((enabled["rows"], enabled["cols"], enabled["no_new_privs"]), (500, 1000, 0))
|
||||
|
||||
def test_profile_limits_reject_before_starting_a_unit(self):
|
||||
controller = control.SudoConsoleController(
|
||||
self.base / "profile", min_rows=3, max_rows=8, min_cols=11, max_cols=30)
|
||||
with mock.patch.object(controller, "_run", side_effect=AssertionError("unit must not start")):
|
||||
for rows, cols in [(2, 20), (9, 20), (4, 10), (4, 31), (True, 20)]:
|
||||
with self.assertRaises(control.SudoControlError) as error:
|
||||
controller.launch(uuid.uuid4().hex, rows=rows, cols=cols)
|
||||
self.assertEqual(error.exception.code, "TTY_SUDO_SIZE_INVALID")
|
||||
|
||||
def test_actual_nnp_mismatch_rolls_back_candidate(self):
|
||||
with mock.patch.object(self.controller, "_inspect",
|
||||
return_value={"no_new_privs": 0, "busy": False}):
|
||||
with self.assertRaises(control.SudoControlError) as error:
|
||||
self.controller.launch(uuid.uuid4().hex, mode="off")
|
||||
self.assertEqual(error.exception.code, "TTY_SUDO_PRIVILEGE_STATE_MISMATCH")
|
||||
self.assertFalse(list((self.base / "sudo").glob("*.sock")))
|
||||
self.assertFalse(self.controller._records)
|
||||
|
||||
def test_profile_runtime_isolation(self):
|
||||
other = control.SudoConsoleController(self.base / "other")
|
||||
self.assertNotEqual(other.base, self.controller.base)
|
||||
self.assertNotEqual(other.prefix, self.controller.prefix)
|
||||
|
||||
|
||||
def test_private_profile_bus_does_not_redirect_user_manager(self):
|
||||
private = self.base / "private-runtime"
|
||||
private.mkdir(mode=0o700)
|
||||
with mock.patch.dict(os.environ, {
|
||||
"XDG_RUNTIME_DIR": str(private),
|
||||
"DBUS_SESSION_BUS_ADDRESS": "unix:path=" + str(private / "not-the-user-manager")}):
|
||||
record = self.launch(mode="off")
|
||||
self.assertEqual(record["no_new_privs"], 1)
|
||||
self.assertEqual(record["server_no_new_privs"], 1)
|
||||
self.assertEqual(self.controller.user_runtime, Path("/run/user") / str(os.getuid()))
|
||||
|
||||
|
||||
|
||||
def test_profile_graphical_context_is_explicitly_forwarded_to_unit(self):
|
||||
import shlex
|
||||
private = self.base / "profile-desktop"
|
||||
private.mkdir(mode=0o700)
|
||||
expected = {
|
||||
"DISPLAY": ":193",
|
||||
"XAUTHORITY": str(private / "authority-not-used"),
|
||||
"DBUS_SESSION_BUS_ADDRESS": "unix:path=" + str(private / "private-bus-not-used"),
|
||||
"XDG_RUNTIME_DIR": str(private),
|
||||
"LD_LIBRARY_PATH": str(private / "libraries"),
|
||||
"LIBGL_DRIVERS_PATH": str(private / "drivers"),
|
||||
"GBM_BACKENDS_PATH": str(private / "gbm"),
|
||||
"__EGL_VENDOR_LIBRARY_FILENAMES": str(private / "egl-not-used.json"),
|
||||
"__GLX_VENDOR_LIBRARY_NAME": "mesa",
|
||||
"GALLIUM_DRIVER": "llvmpipe",
|
||||
"MESA_LOADER_DRIVER_OVERRIDE": "zink",
|
||||
"VK_DRIVER_FILES": str(private / "vk-not-used.json"),
|
||||
"VK_ICD_FILENAMES": str(private / "vk-compat-not-used.json"),
|
||||
"DRI_PRIME": "0",
|
||||
}
|
||||
forbidden = "FRAMEBRIDGE_TEST_NOT_FORWARDED"
|
||||
with mock.patch.dict(os.environ, dict(expected, **{forbidden: "MUST_NOT_FORWARD"})):
|
||||
controller = control.SudoConsoleController(self.base / "graphical-controller")
|
||||
record = controller.launch(uuid.uuid4().hex, mode="off")
|
||||
self.addCleanup(controller.close, record)
|
||||
self.assertEqual(record["no_new_privs"], 1)
|
||||
self.assertEqual(record["server_no_new_privs"], 1)
|
||||
self.assertEqual(controller.session_environment, expected)
|
||||
result = controller._run([
|
||||
controller.systemctl, "--user", "show", record["unit_name"],
|
||||
"--property=Environment", "--value"])
|
||||
unit_environment = dict(value.split("=", 1)
|
||||
for value in shlex.split(result.stdout.decode()))
|
||||
for name, value in expected.items():
|
||||
self.assertEqual(unit_environment.get(name), value)
|
||||
self.assertNotIn(forbidden, unit_environment)
|
||||
self.assertEqual(controller.user_bus_address,
|
||||
"unix:path=/run/user/" + str(os.getuid()) + "/bus")
|
||||
|
||||
|
||||
|
||||
# Do not run inherited acceptance cases twice.
|
||||
for _name in tuple(name for name in dir(SudoControlTests) if name.startswith("test_")):
|
||||
setattr(PortableSudoLimitsTests, _name, None)
|
||||
|
||||
|
||||
class PortableSudoRpcTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
import threading
|
||||
from framebridge_expanded import tty
|
||||
self.tty = tty
|
||||
class FakeManager:
|
||||
_lock = threading.RLock()
|
||||
def reap(self): pass
|
||||
def touch(self, sid): self.last_touch = sid
|
||||
def sudo(self, sid, mode):
|
||||
return {"session_id": sid, "mode": mode, "no_new_privs": 1 if mode == "off" else 0}
|
||||
self.manager = FakeManager()
|
||||
self.sid = "a" * 32
|
||||
self.payload = {"op": "sudo", "mode": "on", "session_id": self.sid, "authorized": True}
|
||||
|
||||
def test_authorized_typed_sudo_dispatch(self):
|
||||
reply = self.tty.dispatch(self.manager, self.payload)
|
||||
self.assertTrue(reply["ok"])
|
||||
self.assertEqual(reply["result"]["session_id"], self.sid)
|
||||
self.assertEqual(reply["result"]["mode"], "on")
|
||||
self.assertEqual(self.manager.last_touch, self.sid)
|
||||
|
||||
def test_missing_authorization_and_extra_keys_are_rejected(self):
|
||||
for payload in [dict(self.payload, authorized=False), dict(self.payload, command="sudo id")]:
|
||||
with self.assertRaises(self.tty.TerminalDenied):
|
||||
self.tty.dispatch(self.manager, payload)
|
||||
|
||||
def test_cleanup_pending_keeps_verified_replacement_in_failure_reply(self):
|
||||
self.manager.sudo = lambda sid, mode: {"session_id": sid, "cleanup_pending": True, "mode": mode}
|
||||
reply = self.tty.dispatch(self.manager, self.payload)
|
||||
self.assertFalse(reply["ok"])
|
||||
self.assertEqual(reply["error"], "TTY_SUDO_TRANSITION_CLEANUP_PENDING")
|
||||
self.assertEqual(reply["result"]["session_id"], self.sid)
|
||||
status = self.tty.dispatch(self.manager, dict(self.payload, mode="status"))
|
||||
self.assertTrue(status["ok"])
|
||||
|
||||
def test_controller_error_is_public_fixed_code(self):
|
||||
error = control.SudoControlError("TTY_SUDO_CONSOLE_BUSY")
|
||||
self.assertEqual(self.tty._safe_error(error), "TTY_SUDO_CONSOLE_BUSY")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,323 @@
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from framebridge_expanded import tunnel
|
||||
|
||||
ID = "tunnel_0123456789abcdef0123456789abcdef"
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class ParserTests(unittest.TestCase):
|
||||
def test_id_and_documented_commands(self):
|
||||
commands = [
|
||||
ID,
|
||||
"'" + ID + "'",
|
||||
"tunnel-client run --control-plane.tunnel-id " + ID,
|
||||
"/opt/client/tunnel-client run --control-plane.tunnel-id=" + ID + " --control-plane.api-key env:OLD_KEY --mcp.server-url url=http://127.0.0.1:8898/mcp",
|
||||
"tunnel-client init --profile demo --sample sample_mcp_remote_no_auth --tunnel-id " + ID + " --mcp-server-url http://127.0.0.1:8898/mcp",
|
||||
"tunnel-client runtimes connect --alias demo --tunnel-id " + ID + " --runtime-api-key env:OLD_KEY --json",
|
||||
"framebridge-original tunnel -- run --control-plane.tunnel-id " + ID,
|
||||
]
|
||||
for command in commands:
|
||||
with self.subTest(command=command):
|
||||
self.assertEqual(tunnel.parse_tunnel_input(command).tunnel_id, ID)
|
||||
|
||||
def test_rejections_never_echo_hidden_input(self):
|
||||
secret = "sk-test-DO_NOT_DISCLOSE-0123456789"
|
||||
bad = [
|
||||
"sudo tunnel-client run --control-plane.tunnel-id " + ID,
|
||||
"env OPENAI_API_KEY=" + secret + " tunnel-client run --control-plane.tunnel-id " + ID,
|
||||
"tunnel-client run --control-plane.tunnel-id " + ID + "; echo bad",
|
||||
"tunnel-client run --control-plane.tunnel-id $(" + secret + ")",
|
||||
"tunnel-client run --control-plane.tunnel-id " + ID + " | cat",
|
||||
"tunnel-client run --control-plane.tunnel-id " + ID + " --config /private/profile.yaml",
|
||||
"tunnel-client run --control-plane.tunnel-id " + ID + " --control-plane.api-key " + secret,
|
||||
"tunnel-client run --control-plane.tunnel-id " + ID + " --control-plane.api-key file:/private/key",
|
||||
"tunnel-client run --control-plane.tunnel-id " + ID + " --control-plane.base-url https://example.invalid",
|
||||
"tunnel-client run --control-plane.tunnel-id " + ID + " --tunnel-id " + ID,
|
||||
"tunnel-client run --control-plane.tunnel-id",
|
||||
"tunnel-client run --control-plane.tunnel-id '" + secret,
|
||||
]
|
||||
for command in bad:
|
||||
with self.subTest(command=command):
|
||||
with self.assertRaises(tunnel.TunnelInputError) as error:
|
||||
tunnel.parse_tunnel_input(command)
|
||||
self.assertNotIn(secret, str(error.exception))
|
||||
self.assertNotIn(command, str(error.exception))
|
||||
|
||||
def test_rebuild_uses_packaged_binary_and_profile_endpoint(self):
|
||||
spec = tunnel.parse_tunnel_input("tunnel-client run --control-plane.tunnel-id " + ID + " --mcp.server-url https://mcp.example.com/mcp")
|
||||
args = tunnel.build_command("/packaged/tunnel-client", "/own/state", "http://127.0.0.1:20865/mcp", spec)
|
||||
self.assertEqual(args[0], "/packaged/tunnel-client")
|
||||
self.assertIn("url=http://127.0.0.1:20865/mcp", args)
|
||||
self.assertNotIn("https://mcp.example.com/mcp", args)
|
||||
self.assertIn("env:" + tunnel.KEY_ENV, args)
|
||||
|
||||
def test_inherited_runtime_settings_removed_without_global_mutation(self):
|
||||
original = {"PATH": "/usr/bin", "DISPLAY": ":77", "HTTPS_PROXY": "http://proxy.example", "CA_BUNDLE": "/ca.pem",
|
||||
"TUNNEL_CLIENT_CONFIG": "/production/config", "CONTROL_PLANE_TUNNEL_ID": "production",
|
||||
"MCP_SERVER_URL": "https://production", "MCP_COMMAND": "production-command",
|
||||
"HEALTH_URL_FILE": "/production/health", "LOG_HTTP_RAW_UNSAFE": "true",
|
||||
"CLOUDFLARED_TUNNEL_TOKEN": "not-used", "OPENAI_API_KEY": "not-used"}
|
||||
snapshot = dict(original)
|
||||
env = tunnel.child_environment("FAKE_RUNTIME_KEY", original)
|
||||
self.assertEqual(original, snapshot)
|
||||
for key in snapshot:
|
||||
if key.startswith(tunnel._RUNTIME_PREFIXES):
|
||||
self.assertNotIn(key, env)
|
||||
self.assertEqual(env["HTTPS_PROXY"], original["HTTPS_PROXY"])
|
||||
self.assertEqual(env[tunnel.KEY_ENV], "FAKE_RUNTIME_KEY")
|
||||
|
||||
def test_child_default_profile_paths_are_private_and_scoped(self):
|
||||
with tempfile.TemporaryDirectory(prefix="tunnel-test-", dir=ROOT / "qa") as td:
|
||||
env = tunnel.child_environment("FAKE_ONLY", {"HOME": "/old/home", "XDG_CONFIG_HOME": "/old/config"}, state=Path(td))
|
||||
self.assertEqual(env["HOME"], str(Path(td) / "chatgpt-home"))
|
||||
self.assertEqual(env["XDG_CONFIG_HOME"], str(Path(td) / "chatgpt-home/.config"))
|
||||
self.assertEqual(Path(env["TMPDIR"]).stat().st_mode & 0o777, 0o700)
|
||||
self.assertFalse(any(p.is_file() for p in Path(td).rglob("*")))
|
||||
|
||||
def test_saved_public_context_is_reused(self):
|
||||
public = {"tunnel_id": ID, "control_plane_organization_id": "org_test", "control_plane_url_path": "/v1/tunnel"}
|
||||
with mock.patch.object(tunnel, "_hidden", side_effect=["", "FAKE_ONLY"]), mock.patch("framebridge_expanded.connection_background.run_foreground", return_value=0) as run:
|
||||
rc = tunnel.connect_chatgpt("/binary", "/state", "http://127.0.0.1:20865/mcp", public, output=io.StringIO(), foreground=True)
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertIn("org_test", run.call_args.args[0])
|
||||
self.assertIn("/v1/tunnel", run.call_args.args[0])
|
||||
|
||||
def test_non_tty_fails_before_secret_prompt(self):
|
||||
with mock.patch.object(tunnel.sys.stdin, "isatty", return_value=False), mock.patch.object(tunnel.getpass, "getpass") as prompt:
|
||||
with self.assertRaises(tunnel.TunnelInputError):
|
||||
tunnel._hidden("Hidden: ")
|
||||
prompt.assert_not_called()
|
||||
|
||||
|
||||
class ForegroundTests(unittest.TestCase):
|
||||
def command(self, code):
|
||||
return [sys.executable, "-u", "-c", code,
|
||||
"--control-plane.tunnel-id", ID,
|
||||
"--mcp.server-url", "url=http://127.0.0.1:20865/mcp"]
|
||||
|
||||
def test_fake_key_only_in_child_environment_and_private_log(self):
|
||||
secret = "FAKE_RUNTIME_ONLY_0123456789"
|
||||
seen = {}
|
||||
real_popen = subprocess.Popen
|
||||
def spawn(args, **kwargs):
|
||||
seen["args"] = list(args)
|
||||
seen["env"] = dict(kwargs["env"])
|
||||
return real_popen(args, **kwargs)
|
||||
code = "import os,time;print('TECHNICAL_DEBUG '+os.environ['" + tunnel.KEY_ENV + "'],flush=True);time.sleep(.3)"
|
||||
with tempfile.TemporaryDirectory(prefix="tunnel-output-") as td:
|
||||
output = io.StringIO()
|
||||
with mock.patch("framebridge_expanded.connection_monitor.probe", return_value=("ready", "Verified synthetic test state")):
|
||||
result = tunnel.run_foreground(self.command(code), secret, state=td, output=output, popen=spawn)
|
||||
data = (Path(td) / "logs/chatgpt/connection.log").read_text()
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(seen["env"][tunnel.KEY_ENV], secret)
|
||||
self.assertNotIn(secret, repr(seen["args"]))
|
||||
self.assertNotIn(secret, output.getvalue())
|
||||
self.assertNotIn("TECHNICAL_DEBUG", output.getvalue())
|
||||
self.assertIn("TECHNICAL_DEBUG [REDACTED]", data)
|
||||
self.assertNotIn(secret, data)
|
||||
self.assertIn("[OK]", output.getvalue())
|
||||
self.assertIn("successful tool call", output.getvalue())
|
||||
self.assertEqual((Path(td) / "logs/chatgpt/connection.log").stat().st_mode & 0o777, 0o600)
|
||||
|
||||
def test_ctrl_c_stops_only_owned_child(self):
|
||||
real_popen = subprocess.Popen
|
||||
seen = {}
|
||||
def spawn(args, **kwargs):
|
||||
seen["process"] = real_popen(args, **kwargs)
|
||||
return seen["process"]
|
||||
with tempfile.TemporaryDirectory(prefix="tunnel-stop-") as td:
|
||||
with mock.patch("framebridge_expanded.connection_monitor.probe", side_effect=KeyboardInterrupt):
|
||||
rc = tunnel.run_foreground(self.command("import time;time.sleep(60)"), "FAKE_ONLY", state=td,
|
||||
output=io.StringIO(), popen=spawn)
|
||||
self.assertEqual(rc, 130)
|
||||
self.assertIsNotNone(seen["process"].poll())
|
||||
self.assertFalse(any((Path(td) / "connection-runtime").iterdir()))
|
||||
|
||||
def test_interactive_flow_saves_only_public_values(self):
|
||||
secret = "FAKE_RUNTIME_ONLY_0123456789"
|
||||
saved = []
|
||||
output = io.StringIO()
|
||||
with mock.patch.object(tunnel, "_hidden", side_effect=[ID, secret]), mock.patch("framebridge_expanded.connection_background.run_foreground", return_value=0) as run:
|
||||
rc = tunnel.connect_chatgpt("/binary", "/state", "http://127.0.0.1:20865/mcp", {"account_label": "test@example.com"}, saved.append, output=output, foreground=True)
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertEqual(saved[0]["tunnel_id"], ID)
|
||||
self.assertNotIn(secret, repr(saved))
|
||||
self.assertNotIn(secret, output.getvalue())
|
||||
self.assertEqual(run.call_args.args[1], secret)
|
||||
self.assertNotIn(secret, repr(run.call_args.args[0]))
|
||||
|
||||
|
||||
class ManagedWizardTests(unittest.TestCase):
|
||||
@staticmethod
|
||||
def ready():
|
||||
return {"configured": True, "enabled": True, "service_active": True,
|
||||
"service_state": "active", "runtime": {"running": True, "ready": True},
|
||||
"connection": {"alive": True, "verified": True, "state": "ready"}}
|
||||
|
||||
def invoke(self, configure, *, public=None, output=None, wait_timeout=0):
|
||||
return tunnel.connect_chatgpt("/binary", "/state", "http://127.0.0.1:20865/mcp", public,
|
||||
output=output or io.StringIO(), configure_connection=configure,
|
||||
wait_timeout=wait_timeout)
|
||||
|
||||
def test_three_fields_are_ordered_and_only_public_settings_are_committed(self):
|
||||
events = []
|
||||
def label(prompt):
|
||||
events.append(prompt)
|
||||
return "test@example.com"
|
||||
answers = iter([ID, "SYNTHETIC-MCP-ACCESS-KEY"])
|
||||
def hidden(prompt):
|
||||
events.append(prompt)
|
||||
return next(answers)
|
||||
seen = []
|
||||
def configure(state, public, credential):
|
||||
events.append("commit")
|
||||
seen.append((state, public, credential))
|
||||
return self.ready()
|
||||
output = io.StringIO()
|
||||
with mock.patch.object(tunnel.sys.stdin, "isatty", return_value=True), mock.patch("builtins.input", side_effect=label), mock.patch.object(tunnel, "_hidden", side_effect=hidden):
|
||||
rc = self.invoke(configure, output=output)
|
||||
self.assertEqual(0, rc)
|
||||
self.assertTrue(events[0].startswith("ChatGPT account (email label)"))
|
||||
self.assertTrue(events[1].startswith("Tunnel ID (hidden"))
|
||||
self.assertEqual("MCP server access key (hidden): ", events[2])
|
||||
self.assertEqual("commit", events[3])
|
||||
self.assertEqual("test@example.com", seen[0][1]["account_label"])
|
||||
self.assertEqual(ID, seen[0][1]["tunnel_id"])
|
||||
self.assertNotIn("SYNTHETIC-MCP-ACCESS-KEY", repr(seen[0][1]))
|
||||
self.assertNotIn("SYNTHETIC-MCP-ACCESS-KEY", output.getvalue())
|
||||
self.assertEqual("Connecting...\n[OK] Connected.\n", output.getvalue())
|
||||
|
||||
def test_cancel_at_each_field_never_calls_committer(self):
|
||||
for field in range(3):
|
||||
with self.subTest(field=field):
|
||||
configure = mock.Mock()
|
||||
label = mock.Mock(return_value="test@example.com")
|
||||
hidden = mock.Mock(side_effect=[ID, "SYNTHETIC-MCP-ACCESS-KEY"])
|
||||
if field == 0:
|
||||
label.side_effect = KeyboardInterrupt
|
||||
elif field == 1:
|
||||
hidden.side_effect = KeyboardInterrupt
|
||||
else:
|
||||
hidden.side_effect = [ID, KeyboardInterrupt]
|
||||
with mock.patch.object(tunnel.sys.stdin, "isatty", return_value=True), mock.patch("builtins.input", label), mock.patch.object(tunnel, "_hidden", hidden):
|
||||
rc = self.invoke(configure)
|
||||
self.assertEqual(130, rc)
|
||||
configure.assert_not_called()
|
||||
|
||||
def test_full_command_in_normal_hidden_id_field_is_rejected_without_echo(self):
|
||||
configure = mock.Mock()
|
||||
pasted = "tunnel-client run --tunnel-id " + ID + " --api-key SYNTHETIC-PASTED-SECRET"
|
||||
output = io.StringIO()
|
||||
with mock.patch.object(tunnel.sys.stdin, "isatty", return_value=True), mock.patch("builtins.input", return_value="test@example.com"), mock.patch.object(tunnel, "_hidden", return_value=pasted) as hidden:
|
||||
with self.assertRaises(tunnel.TunnelInputError) as error:
|
||||
self.invoke(configure, output=output)
|
||||
self.assertNotIn("SYNTHETIC-PASTED-SECRET", str(error.exception) + output.getvalue())
|
||||
self.assertEqual(1, hidden.call_count)
|
||||
configure.assert_not_called()
|
||||
|
||||
def test_invalid_key_does_not_commit_and_is_not_echoed(self):
|
||||
configure = mock.Mock()
|
||||
output = io.StringIO()
|
||||
with mock.patch.object(tunnel.sys.stdin, "isatty", return_value=True), mock.patch("builtins.input", return_value="test@example.com"), mock.patch.object(tunnel, "_hidden", side_effect=[ID, "SYNTHETIC INVALID KEY"]):
|
||||
with self.assertRaises(tunnel.TunnelInputError) as error:
|
||||
self.invoke(configure, output=output)
|
||||
self.assertNotIn("SYNTHETIC INVALID KEY", str(error.exception) + output.getvalue())
|
||||
configure.assert_not_called()
|
||||
|
||||
def test_saved_public_defaults_preserve_tunnel_context_but_key_is_requested(self):
|
||||
public = {"account_label": "test@example.com", "tunnel_id": ID,
|
||||
"control_plane_organization_id": "org_test", "control_plane_url_path": "/v1/tunnel"}
|
||||
configure = mock.Mock(return_value=self.ready())
|
||||
with mock.patch.object(tunnel.sys.stdin, "isatty", return_value=True), mock.patch("builtins.input", return_value=""), mock.patch.object(tunnel, "_hidden", side_effect=["", "SYNTHETIC-MCP-ACCESS-KEY"]) as hidden:
|
||||
self.assertEqual(0, self.invoke(configure, public=public))
|
||||
self.assertEqual(2, hidden.call_count)
|
||||
self.assertEqual(public, configure.call_args.args[1])
|
||||
|
||||
def test_pending_returns_prompt_nonzero_without_false_success_or_reboot(self):
|
||||
pending = self.ready()
|
||||
pending["connection"] = {"alive": True, "verified": False, "state": "connecting"}
|
||||
output = io.StringIO()
|
||||
self.assertEqual(1, tunnel._wait_managed_connection("/state", pending, output, wait_timeout=0))
|
||||
self.assertIn("[PENDING]", output.getvalue())
|
||||
self.assertNotIn("[OK]", output.getvalue())
|
||||
self.assertNotIn("reboot", output.getvalue().lower())
|
||||
|
||||
def test_failed_service_cannot_use_stale_verified_connection_as_success(self):
|
||||
for failure in ({"service_state": "failed", "service_active": False}, {"service_state": "failed", "service_active": True}, {"worker": {"state": "failed"}}):
|
||||
with self.subTest(failure=failure):
|
||||
state = self.ready()
|
||||
state.update(failure)
|
||||
output = io.StringIO()
|
||||
self.assertEqual(1, tunnel._wait_managed_connection("/state", state, output, wait_timeout=0))
|
||||
self.assertIn("[ERROR]", output.getvalue())
|
||||
self.assertNotIn("[OK]", output.getvalue())
|
||||
|
||||
def test_readiness_is_polled_and_key_is_requested_only_once(self):
|
||||
from framebridge_expanded import autostart
|
||||
starting = self.ready()
|
||||
starting["connection"] = {"alive": False, "verified": False, "state": "connecting"}
|
||||
configure = mock.Mock(return_value=starting)
|
||||
with mock.patch.object(tunnel.sys.stdin, "isatty", return_value=True), mock.patch("builtins.input", return_value="test@example.com"), mock.patch.object(tunnel, "_hidden", side_effect=[ID, "SYNTHETIC-MCP-ACCESS-KEY"]) as hidden, mock.patch.object(tunnel.time, "sleep"), mock.patch.object(autostart, "status", return_value=self.ready()) as status:
|
||||
self.assertEqual(0, self.invoke(configure, wait_timeout=2))
|
||||
self.assertEqual(2, hidden.call_count)
|
||||
self.assertEqual("MCP server access key (hidden): ", hidden.call_args.args[0])
|
||||
status.assert_called_once_with("/state")
|
||||
|
||||
def test_interrupt_during_wait_reports_saved_background_connection(self):
|
||||
from framebridge_expanded import autostart
|
||||
starting = self.ready()
|
||||
starting["connection"] = {"alive": False, "verified": False, "state": "connecting"}
|
||||
output = io.StringIO()
|
||||
with mock.patch.object(tunnel.sys.stdin, "isatty", return_value=True), mock.patch("builtins.input", return_value="test@example.com"), mock.patch.object(tunnel, "_hidden", side_effect=[ID, "SYNTHETIC-MCP-ACCESS-KEY"]), mock.patch.object(tunnel.time, "sleep"), mock.patch.object(autostart, "status", side_effect=KeyboardInterrupt):
|
||||
self.assertEqual(130, self.invoke(mock.Mock(return_value=starting), output=output, wait_timeout=2))
|
||||
self.assertIn("continues in the background", output.getvalue())
|
||||
self.assertNotIn("Connection cancelled", output.getvalue())
|
||||
|
||||
def test_interrupt_after_commit_does_not_report_connection_cancelled(self):
|
||||
marker = {"configured": True, "enabled": True, "interrupted_after_commit": True}
|
||||
configure = mock.Mock(return_value=marker)
|
||||
output = io.StringIO()
|
||||
with mock.patch.object(tunnel.sys.stdin, "isatty", return_value=True), mock.patch("builtins.input", return_value="test@example.com"), mock.patch.object(tunnel, "_hidden", side_effect=[ID, "SYNTHETIC-MCP-ACCESS-KEY"]):
|
||||
self.assertEqual(130, self.invoke(configure, output=output))
|
||||
self.assertIn("continues in the background", output.getvalue())
|
||||
self.assertNotIn("cancelled", output.getvalue().lower())
|
||||
self.assertNotIn("[OK]", output.getvalue())
|
||||
|
||||
def test_resume_status_interrupted_after_commit_is_not_reported_cancelled(self):
|
||||
marker = {"configured": True, "enabled": True, "interrupted_after_commit": True}
|
||||
output = io.StringIO()
|
||||
self.assertEqual(130, tunnel.resume_managed_connection("/state", mock.Mock(return_value=marker), output=output, wait_timeout=0))
|
||||
self.assertIn("continues in the background", output.getvalue())
|
||||
self.assertNotIn("cancelled", output.getvalue().lower())
|
||||
|
||||
def test_non_tty_fails_before_any_input_or_commit(self):
|
||||
configure = mock.Mock()
|
||||
with mock.patch.object(tunnel.sys.stdin, "isatty", return_value=False), mock.patch("builtins.input") as label, mock.patch.object(tunnel, "_hidden") as hidden:
|
||||
with self.assertRaises(tunnel.TunnelInputError):
|
||||
self.invoke(configure)
|
||||
label.assert_not_called()
|
||||
hidden.assert_not_called()
|
||||
configure.assert_not_called()
|
||||
|
||||
def test_resume_uses_no_setup_prompts(self):
|
||||
resume = mock.Mock(return_value=self.ready())
|
||||
with mock.patch("builtins.input") as label, mock.patch.object(tunnel, "_hidden") as hidden:
|
||||
self.assertEqual(0, tunnel.resume_managed_connection("/state", resume, output=io.StringIO(), wait_timeout=0))
|
||||
label.assert_not_called()
|
||||
hidden.assert_not_called()
|
||||
resume.assert_called_once_with("/state")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,109 @@
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import pwd
|
||||
import subprocess
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from framebridge_expanded import uninstall
|
||||
|
||||
|
||||
class UninstallTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.home = Path(self.temp.name)
|
||||
self.directory = self.home / ".config/systemd/user"
|
||||
self.directory.mkdir(parents=True)
|
||||
|
||||
def unit(self, state=None):
|
||||
state = str(state or self.home / "state")
|
||||
name = "framebridge-autostart-" + hashlib.sha256(state.encode()).hexdigest()[:16] + ".service"
|
||||
path = self.directory / name
|
||||
quoted_state = state.replace("$", "$$").replace("%", "%%").replace("\\", "\\\\").replace('"', '\\"')
|
||||
path.write_text('# FrameBridge managed autostart v1\n[Service]\nEnvironment="PYTHONPATH=' + uninstall.MODULE_ROOT + '"\nExecStart="/usr/bin/python3" "-B" "-m" "framebridge_expanded.autostart" "_run" "--state" "' + quoted_state + '"\n[Install]\nWantedBy=default.target\n')
|
||||
return path
|
||||
|
||||
def test_owned_service_matches_even_with_escaped_state(self):
|
||||
path = self.unit(self.home / "a space $ and % value")
|
||||
self.assertTrue(uninstall.managed_unit(path, os.getuid()))
|
||||
|
||||
def test_wrong_owner_is_not_accepted(self):
|
||||
self.assertFalse(uninstall.managed_unit(self.unit(), os.getuid() + 1))
|
||||
|
||||
def test_foreign_command_and_missing_marker_are_not_accepted(self):
|
||||
p = self.unit()
|
||||
p.write_text(p.read_text().replace('"framebridge_expanded.autostart"', '"another_application"'))
|
||||
self.assertFalse(uninstall.managed_unit(p, os.getuid()))
|
||||
p = self.unit();p.write_text(p.read_text().replace('# FrameBridge managed autostart v1', '# foreign'))
|
||||
self.assertFalse(uninstall.managed_unit(p, os.getuid()))
|
||||
|
||||
def test_symlink_and_wrong_hash_are_not_accepted(self):
|
||||
p = self.unit();data = p.read_text();p.unlink();target = self.home / "outside";target.write_text(data);p.symlink_to(target)
|
||||
self.assertFalse(uninstall.managed_unit(p, os.getuid()))
|
||||
p.unlink();p.write_text(data);other = self.directory / ("framebridge-autostart-" + "f" * 16 + ".service");p.rename(other)
|
||||
self.assertFalse(uninstall.managed_unit(other, os.getuid()))
|
||||
|
||||
def test_live_cleanup_stops_and_disables_only_owned_unit(self):
|
||||
p = self.unit();foreign = self.directory / "unrelated.service";foreign.write_text("unrelated")
|
||||
run = mock.Mock(return_value=SimpleNamespace(returncode=0, stdout=b""))
|
||||
self.assertEqual(uninstall.cleanup_user(self.home, run=run, bus_available=True), 1)
|
||||
calls = [c.args[0] for c in run.call_args_list]
|
||||
self.assertEqual(calls[0], ["/usr/bin/systemctl", "--user", "stop", p.name])
|
||||
self.assertEqual(calls[1], ["/usr/bin/systemctl", "--user", "--no-reload", "disable", p.name])
|
||||
self.assertEqual(foreign.read_text(), "unrelated")
|
||||
self.assertTrue(p.is_file())
|
||||
|
||||
def test_actual_offline_disable_preserves_profile_and_cipher(self):
|
||||
p = self.unit();wants = self.directory / "default.target.wants";wants.mkdir();link = wants / p.name;link.symlink_to(Path("..") / p.name)
|
||||
profile = self.home / "profile.json";cipher = self.home / "credential.cred";profile.write_text('profile fixture');cipher.write_bytes(b'encrypted fixture only')
|
||||
self.assertEqual(uninstall.cleanup_user(self.home, bus_available=False), 1)
|
||||
self.assertFalse(link.is_symlink())
|
||||
self.assertEqual(profile.read_text(), 'profile fixture')
|
||||
self.assertEqual(cipher.read_bytes(), b'encrypted fixture only')
|
||||
self.assertTrue(p.is_file())
|
||||
|
||||
def test_failed_stop_still_attempts_disable_and_reports_failure(self):
|
||||
self.unit()
|
||||
def invoke(args, **kwargs):
|
||||
if "stop" in args:return SimpleNamespace(returncode=1, stdout=b"")
|
||||
if "show" in args:return SimpleNamespace(returncode=0, stdout=b"active\n")
|
||||
return SimpleNamespace(returncode=0, stdout=b"")
|
||||
run = mock.Mock(side_effect=invoke)
|
||||
with self.assertRaises(RuntimeError):uninstall.cleanup_user(self.home, run=run, bus_available=True)
|
||||
self.assertTrue(any("disable" in c.args[0] for c in run.call_args_list))
|
||||
|
||||
def test_no_managed_services_makes_no_calls(self):
|
||||
run = mock.Mock();self.assertEqual(uninstall.cleanup_user(self.home, run=run), 0);run.assert_not_called()
|
||||
|
||||
def test_all_users_drops_privileges_and_includes_service_accounts(self):
|
||||
accounts = [SimpleNamespace(pw_name="testservice", pw_uid=104, pw_dir="/var/lib/testservice"), SimpleNamespace(pw_name="testperson", pw_uid=1000, pw_dir="/home/testperson"), SimpleNamespace(pw_name="nobody", pw_uid=65534, pw_dir="/nonexistent")]
|
||||
run = mock.Mock(return_value=SimpleNamespace(returncode=0))
|
||||
with mock.patch.object(uninstall.os, "getuid", return_value=0):uninstall.cleanup_all_users(run=run, accounts=accounts)
|
||||
self.assertEqual(run.call_count, 2)
|
||||
for call, name in zip(run.call_args_list, ["testservice", "testperson"]):
|
||||
args = call.args[0]
|
||||
self.assertEqual(args[:7], ["/usr/sbin/runuser", "-u", name, "--", "/usr/bin/env", "-i", "HOME=" + ("/var/lib/testservice" if name == "testservice" else "/home/testperson")])
|
||||
self.assertEqual(args[-4:], ["-I", "-S", str(Path(uninstall.__file__).resolve()), "--user"])
|
||||
|
||||
def test_root_dispatch_continues_after_one_user_failure(self):
|
||||
accounts = [SimpleNamespace(pw_name="first", pw_uid=1000, pw_dir="/home/first"), SimpleNamespace(pw_name="second", pw_uid=1001, pw_dir="/home/second")]
|
||||
run = mock.Mock(side_effect=[SimpleNamespace(returncode=1), SimpleNamespace(returncode=0)])
|
||||
with mock.patch.object(uninstall.os, "getuid", return_value=0):
|
||||
with self.assertRaises(RuntimeError):uninstall.cleanup_all_users(run=run, accounts=accounts)
|
||||
self.assertEqual(run.call_count, 2)
|
||||
|
||||
def test_nonroot_cannot_dispatch_other_users(self):
|
||||
with mock.patch.object(uninstall.os, "getuid", return_value=1000):
|
||||
with self.assertRaises(RuntimeError):uninstall.cleanup_all_users(accounts=[])
|
||||
|
||||
def test_stop_timeout_still_disables_unit(self):
|
||||
self.unit()
|
||||
def invoke(args, **kwargs):
|
||||
if "stop" in args:raise subprocess.TimeoutExpired(args, 35)
|
||||
return SimpleNamespace(returncode=0, stdout=b"")
|
||||
run = mock.Mock(side_effect=invoke)
|
||||
with self.assertRaises(RuntimeError):uninstall.cleanup_user(self.home, run=run, bus_available=True)
|
||||
self.assertTrue(any("disable" in c.args[0] for c in run.call_args_list))
|
||||
Reference in new issue
Block a user