okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
57a82be
main
1"""Check remote relay, streaming jobs and degraded boot without SSH credentials. 2 3Malformed frames cannot come from the normal daemon. Feed them into the real 4remote reader to catch abandoned handshake and call futures on reader exits. 5""" 6 7import asyncio 8import contextlib 9import hashlib 10import io 11import json 12import struct 13import os 14import sys 15import tempfile 16import unittest 17from pathlib import Path 18from unittest .mock import patch 19 20ROOT = Path (__file__ ).resolve ().parents [2 ] 21sys .path .insert (0 ,str (ROOT / "priv" / "python" )) 22from albedo_api import PythonApi # noqa: E402 23from albedo_capture import Capture # noqa: E402 24from albedo_plugins import remote # noqa: E402 25 26sys .path .insert (0 ,str (ROOT / "test" )) 27import scratch # noqa: E402 28 29# Temporary workspaces and homes go under this run's scratch directory, which 30# is removed at exit. 31scratch .claim ("harness" ) 32 33 34class FakeHostError (Exception ): 35def __init__ (self ,code ,message ): 36super ().__init__ (message ) 37self .code ,self .message = code ,message 38 39 40def fake_ssh_path ()-> str : 41"""A loopback ssh: option pairs, target, then run the command locally.""" 42script = Path (tempfile .mkdtemp (prefix = "albedo-fake-ssh-" ))/ "ssh" 43script .write_text ( 44"#!/bin/sh\n" 45"while [ $# -gt 0 ]; do\n" 46' case "$1" in\n' 47" -*) shift 2 ;;\n" 48" *) break ;;\n" 49" esac\n" 50"done\n" 51"shift\n" 52'exec sh -c "$*"\n' 53 ) 54script .chmod (0o755 ) 55return str (script ) 56 57 58FAKE_SSH = fake_ssh_path () 59 60 61notices = []# every jobs.completed notice that reached the daemon 62 63 64async def daemon_host (method ,args ): 65if method == "jobs.completed" : 66notices .append (args ) 67return None 68if method == "work.list" : 69return [ 70 { 71"id" :1 , 72"title" :"from-the-daemon" , 73"notes" :"" , 74"status" :"open" , 75"parent" :None , 76"session" :None , 77"run" :None , 78"revision" :1 , 79 } 80 ] 81if method == "work.get" : 82raise FakeHostError ("missing" ,f"no work item { args [ 'id' ] } " ) 83return {} 84 85 86class RemotePluginTest (unittest .IsolatedAsyncioTestCase ): 87def setUp (self ): 88remote .configured .clear () 89remote .connections .clear () 90notices .clear () 91self .home = tempfile .mkdtemp (prefix = "albedo-remote-home-" ) 92self .workspace = tempfile .mkdtemp (prefix = "albedo-remote-ws-" ) 93os .chdir (self .workspace ) 94self .environment = patch .dict ( 95os .environ , 96 { 97"HOME" :self .home , 98"ALBEDO_HOME" :os .path .join (self .home ,".albedo" ), 99"ALBEDO_SSH" :"loopback" , 100"SHELL" :"/bin/sh" , 101 }, 102 ) 103self .environment .start () 104self .addCleanup (self .environment .stop ) 105self .addCleanup (os .chdir ,ROOT ) 106self .fake_ssh = patch .object (remote ,"ssh_base" ,lambda target : [FAKE_SSH ]) 107self .fake_ssh .start () 108self .addCleanup (self .fake_ssh .stop ) 109 110async def asyncSetUp (self ): 111self .loop = asyncio .get_running_loop () 112self .api = PythonApi ( 113self .loop , 114daemon_host , 115FakeHostError , 116Capture , 11764 * 1024 , 118lambda event :None , 119lambda close :None , 120lambda cls :None , 1212 , 122 ["run" ,"files" ,"work" ,"skills" ,"remote" ], 123 ) 124self .namespace = {} 125remote .setup (self .api ) 126self .remote = remote .Remote () 127 128async def connect (self ,** kwargs ): 129return await self .remote .connect (python = sys .executable ,** kwargs ) 130 131async def test_reader_exits_fail_handshake_and_calls (self ): 132frames = [ 133 [], 134None , 135 {}, 136 {"type" :"unknown" }, 137 {"type" :"ready" ,"pid" :True ,"pgid" :None ,"leader" :None }, 138 {"type" :"startup_error" }, 139 {"type" :"invoked" ,"id" :"call" ,"ok" :1 ,"value" :None }, 140 {"type" :"invoked" ,"id" :"call" ,"ok" :True }, 141 {"type" :"invoked" ,"id" : [],"ok" :True ,"value" :None }, 142 {"type" :"invoked" ,"id" :"call" ,"ok" :False ,"error" : []}, 143 { 144"type" :"invoked" , 145"id" :"call" , 146"ok" :False , 147"error" : {"ename" :"Error" ,"evalue" :"bad" ,"traceback" : [1 ]}, 148 }, 149 {"type" :"invoked" ,"id" :"call" ,"ok" :True ,"handle" :1 }, 150 {"type" :"invoked" ,"id" :"call" ,"ok" :True ,"handle" :"h" ,"state" : []}, 151 {"type" :"mirror" ,"handle" :"h" ,"exit_code" :True }, 152 {"type" :"mirror" ,"handle" :"h" ,"timed_out" :1 }, 153 {"type" :"introspected" ,"id" :"call" ,"names" : [],"handles" : [None ]}, 154 {"type" :"call" ,"id" :"x" ,"method" :"work.list" ,"args" : []}, 155 {"type" :"trace" ,"id" : []}, 156 {"type" :"cleanup" ,"module" :None }, 157 {"type" :"job_stop" ,"id" :"x" ,"ok" :1 }, 158 ] 159payloads = [json .dumps (frame ).encode ()for frame in frames ]+ [b"{bad json" ] 160wires = [struct .pack (">I" ,len (data ))+ data for data in payloads ] 161wires += [struct .pack (">I" ,remote .MAX_FRAME + 1 ),b"" ,b"\x00\x00" ] 162for wire in wires : 163with self .subTest (wire = wire [:160 ]): 164connection ,stream ,call = self .reader_connection () 165stream .feed_data (wire ) 166stream .feed_eof () 167await self .assert_reader_lost (connection ,call ) 168 169def reader_connection (self ): 170connection = remote .RemoteConnection ({"host" :"loopback" }) 171stream = asyncio .StreamReader () 172connection ._stdout = stream 173connection ._handshake = self .loop .create_future () 174call = self .loop .create_future () 175connection ._pending ["call" ]= call 176return connection ,stream ,call 177 178async def assert_reader_lost (self ,connection ,call ,* ,cancel = False ): 179reader = self .loop .create_task (connection ._read_frames ()) 180if cancel : 181await asyncio .sleep (0 ) 182reader .cancel () 183with self .assertRaises (asyncio .CancelledError ): 184await asyncio .wait_for (reader ,1 ) 185else : 186await asyncio .wait_for (reader ,1 ) 187with self .assertRaises (remote .RemoteBootError ): 188await asyncio .wait_for (connection ._handshake ,1 ) 189with self .assertRaises (remote .RemoteLost ): 190await asyncio .wait_for (call ,1 ) 191self .assertTrue (connection .closed ) 192 193async def test_cancellation_and_unexpected_reader_errors_fail_futures (self ): 194connection ,_ ,call = self .reader_connection () 195await self .assert_reader_lost (connection ,call ,cancel = True ) 196connection ,stream ,call = self .reader_connection () 197stream .set_exception (RuntimeError ("unexpected read failure" )) 198await self .assert_reader_lost (connection ,call ) 199 200async def test_ignored_events_and_extra_fields_allow_a_following_reply (self ): 201connection ,stream ,call = self .reader_connection () 202frames = [ 203 {"type" :"trace" ,"id" :"cell" ,"trace" :None }, 204 {"type" :"done" ,"id" :"cell" ,"unused" : []}, 205 {"type" :"cleanup" ,"module" :"plugin" ,"failures" :None }, 206 {"type" :"ready" ,"pid" :1 ,"pgid" :None ,"leader" :None ,"extra" : []}, 207 { 208"type" :"invoked" , 209"id" :"call" , 210"ok" :True , 211"value" : [None ,True , {"x" :1 }], 212"extra" : [], 213 }, 214 ] 215for frame in frames : 216data = json .dumps (frame ).encode () 217stream .feed_data (struct .pack (">I" ,len (data ))+ data ) 218stream .feed_eof () 219await asyncio .wait_for (connection ._read_frames (),1 ) 220self .assertEqual ((await call )["value" ], [None ,True , {"x" :1 }]) 221self .assertEqual ((await connection ._handshake )["type" ],"ready" ) 222 223def test_settings_validate_strings_and_preserve_fallback_and_precedence (self ): 224home = Path (os .environ ["ALBEDO_HOME" ]) 225home .mkdir () 226settings = home / "extensions.json" 227self .assertEqual (remote ._settings (), {}) 228for field in ("host" ,"remoteCwd" ,"python" ): 229for value in (None ,1 ,True , [], {}): 230with self .subTest (field = field ,value = value ): 231settings .write_text (json .dumps ({"remote" : {field :value }})) 232with self .assertRaisesRegex (remote .RemoteError ,field ): 233remote ._settings () 234values = { 235"host" :"settings-host" , 236"remoteCwd" :"/remote" , 237"python" :"python-custom" , 238 } 239settings .write_text (json .dumps ({"ssh" : {** values ,"unrelated" : []}})) 240self .assertEqual (remote ._settings (),values ) 241self .assertEqual (remote .resolve ()["host" ],"loopback" ) 242remote .remote .configure ("configured:/configured" ) 243self .assertEqual (remote .resolve ()["host" ],"configured" ) 244target = remote .resolve ("explicit:/path" ,"/override" ,"python-explicit" ) 245self .assertEqual ( 246target , 247 { 248"host" :"explicit" , 249"remote_cwd" :"/override" , 250"python" :"python-explicit" , 251 }, 252 ) 253settings .write_text (json .dumps ({"remote" : {},"ssh" :values })) 254self .assertEqual (remote ._settings (), {}) 255# ssh takes a URI target with a port; its path is the remote cwd 256self .assertEqual ( 257remote .parse_target ("ssh://me@box:2222/srv/app" ), 258 {"host" :"ssh://me@box:2222" ,"remote_cwd" :"/srv/app" }, 259 ) 260self .assertEqual ( 261remote .parse_target ("ssh://box:2222" ), 262 {"host" :"ssh://box:2222" ,"remote_cwd" :None }, 263 ) 264 265async def test_a_host_that_needs_a_sign_in_names_the_command_for_it (self ): 266locked = Path (tempfile .mkdtemp (prefix = "albedo-locked-ssh-" ))/ "ssh" 267locked .write_text ( 268"#!/bin/sh\necho 'box: Permission denied (publickey).' >&2\nexit 255\n" 269 ) 270locked .chmod (0o755 ) 271with patch .object (remote ,"ssh_base" ,lambda target : [str (locked )]): 272with self .assertRaises (remote .RemoteError )as raised : 273await self .connect (host = "ssh://box:2222" ) 274message = str (raised .exception ) 275self .assertIn ("needs a person to sign in" ,message ) 276self .assertIn ("ssh -M -fN -o ControlPath=" ,message ) 277self .assertIn (" ssh://box:2222`" ,message ) 278 279async def test_connect_boots_the_kernel_and_answers_every_tool (self ): 280rem = await self .connect () 281self .addCleanup (rem .close ) 282self .assertIn ("kernel" ,repr (rem )) 283self .assertIn ("run" , (await rem .tools ())["names" ]) 284 285text = await rem .files .read ("/etc/hosts" ) 286self .assertIsInstance (text ,str ) 287 288# The call is synchronous like local run; the reference settles on await. 289job = rem .run ( 290sys .executable , 291"-c" , 292"import time; print('loopback-echo', flush=True); time.sleep(0.2)" , 293 ) 294self .assertIn ("pending" ,repr (job )) 295self .assertIsNone (job .poll ()) 296self .assertEqual (job .tail (),"" ) 297awaited = await job 298self .assertIs (awaited ,job ) 299self .assertEqual (job .poll (),0 ) 300self .assertEqual (job .exit_code ,0 ) 301self .assertEqual (job .returncode ,0 )# subprocess's spelling answers too 302self .assertFalse (job .timed_out ) 303self .assertIsNotNone (job .duration ) 304self .assertGreaterEqual (job .duration ,0.2 ) 305self .assertEqual (job .tail (),"loopback-echo\n" ) 306self .assertIsNotNone (job .id ) 307self .assertIn ("tail" ,dir (job ))# dir() mixes local and mirrored names 308ending = await job .stop # awaiting an uncalled method runs it 309self .assertTrue (ending .gone ) 310 311async def test_a_job_awaited_here_does_not_wake_the_session (self ): 312rem = await self .connect () 313self .addCleanup (rem .close ) 314# The await crosses a round trip after the call's own reply; over a slow 315# link a quick job is done by then, and its wake must not reach the 316# idle session a backgrounded cell's waiting loop leaves behind. 317send = rem ._send 318 319async def slow_link (frame ): 320if frame .get ("await" ): 321await asyncio .sleep (0.5 ) 322await send (frame ) 323 324with patch .object (rem ,"_send" ,slow_link ): 325job = rem .run ("true" ) 326await job 327self .assertEqual (job .exit_code ,0 ) 328rem .run ("echo" ,"left-unread" ) 329for _ in range (100 ): 330if notices : 331break 332await asyncio .sleep (0.05 ) 333 [notice ]= notices 334self .assertEqual ( 335 [fact ["command" ]for fact in notice ["jobs" ]], ["echo left-unread" ] 336 ) 337# That job lives in the remote kernel's jobs, not the model's. 338self .assertIn ("rem.output.read(" ,notice ["text" ]) 339self .assertNotIn ("jobs[" ,notice ["text" ]) 340 341async def test_an_await_cut_short_leaves_the_job_waking_the_session (self ): 342rem = await self .connect () 343self .addCleanup (rem .close ) 344# A cell deadline cancels the await here; the interrupt must reach the 345# remote kernel's await too, or neither side ever sends the wake. 346job = rem .run (sys .executable ,"-c" ,"import time; time.sleep(1)" ) 347for _ in range (100 ): 348if job .id is not None : 349break 350await asyncio .sleep (0.05 ) 351with self .assertRaises (asyncio .TimeoutError ): 352await asyncio .wait_for (job ,0.2 ) 353for _ in range (100 ): 354if notices : 355break 356await asyncio .sleep (0.05 ) 357 [notice ]= notices 358self .assertEqual ([fact ["id" ]for fact in notice ["jobs" ]], [job .id ]) 359 360async def test_a_call_that_fails_unawaited_says_so_on_its_reference (self ): 361rem = await self .connect () 362self .addCleanup (rem .close ) 363# Left to wake the session, a job that never started must not look 364# pending forever. 365job = rem .run ("true" ,timeout = 0 ) 366for _ in range (100 ): 367if "failed" in repr (job ): 368break 369await asyncio .sleep (0.05 ) 370self .assertIn ("timeout is in seconds" ,repr (job )) 371with self .assertRaisesRegex ( 372remote .RemoteExecutionError ,"timeout is in seconds" 373 ): 374_ = job .id 375with self .assertRaisesRegex ( 376remote .RemoteExecutionError ,"timeout is in seconds" 377 ): 378await job 379 380async def test_a_file_larger_than_a_command_line_writes_and_reads_back (self ): 381rem = await self .connect () 382self .addCleanup (rem .close ) 383# Past macOS's whole ARG_MAX and Linux's 128 KiB per argument, so the 384# text must travel on stdin rather than in the remote command. 385content = "" .join (f"line { index } ü\n" for index in range (150_000 )) 386await rem .write ("big.lock" ,content ) 387self .assertEqual (Path (self .workspace ,"big.lock" ).read_text (),content ) 388self .assertEqual (await rem .read ("big.lock" ),content ) 389 390async def test_binary_stdin_and_bytes_writes_cross_intact (self ): 391rem = await self .connect () 392self .addCleanup (rem .close ) 393# Vent 9: bytes arguments crossed as {"__bytes__": b64} markers that 394# the remote kernel passed through as dicts, so run(..., stdin=b"...") 395# refused its own stdin and rem.write(path, b"...") called .encode() 396# on bytes. Every byte value, so the round trip cannot pass by luck. 397payload = bytes (range (256 ))* 64 398job = rem .run ( 399sys .executable , 400"-c" , 401"import hashlib, sys; d = sys.stdin.buffer.read();" 402"print(len(d), hashlib.sha256(d).hexdigest());" 403"open('sink.bin', 'wb').write(d)" , 404stdin = payload , 405 ) 406await job 407self .assertEqual (job .exit_code ,0 ) 408count ,digest = job .tail ().split () 409self .assertEqual (int (count ),len (payload )) 410self .assertEqual (digest ,hashlib .sha256 (payload ).hexdigest ()) 411self .assertEqual (Path (self .workspace ,"sink.bin" ).read_bytes (),payload ) 412blob = b"\x00\xff\xfe" + bytes (range (256 )) 413await rem .write ("blob.bin" ,blob ) 414self .assertEqual (Path (self .workspace ,"blob.bin" ).read_bytes (),blob ) 415# A binary read decoded with errors="replace", so a PNG came back as 416# U+FFFD text and the model fell back to base64 through a capped read. 417self .assertEqual (await rem .read ("blob.bin" ),blob ) 418 419async def test_awaiting_a_non_awaitable_remote_object_keeps_its_reference (self ): 420rem = await self .connect () 421self .addCleanup (rem .close ) 422# Vent 9: awaiting a call whose result is a live, non-awaitable object 423# (rem.browser.spawn() is the real case; a spawned Browser) made the 424# remote kernel `await` the settled object and raise TypeError, with 425# no handle back, so every retry leaked another process. The remote 426# jobs dict is the same shape: non-crossable, non-awaitable, retained. 427job = rem .run ("echo" ,"vent-nine" ) 428live = await rem .jobs .copy () 429self .assertIn ("remote reference" ,repr (live )) 430found = await live .get (job .id )# a method call on the live object 431self .assertEqual (found .exit_code ,0 ) 432 433async def test_an_unsettled_reference_argument_resolves_in_order (self ): 434rem = await self .connect () 435self .addCleanup (rem .close ) 436# Vent 9, the argument form: a reference still in flight crossed as 437# {"pending": id}, which the remote kernel passed through as a dict, 438# so run(..., stdin=<remote job>) saw stdin it refuses. It must 439# resolve to the producer's own object, like a pending target. 440producer = rem .run (sys .executable ,"-c" ,"print('pending-arg-fed', flush=True)" ) 441consumer = rem .run ("cat" ,stdin = producer ) 442await consumer 443self .assertEqual (consumer .exit_code ,0 ) 444self .assertEqual (consumer .tail ().strip (),"pending-arg-fed" ) 445 446async def test_session_tools_relay_to_this_daemon (self ): 447rem = await self .connect () 448self .addCleanup (rem .close ) 449items = await rem .work .list () 450self .assertEqual (items [0 ]["title" ],"from-the-daemon" ) 451with self .assertRaises (remote .RemoteExecutionError )as raised : 452await rem .work .get (99 ) 453self .assertEqual (raised .exception .ename ,"WorkError" ) 454 455async def test_remote_jobs_pipe_and_read_lines_like_local_ones (self ): 456rem = await self .connect () 457self .addCleanup (rem .close ) 458Path (self .workspace ,"sub" ).mkdir () 459Path (self .workspace ,"sub" ,"notes.txt" ).write_text ("b\na\nb\n" ) 460job = ( 461await rem .run ("cat" ,"notes.txt" ,cwd = "sub" ).pipe ("sort" ).pipe ("uniq" ,"-c" ) 462 ) 463self .assertEqual (job .exit_code ,0 ) 464self .assertEqual (job .tail (lines = 1 ).split (), ["2" ,"b" ]) 465self .assertEqual ((await job .head (lines = 1 )).split (), ["1" ,"a" ]) 466 467async def test_a_kernel_that_cannot_boot_degrades_with_a_warning (self ): 468warning = io .StringIO () 469with contextlib .redirect_stdout (warning ): 470rem = await self .connect (modules = ["definitely-not-a-module" ]) 471self .addCleanup (rem .close ) 472self .assertIn ("degraded" ,repr (rem )) 473self .assertIn ( 474"for pipes or shell syntax use rem.shell('git log --oneline | rg fix')" , 475warning .getvalue (), 476 ) 477Path (self .workspace ,"sub" ).mkdir () 478parity = rem .run ( 479sys .executable , 480"-c" , 481"import os, sys; print(os.path.basename(os.getcwd()), os.environ['WHO'], sys.stdin.read())" , 482cwd = "sub" , 483env = {"WHO" :"me" }, 484stdin = "fed" , 485 ) 486await parity 487self .assertEqual (parity .tail (),"sub me fed\n" ) 488self .assertEqual (parity .head (lines = 1 ),"sub me fed\n" ) 489piped = await rem .shell ("printf 'b\\na\\nb\\n' | sort | uniq -c" ,cwd = "sub" ) 490self .assertEqual (piped .exit_code ,0 ) 491self .assertEqual (piped .tail ().split (), ["1" ,"a" ,"2" ,"b" ]) 492self .assertEqual (piped .command ,"printf 'b\\na\\nb\\n' | sort | uniq -c" ) 493with self .assertRaisesRegex (TypeError ,"stdin is text or bytes" ): 494rem .shell ("cat" ,stdin = piped ) 495job = rem .run ("echo" ,"degraded-echo" ) 496await job 497self .assertEqual (job .poll (),0 ) 498self .assertEqual (job .returncode ,0 )# subprocess's spelling answers too 499self .assertIsNotNone (job .duration ) 500self .assertFalse (job .timed_out ) 501self .assertEqual (job .tail (),"degraded-echo\n" ) 502with self .assertRaises (AttributeError )as raised : 503rem .files 504self .assertIn ("degraded to ssh command mode" ,str (raised .exception )) 505tools = await rem .tools () 506self .assertIn ("degraded" ,tools ) 507 508async def test_stop_remote_job_routes_to_connection_and_resolves (self ): 509rem = await self .connect () 510self .addCleanup (rem .close ) 511job = rem .run (sys .executable ,"-c" ,"import time; time.sleep(60)" ) 512for _ in range (100 ): 513if job .id in rem ._live : 514break 515await asyncio .sleep (0.05 ) 516self .assertIn (job .id ,rem ._live ) 517result = await remote .stop_remote_job (job .id ) 518self .assertEqual (result , {"ok" :True }) 519for _ in range (100 ): 520if job .id not in rem ._live : 521break 522await asyncio .sleep (0.05 ) 523self .assertNotIn (job .id ,rem ._live ) 524 525async def test_stop_remote_job_unknown_returns_none (self ): 526result = await remote .stop_remote_job ("unknown-id" ) 527self .assertIsNone (result ) 528 529async def test_stop_remote_job_fallback_job_stops_locally (self ): 530rem = await self .connect (modules = ["definitely-not-a-module" ]) 531self .addCleanup (rem .close ) 532job = rem .run (sys .executable ,"-c" ,"import time; time.sleep(60)" ) 533self .assertIn (job .id ,remote ._fallback_jobs ) 534self .assertIn (job .id ,remote ._fallback_live ) 535result = await remote .stop_remote_job (job .id ) 536self .assertEqual (result , {"ok" :True }) 537self .assertIn ( 538"the remote command may still be running" , 539job .tail (), 540 ) 541self .assertNotIn (job .id ,remote ._fallback_jobs ) 542self .assertNotIn (job .id ,remote ._fallback_live ) 543 544 545if __name__ == "__main__" : 546unittest .main ()