Files
orca/src/shared/runtime-environments.ts
T
Neil 9f2abd5e70 feat(wsl): Add WSL distro host UI and status (WSL-via-orcad C7b, experimental) (#27934)
* feat(wsl): Add WSL distro host UI, host row status and fake-transport e2e (WSL-via-orcad C7b)

Experimental toggle (Windows only), an Add WSL distro choice beside SSH host and remote server,
the WSL host's own row in host pickers with its liveness state, a Connect action that starts a
stopped distro only on request, a WSL hosts list in Settings, and the renderer report of which
WSL hosts an open project or terminal needs.

* feat(wsl): show WSL hosts as turned off while the experiment is off, and confirm their removal

With Experimental → WSL distros as hosts off, a WSL host keeps its row so its projects never
seem to vanish, says it is turned off, and offers Turn on (opening that setting) instead of
Connect; Settings lists it the same way with Connect disabled. Removing a WSL host now asks
first in the dialog SSH hosts use, with WSL copy. The e2e covers both.

* fix(wsl): skip the WSL host status bridge when the preload lacks it

* fix(wsl): show when this build cannot run WSL hosts instead of failing an add

The UI reads main's template capability once. Without it, the add dialog says this build can't
run WSL hosts and disables Add, existing hosts' rows and Settings say the same with Connect
disabled, and the Experimental toggle notes it. The e2e covers a fake build without the files.

* fix(wsl): re-read the WSL distro list when the add dialog's window returns, and on Refresh

The add dialog read the registered distros once, so one imported while it was open never
appeared. It now re-reads when the window regains focus or becomes visible, and from an explicit
Refresh button, never on a timer. The selection is derived from the current list, so a refresh
cannot leave a stale pick. The e2e imports a distro mid-dialog.

* fix(wsl): list WSL hosts only as WSL hosts, not among the generic remote servers

Settings → Remote Orca Servers, the server-update checks and the status bar's server list now
leave out environments whose deployment record is a WSL host (by kind, never by name). The WSL
hosts section reads them from the store itself.

* fix(wsl): say a WSL host's removal ends its terminals, and report what it left running

The confirmation now says removing ends the host's open terminals as well as Orca's server, and
a removal main could not complete in the distro (a terminal or the daemon kept running) is
reported instead of passing silently.

* fix(wsl): ship the removal copy that says a WSL host's terminals end

The build resolves copy from the English catalog, which still held the old text under the
reused key, so the new inline default never reached the dialog. The copy gets its own key, the
stale entry is dropped from both catalogs, and a test reads the shipped catalog.

* fix(wsl): leave a removed host's workspace, keep its removal warning up, and prove removal in e2e

After removal the active workspace is left when it lived on that host, so nothing keeps calling a
host that no longer exists, and a warning about what still runs in the distro stays until
dismissed. The e2e now checks that removal ends the host's terminals on the server standing in
for orcad, shows no warning, leaves the workspace, and starts nothing for the host afterwards.

* test(wsl): read the shipped removal copy and the fake's connect log without reflection

* fix(wsl): say a WSL host is turned off in its terminal instead of 'Reconnecting'

With the experiment off (or without server files) nothing retries, so a WSL host's terminal now
says so, with Turn on, rather than claiming Orca is reconnecting.

* test(wsl): reproduce project-removal survivors and orphan terminals in e2e

The headless server standing in for orcad now carries a project whose terminal must end when the
project is removed (and leave the host's own catalog), and an orphan terminal whose project the
host already forgot, which removing the host must still end. Both checks fail without the fixes.

* fix(wsl): drop the IPC wrapper from file-explorer and editor load errors; offer Turn on when WSL hosts are off

The file explorer showed "Error invoking remote method 'runtimeEnvironments:call': Error: ..." for a
WSL workspace whose host was turned off. Explorer read errors now go through the shared IPC-error
unwrapping, and when the workspace's WSL host is turned off the empty pane shows the same turned-off
copy and Turn on button as the terminal banner instead of a read error and a Retry that cannot work.
The editor's file-load error view drops the same wrapper at render time (stored text is unchanged
for retry classifiers).

* test(wsl): open the internal gate for the WSL host e2e, and prove a closed gate shows nothing

The fake-transport e2e launches its desktop with ORCA_INTERNAL_WSL_HOSTS=1.
A new case leaves the gate closed with a WSL host already saved and the
Experimental setting on: no WSL host API, list entry, host row, Add WSL
distro action, Experimental toggle or Settings section appears, the fake
never starts a distro, and the saved host stays on disk.

* test(wsl): count only live shells when proving a removed project's terminals ended

On Linux CI the hub keeps a pty-less tab record for the removed worktree after terminal.stop ended its shell, so counting every terminal.list row never reached zero.
2026-10-13 09:37:50 -07:00

321 lines
12 KiB
TypeScript

import { z } from 'zod'
import { PAIRING_OFFER_VERSION, type PairingOffer } from './pairing'
import {
pairedRuntimeConnectionFromOffer,
type PairedRuntimeConnection
} from './paired-runtime-connection'
import { classifyRemotePairingHostname } from './remote-pairing-address'
import { RuntimeEnvironmentReconciliationRecordSchema } from './runtime-environment-reconciliation-record'
import { OrcadWslDeploymentLinkSchema } from './wsl-host-deployment-link'
export const RuntimeAccessEndpointSchema = z.object({
id: z.string().min(1),
kind: z.literal('websocket'),
label: z.string().min(1),
endpoint: z.string().min(1),
deviceToken: z.string().min(1),
publicKeyB64: z.string().min(1)
})
export const PublicRuntimeAccessEndpointSchema = RuntimeAccessEndpointSchema.omit({
deviceToken: true,
publicKeyB64: true
})
export type PublicRuntimeAccessEndpoint = z.infer<typeof PublicRuntimeAccessEndpointSchema>
export const RuntimeEnvironmentSourceSchema = z.enum(['manual', 'ephemeral-vm'])
export type RuntimeEnvironmentSource = z.infer<typeof RuntimeEnvironmentSourceSchema>
export const RuntimeSshTunnelLinkSchema = z.object({
sshTargetId: z.string().min(1),
sshTargetGeneration: z.number().int().positive(),
localPort: z.number().int().min(1).max(65_535),
remotePort: z.number().int().min(1).max(65_535)
})
export type RuntimeSshTunnelLink = z.infer<typeof RuntimeSshTunnelLinkSchema>
/** A server Orca deployed and owns over SSH; unlike sshAccess, it grants lifecycle ownership. */
export const OrcadSshDeploymentLinkSchema = RuntimeSshTunnelLinkSchema.extend({
// Absent on disk: the sidecar persists SSH links without it, so older builds read them unchanged.
kind: z.literal('ssh').optional()
})
export type OrcadSshDeploymentLink = z.infer<typeof OrcadSshDeploymentLinkSchema>
/** A server Orca deployed and owns; the WSL kind is persisted only in wsl-host-deployment-store. */
export const OrcadDeploymentLinkSchema = z.union([
OrcadSshDeploymentLinkSchema,
OrcadWslDeploymentLinkSchema
])
export type OrcadDeploymentLink = z.infer<typeof OrcadDeploymentLinkSchema>
/** The managed deployment Orca owns over SSH; undefined for none or a WSL one. */
export function getOrcadSshDeployment(environment: {
orcadDeployment?: OrcadDeploymentLink | null
}): OrcadSshDeploymentLink | undefined {
const deployment = environment.orcadDeployment
return deployment && deployment.kind !== 'wsl' ? deployment : undefined
}
export const RuntimeSshAccessLinkSchema = RuntimeSshTunnelLinkSchema.extend({
requestId: z.string().min(1).optional(),
targetFingerprint: z.string().min(1).optional(),
endpointId: z.string().min(1),
previousPreferredEndpointId: z.string().min(1)
})
export type RuntimeSshAccessLink = z.infer<typeof RuntimeSshAccessLinkSchema>
export const RuntimeSshAccessOperationSchema = RuntimeSshTunnelLinkSchema.omit({ localPort: true })
.extend({
requestId: z.string().min(1),
operation: z.enum(['link', 'unlink']),
targetFingerprint: z.string().min(1).optional()
})
.refine((intent) => intent.operation !== 'link' || !!intent.targetFingerprint, {
message: 'Link intent requires a target fingerprint.'
})
export type RuntimeSshAccessOperation = z.infer<typeof RuntimeSshAccessOperationSchema>
/** The fields shipped builds read and rewrite in orca-environments.json. */
export const PersistedRuntimeEnvironmentSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
createdAt: z.number().finite(),
updatedAt: z.number().finite(),
pairingRevision: z.number().finite().optional(),
pairedDeviceId: z.string().min(1).optional(),
lastUsedAt: z.number().finite().nullable(),
runtimeId: z.string().min(1).nullable(),
source: RuntimeEnvironmentSourceSchema.optional(),
connectionDependency: z.literal('ssh-tunnel').optional(),
endpoints: z.array(RuntimeAccessEndpointSchema).min(1),
preferredEndpointId: z.string().min(1)
})
export type PersistedRuntimeEnvironment = z.infer<typeof PersistedRuntimeEnvironmentSchema>
/**
* A persisted environment with its sidecar state overlaid (runtime-environment-sidecar). These
* fields never enter orca-environments.json: shipped builds strip unknown keys when they rewrite it.
*/
export const KnownRuntimeEnvironmentSchema = PersistedRuntimeEnvironmentSchema.extend({
sshAccess: RuntimeSshAccessLinkSchema.optional(),
pendingSshAccessOperation: RuntimeSshAccessOperationSchema.optional(),
orcadDeployment: OrcadDeploymentLinkSchema.optional(),
/** When a migration into this managed server began committing; older snapshots predate it. */
orcadMigratedAt: z.string().datetime().optional(),
reconciliation: RuntimeEnvironmentReconciliationRecordSchema.optional()
})
.refine(
({ pendingSshAccessOperation, sshAccess, orcadDeployment, connectionDependency }) =>
!pendingSshAccessOperation || (!sshAccess && !orcadDeployment && !connectionDependency),
{
message: 'Pending SSH access operations cannot coexist with active SSH access.',
path: ['pendingSshAccessOperation']
}
)
.refine(
(environment) => {
const access = environment.sshAccess
return (
!access ||
(environment.connectionDependency === 'ssh-tunnel' &&
environment.preferredEndpointId === access.endpointId &&
access.previousPreferredEndpointId !== access.endpointId &&
environment.endpoints.some(
(endpoint) => endpoint.id === access.previousPreferredEndpointId
) &&
getPreferredLoopbackRuntimePort(environment) === access.localPort)
)
},
{
message:
'Runtime SSH access must preserve its previous endpoint and prefer its loopback endpoint.',
path: ['sshAccess']
}
)
.refine(
(environment) => {
const deployment = environment.orcadDeployment
// Why no WSL dependency value: older builds reject any other value in orca-environments.json;
// a WSL link's kind, kept in its own file, is the bridge dependency.
const dependency = deployment?.kind === 'wsl' ? undefined : 'ssh-tunnel'
return (
!deployment ||
(!environment.sshAccess &&
environment.connectionDependency === dependency &&
getPreferredLoopbackRuntimePort(environment) === deployment.localPort)
)
},
{
message:
'A managed deployment must prefer its own tunnel and exclude independent SSH access.',
path: ['orcadDeployment']
}
)
export type KnownRuntimeEnvironment = z.infer<typeof KnownRuntimeEnvironmentSchema>
export type PublicKnownRuntimeEnvironment = Omit<KnownRuntimeEnvironment, 'endpoints'> & {
endpoints: PublicRuntimeAccessEndpoint[]
/** Digest of the host's persisted E2EE public key, which its pairing handshake proves. */
hostKeyFingerprint?: string
}
export function redactRuntimeEnvironment(
environment: KnownRuntimeEnvironment
): PublicKnownRuntimeEnvironment {
return {
...environment,
endpoints: environment.endpoints.map(
({ deviceToken: _deviceToken, publicKeyB64: _key, ...rest }) => rest
)
}
}
// Why version 1 and the persisted schema: shipped builds accept only this shape, so every
// T5 field lives in the sidecar and a downgrade rewrite cannot lose or reject it.
export const RuntimeEnvironmentStoreSchema = z.object({
version: z.literal(1),
environments: z.array(PersistedRuntimeEnvironmentSchema)
})
export type RuntimeEnvironmentStore = z.infer<typeof RuntimeEnvironmentStoreSchema>
export function createEnvironmentFromPairingOffer(args: {
id: string
name: string
now: number
offer: PairingOffer
runtimeId?: string | null
source?: RuntimeEnvironmentSource
connectionDependency?: 'ssh-tunnel'
}): KnownRuntimeEnvironment {
const endpointId = `ws-${args.id}`
return KnownRuntimeEnvironmentSchema.parse({
id: args.id,
name: args.name,
createdAt: args.now,
updatedAt: args.now,
pairingRevision: args.now,
...(args.offer.pairedDeviceId ? { pairedDeviceId: args.offer.pairedDeviceId } : {}),
lastUsedAt: null,
runtimeId: args.runtimeId ?? null,
...(args.source ? { source: args.source } : {}),
...(args.connectionDependency ? { connectionDependency: args.connectionDependency } : {}),
endpoints: [
{
id: endpointId,
kind: 'websocket',
label: 'WebSocket',
endpoint: args.offer.endpoint,
deviceToken: args.offer.deviceToken,
publicKeyB64: args.offer.publicKeyB64
}
],
preferredEndpointId: endpointId
})
}
export function isEphemeralVmRuntimeEnvironment(
environment: Pick<PublicKnownRuntimeEnvironment, 'source'>
): boolean {
return environment.source === 'ephemeral-vm'
}
export function isUserManagedRuntimeEnvironment(
environment: Pick<PublicKnownRuntimeEnvironment, 'source'>
): boolean {
return !isEphemeralVmRuntimeEnvironment(environment)
}
/** A WSL distro added as a host; keyed by its deployment record's kind, never by its name. */
export function isWslHostRuntimeEnvironment(
environment: Pick<PublicKnownRuntimeEnvironment, 'orcadDeployment'>
): boolean {
return environment.orcadDeployment?.kind === 'wsl'
}
/**
* A server the generic remote-server surfaces list (Settings, status bar, server updates):
* neither a recipe VM nor a WSL host, which each have their own place.
*/
export function isListedRemoteServer(
environment: Pick<PublicKnownRuntimeEnvironment, 'source' | 'orcadDeployment'>
): boolean {
return isUserManagedRuntimeEnvironment(environment) && !isWslHostRuntimeEnvironment(environment)
}
type PreferredEndpointSource = Pick<
KnownRuntimeEnvironment,
'name' | 'endpoints' | 'preferredEndpointId' | 'pairedDeviceId'
>
function preferredEndpoint(environment: PreferredEndpointSource) {
const endpoint =
environment.endpoints.find((entry) => entry.id === environment.preferredEndpointId) ??
environment.endpoints[0]
if (!endpoint) {
throw new Error(`Environment ${environment.name} has no access endpoints`)
}
return endpoint
}
export function getPreferredPairedRuntimeConnection(
environment: PreferredEndpointSource
): PairedRuntimeConnection {
const endpoint = preferredEndpoint(environment)
return pairedRuntimeConnectionFromOffer({
endpoint: endpoint.endpoint,
deviceToken: endpoint.deviceToken,
publicKeyB64: endpoint.publicKeyB64,
pairedDeviceId: environment.pairedDeviceId
})
}
export function getPreferredPairingOffer(environment: PreferredEndpointSource): PairingOffer {
const endpoint = preferredEndpoint(environment)
return {
v: PAIRING_OFFER_VERSION,
endpoint: endpoint.endpoint,
deviceToken: endpoint.deviceToken,
publicKeyB64: endpoint.publicKeyB64,
...(environment.pairedDeviceId ? { pairedDeviceId: environment.pairedDeviceId } : {})
}
}
/** The SSH tunnel a server is reached through; only orcadDeployment also grants lifecycle ownership. */
export function getRuntimeSshAccess(
environment: Pick<KnownRuntimeEnvironment, 'orcadDeployment' | 'sshAccess'>
): RuntimeSshTunnelLink | undefined {
return getOrcadSshDeployment(environment) ?? environment.sshAccess
}
export function getPreferredLoopbackRuntimePort(environment: {
endpoints: { id: string; endpoint: string }[]
preferredEndpointId: string
}): number | null {
const endpoint = environment.endpoints.find(
(entry) => entry.id === environment.preferredEndpointId
)
if (!endpoint) {
return null
}
try {
const url = new URL(endpoint.endpoint)
const port = Number(url.port)
return (url.protocol === 'ws:' || url.protocol === 'wss:') &&
classifyRemotePairingHostname(url.hostname) === 'loopback' &&
Number.isInteger(port) &&
port >= 1 &&
port <= 65_535
? port
: null
} catch {
return null
}
}