diff --git a/apps/emdash-desktop/src/main/core/ssh/config/sshConfigParser.test.ts b/apps/emdash-desktop/src/main/core/ssh/config/sshConfigParser.test.ts index 8c4cd10929..8c881071df 100644 --- a/apps/emdash-desktop/src/main/core/ssh/config/sshConfigParser.test.ts +++ b/apps/emdash-desktop/src/main/core/ssh/config/sshConfigParser.test.ts @@ -8,6 +8,13 @@ import { parseSshConfigFileAt, } from './sshConfigParser'; +const itWindows = process.platform === 'win32' ? it : it.skip; +const itPosix = process.platform === 'win32' ? it.skip : it; +const pathSuffix = (suffix: string) => { + const escapedSuffix = suffix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replaceAll('/', '[\\\\/]'); + return new RegExp(`[\\\\/]${escapedSuffix}$`); +}; + describe('parseSshConfigContent', () => { it('lists concrete SSH config aliases with proxy and forwarding preview fields', () => { const hosts = parseSshConfigContent(` @@ -35,8 +42,8 @@ Host plain hostname: 'dev.internal', user: 'alice', port: 2222, - identityFile: expect.stringContaining('/.ssh/dev_ed25519'), - identityAgent: expect.stringContaining('/.1password/agent.sock'), + identityFile: expect.stringMatching(pathSuffix('.ssh/dev_ed25519')), + identityAgent: expect.stringMatching(pathSuffix('.1password/agent.sock')), proxyCommand: 'cloudflared access ssh --hostname %h', forwardAgent: true, forwardAgentValue: '$SSH_AUTH_SOCK', @@ -46,8 +53,8 @@ Host plain hostname: 'dev.internal', user: 'alice', port: 2222, - identityFile: expect.stringContaining('/.ssh/dev_ed25519'), - identityAgent: expect.stringContaining('/.1password/agent.sock'), + identityFile: expect.stringMatching(pathSuffix('.ssh/dev_ed25519')), + identityAgent: expect.stringMatching(pathSuffix('.1password/agent.sock')), proxyCommand: 'cloudflared access ssh --hostname %h', forwardAgent: true, forwardAgentValue: '$SSH_AUTH_SOCK', @@ -130,6 +137,116 @@ Host included-alias ]); }); + it('expands quoted and unquoted Include glob patterns', async () => { + const dir = await mkdtemp(join(tmpdir(), 'emdash-ssh-config-quoted-')); + await mkdir(join(dir, 'team config')); + await mkdir(join(dir, 'conf.d')); + await writeFile( + join(dir, 'config'), + ` +Include "team config/*.conf" conf.d/*.conf +`, + 'utf-8' + ); + await writeFile( + join(dir, 'team config', 'primary.conf'), + ` +Host quoted-include + HostName quoted.internal +`, + 'utf-8' + ); + await writeFile( + join(dir, 'conf.d', 'secondary.conf'), + ` +Host unquoted-include + HostName unquoted.internal +`, + 'utf-8' + ); + + const hosts = await parseSshConfigFileAt(join(dir, 'config')); + expect(hosts).toHaveLength(2); + expect(hosts).toEqual( + expect.arrayContaining([ + { + host: 'unquoted-include', + hostname: 'unquoted.internal', + }, + { + host: 'quoted-include', + hostname: 'quoted.internal', + }, + ]) + ); + }); + + itWindows('expands Include glob patterns with Windows path separators', async () => { + const dir = await mkdtemp(join(tmpdir(), 'emdash-ssh-config-windows-')); + await mkdir(join(dir, 'absolute')); + await mkdir(join(dir, 'relative')); + await writeFile( + join(dir, 'config'), + ` +Include relative\\*.conf "${join(dir, 'absolute', '*.conf')}" +`, + 'utf-8' + ); + await writeFile( + join(dir, 'absolute', 'team.conf'), + ` +Host absolute-windows-include + HostName absolute.internal +`, + 'utf-8' + ); + await writeFile( + join(dir, 'relative', 'team.conf'), + ` +Host relative-windows-include + HostName relative.internal +`, + 'utf-8' + ); + + expect(await parseSshConfigFileAt(join(dir, 'config'))).toEqual([ + { + host: 'absolute-windows-include', + hostname: 'absolute.internal', + }, + { + host: 'relative-windows-include', + hostname: 'relative.internal', + }, + ]); + }); + + itPosix('treats ~\\ as a literal path prefix on POSIX', async () => { + const dir = await mkdtemp(join(tmpdir(), 'emdash-ssh-config-posix-')); + await writeFile( + join(dir, 'config'), + ` +Include ~\\*.conf +`, + 'utf-8' + ); + await writeFile( + join(dir, '~*.conf'), + ` +Host posix-escaped-include + HostName posix.internal +`, + 'utf-8' + ); + + expect(await parseSshConfigFileAt(join(dir, 'config'))).toEqual([ + { + host: 'posix-escaped-include', + hostname: 'posix.internal', + }, + ]); + }); + it('applies the same included snippet at each Include occurrence', async () => { const dir = await mkdtemp(join(tmpdir(), 'emdash-ssh-config-repeat-')); await writeFile( @@ -159,13 +276,13 @@ Host second host: 'first', hostname: 'first.internal', user: 'shared', - identityAgent: expect.stringContaining('/.ssh/shared-agent.sock'), + identityAgent: expect.stringMatching(pathSuffix('.ssh/shared-agent.sock')), }, { host: 'second', hostname: 'second.internal', user: 'shared', - identityAgent: expect.stringContaining('/.ssh/shared-agent.sock'), + identityAgent: expect.stringMatching(pathSuffix('.ssh/shared-agent.sock')), }, ]); }); diff --git a/apps/emdash-desktop/src/main/core/ssh/config/sshConfigParser.ts b/apps/emdash-desktop/src/main/core/ssh/config/sshConfigParser.ts index 3ce3048e84..bd74915876 100644 --- a/apps/emdash-desktop/src/main/core/ssh/config/sshConfigParser.ts +++ b/apps/emdash-desktop/src/main/core/ssh/config/sshConfigParser.ts @@ -19,13 +19,13 @@ function stripQuotes(value: string): string { } /** - * Expands a leading `~` or `~/` to the user's home directory. + * Expands a leading `~`, `~/`, or Windows `~\` to the user's home directory. */ function expandTilde(filePath: string): string { if (filePath === '~') { return homedir(); } - if (filePath.startsWith('~/')) { + if (filePath.startsWith('~/') || (process.platform === 'win32' && filePath.startsWith('~\\'))) { return join(homedir(), filePath.slice(2)); } return filePath; @@ -83,7 +83,10 @@ async function resolveIncludePaths(value: string, configDir: string): Promise