diff --git a/apps/desktop/electron/connection-config.test.ts b/apps/desktop/electron/connection-config.test.ts index 748ff9ef8ddee..e5ba2812725ea 100644 --- a/apps/desktop/electron/connection-config.test.ts +++ b/apps/desktop/electron/connection-config.test.ts @@ -133,16 +133,38 @@ test('profileRemoteOverride tolerates a missing/!object profiles map', () => { assert.equal(profileRemoteOverride(null, 'coder'), null) }) -test('SSH remains separate from URL-shaped remote modes', () => { +test('SSH remains separate from URL-shaped remote modes and preserves an explicit remote profile', () => { assert.equal(modeIsRemoteLike('ssh'), false) - const config = { profiles: { coder: { mode: 'ssh', host: 'alice@box:2222', keyPath: '/key' } } } + + const config = { + profiles: { coder: { mode: 'ssh', host: 'alice@box:2222', keyPath: '/key', remoteProfile: 'default' } } + } + assert.equal(profileRemoteOverride(config, 'coder'), null) + assert.deepEqual(profileSshOverride(config, 'coder'), { mode: 'ssh', host: 'box', user: 'alice', port: 2222, - keyPath: '/key' + keyPath: '/key', + remoteProfile: 'default' + }) +}) + +test('normalizeSshConfig rejects unsafe remote profile mappings', () => { + assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'box', remoteProfile: 'writer_2' }), { + mode: 'ssh', + host: 'box', + remoteProfile: 'writer_2' + }) + assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'box', remoteProfile: 'bad profile' }), { + mode: 'ssh', + host: 'box' + }) + assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'box', remoteProfile: '' }), { + mode: 'ssh', + host: 'box' }) }) diff --git a/apps/desktop/electron/connection-config.ts b/apps/desktop/electron/connection-config.ts index 6c3c6ce9bb829..7cbf9c911a636 100644 --- a/apps/desktop/electron/connection-config.ts +++ b/apps/desktop/electron/connection-config.ts @@ -281,6 +281,16 @@ function normalizeSshConfig(entry) { out.remoteHermesPath = remoteHermesPath } + // A Desktop profile can be a local routing label rather than the profile + // name used by the remote Hermes installation. Preserve an explicit mapping + // when it is a valid Hermes profile identifier; otherwise fall back to the + // historical same-name behavior in the caller. + const remoteProfile = String(entry.remoteProfile || '').trim() + + if (/^[a-z0-9][a-z0-9_-]{0,63}$/.test(remoteProfile)) { + out.remoteProfile = remoteProfile + } + return out } diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 2687ae322ef8e..785a1dc96e203 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -6996,6 +6996,7 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon sshPort: (ssh || savedSsh)?.port || null, sshKeyPath: (ssh || savedSsh)?.keyPath || '', sshRemoteHermesPath: (ssh || savedSsh)?.remoteHermesPath || '', + sshRemoteProfile: (ssh || savedSsh)?.remoteProfile || '', // The env override only forces the global/primary connection; a per-profile // scope is never overridden by HERMES_DESKTOP_REMOTE_URL. envOverride @@ -7128,7 +7129,8 @@ function buildSshBlock(input: any, existingBlock: any = {}) { user: input.sshUser ?? existingBlock.user, port: input.sshPort ?? existingBlock.port, keyPath: input.sshKeyPath ?? existingBlock.keyPath, - remoteHermesPath: input.sshRemoteHermesPath ?? existingBlock.remoteHermesPath + remoteHermesPath: input.sshRemoteHermesPath ?? existingBlock.remoteHermesPath, + remoteProfile: input.sshRemoteProfile ?? existingBlock.remoteProfile }) if (!merged) { @@ -7417,7 +7419,7 @@ async function bootstrapSshConnectionInner(profile, sshConfig, reuseToken, sourc const lifecycle = platform.os === 'Windows' ? connectWindowsRemote : remoteLifecycle.connect result = await lifecycle({ ssh, - profile: connectionScopeKey(profile) || '', + profile: sshConfig.remoteProfile || connectionScopeKey(profile) || '', remoteHermesPath: sshConfig.remoteHermesPath || '', ownershipId: sshOwnershipKey(profile), reuseToken: reuseToken || '', diff --git a/apps/desktop/electron/remote-lifecycle.test.ts b/apps/desktop/electron/remote-lifecycle.test.ts index b421b0ef0c31d..c6f411475fd33 100644 --- a/apps/desktop/electron/remote-lifecycle.test.ts +++ b/apps/desktop/electron/remote-lifecycle.test.ts @@ -440,6 +440,33 @@ test('connect() reuses a healthy dashboard when fingerprint + probe pass', async assert.ok(!ssh.calls.some(c => /setsid/.test(c)), 'reuse path must not spawn a new dashboard') }) +test('connect() respawns when the requested remote profile differs from the lockfile profile', async () => { + const reuseToken = 'stored-token' + const lock = ownedLock({ profile: 'desktop-work', tokenFingerprint: fingerprintToken(reuseToken) }) + + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, JSON.stringify(lock)], + [/kill -0 333/, 'ALIVE'], + [/print\("OWNED"/, 'OWNED\n'], + [/kill 333/, ''], + [/--version/, 'Hermes Agent v0.18.2\n'], + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], + [/setsid/, '890\n'], + [/kill -0 890/, 'ALIVE'], + [/cat .*\.log/, 'HERMES_DASHBOARD_READY port=52050\n'] + ]) + + const result = await connect( + connectDeps(ssh, { profile: 'default', reuseToken, adoptServedToken: async () => 'fresh' }) + ) + + assert.equal(result.reused, false) + assert.ok(ssh.calls.some(c => /setsid/.test(c)), 'profile mismatch must spawn a fresh dashboard') +}) + test('connect() respawns when the lockfile hermesPath differs from the resolved path', async () => { const reuseToken = 'stored-token' const lock = ownedLock({ hermesPath: '/old/stale/hermes', tokenFingerprint: fingerprintToken(reuseToken) }) diff --git a/apps/desktop/electron/remote-lifecycle.ts b/apps/desktop/electron/remote-lifecycle.ts index 705c80000b927..db4d5b20dda3f 100644 --- a/apps/desktop/electron/remote-lifecycle.ts +++ b/apps/desktop/electron/remote-lifecycle.ts @@ -713,6 +713,7 @@ async function connect(deps) { pidAlive && owned && lock.port > 0 && + lock.profile === profile && Boolean(reuseToken) && lock.tokenFingerprint === fingerprintToken(reuseToken) && lock.hermesPath === hermesPath && diff --git a/apps/desktop/electron/ssh-bootstrap-coordinator.test.ts b/apps/desktop/electron/ssh-bootstrap-coordinator.test.ts index cf5c24e97270a..d3ffb99e0eee7 100644 --- a/apps/desktop/electron/ssh-bootstrap-coordinator.test.ts +++ b/apps/desktop/electron/ssh-bootstrap-coordinator.test.ts @@ -28,6 +28,7 @@ test('sshConfigFingerprint covers scope and every connection field', () => { port: 2222, keyPath: '/other', remoteHermesPath: '/other-hermes', + remoteProfile: 'default', effectiveConfigFingerprint: 'changed-config' })) { assert.notEqual(base, sshConfigFingerprint('', { ...config, [field]: value })) diff --git a/apps/desktop/electron/ssh-bootstrap-coordinator.ts b/apps/desktop/electron/ssh-bootstrap-coordinator.ts index 827e9a4e7a18b..c38e0473aae2c 100644 --- a/apps/desktop/electron/ssh-bootstrap-coordinator.ts +++ b/apps/desktop/electron/ssh-bootstrap-coordinator.ts @@ -8,6 +8,7 @@ function sshConfigFingerprint(scope, config) { config.port, config.keyPath, config.remoteHermesPath, + config.remoteProfile, config.effectiveConfigFingerprint ] diff --git a/apps/desktop/electron/windows-remote-lifecycle.test.ts b/apps/desktop/electron/windows-remote-lifecycle.test.ts index 56cfb4d45d280..b63d9c69bff9d 100644 --- a/apps/desktop/electron/windows-remote-lifecycle.test.ts +++ b/apps/desktop/electron/windows-remote-lifecycle.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict' +import crypto from 'node:crypto' import { test } from 'vitest' @@ -9,6 +10,7 @@ import { helperCommand, powerShellCommand, psLiteral, + reusableWindowsLock, validLock } from './windows-remote-lifecycle' @@ -114,6 +116,31 @@ test('Windows lock validation is scoped and exact', () => { assert.equal(validLock({ ...lock, port: -1 }, ownershipId), false) }) +test('Windows SSH reuse requires the requested remote profile to match the lock', () => { + const token = 'stored-token' + + const lock = { + schemaVersion: 2, + protocolVersion: 1, + ownershipId, + spawnNonce: '0123456789abcdef', + pid: 10, + creationTimeNs: '1784219690452757504', + port: 1234, + profile: 'default', + tokenFingerprint: crypto.createHash('sha256').update(token).digest('hex').slice(0, 32), + hermesPath: 'C:\\h\\hermes.exe', + hermesHome: 'C:\\h' + } + + const state = { alive: true, owned: true } + const runtime = { hermesPath: lock.hermesPath, hermesHome: lock.hermesHome } + + assert.equal(reusableWindowsLock(lock, state, 'default', token, runtime), true) + assert.equal(reusableWindowsLock(lock, state, 'desktop-work', token, runtime), false) + assert.equal(reusableWindowsLock({ ...lock, profile: '' }, state, '', token, runtime), true) +}) + test('Windows integrated terminal uses encoded PowerShell and preserves cwd as literal data', () => { const command = buildWindowsInteractiveCommand("C:\\Users\\O'Brien\\repo") const script = Buffer.from(command.split(' ').pop()!, 'base64').toString('utf16le') diff --git a/apps/desktop/electron/windows-remote-lifecycle.ts b/apps/desktop/electron/windows-remote-lifecycle.ts index ff03d6a8c791d..5d96853a1a105 100644 --- a/apps/desktop/electron/windows-remote-lifecycle.ts +++ b/apps/desktop/electron/windows-remote-lifecycle.ts @@ -149,6 +149,19 @@ function validLock(lock, ownershipId) { ) } +function reusableWindowsLock(lock, state, profile, reuseToken, runtime) { + return Boolean( + state.alive && + state.owned && + lock.port > 0 && + lock.profile === profile && + reuseToken && + lock.tokenFingerprint === fingerprintToken(reuseToken) && + lock.hermesPath === runtime.hermesPath && + lock.hermesHome === runtime.hermesHome + ) +} + function assertCurrent(signal) { if (signal?.aborted) { const error: any = new Error('SSH bootstrap was cancelled.') @@ -299,14 +312,7 @@ async function connectWindowsRemote(deps) { throw error } - const reusable = - state.alive && - state.owned && - lock.port > 0 && - Boolean(reuseToken) && - lock.tokenFingerprint === fingerprintToken(reuseToken) && - lock.hermesPath === runtime.hermesPath && - lock.hermesHome === runtime.hermesHome + const reusable = reusableWindowsLock(lock, state, profile, reuseToken, runtime) if (reusable) { const localPort = await pickLocalPort() @@ -451,5 +457,6 @@ export { powerShellCommand, probeWindowsRemote, psLiteral, + reusableWindowsLock, validLock } diff --git a/apps/desktop/src/app/settings/gateway-settings.test.tsx b/apps/desktop/src/app/settings/gateway-settings.test.tsx index 89df6a90491e1..06f3a99d71468 100644 --- a/apps/desktop/src/app/settings/gateway-settings.test.tsx +++ b/apps/desktop/src/app/settings/gateway-settings.test.tsx @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ProfileInfo } from '@/types/hermes' const getConnectionConfig = vi.fn() +const saveConnectionConfig = vi.fn() const profiles = atom([]) vi.mock('@/store/profile', () => ({ @@ -45,9 +46,10 @@ beforeEach(() => { } ]) getConnectionConfig.mockResolvedValue(localConnection) + saveConnectionConfig.mockResolvedValue(localConnection) Object.defineProperty(window, 'hermesDesktop', { configurable: true, - value: { getConnectionConfig } + value: { getConnectionConfig, saveConnectionConfig } }) }) @@ -75,4 +77,43 @@ describe('GatewaySettings', () => { screen.queryByText('Start a private Hermes backend on localhost. This is the default and works offline.') ).toBeNull() }) + + it('shows and clears an SSH remote-profile mapping for a named Desktop profile', async () => { + getConnectionConfig.mockImplementation(async profile => + profile === 'work' + ? { + ...localConnection, + mode: 'ssh', + profile: 'work', + sshHost: 'remote-box', + sshUser: 'alice', + sshPort: 22, + sshKeyPath: '', + sshRemoteHermesPath: '/opt/hermes/bin/hermes', + sshRemoteProfile: 'default' + } + : localConnection + ) + saveConnectionConfig.mockReturnValue(new Promise(() => {})) + const { GatewaySettings } = await import('./gateway-settings') + + render() + fireEvent.click(await screen.findByRole('button', { name: 'work' })) + + await waitFor(() => expect(getConnectionConfig).toHaveBeenLastCalledWith('work')) + expect(await screen.findByText('Remote profile (optional)')).toBeTruthy() + + const input = screen.getByPlaceholderText('work') + + expect((input as HTMLInputElement).value).toBe('default') + fireEvent.change(input, { target: { value: '' } }) + fireEvent.click(screen.getByRole('button', { name: 'Save for next restart' })) + + await waitFor(() => + expect(saveConnectionConfig).toHaveBeenCalledWith(expect.objectContaining({ + profile: 'work', + sshRemoteProfile: '' + })) + ) + }) }) diff --git a/apps/desktop/src/app/settings/gateway-settings.tsx b/apps/desktop/src/app/settings/gateway-settings.tsx index 2750a06eccd37..23773ae525618 100644 --- a/apps/desktop/src/app/settings/gateway-settings.tsx +++ b/apps/desktop/src/app/settings/gateway-settings.tsx @@ -51,6 +51,7 @@ interface GatewaySettingsState { sshPort: number | null sshKeyPath: string sshRemoteHermesPath: string + sshRemoteProfile: string } const SSH_HOST_CUSTOM = '__custom__' @@ -68,7 +69,8 @@ const EMPTY_STATE: GatewaySettingsState = { sshUser: '', sshPort: null, sshKeyPath: '', - sshRemoteHermesPath: '' + sshRemoteHermesPath: '', + sshRemoteProfile: '' } export function savedCloudConnectionUrl(config: Pick): string { @@ -413,7 +415,16 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { signingSeq.current += 1 cloudConnectSeq.current += 1 setLastTest(null) - }, [scope, state.mode, state.sshHost, state.sshUser, state.sshPort, state.sshKeyPath, state.sshRemoteHermesPath]) + }, [ + scope, + state.mode, + state.sshHost, + state.sshUser, + state.sshPort, + state.sshKeyPath, + state.sshRemoteHermesPath, + state.sshRemoteProfile + ]) const oauthConnected = state.remoteOauthConnected @@ -439,7 +450,10 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { sshUser: state.sshUser.trim() || undefined, sshPort: state.sshPort, sshKeyPath: state.sshKeyPath.trim() || undefined, - sshRemoteHermesPath: state.sshRemoteHermesPath.trim() + sshRemoteHermesPath: state.sshRemoteHermesPath.trim(), + // Preserve an intentional blank so an existing remote-profile mapping can + // be cleared instead of being mistaken for an omitted field. + sshRemoteProfile: state.sshRemoteProfile.trim() }) const save = async (apply: boolean) => { @@ -1424,6 +1438,20 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { description={g.sshHermesPathDesc} title={g.sshHermesPathTitle} /> + {scope !== null ? ( + setState(current => ({ ...current, sshRemoteProfile: event.target.value }))} + placeholder={scope} + value={state.sshRemoteProfile} + /> + } + description={g.sshRemoteProfileDesc} + title={g.sshRemoteProfileTitle} + /> + ) : null} ) : null} diff --git a/apps/desktop/src/components/boot-failure-reauth.test.ts b/apps/desktop/src/components/boot-failure-reauth.test.ts index 42527604b14f9..78b58329ce577 100644 --- a/apps/desktop/src/components/boot-failure-reauth.test.ts +++ b/apps/desktop/src/components/boot-failure-reauth.test.ts @@ -27,6 +27,7 @@ function config(overrides: Partial = {}): DesktopConnec sshPort: null, sshKeyPath: '', sshRemoteHermesPath: '', + sshRemoteProfile: '', ...overrides } } diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 7d21757775920..c2b677619ec41 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -531,6 +531,7 @@ export interface DesktopConnectionConfig { sshPort: number | null sshKeyPath: string sshRemoteHermesPath: string + sshRemoteProfile: string } export interface DesktopConnectionConfigInput { @@ -549,6 +550,7 @@ export interface DesktopConnectionConfigInput { sshPort?: number | null sshKeyPath?: string sshRemoteHermesPath?: string + sshRemoteProfile?: string } export interface DesktopConnectionTestResult { diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index c08e289c0374d..70b7cea9ce28e 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -719,6 +719,8 @@ export const en: Translations = { sshHermesPathTitle: 'Hermes path (optional)', sshHermesPathDesc: 'Full path to the remote hermes binary. Blank = auto-detect.', sshHermesPathPlaceholder: 'auto-detect', + sshRemoteProfileTitle: 'Remote profile (optional)', + sshRemoteProfileDesc: 'Profile name on the remote host. Blank = use the Desktop profile name.', sshTestConnection: 'Test SSH', sshConnect: 'Connect', sshButtonsHint: 'Save applies on the next launch. Connect reconnects now.', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index a3f949dc9ec83..3841bcdbf1399 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -788,6 +788,8 @@ export const ja = defineLocale({ sshHermesPathTitle: 'Hermes パス(任意)', sshHermesPathDesc: 'リモートの hermes バイナリへのフルパス。空欄 = 自動検出。', sshHermesPathPlaceholder: '自動検出', + sshRemoteProfileTitle: 'リモートプロファイル(任意)', + sshRemoteProfileDesc: 'リモートホスト上のプロファイル名。空欄 = Desktop のプロファイル名を使用。', sshTestConnection: 'SSH をテスト', sshConnect: '接続', sshButtonsHint: '「保存」は次回起動時に適用され、「接続」は今すぐ再接続します。', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 3511e866a17e1..976dc8b9821c7 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -611,6 +611,8 @@ export interface Translations { sshHermesPathTitle: string sshHermesPathDesc: string sshHermesPathPlaceholder: string + sshRemoteProfileTitle: string + sshRemoteProfileDesc: string sshTestConnection: string sshConnect: string sshButtonsHint: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 0c382484a0454..9cf9c99a378e2 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -764,6 +764,8 @@ export const zhHant = defineLocale({ sshHermesPathTitle: 'Hermes 路徑(選用)', sshHermesPathDesc: '遠端 hermes 執行檔的完整路徑。留空 = 自動偵測。', sshHermesPathPlaceholder: '自動偵測', + sshRemoteProfileTitle: '遠端設定檔(選用)', + sshRemoteProfileDesc: '遠端主機上的設定檔名稱。留空 = 使用 Desktop 設定檔名稱。', sshTestConnection: '測試 SSH', sshConnect: '連線', sshButtonsHint: '「儲存」會在下次啟動時生效,「連線」則立即重新連線。', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 0295d4817863a..9e4845f452264 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -925,6 +925,8 @@ export const zh: Translations = { sshHermesPathTitle: 'Hermes 路径(可选)', sshHermesPathDesc: '远程 hermes 可执行文件的完整路径。留空 = 自动检测。', sshHermesPathPlaceholder: '自动检测', + sshRemoteProfileTitle: '远程配置文件(可选)', + sshRemoteProfileDesc: '远程主机上的配置文件名称。留空 = 使用 Desktop 配置文件名称。', sshTestConnection: '测试 SSH', sshConnect: '连接', sshButtonsHint: '“保存”将在下次启动时生效,“连接”则立即重新连接。',