okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
57a82be
main
1"""Remote kernels over SSH: every harness tool, on another machine. 2 3connect() stages albedo's python bundle on the target, boots the same kernel 4over one long-lived ssh connection, and answers its host-route calls against 5this session's daemon, so session tools (work, skills, cells) keep running 6where the daemon runs while machine tools (run, files) run on the target. 7Both kernels load the same content-hashed bundle, so a tool's remote shape 8matches its local one and results cross as real objects. 9 10Every call is one reference: the reply inlines the value when it can cross and 11keeps the object live over there otherwise, so methods can be called on the 12remote object either way. If the kernel cannot boot, the connection degrades 13to command mode -- only rem.run() -- and says so at connect. 14""" 15 16from __future__import annotations 17 18from albedo_output import JobOutput 19 20import asyncio 21import base64 22import contextlib 23import json 24import os 25import shlex 26import struct 27import time 28import uuid 29from pathlib import Path 30from typing import Any ,TypedDict 31 32from albedo_protocol import RemoteCall ,RemoteMessage ,parse_remote 33 34from albedo_api import PythonApi ,ReadyList ,Text ,check_timeout ,excerpt 35import albedo_bundle 36from albedo_values import InvalidValue ,decode 37import albedo_shell 38import albedo_ssh 39from albedo_plugins .run import notice 40 41CONNECT_TIMEOUT = 60 # seconds before an unreachable target gives up 42HANDSHAKE_TIMEOUT = 60 # seconds for the remote kernel to say ready 43STAGE_TIMEOUT = 120 # seconds to move the bundle over one ssh stream 44INVOKE_TIMEOUT = 3600 # anti-wedge bound; the cell deadline is the real limit 45COMMAND_TIMEOUT = 120 # degraded-mode command default 46MAX_FRAME = 8 * 1024 * 1024 # control-frame ceiling, as in the kernel 47STDERR_TAIL = 65536 # bytes of ssh diagnostics kept for failures 48IMAGE_READ = 5 * 1024 * 1024 + 1 # one past a cell's image bytes, as local reads 49 50loop :asyncio .AbstractEventLoop 51host_call :Any 52HostErrorType :type [Exception ] 53capture_factory :Any 54send_frame :Any 55attach_image :Any 56session_modules :list [str ]= [] 57 58 59class LiveJob (TypedDict ): 60"""A job still running over ssh, as the supervisor lists it.""" 61 62id :str 63command :str 64service :bool # nothing waits on its end 65started :int # wall-clock milliseconds 66 67 68MAX_LISTED_JOBS = 100 # as many rows as the supervisor lists for local jobs 69COMMAND_SCALARS = 4096 # a command's preview, as local jobs report it 70 71_fallback_live :dict [str ,LiveJob ]= {}# degraded-mode jobs still running over ssh 72_fallback_jobs :dict [str ,FallbackJob ]= {} 73 74 75def _live_job (id :str ,command :str ,service :bool )-> LiveJob : 76return { 77"id" :id , 78"command" :command [:COMMAND_SCALARS ], 79"service" :service , 80"started" :time .time_ns ()// 1_000_000 , 81 } 82 83 84def _report_live ()-> None : 85"""Tell the local supervisor which remote jobs are still live, so it lists 86them beside its own and knows which will wake the session: every one not 87started as a service. 88 89Releasing the kernel would kill every ssh client and with them the remote 90kernels, so the idle sweep keeps a kernel alive while remote work runs, 91and the cache warmer keeps an idle session warm while one it awaits does. 92""" 93try : 94live = [ 95* _fallback_live .values (), 96* (job for c in connections for job in c ._live .values ()), 97 ] 98send_frame ( 99 { 100"type" :"jobs" , 101"live" :len (live ), 102"awaited" :sum (not job ["service" ]for job in live ), 103"items" :live [:MAX_LISTED_JOBS ], 104 } 105 ) 106except Exception : 107pass # no channel or shutdown; the supervisor keeps the last count 108 109 110configured :dict [str ,str | None ]= {} 111connections :list ["RemoteConnection" ]= [] 112 113_UNSET = object ()# a value result that has not settled yet 114 115 116class RemoteError (RuntimeError ): 117"""A remote operation failed; the message names what happened.""" 118 119 120class RemoteBootError (RemoteError ): 121"""The remote kernel could not start; the connection degrades to commands.""" 122 123 124class RemoteLost (RemoteError ): 125"""The ssh channel is gone; every reference it held is invalid.""" 126 127 128class RemoteTimeout (RemoteError ): 129"""A remote call did not answer; an interrupt was sent.""" 130 131 132class RemoteCancelled (RemoteError ): 133"""The wait was cancelled; the remote effect may continue.""" 134 135 136class RemoteExecutionError (RemoteError ): 137"""A remote tool call raised; its traceback crossed the connection.""" 138 139def __init__ (self ,ename :str ,evalue :str ,traceback :list [str ]): 140lines = "\n" .join (traceback [- 4 :]) 141super ().__init__ (f" { ename } : { evalue } " + (f"\n { lines } " if lines else "" )) 142self .ename = ename 143self .evalue = evalue 144self .traceback = tuple (traceback ) 145 146 147def parse_target (arg :str )-> dict [str ,str | None ]: 148"""Split `user@host[:/path]` or `ssh://user@host[:port][/path]`. ssh takes 149the URI as its target, path left out; in the scp form a colon not followed 150by a path stays in the host.""" 151if arg .startswith ("ssh://" ): 152authority ,slash ,path = arg .removeprefix ("ssh://" ).partition ("/" ) 153return { 154"host" :f"ssh:// { authority } " , 155"remote_cwd" :f"/ { path } " if slash else None , 156 } 157head ,separator ,tail = arg .partition (":" ) 158remote_cwd = tail if separator and tail .startswith ("/" )else None 159return {"host" :arg if remote_cwd is None else head ,"remote_cwd" :remote_cwd } 160 161 162def _settings ()-> dict [str ,str ]: 163"""The `remote` section of extensions.json, falling back to `ssh`; errors raise.""" 164home = os .environ .get ("ALBEDO_HOME" )or os .path .expanduser ("~/.albedo" ) 165try : 166with open (os .path .join (home ,"extensions.json" ),"rb" )as handle : 167sections = json .load (handle ) 168except FileNotFoundError : 169return {} 170if not isinstance (sections ,dict ): 171raise RemoteError ("extensions.json must hold an object" ) 172section = sections .get ("remote" ,sections .get ("ssh" )) 173if section is None : 174return {} 175if not isinstance (section ,dict ): 176raise RemoteError ("extensions.json remote section must be an object" ) 177keys = ("host" ,"remoteCwd" ,"python" ) 178for key in keys : 179if key in section and not isinstance (section [key ],str ): 180raise RemoteError (f"extensions.json remote. { key } must be a string" ) 181return {key :section [key ]for key in keys if key in section } 182 183 184def resolve ( 185host :str | None = None ,remote_cwd :str | None = None ,python :str | None = None 186)-> dict [str ,str | None ]: 187"""The target a connection runs against, or a misuse error naming every source.""" 188if host is None and configured : 189target = dict (configured ) 190else : 191if host is None : 192host = os .environ .get ("ALBEDO_SSH" )or _settings ().get ("host" ) 193if host is None : 194raise RemoteError ( 195"no remote target: pass host=, call remote.configure(), set $ALBEDO_SSH, " 196'or add a "remote" section to extensions.json' 197 ) 198target = parse_target (host ) 199if target ["remote_cwd" ]is None : 200target ["remote_cwd" ]= _settings ().get ("remoteCwd" ) 201if remote_cwd is not None : 202target ["remote_cwd" ]= remote_cwd 203if python is None : 204python = _settings ().get ("python" ) 205target ["python" ]= python 206return target 207 208 209def refusal (host :str ,stderr :str )-> str : 210"""Why ssh could not reach `host`; a sign-in only a person can give names 211the command that opens the master this connection would ride.""" 212if albedo_ssh .failure (stderr )[0 ]!= "needs_auth" : 213return f"ssh target { host !r } is unreachable: { stderr . strip ()[: 300 ] } " 214return ( 215f"ssh target { host !r } needs a person to sign in: { stderr . strip ()[: 300 ] } \n" 216f"ask the user to run ` { shlex . join ( albedo_ssh . sign_in ( host )) } ` in a terminal," 217" then connect again; the master it opens carries this connection" 218 ) 219 220 221ssh_env = albedo_ssh .env 222in_login_shell = albedo_ssh .in_login_shell 223ssh_base = albedo_ssh .base 224 225 226async def ssh_run ( 227target :str ,script :str ,* ,timeout :float ,stdin :bytes | None = None 228)-> tuple [int | None ,bytes ,str ]: 229"""One command over the control connection; its exit code is data.""" 230process = await asyncio .create_subprocess_exec ( 231* ssh_base (target ), 232target , 233script , 234stdin = asyncio .subprocess .PIPE 235if stdin is not None 236else asyncio .subprocess .DEVNULL , 237stdout = asyncio .subprocess .PIPE , 238stderr = asyncio .subprocess .PIPE , 239env = ssh_env (target ), 240 ) 241try : 242stdout ,stderr = await asyncio .wait_for (process .communicate (stdin ),timeout ) 243except asyncio .TimeoutError : 244process .kill () 245raise RemoteTimeout ( 246f"ssh command did not finish in { timeout :g } s: { script } " 247 )from None 248return process .returncode ,stdout ,stderr .decode (errors = "replace" ) 249 250 251_BUNDLE :dict [str ,str ]= {} 252 253 254def bundle ()-> dict [str ,str ]: 255"""Content hash of the packaged python tree, so both sides run the same code.""" 256if not _BUNDLE : 257root = albedo_bundle .ROOT 258digest = albedo_bundle .digest (root ) 259_BUNDLE ["id" ]= digest 260_BUNDLE ["root" ]= str (root ) 261_BUNDLE ["remote" ]= f"$HOME/ { albedo_ssh . staged_name ( digest ) } " 262return _BUNDLE 263 264 265async def stage (target :str )-> None : 266"""Move the bundle over one ssh stream, as the daemon's host probe does.""" 267script = albedo_ssh .stage_script (bundle ()["remote" ]) 268code ,_ ,stderr = await ssh_run ( 269target , 270in_login_shell (script ), 271timeout = STAGE_TIMEOUT , 272stdin = albedo_ssh .archive (), 273 ) 274if code != 0 : 275raise RemoteBootError (f"staging failed ( { code } ): { stderr . strip ()[: 500 ] } " ) 276 277 278def _revive (value :Any )-> Any : 279"""Pickle hook target: a reference with a crossed value saves as its value.""" 280return value 281 282 283class RemoteRef : 284"""One remote reference: pending, live, or settled into its value. 285 286A call returns this immediately -- the invoke is already in flight, no 287round trip blocks the call -- and the reference settles on first await. 288Methods called before the reply lands target the pending call, so 289`rem.run(program, ...)` behaves like local run: the handle exists now, `await` 290waits for the object, and mirrored job state answers tail() and poll() 291locally, with no round trip. A result whose value crossed reads and 292iterates like that value; its local methods run locally, and attributes 293that only exist remotely are remote calls. 294""" 295 296def __init__ ( 297self , 298connection :"RemoteConnection" , 299handle :str | None = None , 300value :Any = None , 301task :"asyncio.Task[Any] | None" = None , 302call_id :str | None = None , 303label :str = "" , 304 )-> None : 305self ._connection = connection 306self ._handle = handle 307self ._value = value 308self ._task = task 309self ._call_id = call_id 310self ._label = label 311self ._settled :Any = _UNSET 312self ._withdrawn :bool = False 313self ._awaited = False # an await here returned, so its result reached this cell 314self ._waiters = 0 # awaits here still waiting on it 315 316# --- mirrored job state: sync, like the local handle --- 317 318def tail (self ,n :int = 4000 ,* ,lines :int | None = None )-> Text : 319"""The recent output of the remote job this reference holds: the last 320n characters, or its last `lines` lines.""" 321state = self ._mirror (read = False ) 322text = str (state .get ("tail" ,"" ))if state else "" 323size = state .get ("tail_bytes" ,len (text .encode ()))if state else 0 324result = excerpt ( 325text , 326n , 327lines , 328end = True , 329clipped = bool (state and state .get ("seen" ,0 )> size ), 330 ) 331self ._mirror () 332return result 333 334def poll (self )-> int | None : 335"""The remote job's exit status so far, from the mirrored stream.""" 336state = self ._mirror () 337return state .get ("exit_code" )if state else None 338 339@property 340def exit_code (self )-> int | None : 341"""The remote job's exit status; the mirror keeps it current.""" 342return self .poll () 343 344@property 345def returncode (self )-> int | None : 346"""`exit_code` under subprocess's name; both spellings answer.""" 347return self .poll () 348 349@property 350def timed_out (self )-> bool : 351"""Whether the remote job lost its deadline.""" 352state = self ._mirror () 353return bool (state .get ("timed_out" ))if state else False 354 355@property 356def duration (self )-> float | None : 357"""The seconds the remote job ran, known once it ended.""" 358state = self ._mirror () 359return state .get ("duration" )if state else None 360 361@property 362def id (self )-> str | None : 363"""The remote job's id, for output.read on the remote side.""" 364state = self ._mirror () 365return state .get ("job" )if state else None 366 367def _mirror (self ,* ,read :bool = True )-> dict [str ,Any ]| None : 368if (failure := self ._failure ())is not None : 369raise failure 370if self ._handle is None : 371return None 372state = self ._connection ._mirrors .get (self ._handle ) 373# A finished job read through its mirror is consumed on the remote side 374# too, or its kernel would keep retrying a wake this read satisfies. 375if ( 376read 377and state is not None 378and state .get ("exit_code" )is not None 379and not self ._withdrawn 380 ): 381self ._withdrawn = True 382try : 383_ = loop .create_task (self ._connection .mark_read (self ._handle )) 384except RuntimeError : 385pass # the loop is closing; the notice dies with the kernel 386return state 387 388def _failure (self )-> BaseException | None : 389"""The error of a call that failed before anything awaited it. A ref 390nobody awaits (a long build left to wake the session) has no other way 391to say it never started.""" 392task = self ._task 393if task is None or not task .done ()or task .cancelled (): 394return None 395return task .exception () 396 397@property 398def _claimed (self )-> bool : 399"""Its result reached this side, or an await here is about to take it, 400so the job's wake is not owed.""" 401return self ._awaited or self ._waiters > 0 or self ._withdrawn 402 403# --- the value surface: identical output to the local tool --- 404 405def __repr__ (self )-> str : 406if (failure := self ._failure ())is not None : 407return f"<failed remote { self . _label or 'call' } : { failure } >" 408if self ._value is not None : 409return repr (self ._value ) 410if self ._handle is not None : 411return f"<remote reference { self . _handle [: 8 ] } on { self . _connection . host } >" 412return f"<pending remote { self . _label or 'call' } ; await it>" 413 414__str__ = __repr__ 415 416def _wanted (self )-> Any : 417if self ._value is None and self ._handle is None : 418raise TypeError ( 419f"the remote { self . _label or 'result' } is still pending; await it first" 420 ) 421if self ._value is None : 422raise TypeError ( 423"a live remote reference is not a value; await it or call its methods" 424 ) 425return self ._value 426 427def __iter__ (self ): 428return iter (self ._wanted ()) 429 430def __len__ (self )-> int : 431return len (self ._wanted ()) 432 433def __getitem__ (self ,key :Any )-> Any : 434return self ._wanted ()[key ] 435 436def __contains__ (self ,key :Any )-> bool : 437return key in self ._wanted () 438 439def __eq__ (self ,other :object )-> bool : 440return self ._value == other if self ._value is not None else self is other 441 442def __dir__ (self )-> list [str ]: 443return sorted ( 444set (dir (type (self ))) 445| (set (dir (self ._value ))if self ._value is not None else set ()) 446 ) 447 448def __reduce__ (self ): 449if self ._value is None : 450raise TypeError ( 451"a live remote reference cannot be saved; await or release it first" 452 ) 453return _revive , (self ._value ,) 454 455# --- the remote surface --- 456 457def __getattr__ (self ,name :str )-> Any : 458if name .startswith ("_" ): 459raise AttributeError (name ) 460if self ._value is not None and hasattr (self ._value ,name ): 461return getattr (self ._value ,name ) 462if self ._connection .closed : 463raise RemoteLost ( 464f"the connection for reference { self . _label or '?' } is closed" 465 ) 466if self ._handle is not None : 467return _RemoteCall (self ._connection ,self ._handle ,name ) 468return _RemoteCall ( 469self ._connection , 470None , 471name , 472pending = self ._call_id , 473label = f" { self . _label } . { name } " if self ._label else name , 474 ) 475 476def __await__ (self ): 477return self ._wait ().__await__ () 478 479async def _wait (self )-> Any : 480# Counted now, like a local job: the await crosses the connection only 481# after the reply to the call, and a quick job is done before that. An 482# await cut short interrupts the remote one too, so the remote job owes 483# its wake again and this reference no longer claims it. 484self ._waiters += 1 485try : 486result = await self ._settle () 487self ._awaited = True 488finally : 489self ._waiters -= 1 490return result 491 492async def _settle (self )-> Any : 493"""Resolve the in-flight call; awaiting a live object awaits the object.""" 494if self ._task is not None : 495task ,self ._task = self ._task ,None 496result = await task 497if result is not self : 498self ._settled = result 499return result 500if self ._settled is not _UNSET : 501return self ._settled 502if self ._handle is not None and self ._value is None : 503await self ._connection .invoke (handle = self ._handle ,wait = True ) 504return self 505 506async def release (self )-> None : 507"""Drop the live reference; the object itself is the remote plugin's to keep.""" 508if self ._task is not None : 509self ._task .cancel () 510self ._task = None 511return 512if self ._handle is not None : 513self ._connection ._refs .pop (self ._handle ,None ) 514await self ._connection .invoke_release (self ._handle ) 515self ._value = None 516 517 518class _RemoteCall : 519"""A method of the remote namespace, of one live reference, or of a pending call.""" 520 521def __init__ ( 522self , 523connection :"RemoteConnection" , 524handle :str | None , 525name :str , 526pending :str | None = None , 527label :str | None = None , 528 )-> None : 529self ._connection = connection 530self ._handle = handle 531self ._name = name 532self ._pending = pending 533self ._label = label or name 534 535def __call__ (self ,* args :Any ,** kwargs :Any )-> RemoteRef : 536"""Start the call now; the reference it produces settles on first await.""" 537return self ._connection .start (self ,args ,kwargs ) 538 539def __await__ (self ): 540"""Awaiting an uncalled method runs it with no arguments, like a property.""" 541return self ().__await__ () 542 543def __getattr__ (self ,name :str )-> Any : 544if name .startswith ("_" ): 545raise AttributeError (name ) 546return _RemoteCall ( 547self ._connection , 548self ._handle , 549f" { self . _name } . { name } " , 550pending = self ._pending , 551 ) 552 553def __repr__ (self )-> str : 554where = ( 555self ._handle [:8 ] 556if self ._handle 557else (self ._pending [:8 ]if self ._pending else "namespace" ) 558 ) 559return f"<remote call { self . _name } on { where } >" 560 561 562SHELL_ONLY_DEGRADED = "rem.shell is only available in degraded SSH command mode; use rem.run(...).pipe(...) instead" 563 564 565class RemoteConnection : 566"""One ssh channel owning one remote kernel, or degraded to plain commands.""" 567 568def __init__ (self ,target :dict [str ,str | None ])-> None : 569self .target = target 570self ._process :asyncio .subprocess .Process | None = None 571self ._stdin = None 572self ._stdout = None 573self ._write_lock = asyncio .Lock () 574self ._pending :dict [str ,asyncio .Future [dict [str ,Any ]]]= {} 575self ._refs :dict [str ,RemoteRef ]= {} 576self ._mirrors :dict [str ,dict [str ,Any ]]= {} 577self ._handshake :asyncio .Future [dict [str ,Any ]]| None = None 578self ._live :dict [str ,LiveJob ]= {} 579self ._stderr_tail = bytearray () 580self ._reader :asyncio .Task [None ]| None = None 581self ._drain :asyncio .Task [None ]| None = None 582self .closed = False 583self .degraded :RemoteBootError | None = None 584 585# --- boot --- 586 587async def boot ( 588self ,modules :list [str ]| None = None ,timeout :float = HANDSHAKE_TIMEOUT 589 )-> None : 590"""Stage, spawn, and wait for ready; a failure raises RemoteBootError.""" 591remote_root = bundle ()["remote" ] 592script_path = f" { remote_root } /albedo_kernel.py" 593code ,_ ,_ = await ssh_run ( 594self .host , 595in_login_shell (f'test -f " { script_path } "' ), 596timeout = CONNECT_TIMEOUT , 597 ) 598if code != 0 : 599await stage (self .host ) 600python = self .target .get ("python" )or "python3" 601# The stamp reaches the remote kernel's plugins, so a finished remote 602# job's wake notice names the machine it ran on. 603inner = ( 604f"ALBEDO_REMOTE_TARGET= { shlex . quote ( self . host ) } exec " 605+ f' { shlex . quote ( python ) } -u " { script_path } " ' 606+ shlex .quote ( 607json .dumps (modules if modules is not None else default_modules ()) 608 ) 609 ) 610remote_cwd = self .target .get ("remote_cwd" ) 611if remote_cwd : 612inner = f"cd { shlex . quote ( remote_cwd ) } && { inner } " 613script = in_login_shell (inner ) 614self ._handshake = loop .create_future () 615self ._process = await asyncio .create_subprocess_exec ( 616* ssh_base (self .host ), 617self .host , 618script , 619stdin = asyncio .subprocess .PIPE , 620stdout = asyncio .subprocess .PIPE , 621stderr = asyncio .subprocess .PIPE , 622env = ssh_env (self .host ), 623 ) 624self ._stdin ,self ._stdout = self ._process .stdin ,self ._process .stdout 625self ._drain = loop .create_task (self ._drain_stderr ()) 626self ._reader = loop .create_task (self ._read_frames ()) 627try : 628frame = await asyncio .wait_for (asyncio .shield (self ._handshake ),timeout ) 629except asyncio .TimeoutError : 630raise RemoteBootError (f"kernel did not say ready in { timeout :g } s" )from None 631if frame .get ("type" )!= "ready" : 632raise RemoteBootError (str (frame .get ("message" ,"invalid kernel handshake" ))) 633self ._handshake = None 634 635async def _drain_stderr (self )-> None : 636"""Keep a bounded tail of ssh diagnostics; a banner must not wedge the pipe.""" 637assert self ._process is not None and self ._process .stderr is not None 638while True : 639chunk = await self ._process .stderr .read (4096 ) 640if not chunk : 641return 642self ._stderr_tail .extend (chunk ) 643del self ._stderr_tail [:- STDERR_TAIL ] 644 645# --- frames --- 646 647async def _read_frames (self )-> None : 648try : 649while True : 650assert self ._stdout is not None 651header = await self ._stdout .readexactly (4 ) 652size = struct .unpack (">I" ,header )[0 ] 653if size > MAX_FRAME : 654raise RemoteLost ("remote control frame exceeds the ceiling" ) 655frame = parse_remote (json .loads (await self ._stdout .readexactly (size ))) 656self ._dispatch (frame ) 657except asyncio .CancelledError : 658raise 659except Exception : 660pass # Every reader failure invalidates its outstanding work below. 661finally : 662self ._lost () 663 664def _dispatch (self ,frame :RemoteMessage )-> None : 665if ( 666frame ["type" ]== "invoked" 667or frame ["type" ]== "introspected" 668or frame ["type" ]== "job_stop" 669 ): 670future = self ._pending .get (frame ["id" ]) 671if future is not None and not future .done (): 672future .set_result (dict (frame )) 673elif frame ["type" ]in ("ready" ,"startup_error" ): 674if self ._handshake is not None and not self ._handshake .done (): 675self ._handshake .set_result (dict (frame )) 676elif frame ["type" ]== "call" : 677_ = loop .create_task (self ._relay (frame )) 678elif frame ["type" ]== "mirror" : 679self ._mirrors [frame ["handle" ]]= dict (frame ) 680elif frame ["type" ]== "job_start" : 681self ._live [frame ["id" ]]= _live_job ( 682frame ["id" ], 683str (frame .get ("command" ,"" )), 684frame .get ("service" )is True , 685 ) 686_report_live () 687elif frame ["type" ]== "job" : 688self ._live .pop (frame ["id" ],None ) 689_report_live () 690# trace, done, cleanup describe remote cells; those are addressed 691# through references, not events on this side. 692 693async def _relay (self ,frame :RemoteCall )-> None : 694"""Answer one remote host-route call against this session's daemon.""" 695try : 696if frame ["method" ]== "jobs.completed" : 697value = await self ._wake (frame ["args" ]) 698else : 699value = await host_call (frame ["method" ],frame ["args" ]) 700reply :dict [str ,Any ]= {"ok" :True ,"value" :value } 701except HostErrorType as error : 702reply = { 703"ok" :False , 704"code" :getattr (error ,"code" ,"host" ), 705"message" :str (getattr (error ,"message" ,error )), 706 } 707except Exception as error :# the daemon's answer must always be a reply 708reply = { 709"ok" :False , 710"code" :"host" , 711"message" :f" { type ( error ). __name__ } : { error } " , 712 } 713await self ._send ({"type" :"reply" ,"id" :frame ["id" ],"value" :reply }) 714 715async def _wake (self ,args :Any )-> Any : 716"""Pass on a remote job's wake unless a reference here already awaited 717or read the job, which the remote kernel may not have heard yet.""" 718facts = [ 719fact 720for fact in args .get ("jobs" , []) 721if not self ._claimed_here (fact .get ("id" )) 722 ] 723if not facts : 724return None 725return await host_call ("jobs.completed" ,notice (facts )) 726 727def _claimed_here (self ,job :object )-> bool : 728return any ( 729reference ._claimed 730for handle ,reference in self ._refs .items () 731if self ._mirrors .get (handle , {}).get ("job" )== job 732 ) 733 734async def _send (self ,frame :dict [str ,Any ])-> None : 735if self .closed or self ._stdin is None : 736raise RemoteLost (f"the connection to { self . host } is closed" ) 737data = json .dumps (frame ).encode () 738if len (data )> MAX_FRAME : 739raise RemoteError ( 740f"remote call payload is { len ( data ) } bytes, over the { MAX_FRAME } -byte ceiling" 741 ) 742async with self ._write_lock : 743self ._stdin .write (struct .pack (">I" ,len (data ))+ data ) 744await self ._stdin .drain () 745 746def _lost (self )-> None : 747"""The channel died: every pending call and reference learns it once.""" 748if self ._handshake is not None and not self ._handshake .done (): 749self ._handshake .set_exception ( 750RemoteBootError ( 751"the ssh channel closed at startup: " 752+ bytes (self ._stderr_tail ).decode (errors = "replace" ).strip ()[:500 ] 753 ) 754 ) 755for future in self ._pending .values (): 756if not future .done (): 757future .set_exception ( 758RemoteLost (f"the connection to { self . host } was lost" ) 759 ) 760self .closed = True 761 762# --- calls --- 763 764def start ( 765self ,call :"_RemoteCall" ,args :tuple [Any , ...],kwargs :dict [str ,Any ] 766 )-> RemoteRef : 767"""Fire one call and return its reference immediately; it settles on await.""" 768call_id = uuid .uuid4 ().hex 769reference = RemoteRef (self ,call_id = call_id ,label = call ._label ) 770reference ._task = loop .create_task ( 771self .invoke ( 772name = call ._name , 773handle = call ._handle , 774pending = call ._pending , 775args = args , 776kwargs = kwargs , 777call_id = call_id , 778placeholder = reference , 779 ) 780 ) 781return reference 782 783async def invoke ( 784self , 785name :str = "" , 786* , 787handle :str | None = None , 788pending :str | None = None , 789args :tuple [Any , ...]= (), 790kwargs :dict [str ,Any ]| None = None , 791wait :bool = False , 792timeout :float = INVOKE_TIMEOUT , 793call_id :str | None = None , 794placeholder :RemoteRef | None = None , 795 )-> Any : 796"""One remote call; the answer is a value, or a reference to the live object. 797 798A `pending` target names an earlier call whose result this one uses, so 799method calls raced ahead of their own reply still resolve in order. The 800`placeholder` is the reference the caller already holds; a handle reply 801adopts into it rather than minting a second object. 802""" 803if self .degraded is not None : 804raise RemoteError ( 805f"degraded to ssh command mode ( { self . degraded } ); only rem.run() is available" 806 ) 807call_id = call_id or uuid .uuid4 ().hex 808frame :dict [str ,Any ]= { 809"type" :"invoke" , 810"id" :call_id , 811"args" : [_encode_arg (item )for item in args ], 812"kwargs" : {key :_encode_arg (item )for key ,item in (kwargs or {}).items ()}, 813 } 814if name : 815frame ["name" ]= name 816if handle is not None : 817frame ["target" ]= {"handle" :handle } 818elif pending is not None : 819frame ["target" ]= {"pending" :pending } 820if wait : 821frame ["await" ]= True 822future :asyncio .Future [dict [str ,Any ]]= loop .create_future () 823self ._pending [call_id ]= future 824await self ._send (frame ) 825try : 826reply = await asyncio .wait_for (future ,timeout ) 827except asyncio .TimeoutError : 828await self ._interrupt (call_id ) 829raise RemoteTimeout ( 830f"remote call { name or ( 'await ' + str ( handle ))!r } did not answer " 831f"in { timeout :g } s; an interrupt was sent" 832 )from None 833except asyncio .CancelledError : 834await self ._interrupt (call_id ) 835raise 836finally : 837self ._pending .pop (call_id ,None ) 838return self ._result (reply ,placeholder ) 839 840def _result ( 841self ,reply :dict [str ,Any ],placeholder :RemoteRef | None = None 842 )-> Any : 843"""One reply into a value or a reference, or the error it names.""" 844if reply .get ("ok" )is not True : 845error = reply .get ("error" )or {} 846if reply .get ("cancelled" ): 847raise RemoteCancelled (str (error .get ("evalue" ,"cancelled remotely" ))) 848raise RemoteExecutionError ( 849str (error .get ("ename" ,"RemoteError" )), 850str (error .get ("evalue" ,"" )), 851 [str (line )for line in error .get ("traceback" , [])], 852 ) 853try : 854value = decode (reply .get ("value" )) 855except InvalidValue as error : 856raise RemoteError (str (error ))from error 857if "handle" in reply : 858handle = str (reply ["handle" ]) 859state = reply .get ("state" ) 860if state is not None : 861self ._mirrors [handle ]= state 862reference = ( 863placeholder if placeholder is not None else self ._refs .get (handle ) 864 ) 865if reference is None : 866reference = RemoteRef (self ,handle ,value ) 867else : 868reference ._handle = handle 869reference ._value = value 870self ._refs [handle ]= reference 871return reference 872return value 873 874async def _interrupt (self ,call_id :str )-> None : 875"""Best-effort: tell the remote kernel this wait is over.""" 876try : 877await self ._send ({"type" :"interrupt" ,"id" :call_id }) 878except Exception : 879pass # the channel is already gone; the caller has that error 880 881async def invoke_release (self ,handle :str )-> None : 882"""Drop a live reference; no reply waits for it.""" 883self ._refs .pop (handle ,None ) 884await self ._send ({"type" :"release" ,"handle" :handle }) 885 886async def mark_read (self ,handle :str )-> None : 887"""Tell the remote object its result was read, retiring its wake. 888 889A local mirror read never crosses the channel on its own, so this one 890round trip (a poll, which the job counts as a read) is the only way a 891finished remote job learns the model already has its output. 892""" 893try : 894await self .invoke (handle = handle ,name = "poll" ,timeout = 30 ) 895except Exception : 896pass # the channel is gone; the notice fails its own way 897 898async def tools (self )-> dict [str ,Any ]: 899"""What the remote namespace holds, and the live references it kept.""" 900if self .degraded is not None : 901return {"names" : ["run" ],"handles" : [],"degraded" :str (self .degraded )} 902call_id = uuid .uuid4 ().hex 903future :asyncio .Future [dict [str ,Any ]]= loop .create_future () 904self ._pending [call_id ]= future 905await self ._send ({"type" :"introspect" ,"id" :call_id }) 906try : 907frame = await asyncio .wait_for (future ,CONNECT_TIMEOUT ) 908finally : 909self ._pending .pop (call_id ,None ) 910return {"names" :frame .get ("names" , []),"handles" :frame .get ("handles" , [])} 911 912async def stop_job (self ,job_id :str )-> dict [str ,object ]: 913"""Stop a background job running on the remote kernel.""" 914if self .closed or self .degraded is not None : 915return { 916"ok" :False , 917"error" :"the remote kernel is closed or degraded" , 918 } 919future :asyncio .Future [dict [str ,Any ]]= loop .create_future () 920self ._pending [job_id ]= future 921try : 922await self ._send ({"type" :"job_stop" ,"id" :job_id }) 923reply = await asyncio .wait_for (future ,15.0 ) 924return { 925"ok" :reply .get ("ok" )is True , 926** ({"error" :reply ["error" ]}if "error" in reply else {}), 927 } 928except asyncio .TimeoutError : 929return { 930"ok" :False , 931"error" :"remote job stop was not confirmed after 15s" , 932 } 933except Exception as error : 934return { 935"ok" :False , 936"error" :str (error ).lower ()or "could not stop remote job" , 937 } 938finally : 939self ._pending .pop (job_id ,None ) 940 941# --- ssh conveniences, both modes --- 942 943async def read (self ,path :str ,* ,timeout :float = COMMAND_TIMEOUT )-> str | bytes : 944"""A remote file over the control connection, not the kernel: its 945text, or its bytes when it is not UTF-8.""" 946remote = _remote_path (self ,path ) 947data = await self ._ssh (f"cat { shlex . quote ( remote ) } " ,timeout ) 948try : 949return data .decode () 950except UnicodeDecodeError : 951return data 952 953async def show_image ( 954self , 955source :bytes | str | os .PathLike [str ], 956* , 957timeout :float = COMMAND_TIMEOUT , 958 )-> str : 959"""Return a remote image to yourself with this cell's result, under the 960same limits as local show_image. The remote kernel has no cell of its 961own to carry one, so the bytes come here over the control connection.""" 962if attach_image is None : 963raise RemoteError ("this kernel cannot attach images" ) 964if isinstance (source , (bytes ,bytearray ,memoryview )): 965data = bytes (source ) 966elif isinstance (source , (str ,os .PathLike )): 967remote = shlex .quote (_remote_path (self ,os .fspath (source ))) 968data = await self ._ssh (f"head -c { IMAGE_READ } { remote } " ,timeout ) 969else : 970raise TypeError ( 971f"show_image takes image bytes or a remote path, not { type ( source ). __name__ } " 972 ) 973return "attached " + attach_image (data ) 974 975async def _ssh ( 976self ,script :str ,timeout :float ,stdin :bytes | None = None 977 )-> bytes : 978code ,stdout ,stderr = await ssh_run ( 979self .host ,in_login_shell (script ),timeout = timeout ,stdin = stdin 980 ) 981if code != 0 : 982raise RemoteError (f"SSH failed ( { code } ): { stderr . strip () } " ) 983return stdout 984 985async def write ( 986self , 987path :str , 988content :str | bytes | bytearray | memoryview , 989* , 990timeout :float = COMMAND_TIMEOUT , 991 )-> None : 992"""Replace a remote file, text or binary. The content travels on 993stdin, never in the command line, which a host caps (128 KiB per 994argument on Linux).""" 995remote = _remote_path (self ,path ) 996data = content .encode ()if isinstance (content ,str )else bytes (content ) 997await self ._ssh (f"cat > { shlex . quote ( remote ) } " ,timeout ,data ) 998 999# --- degraded mode --- 1000 1001@property 1002def run (self )-> Any : 1003"""rem.run: the remote binding in kernel mode, command mode otherwise.""" 1004if self .degraded is None : 1005if self .closed : 1006raise RemoteLost (f"the connection to { self . host } is closed" ) 1007return _RemoteCall (self ,None ,"run" ) 1008 1009def run ( 1010program :object , 1011* args :object , 1012cwd :str | None = None , 1013env :dict [str ,object ]| None = None , 1014stdin :str | bytes | None = None , 1015timeout :float = 300 , 1016 )-> FallbackJob : 1017check_timeout (timeout ) 1018if not (stdin is None or isinstance (stdin , (str ,bytes ))): 1019raise TypeError ("in ssh command mode stdin is text or bytes" ) 1020argv = albedo_shell .words ((program ,* args )) 1021script = albedo_shell .shell_script (argv ) 1022if script is not None : 1023raise albedo_shell .refusal ( 1024f"` { os . path . basename ( argv [ 0 ]) } -c`" ,script = script 1025 ) 1026return FallbackJob (self ,argv ,timeout ,cwd = cwd ,env = env ,stdin = stdin ) 1027 1028return run 1029 1030@property 1031def shell (self )-> Any : 1032"""A shell escape hatch only when the remote kernel could not boot.""" 1033if self .degraded is None : 1034raise AttributeError (SHELL_ONLY_DEGRADED ) 1035if self .closed : 1036raise RemoteLost (f"the connection to { self . host } is closed" ) 1037 1038def shell ( 1039script :str , 1040* , 1041cwd :str | None = None , 1042env :dict [str ,object ]| None = None , 1043stdin :str | bytes | None = None , 1044timeout :float = 300 , 1045 )-> FallbackJob : 1046if not isinstance (script ,str ): 1047raise ValueError ("shell script must be text" ) 1048check_timeout (timeout ) 1049if not (stdin is None or isinstance (stdin , (str ,bytes ))): 1050raise TypeError ("in ssh command mode stdin is text or bytes" ) 1051return FallbackJob ( 1052self , 1053 ["bash" ,"-c" ,script ], 1054timeout , 1055cwd = cwd , 1056env = env , 1057stdin = stdin , 1058shell = script , 1059 ) 1060 1061return shell 1062 1063# --- surface --- 1064 1065def __getattr__ (self ,name :str )-> Any : 1066if name .startswith ("_" ): 1067raise AttributeError (name ) 1068if name == "shell" and self .degraded is None : 1069raise AttributeError (SHELL_ONLY_DEGRADED ) 1070if self .degraded is not None : 1071raise AttributeError ( 1072f" { name !r } is unavailable: this connection is degraded to ssh command " 1073f"mode ( { self . degraded } ); only rem.run(), rem.shell(), rem.read, and rem.write work" 1074 ) 1075if self .closed : 1076raise RemoteLost (f"the connection to { self . host } is closed" ) 1077return _RemoteCall (self ,None ,name ) 1078 1079def __repr__ (self )-> str : 1080state = ( 1081"degraded" 1082if self .degraded is not None 1083else ("closed" if self .closed else "kernel" ) 1084 ) 1085return f"<remote { self . host } ( { state } )>" 1086 1087async def close (self )-> None : 1088"""End the kernel and its channel: shutdown frame, then the signal ladder.""" 1089if self .closed and self ._process is None : 1090return 1091if self ._process is not None and self ._process .returncode is None : 1092try : 1093await self ._send ({"type" :"shutdown" }) 1094except Exception : 1095pass 1096try : 1097await asyncio .wait_for (self ._process .wait (),1.0 ) 1098except asyncio .TimeoutError : 1099self ._process .terminate () 1100try : 1101await asyncio .wait_for (self ._process .wait (),1.0 ) 1102except asyncio .TimeoutError : 1103self ._process .kill () 1104self .closed = True 1105self ._live .clear () 1106_report_live () 1107for future in self ._pending .values (): 1108if not future .done (): 1109future .set_exception ( 1110RemoteLost (f"the connection to { self . host } closed" ) 1111 ) 1112self ._pending .clear () 1113for task in (self ._reader ,self ._drain ): 1114if task is not None : 1115task .cancel () 1116if self in connections : 1117connections .remove (self ) 1118 1119async def __aenter__ (self )-> "RemoteConnection" : 1120return self 1121 1122async def __aexit__ (self ,* _ :Any )-> None : 1123await self .close () 1124 1125@property 1126def host (self )-> str : 1127return str (self .target ["host" ]) 1128 1129 1130def _encode_arg (value :Any )-> Any : 1131"""References pass as references; everything else must already be wire data.""" 1132if isinstance (value ,RemoteRef ): 1133if value ._connection .closed : 1134raise RemoteLost ("cannot pass a reference from a closed connection" ) 1135if value ._handle is not None : 1136return {"__ref__" :value ._handle } 1137if value ._task is not None : 1138return {"pending" :value ._call_id } 1139if value ._settled is not _UNSET : 1140return _encode_arg (value ._settled ) 1141raise RemoteError ("cannot pass an unusable remote reference" ) 1142if isinstance (value , (str ,int ,float ,bool ))or value is None : 1143return value 1144if isinstance (value , (bytes ,bytearray )): 1145return {"__bytes__" :base64 .b64encode (bytes (value )).decode ("ascii" )} 1146if isinstance (value ,list ): 1147return [_encode_arg (item )for item in value ] 1148if isinstance (value ,dict )and type (value )is dict : 1149return {key :_encode_arg (item )for key ,item in value .items ()} 1150try : 1151json .dumps (value ) 1152return value 1153except (TypeError ,ValueError )as error : 1154raise RemoteError ( 1155f"cannot pass { type ( value ). __name__ } to a remote call: only wire data " 1156"and remote references cross" 1157 )from error 1158 1159 1160def _remote_path (connection :RemoteConnection ,path :str )-> str : 1161"""The remote spelling of a workspace path; anything else passes through.""" 1162remote_cwd = connection .target .get ("remote_cwd" ) 1163if remote_cwd and path .startswith (str (Path .cwd ())): 1164return str (Path (remote_cwd )/ Path (path ).relative_to (Path .cwd ())) 1165return path 1166 1167 1168class FallbackJob (JobOutput ): 1169"""One command over the control connection when no kernel could boot. 1170 1171The same shape as a run job: await it, tail() it, stop() it. Killing the 1172ssh client cannot stop the remote side, so a deadline reports timed_out 1173and says the remote effect is unknown rather than ended. 1174""" 1175 1176def __init__ ( 1177self , 1178connection :RemoteConnection , 1179argv :list [str ], 1180timeout :float , 1181* , 1182cwd :str | None = None , 1183env :dict [str ,object ]| None = None , 1184stdin :str | bytes | None = None , 1185shell :str | None = None , 1186 )-> None : 1187self .id = uuid .uuid4 ().hex 1188self .argv = argv 1189self .command = shell if shell is not None else shlex .join (argv ) 1190self .timed_out = False 1191self .exit_code :int | None = None 1192self .started :float = loop .time () 1193self .duration :float | None = None 1194self .capture = capture_factory (self .id ) 1195self ._awaited = False # an await returned this job; its result reached them 1196self ._waiters = 0 # awaits still waiting; one cut short leaves the wake owed 1197self ._read = False 1198self ._announcer :asyncio .Task [None ]| None = None 1199self ._connection = connection 1200_fallback_live [self .id ]= _live_job (self .id ,self .command ,False ) 1201_fallback_jobs [self .id ]= self 1202_report_live () 1203# the remote login shell reads this line; everything in it is quoted 1204steps = [ 1205f"cd { shlex . quote ( str ( where )) } " 1206for where in (connection .target .get ("remote_cwd" ),cwd ) 1207if where 1208 ] 1209assignments = [f" { name } = { value } " for name ,value in (env or {}).items ()] 1210steps .append ( 1211shlex .join (["exec" ,* (["env" ,* assignments ]if assignments else []),* argv ]) 1212 ) 1213data = stdin .encode ()if isinstance (stdin ,str )else stdin 1214self ._task = loop .create_task ( 1215self ._run ( 1216connection .host ,in_login_shell (" && " .join (steps )),timeout ,data 1217 ) 1218 ) 1219 1220async def _run ( 1221self ,host :str ,script :str ,timeout :float ,stdin :bytes | None 1222 )-> "FallbackJob" : 1223process = await asyncio .create_subprocess_exec ( 1224* ssh_base (host ), 1225host , 1226script , 1227stdin = asyncio .subprocess .DEVNULL 1228if stdin is None 1229else asyncio .subprocess .PIPE , 1230stdout = asyncio .subprocess .PIPE , 1231stderr = asyncio .subprocess .STDOUT , 1232env = ssh_env (host ), 1233 ) 1234self ._process = process 1235if stdin is not None and process .stdin is not None : 1236process .stdin .write (stdin ) 1237process .stdin .close () 1238copying = loop .create_task (self ._copy (process .stdout )) 1239try : 1240self .exit_code = await asyncio .wait_for (process .wait (),timeout ) 1241with contextlib .suppress (asyncio .TimeoutError ): 1242await asyncio .wait_for (copying ,timeout = 1.0 ) 1243except asyncio .TimeoutError : 1244self .timed_out = True 1245process .kill () 1246self .exit_code = await process .wait () 1247self .capture .write ( 1248f"\n[deadline exceeded after { timeout :g } s; the ssh client was killed; " 1249f"the remote command may still be running]" 1250 ) 1251finally : 1252copying .cancel () 1253self .capture .end_spill () 1254self .duration = loop .time ()- self .started 1255_fallback_live .pop (self .id ,None ) 1256_fallback_jobs .pop (self .id ,None ) 1257_report_live () 1258self ._owe_unread () 1259return self 1260 1261@property 1262def _unreported (self )-> bool : 1263"""Finished, and its result has reached no one yet.""" 1264return not self ._read and not self ._awaited and not self ._waiters 1265 1266def _owe_unread (self )-> None : 1267"""Start the finished command's wake unless its result reached someone.""" 1268if host_call is None or not self ._unreported : 1269return 1270if self ._announcer is None or self ._announcer .done (): 1271self ._announcer = loop .create_task (self ._announce ()) 1272 1273async def _announce (self )-> None : 1274"""Wake the session for this command, retrying while it runs.""" 1275while self ._unreported : 1276try : 1277await host_call ("jobs.completed" ,self ._notice ()) 1278return 1279except HostErrorType as error : 1280if getattr (error ,"code" ,"" )!= "busy" : 1281self .capture .write (f"\n[completion notice not delivered: { error } \n" ) 1282return 1283except Exception : 1284return # the kernel is going away; the result stays on the handle 1285await asyncio .sleep (2.0 ) 1286 1287def _notice (self )-> dict [str ,object ]: 1288"""The wake turn's display text, model text, and the facts behind both.""" 1289command = self .command [:200 ]+ ("..." if len (self .command )> 200 else "" ) 1290outcome = ( 1291"timed out" 1292if self .timed_out 1293else f"exit_code= { self . exit_code } " 1294if self .exit_code is not None 1295else "exit status unknown" 1296 ) 1297seconds = ( 1298f" { self . duration :.1f } s" if self .duration is not None else "unknown duration" 1299 ) 1300display = ( 1301f"job finished on { self . _connection . host } ( { outcome } , { seconds } ): { command } " 1302 ) 1303text = ( 1304"<system-note>a background command finished with its result unread on" 1305f" { self . _connection . host } (ssh command mode): { outcome } , { seconds } ," 1306f" command: { command } ." 1307" its handle is the job rem.run returned; its .tail() or" 1308f" output.read( { self . id !r } ) reads its output." 1309" no user sent this message; use the result if the session's work needs" 1310" it, otherwise acknowledge briefly and stay idle.</system-note>" 1311 ) 1312return { 1313"display" :display , 1314"text" :text , 1315"id" :self .id , 1316"exit_code" :self .exit_code , 1317"timed_out" :self .timed_out , 1318"duration" :self .duration , 1319"host" :self ._connection .host , 1320 } 1321 1322async def _copy (self ,stream :Any )-> None : 1323while chunk := await stream .read (8192 ): 1324self .capture .write_bytes (chunk ) 1325 1326def __await__ (self ): 1327return self ._wait ().__await__ () 1328 1329async def _wait (self )-> "FallbackJob" : 1330"""Wait for the command; an await cut short leaves its wake owed.""" 1331self ._waiters += 1 1332try : 1333await asyncio .shield (self ._task ) 1334self ._awaited = True 1335finally : 1336self ._waiters -= 1 1337if self ._task .done (): 1338self ._owe_unread () 1339return self 1340 1341def poll (self )-> int | None : 1342if self .exit_code is not None : 1343self ._read = True 1344return self .exit_code 1345 1346@property 1347def returncode (self )-> int | None : 1348"""`exit_code` under subprocess's name; both spellings answer.""" 1349return self .poll () 1350 1351async def stop (self )-> None : 1352"""End the ssh client; the remote command's fate is reported, not assumed.""" 1353self ._read = True # an explicit stop is its own report; no wake is owed 1354# A stop can arrive before the spawn produced a process, but a spawn 1355# that never does must not spin here forever. 1356for _ in range (500 ): 1357if getattr (self ,"_process" ,None )is not None or self ._task .done (): 1358break 1359await asyncio .sleep (0.01 ) 1360process = getattr (self ,"_process" ,None ) 1361if process is not None and process .returncode is None : 1362process .terminate () 1363try : 1364await asyncio .wait_for (process .wait (),2.0 ) 1365except asyncio .TimeoutError : 1366process .kill () 1367self .capture .write ( 1368"\n[stopped locally; the remote command may still be running]" 1369 ) 1370if hasattr (self ,"_task" ): 1371with contextlib .suppress (Exception ): 1372await self ._task 1373 1374def __repr__ (self )-> str : 1375return ( 1376f"FallbackJob(id= { self . id !r } , exit_code= { self . exit_code !r } , " 1377f"timed_out= { self . timed_out !r } , duration= { self . duration !r } , " 1378f"bytes= { self . capture . seen } )" 1379 ) 1380 1381 1382def default_modules ()-> list [str ]: 1383"""The session's tool selection, minus this plugin: what boots remotely.""" 1384return [name for name in session_modules if name != "remote" ] 1385 1386 1387class Remote : 1388"""The namespace bound into the kernel as `remote`.""" 1389 1390@staticmethod 1391def configure (host :str ,remote_cwd :str | None = None )-> dict [str ,str | None ]: 1392"""Resolve `user@host[:/path]` once and keep it for later connections.""" 1393target = parse_target (host ) 1394if remote_cwd is not None : 1395target ["remote_cwd" ]= remote_cwd 1396configured .clear () 1397configured .update (target ) 1398return dict (target ) 1399 1400@staticmethod 1401async def connect ( 1402host :str | None = None , 1403* , 1404remote_cwd :str | None = None , 1405python :str | None = None , 1406modules :list [str ]| None = None , 1407timeout :float = HANDSHAKE_TIMEOUT , 1408 )-> RemoteConnection : 1409"""Boot this session's kernel on a remote host, or degrade to commands. 1410 1411The target resolves per call: an explicit `host=`, then the value 1412`remote.configure` stored, then `$ALBEDO_SSH`, then the `remote` 1413section of extensions.json. If the host is reachable but the kernel 1414cannot boot, the connection still returns, prints a warning, and 1415answers `rem.run()`, degraded-only `rem.shell()`, `rem.read`, `rem.write`, 1416and `rem.show_image`. 1417""" 1418target = resolve (host ,remote_cwd ,python ) 1419code ,_ ,stderr = await ssh_run ( 1420str (target ["host" ]),"true" ,timeout = CONNECT_TIMEOUT 1421 ) 1422if code != 0 : 1423raise RemoteError (refusal (str (target ["host" ]),stderr )) 1424if target ["remote_cwd" ]is None : 1425code ,stdout ,_ = await ssh_run ( 1426str (target ["host" ]),"pwd" ,timeout = CONNECT_TIMEOUT 1427 ) 1428if code == 0 : 1429target ["remote_cwd" ]= stdout .decode (errors = "replace" ).strip ()or None 1430connection = RemoteConnection (target ) 1431try : 1432await connection .boot (modules ,timeout ) 1433except RemoteBootError as error : 1434connection .degraded = error 1435if connection ._process is not None : 1436if connection ._reader is not None : 1437connection ._reader .cancel () 1438with contextlib .suppress (asyncio .CancelledError ): 1439await connection ._reader 1440if connection ._drain is not None : 1441connection ._drain .cancel () 1442if connection ._process .returncode is None : 1443connection ._process .terminate () 1444connection ._process = None 1445connection ._stdin = connection ._stdout = None 1446connection .closed = False 1447f"[remote] kernel boot failed on { target [ 'host' ] } : { error } ; " 1449"degraded to ssh command mode: rem.run(program, *args) still works; " 1450"for pipes or shell syntax use rem.shell('git log --oneline | rg fix') " 1451"(bash on the host; no remote process supervision)" 1452 ) 1453connections .append (connection ) 1454return connection 1455 1456@staticmethod 1457def connections ()-> ReadyList : 1458"""Every open connection, kernel or degraded.""" 1459return ReadyList (connections ) 1460 1461@staticmethod 1462async def close_all ()-> None : 1463"""Kernel shutdown: end every connection inside the cleanup deadline.""" 1464for connection in list (connections ): 1465await connection .close () 1466 1467 1468remote = Remote () 1469 1470 1471async def stop_remote_job (job_id :str )-> dict [str ,object ]| None : 1472"""Stop a remote background job across open remote connections and fallbacks.""" 1473fallback = _fallback_jobs .get (job_id ) 1474if fallback is not None : 1475await fallback .stop () 1476return {"ok" :True } 1477for connection in connections : 1478if job_id in connection ._live : 1479return await connection .stop_job (job_id ) 1480return None 1481 1482 1483def setup (api :PythonApi )-> dict [str ,object ]: 1484global loop ,host_call ,HostErrorType ,capture_factory ,send_frame 1485global attach_image ,session_modules 1486loop ,host_call = api .loop ,api .host 1487HostErrorType ,capture_factory = api .HostError ,api .capture 1488send_frame = api .send 1489attach_image = api .attach_image 1490session_modules = list (api .modules ) 1491api .on_shutdown (Remote .close_all ) 1492if api .on_stop_job is not None : 1493api .on_stop_job (stop_remote_job ) 1494return {"remote" :remote ,"RemoteError" :RemoteError }