diff --git a/src/main/ipc/ssh/ssh-renderer-broadcast.ts b/src/main/ipc/ssh/ssh-renderer-broadcast.ts index c25733dba74..1caa3951ce4 100644 --- a/src/main/ipc/ssh/ssh-renderer-broadcast.ts +++ b/src/main/ipc/ssh/ssh-renderer-broadcast.ts @@ -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 } diff --git a/src/main/ssh/connection/ssh-connection-store.ts b/src/main/ssh/connection/ssh-connection-store.ts index fa5bd340041..36cba4bd26a 100644 --- a/src/main/ssh/connection/ssh-connection-store.ts +++ b/src/main/ssh/connection/ssh-connection-store.ts @@ -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 } diff --git a/src/main/ssh/connection/ssh-connection.ts b/src/main/ssh/connection/ssh-connection.ts index 5532e128c52..787475f3853 100644 --- a/src/main/ssh/connection/ssh-connection.ts +++ b/src/main/ssh/connection/ssh-connection.ts @@ -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 diff --git a/src/main/ssh/ssh-host-key-decision.test.ts b/src/main/ssh/ssh-host-key-decision.test.ts index 1724b420584..a5ae32d9d87 100644 --- a/src/main/ssh/ssh-host-key-decision.test.ts +++ b/src/main/ssh/ssh-host-key-decision.test.ts @@ -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 { 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' }) + }) +}) diff --git a/src/main/ssh/ssh-host-key-decision.ts b/src/main/ssh/ssh-host-key-decision.ts index 02707dcade9..e56c83ff34e 100644 --- a/src/main/ssh/ssh-host-key-decision.ts +++ b/src/main/ssh/ssh-host-key-decision.ts @@ -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) { diff --git a/src/main/ssh/ssh-host-key-verifier.test.ts b/src/main/ssh/ssh-host-key-verifier.test.ts index 01cb2a62296..e5d66189eab 100644 --- a/src/main/ssh/ssh-host-key-verifier.test.ts +++ b/src/main/ssh/ssh-host-key-verifier.test.ts @@ -21,7 +21,7 @@ function deps(overrides: Partial = {}): 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() diff --git a/src/main/ssh/ssh-host-key-verifier.ts b/src/main/ssh/ssh-host-key-verifier.ts index 65da407ee84..9fcd91ba481 100644 --- a/src/main/ssh/ssh-host-key-verifier.ts +++ b/src/main/ssh/ssh-host-key-verifier.ts @@ -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, diff --git a/src/renderer/src/app-shell/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts index b6189e3809a..db106bcda5f 100644 --- a/src/renderer/src/app-shell/use-app-startup-hydration.ts +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -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 { diff --git a/src/renderer/src/components/automations/automation-host-catalog.ts b/src/renderer/src/components/automations/automation-host-catalog.ts index 71e445e546c..94892971bb1 100644 --- a/src/renderer/src/components/automations/automation-host-catalog.ts +++ b/src/renderer/src/components/automations/automation-host-catalog.ts @@ -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(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) diff --git a/src/renderer/src/components/status-bar/SshStatusSegment.tsx b/src/renderer/src/components/status-bar/SshStatusSegment.tsx index 23c71fc4bf5..c324331c357 100644 --- a/src/renderer/src/components/status-bar/SshStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/SshStatusSegment.tsx @@ -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 { diff --git a/src/renderer/src/lib/workspace-session-reconnect-targets.test.ts b/src/renderer/src/lib/workspace-session-reconnect-targets.test.ts new file mode 100644 index 00000000000..dc7519f9324 --- /dev/null +++ b/src/renderer/src/lib/workspace-session-reconnect-targets.test.ts @@ -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']) + }) +}) diff --git a/src/renderer/src/lib/workspace-session-reconnect-targets.ts b/src/renderer/src/lib/workspace-session-reconnect-targets.ts index 1dc2ae717e1..e7baec33c81 100644 --- a/src/renderer/src/lib/workspace-session-reconnect-targets.ts +++ b/src/renderer/src/lib/workspace-session-reconnect-targets.ts @@ -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 diff --git a/src/shared/execution-host-registry.test.ts b/src/shared/execution-host-registry.test.ts index 86f01093e0c..001e52feee3 100644 --- a/src/shared/execution-host-registry.test.ts +++ b/src/shared/execution-host-registry.test.ts @@ -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. diff --git a/src/shared/execution-host-registry.ts b/src/shared/execution-host-registry.ts index b526c2e1100..058618d76ae 100644 --- a/src/shared/execution-host-registry.ts +++ b/src/shared/execution-host-registry.ts @@ -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-`. 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) } } diff --git a/src/shared/execution-host.test.ts b/src/shared/execution-host.test.ts index e86bccbecbd..8e3fd83a89a 100644 --- a/src/shared/execution-host.test.ts +++ b/src/shared/execution-host.test.ts @@ -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) + }) +}) diff --git a/src/shared/execution-host.ts b/src/shared/execution-host.ts index 604271226a5..47e9d867720 100644 --- a/src/shared/execution-host.ts +++ b/src/shared/execution-host.ts @@ -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) { diff --git a/src/shared/managed-orcad-ssh-owner.ts b/src/shared/managed-orcad-ssh-owner.ts index 9f220376f35..7964e813f29 100644 --- a/src/shared/managed-orcad-ssh-owner.ts +++ b/src/shared/managed-orcad-ssh-owner.ts @@ -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 - /** The managed Orca server this host serves, or null for a direct SSH host. */ export function getManagedOrcadFenceEnvironmentId( target: Pick | undefined diff --git a/src/shared/ssh-types.ts b/src/shared/ssh-types.ts index 6e5504e256c..b8db238a0c4 100644 --- a/src/shared/ssh-types.ts +++ b/src/shared/ssh-types.ts @@ -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