okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
cba5ff8
main
1"""CLI attachment and startup must preserve an independently owned real daemon.""" 2 3import json 4import os 5import pty 6import select 7import signal 8import socket 9import stat 10import subprocess 11import sys 12import termios 13import time 14import unittest 15 16from inspect_support import Inspection 17from harness import Albedo ,Provider ,exclusive ,operation_id ,python ,text 18 19 20def direct_start (daemon ): 21daemon .start_launcher () 22daemon .await_ready () 23 24 25def runtime_limits (app ): 26expression = ( 27"Limits = [Call(erlang, system_info, [Key]) " 28"|| Key <- [process_limit, port_limit, schedulers_online]], " 29"io:put_chars(json:encode(Limits))" 30 ) 31return json .loads (Inspection (app ).evaluate (expression ,timeout = 20 )) 32 33 34def gated_launcher (app ): 35"""A pipe barrier signals the real launch attempt before letting it exec.""" 36original = app .env ["ALBEDO_DAEMON" ] 37started ,release = app .root / "launcher-started" ,app .root / "launcher-release" 38os .mkfifo (started ) 39os .mkfifo (release ) 40attempts = app .root / "launcher-attempts" 41wrapper = app .root / "gated-launcher" 42wrapper .write_text ( 43f"#! { sys . executable } \n" 44"import os\n" 45f"with open( { str ( attempts )!r } , 'a') as attempts:\n" 46" attempts.write(str(os.getpid()) + '\\n')\n" 47" attempts.flush()\n" 48f"with open( { str ( started )!r } , 'w') as ready:\n" 49" ready.write(str(os.getpid()) + '\\n')\n" 50f"with open( { str ( release )!r } , 'rb', buffering=0) as gate:\n" 51" gate.read(1)\n" 52f"os.execv( { original !r } , [ { original !r } ])\n" 53 ) 54wrapper .chmod (0o700 ) 55app .env ["ALBEDO_DAEMON" ]= str (wrapper ) 56return started ,release ,attempts 57 58 59class TerminalCommand : 60def __init__ (self ,app ,* arguments ): 61self .master ,slave = pty .openpty () 62termios .tcsetwinsize (slave , (24 ,100 )) 63self .process = app .daemon .start_cli ( 64* arguments ,stdin = slave ,stdout = slave ,stderr = slave 65 ) 66os .close (slave ) 67self .output = b"" 68 69def prompt (self ,timeout = 10 ): 70deadline = time .monotonic ()+ timeout 71while time .monotonic ()< deadline : 72readable ,_ ,_ = select .select ( 73 [self .master ], [], [],max (0 ,deadline - time .monotonic ()) 74 ) 75if readable : 76try : 77chunk = os .read (self .master ,65536 ) 78except OSError as error : 79raise AssertionError ( 80"CLI exited before restart prompt: " 81+ self .output .decode (errors = "replace" ) 82 )from error 83self .output += chunk 84if b"\x1b[6n" in chunk : 85os .write (self .master ,b"\x1b[1;1R" ) 86if b"\x1b[c" in chunk : 87os .write (self .master ,b"\x1b[?1;2c" ) 88if b"[y/N]" in self .output or b"[Y/n]" in self .output : 89return 90raise AssertionError ( 91"CLI did not offer a restart: " + self .output .decode (errors = "replace" ) 92 ) 93 94def answer (self ,value ): 95os .write (self .master , (value + "\r" ).encode ()) 96result = self .process .wait (timeout = 20 ) 97os .close (self .master ) 98return result 99 100 101# exclusive: owns daemon startup and restart behavior 102@exclusive 103class LifecycleTest (unittest .TestCase ): 104def test_standalone_default_home_authentication_and_vm_defaults_match_cli (self ): 105def prepare (app ): 106app .home = app .daemon .home = app .root / "user-home" / ".albedo" 107app .home .mkdir (mode = 0o755 ) 108app .env .pop ("ALBEDO_HOME" ) 109app .env .pop ("ALBEDO_TOKEN" ,None ) 110app .env ["ALBEDO_INSPECT" ]= "1" 111app .daemon .boot_action = direct_start 112 113with Albedo (prepare = prepare )as app : 114direct = dict (app .connection ) 115self .assertGreaterEqual (len (direct ["token" ]),32 ) 116self .assertEqual (stat .S_IMODE (app .home .stat ().st_mode ),0o700 ) 117self .assertEqual ( 118stat .S_IMODE ((app .home / "daemon.json" ).stat ().st_mode ),0o600 119 ) 120connection = socket .create_connection ( 121 ("127.0.0.1" ,direct ["port" ]),timeout = 5 122 ) 123with connection : 124connection .sendall ( 125f"GET /server HTTP/1.1\r\nHost: localhost: { direct [ 'port' ] } \r\nConnection: close\r\n\r\n" .encode () 126 ) 127self .assertIn (b"401" ,connection .recv (1024 ).split (b"\r\n" ,1 )[0 ]) 128direct_limits = runtime_limits (app ) 129session = app .session () 130app .prompt (session ,"independent daemon is usable" ).close () 131app .idle (session ) 132self .assertIn ( 133"ok" , 134 [ 135part ["text" ] 136for entry in app .history (session )["items" ] 137for part in entry ["content" ] 138if part ["kind" ]== "text" 139 ], 140 ) 141self .assertEqual (app .connection ,direct ) 142app .restart () 143self .assertNotEqual (app .connection ["pid" ],direct ["pid" ]) 144self .assertNotEqual (app .connection ["token" ],direct ["token" ]) 145self .assertEqual (runtime_limits (app ),direct_limits ) 146app .restart ( 147prepare = lambda fixture :fixture .env .update ( 148ERL_FLAGS = fixture .env ["ERL_FLAGS" ]+ " +P 131072 +Q 32768" , 149ALBEDO_TOKEN = "operator-chosen-authentication-token" , 150 ) 151 ) 152self .assertEqual (runtime_limits (app )[:2 ], [131072 ,32768 ]) 153self .assertEqual ( 154app .connection ["token" ],"operator-chosen-authentication-token" 155 ) 156self .assertEqual (app .api ("/server" ).status ,200 ) 157 158def test_cancelling_cli_startup_wait_leaves_the_started_daemon_owned (self ): 159captured = {} 160 161def prepare (app ): 162started ,release ,attempts = gated_launcher (app ) 163 164def start (daemon ): 165command = daemon .start_cli ( 166"daemon" ,stdout = subprocess .PIPE ,stderr = subprocess .PIPE 167 ) 168with started .open ()as ready : 169captured ["child" ]= int (ready .readline ()) 170adopted = False 171try : 172command .send_signal (signal .SIGINT ) 173stdout ,stderr = command .communicate (timeout = 5 ) 174captured ["cancelled" ]= command .returncode 175captured ["output" ]= stdout + stderr 176with release .open ("wb" ,buffering = 0 )as gate : 177gate .write (b"x" ) 178daemon .await_ready () 179adopted = True 180finally : 181if not adopted : 182try : 183os .kill (captured ["child" ],signal .SIGKILL ) 184except ProcessLookupError : 185pass 186captured ["attempts" ]= attempts .read_text ().splitlines () 187 188app .daemon .boot_action = start 189 190with Albedo (prepare = prepare )as app : 191self .assertNotEqual (captured ["cancelled" ],0 ,captured ["output" ]) 192self .assertEqual (app .connection ["pid" ],captured ["child" ]) 193self .assertEqual (len (captured ["attempts" ]),1 ) 194session = app .session () 195app .prompt (session ,"daemon survived waiter cancellation" ).close () 196app .idle (session ) 197self .assertIn ( 198"ok" , 199 [ 200part ["text" ] 201for entry in app .history (session )["items" ] 202for part in entry ["content" ] 203if part ["kind" ]== "text" 204 ], 205 ) 206 207def test_simultaneous_cli_starters_converge_on_one_real_execution (self ): 208captured = {} 209 210def prepare (app ): 211started ,release ,attempts = gated_launcher (app ) 212 213def start (daemon ): 214first = daemon .start_cli ( 215"daemon" ,stdout = subprocess .PIPE ,stderr = subprocess .PIPE 216 ) 217second = daemon .start_cli ( 218"daemon" ,stdout = subprocess .PIPE ,stderr = subprocess .PIPE 219 ) 220with started .open ()as ready : 221captured ["owner" ]= int (ready .readline ()) 222adopted = False 223try : 224with release .open ("wb" ,buffering = 0 )as gate : 225gate .write (b"x" ) 226captured ["outputs" ]= [ 227command .communicate (timeout = 20 )for command in (first ,second ) 228 ] 229captured ["codes" ]= [first .returncode ,second .returncode ] 230daemon .await_ready () 231adopted = True 232finally : 233if not adopted : 234try : 235os .kill (captured ["owner" ],signal .SIGKILL ) 236except ProcessLookupError : 237pass 238captured ["attempts" ]= attempts .read_text ().splitlines () 239 240app .daemon .boot_action = start 241 242with Albedo (prepare = prepare )as app : 243self .assertEqual (captured ["codes" ], [0 ,0 ],captured ["outputs" ]) 244self .assertEqual (captured ["attempts" ], [str (app .connection ["pid" ])]) 245self .assertEqual (app .connection ["pid" ],captured ["owner" ]) 246self .assertEqual (app .api ("/sessions" ).status ,200 ) 247 248def test_compatible_other_build_is_kept_without_a_terminal_and_explicit_yes_restarts ( 249self , 250 ): 251def prepare (app ): 252app .env ["ALBEDO_BUILD" ]= "independent-compatible-build" 253app .daemon .boot_action = direct_start 254 255with Albedo (prepare = prepare )as app : 256previous = dict (app .connection ) 257self .assertEqual (previous ["build" ],"independent-compatible-build" ) 258# The daemon records the content digest of the code it runs, so a 259# client can prove a build difference without a label. 260self .assertRegex (previous ["digest" ],r"^[0-9a-f]{64}$" ) 261app .env .pop ("ALBEDO_BUILD" ) 262app .cli ("daemon" ) 263self .assertEqual ( 264json .loads ((app .home / "daemon.json" ).read_text ()),previous 265 ) 266terminal = TerminalCommand (app ,"daemon" ) 267terminal .prompt () 268self .assertEqual (terminal .answer ("" ),0 ) 269self .assertEqual ( 270json .loads ((app .home / "daemon.json" ).read_text ()),previous 271 ) 272for cancel in ("\x03" ,"\x04" ): 273terminal = TerminalCommand (app ,"daemon" ) 274terminal .prompt () 275self .assertNotEqual (terminal .answer (cancel ),0 ) 276self .assertEqual ( 277json .loads ((app .home / "daemon.json" ).read_text ()),previous 278 ) 279terminal = TerminalCommand (app ,"daemon" ) 280terminal .prompt () 281self .assertEqual (terminal .answer ("y" ),0 ) 282app .daemon ._refresh () 283self .assertNotEqual (app .connection ["pid" ],previous ["pid" ]) 284self .assertNotEqual (app .connection ["token" ],previous ["token" ]) 285 286def test_restart_confirmation_cannot_replace_a_newer_daemon (self ): 287with Albedo ()as app : 288terminal = TerminalCommand (app ,"daemon" ) 289terminal .prompt () 290app .restart () 291replacement = dict (app .connection ) 292self .assertNotEqual (terminal .answer ("y" ),0 ) 293self .assertEqual ( 294json .loads ((app .home / "daemon.json" ).read_text ()),replacement 295 ) 296self .assertEqual (app .api ("/server" ).status ,200 ) 297 298def test_explicit_upgrade_recovers_history_receipt_and_uncertain_tool_without_repeating_effect ( 299self , 300 ): 301fixture = {} 302 303def script (request ): 304messages = request ["messages" ] 305if any ( 306"daemon restart" in str (message .get ("content" ,"" )) 307for message in messages 308 ): 309return text ("recovered after upgrade" ) 310if messages [- 1 ]["role" ]== "tool" : 311return text ("tool finished" ) 312return python ( 313"from pathlib import Path\nimport threading\n" 314f"with Path( { str ( fixture [ 'effect' ])!r } ).open('a') as effect:\n" 315" effect.write('effect applied\\n')\n" 316" effect.flush()\n" 317"threading.Event().wait(60)" 318 ) 319 320provider = Provider (script ) 321self .addCleanup (provider .close ) 322with Albedo (provider )as app : 323fixture ["effect" ]= app .workspace / "effect" 324session = app .session () 325operation = operation_id () 326app .api ( 327f"/sessions/ { session } /inputs/ { operation } " , 328 {"kind" :"message" ,"text" :"apply an effect then wait" }, 329method = "PUT" , 330 ).close () 331deadline = time .monotonic ()+ 20 332while ( 333not fixture ["effect" ].exists () 334or fixture ["effect" ].read_text ()!= "effect applied\n" 335 ): 336if time .monotonic ()> deadline : 337app ._fail ("tool never published its external effect" ) 338time .sleep (0.02 ) 339self .assertTrue ( 340json .load (app .api (f"/sessions/ { session } ?tail=0" ))["status" ]["phase" ] 341== "running" 342 ) 343previous = dict (app .connection ) 344terminal = TerminalCommand (app ,"daemon" ) 345terminal .prompt () 346self .assertEqual (terminal .answer ("y" ),0 ) 347app .daemon ._refresh () 348self .assertNotEqual (app .connection ["pid" ],previous ["pid" ]) 349app .idle (session ) 350receipt = json .load (app .api (f"/sessions/ { session } /inputs/ { operation } " )) 351self .assertEqual (receipt ["delivery" ],"committed" ) 352self .assertEqual (receipt ["turn" ]["state" ],"abandoned" ) 353entries = app .history (session )["items" ] 354self .assertEqual ( 355sum (entry ["input_id" ]== operation for entry in entries ),1 356 ) 357self .assertTrue ( 358any ( 359entry ["kind" ]== "note" 360and any ( 361part ["kind" ]== "text" and "restart" in part ["text" ] 362for part in entry ["content" ] 363 ) 364for entry in entries 365 ) 366 ) 367self .assertTrue ( 368any ( 369part ["kind" ]== "text" and part ["text" ]== "recovered after upgrade" 370for entry in entries 371for part in entry ["content" ] 372 ) 373 ) 374self .assertEqual (fixture ["effect" ].read_text (),"effect applied\n" ) 375 376def test_failed_discovery_publication_exits_and_releases_home_for_retry (self ): 377captured = {} 378 379def prepare (app ): 380def start (daemon ): 381blocked = app .home / "daemon.json" 382blocked .mkdir () 383process = daemon .start_launcher () 384captured ["exit" ]= process .wait (timeout = 20 ) 385captured ["pid" ]= process .pid 386blocked .rmdir () 387 388app .daemon .boot_action = start 389 390with Albedo (prepare = prepare )as app : 391self .assertNotEqual (captured ["exit" ],0 ) 392self .assertNotEqual (app .connection ["pid" ],captured ["pid" ]) 393self .assertEqual (app .api ("/server" ).status ,200 )