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 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.
326 lines
13 KiB
TypeScript
326 lines
13 KiB
TypeScript
import type { GlobalSettings } from './global-settings-types'
|
|
import type { Repo } from './repo-types'
|
|
import type { Worktree } from './worktree/types'
|
|
|
|
export const LOCAL_EXECUTION_HOST_ID = 'local'
|
|
export const ALL_EXECUTION_HOSTS_SCOPE = 'all'
|
|
|
|
export type ExecutionHostKind = 'local' | 'ssh' | 'runtime'
|
|
export type ExecutionHostId = typeof LOCAL_EXECUTION_HOST_ID | `ssh:${string}` | `runtime:${string}`
|
|
|
|
export type ExecutionHostScope = typeof ALL_EXECUTION_HOSTS_SCOPE | ExecutionHostId
|
|
|
|
/** Whether work at this location runs natively on this machine, in a folder this process can read. */
|
|
export function isNativeLocalLocation(location: {
|
|
executionHostId: string
|
|
wslDistro: string | null
|
|
}): boolean {
|
|
return location.executionHostId === LOCAL_EXECUTION_HOST_ID && location.wslDistro === null
|
|
}
|
|
|
|
export type ParsedExecutionHost =
|
|
| { kind: 'local'; id: typeof LOCAL_EXECUTION_HOST_ID }
|
|
| { kind: 'ssh'; id: `ssh:${string}`; targetId: string }
|
|
| { kind: 'runtime'; id: `runtime:${string}`; environmentId: string }
|
|
|
|
function getCurrentLocalPlatform(): NodeJS.Platform | null {
|
|
const globalNavigator = (globalThis as { navigator?: { userAgent?: string; platform?: string } })
|
|
.navigator
|
|
const userAgent = globalNavigator?.userAgent || globalNavigator?.platform || ''
|
|
if (/Windows/i.test(userAgent)) {
|
|
return 'win32'
|
|
}
|
|
if (/Mac/i.test(userAgent)) {
|
|
return 'darwin'
|
|
}
|
|
if (/Linux|X11/i.test(userAgent)) {
|
|
return 'linux'
|
|
}
|
|
return typeof process === 'undefined' ? null : process.platform
|
|
}
|
|
|
|
export function getLocalExecutionHostLabel(platform: NodeJS.Platform | null = null): string {
|
|
const localPlatform = platform ?? getCurrentLocalPlatform()
|
|
if (localPlatform === 'darwin') {
|
|
return 'Local Mac'
|
|
}
|
|
if (localPlatform === 'win32') {
|
|
return 'Local Windows'
|
|
}
|
|
if (localPlatform === 'linux') {
|
|
return 'Local Linux'
|
|
}
|
|
return 'This computer'
|
|
}
|
|
|
|
/** Trims a stored id; blank is absent, since no write path normalizes it. */
|
|
export function normalizeHostPart(value: string | null | undefined): string | null {
|
|
return value?.trim() || null
|
|
}
|
|
|
|
export function toSshExecutionHostId(targetId: string): `ssh:${string}` {
|
|
return `ssh:${encodeURIComponent(targetId)}`
|
|
}
|
|
|
|
/** Legacy `connectionId` spelling: absent means this machine, present means that SSH target. */
|
|
export function getConnectionExecutionHostId(
|
|
connectionId: string | null | undefined
|
|
): ExecutionHostId {
|
|
return connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID
|
|
}
|
|
|
|
export function toRuntimeExecutionHostId(environmentId: string): `runtime:${string}` {
|
|
return `runtime:${encodeURIComponent(environmentId)}`
|
|
}
|
|
|
|
// Why: runtime-owned (ephemeral-VM) SSH targets are hidden from user-facing
|
|
// 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) {
|
|
return null
|
|
}
|
|
if (normalized === LOCAL_EXECUTION_HOST_ID) {
|
|
return { kind: 'local', id: LOCAL_EXECUTION_HOST_ID }
|
|
}
|
|
if (normalized.startsWith('ssh:')) {
|
|
const encoded = normalized.slice('ssh:'.length)
|
|
if (!encoded) {
|
|
return null
|
|
}
|
|
// `|` must stay out of a host id: composeWorktreeHostIdentity uses it as its delimiter and
|
|
// splits at the first one, so an unencoded pipe would rebind an alias to a different host.
|
|
if (encoded.includes('|')) {
|
|
return null
|
|
}
|
|
try {
|
|
const targetId = decodeURIComponent(encoded)
|
|
return targetId ? { kind: 'ssh', id: `ssh:${encoded}`, targetId } : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
if (normalized.startsWith('runtime:')) {
|
|
const encoded = normalized.slice('runtime:'.length)
|
|
if (!encoded) {
|
|
return null
|
|
}
|
|
if (encoded.includes('|')) {
|
|
return null
|
|
}
|
|
try {
|
|
const environmentId = decodeURIComponent(encoded)
|
|
return environmentId ? { kind: 'runtime', id: `runtime:${encoded}`, environmentId } : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
/** Whether a host id names another machine; an absent or unparsable id reads as this one. */
|
|
export function isLocalExecutionHost(value: string | null | undefined): boolean {
|
|
return parseExecutionHostId(value)?.kind === 'local'
|
|
}
|
|
|
|
export function isRemoteExecutionHostId(value: string | null | undefined): boolean {
|
|
return (parseExecutionHostId(value)?.kind ?? 'local') !== 'local'
|
|
}
|
|
|
|
/**
|
|
* Bookkeeping for a worktree whose owner publications conflict. It is spelled as a runtime host so
|
|
* nothing treats the worktree as local, but no environment has this id and it must never be dialed.
|
|
*/
|
|
export const UNRESOLVED_OWNER_HOST_ID = 'runtime:unresolved-owner' satisfies ExecutionHostId
|
|
|
|
export function isUnresolvedOwnerHostId(value: string | null | undefined): boolean {
|
|
return parseExecutionHostId(value)?.id === UNRESOLVED_OWNER_HOST_ID
|
|
}
|
|
|
|
/** {@link parseExecutionHostId} for transports: the unresolved-owner sentinel is not a host. */
|
|
export function parseRoutableExecutionHostId(
|
|
value: string | null | undefined
|
|
): ParsedExecutionHost | null {
|
|
const parsed = parseExecutionHostId(value)
|
|
return parsed?.id === UNRESOLVED_OWNER_HOST_ID ? null : parsed
|
|
}
|
|
|
|
export function normalizeExecutionHostId(value: string | null | undefined): ExecutionHostId | null {
|
|
return parseExecutionHostId(value)?.id ?? null
|
|
}
|
|
|
|
export function normalizeExecutionHostScope(value: string | null | undefined): ExecutionHostScope {
|
|
const normalized = normalizeHostPart(value)
|
|
if (!normalized || normalized === ALL_EXECUTION_HOSTS_SCOPE) {
|
|
return ALL_EXECUTION_HOSTS_SCOPE
|
|
}
|
|
return normalizeExecutionHostId(normalized) ?? ALL_EXECUTION_HOSTS_SCOPE
|
|
}
|
|
|
|
// An omitted scope on a request means this host, not a fan-out. Callers and the
|
|
// renderer share this so both agree on which requests answer with a merge.
|
|
export function requestedExecutionHostScope(value: string | null | undefined): ExecutionHostScope {
|
|
return normalizeExecutionHostScope(value ?? LOCAL_EXECUTION_HOST_ID)
|
|
}
|
|
|
|
export function normalizeVisibleExecutionHostIds(
|
|
value: readonly string[] | null | undefined
|
|
): ExecutionHostId[] | null {
|
|
if (!Array.isArray(value)) {
|
|
return null
|
|
}
|
|
const ids: ExecutionHostId[] = []
|
|
const seen = new Set<ExecutionHostId>()
|
|
for (const raw of value) {
|
|
const id = normalizeExecutionHostId(raw)
|
|
if (!id || seen.has(id)) {
|
|
continue
|
|
}
|
|
seen.add(id)
|
|
ids.push(id)
|
|
}
|
|
return ids.length > 0 ? ids : null
|
|
}
|
|
|
|
export function normalizeExecutionHostOrder(
|
|
value: readonly string[] | null | undefined
|
|
): ExecutionHostId[] {
|
|
const normalized = normalizeVisibleExecutionHostIds(value)
|
|
return normalized ?? []
|
|
}
|
|
|
|
// Why the loose member types: a reply reader hands these through as the strings they are on the
|
|
// wire, and this function is already the thing that decides what an unparseable spelling means.
|
|
export function getRepoExecutionHostId(repo: {
|
|
connectionId?: string | null
|
|
executionHostId?: string | null
|
|
}): ExecutionHostId {
|
|
const executionHostId = normalizeExecutionHostId(repo.executionHostId)
|
|
if (executionHostId) {
|
|
return executionHostId
|
|
}
|
|
return getConnectionExecutionHostId(normalizeHostPart(repo.connectionId))
|
|
}
|
|
|
|
/**
|
|
* Whether a row's files are on the machine whose catalog holds it. Strict: an unknown host id or a
|
|
* stale `connectionId` beside `local` reads as elsewhere, so it never matches a local checkout.
|
|
*/
|
|
export function isRepoOnCatalogHost(repo: {
|
|
connectionId?: string | null
|
|
executionHostId?: string | null
|
|
}): boolean {
|
|
const executionHostId = normalizeHostPart(repo.executionHostId)
|
|
return (
|
|
(executionHostId === null || executionHostId === LOCAL_EXECUTION_HOST_ID) &&
|
|
normalizeHostPart(repo.connectionId) === null
|
|
)
|
|
}
|
|
|
|
export function getSshTargetIdForExecutionHost(
|
|
executionHostId: string | null | undefined
|
|
): string | null {
|
|
const parsed = parseExecutionHostId(executionHostId)
|
|
return parsed?.kind === 'ssh' ? parsed.targetId : null
|
|
}
|
|
|
|
// Why: SSH ownership has two spellings on a repo row — the legacy `connectionId`
|
|
// field and the unified `executionHostId`. Routing that reads the raw field answers
|
|
// "local" for a row that only carries `ssh:<target>`, which runs a remote operation
|
|
// on the client. Resolve the host first, then read the connection off it.
|
|
//
|
|
// The two hosts that are not themselves SSH are not the same case:
|
|
//
|
|
// - `local` has no SSH namespace to nest in, so a surviving `connectionId` is a row
|
|
// contradicting itself — the shape main's `resolveRepoOwnershipEvidence` calls
|
|
// `contradictory`. Answering with it hands out an SSH connection for a row that declares
|
|
// itself local.
|
|
// - `runtime:<env>` is a different machine with its own SSH targets, and a nested one appears
|
|
// only in this field (`adoptFromEndpoint` spreads it through). It is not dialable on its
|
|
// own, but it is addressable as the pair (environmentId, targetId) — which is how the
|
|
// renderer reads it, recovering the environment from the worktree and looking the target up
|
|
// inside it (`selectRuntimeAwareSshStatus`). Dropping it makes a nested-SSH workspace read
|
|
// as local, which is what decides whether a transcript is read on this client.
|
|
//
|
|
// So this answers "which SSH target holds this row's files", not "which connection may this
|
|
// client dial". `getSshTargetIdForExecutionHost` answers the latter; callers routing a
|
|
// client-local PTY or Git provider want that one instead.
|
|
export function getRepoSshConnectionId(
|
|
repo: Pick<Repo, 'connectionId' | 'executionHostId'>
|
|
): string | null {
|
|
const host = parseExecutionHostId(getRepoExecutionHostId(repo))
|
|
if (host?.kind === 'ssh') {
|
|
return host.targetId
|
|
}
|
|
return host?.kind === 'runtime' ? normalizeHostPart(repo.connectionId) : null
|
|
}
|
|
|
|
export function getWorktreeExecutionHostId(
|
|
worktree: Pick<Worktree, 'hostId'>,
|
|
repo: Pick<Repo, 'connectionId' | 'executionHostId'> | undefined,
|
|
defaultHostId: ExecutionHostId = LOCAL_EXECUTION_HOST_ID
|
|
): ExecutionHostId {
|
|
// Why: runtime and SSH snapshots can identify a more precise owner than
|
|
// the repo fallback; every sidebar host decision must use the same precedence.
|
|
return (
|
|
worktree.hostId ??
|
|
(repo?.connectionId || repo?.executionHostId ? getRepoExecutionHostId(repo) : defaultHostId)
|
|
)
|
|
}
|
|
|
|
export function getSettingsFocusedExecutionHostId(
|
|
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
|
|
): ExecutionHostId {
|
|
const runtimeEnvironmentId = normalizeHostPart(settings?.activeRuntimeEnvironmentId)
|
|
return runtimeEnvironmentId
|
|
? toRuntimeExecutionHostId(runtimeEnvironmentId)
|
|
: LOCAL_EXECUTION_HOST_ID
|
|
}
|
|
|
|
export function getExecutionHostLabel(id: ExecutionHostScope | null | undefined): string {
|
|
if (id === ALL_EXECUTION_HOSTS_SCOPE) {
|
|
return 'All hosts'
|
|
}
|
|
const parsed = parseExecutionHostId(id)
|
|
if (!parsed) {
|
|
// Not "All hosts": an id that names no host is one *unknown* host, and answering with the
|
|
// everything-scope label shows an unroutable row as though it were on every host.
|
|
// Plain English like every other label in this module (`Local Mac`, `This computer`,
|
|
// `All hosts`) — none of them resolve through the renderer's i18n catalog, so a lone
|
|
// translated string here would read inconsistently.
|
|
return 'Unknown host'
|
|
}
|
|
switch (parsed.kind) {
|
|
case 'local':
|
|
return getLocalExecutionHostLabel()
|
|
case 'ssh':
|
|
return parsed.targetId
|
|
case 'runtime':
|
|
return parsed.environmentId
|
|
}
|
|
}
|