@@ -534,6 +534,149 @@ def _add_keypair_parser(sub: "argparse._SubParsersAction[argparse.ArgumentParser
534534 fp .add_argument ("key_file" , help = "Path to a .pem or .pub key file" )
535535
536536
537+ def _cmd_pair (args : argparse .Namespace ) -> None :
538+ """Handle ``pyfuse pair accept|request`` subcommands."""
539+ action = getattr (args , "pair_action" , None )
540+ if action is None :
541+ print ("Usage: pyfuse pair {accept|request}" , file = sys .stderr )
542+ sys .exit (1 )
543+
544+ if action == "accept" :
545+ asyncio .run (_pair_accept (args ))
546+ elif action == "request" :
547+ asyncio .run (_pair_request (args ))
548+
549+
550+ async def _pair_accept (args : argparse .Namespace ) -> None :
551+ """Worker-side pairing: generate code, wait for client."""
552+ from pyfuse .core .pairing import (
553+ RedisPairingTransport ,
554+ accept_pairing ,
555+ generate_pairing_code ,
556+ )
557+
558+ backend_url = args .backend or os .environ .get ("PYFUSE_BACKEND" )
559+ if not backend_url :
560+ print (
561+ "Error: --backend is required (or set PYFUSE_BACKEND)." ,
562+ file = sys .stderr ,
563+ )
564+ sys .exit (1 )
565+
566+ code = args .code or generate_pairing_code ()
567+ timeout = args .timeout
568+
569+ transport = RedisPairingTransport (backend_url )
570+ print (f"Pairing code: { code } " )
571+ print (f"Waiting for client... (expires in { timeout } s)" )
572+
573+ try :
574+ result = await accept_pairing (
575+ transport ,
576+ code ,
577+ trusted_keys_dir = args .trusted_keys ,
578+ timeout = timeout ,
579+ )
580+ print (f"\u2713 Paired! Fingerprint: { result .fingerprint } " )
581+ if args .trusted_keys :
582+ print (f" Key saved to { args .trusted_keys } /" )
583+ except TimeoutError :
584+ print ("\u2717 Pairing timed out — no client connected." , file = sys .stderr )
585+ sys .exit (1 )
586+ except ValueError as exc :
587+ print (f"\u2717 Pairing failed: { exc } " , file = sys .stderr )
588+ sys .exit (1 )
589+ finally :
590+ await transport .close ()
591+
592+
593+ async def _pair_request (args : argparse .Namespace ) -> None :
594+ """Client-side pairing: connect to worker with code."""
595+ from pyfuse .core .pairing import RedisPairingTransport , request_pairing
596+
597+ backend_url = args .backend or os .environ .get ("PYFUSE_BACKEND" )
598+ if not backend_url :
599+ print (
600+ "Error: --backend is required (or set PYFUSE_BACKEND)." ,
601+ file = sys .stderr ,
602+ )
603+ sys .exit (1 )
604+
605+ if not args .code :
606+ print ("Error: --code is required." , file = sys .stderr )
607+ sys .exit (1 )
608+
609+ transport = RedisPairingTransport (backend_url )
610+
611+ try :
612+ result = await request_pairing (
613+ transport ,
614+ args .code ,
615+ save_path = args .output ,
616+ timeout = args .timeout ,
617+ )
618+ print (f"\u2713 Paired with worker! Fingerprint: { result .fingerprint } " )
619+ if args .output :
620+ print (f" Private key: { args .output } " )
621+ print (f" Public key: { Path (args .output ).with_suffix ('.pub' )} " )
622+ except TimeoutError :
623+ print ("\u2717 Pairing timed out — no worker responded." , file = sys .stderr )
624+ sys .exit (1 )
625+ except ValueError as exc :
626+ print (f"\u2717 Pairing failed: { exc } " , file = sys .stderr )
627+ sys .exit (1 )
628+ finally :
629+ await transport .close ()
630+
631+
632+ def _add_pair_parser (sub : "argparse._SubParsersAction[argparse.ArgumentParser]" ) -> None :
633+ p = sub .add_parser (
634+ "pair" ,
635+ help = "Automated SPAKE2-based client-worker pairing" ,
636+ )
637+ pair_sub = p .add_subparsers (dest = "pair_action" )
638+
639+ accept = pair_sub .add_parser (
640+ "accept" , help = "Worker side: generate a pairing code and wait for a client" ,
641+ )
642+ accept .add_argument (
643+ "--backend" , default = None ,
644+ help = "Backend URL (default: $PYFUSE_BACKEND)" ,
645+ )
646+ accept .add_argument (
647+ "--code" , default = None ,
648+ help = "Use a specific pairing code instead of generating one" ,
649+ )
650+ accept .add_argument (
651+ "--trusted-keys" , default = None , metavar = "DIR" ,
652+ help = "Directory to save the client's .pub key file" ,
653+ )
654+ accept .add_argument (
655+ "--timeout" , type = float , default = 60.0 ,
656+ help = "Seconds to wait for a client (default: 60)" ,
657+ )
658+
659+ request = pair_sub .add_parser (
660+ "request" , help = "Client side: pair with a worker using a pairing code" ,
661+ )
662+ request .add_argument (
663+ "--backend" , default = None ,
664+ help = "Backend URL (default: $PYFUSE_BACKEND)" ,
665+ )
666+ request .add_argument (
667+ "--code" , default = None ,
668+ help = "Pairing code displayed by the worker" ,
669+ )
670+ request .add_argument (
671+ "-o" , "--output" , default = None ,
672+ help = "Path to save the generated keypair (default: pyfuse_key.pem)" ,
673+ )
674+ request .add_argument (
675+ "--timeout" , type = float , default = 60.0 ,
676+ help = "Seconds to wait for the worker (default: 60)" ,
677+ )
678+
679+
537680def _build_parser () -> argparse .ArgumentParser :
538681 parser = argparse .ArgumentParser (
539682 prog = "pyfuse" , description = "pyfuse - distributed task execution" ,
@@ -546,6 +689,7 @@ def _build_parser() -> argparse.ArgumentParser:
546689 _add_reconstruct_parser (sub )
547690 _add_sandbox_parser (sub )
548691 _add_keypair_parser (sub )
692+ _add_pair_parser (sub )
549693 return parser
550694
551695
@@ -557,6 +701,7 @@ def _build_parser() -> argparse.ArgumentParser:
557701 "reconstruct" : _cmd_reconstruct ,
558702 "sandbox" : _cmd_sandbox ,
559703 "keypair" : _cmd_keypair ,
704+ "pair" : _cmd_pair ,
560705}
561706
562707
0 commit comments