diff --git a/apps/emdash-desktop/src/main/core/ssh/connect/production-connect-config.ts b/apps/emdash-desktop/src/main/core/ssh/connect/production-connect-config.ts index 579b897e29..77e873dbac 100644 --- a/apps/emdash-desktop/src/main/core/ssh/connect/production-connect-config.ts +++ b/apps/emdash-desktop/src/main/core/ssh/connect/production-connect-config.ts @@ -28,6 +28,7 @@ export const resolveProductionSshConnectConfig = createSshConnectConfigResolver( spawnProxyJump, createAgent, env: process.env, + platform: process.platform, }); export type { SshConnectInput, SshConnectResult }; diff --git a/apps/emdash-desktop/src/main/core/ssh/connect/resolve-ssh-connect-config.test.ts b/apps/emdash-desktop/src/main/core/ssh/connect/resolve-ssh-connect-config.test.ts index 00d87662e6..5027ce855d 100644 --- a/apps/emdash-desktop/src/main/core/ssh/connect/resolve-ssh-connect-config.test.ts +++ b/apps/emdash-desktop/src/main/core/ssh/connect/resolve-ssh-connect-config.test.ts @@ -9,6 +9,7 @@ import { resolveSshConnectConfig, type SshConnectDeps, } from './resolve-ssh-connect-config'; +import { WINDOWS_OPENSSH_AGENT_PIPE } from './ssh-agent-socket'; function baseConfig(partial: Partial = {}): SshConfig { return { @@ -66,6 +67,7 @@ function deps(overrides: Partial = {}): SshConnectDeps { getStream: (callback) => callback(undefined, new PassThrough()), }) satisfies BaseAgent, env: { SSH_AUTH_SOCK: '/tmp/default-agent.sock' }, + platform: 'linux', ...overrides, }; } @@ -243,6 +245,129 @@ describe('resolveSshConnectConfig', () => { }); }); + it('falls back to the Windows OpenSSH agent pipe for agent auth when no socket is configured', async () => { + await expect( + resolveSshConnectConfig( + { + kind: 'transient', + config: baseConfig({ sshConfigAlias: 'corp-dev', authType: 'agent' }), + }, + deps({ + env: {}, + platform: 'win32', + resolveSshConfig: async () => ({ + hostname: 'dev.internal', + user: 'alice', + port: 22, + identityFile: [], + identityAgent: undefined, + identityAgentDisabled: false, + identitiesOnly: false, + proxyCommand: undefined, + proxyJump: undefined, + forwardAgent: false, + }), + }) + ) + ).resolves.toMatchObject({ + config: { agent: WINDOWS_OPENSSH_AGENT_PIPE }, + }); + }); + + it('falls back to the Windows OpenSSH agent pipe when SSH_AUTH_SOCK is missing or empty', async () => { + for (const env of [{}, { SSH_AUTH_SOCK: '' }]) { + await expect( + resolveSshConnectConfig( + { + kind: 'transient', + config: baseConfig({ sshConfigAlias: 'corp-dev', authType: 'agent' }), + }, + deps({ + env, + platform: 'win32', + resolveSshConfig: async () => ({ + hostname: 'dev.internal', + user: 'alice', + port: 22, + identityFile: [], + identityAgent: 'SSH_AUTH_SOCK', + identityAgentDisabled: false, + identitiesOnly: false, + proxyCommand: undefined, + proxyJump: undefined, + forwardAgent: false, + }), + }) + ) + ).resolves.toMatchObject({ + config: { agent: WINDOWS_OPENSSH_AGENT_PIPE }, + }); + } + }); + + it('does not override IdentityAgent none with the Windows OpenSSH agent pipe', async () => { + await expect( + resolveSshConnectConfig( + { + kind: 'transient', + config: baseConfig({ sshConfigAlias: 'corp-dev', authType: 'agent' }), + }, + deps({ + env: {}, + platform: 'win32', + resolveSshConfig: async () => ({ + hostname: 'dev.internal', + user: 'alice', + port: 22, + identityFile: [], + identityAgent: undefined, + identityAgentDisabled: true, + identitiesOnly: false, + proxyCommand: undefined, + proxyJump: undefined, + forwardAgent: false, + }), + }) + ) + ).rejects.toThrow('SSH agent is disabled'); + }); + + it('falls back to the Windows OpenSSH agent pipe for ForwardAgent yes when no socket is configured', async () => { + await expect( + resolveSshConnectConfig( + { + kind: 'transient', + config: { + ...baseConfig({ sshConfigAlias: 'corp-dev', authType: 'password' }), + password: 'pw', + }, + }, + deps({ + env: {}, + platform: 'win32', + resolveSshConfig: async () => ({ + hostname: 'dev.internal', + user: 'alice', + port: 22, + identityFile: [], + identityAgent: undefined, + identityAgentDisabled: false, + identitiesOnly: false, + proxyCommand: undefined, + proxyJump: undefined, + forwardAgent: true, + }), + }) + ) + ).resolves.toMatchObject({ + config: { + password: 'pw', + agentForward: true, + agent: WINDOWS_OPENSSH_AGENT_PIPE, + }, + }); + }); + it('limits alias-backed agent auth to IdentityFile keys when IdentitiesOnly is enabled', async () => { const readFiles: string[] = []; const allowedKey = parseFixturePublicKey(); @@ -387,7 +512,7 @@ describe('resolveSshConnectConfig', () => { ).rejects.toThrow('IdentitiesOnly is enabled'); }); - it('uses SSH_AUTH_SOCK for ForwardAgent yes even when IdentityAgent disables auth agent selection', async () => { + it('does not forward the agent when IdentityAgent disables agent selection', async () => { await expect( resolveSshConnectConfig( { @@ -413,13 +538,37 @@ describe('resolveSshConnectConfig', () => { }), }) ) - ).resolves.toMatchObject({ - config: { - password: 'pw', - agentForward: true, - agent: '/tmp/default-agent.sock', - }, - }); + ).rejects.toThrow('SSH agent is disabled'); + }); + + it('does not allow explicit ForwardAgent sockets when IdentityAgent disables agent selection', async () => { + await expect( + resolveSshConnectConfig( + { + kind: 'transient', + config: { + ...baseConfig({ sshConfigAlias: 'corp-dev', authType: 'password' }), + password: 'pw', + }, + }, + deps({ + env: { SSH_AUTH_SOCK: '/tmp/default-agent.sock' }, + resolveSshConfig: async () => ({ + hostname: 'dev.internal', + user: 'alice', + port: 22, + identityFile: [], + identityAgent: undefined, + identityAgentDisabled: true, + identitiesOnly: false, + proxyCommand: undefined, + proxyJump: undefined, + forwardAgent: true, + forwardAgentValue: '$SSH_AUTH_SOCK', + }), + }) + ) + ).rejects.toThrow('SSH agent is disabled'); }); it('fails clearly when required auth or forwarding credentials are unavailable', async () => { diff --git a/apps/emdash-desktop/src/main/core/ssh/connect/resolve-ssh-connect-config.ts b/apps/emdash-desktop/src/main/core/ssh/connect/resolve-ssh-connect-config.ts index 34d55dd270..64f4dee500 100644 --- a/apps/emdash-desktop/src/main/core/ssh/connect/resolve-ssh-connect-config.ts +++ b/apps/emdash-desktop/src/main/core/ssh/connect/resolve-ssh-connect-config.ts @@ -46,6 +46,7 @@ export interface SshConnectDeps { ) => Omit; createAgent: (socketPath: string) => BaseAgent; env: Record; + platform: NodeJS.Platform; } function defaultDeps(): SshConnectDeps { @@ -68,6 +69,7 @@ function defaultDeps(): SshConnectDeps { defaultSpawnProxyJump(jumpSpec, destHost, destPort), createAgent, env: process.env, + platform: process.platform, }; } diff --git a/apps/emdash-desktop/src/main/core/ssh/connect/ssh-agent-socket.ts b/apps/emdash-desktop/src/main/core/ssh/connect/ssh-agent-socket.ts new file mode 100644 index 0000000000..69012c73a3 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/ssh/connect/ssh-agent-socket.ts @@ -0,0 +1,8 @@ +export const WINDOWS_OPENSSH_AGENT_PIPE = '\\\\.\\pipe\\openssh-ssh-agent'; + +export function defaultSshAgentSocket( + env: Record, + platform: NodeJS.Platform +): string | undefined { + return env.SSH_AUTH_SOCK || (platform === 'win32' ? WINDOWS_OPENSSH_AGENT_PIPE : undefined); +} diff --git a/apps/emdash-desktop/src/main/core/ssh/connect/ssh-connect-auth.ts b/apps/emdash-desktop/src/main/core/ssh/connect/ssh-connect-auth.ts index d4b17125ac..9d43824fc6 100644 --- a/apps/emdash-desktop/src/main/core/ssh/connect/ssh-connect-auth.ts +++ b/apps/emdash-desktop/src/main/core/ssh/connect/ssh-connect-auth.ts @@ -14,6 +14,7 @@ import { type ResolvedSshConfig, } from '../config/resolve-ssh-config'; import type { SshConnectDeps, SshConnectInput } from './resolve-ssh-connect-config'; +import { defaultSshAgentSocket } from './ssh-agent-socket'; const { utils } = ssh2; @@ -149,7 +150,7 @@ export async function buildAuthConfig( ? agentSocket.path : agentSocket.kind === 'disabled' ? undefined - : deps.env.SSH_AUTH_SOCK; + : defaultSshAgentSocket(deps.env, deps.platform); if (agentSocket.kind === 'disabled') { throw new Error(`SSH agent is disabled by SSH config for connection '${base.name}'`); } diff --git a/apps/emdash-desktop/src/main/core/ssh/connect/ssh-connect-forward-agent.ts b/apps/emdash-desktop/src/main/core/ssh/connect/ssh-connect-forward-agent.ts index 21b69928b0..d4bdfb703a 100644 --- a/apps/emdash-desktop/src/main/core/ssh/connect/ssh-connect-forward-agent.ts +++ b/apps/emdash-desktop/src/main/core/ssh/connect/ssh-connect-forward-agent.ts @@ -6,6 +6,7 @@ import { type ResolvedSshConfig, } from '../config/resolve-ssh-config'; import type { SshConnectDeps } from './resolve-ssh-connect-config'; +import { defaultSshAgentSocket } from './ssh-agent-socket'; import type { AuthResult } from './ssh-connect-auth'; function expandForwardAgentValue( @@ -21,6 +22,13 @@ function expandForwardAgentValue( } function agentForForwarding(resolved: ResolvedSshConfig | undefined, deps: SshConnectDeps): string { + const agentSocket = resolved + ? resolveAgentSocketFromResolved(resolved, deps.env) + : ({ kind: 'unset' } satisfies ResolvedAgentSocket); + if (agentSocket.kind === 'disabled') { + throw new Error('Agent forwarding was requested, but SSH agent is disabled by SSH config'); + } + if (resolved?.forwardAgentValue) { const agent = expandForwardAgentValue(resolved.forwardAgentValue, deps.env); if (!agent) { @@ -29,10 +37,10 @@ function agentForForwarding(resolved: ResolvedSshConfig | undefined, deps: SshCo return agent; } - const agentSocket = resolved - ? resolveAgentSocketFromResolved(resolved, deps.env) - : ({ kind: 'unset' } satisfies ResolvedAgentSocket); - const agent = agentSocket.kind === 'socket' ? agentSocket.path : deps.env.SSH_AUTH_SOCK; + const agent = + agentSocket.kind === 'socket' + ? agentSocket.path + : defaultSshAgentSocket(deps.env, deps.platform); if (!agent) { throw new Error('Agent forwarding was requested, but no SSH agent socket is available'); }