fix(desktop): map SSH profiles to remote profiles
This commit is contained in:
parent
4ebdbadef8
commit
d6be88fbc8
|
|
@ -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'
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 || '',
|
||||
|
|
|
|||
|
|
@ -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) })
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ function sshConfigFingerprint(scope, config) {
|
|||
config.port,
|
||||
config.keyPath,
|
||||
config.remoteHermesPath,
|
||||
config.remoteProfile,
|
||||
config.effectiveConfigFingerprint
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: ''
|
||||
}))
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ function config(overrides: Partial<DesktopConnectionConfig> = {}): DesktopConnec
|
|||
sshPort: null,
|
||||
sshKeyPath: '',
|
||||
sshRemoteHermesPath: '',
|
||||
sshRemoteProfile: '',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -788,6 +788,8 @@ export const ja = defineLocale({
|
|||
sshHermesPathTitle: 'Hermes パス(任意)',
|
||||
sshHermesPathDesc: 'リモートの hermes バイナリへのフルパス。空欄 = 自動検出。',
|
||||
sshHermesPathPlaceholder: '自動検出',
|
||||
sshRemoteProfileTitle: 'リモートプロファイル(任意)',
|
||||
sshRemoteProfileDesc: 'リモートホスト上のプロファイル名。空欄 = Desktop のプロファイル名を使用。',
|
||||
sshTestConnection: 'SSH をテスト',
|
||||
sshConnect: '接続',
|
||||
sshButtonsHint: '「保存」は次回起動時に適用され、「接続」は今すぐ再接続します。',
|
||||
|
|
|
|||
|
|
@ -611,6 +611,8 @@ export interface Translations {
|
|||
sshHermesPathTitle: string
|
||||
sshHermesPathDesc: string
|
||||
sshHermesPathPlaceholder: string
|
||||
sshRemoteProfileTitle: string
|
||||
sshRemoteProfileDesc: string
|
||||
sshTestConnection: string
|
||||
sshConnect: string
|
||||
sshButtonsHint: string
|
||||
|
|
|
|||
|
|
@ -764,6 +764,8 @@ export const zhHant = defineLocale({
|
|||
sshHermesPathTitle: 'Hermes 路徑(選用)',
|
||||
sshHermesPathDesc: '遠端 hermes 執行檔的完整路徑。留空 = 自動偵測。',
|
||||
sshHermesPathPlaceholder: '自動偵測',
|
||||
sshRemoteProfileTitle: '遠端設定檔(選用)',
|
||||
sshRemoteProfileDesc: '遠端主機上的設定檔名稱。留空 = 使用 Desktop 設定檔名稱。',
|
||||
sshTestConnection: '測試 SSH',
|
||||
sshConnect: '連線',
|
||||
sshButtonsHint: '「儲存」會在下次啟動時生效,「連線」則立即重新連線。',
|
||||
|
|
|
|||
|
|
@ -925,6 +925,8 @@ export const zh: Translations = {
|
|||
sshHermesPathTitle: 'Hermes 路径(可选)',
|
||||
sshHermesPathDesc: '远程 hermes 可执行文件的完整路径。留空 = 自动检测。',
|
||||
sshHermesPathPlaceholder: '自动检测',
|
||||
sshRemoteProfileTitle: '远程配置文件(可选)',
|
||||
sshRemoteProfileDesc: '远程主机上的配置文件名称。留空 = 使用 Desktop 配置文件名称。',
|
||||
sshTestConnection: '测试 SSH',
|
||||
sshConnect: '连接',
|
||||
sshButtonsHint: '“保存”将在下次启动时生效,“连接”则立即重新连接。',
|
||||
|
|
|
|||
Loading…
Reference in New Issue