mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
refactor(ssh): derive a host-key policy from the target owner (#30627)
* refactor(ssh): derive a host-key policy from the target owner The verifier and decision took an isEphemeralRuntimeTarget boolean. They now take a HostKeyPolicy derived from the owner by one function, and the owner shape gets a named SshTargetOwner type. No behavior change: on-demand runtime VMs map to the ephemeral policy, everything else to standard, and every existing decision test passes unchanged in substance. * docs(ssh): separate owned-target hiding from the recipe-VM id prefix Hidden-ness is decided by owner in main; the runtime-ssh- prefix also carries per-provision semantics (fresh filesystem, VM cleanup), so only on-demand-runtime owners may use it. Comments only. * refactor(ssh): one id predicate for every Orca-owned SSH target Surfaces that hide owned targets or must never dial them from the renderer (startup re-dial, reconnect hints, automation host catalog, status bar, host registry, state broadcast) now ask isOwnedSshTargetId, which also covers the machine- prefix. isRuntimeOwnedSshTargetId keeps its narrower recipe-VM meaning (address and filesystem replaced every provision), so name retirement and VM cleanup are unchanged. No target uses machine- on main yet, so behavior is identical; tests pin both.
This commit is contained in:
@@ -5,7 +5,7 @@ import type {
|
||||
EnrichedDetectedPort,
|
||||
SshConnectionState
|
||||
} from '../../../shared/ssh-types'
|
||||
import { isRuntimeOwnedSshTargetId } from '../../../shared/execution-host'
|
||||
import { isOwnedSshTargetId } from '../../../shared/execution-host'
|
||||
import {
|
||||
enrichSshDetectedPorts,
|
||||
enrichSshForwardEntries,
|
||||
@@ -31,7 +31,7 @@ export function broadcastSshState(
|
||||
): void {
|
||||
const target = getSshTargetRegistryStore()?.getTarget(targetId)
|
||||
// Why: owned targets are hidden from clients; broadcasting them leaks internal transport state into persisted reconnect hints.
|
||||
if (isRuntimeOwnedSshTargetId(targetId) || (target && isRuntimeOwnedSshTarget(target))) {
|
||||
if (isOwnedSshTargetId(targetId) || (target && isRuntimeOwnedSshTarget(target))) {
|
||||
currentRuntime?.invalidateSshWorktreeScanCache?.(targetId)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -265,7 +265,7 @@ export function getRuntimeOwnedSshTargetId(runtimeId: string): string {
|
||||
return `${RUNTIME_OWNED_SSH_TARGET_ID_PREFIX}${runtimeId}`
|
||||
}
|
||||
|
||||
/** Ephemeral runtime targets, hidden from SSH-host surfaces. */
|
||||
/** Any target Orca created as an implementation detail; hidden from SSH-host surfaces. */
|
||||
export function isRuntimeOwnedSshTarget(target: SshTarget): boolean {
|
||||
return target.owner !== undefined
|
||||
}
|
||||
|
||||
@@ -57,7 +57,11 @@ import {
|
||||
DEFAULT_SERVER_HOST_KEY_ALGORITHMS,
|
||||
orderServerHostKeyAlgorithms
|
||||
} from '../ssh-host-key-verifier'
|
||||
import { HostKeyVerificationError, isHostKeyVerificationError } from '../ssh-host-key-decision'
|
||||
import {
|
||||
HostKeyVerificationError,
|
||||
hostKeyPolicyForOwner,
|
||||
isHostKeyVerificationError
|
||||
} from '../ssh-host-key-decision'
|
||||
import {
|
||||
resolveWithSshG,
|
||||
siteConfigMayRestrictHostKeys,
|
||||
@@ -95,7 +99,6 @@ import type { FileUploadSession } from '../../providers/filesystem-provider-cont
|
||||
import { openSshSessionChannelWithRetry, waitForSshChannelOpen } from '../ssh-channel-open'
|
||||
import { tagSftpHandshakeCorruption } from '../sftp-handshake-corruption'
|
||||
import { withTimeout } from '../../../shared/promise-timeout-fallback'
|
||||
import { isEphemeralRuntimeSshOwner } from '../../../shared/managed-orcad-ssh-owner'
|
||||
import { isSshUserDisconnected } from '../ssh-user-disconnect'
|
||||
|
||||
/** The sign-in a connection holds in memory, handed to the connection that replaces it. */
|
||||
@@ -1230,7 +1233,7 @@ export class SshConnection {
|
||||
hostKeyStoreFile: boundSshHostKeyStoreFile() ?? undefined,
|
||||
strictHostKeyChecking: hostKeyResolved?.strictHostKeyChecking ?? 'ask',
|
||||
isHostKeyAlias,
|
||||
isEphemeralRuntimeTarget: isEphemeralRuntimeSshOwner(this.target.owner),
|
||||
policy: hostKeyPolicyForOwner(this.target.owner),
|
||||
siteConfigSuppressed,
|
||||
// A file that EXISTS and will not open is the absence of evidence, not evidence of a new
|
||||
// host — the entry that would have said "this key changed" may be in it. An ABSENT file is
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { decideHostKey, type HostKeyDecisionInput } from './ssh-host-key-decision'
|
||||
import {
|
||||
decideHostKey,
|
||||
hostKeyPolicyForOwner,
|
||||
type HostKeyDecisionInput
|
||||
} from './ssh-host-key-decision'
|
||||
|
||||
function input(overrides: Partial<HostKeyDecisionInput> = {}): HostKeyDecisionInput {
|
||||
return {
|
||||
knownHostsOutcome: 'unknown',
|
||||
storeOutcome: 'unknown',
|
||||
strictHostKeyChecking: 'ask',
|
||||
isEphemeralRuntimeTarget: false,
|
||||
policy: { kind: 'standard' },
|
||||
siteConfigSuppressed: false,
|
||||
knownHostsUnreadable: false,
|
||||
displayHost: 'build-01',
|
||||
@@ -311,12 +315,12 @@ describe('deciding what to do with a presented host key', () => {
|
||||
// A fresh VM presents a new key every launch, so recording one would accumulate a row per
|
||||
// launch and eventually turn a stale record into a spurious mismatch.
|
||||
it('accepts an ephemeral runtime target without remembering it', () => {
|
||||
expect(decideHostKey(input({ isEphemeralRuntimeTarget: true })).action).toBe('accept')
|
||||
expect(decideHostKey(input({ policy: { kind: 'ephemeral' } })).action).toBe('accept')
|
||||
})
|
||||
|
||||
it('still rejects a changed key for an ephemeral target', () => {
|
||||
const decision = decideHostKey(
|
||||
input({ isEphemeralRuntimeTarget: true, knownHostsOutcome: 'mismatch' })
|
||||
input({ policy: { kind: 'ephemeral' }, knownHostsOutcome: 'mismatch' })
|
||||
)
|
||||
expect(decision.action).toBe('reject')
|
||||
})
|
||||
@@ -341,7 +345,7 @@ describe('deciding what to do with a presented host key', () => {
|
||||
// runtimes off for everyone whose HOME diverges from their passwd home.
|
||||
it('still accepts an ephemeral target when a source could not be read', () => {
|
||||
const decision = decideHostKey(
|
||||
input({ isEphemeralRuntimeTarget: true, siteConfigSuppressed: true })
|
||||
input({ policy: { kind: 'ephemeral' }, siteConfigSuppressed: true })
|
||||
)
|
||||
expect(decision.action).toBe('accept')
|
||||
})
|
||||
@@ -350,7 +354,7 @@ describe('deciding what to do with a presented host key', () => {
|
||||
// actually read and the user actually asked for, so it outranks the carve-out.
|
||||
it('denies an ephemeral target under an explicit StrictHostKeyChecking=yes', () => {
|
||||
const decision = decideHostKey(
|
||||
input({ isEphemeralRuntimeTarget: true, strictHostKeyChecking: 'yes' })
|
||||
input({ policy: { kind: 'ephemeral' }, strictHostKeyChecking: 'yes' })
|
||||
)
|
||||
expect(decision.action).toBe('reject')
|
||||
})
|
||||
@@ -359,7 +363,7 @@ describe('deciding what to do with a presented host key', () => {
|
||||
it('still rejects a changed key for an ephemeral target with sources incomplete', () => {
|
||||
const decision = decideHostKey(
|
||||
input({
|
||||
isEphemeralRuntimeTarget: true,
|
||||
policy: { kind: 'ephemeral' },
|
||||
siteConfigSuppressed: true,
|
||||
storeOutcome: 'mismatch'
|
||||
})
|
||||
@@ -382,7 +386,7 @@ describe('deciding what to do with a presented host key', () => {
|
||||
{ storeOutcome: 'mismatch' },
|
||||
{ strictHostKeyChecking: 'yes' },
|
||||
{ strictHostKeyChecking: 'no' },
|
||||
{ isEphemeralRuntimeTarget: true },
|
||||
{ policy: { kind: 'ephemeral' } },
|
||||
{ siteConfigSuppressed: true },
|
||||
{ knownHostsUnreadable: true }
|
||||
]
|
||||
@@ -391,3 +395,15 @@ describe('deciding what to do with a presented host key', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('hostKeyPolicyForOwner', () => {
|
||||
it('trusts first contact only for ephemeral runtime VMs', () => {
|
||||
expect(hostKeyPolicyForOwner({ type: 'on-demand-runtime', runtimeId: 'vm-1' })).toEqual({
|
||||
kind: 'ephemeral'
|
||||
})
|
||||
expect(
|
||||
hostKeyPolicyForOwner({ type: 'on-demand-runtime', runtimeId: 'managed-orcad:env-1' })
|
||||
).toEqual({ kind: 'standard' })
|
||||
expect(hostKeyPolicyForOwner(undefined)).toEqual({ kind: 'standard' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* rather than importing its sources so a test states its own trust state instead of writing files.
|
||||
* See docs/reference/ssh-host-key-verification.md.
|
||||
*/
|
||||
import { isEphemeralRuntimeSshOwner } from '../../shared/managed-orcad-ssh-owner'
|
||||
import type { SshTargetOwner } from '../../shared/ssh-types'
|
||||
import type { KnownHostsOutcome } from './config/ssh-known-hosts'
|
||||
|
||||
/** Phase 1 ships no dialog; `prompt` is reserved for Phase 2 and never produced today. */
|
||||
@@ -19,6 +21,18 @@ export type HostKeyDecision = {
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export type HostKeyPolicy =
|
||||
| { kind: 'standard' }
|
||||
/**
|
||||
* A freshly provisioned VM presents a new key every launch, so first contact is expected rather
|
||||
* than suspicious — and persisting would accumulate a record per launch.
|
||||
*/
|
||||
| { kind: 'ephemeral' }
|
||||
|
||||
export function hostKeyPolicyForOwner(owner: SshTargetOwner | undefined): HostKeyPolicy {
|
||||
return isEphemeralRuntimeSshOwner(owner) ? { kind: 'ephemeral' } : { kind: 'standard' }
|
||||
}
|
||||
|
||||
export type HostKeyDecisionInput = {
|
||||
/** From the user's known_hosts files, unioned. */
|
||||
knownHostsOutcome: KnownHostsOutcome
|
||||
@@ -26,11 +40,8 @@ export type HostKeyDecisionInput = {
|
||||
storeOutcome: 'match' | 'mismatch' | 'unknown-type-known-host' | 'unknown'
|
||||
/** Effective `StrictHostKeyChecking`; anything unrecognised is treated as `ask`. */
|
||||
strictHostKeyChecking: string
|
||||
/**
|
||||
* A freshly provisioned VM presents a new key every launch, so first contact is expected rather
|
||||
* than suspicious — and persisting would accumulate a record per launch.
|
||||
*/
|
||||
isEphemeralRuntimeTarget: boolean
|
||||
/** How this target's keys are trusted, decided by who owns the target. */
|
||||
policy: HostKeyPolicy
|
||||
/**
|
||||
* `ssh -G` ran on the HOME-divergent `-F` path, which suppresses /etc/ssh/ssh_config, so a
|
||||
* site-wide StrictHostKeyChecking may exist that we cannot see. We refuse a NEW host rather than
|
||||
@@ -137,7 +148,7 @@ export function decideHostKey(input: HostKeyDecisionInput): HostKeyDecision {
|
||||
const {
|
||||
knownHostsOutcome,
|
||||
storeOutcome,
|
||||
isEphemeralRuntimeTarget,
|
||||
policy,
|
||||
siteConfigSuppressed,
|
||||
knownHostsUnreadable,
|
||||
displayHost,
|
||||
@@ -255,7 +266,7 @@ export function decideHostKey(input: HostKeyDecisionInput): HostKeyDecision {
|
||||
// Accepting without recording is the rest of it: a new key every launch would otherwise grow a
|
||||
// row per VM, and a stale row eventually reads as a mismatch against a host that did nothing
|
||||
// wrong.
|
||||
if (isEphemeralRuntimeTarget) {
|
||||
if (policy.kind === 'ephemeral') {
|
||||
return { action: 'accept', outcome: unknownOutcome }
|
||||
}
|
||||
if (siteConfigSuppressed) {
|
||||
|
||||
@@ -21,7 +21,7 @@ function deps(overrides: Partial<HostKeyVerifierDeps> = {}): HostKeyVerifierDeps
|
||||
port: 22,
|
||||
displayHost: 'example.com',
|
||||
strictHostKeyChecking: 'ask',
|
||||
isEphemeralRuntimeTarget: false,
|
||||
policy: { kind: 'standard' },
|
||||
siteConfigSuppressed: false,
|
||||
knownHostsUnreadable: false,
|
||||
entries: [],
|
||||
@@ -121,7 +121,7 @@ describe('the ssh2 host key verifier', () => {
|
||||
it.each([
|
||||
['a key we already know', { entries: parseKnownHosts(`example.com ssh-ed25519 ${ED_A}`) }],
|
||||
['a rejected key', { entries: parseKnownHosts(`example.com ssh-ed25519 ${ED_B}`) }],
|
||||
['an ephemeral runtime target', { isEphemeralRuntimeTarget: true }],
|
||||
['an ephemeral runtime target', { policy: { kind: 'ephemeral' as const } }],
|
||||
['a lax StrictHostKeyChecking', { strictHostKeyChecking: 'no' }]
|
||||
])('does not record %s', (_label, overrides) => {
|
||||
const rememberHostKey = vi.fn()
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type KnownHostsEntry,
|
||||
type KnownHostsOutcome
|
||||
} from './config/ssh-known-hosts'
|
||||
import { decideHostKey, type HostKeyDecision } from './ssh-host-key-decision'
|
||||
import { decideHostKey, type HostKeyDecision, type HostKeyPolicy } from './ssh-host-key-decision'
|
||||
|
||||
export type TrustedHostKeyLookup = (query: {
|
||||
host: string
|
||||
@@ -31,7 +31,7 @@ export type HostKeyVerifierDeps = {
|
||||
/** True when `host` came from HostKeyAlias; ssh looks those up without the port. */
|
||||
isHostKeyAlias?: boolean
|
||||
strictHostKeyChecking: string
|
||||
isEphemeralRuntimeTarget: boolean
|
||||
policy: HostKeyPolicy
|
||||
siteConfigSuppressed: boolean
|
||||
knownHostsUnreadable: boolean
|
||||
/** Already unioned across every known_hosts file. */
|
||||
@@ -222,7 +222,7 @@ export function createHostKeyVerifier(
|
||||
}),
|
||||
storeOutcome: deps.isTrusted({ host: deps.host, port: deps.port, keyType, key }),
|
||||
strictHostKeyChecking: deps.strictHostKeyChecking,
|
||||
isEphemeralRuntimeTarget: deps.isEphemeralRuntimeTarget,
|
||||
policy: deps.policy,
|
||||
siteConfigSuppressed: deps.siteConfigSuppressed,
|
||||
knownHostsUnreadable: deps.knownHostsUnreadable,
|
||||
displayHost: deps.displayHost,
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
} from '../components/terminal/terminal-provider-snapshot-capability'
|
||||
import {
|
||||
getRepoExecutionHostId,
|
||||
isRuntimeOwnedSshTargetId,
|
||||
isOwnedSshTargetId,
|
||||
parseExecutionHostId,
|
||||
toRuntimeExecutionHostId,
|
||||
type ExecutionHostId
|
||||
@@ -242,7 +242,7 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta
|
||||
// Why: re-establish SSH before terminal reconnect so SSH-backed tabs route through pty.attach; passphrase targets defer to tab focus to avoid stacked credential dialogs.
|
||||
// Why: never dial runtime-owned (ephemeral-VM) targets from the renderer — ssh.connect would dispose the runtime layer's live relay session; filter them out here too.
|
||||
const connectionIds = (sessionRead.session.activeConnectionIdsAtShutdown ?? []).filter(
|
||||
(targetId) => !isRuntimeOwnedSshTargetId(targetId)
|
||||
(targetId) => !isOwnedSshTargetId(targetId)
|
||||
)
|
||||
if (connectionIds.length > 0) {
|
||||
try {
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
StableAutomationAuthorityRef,
|
||||
StableAutomationCatalogRef
|
||||
} from '../../../../shared/automation-owner-ref'
|
||||
import { isRuntimeOwnedSshTargetId } from '../../../../shared/execution-host'
|
||||
import { isOwnedSshTargetId } from '../../../../shared/execution-host'
|
||||
import {
|
||||
automationAuthorityCatalogKey,
|
||||
type AutomationCatalogSshMirrorInput,
|
||||
@@ -164,7 +164,7 @@ function resolveSshScopeGap(
|
||||
function projectSshEntries(ctx: AuthorityProjectionContext): AutomationHostCatalogEntry[] {
|
||||
const liveTargets = new Map(
|
||||
ctx.ssh.targets
|
||||
.filter((target) => !isRuntimeOwnedSshTargetId(target.targetId))
|
||||
.filter((target) => !isOwnedSshTargetId(target.targetId))
|
||||
.map((target) => [target.targetId, target])
|
||||
)
|
||||
const candidateIds = new Set<string>(liveTargets.keys())
|
||||
@@ -176,7 +176,7 @@ function projectSshEntries(ctx: AuthorityProjectionContext): AutomationHostCatal
|
||||
}
|
||||
const entries: AutomationHostCatalogEntry[] = []
|
||||
for (const targetId of candidateIds) {
|
||||
if (isRuntimeOwnedSshTargetId(targetId)) {
|
||||
if (isOwnedSshTargetId(targetId)) {
|
||||
continue
|
||||
}
|
||||
const live = liveTargets.get(targetId)
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { SshConnectionStatus } from '../../../../shared/ssh-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides'
|
||||
import {
|
||||
isRuntimeOwnedSshTargetId,
|
||||
isOwnedSshTargetId,
|
||||
toRuntimeExecutionHostId,
|
||||
toSshExecutionHostId
|
||||
} from '../../../../shared/execution-host'
|
||||
@@ -79,7 +79,7 @@ export function SshStatusSegment({
|
||||
const targets = Array.from(sshTargetLabels.entries())
|
||||
// Why: runtime-owned (per-workspace-env) SSH targets are hidden — never list them
|
||||
// as a user-facing SSH host in the status bar.
|
||||
.filter(([id]) => !isRuntimeOwnedSshTargetId(id) && visibleHosts.has(toSshExecutionHostId(id)))
|
||||
.filter(([id]) => !isOwnedSshTargetId(id) && visibleHosts.has(toSshExecutionHostId(id)))
|
||||
.map(([id, label]) => {
|
||||
const state = sshConnectionStates.get(id)
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { toAppSshPtyId } from '../../../shared/ssh-pty-id'
|
||||
import type { SshConnectionState } from '../../../shared/ssh-types'
|
||||
import type { WorkspaceSessionSnapshot } from './workspace-session'
|
||||
import { buildActiveConnectionIdsAtShutdown } from './workspace-session-reconnect-targets'
|
||||
|
||||
function connected(targetId: string): [string, SshConnectionState] {
|
||||
return [targetId, { targetId, status: 'connected', error: null, reconnectAttempt: 0 }]
|
||||
}
|
||||
|
||||
describe('buildActiveConnectionIdsAtShutdown', () => {
|
||||
it('never asks startup to dial an Orca-owned target', () => {
|
||||
const owned = ['runtime-ssh-vm-1', 'machine-0123']
|
||||
const snapshot = {
|
||||
sshConnectionStates: new Map([connected('ssh-user'), ...owned.map(connected)])
|
||||
}
|
||||
const remoteSessions = Object.fromEntries(
|
||||
owned.map((id, index) => [`tab-${index}`, toAppSshPtyId(id, 'pty-1')])
|
||||
)
|
||||
expect(
|
||||
buildActiveConnectionIdsAtShutdown(
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the builder reads only sshConnectionStates.
|
||||
snapshot as WorkspaceSessionSnapshot,
|
||||
remoteSessions
|
||||
)
|
||||
).toEqual(['ssh-user'])
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
|
||||
import { parseAppSshPtyId } from '../../../shared/ssh-pty-id'
|
||||
import { isRuntimeOwnedSshTargetId } from '../../../shared/execution-host'
|
||||
import { isOwnedSshTargetId } from '../../../shared/execution-host'
|
||||
import type { WorkspaceSessionSnapshot } from './workspace-session'
|
||||
|
||||
export function buildActiveConnectionIdsAtShutdown(
|
||||
@@ -16,9 +16,7 @@ export function buildActiveConnectionIdsAtShutdown(
|
||||
// pane-level optimistic write could stamp one; exclude them here too.
|
||||
const targetIds = new Set(
|
||||
Array.from(snapshot.sshConnectionStates.entries())
|
||||
.filter(
|
||||
([targetId, state]) => state.status === 'connected' && !isRuntimeOwnedSshTargetId(targetId)
|
||||
)
|
||||
.filter(([targetId, state]) => state.status === 'connected' && !isOwnedSshTargetId(targetId))
|
||||
.map(([targetId]) => targetId)
|
||||
)
|
||||
|
||||
@@ -34,7 +32,7 @@ export function buildActiveConnectionIdsAtShutdown(
|
||||
// renderer-driven ssh.connect would dispose the runtime's live relay session.
|
||||
for (const sessionId of Object.values(remoteSessionIdsByTabId ?? {})) {
|
||||
const connectionId = parseAppSshPtyId(sessionId)?.connectionId
|
||||
if (!connectionId || isRuntimeOwnedSshTargetId(connectionId)) {
|
||||
if (!connectionId || isOwnedSshTargetId(connectionId)) {
|
||||
continue
|
||||
}
|
||||
const status = snapshot.sshConnectionStates.get(connectionId)?.status
|
||||
|
||||
@@ -92,6 +92,15 @@ describe('execution host registry', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('hides machine-provider SSH targets the same way', () => {
|
||||
const hosts = buildExecutionHostRegistry({
|
||||
repos: [{ connectionId: 'machine-0123', executionHostId: 'ssh:machine-0123' }],
|
||||
settings: { activeRuntimeEnvironmentId: null },
|
||||
sshTargetLabels: new Map([['machine-0123', 'omarchy-dev']])
|
||||
})
|
||||
expect(hosts.some((h) => h.id.includes('machine-0123'))).toBe(false)
|
||||
})
|
||||
|
||||
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.
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
getRepoExecutionHostId,
|
||||
getSettingsFocusedExecutionHostId,
|
||||
getSshTargetIdForExecutionHost,
|
||||
isRuntimeOwnedSshTargetId,
|
||||
isOwnedSshTargetId,
|
||||
normalizeHostPart,
|
||||
parseExecutionHostId,
|
||||
toRuntimeExecutionHostId,
|
||||
@@ -259,14 +259,14 @@ export function buildExecutionHostRegistry(args: {
|
||||
}
|
||||
// Why: a VM-backed repo's executionHostId is `ssh:runtime-ssh-<id>`. Runtime-owned
|
||||
// targets are hidden, so they must not become visible SSH run-target hosts here.
|
||||
if (parsedHost?.kind === 'ssh' && !isRuntimeOwnedSshTargetId(parsedHost.targetId)) {
|
||||
if (parsedHost?.kind === 'ssh' && !isOwnedSshTargetId(parsedHost.targetId)) {
|
||||
sshTargetIds.add(parsedHost.targetId)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const targetId of args.sshTargetLabels?.keys() ?? []) {
|
||||
const normalized = normalizeHostPart(targetId)
|
||||
if (normalized && !isRuntimeOwnedSshTargetId(normalized)) {
|
||||
if (normalized && !isOwnedSshTargetId(normalized)) {
|
||||
sshTargetIds.add(normalized)
|
||||
}
|
||||
}
|
||||
@@ -275,7 +275,7 @@ export function buildExecutionHostRegistry(args: {
|
||||
// Why the host, not `connectionId`: a paired-server repo's `connectionId` is the server's
|
||||
// own SSH target, which this client cannot dial.
|
||||
const targetId = getSshTargetIdForExecutionHost(getRepoExecutionHostId(repo))
|
||||
if (targetId && !isRuntimeOwnedSshTargetId(targetId)) {
|
||||
if (targetId && !isOwnedSshTargetId(targetId)) {
|
||||
sshTargetIds.add(targetId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
getSettingsFocusedExecutionHostId,
|
||||
getSshTargetIdForExecutionHost,
|
||||
getWorktreeExecutionHostId,
|
||||
isOwnedSshTargetId,
|
||||
isRepoOnCatalogHost,
|
||||
isRuntimeOwnedSshTargetId,
|
||||
isUnresolvedOwnerHostId,
|
||||
normalizeExecutionHostOrder,
|
||||
normalizeExecutionHostScope,
|
||||
@@ -218,3 +220,14 @@ describe('isRepoOnCatalogHost', () => {
|
||||
expect(isRepoOnCatalogHost({ executionHostId: 'future:host' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('owned SSH target ids', () => {
|
||||
it('hides recipe-VM and machine targets but keeps the recipe-VM meaning narrow', () => {
|
||||
expect(isOwnedSshTargetId('runtime-ssh-vm-1')).toBe(true)
|
||||
expect(isOwnedSshTargetId('machine-0123456789abcdef0123')).toBe(true)
|
||||
expect(isOwnedSshTargetId('ssh-1792393612859-abc123')).toBe(false)
|
||||
expect(isOwnedSshTargetId(null)).toBe(false)
|
||||
// Only recipe VMs carry "replaced every provision" semantics.
|
||||
expect(isRuntimeOwnedSshTargetId('machine-0123456789abcdef0123')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -77,12 +77,28 @@ export function toRuntimeExecutionHostId(environmentId: string): `runtime:${stri
|
||||
// SSH/run-target surfaces. The renderer can't read the target.owner field, so it
|
||||
// recognizes them by their deterministic id prefix. getRuntimeOwnedSshTargetId
|
||||
// (main) builds on this same prefix to keep the two in sync.
|
||||
// The prefix also means "address and filesystem are replaced every provision", so only
|
||||
// on-demand-runtime owners may use it; main decides hidden-ness from `owner` alone.
|
||||
export const RUNTIME_OWNED_SSH_TARGET_ID_PREFIX = 'runtime-ssh-'
|
||||
/** Machine-provider targets: owned like recipe VMs, but persistent, so not `runtime-ssh-`. */
|
||||
export const MACHINE_SSH_TARGET_ID_PREFIX = 'machine-'
|
||||
|
||||
/** A recipe VM's target, whose address and filesystem are replaced every provision. */
|
||||
export function isRuntimeOwnedSshTargetId(targetId: string | null | undefined): boolean {
|
||||
return typeof targetId === 'string' && targetId.startsWith(RUNTIME_OWNED_SSH_TARGET_ID_PREFIX)
|
||||
}
|
||||
|
||||
/**
|
||||
* Any target Orca owns as plumbing, by id because the renderer never sees `owner`: hidden from
|
||||
* SSH-host surfaces and never dialed by the renderer, which would dispose its owner's session.
|
||||
*/
|
||||
export function isOwnedSshTargetId(targetId: string | null | undefined): boolean {
|
||||
return (
|
||||
isRuntimeOwnedSshTargetId(targetId) ||
|
||||
(typeof targetId === 'string' && targetId.startsWith(MACHINE_SSH_TARGET_ID_PREFIX))
|
||||
)
|
||||
}
|
||||
|
||||
export function parseExecutionHostId(value: string | null | undefined): ParsedExecutionHost | null {
|
||||
const normalized = normalizeHostPart(value)
|
||||
if (!normalized) {
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import type { SshTarget } from './ssh-types'
|
||||
import type { SshTarget, SshTargetOwner } from './ssh-types'
|
||||
|
||||
/** The runtime id phase-3 builds wrote into `owner` before the fence moved to `orcadFence`. */
|
||||
const LEGACY_MANAGED_ORCAD_RUNTIME_ID_PREFIX = 'managed-orcad:'
|
||||
|
||||
type SshTargetOwner = NonNullable<SshTarget['owner']>
|
||||
|
||||
/** The managed Orca server this host serves, or null for a direct SSH host. */
|
||||
export function getManagedOrcadFenceEnvironmentId(
|
||||
target: Pick<SshTarget, 'orcadFence'> | undefined
|
||||
|
||||
@@ -34,12 +34,15 @@ export type SshRemoteRuntimeResolution = {
|
||||
orcaMajor: number
|
||||
}
|
||||
|
||||
/** Who created a hidden target as an implementation detail, and so owns its lifecycle. */
|
||||
export type SshTargetOwner = { type: 'on-demand-runtime'; runtimeId: string }
|
||||
|
||||
export type SshTarget = {
|
||||
id: string
|
||||
label: string
|
||||
/** Internal owner for targets that Orca creates as implementation details.
|
||||
* Owned targets are hidden from normal SSH-host management surfaces. */
|
||||
owner?: { type: 'on-demand-runtime'; runtimeId: string }
|
||||
owner?: SshTargetOwner
|
||||
/** Host alias to resolve through OpenSSH config (ssh -G). */
|
||||
configHost?: string
|
||||
host: string
|
||||
|
||||
Reference in New Issue
Block a user