okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
57a82be
main
1"""Sessions whose kernel lives on another host, reached over ssh. 2 3The daemon's PATH holds a loopback ssh that runs each command locally under 4its own "remote" HOME, so remote paths, the staged bundle and the run 5directories are all distinct from the daemon's. Hosts named `oldhost`, 6`locked` and `gone` stand for a python that is too old, a host that needs a 7person to sign in, and one that cannot be reached. 8""" 9 10import json 11import os 12import re 13import shlex 14import shutil 15import signal 16import subprocess 17import sys 18import threading 19import time 20import unittest 21import urllib .error 22import urllib .parse 23from pathlib import Path 24 25from harness import Albedo ,Provider ,exclusive ,operation_id ,python ,text 26 27FAKE_SSH = """#!/bin/sh 28printf '%s\\n' "$*" >> {log} 29query= 30while [ $# -gt 0 ]; do 31case "$1" in 32-o) shift 2 ;; 33-G) query=1; shift ;; 34-*) shift ;; 35*) break ;; 36esac 37done 38target=$1 39shift 40if [ -n "$query" ]; then 41# ssh -G only reads the config: muxed's names a ControlPath, no other does 42if [ "$target" = muxed ]; then 43echo "controlmaster auto" 44echo "controlpath {mux}" 45echo "controlpersist 1800" 46fi 47exit 0 48fi 49if [ "$target" = fakehost ] && [ -e {down} ]; then 50echo "ssh: connect to host fakehost port 22: Operation timed out" >&2 51exit 255 52fi 53case "$target" in 54gone) echo "ssh: connect to host gone port 22: Connection refused" >&2; exit 255 ;; 55locked) echo "locked: Permission denied (publickey)." >&2; exit 255 ;; 56slow-*) sleep 3; echo "ssh: connect to host $target port 22: Connection refused" >&2; exit 255 ;; 57esac 58case "$*" in 59*.part.*) [ -e {slow} ] && sleep 3 ;; 60esac 61path={path} 62[ "$target" = oldhost ] && path={old}:$path 63exec env -i HOME={home} PATH="$path" SHELL={bin}/loginsh /bin/sh -c "$*" 64""" 65 66# A login shell whose "profile" greets on stdout and stderr, as many do, and 67# sources nothing else: /etc/profile would put the system python ahead of the 68# fake host's. Every scenario runs through it, so a greeting must never reach 69# a framed stream, nor the daemon's log. 70LOGIN_SHELL = """#!/bin/sh 71if [ "$1" = -l ]; then 72shift 73echo "welcome to the fake host" 74echo "you have no mail" >&2 75fi 76exec /bin/sh "$@" 77""" 78 79# The daemon's own python3, logging what it runs, so a test can tell that a 80# termination ladder never ran on this machine. 81LOCAL_PYTHON = """#!/bin/sh 82printf '%s\\n' "$*" >> {log} 83exec {python} "$@" 84""" 85 86 87def write (path ,content ): 88path .write_text (content ) 89path .chmod (0o755 ) 90 91 92def processes (* needles ): 93"""Pids whose command line holds every needle.""" 94listing = subprocess .run ( 95 ["ps" ,"-axo" ,"pid=,command=" ],capture_output = True ,text = True ,check = True 96 ).stdout 97return [ 98int (line .split (None ,1 )[0 ]) 99for line in listing .splitlines () 100if all (needle in line for needle in needles ) 101 ] 102 103 104def wait_for (predicate ,timeout = 15 ,message = "condition not met" ): 105deadline = time .monotonic ()+ timeout 106while time .monotonic ()< deadline : 107result = predicate () 108if result : 109return result 110time .sleep (0.1 ) 111raise AssertionError (message ) 112 113 114# exclusive: sets daemon PATH for loopback SSH and local Python wrappers 115@exclusive 116class RemoteKernelTests (unittest .TestCase ): 117remote_home :Path 118ssh_log :Path 119mux :Path 120python_log :Path 121down :Path 122slow_stage :Path 123 124def setUp (self ): 125self .cells = [] 126 127def script (request ): 128if request ["input" ][- 1 ].get ("role" )== "user" and self .cells : 129return python (self .cells .pop (0 )) 130return text ("ok" ) 131 132def prepare (app ): 133root = app .root 134self .remote_home = root / "remote-home" 135self .remote_home .mkdir () 136self .ssh_log = root / "ssh.log" 137self .python_log = root / "local-python.log" 138self .down = root / "fakehost-down" 139self .slow_stage = root / "slow-stage" 140self .mux = root / "cm-muxed" 141real = shutil .which ("python3" )or sys .executable 142local ,remote ,old = ( 143root / "local-bin" , 144root / "remote-bin" , 145root / "old-bin" , 146 ) 147for directory in (local ,remote ,old ): 148directory .mkdir () 149write ( 150local / "ssh" , 151FAKE_SSH .format ( 152log = self .ssh_log , 153bin = remote , 154old = old , 155home = self .remote_home , 156down = self .down , 157slow = self .slow_stage , 158mux = self .mux , 159path = shlex .quote (f" { remote } { os . pathsep } { app . daemon . env [ 'PATH' ] } " ), 160 ), 161 ) 162write ( 163local / "python3" ,LOCAL_PYTHON .format (log = self .python_log ,python = real ) 164 ) 165write (remote / "loginsh" ,LOGIN_SHELL ) 166os .symlink (real ,remote / "python3" ) 167ssh_config = root / "user-home" / ".ssh" 168ssh_config .mkdir () 169 (ssh_config / "config" ).write_text ( 170"Host configured-box\n User someone\nHost *\n ServerAliveInterval 30\n" 171"Include extra.conf\n" 172 ) 173 (ssh_config / "extra.conf" ).write_text ("Host included-box other-*\n" ) 174write (old / "python3" ,"#!/bin/sh\necho 3.9\n" ) 175app .daemon .env ["PATH" ]= f" { local } { os . pathsep } { app . daemon . env [ 'PATH' ] } " 176 177self .provider = Provider (script ) 178self .addCleanup (self .provider .close ) 179self .app = Albedo (self .provider ,protocol = "responses" ,prepare = prepare ) 180self .app .__enter__ () 181self .addCleanup (self .app .__exit__ ,None ,None ,None ) 182self .project = self .remote_home / "proj" 183self .project .mkdir () 184 185def create (self ,workspace ): 186session = operation_id () 187with self .app .api ( 188f"/sessions/ { session } " , 189 { 190"kind" :"new" , 191"workspace" :workspace , 192"provider_profile" :self .app .profile , 193 }, 194method = "PUT" , 195headers = {"If-None-Match" :"*" }, 196 )as response : 197return json .load (response ) 198 199def host (self ,target ): 200query = urllib .parse .urlencode ({"target" :target }) 201with self .app .api ("/hosts?" + query )as response : 202 [host ]= json .load (response )["items" ] 203self .assertEqual (host ["target" ],target ) 204return host 205 206def group (self ,workspace ): 207query = urllib .parse .urlencode ({"workspace" :str (workspace )}) 208with self .app .api (f"/extensions/links/groups? { query } " )as response : 209return json .load (response ) 210 211def merge (self ,workspace ,other ): 212group = self .group (workspace )["configuration_resource" ] 213target = self .group (other )["configuration_resource" ] 214with self .app .api ( 215group ["url" ], 216 {"other_workspace" :str (other ),"other_etag" :target ["etag" ]}, 217headers = {"If-Match" :group ["etag" ]}, 218 )as response : 219return json .load (response ) 220 221def cell (self ,session ,code ,* ,during = None ): 222"""Run one cell through a model turn and return its tool result.""" 223self .cells .append (code ) 224self .app .prompt (session ,"run it" ).close () 225if during : 226during () 227self .app .idle (session ,timeout = 60 ) 228results = [ 229json .loads (part ["value" ]) 230for entry in self .app .history (session )["items" ] 231if entry ["kind" ]== "tool_result" and entry ["tool" ]["name" ]== "python" 232for part in entry ["content" ] 233if part ["kind" ]== "json" and part ["field" ]== "result" 234 ] 235return results [- 1 ] 236 237def test_a_remote_kernel_runs_in_the_remote_folder_from_the_staged_bundle (self ): 238session = self .create (f"fakehost: { self . project } " )["id" ] 239result = self .cell ( 240session , 241"import os, albedo_bundle\n(os.getcwd(), os.environ['HOME'], str(albedo_bundle.ROOT))" , 242 ) 243self .assertEqual (result ["status" ],"ok" ,result ) 244cwd ,home ,bundle = eval (result ["value" ]) 245self .assertEqual (os .path .realpath (cwd ),os .path .realpath (self .project )) 246self .assertEqual (home ,str (self .remote_home )) 247self .assertTrue ( 248bundle .startswith (f" { os . path . realpath ( self . remote_home ) } /.albedo-remote/" ) 249or bundle .startswith (f" { self . remote_home } /.albedo-remote/" ), 250bundle , 251 ) 252# The model is told where it runs. 253system = json .dumps (self .provider .requests [0 ]["request" ]) 254self .assertIn ("execute on fakehost (" ,system ) 255log = (self .app .home / "daemon.log" ).read_text (errors = "replace" ) 256self .assertNotIn ("welcome to the fake host" ,log ) 257self .assertNotIn ("you have no mail" ,log ) 258 259def test_remote_job_output_is_not_a_silently_shortened_patch (self ): 260session = self .create (str (self .app .workspace ))["id" ] 261result = self .cell ( 262session , 263r"""import sys 264rem = await remote.connect('fakehost') 265fallback = await remote.connect('oldhost') 266starts = [('local', run), ('remote', rem.run), ('fallback', fallback.run)] 267expected = ('first file\n' + 'line αβ\n' * 5000 + 'second file\n' + 'other line\n' * 8000).encode() 268program = "import sys; sys.stdout.buffer.write(('first file\\n' + 'line αβ\\n' * 5000 + 'second file\\n' + 'other line\\n' * 8000).encode())" 269for label, start in starts: 270job = start(sys.executable, '-c', program) 271await job 272assert job.tail(60000) == expected.decode()[-60000:], label 273assert await job.head(40000) == expected.decode()[:40000], label 274assert await job.read() == expected.decode(), label 275assert job.tail(65536) == expected.decode()[-65536:], label 276assert await job.read(offset=17, limit=4000) == expected[17:4017].decode(errors='ignore'), label 277# Byte offsets remain byte offsets, even when a page cuts through UTF-8. 278offset = expected.index('α'.encode()) + 1 279assert await job.read(offset=offset, limit=9) == expected[offset:offset+9].decode(errors='ignore'), label 280for preview in (job.head, job.tail): 281try: 282await preview(3_000_000) 283except Exception as error: 284assert 'job.read()' in str(error) and 'job.save' in str(error), str(error) 285else: 286raise AssertionError('silently shortened a preview: ' + label) 287path = await job.save(label + '.patch') 288actual = await rem.read(path) if label == 'remote' else Path(path).read_bytes() 289if isinstance(actual, str): 290actual = actual.encode() 291assert actual == expected, label 292 293async def refuses(call, *args, message, **kwargs): 294try: 295await call(*args, **kwargs) 296except Exception as error: 297assert message in str(error), str(error) 298else: 299raise AssertionError('accepted incomplete output') 300 301for label, start in starts: 302job = start(sys.executable, '-c', "import sys; sys.stdout.buffer.write(b'begin\\xff\\x00' + b'x' * 140000 + b'end')") 303await job 304assert '\ufffd' in await job.read(), label 305assert await job.tail(0) == '' and await job.head(lines=0) == '', label 306await refuses(job.read, limit=65537, message='65536') 307# No newline in the window: a line request must not return a fragment. 308await refuses(job.tail, lines=1, message='job.read()') 309await refuses(job.head, lines=1, message='job.read()') 310path = await job.save(label + '.bin') 311actual = await rem.read(path) if label == 'remote' else Path(path).read_bytes() 312assert actual == b'begin\xff\x00' + b'x' * 140000 + b'end', label 313overflow = start(sys.executable, '-c', "import sys; sys.stdout.buffer.write(b'z' * (2097152 + 10) + b'\\xff\\x00end')") 314await overflow 315await refuses(overflow.read, message='paginate with') 316assert await overflow.read(offset=1048576, limit=10) == 'z' * 10, label 317assert await overflow.read(offset=1048576-2, limit=10) == 'z' * 10, label 318page_reader = rem.output.read if label == 'remote' else output.read 319assert await page_reader(overflow.id, offset=1048576, limit=10) == 'z' * 10, label 320pages = [await overflow.read(offset=offset, limit=65536) 321for offset in range(0, 2097152 + 15, 65536)] 322assert ''.join(pages) == 'z' * (2097152 + 10) + '\x00end', label 323assert await overflow.read(offset=2097152+15, limit=65536) == '', label 324await overflow.save(path) 325saved = await rem.read(path) if label == 'remote' else Path(path).read_bytes() 326assert saved == b'z' * (2097152 + 10) + b'\xff\x00end', label 327# The disk cap is explicit; stored pages remain accessible, lost pages refuse. 328capped = start(sys.executable, '-c', "import sys; sys.stdout.buffer.write(b'c' * (16777216 + 1))") 329await capped 330assert await capped.read(offset=16777217, limit=0) == '', label 331await refuses(capped.read, message='refusing incomplete output') 332await refuses(capped.save, path, message='refusing incomplete output') 333preserved = await rem.read(path) if label == 'remote' else Path(path).read_bytes() 334assert preserved == saved, 'overwrote destination with partial output: ' + label 335assert await capped.read(offset=16777216-10, limit=10) == 'c' * 10, label 336await refuses(capped.read, offset=16777216, limit=1, message='cannot be recovered by pagination') 337await refuses(page_reader, capped.id, offset=16777216-2, limit=10, message='cannot be recovered by pagination') 338unicode = start(sys.executable, '-c', "print('é' * 70000, end='')") 339await unicode 340await refuses(unicode.tail, 60000, message='job.read()') 341await refuses(unicode.head, 60000, message='job.read()') 342assert await unicode.tail(100) == 'é' * 100, label 343 344# A running job must not overwrite a completed artifact. 345protected = Path('protected.bin') 346protected.write_bytes(b'keep me') 347running = run(sys.executable, '-c', 'import time; time.sleep(0.5)') 348await refuses(running.read, message='await job') 349await refuses(running.save, protected, message='await job') 350assert protected.read_bytes() == b'keep me' 351await running 352# Streaming to a file recovers output larger than the retained buffers. 353streamed = run(sys.executable, '-c', "print('s' * 2000000, end='')").pipe('tee', 'whole.txt') 354await streamed 355assert Path('whole.txt').read_bytes() == b's' * 2000000 356# A failed atomic rename leaves the destination and no temporary output file. 357directory = Path('not-a-file') 358directory.mkdir() 359(directory / 'keep').write_bytes(b'preserved') 360before = set(Path('.').iterdir()) 361await refuses(running.save, directory, message='directory') 362assert set(Path('.').iterdir()) == before 363assert (directory / 'keep').read_bytes() == b'preserved' 364print('refusals preserve destinations and exact binary output') 365 366await rem.close() 367await fallback.close() 368print('complete output preserved for all three job kinds') 369""" , 370 ) 371self .assertEqual (result ["status" ],"ok" ,result ) 372self .assertIn ("complete output preserved" ,result ["output" ]) 373self .assertIn ("refusals preserve destinations" ,result ["output" ]) 374 375def test_job_spill_is_live_and_failures_preserve_artifacts (self ): 376session = self .create (str (self .app .workspace ))["id" ] 377result = self .cell ( 378session , 379r"""import albedo_capture 380import tempfile 381import time 382from unittest.mock import patch 383 384async def refuses(call, *args, message, **kwargs): 385try: 386await call(*args, **kwargs) 387except Exception as error: 388assert message in str(error), str(error) 389else: 390raise AssertionError('accepted incomplete output') 391 392protected = Path('protected-spill.bin') 393protected.write_bytes(b'keep me') 394release = Path('release-writer').absolute() 395program = f'''import sys, time 396from pathlib import Path 397sys.stdout.buffer.write(b'x' * 2097152) 398sys.stdout.flush() 399while not Path({str(release)!r}).exists(): 400time.sleep(0.01) 401sys.stdout.buffer.write(b'end') 402sys.stdout.flush() 403''' 404live = run(sys.executable, '-c', program) 405async def ready(): 406while live.capture.seen < 2097152: 407await asyncio.sleep(0.01) 408await asyncio.wait_for(ready(), 5) 409assert live.duration is None 410assert live.read(offset=1048576-2, limit=10) == 'x' * 10 411assert output.read(live.id, offset=2097152-10, limit=10) == 'x' * 10 412await refuses(live.save, protected, message='await job') 413assert protected.read_bytes() == b'keep me' 414assert Path(live.capture.spill).stat().st_mode & 0o077 == 0 415 416# Simulate a disk write failure after a valid spill prefix has been captured. 417with patch.object(live.capture._spill_file, 'write', side_effect=OSError('disk full')): 418release.touch() 419await live 420assert live.capture._spill_file is None 421assert live.tail(3) == 'end' 422await refuses(live.read, offset=1048576, limit=1, message='refusing incomplete output') 423await refuses(live.save, protected, message='refusing incomplete output') 424assert protected.read_bytes() == b'keep me' 425 426# Creation failure keeps previews usable, and never retries from a lost prefix. 427blocked = Path('not-a-spill-directory') 428blocked.write_text('blocked') 429with patch.object(albedo_capture, 'SPILL_DIR', str(blocked)): 430failed = run(sys.executable, '-c', "print('f' * 2097152, end='')") 431await failed 432assert failed.tail(10) == 'f' * 10 433assert failed.capture.spill is None 434await refuses(failed.read, offset=1048576, limit=1, message='refusing incomplete output') 435await refuses(failed.save, protected, message='refusing incomplete output') 436assert protected.read_bytes() == b'keep me' 437 438# A missing or shortened spill must not become a short page or a partial save. 439for damage in ('shorten', 'remove'): 440job = run(sys.executable, '-c', "print('s' * 2097152, end='')") 441await job 442assert job.capture._spill_file is None 443spill = Path(job.capture.spill) 444if damage == 'shorten': 445spill.write_bytes(b'short') 446else: 447spill.unlink() 448before = set(Path('.').iterdir()) 449await refuses(job.read, offset=1048576, limit=10, 450message='incomplete' if damage == 'shorten' else 'No such file') 451await refuses(job.save, protected, 452message='incomplete' if damage == 'shorten' else 'No such file') 453assert protected.read_bytes() == b'keep me' 454assert set(Path('.').iterdir()) == before 455 456# Pruning deletes only expired spill files, not fresh files or directories. 457with tempfile.TemporaryDirectory() as directory: 458old = Path(directory, 'old.txt') 459old.write_text('old') 460fresh = Path(directory, 'fresh.txt') 461fresh.write_text('fresh') 462nested = Path(directory, 'nested') 463nested.mkdir() 464(nested / 'keep.txt').write_text('keep') 465stale = time.time() - albedo_capture.SPILL_KEEP - 1 466os.utime(old, (stale, stale)) 467os.utime(nested, (stale, stale)) 468previous = albedo_capture.SPILL_DIR 469try: 470albedo_capture.prune_spills(directory) 471finally: 472albedo_capture.SPILL_DIR = previous 473assert not old.exists() and fresh.read_text() == 'fresh' 474assert (nested / 'keep.txt').read_text() == 'keep' 475print('live spill pages, disk failures, atomic saves, and pruning verified') 476""" , 477 ) 478self .assertEqual (result ["status" ],"ok" ,result ) 479self .assertIn ("pruning verified" ,result ["output" ]) 480 481def test_a_first_visit_says_it_is_copying_the_kernel (self ): 482self .slow_stage .touch ()# each copy of the bundle takes 3 s 483session = self .create (f"fakehost: { self . project } " )["id" ] 484seen = set () 485 486def watch (): 487deadline = time .monotonic ()+ 15 488while time .monotonic ()< deadline : 489with self .app .api (f"/sessions/ { session } ?tail=0" )as response : 490kernel = json .load (response )["kernel" ] 491seen .add (kernel ["stage" ]) 492if kernel ["state" ]== "attached" : 493return 494time .sleep (0.2 ) 495 496result = self .cell (session ,"1" ,during = watch ) 497self .assertEqual (result ["status" ],"ok" ,result ) 498self .assertIn ("staging" ,seen ) 499 500def test_a_bridge_killed_mid_cell_reattaches_over_ssh (self ): 501session = self .create (f"fakehost: { self . project } " )["id" ] 502first = self .cell (session ,"import os\nos.getpid()" ) 503 (bridge ,)= processes (f" { self . remote_home } /.albedo-remote/" ,"albedo_bridge.py" ) 504 505def kill_bridge (): 506time .sleep (1 ) 507os .kill (bridge ,signal .SIGKILL ) 508 509slept = self .cell ( 510session , 511"import threading\nthreading.Event().wait(3)\nprint('woke')\nos.getpid()" , 512during = kill_bridge , 513 ) 514self .assertEqual (slept ["status" ],"ok" ,slept ) 515self .assertEqual (slept ["output" ].strip (),"woke" ) 516self .assertEqual (slept ["value" ],first ["value" ]) 517bridges = [ 518line 519for line in self .ssh_log .read_text ().splitlines () 520if "albedo_bridge.py" in line 521 ] 522self .assertGreaterEqual (len (bridges ),2 ,"the reattach did not go over ssh" ) 523 524def test_a_remote_kernel_outlives_a_daemon_restart (self ): 525session = self .create (f"fakehost: { self . project } " )["id" ] 526first = self .cell (session ,"import os\nsurvivor = 41\nos.getpid()" ) 527self .app .restart (crash = True ) 528after = self .cell (session ,"survivor += 1\n(survivor, os.getpid())" ) 529self .assertEqual (after ["value" ],f"(42, { first [ 'value' ] } )" ) 530 531def test_a_kernel_survives_ssh_being_down_while_the_daemon_restarts (self ): 532session = self .create (f"fakehost: { self . project } " )["id" ] 533first = self .cell (session ,"import os\nsurvivor = 41\nos.getpid()" ) 534self .down .touch () 535# Back a few seconds after the daemon came up, while its startup 536# attach and the turn's are still failing. 537threading .Timer (4 ,self .down .unlink ).start () 538self .app .restart (crash = True ) 539after = self .cell (session ,"survivor += 1\n(survivor, os.getpid())" ) 540self .assertEqual (after ["value" ],f"(42, { first [ 'value' ] } )" ) 541 542def test_a_remote_sessions_memory_is_the_daemons_and_links_with_a_local_one (self ): 543remote = f"fakehost: { self . project } " 544session = self .create (remote )["id" ] 545self .cell (session ,"memory.append('the gate runs on fakehost')" ) 546slug = re .sub (r"[^A-Za-z0-9]" ,"-" ,remote ) 547written = self .app .home / "memories" / slug / "memory.md" 548self .assertEqual (written .read_text (),"the gate runs on fakehost\n" ) 549self .assertFalse ((self .remote_home / ".albedo" ).exists ()) 550 551# The same project checked out here, linked: it reads the remote one. 552checkout = self .app .root / "checkout" 553checkout .mkdir () 554local = self .create (str (checkout ))["id" ] 555merged = self .merge (checkout ,remote ) 556self .assertIn (remote ,merged ["resource" ]["value" ]["members" ]) 557found = self .cell (local ,"print(memory.grep('gate'))" ) 558self .assertIn (f"[ { remote } ] memory.md:1: the gate runs" ,found ["output" ]) 559 560def test_a_linked_remote_folder_is_checked_on_its_host (self ): 561checkout = self .app .root / "checkout" 562checkout .mkdir () 563self .create (str (checkout )) 564 565def presence (): 566return { 567item ["workspace" ]:item for item in self .group (checkout )["presence" ] 568 } 569 570present = f"fakehost: { self . project } " 571missing = f"fakehost: { self . remote_home } /missing" 572for member in (present ,missing ,"gone:/srv/app" ): 573self .merge (checkout ,member ) 574found = presence () 575self .assertTrue (found [present ]["exists" ]) 576self .assertFalse (found [missing ]["exists" ]) 577self .assertEqual (found ["gone:/srv/app" ]["host" ]["state" ],"unreachable" ) 578self .assertIn ( 579"Connection refused" ,found ["gone:/srv/app" ]["host" ]["detail" ]["detail" ] 580 ) 581 582# A host that stops answering is not a gone folder. 583self .down .touch () 584found = presence () 585self .assertIsNone (found [present ]["exists" ]) 586self .assertIsNone (found [missing ]["exists" ]) 587with self .app .api ("/hosts/fakehost/probe" , {})as response : 588self .assertEqual (response .status ,202 ) 589deadline = time .monotonic ()+ 15 590while ( 591self .host ("fakehost" )["state" ]== "probing" and time .monotonic ()< deadline 592 ): 593time .sleep (0.05 ) 594found = presence () 595self .assertEqual (found [present ]["host" ]["state" ],"unreachable" ) 596self .assertEqual (found [missing ]["host" ]["state" ],"unreachable" ) 597self .down .unlink () 598with self .app .api ("/hosts/fakehost/probe" , {})as response : 599self .assertEqual (response .status ,202 ) 600deadline = time .monotonic ()+ 15 601while ( 602self .host ("fakehost" )["state" ]== "probing" and time .monotonic ()< deadline 603 ): 604time .sleep (0.05 ) 605self .assertEqual (self .host ("fakehost" )["state" ],"ready" ) 606 607# A session opening with the host's probe fresh is told what is gone. 608self .cell (self .create (str (checkout ))["id" ],"1" ) 609instructions = self .provider .requests [- 1 ]["request" ]["instructions" ] 610self .assertIn (f" { missing } (its folder is gone)" ,instructions ) 611self .assertNotIn (f" { present } (its folder is gone)" ,instructions ) 612 613def test_linked_hosts_are_checked_side_by_side (self ): 614checkout = self .app .root / "slow-checkout" 615checkout .mkdir () 616self .create (str (checkout )) 617 618for host in ("slow-a" ,"slow-b" ): 619self .merge (checkout ,f" { host } :/srv" ) 620# Each host takes 3 s to refuse; one after the other would be 6. 621began = time .monotonic () 622rows = [ 623item 624for item in self .group (checkout )["presence" ] 625if item ["workspace" ]!= str (checkout ) 626 ] 627self .assertLess (time .monotonic ()- began ,5.5 ) 628self .assertEqual ([row ["host" ]["state" ]for row in rows ], ["unreachable" ]* 2 ) 629 630def test_a_remote_home_is_resolved_and_stored_absolute (self ): 631created = self .create ("fakehost:~/proj" ) 632self .assertEqual (created ["workspace" ],f"fakehost: { self . project } " ) 633with self .assertRaises (urllib .error .HTTPError )as refused : 634self .create ("gone:~/proj" ) 635self .assertEqual (refused .exception .code ,503 ) 636self .assertIn ("Connection refused" ,refused .exception .read ().decode ()) 637 638def test_hosts_report_what_ssh_found (self ): 639def warm (host ): 640with self .app .api (f"/hosts/ { host } /probe" , {})as response : 641self .assertEqual (response .status ,202 ) 642observation = json .load (response ) 643deadline = time .monotonic ()+ 15 644while observation ["state" ]== "probing" and time .monotonic ()< deadline : 645time .sleep (0.05 ) 646observation = self .host (host ) 647self .assertNotEqual (observation ["state" ],"probing" ,observation ) 648return observation 649 650ready = warm ("fakehost" ) 651self .assertEqual (ready ["state" ],"ready" ,ready ) 652self .assertEqual (ready ["home" ],str (self .remote_home )) 653self .assertTrue (ready ["os" ]and ready ["architecture" ]) 654old = warm ("oldhost" ) 655self .assertEqual (old ["state" ],"unsupported" ,old ) 656self .assertIn ("python >= 3.11" ,old ["detail" ]["detail" ]) 657self .assertEqual (warm ("locked" )["state" ],"needs_auth" ) 658self .assertEqual (warm ("gone" )["state" ],"unreachable" ) 659# A host whose ssh config names a ControlPath rides that master, so the 660# user's own ssh and albedo share one sign-in. 661self .assertEqual (warm ("muxed" )["state" ],"ready" ) 662muxed = [ 663line 664for line in self .ssh_log .read_text ().splitlines () 665if " muxed " in line and "-G" not in line 666 ] 667self .assertTrue (muxed ) 668for line in muxed : 669self .assertIn (f"ControlPath= { self . mux } " ,line ) 670self .assertIn ("ControlPersist=1800 " ,line ) 671self .assertEqual (self .host ("fakehost" )["state" ],"ready" ) 672 673# A bracketed IPv6 host travels percent-encoded in the probe's path and 674# is probed as itself, the same host a target query names. 675with self .app .api ("/hosts/%5B::1%5D/probe" , {})as response : 676self .assertEqual (response .status ,202 ) 677self .assertEqual (json .load (response )["target" ],"[::1]" ) 678self .host ("[::1]" ) 679 680# A turn at a host that needs a sign-in waits with ssh's words, and 681# the host names the control path the tui's sign-in opens. 682session = self .create ("locked:/srv/app" )["id" ] 683with self .app .prompt (session ,"hello" )as response : 684input_id = json .load (response )["id" ] 685deadline = time .monotonic ()+ 10 686while time .monotonic ()< deadline : 687with self .app .api (f"/sessions/ { session } /inputs/ { input_id } " )as response : 688receipt = json .load (response ) 689if receipt ["blocking_reason" ]: 690break 691time .sleep (0.05 ) 692self .assertEqual (receipt ["delivery" ],"pending" ,receipt ) 693self .assertIn ("Permission denied" ,receipt ["blocking_reason" ]["detail" ]) 694host = self .host ("locked" ) 695self .assertEqual (host ["state" ],"needs_auth" ,host ) 696self .assertTrue (host ["authentication" ]["control_path" ].endswith ("/%C" ),host ) 697 698def test_a_remote_kernel_is_ended_on_its_host_never_here (self ): 699session = self .create (f"fakehost: { self . project } " )["id" ] 700started = self .cell ( 701session , 702"import asyncio\njob = run('sleep', '7777')\nawait asyncio.sleep(0.5)\njob.exit_code" , 703 ) 704self .assertEqual (started ["status" ],"ok" ,started ) 705self .assertTrue (processes ("sleep 7777" )) 706elsewhere = self .remote_home / "elsewhere" 707elsewhere .mkdir () 708with self .app .api (f"/sessions/ { session } ?view=configuration" )as response : 709configuration = json .load (response ) 710revision = response .headers ["ETag" ] 711self .app .api ( 712f"/sessions/ { session } ?view=configuration" , 713 { 714"workspace" :f"fakehost: { elsewhere } " , 715"family_revision" :configuration ["family_revision" ], 716 }, 717method = "PATCH" , 718headers = {"If-Match" :revision }, 719 ).close () 720deadline = time .monotonic ()+ 15 721while processes ("sleep 7777" )and time .monotonic ()< deadline : 722time .sleep (0.1 ) 723self .assertFalse (processes ("sleep 7777" )) 724remote_ladder = [ 725line 726for line in self .ssh_log .read_text ().splitlines () 727if "albedo_signal.py" in line 728 ] 729self .assertTrue (remote_ladder ,"the kernel was not ended over ssh" ) 730local = self .python_log .read_text ()if self .python_log .exists ()else "" 731self .assertNotIn ("albedo_signal.py" ,local ) 732 733def test_jobs_started_over_a_remote_connection_are_listed_like_local_ones (self ): 734session = self .create (str (self .app .workspace ))["id" ] 735started = self .cell ( 736session , 737"import asyncio\n" 738"rem = await remote.connect('fakehost')\n" 739"fallback = await remote.connect('oldhost')\n" 740"rem.run('sleep', '8881')\n" 741"fallback.run('sleep', '6')\n" 742"await asyncio.sleep(1)" , 743 ) 744self .assertEqual (started ["status" ],"ok" ,started ) 745kernel = json .load (self .app .api (f"/sessions/ { session } ?tail=0" ))["kernel" ] 746self .assertEqual (kernel ["live_job_count" ],2 ,kernel ) 747running = {job ["command" ]:job for job in kernel ["running_jobs" ]} 748self .assertEqual (sorted (running ), ["sleep 6" ,"sleep 8881" ],kernel ) 749for job in running .values (): 750self .assertLess (abs (job ["started_at" ]- time .time ()* 1000 ),60_000 ) 751with self .app .api (f"/extensions/run/sessions/ { session } /jobs" )as response : 752page = json .load (response )["page" ] 753self .assertEqual (page ["summary" ],"2 background jobs running" ) 754self .assertEqual ( 755sorted (row ["text" ]for row in page ["rows" ]), ["sleep 6" ,"sleep 8881" ] 756 ) 757ended = self .cell (session ,"await rem.close()\nawait asyncio.sleep(1)" ) 758self .assertEqual (ended ["status" ],"ok" ,ended ) 759kernel = json .load (self .app .api (f"/sessions/ { session } ?tail=0" ))["kernel" ] 760self .assertNotIn ( 761"sleep 8881" , [job ["command" ]for job in kernel ["running_jobs" ]] 762 ) 763 764def test_remote_background_jobs_can_be_stopped_through_run_api (self ): 765session = self .create (str (self .app .workspace ))["id" ] 766started = self .cell ( 767session , 768"import asyncio\n" 769"rem = await remote.connect('fakehost')\n" 770"fallback = await remote.connect('oldhost')\n" 771"rem_job = rem.run('sleep', '8892')\n" 772"fallback_job = fallback.run('sleep', '8893')\n" 773"await asyncio.sleep(1)" , 774 ) 775self .assertEqual (started ["status" ],"ok" ,started ) 776 777def cleanup (): 778try : 779self .cell ( 780session , 781"try:\n" 782" await rem_job.stop()\n" 783"except Exception:\n" 784" pass\n" 785"try:\n" 786" await fallback_job.stop()\n" 787"except Exception:\n" 788" pass\n" 789"try:\n" 790" await rem.close()\n" 791"except Exception:\n" 792" pass\n" 793"try:\n" 794" await fallback.close()\n" 795"except Exception:\n" 796" pass\n" , 797 ) 798except Exception : 799pass 800for pid in processes ("sleep 8892" )+ processes ("sleep 8893" ): 801try : 802os .kill (pid ,signal .SIGKILL ) 803except OSError : 804pass 805 806self .addCleanup (cleanup ) 807 808kernel = json .load (self .app .api (f"/sessions/ { session } ?tail=0" ))["kernel" ] 809self .assertEqual (kernel ["live_job_count" ],2 ,kernel ) 810running = {job ["command" ]:job for job in kernel ["running_jobs" ]} 811self .assertEqual (sorted (running ), ["sleep 8892" ,"sleep 8893" ],kernel ) 812rem_job_id = running ["sleep 8892" ]["id" ] 813fallback_job_id = running ["sleep 8893" ]["id" ] 814self .assertTrue (wait_for (lambda :processes ("sleep 8892" ),timeout = 5 )) 815 816with self .app .api (f"/extensions/run/sessions/ { session } /jobs" )as response : 817page = json .load (response )["page" ] 818self .assertEqual (page ["summary" ],"2 background jobs running" ) 819self .assertEqual ( 820sorted (row ["text" ]for row in page ["rows" ]), ["sleep 8892" ,"sleep 8893" ] 821 ) 822 823# 3. Stopping an unknown job id still fails with 404 824with self .assertRaises (urllib .error .HTTPError )as missing : 825self .app .api ( 826f"/extensions/run/sessions/ { session } /jobs/missing/stop" , 827method = "POST" , 828 ) 829self .assertEqual (missing .exception .code ,404 ) 830 831# 1. Stop the kernel-mode remote job through the run service API 832with self .app .api ( 833f"/extensions/run/sessions/ { session } /jobs/ { rem_job_id } /stop" , 834method = "POST" , 835 )as response : 836self .assertEqual (response .status ,200 ) 837self .assertEqual (json .load (response ), {"stopped" :rem_job_id }) 838 839wait_for ( 840lambda :not processes ("sleep 8892" ), 841timeout = 15 , 842message = "remote sleep 8892 process did not terminate after stop" , 843 ) 844 845def rem_cleared_from_kernel (): 846with self .app .api (f"/sessions/ { session } ?tail=0" )as resp : 847k = json .load (resp )["kernel" ] 848ids = [job ["id" ]for job in k ["running_jobs" ]] 849return rem_job_id not in ids and k ["live_job_count" ]== 1 850 851wait_for ( 852rem_cleared_from_kernel , 853timeout = 15 , 854message = "kernel observation did not drop stopped remote job" , 855 ) 856 857def rem_cleared_from_page (): 858with self .app .api (f"/extensions/run/sessions/ { session } /jobs" )as resp : 859p = json .load (resp )["page" ] 860ids = [row ["id" ]for row in p ["rows" ]] 861return rem_job_id not in ids and p ["summary" ]== "1 background job running" 862 863wait_for ( 864rem_cleared_from_page , 865timeout = 15 , 866message = "jobs page did not drop stopped remote job" , 867 ) 868 869# 2. Stop the degraded fallback job through the run service API 870with self .app .api ( 871f"/extensions/run/sessions/ { session } /jobs/ { fallback_job_id } /stop" , 872method = "POST" , 873 )as response : 874self .assertEqual (response .status ,200 ) 875self .assertEqual (json .load (response ), {"stopped" :fallback_job_id }) 876 877def fallback_cleared_from_kernel (): 878with self .app .api (f"/sessions/ { session } ?tail=0" )as resp : 879k = json .load (resp )["kernel" ] 880ids = [job ["id" ]for job in k ["running_jobs" ]] 881return fallback_job_id not in ids and k ["live_job_count" ]== 0 882 883wait_for ( 884fallback_cleared_from_kernel , 885timeout = 15 , 886message = "kernel observation did not drop stopped fallback job" , 887 ) 888 889def fallback_cleared_from_page (): 890with self .app .api (f"/extensions/run/sessions/ { session } /jobs" )as resp : 891p = json .load (resp )["page" ] 892ids = [row ["id" ]for row in p ["rows" ]] 893return fallback_job_id not in ids and p ["rows" ]== [] 894 895wait_for ( 896fallback_cleared_from_page , 897timeout = 15 , 898message = "jobs page did not drop stopped fallback job" , 899 ) 900 901output = self .cell (session ,"fallback_job.tail()" ) 902self .assertEqual (output ["status" ],"ok" ,output ) 903self .assertIn ( 904"the remote command may still be running" , 905output .get ("value" ,"" )+ output .get ("output" ,"" ), 906 ) 907 908ended = self .cell ( 909session , 910"await rem.close()\nawait fallback.close()\nawait asyncio.sleep(0.5)" , 911 ) 912self .assertEqual (ended ["status" ],"ok" ,ended ) 913 914def test_hosts_list_recent_and_configured_hosts_without_probing (self ): 915self .create (f"fakehost: { self . project } " ) 916with self .app .api ("/hosts" )as response : 917hosts = json .load (response )["items" ] 918by_host = {entry ["target" ]:entry for entry in hosts } 919self .assertTrue ({"fakehost" ,"configured-box" ,"included-box" }<= set (by_host )) 920self .assertNotIn ("*" ,by_host ) 921self .assertFalse ([host for host in by_host if "*" in host ]) 922# Nothing was probed just to list them. 923self .assertEqual (by_host ["configured-box" ]["state" ],"unknown" ) 924self .assertIsNone (by_host ["configured-box" ]["observed_at" ]) 925 926def browse (self ,location ,* ,preview = False ): 927query = {"location" :str (location )} 928if preview : 929query ["include" ]= "preview" 930with self .app .api ("/workspaces?" + urllib .parse .urlencode (query ))as response : 931return json .load (response ) 932 933def assert_same_answers (self ,tree ): 934"""Remote and local inspection preserve the same filesystem facts.""" 935remote = self .browse (f"fakehost: { tree } " ,preview = True ) 936local = self .browse (tree ,preview = True ) 937self .assertEqual (remote ["directory" ],f"fakehost: { tree } " ) 938self .assertEqual (remote ["home" ],f"fakehost: { self . remote_home } " ) 939self .assertEqual ( 940 [ 941 {key :row [key ]for key in ("name" ,"vcs" ,"hidden" ,"modified_at" )} 942for row in remote ["items" ] 943 ], 944 [ 945 {key :row [key ]for key in ("name" ,"vcs" ,"hidden" ,"modified_at" )} 946for row in local ["items" ] 947 ], 948 ) 949remote_preview = remote ["preview" ] 950local_preview = local ["preview" ] 951for key in ("languages" ,"more" ): 952self .assertEqual (remote_preview [key ],local_preview [key ]) 953self .assertEqual (len (remote_preview ["tree" ]),len (local_preview ["tree" ])) 954for remote_row ,local_row in zip (remote_preview ["tree" ],local_preview ["tree" ]): 955self .assertEqual ( 956remote_row ["location" ],"fakehost:" + local_row ["location" ] 957 ) 958self .assertEqual ( 959 { 960key :value 961for key ,value in remote_row .items () 962if key not in {"location" ,"children" } 963 }, 964 { 965key :value 966for key ,value in local_row .items () 967if key not in {"location" ,"children" } 968 }, 969 ) 970self .assertEqual (len (remote_row ["children" ]),len (local_row ["children" ])) 971for remote_child ,local_child in zip ( 972remote_row ["children" ],local_row ["children" ] 973 ): 974self .assertEqual ( 975remote_child ["location" ],"fakehost:" + local_child ["location" ] 976 ) 977self .assertEqual ( 978 { 979key :value 980for key ,value in remote_child .items () 981if key != "location" 982 }, 983 { 984key :value 985for key ,value in local_child .items () 986if key != "location" 987 }, 988 ) 989remote_repo = remote_preview ["repository" ] 990local_repo = local_preview ["repository" ] 991self .assertEqual (remote_repo is None ,local_repo is None ) 992if remote_repo is not None : 993self .assertEqual (remote_repo ["root" ],"fakehost:" + local_repo ["root" ]) 994self .assertEqual ( 995 {key :value for key ,value in remote_repo .items ()if key != "root" }, 996 {key :value for key ,value in local_repo .items ()if key != "root" }, 997 ) 998 999def make_tree (self ,name ): 1000tree = self .remote_home / name 1001 (tree / "src" / "deep" ).mkdir (parents = True ) 1002 (tree / "docs" ).mkdir () 1003 (tree / "src" / "main.py" ).write_text ("print('hi')\n" * 40 ) 1004 (tree / "src" / "deep" / "util.go" ).write_text ("package deep\n" ) 1005 (tree / "README.md" ).write_text ("# tree\n" ) 1006 (tree / ".hidden" ).mkdir () 1007return tree 1008 1009def test_a_remote_git_repo_browses_like_a_local_one (self ): 1010if not shutil .which ("git" ): 1011self .skipTest ("git is not installed" ) 1012tree = self .make_tree ("git-tree" ) 1013git = ["git" ,"-c" ,"user.name=t" ,"-c" ,"user.email=t@e" ,"-C" ,str (tree )] 1014subprocess .run ([* git ,"init" ,"-q" ,"-b" ,"main" ],check = True ) 1015subprocess .run ([* git ,"add" ,"src" ,"README.md" ],check = True ) 1016subprocess .run ([* git ,"commit" ,"-qm" ,"one" ],check = True ) 1017 (tree / "docs" / "new.md" ).write_text ("new\n" ) 1018self .assertEqual ( 1019self .browse (f"fakehost: { tree } " ,preview = True )["preview" ]["repository" ][ 1020"kind" 1021 ], 1022"git" , 1023 ) 1024self .assert_same_answers (tree ) 1025self .assert_same_answers (tree / "src" ) 1026 1027def test_a_remote_jj_repo_browses_like_a_local_one (self ): 1028if not shutil .which ("jj" ): 1029self .skipTest ("jj is not installed" ) 1030tree = self .make_tree ("jj-tree" ) 1031env = {** os .environ ,"JJ_USER" :"t" ,"JJ_EMAIL" :"t@e" } 1032subprocess .run (["jj" ,"git" ,"init" ,"--quiet" ,str (tree )],check = True ,env = env ) 1033subprocess .run ( 1034 ["jj" ,"-R" ,str (tree ),"bookmark" ,"create" ,"main" ,"-r" ,"@" ,"--quiet" ], 1035check = True , 1036env = env , 1037 ) 1038self .assertEqual ( 1039self .browse (f"fakehost: { tree } " ,preview = True )["preview" ]["repository" ][ 1040"kind" 1041 ], 1042"jj" , 1043 ) 1044self .assert_same_answers (tree ) 1045 1046def test_a_remote_folder_answers_with_the_hosts_state (self ): 1047query = urllib .parse .urlencode ({"location" :"gone:/srv" }) 1048with self .assertRaises (urllib .error .HTTPError )as refused : 1049self .app .api (f"/workspaces? { query } " ).close () 1050self .assertEqual (refused .exception .code ,503 ) 1051body = json .loads (refused .exception .read ()) 1052self .assertEqual (body ["status" ],503 ) 1053self .assertIn ("Connection refused" ,body ["detail" ]) 1054self .assertEqual (self .host ("gone" )["state" ],"unreachable" ) 1055# A session there still composes, its project files skipped. 1056session = self .create ("gone:/srv/app" )["id" ] 1057with self .app .api (f"/sessions/ { session } /catalog" )as response : 1058self .assertTrue (json .load (response )["discovery" ]["candidates" ]) 1059# A ~ path lists the remote home. 1060listed = self .browse ("fakehost:~" ) 1061self .assertEqual (listed ["directory" ],f"fakehost: { self . remote_home } " ) 1062self .assertIn ("proj" , [entry ["name" ]for entry in listed ["items" ]]) 1063 1064def test_project_instructions_and_skills_come_from_the_host (self ): 1065 (self .project / "AGENTS.md" ).write_text ("remote rule: always say banana\n" ) 1066skill = self .project / ".agents" / "skills" / "far-skill" 1067skill .mkdir (parents = True ) 1068 (skill / "SKILL.md" ).write_text ( 1069"---\nname: far-skill\ndescription: a skill that lives on the host\n---\nbody\n" 1070 ) 1071session = self .create (f"fakehost: { self . project } " )["id" ] 1072self .app .prompt (session ,"hello" ).close () 1073self .app .idle (session ,timeout = 60 ) 1074request = json .dumps (self .provider .requests [- 1 ]["request" ]) 1075self .assertIn ("always say banana" ,request ) 1076self .assertIn ("a skill that lives on the host" ,request ) 1077self .assertNotIn ("aren't available yet" ,request ) 1078 1079 1080if __name__ == "__main__" : 1081unittest .main ()