Skip to content

fix(ssh): report the SSH agent's own failure instead of prompting for a key passphrase (#2583) - #2584

Merged
datlechin merged 1 commit into
mainfrom
fix/ssh-agent-passphrase-fallback
Aug 31, 2026
Merged

fix(ssh): report the SSH agent's own failure instead of prompting for a key passphrase (#2583)#2584
datlechin merged 1 commit into
mainfrom
fix/ssh-agent-passphrase-fallback

Conversation

@datlechin

Copy link
Copy Markdown
Member

Fixes #2583.

The bug

Choosing SSH Agent and connecting put up TablePro's own SSH Key Passphrase Required dialog for a private key on disk, instead of 1Password's approval sheet. The agent was never asked to sign anything.

Root cause

LibSSH2TunnelFactory.buildAuthenticator composed the SSH Agent method as

[AgentAuthenticator, KeyFileAuthenticator(~/.ssh/id_ed25519 | id_rsa | id_ecdsa), KeyboardInteractiveAuthenticator]

and CompositeAuthenticator swallows each step's failure to try the next. So an agent that produced nothing was silently replaced by private-key auth against a key file TablePro had guessed, and an encrypted one raised PromptPassphraseProvider. The agent's own failure reached OSLog and nowhere else.

The usual way in is leaving Agent Socket on its default SSH_AUTH_SOCK. For a GUI-launched app that variable comes from launchd and names the ssh-agent macOS runs, never 1Password's socket, whatever a shell profile exports. AgentAuthenticator.resolveSocketViaLaunchctl() (from #729) only runs when the variable is absent, which under launchd it is not.

A second layer made it worse: when every step failed, the reported error was the last one, and KeyboardInteractiveAuthenticator threw .password even on a server that issued no prompt at all. A connection with no password reported SSH password rejected.

Measured

A C probe against the shipped Libs/libssh2_arm64.a (libssh2 1.11.1):

Probe Result
SSH_AUTH_SOCK default agent on this Mac connects, 0 identities
80-char group-container-shaped socket path connects, identities listed, no sun_path overflow
literal ~/Library/... path agent_connect rc=-42, so tilde expansion is load-bearing

The last row sent me to buildJumpAuthenticator, which passes resolved.agentSocketPath unexpanded. That turned out not to be a bug: AgentAuthenticator.authenticate expands it itself. No change there.

The fix

SSH Agent means the agent. The chain is now [AgentAuthenticator, KeyboardInteractiveAuthenticator]. No private key file is tried, so nothing can substitute a credential the user did not choose. Keyboard-interactive stays, because it is a second factor the same server asked for (AuthenticationMethods publickey,keyboard-interactive), not another credential.

The agent's failure has a name. AgentAuthenticator now distinguishes three cases instead of one generic string:

Reason When
.agentUnavailable nothing answered on the socket
.agentNoIdentities the agent answered and offered no keys
.agentRejected keys were offered, the server accepted none

And it names the socket the connect actually used. agentSocketPath collapses three sources into one string, and each is changed somewhere different, so a message that assumes one of them misdirects the other two. AgentSocketOrigin is resolved alongside the path in SSHConfigResolver and carried on the reason:

Origin Where the message sends you
.agentSocketSetting the Agent Socket control on the SSH Tunnel pane
.identityAgentDirective the IdentityAgent line for that host in ~/.ssh/config
.environment SSH_AUTH_SOCK

That matters for two supported setups the first draft got wrong: a jump host has no Agent Socket field at all, and ssh-add only loads keys into the SSH_AUTH_SOCK agent, so suggesting it to a 1Password user is bad advice. It is now offered for that origin alone.

An unreachable agent ends the chain. The keyboard-interactive step is a second factor for a first the agent supplies; with no agent there is no first factor, so on a server that offers keyboard-interactive it would have prompted for a credential of its own, which is the reported defect in a different dialog. CompositeAuthenticator.endsChainOn stops the chain on .agentUnavailable and .agentNoIdentities. .agentRejected does not end it, because that is the partial-success case #1920 added the step for.

A step the server never engaged no longer buries a real failure. New .methodUnavailable, thrown by KeyboardInteractiveAuthenticator when the server issued no prompt and nothing was answered, and CompositeAuthenticator will not let one displace an already-recorded failure. #1018's behaviour is preserved: a keyboard-interactive step that did answer a challenge still wins, so publickey,keyboard-interactive still reports .verificationCode.

The Agent Socket picker says what each option reaches. The static caption under it becomes an option-specific line from SSHAgentSocketOption.explanation, shared by the connection form and the SSH profile editor.

canPrompt on KeyFileAuthenticator was true at every call site once the agent fallback went, so it is gone.

Behaviour change

An SSH Agent connection that used to fall back to an IdentityFile from ~/.ssh/config and succeed there now fails with the agent's reason. That is the point: the fallback is what produced the reported bug. The error says what to change, and Private Key is the method for authenticating with a key file.

Screenshots

Not captured. The visible delta is one caption line under the Agent Socket picker, replacing "Keys are provided by the SSH agent (e.g. 1Password, ssh-agent)." with, for the default option, "The ssh-agent macOS runs, from SSH_AUTH_SOCK. 1Password and Secretive listen elsewhere." Driving a sandboxed Debug build to the SSH Tunnel pane needs several AppleScript steps against a SwiftUI sheet whose rows accessibility cannot press, and the run dismissed the window before reaching the pane. Everything else in this change is an authentication chain and an error string, which a screenshot cannot show.

Verification

Step Result
verify.sh generate PASS
verify.sh build PASS
verify.sh test (13 suites) PASS, 240 executed, 240 passed
verify.sh lint TablePro TableProTests 0 violations
docs/scripts/check-writing-style.sh matches the house style
docs/scripts/check-docs-against-source.py agrees with the source

Suites run: AgentAuthenticatorFailureTests, CompositeAuthenticatorFailureReportingTests, KeyboardInteractiveFailureReasonTests, BuildAuthenticatorTests, AuthFailureReasonTests, CompositeAuthenticatorCancellationTests, KeyboardInteractiveContextTests, KeyboardInteractiveResponsesTests, SSHConfigurationTests, SSHTunnelErrorTests, SSHConfigResolverTests, ConnectionURLFormatterTests, ConnectionURLParserTests.

The two agent cases run against a real unix socket speaking the ssh-agent protocol, on a libssh2 session with no transport, because the distinction under test is what libssh2 does with the bytes on that socket.

No UI automation: reproducing this needs a real SSH server and a real agent that refuses, so it does not run deterministically in TableProUITests.

Review

Reviewed by Codex (review --scope working-tree), which raised two P2s, both fixed before the commit:

  • The new tests called libssh2_init directly, and libssh2's header says it uses global state and must not be called concurrently. Swift Testing runs sibling cases in parallel, so four calls could enter it together. They now go through the app's one lazy LibSSH2TunnelFactory.initialized.
  • The new diagnostic assumed the macOS agent when IdentityAgent, an inherited SSH_AUTH_SOCK, and a jump-host socket are all supported. That is what AgentSocketOrigin above is for.

The chain-ending behaviour is mine, found while reading the review's reasoning about partial authentication.

Not changed

iOS. TableProMobile's SSHTunnelFactory implements .password, .privateKey and .none only, and throws for .sshAgent.

@mintlify

mintlify Bot commented Aug 31, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
TablePro 🔴 Failed Aug 31, 2026, 11:08 AM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@datlechin
datlechin merged commit 1caab2a into main Aug 31, 2026
9 checks passed
@datlechin
datlechin deleted the fix/ssh-agent-passphrase-fallback branch August 31, 2026 11:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SSH connection with 1Password is asking for passphrase instead of 1password promt

1 participant