diff --git a/man/embedded-docs.txt b/man/embedded-docs.txt index 9b7dac8..ddacdae 100644 --- a/man/embedded-docs.txt +++ b/man/embedded-docs.txt @@ -140,6 +140,12 @@ With no flags, both ssh and gpg are wiped. wipe --gpg gpg only wipe --ssh --gpg same as bare ``wipe`` +If you use ``gpg-agent`` as your SSH agent via ``--ssh-spawn-gpg`` or +``--ssh-allow-gpg``, SSH identities may be backed by ``gpg-agent`` rather than +``ssh-agent``. In that case, ``wipe --ssh`` only asks ``ssh-agent`` to remove +its identities; use ``wipe --gpg``, or bare ``wipe``, to clear identities cached +by ``gpg-agent``. + == @action forget: Evict specific SSH keys from ssh-agent. @syntax keychain forget [KEYS...] @@ -421,11 +427,15 @@ Emit a JSON array of key records on stdout. == @option wipe-ssh: wipe SSH agent only -Wipe only the keys held by ssh-agent. +Wipe only the keys held by ``ssh-agent``. If your SSH identities are exposed by +``gpg-agent`` via ``--ssh-spawn-gpg`` or ``--ssh-allow-gpg``, use +``wipe --gpg`` instead. == @option wipe-gpg: wipe GPG agent only -Wipe only the keys held by gpg-agent. +Wipe only the keys held by ``gpg-agent``. This is also the right target for SSH +identities exposed by ``gpg-agent`` when using ``--ssh-spawn-gpg`` or +``--ssh-allow-gpg``. == @option env-shell: output target (default: env) diff --git a/src/keychain/agents.py b/src/keychain/agents.py index 08fec01..65bce80 100644 --- a/src/keychain/agents.py +++ b/src/keychain/agents.py @@ -526,7 +526,12 @@ def load(self, missing: list[str]) -> bool: if not test: out.warn("Agent disappeared; refusing to load keys") return False - out.info(f"Adding {out.value(len(missing))} ssh key(s): " f"{out.value(' '.join(missing))}") + if len(missing) == 1: + out.info(f"Adding {out.value(len(missing))} ssh key(s): {out.value(missing[0])}") + else: + out.info(f"Adding {out.value(len(missing))} ssh keys:") + for key in missing: + out.line(f" - {out.value(key)}") # ssh-add inherits stdio for passphrase prompts, so we cannot use util.run(). run_env = self.env.overlay() if bool(a.get_value("no_gui")) or not run_env.get("SSH_ASKPASS") or not run_env.get("DISPLAY"): @@ -544,16 +549,7 @@ def load(self, missing: list[str]) -> bool: except (FileNotFoundError, OSError): out.warn("ssh-add not found") return False - if rc == 0: - bits = [] - timeout = a.get_value("timeout") - if timeout is not None: - bits.append(f"life={timeout}m") - if bool(a.get_value("confirm")): - bits.append("confirm") - suffix = f" ({','.join(bits)})" if bits else "" - out.info(f"ssh-add: Identities added: {' '.join(missing)}{suffix}") - else: + if rc != 0: out.warn(f"ssh-add failed (return code: {rc})") return rc == 0 diff --git a/tests/test_agents.py b/tests/test_agents.py index 2646481..2a51def 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -115,6 +115,41 @@ def fake_run(cmd, env=None, **_kwargs): assert "SHA256:abc" in capsys.readouterr().out +class TestSshAgentLoadOutput: + def _agent(self, monkeypatch): + def get_value(name): + return {"no_gui": True, "confirm": False, "timeout": None}.get(name, False) + + kstate = SimpleNamespace( + find_active_agent_env=SshAgentRef(sock="/tmp/agent.sock", pid="1111"), + args=SimpleNamespace(get_value=get_value), + ) + agent = agents.SshAgent(kstate, Output.build(quiet=False, debug=False, eval_mode=False, color=False)) + monkeypatch.setattr(agent, "envcheck", lambda *_args, **_kwargs: agent.env) + monkeypatch.setattr(agents.subprocess, "run", lambda *_args, **_kwargs: SimpleNamespace(returncode=0)) + return agent + + def test_multiple_loaded_keys_render_as_lists(self, monkeypatch, capsys): + """Verify multi-key ssh-add output is readable instead of joining paths onto one long line.""" + assert self._agent(monkeypatch).load(["/home/user/.ssh/key1", "/home/user/.ssh/key2"]) is True + + err = capsys.readouterr().err + assert "Adding 2 ssh keys:" in err + assert " - /home/user/.ssh/key1" in err + assert " - /home/user/.ssh/key2" in err + assert "Adding 2 ssh keys: /home/user/.ssh/key1 /home/user/.ssh/key2" not in err + assert "ssh-add: Identities added" not in err + + def test_single_loaded_key_stays_compact(self, monkeypatch, capsys): + """Verify the common one-key path keeps the compact single-line output.""" + assert self._agent(monkeypatch).load(["/home/user/.ssh/key1"]) is True + + err = capsys.readouterr().err + assert "Adding 1 ssh key(s): /home/user/.ssh/key1" in err + assert "ssh-add: Identities added" not in err + assert " - /home/user/.ssh/key1" not in err + + # --------------------------------------------------------------------------- # findpids # --------------------------------------------------------------------------- @@ -261,7 +296,7 @@ class TestSshEnvcheckUnknownSource: def test_unknown_source_message_includes_path_and_does_not_claim_forwarded(self, tmp_path, monkeypatch): """Verify envcheck names an otherwise valid socket as unknown source because without PID or GnuPG evidence it must not claim the socket was forwarded.""" sock_path = tmp_path / "agent.sock" - sock_path.write_text("") # placeholder; ssh_socket_valid is mocked + sock_path.write_text("") # placeholder; validate_ssh_socket is mocked captured: list[str] = [] class _Out: @@ -282,13 +317,13 @@ def c(self, _): # Pretend the socket is valid and that GnuPG isn't supplying it, # so we hit the "unknown source" branch. - monkeypatch.setattr(agents, "ssh_socket_valid", lambda _: True) + monkeypatch.setattr(agents, "validate_ssh_socket", lambda sock: agents.SocketValidation(sock, True)) monkeypatch.setattr(agents, "gpg_ssh_socket", lambda: None) env = SshAgentRef(str(sock_path)) # Build a minimal SshAgent: envcheck reads self._allow_gpg and # self._allow_forwarded (latched by start()), self.out, and the host - # probes ssh_socket_valid / gpg_ssh_socket which we mocked above. + # probes validate_ssh_socket / gpg_ssh_socket which we mocked above. from keychain import state from keychain.paths import KeychainPaths diff --git a/tests/test_e2e_playbooks.py b/tests/test_e2e_playbooks.py index 9a0c5b1..75b6dd2 100644 --- a/tests/test_e2e_playbooks.py +++ b/tests/test_e2e_playbooks.py @@ -211,6 +211,30 @@ def test_man_commands(playbook: PlaybookRunner): assert out_list or err, "Expected output from keychain man --list" +def test_wipe_routes_ssh_and_gpg_targets(playbook: PlaybookRunner, monkeypatch): + """Verify wipe target routing for GPG-backed SSH agents (issue #163).""" + from keychain import agents + + calls: list[str] = [] + + monkeypatch.setattr(platform, "detect", lambda *_args, **_kwargs: platform._classify("linux", has_ps=True)) + monkeypatch.setattr(agents.SshAgent, "wipe", lambda self: calls.append("ssh")) + monkeypatch.setattr(agents.GpgAgent, "wipe", lambda self: calls.append("gpg")) + + playbook.set_host("testhost") + + playbook.run("wipe", "--ssh") + assert calls == ["ssh"] + + calls.clear() + playbook.run("wipe", "--gpg") + assert calls == ["gpg"] + + calls.clear() + playbook.run("wipe") + assert calls == ["ssh", "gpg"] + + @POSIX_AGENT_ONLY def test_add_with_only_missing_keys_does_not_start_agent(playbook: PlaybookRunner): """Verify that a fully unresolved SSH key does not spawn an agent as a side effect."""