Work around non-reentrant SSH agent sockets/clients
Marco Ricci

Marco Ricci commited on 2026-02-01 20:38:39
Zeige 4 geänderte Dateien mit 194 Einfügungen und 17 Löschungen.


Mark some tests as needing the ability to construct a second SSH agent
client (on the same socket address) while another one is still
connected.  This works with most agents, except `gpg-agent` on The
Annoying OS when masquerading as OpenSSH's `ssh-agent`.  Presumably,
`gpg-agent` handles the requests with a single thread, non-multiplexed,
and so blocks all other agent clients from progressing.  The symptoms
are blocking during the connect call, then failing with "socket address
not found" immediately once the other client closes its connection.

We address the affected tests by monkeypatching the
`ssh_agent.SSHAgentClient.ensure_agent_subcontext` context manager – the
main way `derivepassphrase` internally interacts with the SSH agent – to
return a singleton agent: the agent provided to the test function (via
a fixture).  We implement this as a machinery function to set up the
environment explicitly, because this functionality hard to set up
usefully as a test fixture: it interferes with testing SSH agent client
constructor failures, and *those* are sometimes implemented as single
`pytest.param`s of a common test function, but the test function cannot
dynamically adapt the set of applicable fixtures to the specific
parametrization.

While specifically designed for use with `gpg-agent` on The Annoying OS,
the interface is general in nature, and can be used with any declared
SSH agent (except the fake agents).  Since this internally works
similarly to the "permitted SSH agents" feature of the test suite, we
generalize the latter slightly to allow implementing the former.
... ...
@@ -37,6 +37,9 @@ def pytest_configure(config: pytest.Config) -> None:
37 37
         (
38 38
             "heavy_duty: "
39 39
             "mark test as a slow, heavy-duty test (e.g., an integration test)"
40
+            "\n"
41
+            "non_reentrant_agent: "
42
+            "mark the agent (fixture output) as non-reentrant"
40 43
         ),
41 44
     )
42 45
     hypothesis_machinery._hypothesis_settings_setup()
... ...
@@ -390,6 +393,58 @@ The standard registry of agent spawning functions.
390 393
 """
391 394
 
392 395
 
396
+def external_agent_restriction(
397
+    agent_type: data.KnownSSHAgent,
398
+    env_var: str,
399
+    *,
400
+    default: bool = True,
401
+) -> bool:  # pragma: no cover [external]
402
+    """Classify an SSH agent according to some environment variable.
403
+
404
+    Look up the given SSH agent type in a certain environment variable,
405
+    and report on whether the agent type is listed in that variable.
406
+    This can be used to check if the agent is e.g. in a list of
407
+    permitted agents, or if it is in the list of agents requiring
408
+    special workarounds.  Invalid agent type names are silently ignored.
409
+
410
+    If the environment variable is empty, return a default value.
411
+
412
+    By design, the stubbed agents have a fixed classification (the
413
+    default value), unaffected by the environment variable.
414
+
415
+    Args:
416
+        agent_type:
417
+            The agent type to classify.
418
+        env_var:
419
+            The environment variable in which to look up the agent type
420
+            name.
421
+        default:
422
+            The value to return if the environment variable is unset, or
423
+            if a stub agent is to be classified.
424
+
425
+    Returns:
426
+        If the agent type is not a stub agent, and the environment
427
+        variable is non-empty, then `True` if the agent type is
428
+        mentioned in the environment variable, and `False` otherwise;
429
+        the default value otherwise.
430
+
431
+    See also:
432
+        [`is_agent_permitted`][] and [`is_agent_non_reentrant`][], which
433
+        make use of this function internally.
434
+
435
+    """
436
+    if agent_type == data.KnownSSHAgent.StubbedSSHAgent:
437
+        return default
438
+    if not os.environ.get(env_var):
439
+        return default
440
+    marked_agents = {
441
+        data.KnownSSHAgent(x)
442
+        for x in os.environ[env_var].split(",")
443
+        if x in data.KnownSSHAgent.__members__
444
+    }
445
+    return agent_type in marked_agents
446
+
447
+
393 448
 def is_agent_permitted(
394 449
     agent_type: data.KnownSSHAgent,
395 450
 ) -> bool:  # pragma: no cover [external]
... ...
@@ -407,15 +462,35 @@ def is_agent_permitted(
407 462
     suite depends on their availability.
408 463
 
409 464
     """
410
-    if not os.environ.get("PERMITTED_SSH_AGENTS"):
411
-        return True
412
-    permitted_agents = {data.KnownSSHAgent.StubbedSSHAgent}
413
-    permitted_agents.update({
414
-        data.KnownSSHAgent(x)
415
-        for x in os.environ["PERMITTED_SSH_AGENTS"].split(",")
416
-        if x in data.KnownSSHAgent.__members__
417
-    })
418
-    return agent_type in permitted_agents
465
+    return external_agent_restriction(
466
+        agent_type, "PERMITTED_SSH_AGENTS", default=True
467
+    )
468
+
469
+
470
+def is_agent_non_reentrant(
471
+    agent_type: data.KnownSSHAgent,
472
+) -> bool:  # pragma: no cover [external]
473
+    """Is the given SSH agent assumed to be non-reentrant?
474
+
475
+    If the environment variable `NON_REENTRANT_SSH_AGENTS` is given, it
476
+    names a comma-separated list of known SSH agent names that are
477
+    assumed to be non-reentrant, i.e., where the SSH agent cannot
478
+    tolerate multiple clients connecting to it at the same time, and
479
+    thus no client for the same agent may be constructed while another
480
+    one is already connected.  Invalid names are silently ignored.  If
481
+    not given, or empty, then all agents are considered reentrant, i.e.,
482
+    all agents are not considered non-reentrant.
483
+
484
+    (To consider all agents non-reentrant, specify a single comma as the
485
+    list.  But see below.)
486
+
487
+    The stubbed agents cannot be influenced in this manner, as the test
488
+    suite depends on their reentrancy.
489
+
490
+    """
491
+    return external_agent_restriction(
492
+        agent_type, "NON_REENTRANT_SSH_AGENTS", default=False
493
+    )
419 494
 
420 495
 
421 496
 spawn_handlers_params: list[Sequence] = []
... ...
@@ -432,6 +507,13 @@ for key, handler in spawn_handlers.items():
432 507
             "environment variable.",
433 508
         ),
434 509
     ]
510
+    # Allow agents to be marked as non-reentrant.  Workaround for an
511
+    # issue with GnuPG 2.4.8 on The Annoying OS when `gpg-agent`
512
+    # masquerades as OpenSSH's `ssh-agent`.
513
+    if is_agent_non_reentrant(
514
+        handler.agent_type
515
+    ):  # pragma: no cover [external]
516
+        marks.append(pytest.mark.non_reentrant_agent)
435 517
     # Mark non-isolated agents as, well, using non-isolated agents.
436 518
     # Assume by default that an agent is potentially non-isolated unless
437 519
     # we can be absolutely sure it is isolated, by design.  (The "stub"
... ...
@@ -34,6 +34,7 @@ from typing_extensions import assert_never, overload
34 34
 
35 35
 import tests.data
36 36
 import tests.machinery
37
+from derivepassphrase import _types, ssh_agent
37 38
 from derivepassphrase._internals import cli_helpers, cli_machinery
38 39
 from derivepassphrase.ssh_agent import socketprovider
39 40
 
... ...
@@ -547,3 +548,56 @@ def isolated_vault_exporter_config(
547 548
             yield
548 549
         finally:
549 550
             cli_helpers.config_filename("write lock").unlink(missing_ok=True)
551
+
552
+
553
+def ensure_singleton_client_if_non_reentrant_agent(
554
+    *,
555
+    request: pytest.FixtureRequest,
556
+    monkeypatch: pytest.MonkeyPatch,
557
+    client: ssh_agent.SSHAgentClient,
558
+) -> None:
559
+    """Mangle SSH agent construction if the spawned agent is non-reentrant.
560
+
561
+    Monkeypatch a suitable environment to ensure that constructing a new
562
+    SSH agent client -- usually via the
563
+    [`ssh_agent.SSHAgentClient.ensure_agent_subcontext`][] context
564
+    manager -- doesn't really construct a new client, in case the agent
565
+    is non-reentrant.
566
+
567
+    Agent reentrancy is determined by the presence of the
568
+    `non_reentrant_agent` mark.  We change nothing if the agent is
569
+    actually reentrant.
570
+
571
+    Args:
572
+        request:
573
+            The `pytest` request fixture.  Needed to get the active
574
+            markers on this test function execution, which detail
575
+            whether the agent is potentially non-reentrant.
576
+        monkeypatch:
577
+            A monkeypatching context to enrich.
578
+        client:
579
+            The singleton SSH agent client that is to be used.
580
+
581
+    """
582
+    if (
583
+        request.node.get_closest_marker("non_reentrant_agent") is not None
584
+    ):  # pragma: no cover [external]
585
+        real_ensure_agent_subcontext = (
586
+            ssh_agent.SSHAgentClient.ensure_agent_subcontext
587
+        )
588
+
589
+        def ensure_agent_subcontext(
590
+            conn: ssh_agent.SSHAgentClient
591
+            | _types.SSHAgentSocket
592
+            | Sequence[str]
593
+            | None = None,
594
+        ) -> contextlib.AbstractContextManager[ssh_agent.SSHAgentClient]:
595
+            if isinstance(conn, ssh_agent.SSHAgentClient):
596
+                return real_ensure_agent_subcontext(conn)
597
+            return real_ensure_agent_subcontext(client)
598
+
599
+        monkeypatch.setattr(
600
+            ssh_agent.SSHAgentClient,
601
+            "ensure_agent_subcontext",
602
+            ensure_agent_subcontext,
603
+        )
... ...
@@ -29,7 +29,6 @@ import click
29 29
 import hypothesis
30 30
 import pytest
31 31
 from hypothesis import strategies
32
-from typing_extensions import Any
33 32
 
34 33
 from derivepassphrase import _types, cli, ssh_agent, vault
35 34
 from derivepassphrase._internals import (
... ...
@@ -46,6 +45,8 @@ if TYPE_CHECKING:
46 45
     from collections.abc import Callable, Iterable, Iterator
47 46
     from typing import NoReturn
48 47
 
48
+    from typing_extensions import Any
49
+
49 50
 
50 51
 DUMMY_SERVICE = data.DUMMY_SERVICE
51 52
 DUMMY_PASSPHRASE = data.DUMMY_PASSPHRASE
... ...
@@ -480,6 +481,7 @@ class Parametrize(types.SimpleNamespace):
480 481
             "sign_action",
481 482
             "pattern",
482 483
             "warnings_patterns",
484
+            "tests_construction_failure",
483 485
         ],
484 486
         [
485 487
             pytest.param(
... ...
@@ -489,6 +491,7 @@ class Parametrize(types.SimpleNamespace):
489 491
                 SignAction.FAIL,
490 492
                 "not loaded into the agent",
491 493
                 [],
494
+                False,
492 495
                 id="key-not-loaded",
493 496
             ),
494 497
             pytest.param(
... ...
@@ -498,6 +501,7 @@ class Parametrize(types.SimpleNamespace):
498 501
                 SignAction.FAIL,
499 502
                 "SSH agent failed to or refused to",
500 503
                 [],
504
+                False,
501 505
                 id="list-keys-refused",
502 506
             ),
503 507
             pytest.param(
... ...
@@ -507,6 +511,7 @@ class Parametrize(types.SimpleNamespace):
507 511
                 SignAction.FAIL,
508 512
                 "SSH agent failed to or refused to",
509 513
                 [],
514
+                False,
510 515
                 id="list-keys-protocol-error",
511 516
             ),
512 517
             pytest.param(
... ...
@@ -516,6 +521,7 @@ class Parametrize(types.SimpleNamespace):
516 521
                 SignAction.FAIL,
517 522
                 "Cannot connect to the SSH agent",
518 523
                 [],
524
+                True,
519 525
                 id="agent-address-mangled",
520 526
             ),
521 527
             pytest.param(
... ...
@@ -525,6 +531,7 @@ class Parametrize(types.SimpleNamespace):
525 531
                 SignAction.FAIL,
526 532
                 "Cannot find any running SSH agent",
527 533
                 [],
534
+                True,
528 535
                 id="agent-address-missing",
529 536
             ),
530 537
             pytest.param(
... ...
@@ -534,6 +541,7 @@ class Parametrize(types.SimpleNamespace):
534 541
                 SignAction.FAIL,
535 542
                 "does not support communicating with it",
536 543
                 [],
544
+                True,
537 545
                 id="no-native-agent-available",
538 546
             ),
539 547
             pytest.param(
... ...
@@ -543,6 +551,7 @@ class Parametrize(types.SimpleNamespace):
543 551
                 SignAction.FAIL,
544 552
                 "does not support communicating with it",
545 553
                 [],
554
+                True,
546 555
                 id="no-agents-in-agent-provider-list",
547 556
             ),
548 557
             pytest.param(
... ...
@@ -552,6 +561,7 @@ class Parametrize(types.SimpleNamespace):
552 561
                 SignAction.FAIL,
553 562
                 "does not support communicating with it",
554 563
                 ["Cannot connect to an SSH agent via UNIX domain sockets"],
564
+                True,
555 565
                 id="no-unix-domain-sockets",
556 566
             ),
557 567
             pytest.param(
... ...
@@ -561,6 +571,7 @@ class Parametrize(types.SimpleNamespace):
561 571
                 SignAction.FAIL,
562 572
                 "does not support communicating with it",
563 573
                 ["Cannot connect to an SSH agent via Windows named pipes"],
574
+                True,
564 575
                 id="no-windows-named-pipes",
565 576
             ),
566 577
             pytest.param(
... ...
@@ -570,6 +581,7 @@ class Parametrize(types.SimpleNamespace):
570 581
                 SignAction.FAIL_RUNTIME,
571 582
                 "violates the communication protocol",
572 583
                 [],
584
+                False,
573 585
                 id="sign-violates-protocol",
574 586
             ),
575 587
         ],
... ...
@@ -1466,6 +1478,7 @@ class TestMisc:
1466 1478
     @Parametrize.KEY_TO_PHRASE_SETTINGS
1467 1479
     def test_key_to_phrase(
1468 1480
         self,
1481
+        request: pytest.FixtureRequest,
1469 1482
         ssh_agent_client_with_test_keys_loaded: ssh_agent.SSHAgentClient,
1470 1483
         list_keys_action: ListKeysAction | None,
1471 1484
         system_support_action: SystemSupportAction | None,
... ...
@@ -1473,6 +1486,7 @@ class TestMisc:
1473 1486
         sign_action: SignAction,
1474 1487
         pattern: str,
1475 1488
         warnings_patterns: list[str],
1489
+        tests_construction_failure: bool,
1476 1490
     ) -> None:
1477 1491
         """All errors in [`cli_helpers.key_to_phrase`][] are handled."""
1478 1492
 
... ...
@@ -1505,6 +1520,14 @@ class TestMisc:
1505 1520
                 address_action(monkeypatch)
1506 1521
             if system_support_action:
1507 1522
                 system_support_action(monkeypatch)
1523
+
1524
+            if not tests_construction_failure:
1525
+                pytest_machinery.ensure_singleton_client_if_non_reentrant_agent(
1526
+                    request=request,
1527
+                    monkeypatch=monkeypatch,
1528
+                    client=ssh_agent_client_with_test_keys_loaded,
1529
+                )
1530
+
1508 1531
             with pytest.raises(ErrCallback, match=pattern) as excinfo:
1509 1532
                 cli_helpers.key_to_phrase(
1510 1533
                     loaded_key, error_callback=err, warning_callback=warn
... ...
@@ -27,7 +27,6 @@ from typing import TYPE_CHECKING
27 27
 import hypothesis
28 28
 import pytest
29 29
 from hypothesis import strategies
30
-from typing_extensions import Any
31 30
 
32 31
 from derivepassphrase import _types, cli, ssh_agent
33 32
 from derivepassphrase._internals import (
... ...
@@ -43,6 +42,8 @@ if TYPE_CHECKING:
43 42
     from collections.abc import Iterator
44 43
     from typing import NoReturn
45 44
 
45
+    from typing_extensions import Any
46
+
46 47
 DUMMY_SERVICE = data.DUMMY_SERVICE
47 48
 
48 49
 DUMMY_KEY1_B64 = data.DUMMY_KEY1_B64
... ...
@@ -879,15 +880,21 @@ class TestStoringConfigurationFailures:
879 880
             pass
880 881
 
881 882
     def test_fail_because_ssh_agent_has_no_keys_loaded(
882
-        self, spawn_ssh_agent: data.SpawnedSSHAgentInfo
883
+        self,
884
+        request: pytest.FixtureRequest,
885
+        spawn_ssh_agent: data.SpawnedSSHAgentInfo,
883 886
     ) -> None:
884 887
         """Not holding any SSH keys during `--config --key` fails."""
885
-        del spawn_ssh_agent
886 888
         with self._test(
887 889
             ["--key"],
888 890
             error_text="no keys suitable",
889 891
             patch_suitable_ssh_keys=False,
890 892
         ) as monkeypatch:
893
+            pytest_machinery.ensure_singleton_client_if_non_reentrant_agent(
894
+                request=request,
895
+                monkeypatch=monkeypatch,
896
+                client=spawn_ssh_agent.client,
897
+            )
891 898
 
892 899
             def func(
893 900
                 *_args: Any,
... ...
@@ -898,15 +905,21 @@ class TestStoringConfigurationFailures:
898 905
             monkeypatch.setattr(ssh_agent.SSHAgentClient, "list_keys", func)
899 906
 
900 907
     def test_store_config_fail_manual_ssh_agent_runtime_error(
901
-        self, spawn_ssh_agent: data.SpawnedSSHAgentInfo
908
+        self,
909
+        request: pytest.FixtureRequest,
910
+        spawn_ssh_agent: data.SpawnedSSHAgentInfo,
902 911
     ) -> None:
903 912
         """Triggering an error in the SSH agent during `--config --key` leads to failure."""
904
-        del spawn_ssh_agent
905 913
         with self._test(
906 914
             ["--key"],
907 915
             error_text="violates the communication protocol",
908 916
             patch_suitable_ssh_keys=False,
909 917
         ) as monkeypatch:
918
+            pytest_machinery.ensure_singleton_client_if_non_reentrant_agent(
919
+                request=request,
920
+                monkeypatch=monkeypatch,
921
+                client=spawn_ssh_agent.client,
922
+            )
910 923
 
911 924
             def raiser(*_args: Any, **_kwargs: Any) -> None:
912 925
                 raise ssh_agent.TrailingDataError()
... ...
@@ -914,13 +927,19 @@ class TestStoringConfigurationFailures:
914 927
             monkeypatch.setattr(ssh_agent.SSHAgentClient, "list_keys", raiser)
915 928
 
916 929
     def test_store_config_fail_manual_ssh_agent_refuses(
917
-        self, spawn_ssh_agent: data.SpawnedSSHAgentInfo
930
+        self,
931
+        request: pytest.FixtureRequest,
932
+        spawn_ssh_agent: data.SpawnedSSHAgentInfo,
918 933
     ) -> None:
919 934
         """The SSH agent refusing during `--config --key` leads to failure."""
920
-        del spawn_ssh_agent
921 935
         with self._test(
922 936
             ["--key"], error_text="refused to", patch_suitable_ssh_keys=False
923 937
         ) as monkeypatch:
938
+            pytest_machinery.ensure_singleton_client_if_non_reentrant_agent(
939
+                request=request,
940
+                monkeypatch=monkeypatch,
941
+                client=spawn_ssh_agent.client,
942
+            )
924 943
 
925 944
             def func(*_args: Any, **_kwargs: Any) -> NoReturn:
926 945
                 raise ssh_agent.SSHAgentFailedError(
927 946