feat(vm): create workspaces from provisioned SSH roots (#14359)

* feat(vm): use recipe-provisioned SSH roots

* fix(vm): preserve ordinary create failure timing

* test(vm): prepare provisioned root SSH fixture

* ci(vm): enable SSH setup for provisioned root E2E
This commit is contained in:
Jinwoo Hong
2026-08-13 16:23:22 -07:00
committed by GitHub
parent f9f55075f6
commit 77b37d85e2
27 changed files with 719 additions and 76 deletions
+5 -1
View File
@@ -240,7 +240,11 @@ jobs:
echo "Changed startup-readiness specs are owned by the dedicated live lane."
exit 0
fi
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 \
E2E_ENV=(SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1)
if printf '%s\n' "${TEST_FILES[@]}" | grep -qx 'tests/e2e/ephemeral-vm-provisioned-root.spec.ts'; then
E2E_ENV+=(ORCA_E2E_SSH_DOCKER=1)
fi
xvfb-run --auto-servernum env "${E2E_ENV[@]}" \
pnpm run test:e2e "${TEST_FILES[@]}" --workers=1
- name: Upload Playwright traces
+30
View File
@@ -40,6 +40,11 @@ in the env and emits a `pairingCode`; §7c/§7f) vs **SSH** (`create` runs no se
`connection.type:"ssh"` block Orca dials into; §7g/§7h). Settle this first — it changes the `create`
output shape and half the templates.
Keep Orca's checkout behavior unchanged by default: omit `checkoutMode`, emit schema version 1, and
let Orca create a linked worktree. Only use `checkoutMode: provisioned-root` when the user explicitly
wants one ephemeral machine to clone the finished workspace itself. This niche mode currently requires
direct SSH, an ordinary non-bare/non-sparse primary checkout at `projectRoot`, and schema version 2.
**Quick-start (happy path):** interview the user (connection mode Orca-server vs SSH, provider, agent CLI,
git auth — §1.2) + read the provider's CLI docs → scaffold `scripts/orca-vm/` from §7 → run the
base-snapshot script, then the auth script (you invoke these by hand; not via `orca.yaml`) → wire
@@ -60,6 +65,8 @@ a long time, or need the user at the keyboard. Never create an Orca workspace or
- **Connection mode:** how Orca attaches to the environment — an **Orca server** (the VM runs
`orca serve` and Orca pairs over its pairing URL; worked example §7f) or **SSH** (Orca connects to
the host over SSH; §7g). This decides the recipe's connection shape, so settle it first.
- **Checkout ownership:** do not ask by default. Only when the user requires the environment to
create the exact final checkout, confirm `provisioned-root` and direct SSH; otherwise omit it.
- **Provider:** Vercel Sandbox, Fly, Modal, an existing SSH host, … For non-obvious providers, also
ask scope/project/region and plan limits (§2). Then **read that provider's CLI/SDK docs** (or
`<cli> --help`) before scaffolding — you need its exact create/exec/snapshot/remove verbs.
@@ -462,6 +469,24 @@ SSH mode is **fundamentally different from §7c/§7f**, not a relabeling of them
`label`, `host`, `port`, `username` are required; the rest are optional — omit any you don't need.
For an explicitly requested one-VM-per-workspace checkout, the create script must read
`ORCA_RECIPE_RESULT_SCHEMA_VERSION`, `ORCA_REPO_URL`, `ORCA_REPO_REF`, and `ORCA_REPO_BRANCH`, create
that exact primary checkout at `projectRoot`, and emit the same SSH result with:
```json
{
"schemaVersion": 2,
"checkoutMode": "provisioned-root",
"connection": {
"type": "ssh",
"projectRoot": "/abs/repo",
"target": { "label": "my-box", "host": "192.0.2.10", "port": 22, "username": "ubuntu" }
}
}
```
Fail if the requested schema is not `2`; do not silently fall back to the ordinary recipe shape.
**Networking → which `target` fields to set** (how *your desktop* reaches the box — there is no
`orca serve` URL in SSH mode):
@@ -618,6 +643,11 @@ and `userData` are optional.
**SSH mode** — do **not** run `orca serve`; print the `connection.type:"ssh"` block instead (full shape +
worked script in §7g). `pairingCode` is **not** used in SSH mode.
**Optional provisioned root** — only for direct SSH and only when explicitly requested. Add
`checkoutMode: provisioned-root` to the recipe, require `ORCA_RECIPE_RESULT_SCHEMA_VERSION=2`, create
the requested branch from `ORCA_REPO_REF` at the returned `projectRoot`, and emit schema version 2 plus
`checkoutMode: "provisioned-root"`. All recipes without this field retain the schema-v1 behavior above.
Lifecycle hooks (all run locally):
- `create`: required. Prints recipe result JSON.
File diff suppressed because one or more lines are too long
@@ -10,6 +10,7 @@ import {
import { Button } from '@/components/ui/button'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
import { getRepoExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host'
// Why: interpolated into the sentence so locales control where the name sits;
// U+0000 cannot appear in a real project name, so the split is unambiguous.
@@ -24,13 +25,16 @@ const RemoveFolderDialog = React.memo(function RemoveFolderDialog() {
const isOpen = activeModal === 'confirm-remove-folder'
const repoId = typeof modalData.repoId === 'string' ? modalData.repoId : ''
const displayName = typeof modalData.displayName === 'string' ? modalData.displayName : ''
const hostId = typeof modalData.hostId === 'string' ? (modalData.hostId as ExecutionHostId) : null
// Why: for an SSH project the files live on the remote host's disk, not the
// user's — "still on your disk" would be misleading. Name the host (using the
// removed-target label when it's a ghost) so the user knows where it remains
// and that re-adding that host recovers it.
const sshHostLabel = useAppStore((s) => {
const connectionId = s.repos.find((r) => r.id === repoId)?.connectionId?.trim()
const connectionId = s.repos
.find((repo) => repo.id === repoId && (!hostId || getRepoExecutionHostId(repo) === hostId))
?.connectionId?.trim()
if (!connectionId) {
return null
}
@@ -59,10 +63,13 @@ const RemoveFolderDialog = React.memo(function RemoveFolderDialog() {
const handleConfirm = useCallback(() => {
if (repoId) {
void removeProject(repoId, { errorFeedback: 'toast' })
void removeProject(repoId, {
...(hostId ? { hostId } : {}),
errorFeedback: 'toast'
})
}
closeModal()
}, [closeModal, removeProject, repoId])
}, [closeModal, hostId, removeProject, repoId])
const handleOpenChange = useCallback(
(open: boolean) => {
@@ -6176,7 +6176,8 @@ const WorktreeList = React.memo(function WorktreeList({
(repo: Repo) => {
openModal('confirm-remove-folder', {
repoId: repo.id,
displayName: repo.displayName
displayName: repo.displayName,
hostId: getRepoExecutionHostId(repo)
})
},
[openModal]
@@ -0,0 +1,10 @@
import type { Worktree } from '../../../../shared/types'
/** Keeps provisioned roots visible because they are the recipe-created workspace, not a source-repo row. */
export function isDefaultBranchWorkspace(worktree: Worktree): boolean {
return (
worktree.isMainWorktree &&
worktree.branch.trim() !== '' &&
worktree.ephemeralVmCheckoutMode !== 'provisioned-root'
)
}
@@ -12,9 +12,10 @@ const mocks = vi.hoisted(() => {
path: string
displayName: string
isMainWorktree: boolean
hostId?: string
}
>(),
repos: [] as { id: string; displayName: string }[],
repos: [] as { id: string; displayName: string; connectionId?: string }[],
worktreeLineageById: {},
allWorktrees: () => Array.from(state.worktreeMap.values()),
clearWorktreeDeleteState: vi.fn((worktreeId: string) => {
@@ -88,6 +89,7 @@ function setWorktrees(
path?: string
displayName?: string
isMainWorktree?: boolean
hostId?: string
}[]
): void {
mocks.state.worktreeMap = new Map(
@@ -99,7 +101,8 @@ function setWorktrees(
repoId: worktree.repoId ?? 'repo-1',
path: worktree.path ?? `/workspaces/${worktree.id}`,
displayName: worktree.displayName ?? worktree.id,
isMainWorktree: worktree.isMainWorktree ?? false
isMainWorktree: worktree.isMainWorktree ?? false,
...(worktree.hostId ? { hostId: worktree.hostId } : {})
}
])
)
@@ -493,7 +496,33 @@ describe('delete worktree flow', () => {
expect(mocks.state.removeWorktree).not.toHaveBeenCalled()
expect(mocks.state.openModal).toHaveBeenCalledWith('confirm-remove-folder', {
repoId: 'repo-1',
displayName: 'orca'
displayName: 'orca',
hostId: 'local'
})
})
it('routes primary workspace removal to its exact SSH host', () => {
mocks.state.settings = { skipDeleteWorktreeConfirm: true }
setWorktrees([
{
id: 'main',
repoId: 'repo-1',
displayName: 'main',
isMainWorktree: true,
hostId: 'ssh:runtime-ssh-one'
}
])
mocks.state.repos = [
{ id: 'repo-1', displayName: 'local orca' },
{ id: 'repo-1', displayName: 'provisioned orca', connectionId: 'runtime-ssh-one' }
]
runWorktreeDelete('main')
expect(mocks.state.openModal).toHaveBeenCalledWith('confirm-remove-folder', {
repoId: 'repo-1',
displayName: 'provisioned orca',
hostId: 'ssh:runtime-ssh-one'
})
})
@@ -9,6 +9,7 @@ import { getWorkspaceDeleteLineage } from './workspace-delete-lineage'
import { resolveSshWorkspaceForget } from './ssh-workspace-forget-resolution'
import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome'
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
import { getRepoExecutionHostId } from '../../../../shared/execution-host'
import {
resolveWorktreeBatchDeleteTargets,
toWorktreeDeleteIdentities,
@@ -44,11 +45,16 @@ export function runWorktreeDelete(worktreeId: string, options: WorktreeDeleteOpt
return
}
if (target.isMainWorktree) {
const repo = state.repos.find((entry) => entry.id === target.repoId)
const repo = findRepoForHost(state.repos, target.repoId, {
hostId: target.hostId,
settings: state.settings
})
const hostId = repo ? getRepoExecutionHostId(repo) : target.hostId
// Why: git refuses to delete the primary checkout; users can still remove the owning project from Orca (disk contents kept).
state.openModal('confirm-remove-folder', {
repoId: target.repoId,
displayName: repo?.displayName ?? target.displayName
displayName: repo?.displayName ?? target.displayName,
...(hostId ? { hostId } : {})
})
return
}
@@ -64,6 +64,13 @@ describe('isDefaultBranchWorkspace', () => {
const feature = makeWorktree('feature')
expect(isDefaultBranchWorkspace(feature)).toBe(false)
})
it('keeps a provisioned root visible as the recipe-created workspace', () => {
const provisionedRoot = makeWorktree('provisioned-root')
provisionedRoot.isMainWorktree = true
provisionedRoot.ephemeralVmCheckoutMode = 'provisioned-root'
expect(isDefaultBranchWorkspace(provisionedRoot)).toBe(false)
})
})
describe('sidebarHasActiveFilters', () => {
@@ -26,19 +26,9 @@ import {
getPairedDeviceIdsByEnvironment,
isWorkspaceFromOtherDevice
} from './workspace-creator-visibility'
import { isDefaultBranchWorkspace } from './default-branch-workspace'
/**
* Whether a worktree represents the repo's default-branch row that the
* "Hide Default Branch Workspace" setting targets. Folder-mode projects are
* main worktrees with branch === '' and are intentionally preserved.
*
* Why a shared helper: this predicate gates visibility in both the sidebar
* pipeline (computeVisibleWorktreeIds) and the Cmd+J jump palette. Keeping
* the definition in one place prevents the two surfaces from drifting.
*/
export function isDefaultBranchWorkspace(worktree: Worktree): boolean {
return worktree.isMainWorktree && worktree.branch.trim() !== ''
}
export { isDefaultBranchWorkspace } from './default-branch-workspace'
/**
* Whether the "Hide sleeping" sweep must keep this row (#8873).
+6 -1
View File
@@ -4477,10 +4477,14 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
if (vmRecipeTrustDecision === 'skip') {
return
}
const selectedRecipe = ephemeralVmRecipes.find(
(recipe) => recipe.id === activeEphemeralVmRecipeId
)
ephemeralVmRecipe = {
sourceRepoId: repoId,
recipeId: activeEphemeralVmRecipeId,
projectId: selectedWorkspaceTarget.target.projectId
projectId: selectedWorkspaceTarget.target.projectId,
...(selectedRecipe?.checkoutMode ? { checkoutMode: selectedRecipe.checkoutMode } : {})
}
}
@@ -4630,6 +4634,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
setupConfig,
setupPolicy,
selectedRepoHookContextKey,
ephemeralVmRecipes,
isProjectGroupTarget,
submitFolderTarget,
createMultiple,
+5 -1
View File
@@ -717,7 +717,8 @@
"wakeEphemeralVmFailed": "Failed to wake ephemeral VM workspace"
},
"ephemeralVmWorkspaceTarget": {
"projectRootRegistrationFailed": "Failed to register the recipe-created project root on the runtime."
"projectRootRegistrationFailed": "Failed to register the recipe-created project root on the runtime.",
"provisionedRootRequiresSsh": "Provisioned-root recipes currently require a direct SSH connection."
},
"blocked": {
"notification": {
@@ -789,6 +790,9 @@
"additionalErrors": "{{count}} additional images could not be attached."
}
}
},
"ephemeralVmWorktreeCreation": {
"sparseCheckoutUnsupported": "Provisioned-root recipes do not support sparse checkout."
}
},
"hooks": {
@@ -0,0 +1,59 @@
import { describe, expect, it, vi } from 'vitest'
import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation'
import { cleanupFailedEphemeralVmWorkspace } from './ephemeral-vm-failed-create-cleanup'
function request(): WorktreeCreationRequest {
return {
repoId: 'repo-1',
name: 'feature',
setupDecision: 'inherit',
agent: null,
pendingFirstAgentMessageRename: false,
note: '',
startupPlan: null,
quickPrompt: '',
quickTelemetry: null,
ephemeralVmRuntimeId: 'runtime-1',
ephemeralVmCheckoutMode: 'provisioned-root',
workspaceRunContext: {
kind: 'workspace-run',
projectId: 'project-1',
hostId: 'ssh:runtime-ssh-1',
projectHostSetupId: 'setup-1',
repoId: 'repo-1',
path: '/workspace/repo'
}
}
}
describe('cleanupFailedEphemeralVmWorkspace', () => {
it('removes the imported project setup before destroying its VM', async () => {
const order: string[] = []
await cleanupFailedEphemeralVmWorkspace(request(), {
deleteProjectHostSetup: vi.fn(async () => {
order.push('setup')
}),
cleanupRuntime: vi.fn(async () => {
order.push('runtime')
}),
reportSetupError: vi.fn(),
reportRuntimeError: vi.fn()
})
expect(order).toEqual(['setup', 'runtime'])
})
it('still destroys the VM when setup deletion fails', async () => {
const cleanupRuntime = vi.fn().mockResolvedValue(undefined)
const reportSetupError = vi.fn()
await cleanupFailedEphemeralVmWorkspace(request(), {
deleteProjectHostSetup: vi.fn().mockRejectedValue(new Error('setup delete failed')),
cleanupRuntime,
reportSetupError,
reportRuntimeError: vi.fn()
})
expect(reportSetupError).toHaveBeenCalledOnce()
expect(cleanupRuntime).toHaveBeenCalledWith('runtime-1')
})
})
@@ -0,0 +1,32 @@
import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation'
type FailedCreateCleanupActions = {
deleteProjectHostSetup: (setupId: string) => Promise<unknown>
cleanupRuntime: (runtimeId: string) => Promise<unknown>
reportSetupError: (error: unknown) => void
reportRuntimeError: (error: unknown) => void
}
export async function cleanupFailedEphemeralVmWorkspace(
request: WorktreeCreationRequest,
actions: FailedCreateCleanupActions
): Promise<void> {
if (!request.ephemeralVmRuntimeId) {
return
}
if (
request.ephemeralVmCheckoutMode === 'provisioned-root' &&
request.workspaceRunContext?.projectHostSetupId
) {
try {
await actions.deleteProjectHostSetup(request.workspaceRunContext.projectHostSetupId)
} catch (error) {
actions.reportSetupError(error)
}
}
try {
await actions.cleanupRuntime(request.ephemeralVmRuntimeId)
} catch (error) {
actions.reportRuntimeError(error)
}
}
@@ -93,6 +93,7 @@ describe('prepareEphemeralVmWorkspaceTarget', () => {
setup: { ...setupResult.setup, hostId: 'runtime:env-1' }
},
runtimeId: 'runtime-1',
checkoutMode: 'orca-worktree',
environmentId: 'env-1',
stderr: 'creating sandbox',
warnings: []
@@ -163,12 +164,64 @@ describe('prepareEphemeralVmWorkspaceTarget', () => {
setup: { ...setupResult.setup, hostId: 'ssh:runtime-ssh-runtime-1' }
},
runtimeId: 'runtime-1',
checkoutMode: 'orca-worktree',
stderr: 'creating sandbox',
warnings: []
})
expect(window.api.ephemeralVm.cleanup).not.toHaveBeenCalled()
})
it('rejects and cleans up an Orca-server provisioned root before project import', async () => {
vi.mocked(window.api.ephemeralVm.provision).mockResolvedValue({
ok: true,
connectionType: 'orca-server',
stderr: 'creating sandbox',
warnings: [],
environment: {
id: 'env-1',
name: 'Repo VM',
createdAt: 1,
updatedAt: 1,
lastUsedAt: null,
runtimeId: null,
endpoints: [{ id: 'ws-env-1', kind: 'websocket', label: 'WebSocket', endpoint: 'wss://x' }],
preferredEndpointId: 'ws-env-1'
},
runtime: {
id: 'runtime-1',
repoId: 'repo-1',
recipeId: 'cloud-sandbox',
runtimeEnvironmentId: 'env-1',
status: 'running',
cleanupStatus: 'not_started',
createdAt: 1,
updatedAt: 1,
recipeResult: {
schemaVersion: 2,
checkoutMode: 'provisioned-root',
pairingCode: 'orca://pair?code=test',
projectRoot: '/workspace/repo'
}
}
})
const setupExistingFolder = vi.fn()
const result = await prepareEphemeralVmWorkspaceTarget({
repoId: 'repo-1',
recipeId: 'cloud-sandbox',
projectId: 'project-1',
workspaceName: 'Fix Login Race',
setupExistingFolder
})
expect(result).toMatchObject({
ok: false,
error: 'Provisioned-root recipes currently require a direct SSH connection.'
})
expect(window.api.ephemeralVm.cleanup).toHaveBeenCalledWith({ runtimeId: 'runtime-1' })
expect(setupExistingFolder).not.toHaveBeenCalled()
})
it('cleans up the runtime when required project setup capability is missing', async () => {
vi.mocked(assertRuntimeEnvironmentCapability).mockRejectedValue(
new Error('The recipe-created Orca server does not support project setup.')
@@ -3,7 +3,10 @@ import type {
ProjectHostSetupExistingFolderArgs,
ProjectHostSetupResult
} from '../../../shared/types'
import { getEphemeralVmRecipeResultProjectRoot } from '../../../shared/ephemeral-vm-recipes'
import {
getEphemeralVmRecipeResultCheckoutMode,
getEphemeralVmRecipeResultProjectRoot
} from '../../../shared/ephemeral-vm-recipes'
import type { EphemeralVmRecipeResultWarning } from '../../../shared/ephemeral-vm-recipe-diagnostics'
import { PROJECT_HOST_SETUP_RUNTIME_CAPABILITY } from '../../../shared/protocol-version'
import { translate } from '@/i18n/i18n'
@@ -14,6 +17,8 @@ export type PrepareEphemeralVmWorkspaceTargetArgs = {
recipeId: string
projectId: string
workspaceName: string
branch?: string
ref?: string
provisionId?: string
setupExistingFolder: (
args: ProjectHostSetupExistingFolderArgs
@@ -25,6 +30,7 @@ export type PrepareEphemeralVmWorkspaceTargetResult =
ok: true
setup: ProjectHostSetupResult
runtimeId: string
checkoutMode: 'orca-worktree' | 'provisioned-root'
environmentId?: string
stderr: string
warnings: EphemeralVmRecipeResultWarning[]
@@ -43,12 +49,27 @@ export async function prepareEphemeralVmWorkspaceTarget(
recipeId: args.recipeId,
projectId: args.projectId,
workspaceName: args.workspaceName,
...(args.branch ? { branch: args.branch } : {}),
...(args.ref ? { ref: args.ref } : {}),
...(args.provisionId ? { provisionId: args.provisionId } : {})
})
if (!provisioned.ok) {
return { ok: false, error: provisioned.error, stderr: provisioned.stderr }
}
const checkoutMode = getEphemeralVmRecipeResultCheckoutMode(provisioned.runtime.recipeResult)
if (checkoutMode === 'provisioned-root' && provisioned.connectionType !== 'ssh') {
await cleanupProvisionedRuntime(provisioned.runtime.id)
return {
ok: false,
error: translate(
'auto.lib.ephemeralVmWorkspaceTarget.provisionedRootRequiresSsh',
'Provisioned-root recipes currently require a direct SSH connection.'
),
stderr: provisioned.stderr
}
}
const hostId =
provisioned.connectionType === 'ssh'
? toSshExecutionHostId(provisioned.sshTargetId)
@@ -112,6 +133,7 @@ export async function prepareEphemeralVmWorkspaceTarget(
ok: true,
setup,
runtimeId: provisioned.runtime.id,
checkoutMode,
stderr: provisioned.stderr,
warnings: provisioned.warnings
} satisfies PrepareEphemeralVmWorkspaceTargetResult
@@ -4,6 +4,8 @@ import { prepareEphemeralVmWorkspaceTarget } from '@/lib/ephemeral-vm-workspace-
import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation'
import { getProjectIdentityKey } from '../../../shared/project-host-setup-projection'
import type { Repo } from '../../../shared/types'
import { translate } from '@/i18n/i18n'
import { cleanupFailedEphemeralVmWorkspace } from '@/lib/ephemeral-vm-failed-create-cleanup'
const MAX_PROVISIONING_LOG_CHARS = 12_000
@@ -15,6 +17,13 @@ export async function prepareRequestForCreate(
return request
}
const store = useAppStore.getState()
if (request.ephemeralVmRecipe.checkoutMode === 'provisioned-root' && request.sparseCheckout) {
store.updatePendingWorktreeCreation(creationId, {
status: 'error',
error: getProvisionedRootSparseCheckoutError()
})
return null
}
store.updatePendingWorktreeCreation(creationId, {
phase: 'provisioning-vm',
provisioningLog: ''
@@ -36,6 +45,12 @@ export async function prepareRequestForCreate(
projectId:
resolvePortableEphemeralVmProjectId(sourceRepo) ?? request.ephemeralVmRecipe.projectId,
workspaceName: request.name,
...(request.ephemeralVmRecipe.checkoutMode === 'provisioned-root'
? {
branch: request.branchNameOverride ?? request.name,
...(request.baseBranch ? { ref: request.baseBranch } : {})
}
: {}),
provisionId: creationId,
setupExistingFolder: store.setupProjectExistingFolder
})
@@ -59,8 +74,11 @@ export async function prepareRequestForCreate(
const preparedRequest: WorktreeCreationRequest = {
...request,
repoId: preparedTarget.setup.repo.id,
...getEphemeralVmPortableBaseSelection(request),
...(preparedTarget.checkoutMode === 'provisioned-root'
? { baseBranch: request.baseBranch, compareBaseRef: request.compareBaseRef }
: getEphemeralVmPortableBaseSelection(request)),
ephemeralVmRuntimeId: preparedTarget.runtimeId,
ephemeralVmCheckoutMode: preparedTarget.checkoutMode,
...(preparedTarget.environmentId
? { ephemeralVmRuntimeEnvironmentId: preparedTarget.environmentId }
: {}),
@@ -142,7 +160,7 @@ export async function attachEphemeralVmRuntimeToWorkspace(
request: WorktreeCreationRequest,
workspaceId: string
): Promise<void> {
if (!request.ephemeralVmRuntimeId) {
if (!request.ephemeralVmRuntimeId || request.ephemeralVmCheckoutMode === 'provisioned-root') {
return
}
try {
@@ -175,12 +193,22 @@ function resolvePortableEphemeralVmProjectId(repo: Repo | undefined): string | n
export async function cleanupEphemeralVmRuntimeForFailedCreate(
request: WorktreeCreationRequest
): Promise<void> {
if (!request.ephemeralVmRuntimeId) {
return
}
try {
await window.api.ephemeralVm.cleanup({ runtimeId: request.ephemeralVmRuntimeId })
} catch (error) {
console.error('Failed to clean up ephemeral VM runtime after workspace creation failed:', error)
}
await cleanupFailedEphemeralVmWorkspace(request, {
deleteProjectHostSetup: (setupId) => useAppStore.getState().deleteProjectHostSetup({ setupId }),
cleanupRuntime: (runtimeId) => window.api.ephemeralVm.cleanup({ runtimeId }),
reportSetupError: (error) =>
console.error('Failed to remove provisioned-root project setup:', error),
reportRuntimeError: (error) =>
console.error(
'Failed to clean up ephemeral VM runtime after workspace creation failed:',
error
)
})
}
export function getProvisionedRootSparseCheckoutError(): string {
return translate(
'auto.lib.ephemeralVmWorktreeCreation.sparseCheckoutUnsupported',
'Provisioned-root recipes do not support sparse checkout.'
)
}
@@ -45,12 +45,15 @@ export type WorktreeCreationRequest = {
/** Runtime environment created from the VM's pairing code. Used to refresh
* live status immediately after the workspace takes ownership. */
ephemeralVmRuntimeEnvironmentId?: string
/** Checkout ownership selected by the provisioned recipe. */
ephemeralVmCheckoutMode?: 'orca-worktree' | 'provisioned-root'
/** Recipe to provision before creating the worktree. Kept serializable so
* retry can rerun the recipe after a failed create. */
ephemeralVmRecipe?: {
sourceRepoId: string
recipeId: string
projectId: string
checkoutMode?: 'orca-worktree' | 'provisioned-root'
}
/** Captured from the repo/run owner at submit time so Retry keeps the same
* local-vs-runtime progress behavior even if the focused runtime changes. */
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import type { WorktreeCreationRequest } from './pending-worktree-creation'
import { getProvisionedRootCreateOptions } from './provisioned-root-create-options'
function request(overrides: Partial<WorktreeCreationRequest> = {}): WorktreeCreationRequest {
return {
repoId: 'repo-1',
name: 'feature',
setupDecision: 'inherit',
agent: null,
pendingFirstAgentMessageRename: false,
note: '',
startupPlan: null,
quickPrompt: '',
quickTelemetry: null,
...overrides
}
}
describe('getProvisionedRootCreateOptions', () => {
it('leaves ordinary workspace creation unchanged', () => {
expect(getProvisionedRootCreateOptions(request())).toBeNull()
})
it('returns the main-owned adoption identity', () => {
expect(
getProvisionedRootCreateOptions(
request({
ephemeralVmCheckoutMode: 'provisioned-root',
ephemeralVmRuntimeId: 'runtime-1',
workspaceRunContext: {
kind: 'workspace-run',
projectId: 'project-1',
hostId: 'ssh:runtime-ssh-one',
projectHostSetupId: 'setup-1',
repoId: 'repo-runtime',
path: '/workspace/repo'
}
})
)
).toEqual({
runtimeId: 'runtime-1',
executionHostId: 'ssh:runtime-ssh-one',
expectedPath: '/workspace/repo'
})
})
it('rejects incomplete provisioned-root identity', () => {
expect(() =>
getProvisionedRootCreateOptions(
request({ ephemeralVmCheckoutMode: 'provisioned-root', ephemeralVmRuntimeId: 'runtime-1' })
)
).toThrow('identity is incomplete')
})
})
@@ -0,0 +1,23 @@
import type { WorktreeCreationRequest } from './pending-worktree-creation'
export type ProvisionedRootCreateOptions = {
runtimeId: string
executionHostId: NonNullable<WorktreeCreationRequest['workspaceRunContext']>['hostId']
expectedPath: string
}
export function getProvisionedRootCreateOptions(
request: WorktreeCreationRequest
): ProvisionedRootCreateOptions | null {
if (request.ephemeralVmCheckoutMode !== 'provisioned-root') {
return null
}
if (!request.ephemeralVmRuntimeId || !request.workspaceRunContext) {
throw new Error('Provisioned-root workspace identity is incomplete.')
}
return {
runtimeId: request.ephemeralVmRuntimeId,
executionHostId: request.workspaceRunContext.hostId,
expectedPath: request.workspaceRunContext.path
}
}
+12 -7
View File
@@ -13,6 +13,7 @@ import {
cleanupEphemeralVmRuntimeForFailedCreate,
prepareRequestForCreate
} from '@/lib/ephemeral-vm-worktree-creation'
import { getProvisionedRootCreateOptions } from '@/lib/provisioned-root-create-options'
import {
formatWorkspaceCreateError,
getWorkspaceCreateErrorToastMessage
@@ -105,7 +106,8 @@ async function executeWorktreeCreation(
let result: CreateWorktreeResult
try {
const backendStartup = resolveBackendDraftStartup(preparedRequest)
const provisionedRoot = getProvisionedRootCreateOptions(preparedRequest)
const backendStartup = provisionedRoot ? undefined : resolveBackendDraftStartup(preparedRequest)
result = await useAppStore
.getState()
.createWorktree(
@@ -144,7 +146,8 @@ async function executeWorktreeCreation(
// Why: the remote host must own task-draft startup so its initial terminal is the agent, not an idle fallback shell.
...(!backendStartup && preparedRequest.agent && preparedRequest.launchDraftPrompt
? { startupDraft: preparedRequest.launchDraftPrompt }
: {})
: {}),
...(provisionedRoot ? { provisionedRoot } : {})
}
)
} catch (error) {
@@ -153,7 +156,9 @@ async function executeWorktreeCreation(
if (!useAppStore.getState().pendingWorktreeCreations[creationId]) {
return
}
await cleanupEphemeralVmRuntimeForFailedCreate(preparedRequest)
if (preparedRequest.ephemeralVmRuntimeId) {
await cleanupEphemeralVmRuntimeForFailedCreate(preparedRequest)
}
const message = getWorkspaceCreateErrorToastMessage(formatWorkspaceCreateError(error))
// Why: an error must stay on the same creation surface that owns the faux
// tab strip, rather than falling back to stale previous-workspace tabs.
@@ -171,11 +176,11 @@ async function executeWorktreeCreation(
}
const worktree = result.worktree
// Why: if the user dismissed/cancelled while the create was in flight, the entry
// is gone. Git already made the worktree on disk, but don't auto-provision (trust
// write, terminal, agent, note) work they abandoned — it surfaces as a plain row
// via worktrees:changed and provisions lazily on first open.
// Why: cancellation can race a successful backend adoption; clean up again after it settles so an adopted workspace cannot outlive its destroyed VM.
if (!useAppStore.getState().pendingWorktreeCreations[creationId]) {
if (preparedRequest.ephemeralVmRuntimeId) {
await cleanupEphemeralVmRuntimeForFailedCreate(preparedRequest)
}
return
}
await attachEphemeralVmRuntimeToWorkspace(preparedRequest, worktree.id)
+7 -4
View File
@@ -3399,10 +3399,13 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
} catch (err) {
console.error('Failed to delete project host setup:', err)
const message = err instanceof Error ? err.message : String(err)
toast.error(translate('auto.store.slices.repos.c6e022ddfc', 'Failed to add project'), {
description: message,
duration: ERROR_TOAST_DURATION
})
toast.error(
translate('auto.store.slices.repos.removeProjectFailed', 'Failed to remove project'),
{
description: message,
duration: ERROR_TOAST_DURATION
}
)
return null
}
},
@@ -209,6 +209,11 @@ export type WorktreeSlice = {
linkedTaskSourceContext?: TaskSourceContext | null
/** Lets the owning runtime launch and prefill a task agent without first creating an idle shell. */
startupDraft?: string
provisionedRoot?: {
runtimeId: string
executionHostId: ExecutionHostId
expectedPath: string
}
}
) => Promise<CreateWorktreeResult>
/** Register an in-flight background creation and make it the active surface. */
@@ -122,6 +122,7 @@ const forgetRemovedForExecutionHostMock = vi.fn<
const mockApi = {
worktrees: {
create: vi.fn(),
adoptProvisionedRoot: vi.fn(),
prefetchCreateBase: vi.fn().mockResolvedValue(undefined),
list: worktreeListMock,
listDetected: listDetectedMock,
@@ -197,6 +198,8 @@ function resetRemoteRuntimeMocks() {
// earlier describe would silently suppress a row here. Reset for every case, not just the fetch suites.
beforeEach(() => {
resetAuthoritativelyRemovedWorktreeMemoryForTests()
mockApi.worktrees.create.mockReset()
mockApi.worktrees.adoptProvisionedRoot.mockReset()
})
function createTestStore() {
@@ -4927,6 +4930,69 @@ describe('createWorktree base status merge', () => {
})
})
it('adopts an explicit provisioned root without calling ordinary worktree create', async () => {
const store = createTestStore()
const adopted = makeWorktree({
id: 'repo1::/workspace/repo',
repoId: 'repo1',
path: '/workspace/repo',
hostId: 'ssh:runtime-ssh-runtime-1',
isMainWorktree: true,
ephemeralVmCheckoutMode: 'provisioned-root'
})
mockApi.worktrees.adoptProvisionedRoot.mockResolvedValue({ worktree: adopted })
await store
.getState()
.createWorktree(
'repo1',
'feature',
undefined,
'inherit',
undefined,
'sidebar',
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
{
provisionedRoot: {
runtimeId: 'runtime-1',
executionHostId: 'ssh:runtime-ssh-runtime-1',
expectedPath: '/workspace/repo'
}
}
)
expect(mockApi.worktrees.adoptProvisionedRoot).toHaveBeenCalledWith(
expect.objectContaining({
repoId: 'repo1',
runtimeId: 'runtime-1',
executionHostId: 'ssh:runtime-ssh-runtime-1',
expectedPath: '/workspace/repo'
})
)
expect(mockApi.worktrees.create).not.toHaveBeenCalled()
expect(store.getState().worktreesByRepo.repo1).toContainEqual(
expect.objectContaining({ id: adopted.id, ephemeralVmCheckoutMode: 'provisioned-root' })
)
})
it('stamps the owning runtime host onto worktrees created on a remote runtime', async () => {
const store = createTestStore()
const created = makeWorktree({
@@ -10461,21 +10527,34 @@ describe('pending worktree creation state', () => {
expect(store.getState().pendingWorktreeCreations.c1).toBeUndefined()
})
it('removePendingWorktreeCreation cleans up a provisioned VM runtime', () => {
it('removePendingWorktreeCreation cleans up a provisioned-root setup and VM runtime', async () => {
const store = createTestStore()
const deleteProjectHostSetup = vi.mocked(store.getState().deleteProjectHostSetup)
store.getState().beginPendingWorktreeCreation(
makePendingCreation('c1', {
phase: 'fetching',
request: {
...makePendingCreation('c1').request,
ephemeralVmRuntimeId: 'runtime-1'
ephemeralVmRuntimeId: 'runtime-1',
ephemeralVmCheckoutMode: 'provisioned-root',
workspaceRunContext: {
kind: 'workspace-run',
projectId: 'project-1',
hostId: 'ssh:runtime-ssh-1',
projectHostSetupId: 'setup-1',
repoId: 'repo-1',
path: '/workspace/repo'
}
}
})
)
store.getState().removePendingWorktreeCreation('c1')
expect(mockApi.ephemeralVm.cleanup).toHaveBeenCalledWith({ runtimeId: 'runtime-1' })
expect(deleteProjectHostSetup).toHaveBeenCalledWith({ setupId: 'setup-1' })
await vi.waitFor(() =>
expect(mockApi.ephemeralVm.cleanup).toHaveBeenCalledWith({ runtimeId: 'runtime-1' })
)
expect(store.getState().pendingWorktreeCreations.c1).toBeUndefined()
})
+32 -25
View File
@@ -40,6 +40,7 @@ import {
} from './stale-runtime-host-rows'
import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed'
import { cleanupEphemeralVmRuntimesForDeleted } from '@/lib/ephemeral-vm-runtime-cleanup'
import { cleanupFailedEphemeralVmWorkspace } from '@/lib/ephemeral-vm-failed-create-cleanup'
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
import { disposeRemovedWorktreeParkedTerminalWatchers } from '../../components/terminal-pane/terminal-parked-watcher-registry'
import {
@@ -3927,6 +3928,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
const linkedWorkItem = options?.linkedWorkItem
const linkedTaskSourceContext = options?.linkedTaskSourceContext
const startupDraft = options?.startupDraft
const provisionedRoot = options?.provisionedRoot
try {
for (let attempt = 0; attempt < CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS; attempt += 1) {
const candidateName = getClientWorktreeCreateCandidate(name, attempt)
@@ -3991,8 +3993,15 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
'Update the remote runtime to link Jira'
)
}
const result =
target.kind === 'local'
if (provisionedRoot && target.kind !== 'local') {
throw new Error('Provisioned-root recipes currently require a direct SSH connection.')
}
const result = provisionedRoot
? await window.api.worktrees.adoptProvisionedRoot({
...createArgs,
...provisionedRoot
})
: target.kind === 'local'
? await window.api.worktrees.create(createArgs)
: await callRuntimeRpc<Awaited<ReturnType<typeof window.api.worktrees.create>>>(
target,
@@ -4150,34 +4159,13 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
},
removePendingWorktreeCreation: (creationId, options) => {
let removedEntry: AppState['pendingWorktreeCreations'][string] | undefined
set((s) => {
const entry = s.pendingWorktreeCreations[creationId]
if (!entry) {
return {}
}
const cleanupVm = options?.cleanupVm ?? true
if (
cleanupVm &&
entry.phase === 'provisioning-vm' &&
typeof window !== 'undefined' &&
window.api?.ephemeralVm?.cancelProvision
) {
void window.api.ephemeralVm.cancelProvision({ provisionId: creationId }).catch(() => {
// Best effort: dismissing the pending surface shouldn't block on a finished or unreachable provisioning process.
})
}
if (
cleanupVm &&
entry.request.ephemeralVmRuntimeId &&
typeof window !== 'undefined' &&
window.api?.ephemeralVm?.cleanup
) {
void window.api.ephemeralVm
.cleanup({ runtimeId: entry.request.ephemeralVmRuntimeId })
.catch(() => {
// Best effort: cancellation shouldn't block on provider cleanup; Settings still exposes retry/manual cleanup.
})
}
removedEntry = entry
const { [creationId]: _removed, ...rest } = s.pendingWorktreeCreations
return {
pendingWorktreeCreations: rest,
@@ -4185,6 +4173,25 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
...(s.activePendingCreationId === creationId ? { activePendingCreationId: null } : {})
}
})
if (!removedEntry || options?.cleanupVm === false || typeof window === 'undefined') {
return
}
if (removedEntry.phase === 'provisioning-vm' && window.api?.ephemeralVm?.cancelProvision) {
void window.api.ephemeralVm
.cancelProvision({ provisionId: creationId })
.catch(() => undefined)
}
if (!removedEntry.request.ephemeralVmRuntimeId || !window.api?.ephemeralVm?.cleanup) {
return
}
void cleanupFailedEphemeralVmWorkspace(removedEntry.request, {
deleteProjectHostSetup: (setupId) => get().deleteProjectHostSetup({ setupId }),
cleanupRuntime: (runtimeId) => window.api.ephemeralVm.cleanup({ runtimeId }),
reportSetupError: (error) =>
console.error('Failed to remove cancelled provisioned-root project setup:', error),
reportRuntimeError: (error) =>
console.error('Failed to clean up cancelled ephemeral VM runtime:', error)
})
},
setActivePendingWorktreeCreation: (creationId) => {
@@ -0,0 +1,165 @@
import { execFileSync } from 'node:child_process'
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { expect, test } from './helpers/orca-app'
import { ensureDockerSshRelayImage } from './helpers/docker-ssh-relay-image'
import {
cleanupDockerSshRelayTarget,
execDockerSshRelayTargetCommand,
shellQuote,
startDockerSshRelayTarget,
DOCKER_SSH_RELAY_REMOTE_REPO_PATH,
type DockerSshRelayTarget
} from './helpers/docker-ssh-relay-target'
import { ensureTerminalVisible, waitForSessionReady } from './helpers/store'
test.use({ seedTestRepo: false })
test('adopts a recipe-provisioned SSH root without creating a linked worktree', async ({
orcaPage
}, testInfo) => {
test.setTimeout(240_000)
let target: DockerSshRelayTarget | null = null
const sourceRepo = mkdtempSync(path.join(tmpdir(), 'orca-provisioned-root-source-'))
try {
ensureDockerSshRelayImage(process.cwd())
target = startDockerSshRelayTarget(testInfo)
seedRecipeRepo(sourceRepo, target)
await waitForSessionReady(orcaPage)
const sourceRepoId = await addRecipeRepo(orcaPage, sourceRepo)
await orcaPage.getByRole('button', { name: 'New workspace', exact: true }).click()
const dialog = orcaPage.getByRole('dialog', { name: /Create (Workspace|Worktree)/i })
await expect(dialog).toBeVisible()
await dialog.getByRole('combobox', { name: 'Run on' }).click()
await orcaPage.getByRole('option', { name: /Per-Workspace Environment/ }).click()
await orcaPage
.getByRole('listbox', { name: 'Per-Workspace Environment' })
.getByText('Docker provisioned root', { exact: true })
.click()
const workspaceName = `provisioned-root-${Date.now()}`
await dialog.getByPlaceholder(/Type a name/i).fill(workspaceName)
await dialog.getByRole('button', { name: /Create (Workspace|Worktree)/i }).click()
const trustDialog = orcaPage.getByRole('dialog', { name: /Run VM recipe/ })
await expect(trustDialog).toBeVisible()
await trustDialog.getByRole('button', { name: 'Run hooks' }).click()
await expect(dialog).toBeHidden({ timeout: 60_000 })
await expect(orcaPage.getByRole('option', { name: new RegExp(workspaceName) })).toBeVisible({
timeout: 60_000
})
await ensureTerminalVisible(orcaPage)
const adopted = await orcaPage.evaluate(
({ sourceRepoId, workspaceName }) => {
const state = window.__store!.getState()
return Object.values(state.worktreesByRepo)
.flat()
.find(
(worktree) => worktree.displayName === workspaceName && worktree.repoId !== sourceRepoId
)
},
{ sourceRepoId, workspaceName }
)
expect(adopted).toMatchObject({
path: DOCKER_SSH_RELAY_REMOTE_REPO_PATH,
isMainWorktree: true,
ephemeralVmCheckoutMode: 'provisioned-root'
})
expect(adopted?.hostId).toMatch(/^ssh:runtime-ssh-/)
expect(
execDockerSshRelayTargetCommand(
target,
`git -C ${shellQuote(DOCKER_SSH_RELAY_REMOTE_REPO_PATH)} worktree list --porcelain | grep -c '^worktree '`
)
).toBe('1')
expect(
execDockerSshRelayTargetCommand(
target,
`git -C ${shellQuote(DOCKER_SSH_RELAY_REMOTE_REPO_PATH)} branch --show-current`
)
).toBe(workspaceName)
await orcaPage
.getByRole('option', { name: new RegExp(workspaceName) })
.click({ button: 'right' })
await orcaPage.getByRole('menuitem', { name: 'Remove Project from Orca' }).click()
const removeDialog = orcaPage.getByRole('dialog', { name: 'Remove Project' })
await expect(removeDialog).toBeVisible()
await removeDialog.getByRole('button', { name: 'Remove', exact: true }).click()
await expect
.poll(
() =>
orcaPage.evaluate(
(repoId) => window.__store!.getState().repos.some((repo) => repo.id === repoId),
adopted!.repoId
),
{ timeout: 30_000 }
)
.toBe(false)
expect(() => execDockerSshRelayTargetCommand(target, 'true')).toThrow()
} finally {
cleanupDockerSshRelayTarget(target)
rmSync(sourceRepo, { recursive: true, force: true })
}
})
async function addRecipeRepo(page: Parameters<typeof waitForSessionReady>[0], repoPath: string) {
return page.evaluate(async (pathValue) => {
const result = await window.api.repos.add({ path: pathValue })
if ('error' in result) {
throw new Error(result.error)
}
const store = window.__store!
await store.getState().fetchRepos()
await store.getState().updateSettings({ experimentalEphemeralVms: true })
store.getState().setActiveRepo(result.repo.id)
return result.repo.id
}, repoPath)
}
function seedRecipeRepo(repoPath: string, target: DockerSshRelayTarget): void {
const createScript = path.join(repoPath, 'create.sh')
const destroyScript = path.join(repoPath, 'destroy.sh')
writeFileSync(
createScript,
`#!/usr/bin/env bash
set -euo pipefail
[ "\${ORCA_RECIPE_RESULT_SCHEMA_VERSION:-}" = 2 ]
[ -n "\${ORCA_REPO_URL:-}" ]
[ -n "\${ORCA_REPO_BRANCH:-}" ]
docker exec ${shellQuote(target.containerName)} git -C ${shellQuote(DOCKER_SSH_RELAY_REMOTE_REPO_PATH)} checkout -B "$ORCA_REPO_BRANCH" >&2
node -e 'console.log(JSON.stringify({schemaVersion:2,checkoutMode:"provisioned-root",connection:{type:"ssh",projectRoot:process.argv[1],target:{label:"Docker provisioned root",host:process.argv[2],port:Number(process.argv[3]),username:"root",identityFile:process.argv[4],identitiesOnly:true}}}))' ${shellQuote(DOCKER_SSH_RELAY_REMOTE_REPO_PATH)} ${shellQuote(target.host)} ${target.port} ${shellQuote(target.identityFile)}
`
)
writeFileSync(
destroyScript,
`#!/usr/bin/env bash
set -euo pipefail
cat >/dev/null
docker rm -f ${shellQuote(target.containerName)} >/dev/null
`
)
chmodSync(createScript, 0o755)
chmodSync(destroyScript, 0o755)
writeFileSync(
path.join(repoPath, 'orca.yaml'),
`environmentRecipes:
- id: docker-provisioned-root
name: Docker provisioned root
checkoutMode: provisioned-root
create: ./create.sh
destroy: ./destroy.sh
`
)
execFileSync('git', ['init'], { cwd: repoPath })
execFileSync('git', ['config', 'user.email', 'e2e@test.local'], { cwd: repoPath })
execFileSync('git', ['config', 'user.name', 'Orca E2E'], { cwd: repoPath })
execFileSync('git', ['remote', 'add', 'origin', 'https://github.com/stablyai/orca.git'], {
cwd: repoPath
})
execFileSync('git', ['add', '.'], { cwd: repoPath })
execFileSync('git', ['commit', '-m', 'seed recipe'], { cwd: repoPath })
}
+12 -1
View File
@@ -1,4 +1,4 @@
import { execFileSync } from 'node:child_process'
import { execFileSync, spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { readFileSync, readdirSync } from 'node:fs'
import path from 'node:path'
@@ -51,3 +51,14 @@ export function prepareDockerSshRelayImage(root: string): void {
{ stdio: 'inherit', timeout: 300_000 }
)
}
export function ensureDockerSshRelayImage(root: string): void {
if (process.env.ORCA_E2E_SSH_DOCKER_IMAGE) {
return
}
const image = fixtureImage(root)
if (spawnSync('docker', ['image', 'inspect', image], { stdio: 'ignore' }).status === 0) {
return
}
prepareDockerSshRelayImage(root)
}