From 4d14ba60d74b0b3ee2509db9348012b331c04ab7 Mon Sep 17 00:00:00 2001 From: m4air Date: Fri, 2 Oct 2026 00:30:00 -0700 Subject: [PATCH 1/2] feat(orcad): convert Windows relay-hosted SSH targets to managed orcad (W5 part 1) --- .../orcad-source-catalog-retirement.ts | 16 +-- .../orcad-source-catalog.ts | 24 ++-- .../orcad-source-ownership.test.ts | 113 ++++++++++++++++++ .../orcad-source-ownership.ts | 44 +++++++ .../orcad-source-retirement.ts | 8 +- .../orcad-migration-relay-pty-lister.test.ts | 45 +++++++ .../ssh/orcad-migration-relay-pty-lister.ts | 32 +++++ src/main/ssh/orcad-windows-host-lane.test.ts | 6 +- .../orcad-windows-relay-terminal-gate-cell.ts | 59 +++++++++ 9 files changed, 320 insertions(+), 27 deletions(-) create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-ownership.test.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-ownership.ts create mode 100644 src/main/ssh/orcad-migration-relay-pty-lister.test.ts create mode 100644 src/main/ssh/orcad-migration-relay-pty-lister.ts create mode 100644 src/main/ssh/orcad-windows-relay-terminal-gate-cell.ts diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-catalog-retirement.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-catalog-retirement.ts index 27bd170d8e47..3e8ca02be1a0 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-catalog-retirement.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-catalog-retirement.ts @@ -4,6 +4,7 @@ */ import type { StoreRuntimeState } from '../loading-store/store-runtime-state' import type { OrcadMigrationManifest } from '../../../shared/orcad-migration-manifest' +import { orcadSourceFolderWorkspaceIds, repoBelongsToOrcadSource } from './orcad-source-ownership' export function retireOrcadSourceCatalogState( state: StoreRuntimeState['state'], @@ -12,19 +13,14 @@ export function retireOrcadSourceCatalogState( const repoIds = new Set(manifest.payload.repositories.map((repo) => repo.id)) const folderIds = new Set(manifest.payload.folderWorkspaces.map((workspace) => workspace.id)) const groupIds = new Set(manifest.payload.projectGroups.map((group) => group.id)) - const groupConnectionById = new Map( - state.projectGroups.map((group) => [group.id, group.connectionId]) - ) + const targetId = manifest.source.sshTargetId + // Before any repo goes: a folder owned through the repos inside it would read as local after. + const ownedFolderIds = orcadSourceFolderWorkspaceIds(state, targetId) state.repos = state.repos.filter( - (repo) => !(repoIds.has(repo.id) && repo.connectionId === manifest.source.sshTargetId) + (repo) => !(repoIds.has(repo.id) && repoBelongsToOrcadSource(repo, targetId)) ) state.folderWorkspaces = state.folderWorkspaces.filter( - (workspace) => - !( - folderIds.has(workspace.id) && - (workspace.connectionId ?? groupConnectionById.get(workspace.projectGroupId) ?? null) === - manifest.source.sshTargetId - ) + (workspace) => !(folderIds.has(workspace.id) && ownedFolderIds.has(workspace.id)) ) removeUnreferencedSourceGroups(state, groupIds, manifest.source.sshTargetId) } diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-catalog.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-catalog.ts index fc902a994c58..16ad28454693 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-catalog.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-catalog.ts @@ -2,6 +2,7 @@ import type { OrcadMigrationCatalogPayload } from '../../../shared/orcad-migrati import type { ProjectGroup } from '../../../shared/project-group-types' import type { SshTarget } from '../../../shared/ssh-types' import type { Store } from '../../persistence' +import { orcadSourceFolderWorkspaceIds, repoBelongsToOrcadSource } from './orcad-source-ownership' type OrcadSourceCatalogStore = Pick @@ -9,19 +10,18 @@ export function collectOrcadMigrationSourceCatalog( store: OrcadSourceCatalogStore, target: Pick ): OrcadMigrationCatalogPayload { - const repositories = store - .getRepos() - .filter((repo) => repo.connectionId === target.id) - .map((repo) => structuredClone(repo)) + const allRepos = store.getRepos() const allGroups = store.getProjectGroups() - const groupConnectionById = new Map(allGroups.map((group) => [group.id, group.connectionId])) - const folderWorkspaces = store - .getFolderWorkspaces() - .filter( - (workspace) => - (workspace.connectionId ?? groupConnectionById.get(workspace.projectGroupId) ?? null) === - target.id - ) + const allFolders = store.getFolderWorkspaces() + const repositories = allRepos + .filter((repo) => repoBelongsToOrcadSource(repo, target.id)) + .map((repo) => structuredClone(repo)) + const ownedFolderIds = orcadSourceFolderWorkspaceIds( + { repos: allRepos, projectGroups: allGroups, folderWorkspaces: allFolders }, + target.id + ) + const folderWorkspaces = allFolders + .filter((workspace) => ownedFolderIds.has(workspace.id)) .map((workspace) => structuredClone(workspace)) const projectGroups = collectProjectGroups( allGroups, diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-ownership.test.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-ownership.test.ts new file mode 100644 index 000000000000..9a230a000e42 --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-ownership.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultPersistedState } from '../../../shared/constants' +import type { FolderWorkspace } from '../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../shared/project-group-types' +import type { Repo } from '../../../shared/repo-types' +import { orcadMigrationCutoverFixture } from '../../ssh/orcad-migration-cutover-fixture' +import { collectOrcadMigrationSourceCatalog } from './orcad-source-catalog' +import { retireOrcadSourceCatalogState } from './orcad-source-catalog-retirement' +import { orcadSourceFolderWorkspaceIds, repoBelongsToOrcadSource } from './orcad-source-ownership' + +const TARGET = 'ssh-win' + +function repo(overrides: Partial & Pick): Repo { + return { displayName: overrides.id, badgeColor: '#000', addedAt: 0, ...overrides } +} + +function folder( + overrides: Partial & Pick +): FolderWorkspace { + return { + projectGroupId: 'group-folders', + name: overrides.id, + folderPath: 'C:\\Users\\Ann\\work', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + createdAt: 0, + updatedAt: 0, + ...overrides + } +} + +const group: ProjectGroup = { + id: 'group-folders', + name: 'Folders', + parentPath: null, + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 0, + updatedAt: 0 +} + +/** A Windows relay host: drive-letter paths whose case differs between rows. */ +function windowsCatalog() { + return { + repos: [ + repo({ id: 'repo-app', path: 'C:\\Users\\Ann\\work\\app', connectionId: TARGET }), + repo({ id: 'repo-unified', path: 'D:/src/tool', executionHostId: `ssh:${TARGET}` }), + repo({ id: 'repo-local', path: 'C:\\Users\\Ann\\local' }) + ], + projectGroups: [group], + folderWorkspaces: [ + // No connection of its own: the repo inside it, matched case-insensitively, places it. + folder({ id: 'fw-inferred', folderPath: 'c:/users/ann/WORK' }), + folder({ id: 'fw-explicit', folderPath: 'C:\\elsewhere', connectionId: TARGET }), + folder({ id: 'fw-local', folderPath: 'C:\\Users\\Ann\\local' }), + folder({ id: 'fw-other', folderPath: 'C:\\Users\\Ann\\work', connectionId: 'ssh-other' }) + ] + } +} + +describe('orcad migration source ownership on a Windows SSH host', () => { + it('owns repos by either host spelling and folders the way the app attributes them', () => { + const catalog = windowsCatalog() + expect( + catalog.repos.filter((entry) => repoBelongsToOrcadSource(entry, TARGET)).map(({ id }) => id) + ).toEqual(['repo-app', 'repo-unified']) + expect([...orcadSourceFolderWorkspaceIds(catalog, TARGET)].sort()).toEqual([ + 'fw-explicit', + 'fw-inferred' + ]) + }) + + it('exports exactly those rows', () => { + const catalog = windowsCatalog() + const exported = collectOrcadMigrationSourceCatalog( + { + getRepos: () => catalog.repos, + getProjectGroups: () => catalog.projectGroups, + getFolderWorkspaces: () => catalog.folderWorkspaces + }, + { id: TARGET } + ) + expect(exported.repositories.map(({ id }) => id)).toEqual(['repo-app', 'repo-unified']) + expect(exported.folderWorkspaces.map(({ id }) => id).sort()).toEqual([ + 'fw-explicit', + 'fw-inferred' + ]) + }) + + it('retires a folder owned through its repo even though the repo goes first', () => { + const catalog = windowsCatalog() + const state = { ...getDefaultPersistedState('C:/Users/Ann'), ...catalog } + const fixture = orcadMigrationCutoverFixture('migration-1', TARGET) + retireOrcadSourceCatalogState(state, { + ...fixture.manifest, + payload: { + repositories: catalog.repos.slice(0, 2), + projectGroups: [], + folderWorkspaces: catalog.folderWorkspaces.slice(0, 2) + } + }) + expect(state.repos.map(({ id }) => id)).toEqual(['repo-local']) + expect(state.folderWorkspaces.map(({ id }) => id)).toEqual(['fw-local', 'fw-other']) + }) +}) diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-ownership.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-ownership.ts new file mode 100644 index 000000000000..a7973136ee78 --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-ownership.ts @@ -0,0 +1,44 @@ +/** + * Which catalog rows a migrating SSH target owns, answered the way the app attributes them. + * + * A repo names its host by the legacy `connectionId` or by `executionHostId: 'ssh:'`; a + * folder workspace by its own or its group's connection, or by the repos inside its folder, which + * main matches with `isPathInsideOrEqual` (drive letters and case folded on Windows paths). Export + * and retirement must use one rule, or a row exported by one is left behind by the other. + */ +import { getRepoExecutionHostId, parseExecutionHostId } from '../../../shared/execution-host' +import { + resolveFolderWorkspaceHost, + type FolderWorkspaceHostState +} from '../../../shared/folder-workspace-execution-host' +import type { FolderWorkspace } from '../../../shared/folder-workspace-types' +import type { Repo } from '../../../shared/repo-types' + +export function repoBelongsToOrcadSource(repo: Repo, targetId: string): boolean { + if (repo.connectionId === targetId) { + return true + } + const host = parseExecutionHostId(getRepoExecutionHostId(repo)) + return host?.kind === 'ssh' && host.targetId === targetId +} + +/** Folder workspace ids the target owns in `state`; read before any of its repos are removed. */ +export function orcadSourceFolderWorkspaceIds( + state: FolderWorkspaceHostState, + targetId: string +): Set { + const groupConnectionById = new Map( + state.projectGroups.map((group) => [group.id, group.connectionId]) + ) + const owned = (workspace: FolderWorkspace): boolean => { + if ( + (workspace.connectionId ?? groupConnectionById.get(workspace.projectGroupId) ?? null) === + targetId + ) { + return true + } + const host = resolveFolderWorkspaceHost(state, workspace.id) + return host.kind === 'ssh' && host.targetId === targetId + } + return new Set(state.folderWorkspaces.filter(owned).map((workspace) => workspace.id)) +} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-retirement.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-retirement.ts index 38ca063a95af..a43b904d8539 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-retirement.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-retirement.ts @@ -2,6 +2,7 @@ * The Store's one write for a migrated target: retiring the source rows a destination proved it * committed. Idempotent, so a retry after a crash before the journal moved finishes the same work. */ +import { orcadSourceFolderWorkspaceIds, repoBelongsToOrcadSource } from './orcad-source-ownership' import type { OrcadMigrationManifest } from '../../../shared/orcad-migration-manifest' import { assertOrcadMigrationManifestDigest } from '../../orcad/orcad-migration-manifest-digest' import { @@ -53,11 +54,10 @@ export class OrcadSourceRetirementPersistence { const target = manifest.source.sshTargetId const repoIds = new Set(manifest.payload.repositories.map((repo) => repo.id)) const folderIds = new Set(manifest.payload.folderWorkspaces.map((workspace) => workspace.id)) + const ownedFolderIds = orcadSourceFolderWorkspaceIds(state, target) if ( - state.repos.some((repo) => repoIds.has(repo.id) && repo.connectionId === target) || - state.folderWorkspaces.some( - (workspace) => folderIds.has(workspace.id) && workspace.connectionId === target - ) + state.repos.some((repo) => repoIds.has(repo.id) && repoBelongsToOrcadSource(repo, target)) || + [...folderIds].some((id) => ownedFolderIds.has(id)) ) { throw new Error('orcad_migration_source_catalog_reappeared') } diff --git a/src/main/ssh/orcad-migration-relay-pty-lister.test.ts b/src/main/ssh/orcad-migration-relay-pty-lister.test.ts new file mode 100644 index 000000000000..c712f305bccb --- /dev/null +++ b/src/main/ssh/orcad-migration-relay-pty-lister.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('../ipc/pty/provider/registry', () => ({ getSshPtyProvider: vi.fn(() => undefined) })) + +import { toAppSshPtyId } from '../providers/ssh-pty-id' +import { assessOrcadMigrationTerminals } from './orcad-migration-terminal-gate' +import { + ORCAD_MIGRATION_RELAY_LIST_BUDGET_MS, + orcadMigrationRelayPtyLister +} from './orcad-migration-relay-pty-lister' + +const TARGET = 'ssh-win' +const noLeases = { getSshRemotePtyLeases: () => [] } + +describe('the terminal gate asking a relay what it still runs', () => { + it('answers in the relay spelling the leases use, within a bounded deadline', async () => { + const listProcesses = vi.fn(async () => [ + { id: toAppSshPtyId(TARGET, 'pty-7'), cwd: '', title: 'cmd.exe' } + ]) + const lister = orcadMigrationRelayPtyLister(TARGET, { listProcesses }, () => 1_000) + expect(await lister?.()).toEqual(['pty-7']) + expect(listProcesses).toHaveBeenCalledWith({ + deadlineMs: 1_000 + ORCAD_MIGRATION_RELAY_LIST_BUDGET_MS + }) + }) + + it('lets the gate prove exit only when the relay answers with nothing running', async () => { + const running = orcadMigrationRelayPtyLister(TARGET, { + listProcesses: async () => [{ id: toAppSshPtyId(TARGET, 'pty-7'), cwd: '', title: 'pwsh' }] + }) + expect(await assessOrcadMigrationTerminals(noLeases, TARGET, running)).toMatchObject({ + verdict: 'live', + ptyIds: ['pty-7'] + }) + const idle = orcadMigrationRelayPtyLister(TARGET, { listProcesses: async () => [] }) + expect(await assessOrcadMigrationTerminals(noLeases, TARGET, idle)).toEqual({ + verdict: 'exited', + provenPtyIds: [] + }) + }) + + it('has no lister without a connected relay session', () => { + expect(orcadMigrationRelayPtyLister(TARGET)).toBeNull() + }) +}) diff --git a/src/main/ssh/orcad-migration-relay-pty-lister.ts b/src/main/ssh/orcad-migration-relay-pty-lister.ts new file mode 100644 index 000000000000..f4bbd1478460 --- /dev/null +++ b/src/main/ssh/orcad-migration-relay-pty-lister.ts @@ -0,0 +1,32 @@ +/** + * The terminal gate's question to the relay: which PTYs does it still run for this target? + * + * Asked through the SSH PTY provider's `pty.listProcesses`, which the pinned relay answers the + * same way on Windows and POSIX hosts, so a Windows relay-hosted target proves its terminals + * exited exactly as a Linux one does. Ids come back in the relay's own spelling, which is how + * the target's PTY leases name them. + */ +import { getSshPtyProvider } from '../ipc/pty/provider/registry' +import type { IPtyProvider } from '../providers/types' +import { toRelaySshPtyId } from '../providers/ssh-pty-id' +import type { ListRelayPtyIds } from './orcad-migration-terminal-gate' + +/** Long enough for a Windows relay's first process-table read, short enough to block a click. */ +export const ORCAD_MIGRATION_RELAY_LIST_BUDGET_MS = 10_000 + +/** Null when no relay session is connected: the gate then cannot clear expired leases. */ +export function orcadMigrationRelayPtyLister( + targetId: string, + provider: Pick | undefined = getSshPtyProvider(targetId), + now: () => number = Date.now +): ListRelayPtyIds | null { + if (!provider) { + return null + } + return async () => { + const processes = await provider.listProcesses({ + deadlineMs: now() + ORCAD_MIGRATION_RELAY_LIST_BUDGET_MS + }) + return processes.map((process) => toRelaySshPtyId(targetId, process.id)) + } +} diff --git a/src/main/ssh/orcad-windows-host-lane.test.ts b/src/main/ssh/orcad-windows-host-lane.test.ts index 4af12d71dae0..aca62ed43e00 100644 --- a/src/main/ssh/orcad-windows-host-lane.test.ts +++ b/src/main/ssh/orcad-windows-host-lane.test.ts @@ -1,4 +1,5 @@ -// Managed orcad on a real Win32-OpenSSH host, one cell per DefaultShell: resolve the context +// Managed orcad on a real Win32-OpenSSH host, one cell per DefaultShell: prove the conversion's +// terminal gate against the pinned relay already serving the host, then resolve the context // (pinned node.exe, host script), deploy and activate, prove readiness and liveness, decommission // through the instance-bound stop request, prove exit, and run a GC pass. config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1 // provisions the account and runs this file for `orcad-*` cells; ssh-windows-hosts.yml runs that. @@ -16,6 +17,7 @@ import { installHostileHostAppEnvironment } from './ssh-hostile-host-test-harness' import { resolveOrcadRemoteContext } from './orcad-remote-context' +import { proveWindowsRelayTerminalGate } from './orcad-windows-relay-terminal-gate-cell' import { deployOrcad } from './orcad-remote-deploy' import { orcadLivenessProbeCommand, parseOrcadLiveness } from './orcad-remote-launch' import { orcadSlotDir, type OrcadSlotOptions } from './orcad-recovery-slot' @@ -65,6 +67,8 @@ describe.runIf(RUN)('managed orcad on a Windows OpenSSH host', () => { let conn: SshConnection | null = null try { conn = await connectHostileHost(sshTarget) + // A relay-hosted source first: conversion may proceed only once its terminals exited. + Object.assign(receipt, await proveWindowsRelayTerminalGate(conn, sshTarget.id)) const context = await resolveOrcadRemoteContext(sshTarget, conn) expect(context.host.os).toBe('win32') const options: OrcadSlotOptions = { diff --git a/src/main/ssh/orcad-windows-relay-terminal-gate-cell.ts b/src/main/ssh/orcad-windows-relay-terminal-gate-cell.ts new file mode 100644 index 000000000000..8f0a458a7143 --- /dev/null +++ b/src/main/ssh/orcad-windows-relay-terminal-gate-cell.ts @@ -0,0 +1,59 @@ +/** + * The conversion's terminal gate against a real relay on a Windows SSH host, for the orcad host + * lane: deploy the pinned relay, run one terminal, prove the gate reads it as live, stop it, and + * prove the gate then reads the host as exited. The relay is asked through `pty.listProcesses`, + * exactly as `orcadMigrationRelayPtyLister` asks it through the SSH PTY provider. + */ +import { randomUUID } from 'node:crypto' +import { expect } from 'vitest' +import { SshChannelMultiplexer } from './ssh-channel-multiplexer' +import type { SshConnection } from './ssh-connection' +import { deployOnce } from './ssh-hostile-host-test-harness' +import { retrySshOwnerRecoveryWhileBlocked } from './ssh-owner-recovery-retry' +import { openSshPtyConsumerSession } from './ssh-pty-consumer-session' +import { assessOrcadMigrationTerminals } from './orcad-migration-terminal-gate' + +function relayPtyIds(rows: unknown): string[] { + return Array.isArray(rows) + ? rows.flatMap((row: unknown) => + row && typeof row === 'object' && 'id' in row && typeof row.id === 'string' ? [row.id] : [] + ) + : [] +} + +export async function proveWindowsRelayTerminalGate( + conn: SshConnection, + targetId: string +): Promise> { + const relay = await deployOnce(conn) + expect(relay.error, String(relay.error)).toBeNull() + const deployed = relay.deployed! + const mux = new SshChannelMultiplexer(deployed.transport) + try { + await retrySshOwnerRecoveryWhileBlocked( + () => + openSshPtyConsumerSession(mux, { + clientInstanceId: randomUUID(), + expectedServerBuildId: deployed.serverBuildId + }), + { isCurrent: () => true, onClosed: () => () => {} } + ) + const lister = async () => relayPtyIds(await mux.request('pty.listProcesses')) + const noLeases = { getSshRemotePtyLeases: () => [] } + const spawned: unknown = await mux.request('pty.spawn', { cols: 80, rows: 24 }) + const id = relayPtyIds([spawned])[0] ?? '' + expect(id).not.toBe('') + const live = await assessOrcadMigrationTerminals(noLeases, targetId, lister) + expect(live).toMatchObject({ verdict: 'live', ptyIds: [id] }) + await mux.request('pty.shutdown', { id, immediate: true }) + const deadline = Date.now() + 30_000 + while ((await lister()).length > 0 && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 500)) + } + const exited = await assessOrcadMigrationTerminals(noLeases, targetId, lister) + expect(exited).toEqual({ verdict: 'exited', provenPtyIds: [] }) + return { relayTerminal: id, gateLive: live.verdict, gateExited: exited.verdict } + } finally { + mux.dispose() + } +} From 5c75cbb87d3160eaff29866f860094e9c23a21a9 Mon Sep 17 00:00:00 2001 From: m4air Date: Fri, 2 Oct 2026 10:22:39 -0700 Subject: [PATCH 2/2] test(orcad): start the Windows lane's exec spy after the relay gate prelude restores its own --- src/main/ssh/orcad-windows-host-lane.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/ssh/orcad-windows-host-lane.test.ts b/src/main/ssh/orcad-windows-host-lane.test.ts index aca62ed43e00..471b5d0b6fc3 100644 --- a/src/main/ssh/orcad-windows-host-lane.test.ts +++ b/src/main/ssh/orcad-windows-host-lane.test.ts @@ -7,7 +7,7 @@ // Run: ORCA_RUN_SSH_WINDOWS_HOST=1 ORCA_SSH_WINDOWS_HOST_CELL= pnpm test import { randomUUID } from 'node:crypto' import { writeFileSync } from 'node:fs' -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeAll, describe, expect, it, vi, type MockInstance } from 'vitest' vi.mock('electron', () => ({ app: { getAppPath: () => process.cwd() } })) @@ -62,13 +62,15 @@ describe.runIf(RUN)('managed orcad on a Windows OpenSSH host', () => { { id: descriptor.cell, remoteRuntime: 'pinned-node' }, randomUUID() ) - const exec = vi.spyOn(SshConnection.prototype, 'exec') + let exec: MockInstance | null = null const receipt: Record = { cell: descriptor.cell, target: descriptor.target } let conn: SshConnection | null = null try { conn = await connectHostileHost(sshTarget) // A relay-hosted source first: conversion may proceed only once its terminals exited. Object.assign(receipt, await proveWindowsRelayTerminalGate(conn, sshTarget.id)) + // After the prelude: its relay deploy re-spies `exec`, which is the same spy, and restores it. + exec = vi.spyOn(SshConnection.prototype, 'exec') const context = await resolveOrcadRemoteContext(sshTarget, conn) expect(context.host.os).toBe('win32') const options: OrcadSlotOptions = { @@ -128,7 +130,7 @@ describe.runIf(RUN)('managed orcad on a Windows OpenSSH host', () => { } receipt.passed = true } finally { - exec.mockRestore() + exec?.mockRestore() await conn?.disconnect().catch(() => {}) writeFileSync(descriptor.receipt, `${JSON.stringify(receipt, null, 2)}\n`) }