For interfaced SSH agents in the tests, set SSH_AUTH_SOCK manually
Marco Ricci

Marco Ricci commited on 2026-01-18 17:35:34
Zeige 2 geänderte Dateien mit 33 Einfügungen und 19 Löschungen.


Generally, if we have an abstract socket connected to an SSH agent, then
this is sufficient for us to construct a client based on the socket,
and interact with the agent as needed.

However, in the CLI tests, there are situations where we are
orchestrating the whole `derivepassphrase` application, and the
application must connect to the agent itself.  For these situations, we
need to set up the necessary configuration to pass the agent's socket
address to `derivepassphrase`.  For agents merely interfaced in the test
suite, we were not doing such a setup, causing orchestrated
`derivepassphrase` application instances to believe there was no agent
to connect to.

Fix this by (a) exposing the socket address on the socket object,
internal to the spawning/interfacing function, and (b) let interfacing
functions return a pair of socket and socket address, not just the
socket.  (We intend to keep the interface for actually obtaining the
address private to each interfacing function, because not all socket
objects can easily be retrofitted with extra methods to query the socket
address.)

(As a side effect, give the socket providers on The Annoying OS more
specific typing, so that the type checker can verify that the
interfacing functions are accessing the correct attributes and methods
for obtaining the socket address.)
... ...
@@ -429,6 +429,7 @@ class WindowsNamedPipeHandle:
429 429
                 if getattr(exc, "winerror", None) == ERROR_PIPE_BUSY:
430 430
                     continue  # retry
431 431
                 raise
432
+        self._name = name
432 433
 
433 434
     def __enter__(self) -> Self:  # pragma: unless the-annoying-os no cover
434 435
         return self
... ...
@@ -521,6 +522,10 @@ class WindowsNamedPipeHandle:
521 522
         finally:
522 523
             CloseHandle(wait_event)
523 524
 
525
+    def named_pipe_name(self) -> str:
526
+        """Return the named pipe name this socket is connected to."""
527
+        return self._name
528
+
524 529
     @classmethod
525 530
     def for_openssh(cls) -> Self:  # pragma: unless the-annoying-os no cover
526 531
         """Construct a named pipe for use with OpenSSH on The Annoying OS.
... ...
@@ -653,7 +658,7 @@ class SocketProvider:
653 658
     @staticmethod
654 659
     def _windows_named_pipe(
655 660
         pipe_name: _WindowsNamedPipeSocketAddress | str | None,
656
-    ) -> _types.SSHAgentSocket:
661
+    ) -> WindowsNamedPipeHandle:
657 662
         r"""Return a socket wrapper around a Windows named pipe.
658 663
 
659 664
         Args:
... ...
@@ -699,7 +704,7 @@ class SocketProvider:
699 704
             return WindowsNamedPipeHandle(pipe_name)
700 705
 
701 706
     @classmethod
702
-    def windows_named_pipe_for_pageant(cls) -> _types.SSHAgentSocket:
707
+    def windows_named_pipe_for_pageant(cls) -> WindowsNamedPipeHandle:
703 708
         """Return a socket wrapper connected to Pageant on The Annoying OS.
704 709
 
705 710
         Raises:
... ...
@@ -713,7 +718,7 @@ class SocketProvider:
713 718
         return cls._windows_named_pipe(_WindowsNamedPipeSocketAddress.PAGEANT)
714 719
 
715 720
     @classmethod
716
-    def windows_named_pipe_for_openssh(cls) -> _types.SSHAgentSocket:
721
+    def windows_named_pipe_for_openssh(cls) -> WindowsNamedPipeHandle:
717 722
         """Return a socket wrapper connected to the OpenSSH agent on The Annoying OS.
718 723
 
719 724
         Raises:
... ...
@@ -728,7 +733,7 @@ class SocketProvider:
728 733
         return cls._windows_named_pipe(_WindowsNamedPipeSocketAddress.OPENSSH)
729 734
 
730 735
     @classmethod
731
-    def windows_named_pipe_ssh_auth_sock(cls) -> _types.SSHAgentSocket:
736
+    def windows_named_pipe_ssh_auth_sock(cls) -> WindowsNamedPipeHandle:
732 737
         r"""Return a socket wrapper connected to the agent in `SSH_AUTH_SOCK`.
733 738
 
734 739
         The `SSH_AUTH_SOCK` environment variable is assumed to contain
... ...
@@ -110,7 +110,7 @@ class SSHAgentInterfaceFunc(Protocol):
110 110
         self,
111 111
         executable: str | None,
112 112
         env: dict[str, Any],
113
-    ) -> _types.SSHAgentSocket | None:
113
+    ) -> tuple[_types.SSHAgentSocket, str] | None:
114 114
         """Interface the SSH agent.
115 115
 
116 116
         Args:
... ...
@@ -125,8 +125,9 @@ class SSHAgentInterfaceFunc(Protocol):
125 125
                 (Ignored.  Not relevant for interfacing functions.)
126 126
 
127 127
         Returns:
128
-            An abstract socket connected to the specified SSH agent, if
129
-            possible; otherwise, `None`.
128
+            A 2-tuple, if possible, containing an abstract socket
129
+            connected to the specified SSH agent and the associated
130
+            address; otherwise, `None`.
130 131
 
131 132
             It is the caller's responsibility to clean up the socket
132 133
             (through use of the context manager protocol).
... ...
@@ -257,7 +258,7 @@ def spawn_noop(  # pragma: no cover [unused]
257 258
 def interface_pageant_on_the_annoying_os(
258 259
     executable: str | None,
259 260
     env: dict[str, Any],
260
-) -> _types.SSHAgentSocket | None:  # pragma: no cover [external]
261
+) -> tuple[_types.SSHAgentSocket, str] | None:  # pragma: no cover [external]
261 262
     """Interface a Pageant instance on The Annoying OS, if possible.
262 263
 
263 264
     Args:
... ...
@@ -274,15 +275,17 @@ def interface_pageant_on_the_annoying_os(
274 275
     """
275 276
     del executable, env
276 277
     try:
277
-        return socketprovider.SocketProvider.windows_named_pipe_for_pageant()
278
+        socket = socketprovider.SocketProvider.windows_named_pipe_for_pageant()
278 279
     except socketprovider.WindowsNamedPipesNotAvailableError:
279 280
         return None
281
+    else:
282
+        return (socket, socket.named_pipe_name())
280 283
 
281 284
 
282 285
 def interface_openssh_agent_on_the_annoying_os(
283 286
     executable: str | None,
284 287
     env: dict[str, Any],
285
-) -> _types.SSHAgentSocket | None:  # pragma: no cover [external]
288
+) -> tuple[_types.SSHAgentSocket, str] | None:  # pragma: no cover [external]
286 289
     """Interface an OpenSSH agent instance on The Annoying OS, if possible.
287 290
 
288 291
     Args:
... ...
@@ -299,9 +302,11 @@ def interface_openssh_agent_on_the_annoying_os(
299 302
     """
300 303
     del executable, env
301 304
     try:
302
-        return socketprovider.SocketProvider.windows_named_pipe_for_openssh()
305
+        socket = socketprovider.SocketProvider.windows_named_pipe_for_openssh()
303 306
     except socketprovider.WindowsNamedPipesNotAvailableError:
304 307
         return None
308
+    else:
309
+        return (socket, socket.named_pipe_name())
305 310
 
306 311
 
307 312
 class SpawnHandler(NamedTuple):
... ...
@@ -541,17 +546,17 @@ def spawn_named_agent(  # noqa: C901
541 546
     exit_stack = contextlib.ExitStack()
542 547
     agent_env = os.environ.copy()
543 548
     ssh_auth_sock = agent_env.pop("SSH_AUTH_SOCK", None)
544
-    proc_or_socket = spawn_func(executable=executable, env=agent_env)
549
+    proc_or_socket_data = spawn_func(executable=executable, env=agent_env)
545 550
     with exit_stack:
546 551
         if agent_type == data.KnownSSHAgent.StubbedSSHAgent:
547 552
             ssh_auth_sock = None
548 553
         elif spawn_func is spawn_noop:
549 554
             ssh_auth_sock = os.environ.get("SSH_AUTH_SOCK")
550
-        elif proc_or_socket is None:  # pragma: no cover [external]
555
+        elif proc_or_socket_data is None:  # pragma: no cover [external]
551 556
             err_msg = f"Cannot spawn or interface with usable {agent_name}"
552 557
             raise CannotSpawnError(err_msg)
553
-        elif isinstance(proc_or_socket, subprocess.Popen):
554
-            proc = proc_or_socket
558
+        elif isinstance(proc_or_socket_data, subprocess.Popen):
559
+            proc = proc_or_socket_data
555 560
             exit_stack.enter_context(terminate_on_exit(proc))
556 561
             assert os.environ.get("SSH_AUTH_SOCK") == startup_ssh_auth_sock, (
557 562
                 f"SSH_AUTH_SOCK mismatch after spawning {agent_name}"
... ...
@@ -572,6 +577,8 @@ def spawn_named_agent(  # noqa: C901
572 577
             ):  # pragma: no cover [external]
573 578
                 err_msg = f"Cannot parse agent output: {pid_line!r}"
574 579
                 raise CannotSpawnError(err_msg)
580
+        else:
581
+            ssh_auth_sock = proc_or_socket_data[1]
575 582
         monkeypatch = exit_stack.enter_context(pytest.MonkeyPatch.context())
576 583
         if agent_type == data.KnownSSHAgent.StubbedSSHAgent:
577 584
             assert ssh_auth_sock is None
... ...
@@ -596,10 +603,12 @@ def spawn_named_agent(  # noqa: C901
596 603
                 )
597 604
             )
598 605
         elif (
599
-            not isinstance(proc_or_socket, subprocess.Popen)
600
-            and proc_or_socket is not None
606
+            not isinstance(proc_or_socket_data, subprocess.Popen)
607
+            and proc_or_socket_data is not None
601 608
         ):
602
-            socket: _types.SSHAgentSocket = proc_or_socket
609
+            socket: _types.SSHAgentSocket
610
+            socket, ssh_auth_sock = proc_or_socket_data
611
+            monkeypatch.setenv("SSH_AUTH_SOCK", ssh_auth_sock)
603 612
             client = exit_stack.enter_context(
604 613
                 ssh_agent.SSHAgentClient.ensure_agent_subcontext(conn=socket)
605 614
             )
... ...
@@ -635,7 +644,7 @@ def spawn_named_agent(  # noqa: C901
635 644
             agent_type,
636 645
             client,
637 646
             spawn_func is not spawn_noop
638
-            if isinstance(proc_or_socket, subprocess.Popen)
647
+            if isinstance(proc_or_socket_data, subprocess.Popen)
639 648
             else False,
640 649
         )
641 650
     assert os.environ.get("SSH_AUTH_SOCK", None) == startup_ssh_auth_sock, (
642 651