mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 16:02:24 +00:00
* feat(ssh): add SSH config host picker for add-host form Users can now click 'Fill from ~/.ssh/config…' to browse available SSH config hosts in a picker, select one, and have the form automatically prefill with resolved connection details (hostname, port, username, auth). Previously, an 'import' button provided bulk sync on this form—confusing and unhelpful when everything was already synced. That action is now available as a secondary 'Add all' option in the picker. * fix(ssh): import filter preservation and label fallback - Reuse search loader on import completion to preserve active filter inside generation guard - Fall back to hostname when manual host has no label, not empty string - Make alias duplicate detection case-insensitive to match config picker behavior - Validate host availability when restoring project group selection - Add aria-selected attribute to picker options for accessibility * fix(ssh): harden config picker import, alias folding, and host targeting Review findings on the ~/.ssh/config picker + bulk add: - Guard config-host resolution with a generation counter so a late resolve cannot overwrite a later pick or a form the user backed out of; freeze the other rows while a pick resolves. - Stop "Add all N" from re-adopting deleted hosts — it now imports without reAdopt, matching the new-host count it advertises. Settings → Import keeps the explicit re-adopt path. - Fold SSH aliases through a shared normalizeSshConfigAlias for import ownership, delete tombstones, reclaim, picker search, and the save-time duplicate check, which now occupies configHost *and* label like the picker. - Persist GSSAPIAuthentication only when a parsed Host entry asks for it, not when `ssh -G` merely echoes the /etc/ssh system default. - Fail closed with unavailable/setup-not-found when an explicit projectHostSetupId names a non-actionable host instead of silently creating the workspace on a sibling host. - Cache the parsed config for the picker session (refresh on open/retry) so filter keystrokes no longer reparse and Include-expand the file, keep the filter usable during loads, add a Retry on load errors, explain an empty Identity file after a config fill, and drop the always-false aria-selected. * refactor(ssh): centralize host result limit and extract folder group val Move SSH_CONFIG_HOST_RESULT_LIMIT to shared types so the renderer's limit message cannot drift from the host's query limit. Extract findActionableFolderProjectGroup to avoid repeating the folder-host-availability check across the composer hook. * fix(ssh): pass -F to ssh -G when HOME differs from passwd home In E2E tests and sandboxes, isolated HOME can differ from the system passwd home. OpenSSH resolves the default config via getpwuid (passwd), while Node's loadUserSshConfig uses os.homedir() (HOME-aware). Pass -F to explicitly specify the config path when they diverge, so ssh -G and the picker resolve the same file. * fix(ssh): verify config host exists before resolving with ssh -G When a user edits ~/.ssh/config and removes a host, the import picker should not fall back to ssh -G's echoed response (which treats any alias as valid). Check the reloaded config file before resolving. - Force reload config on each resolve to catch user edits post-open - Reject aliases not in the current config before calling ssh -G - Add test for deleted alias edge case - Fix workspace-target fallback to honor explicit host selection * fix(ssh): let tombstoned aliases be re-picked in the config picker Allow users to reclaim a deleted SSH host by re-picking it from ~/.ssh/config. Tombstoned aliases now appear in the picker with a "Removed from Orca" badge and remain pickable, but don't count toward "Add all" operations — ensuring passive import never resurrects a deleted alias while still giving the user a recovery path.
341 lines
11 KiB
TypeScript
341 lines
11 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { getExecutionHostLabel } from './execution-host'
|
|
import { MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, RUNTIME_PROTOCOL_VERSION } from './protocol-version'
|
|
import { buildExecutionHostRegistry } from './execution-host-registry'
|
|
|
|
const LOCAL_HOST_LABEL = getExecutionHostLabel('local')
|
|
|
|
describe('execution host registry', () => {
|
|
it('returns only the local host for local-only state', () => {
|
|
expect(
|
|
buildExecutionHostRegistry({
|
|
repos: [{ connectionId: null }],
|
|
settings: { activeRuntimeEnvironmentId: null }
|
|
})
|
|
).toEqual([
|
|
{
|
|
id: 'local',
|
|
kind: 'local',
|
|
label: LOCAL_HOST_LABEL,
|
|
detail: 'This computer',
|
|
health: 'local'
|
|
}
|
|
])
|
|
})
|
|
|
|
it('includes saved and repo-derived SSH hosts with connection health', () => {
|
|
const hosts = buildExecutionHostRegistry({
|
|
repos: [{ connectionId: 'repo-ssh' }],
|
|
settings: { activeRuntimeEnvironmentId: null },
|
|
sshTargetLabels: new Map([['saved-ssh', 'Saved SSH']]),
|
|
sshConnectionStates: new Map([
|
|
[
|
|
'repo-ssh',
|
|
{
|
|
targetId: 'repo-ssh',
|
|
status: 'connected',
|
|
error: null,
|
|
reconnectAttempt: 0
|
|
}
|
|
],
|
|
[
|
|
'saved-ssh',
|
|
{
|
|
targetId: 'saved-ssh',
|
|
status: 'auth-failed',
|
|
error: 'Permission denied',
|
|
reconnectAttempt: 1
|
|
}
|
|
]
|
|
])
|
|
})
|
|
|
|
expect(hosts).toMatchObject([
|
|
{ id: 'local', health: 'local' },
|
|
{ id: 'ssh:saved-ssh', label: 'Saved SSH', health: 'error', connectionStatus: 'auth-failed' },
|
|
{ id: 'ssh:repo-ssh', label: 'repo-ssh', health: 'available', connectionStatus: 'connected' }
|
|
])
|
|
})
|
|
|
|
it('excludes repository references from configured-only registries', () => {
|
|
const hosts = buildExecutionHostRegistry({
|
|
repos: [
|
|
{ connectionId: 'removed-ssh' },
|
|
{ connectionId: null, executionHostId: 'runtime:removed-runtime' }
|
|
],
|
|
settings: { activeRuntimeEnvironmentId: 'removed-focused-runtime' },
|
|
hostSource: 'configured-only',
|
|
sshTargetLabels: new Map([['saved-ssh', 'Saved SSH']]),
|
|
runtimeEnvironments: [{ id: 'saved-runtime', name: 'Saved Runtime' }]
|
|
})
|
|
|
|
expect(hosts.map((host) => host.id)).toEqual([
|
|
'local',
|
|
'runtime:saved-runtime',
|
|
'ssh:saved-ssh'
|
|
])
|
|
})
|
|
|
|
it('keeps repository references in the default reference-inclusive registry', () => {
|
|
const hosts = buildExecutionHostRegistry({
|
|
repos: [
|
|
{ connectionId: 'removed-ssh' },
|
|
{ connectionId: null, executionHostId: 'runtime:removed-runtime' }
|
|
],
|
|
settings: { activeRuntimeEnvironmentId: null }
|
|
})
|
|
|
|
expect(hosts.map((host) => host.id)).toEqual([
|
|
'local',
|
|
'runtime:removed-runtime',
|
|
'ssh:removed-ssh'
|
|
])
|
|
})
|
|
|
|
it('hides runtime-owned (ephemeral VM) SSH targets from repo-derived hosts', () => {
|
|
const hosts = buildExecutionHostRegistry({
|
|
// A VM-backed repo carries the hidden runtime-owned target on both fields.
|
|
repos: [
|
|
{
|
|
connectionId: 'runtime-ssh-orca-instance-1',
|
|
executionHostId: 'ssh:runtime-ssh-orca-instance-1'
|
|
},
|
|
{ connectionId: 'repo-ssh' }
|
|
],
|
|
settings: { activeRuntimeEnvironmentId: null },
|
|
// Even if a stale label leaked in, it must still be filtered out.
|
|
sshTargetLabels: new Map([['runtime-ssh-orca-instance-1', 'Hidden VM']])
|
|
})
|
|
|
|
expect(hosts.some((h) => h.id.includes('runtime-ssh-orca-instance-1'))).toBe(false)
|
|
// The ordinary repo SSH host is still present.
|
|
expect(hosts.some((h) => h.id === 'ssh:repo-ssh')).toBe(true)
|
|
})
|
|
|
|
it('adds saved runtime environments and preserves compatibility state per host', () => {
|
|
const hosts = buildExecutionHostRegistry({
|
|
repos: [],
|
|
settings: { activeRuntimeEnvironmentId: 'old-server' },
|
|
runtimeEnvironments: [{ id: 'builder', name: 'Linux Builder' }],
|
|
runtimeStatusByEnvironmentId: new Map([
|
|
[
|
|
'builder',
|
|
{
|
|
appVersion: '1.8.0',
|
|
status: {
|
|
runtimeId: 'runtime-builder',
|
|
rendererGraphEpoch: 1,
|
|
graphStatus: 'ready',
|
|
authoritativeWindowId: 1,
|
|
liveTabCount: 0,
|
|
liveLeafCount: 0,
|
|
runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION,
|
|
minCompatibleRuntimeClientVersion: 1,
|
|
capabilities: ['terminal.binary-stream.v1'],
|
|
hostPlatform: 'linux'
|
|
}
|
|
}
|
|
],
|
|
[
|
|
'old-server',
|
|
{
|
|
appVersion: '1.6.0',
|
|
status: {
|
|
runtimeId: 'runtime-old',
|
|
rendererGraphEpoch: 1,
|
|
graphStatus: 'ready',
|
|
authoritativeWindowId: 1,
|
|
liveTabCount: 0,
|
|
liveLeafCount: 0,
|
|
runtimeProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION - 1,
|
|
minCompatibleRuntimeClientVersion: 1,
|
|
capabilities: []
|
|
}
|
|
}
|
|
]
|
|
])
|
|
})
|
|
|
|
expect(hosts).toMatchObject([
|
|
{ id: 'local', health: 'local' },
|
|
{
|
|
id: 'runtime:builder',
|
|
label: 'Linux Builder',
|
|
health: 'available',
|
|
appVersion: '1.8.0',
|
|
protocolVersion: RUNTIME_PROTOCOL_VERSION,
|
|
capabilities: ['terminal.binary-stream.v1'],
|
|
platform: 'linux',
|
|
compatibility: { kind: 'ok' }
|
|
},
|
|
{
|
|
id: 'runtime:old-server',
|
|
label: 'old-server',
|
|
health: 'blocked',
|
|
appVersion: '1.6.0',
|
|
compatibility: { kind: 'blocked', reason: 'server-too-old' }
|
|
}
|
|
])
|
|
})
|
|
|
|
it('uses shared-control diagnostics to show reconnecting runtime host health', () => {
|
|
const hosts = buildExecutionHostRegistry({
|
|
repos: [],
|
|
settings: { activeRuntimeEnvironmentId: null },
|
|
runtimeEnvironments: [{ id: 'dev-box', name: 'Dev Box' }],
|
|
runtimeStatusByEnvironmentId: new Map([
|
|
[
|
|
'dev-box',
|
|
{
|
|
status: {
|
|
runtimeId: 'runtime-dev',
|
|
rendererGraphEpoch: 1,
|
|
graphStatus: 'ready',
|
|
authoritativeWindowId: 1,
|
|
liveTabCount: 0,
|
|
liveLeafCount: 0,
|
|
remoteControl: {
|
|
state: 'reconnecting',
|
|
pendingRequestCount: 0,
|
|
subscriptionCount: 2,
|
|
reconnectAttempt: 1,
|
|
lastConnectedAt: 123,
|
|
lastClose: { code: 1006, reason: '' },
|
|
lastError: 'Remote Orca runtime closed the connection.'
|
|
}
|
|
}
|
|
}
|
|
]
|
|
])
|
|
})
|
|
|
|
expect(hosts).toMatchObject([
|
|
{ id: 'local', health: 'local' },
|
|
{
|
|
id: 'runtime:dev-box',
|
|
label: 'Dev Box',
|
|
health: 'connecting',
|
|
remoteControlState: { state: 'reconnecting', subscriptionCount: 2 }
|
|
}
|
|
])
|
|
})
|
|
|
|
it('preserves runtime environment source on runtime hosts', () => {
|
|
const hosts = buildExecutionHostRegistry({
|
|
repos: [],
|
|
settings: { activeRuntimeEnvironmentId: null },
|
|
runtimeEnvironments: [{ id: 'vm-runtime', name: 'VM Runtime', source: 'ephemeral-vm' }],
|
|
runtimeStatusByEnvironmentId: new Map([
|
|
[
|
|
'vm-runtime',
|
|
{
|
|
status: {
|
|
runtimeId: 'runtime-vm',
|
|
rendererGraphEpoch: 1,
|
|
graphStatus: 'ready',
|
|
authoritativeWindowId: 1,
|
|
liveTabCount: 0,
|
|
liveLeafCount: 0,
|
|
runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION,
|
|
minCompatibleRuntimeClientVersion: 1,
|
|
capabilities: ['project-host-setup.v1']
|
|
}
|
|
}
|
|
]
|
|
])
|
|
})
|
|
|
|
expect(hosts).toContainEqual(
|
|
expect.objectContaining({
|
|
id: 'runtime:vm-runtime',
|
|
kind: 'runtime',
|
|
source: 'ephemeral-vm'
|
|
})
|
|
)
|
|
})
|
|
|
|
it('applies per-host display-label overrides to derived labels', () => {
|
|
const hosts = buildExecutionHostRegistry({
|
|
repos: [{ connectionId: 'repo-ssh' }],
|
|
settings: { activeRuntimeEnvironmentId: null },
|
|
sshTargetLabels: new Map([['repo-ssh', 'Derived SSH']]),
|
|
hostLabelOverrides: new Map([
|
|
['ssh:repo-ssh', 'Renamed Box'],
|
|
['local', 'My Laptop']
|
|
])
|
|
})
|
|
|
|
expect(hosts).toMatchObject([
|
|
{ id: 'local', label: 'My Laptop' },
|
|
{ id: 'ssh:repo-ssh', label: 'Renamed Box' }
|
|
])
|
|
})
|
|
|
|
it('keeps derived labels for hosts without an override', () => {
|
|
const hosts = buildExecutionHostRegistry({
|
|
repos: [{ connectionId: 'repo-ssh' }],
|
|
settings: { activeRuntimeEnvironmentId: null },
|
|
sshTargetLabels: new Map([['repo-ssh', 'Derived SSH']]),
|
|
hostLabelOverrides: new Map([['ssh:other', 'Unrelated']])
|
|
})
|
|
|
|
expect(hosts).toMatchObject([
|
|
{ id: 'local', label: LOCAL_HOST_LABEL },
|
|
{ id: 'ssh:repo-ssh', label: 'Derived SSH' }
|
|
])
|
|
})
|
|
|
|
it('includes runtime hosts from repo ownership but marks them disconnected without live status', () => {
|
|
const hosts = buildExecutionHostRegistry({
|
|
repos: [{ connectionId: null, executionHostId: 'runtime:env-2' }],
|
|
settings: { activeRuntimeEnvironmentId: null }
|
|
})
|
|
|
|
// No live status means no evidence the Orca server is reachable, so it must
|
|
// read 'disconnected' rather than defaulting to 'available'/"Connected".
|
|
expect(hosts).toMatchObject([
|
|
{ id: 'local', health: 'local' },
|
|
{ id: 'runtime:env-2', kind: 'runtime', label: 'env-2', health: 'disconnected' }
|
|
])
|
|
})
|
|
|
|
it('includes runtime hosts from hydrated status even when they are not focused', () => {
|
|
const hosts = buildExecutionHostRegistry({
|
|
repos: [],
|
|
settings: { activeRuntimeEnvironmentId: null },
|
|
runtimeStatusByEnvironmentId: new Map([
|
|
[
|
|
'gpu',
|
|
{
|
|
appVersion: '1.8.0',
|
|
status: {
|
|
runtimeId: 'runtime-gpu',
|
|
rendererGraphEpoch: 1,
|
|
graphStatus: 'ready',
|
|
authoritativeWindowId: 1,
|
|
liveTabCount: 0,
|
|
liveLeafCount: 0,
|
|
runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION,
|
|
minCompatibleRuntimeClientVersion: 1,
|
|
capabilities: ['project-host-setup.v1'],
|
|
hostPlatform: 'linux'
|
|
}
|
|
}
|
|
]
|
|
])
|
|
})
|
|
|
|
expect(hosts).toMatchObject([
|
|
{ id: 'local', health: 'local' },
|
|
{
|
|
id: 'runtime:gpu',
|
|
kind: 'runtime',
|
|
label: 'gpu',
|
|
health: 'available',
|
|
capabilities: ['project-host-setup.v1'],
|
|
platform: 'linux'
|
|
}
|
|
])
|
|
})
|
|
})
|