fix(desktop): map SSH profiles to remote profiles

This commit is contained in:
Cad from Arca 2026-07-30 10:53:11 +00:00 committed by Teknium
parent 4ebdbadef8
commit d6be88fbc8
18 changed files with 197 additions and 17 deletions

View File

@ -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'
})
})

View File

@ -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
}

View File

@ -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 || '',

View File

@ -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) })

View File

@ -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 &&

View File

@ -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 }))

View File

@ -8,6 +8,7 @@ function sshConfigFingerprint(scope, config) {
config.port,
config.keyPath,
config.remoteHermesPath,
config.remoteProfile,
config.effectiveConfigFingerprint
]

View File

@ -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')

View File

@ -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
}

View File

@ -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<ProfileInfo[]>([])
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(<GatewaySettings />)
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: ''
}))
)
})
})

View File

@ -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<GatewaySettingsState, 'mode' | 'remoteUrl'>): 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 ? (
<ListRow
action={
<Input
className={cn('h-8 font-mono', CONTROL_TEXT)}
onChange={event => setState(current => ({ ...current, sshRemoteProfile: event.target.value }))}
placeholder={scope}
value={state.sshRemoteProfile}
/>
}
description={g.sshRemoteProfileDesc}
title={g.sshRemoteProfileTitle}
/>
) : null}
</div>
) : null}

View File

@ -27,6 +27,7 @@ function config(overrides: Partial<DesktopConnectionConfig> = {}): DesktopConnec
sshPort: null,
sshKeyPath: '',
sshRemoteHermesPath: '',
sshRemoteProfile: '',
...overrides
}
}

View File

@ -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 {

View File

@ -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.',

View File

@ -788,6 +788,8 @@ export const ja = defineLocale({
sshHermesPathTitle: 'Hermes パス(任意)',
sshHermesPathDesc: 'リモートの hermes バイナリへのフルパス。空欄 = 自動検出。',
sshHermesPathPlaceholder: '自動検出',
sshRemoteProfileTitle: 'リモートプロファイル(任意)',
sshRemoteProfileDesc: 'リモートホスト上のプロファイル名。空欄 = Desktop のプロファイル名を使用。',
sshTestConnection: 'SSH をテスト',
sshConnect: '接続',
sshButtonsHint: '「保存」は次回起動時に適用され、「接続」は今すぐ再接続します。',

View File

@ -611,6 +611,8 @@ export interface Translations {
sshHermesPathTitle: string
sshHermesPathDesc: string
sshHermesPathPlaceholder: string
sshRemoteProfileTitle: string
sshRemoteProfileDesc: string
sshTestConnection: string
sshConnect: string
sshButtonsHint: string

View File

@ -764,6 +764,8 @@ export const zhHant = defineLocale({
sshHermesPathTitle: 'Hermes 路徑(選用)',
sshHermesPathDesc: '遠端 hermes 執行檔的完整路徑。留空 = 自動偵測。',
sshHermesPathPlaceholder: '自動偵測',
sshRemoteProfileTitle: '遠端設定檔(選用)',
sshRemoteProfileDesc: '遠端主機上的設定檔名稱。留空 = 使用 Desktop 設定檔名稱。',
sshTestConnection: '測試 SSH',
sshConnect: '連線',
sshButtonsHint: '「儲存」會在下次啟動時生效,「連線」則立即重新連線。',

View File

@ -925,6 +925,8 @@ export const zh: Translations = {
sshHermesPathTitle: 'Hermes 路径(可选)',
sshHermesPathDesc: '远程 hermes 可执行文件的完整路径。留空 = 自动检测。',
sshHermesPathPlaceholder: '自动检测',
sshRemoteProfileTitle: '远程配置文件(可选)',
sshRemoteProfileDesc: '远程主机上的配置文件名称。留空 = 使用 Desktop 配置文件名称。',
sshTestConnection: '测试 SSH',
sshConnect: '连接',
sshButtonsHint: '“保存”将在下次启动时生效,“连接”则立即重新连接。',