From 99d19d4635c40e23b2ef546136b22fa3efeb7367 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:23:21 -0700 Subject: [PATCH] feat(vm): add provisioned root recipe contract (#14352) --- src/main/ephemeral-vm-failed-start-cleanup.ts | 71 +++++++ src/main/ephemeral-vm-recipe-runner.test.ts | 73 +++++++ src/main/ephemeral-vm-resume-integrity.ts | 19 ++ src/main/ephemeral-vm-runtime-service.test.ts | 196 +++++++++++++++++- src/main/ephemeral-vm-runtime-service.ts | 17 ++ src/main/hooks.test.ts | 24 +++ .../ephemeral-vm-recipe-checkout-mode.test.ts | 43 ++++ .../ephemeral-vm-recipe-checkout-mode.ts | 21 ++ .../ephemeral-vm-recipe-process.test.ts | 2 + src/shared/ephemeral-vm-recipe-process.ts | 7 +- src/shared/ephemeral-vm-recipe-runner.ts | 30 +++ src/shared/ephemeral-vm-recipes.test.ts | 37 ++++ src/shared/ephemeral-vm-recipes.ts | 29 ++- src/shared/ephemeral-vm-runtimes.ts | 1 + src/shared/orca-yaml.ts | 10 + .../plugins/plugin-vm-recipe-artifact.test.ts | 7 +- .../plugins/plugin-vm-recipe-artifact.ts | 2 + src/shared/types.ts | 3 + 18 files changed, 586 insertions(+), 6 deletions(-) create mode 100644 src/main/ephemeral-vm-failed-start-cleanup.ts create mode 100644 src/main/ephemeral-vm-resume-integrity.ts create mode 100644 src/shared/ephemeral-vm-recipe-checkout-mode.test.ts create mode 100644 src/shared/ephemeral-vm-recipe-checkout-mode.ts diff --git a/src/main/ephemeral-vm-failed-start-cleanup.ts b/src/main/ephemeral-vm-failed-start-cleanup.ts new file mode 100644 index 00000000000..c7d3d991b2e --- /dev/null +++ b/src/main/ephemeral-vm-failed-start-cleanup.ts @@ -0,0 +1,71 @@ +import { + getEphemeralVmRecipeResultConnection, + type EphemeralVmRecipeResult +} from '../shared/ephemeral-vm-recipes' +import { upsertEphemeralVmRuntime } from '../shared/ephemeral-vm-runtime-store' +import type { ProvisionEphemeralVmRuntimeArgs } from './ephemeral-vm-runtime-service' +import { + runEphemeralVmRecipeCleanup, + type EphemeralVmRecipeContext +} from './ephemeral-vm-recipe-runner' + +type FailedStart = { + context: EphemeralVmRecipeContext + recipeResult: EphemeralVmRecipeResult +} + +export async function cleanupFailedEphemeralVmStart( + args: ProvisionEphemeralVmRuntimeArgs, + start: FailedStart +): Promise { + const cleanupError = await getCleanupError(args, start) + if (cleanupError === null) { + return + } + + const now = args.now ?? Date.now() + const connection = getEphemeralVmRecipeResultConnection(start.recipeResult) + upsertEphemeralVmRuntime(args.userDataPath, { + id: start.context.instanceId ?? start.context.recipeId, + recipeId: args.recipe.id, + recipe: args.recipe, + ...(args.repoId ? { repoId: args.repoId } : {}), + ...(args.projectId ? { projectId: args.projectId } : {}), + ...(args.workspaceId ? { workspaceId: args.workspaceId } : {}), + ...(args.workspaceName ? { workspaceName: args.workspaceName } : {}), + status: 'cleanup_failed', + connectionMode: connection.type, + cleanupStatus: args.recipe.destroyDisabled ? 'disabled' : 'failed', + ...(args.recipe.destroyDisabled ? { cleanupDisabled: true } : {}), + cleanupLastAttemptAt: now, + cleanupLastError: cleanupError, + createdAt: now, + updatedAt: now, + recipeResult: start.recipeResult + }) +} + +async function getCleanupError( + args: ProvisionEphemeralVmRuntimeArgs, + start: FailedStart +): Promise { + try { + const cleanup = await runEphemeralVmRecipeCleanup({ + repoPath: args.repoPath, + recipe: args.recipe, + context: start.context, + recipeResult: start.recipeResult, + signal: args.signal, + onStdout: args.onStdout, + onStderr: args.onStderr + }) + if (cleanup.ok && !cleanup.skipped) { + return null + } + return cleanup.ok + ? 'Destroy is disabled for this recipe.' + : (cleanup.error ?? 'Destroy failed.') + } catch (error) { + return error instanceof Error ? error.message : String(error) + } +} diff --git a/src/main/ephemeral-vm-recipe-runner.test.ts b/src/main/ephemeral-vm-recipe-runner.test.ts index e602155a3de..f5c3f4fc9fa 100644 --- a/src/main/ephemeral-vm-recipe-runner.test.ts +++ b/src/main/ephemeral-vm-recipe-runner.test.ts @@ -42,6 +42,42 @@ function nodeCommand(scriptPath: string): string { } describe('runEphemeralVmRecipeStart', () => { + it.each([ + { checkoutMode: undefined, expected: 1 }, + { checkoutMode: 'provisioned-root' as const, expected: 2 } + ])( + 'advertises result schema $expected for checkout mode $checkoutMode', + async ({ checkoutMode, expected }) => { + const repoPath = makeRepo() + const scriptPath = join(repoPath, 'start.js') + writeFileSync( + scriptPath, + [ + 'console.log(JSON.stringify({', + ' schemaVersion: Number(process.env.ORCA_RECIPE_RESULT_SCHEMA_VERSION),', + ' ...(process.env.ORCA_RECIPE_RESULT_SCHEMA_VERSION === "2"', + ' ? { checkoutMode: "provisioned-root" }', + ' : {}),', + ` pairingCode: ${JSON.stringify(makePairingCode())},`, + " projectRoot: '/workspace/repo'", + '}))' + ].join('\n') + ) + + const result = await runEphemeralVmRecipeStart({ + repoPath, + recipe: { + id: 'cloud-sandbox', + name: 'Cloud Sandbox', + checkoutMode, + create: nodeCommand(scriptPath) + } + }) + + expect(result).toMatchObject({ ok: true, result: { schemaVersion: expected } }) + } + ) + it('runs a recipe from the repo root and parses its JSON result', async () => { const repoPath = makeRepo() const scriptPath = join(repoPath, 'start.js') @@ -107,6 +143,43 @@ describe('runEphemeralVmRecipeStart', () => { }) }) + it('requires the versioned result handshake for provisioned-root recipes', async () => { + const repoPath = makeRepo() + const scriptPath = join(repoPath, 'start.js') + writeFileSync( + scriptPath, + [ + 'console.log(JSON.stringify({', + ' schemaVersion: Number(process.env.RESULT_VERSION),', + ' ...(process.env.RESULT_VERSION === "2" ? { checkoutMode: "provisioned-root" } : {}),', + ` pairingCode: ${JSON.stringify(makePairingCode())},`, + " projectRoot: '/workspace/repo'", + '}))' + ].join('\n') + ) + const recipe = { + id: 'cloud-sandbox', + name: 'Cloud Sandbox', + checkoutMode: 'provisioned-root' as const, + create: nodeCommand(scriptPath) + } + + await expect( + runEphemeralVmRecipeStart({ repoPath, recipe, env: { RESULT_VERSION: '1' } }) + ).resolves.toMatchObject({ + ok: false, + error: + 'Provisioned-root recipes must return schemaVersion 2 with checkoutMode "provisioned-root".', + recipeResult: { schemaVersion: 1 } + }) + await expect( + runEphemeralVmRecipeStart({ repoPath, recipe, env: { RESULT_VERSION: '2' } }) + ).resolves.toMatchObject({ + ok: true, + result: { schemaVersion: 2, checkoutMode: 'provisioned-root' } + }) + }) + it('returns a process failure when the recipe exits nonzero', async () => { const repoPath = makeRepo() const scriptPath = join(repoPath, 'start.js') diff --git a/src/main/ephemeral-vm-resume-integrity.ts b/src/main/ephemeral-vm-resume-integrity.ts new file mode 100644 index 00000000000..f9aa024085b --- /dev/null +++ b/src/main/ephemeral-vm-resume-integrity.ts @@ -0,0 +1,19 @@ +import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path' +import { + getEphemeralVmRecipeResultCheckoutMode, + getEphemeralVmRecipeResultProjectRoot, + type EphemeralVmRecipeResult +} from '../shared/ephemeral-vm-recipes' + +export function provisionedRootChangedDuringResume( + previous: EphemeralVmRecipeResult, + resumed: EphemeralVmRecipeResult +): boolean { + if (getEphemeralVmRecipeResultCheckoutMode(previous) !== 'provisioned-root') { + return false + } + return ( + normalizeRuntimePathForComparison(getEphemeralVmRecipeResultProjectRoot(previous)) !== + normalizeRuntimePathForComparison(getEphemeralVmRecipeResultProjectRoot(resumed)) + ) +} diff --git a/src/main/ephemeral-vm-runtime-service.test.ts b/src/main/ephemeral-vm-runtime-service.test.ts index aa78183ba60..80cc500b284 100644 --- a/src/main/ephemeral-vm-runtime-service.test.ts +++ b/src/main/ephemeral-vm-runtime-service.test.ts @@ -3,10 +3,14 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../shared/pairing' -import { listEphemeralVmRuntimes } from '../shared/ephemeral-vm-runtime-store' +import { + listEphemeralVmRuntimes, + upsertEphemeralVmRuntime +} from '../shared/ephemeral-vm-runtime-store' import { cleanupEphemeralVmRuntime, - provisionEphemeralVmRuntime + provisionEphemeralVmRuntime, + resumeEphemeralVmRuntime } from './ephemeral-vm-runtime-service' import type { OrcaVmRecipe } from '../shared/types' @@ -174,4 +178,192 @@ describe('ephemeral VM runtime service', () => { }) expect(listEphemeralVmRuntimes(userDataPath)).toEqual([]) }) + + it('destroys a provisioned resource when its checkout handshake is incompatible', async () => { + const userDataPath = makeDir('orca-ephemeral-vm-service-user-data-') + const repoPath = makeDir('orca-ephemeral-vm-service-repo-') + const startPath = join(repoPath, 'start.js') + const cleanupPath = join(repoPath, 'cleanup.js') + writeFileSync( + startPath, + `console.log(${JSON.stringify( + JSON.stringify({ + schemaVersion: 1, + pairingCode: makePairingCode(), + projectRoot: '/workspace/repo' + }) + )})` + ) + writeFileSync(cleanupPath, "require('fs').writeFileSync('cleanup-ran.txt', 'yes')") + + const provisioned = await provisionEphemeralVmRuntime({ + userDataPath, + repoPath, + recipe: { + id: 'cloud-sandbox', + name: 'Cloud Sandbox', + checkoutMode: 'provisioned-root', + create: nodeCommand(startPath), + destroy: nodeCommand(cleanupPath) + } + }) + + expect(provisioned).toMatchObject({ + ok: false, + start: { + error: + 'Provisioned-root recipes must return schemaVersion 2 with checkoutMode "provisioned-root".' + } + }) + expect(readFileSync(join(repoPath, 'cleanup-ran.txt'), 'utf8')).toBe('yes') + expect(listEphemeralVmRuntimes(userDataPath)).toEqual([]) + }) + + it('persists failed cleanup after an incompatible checkout handshake', async () => { + const userDataPath = makeDir('orca-ephemeral-vm-service-user-data-') + const repoPath = makeDir('orca-ephemeral-vm-service-repo-') + const startPath = join(repoPath, 'start.js') + const cleanupPath = join(repoPath, 'cleanup.js') + writeFileSync( + startPath, + `console.log(${JSON.stringify( + JSON.stringify({ + schemaVersion: 1, + pairingCode: makePairingCode(), + projectRoot: '/workspace/repo' + }) + )})` + ) + writeFileSync(cleanupPath, 'process.exit(1)') + const recipe: OrcaVmRecipe = { + id: 'cloud-sandbox', + name: 'Cloud Sandbox', + checkoutMode: 'provisioned-root', + create: nodeCommand(startPath), + destroy: nodeCommand(cleanupPath) + } + + const provisioned = await provisionEphemeralVmRuntime({ + userDataPath, + repoPath, + recipe, + repoId: 'repo-1', + workspaceName: 'Fix Login Race', + now: 1_000 + }) + + expect(provisioned.ok).toBe(false) + expect(listEphemeralVmRuntimes(userDataPath)).toEqual([ + expect.objectContaining({ + recipe, + repoId: 'repo-1', + workspaceName: 'Fix Login Race', + status: 'cleanup_failed', + cleanupStatus: 'failed', + cleanupLastAttemptAt: 1_000, + cleanupLastError: expect.any(String) + }) + ]) + }) + + it('persists incompatible resources when destroy is disabled', async () => { + const userDataPath = makeDir('orca-ephemeral-vm-service-user-data-') + const repoPath = makeDir('orca-ephemeral-vm-service-repo-') + const startPath = join(repoPath, 'start.js') + writeFileSync( + startPath, + `console.log(${JSON.stringify( + JSON.stringify({ + schemaVersion: 1, + pairingCode: makePairingCode(), + projectRoot: '/workspace/repo' + }) + )})` + ) + + await provisionEphemeralVmRuntime({ + userDataPath, + repoPath, + recipe: { + id: 'cloud-sandbox', + name: 'Cloud Sandbox', + checkoutMode: 'provisioned-root', + create: nodeCommand(startPath), + destroyDisabled: true + } + }) + + expect(listEphemeralVmRuntimes(userDataPath)).toEqual([ + expect.objectContaining({ + status: 'cleanup_failed', + cleanupStatus: 'disabled', + cleanupDisabled: true, + cleanupLastError: 'Destroy is disabled for this recipe.' + }) + ]) + }) + + it('rejects a provisioned root that moves during resume', async () => { + const userDataPath = makeDir('orca-ephemeral-vm-service-user-data-') + const repoPath = makeDir('orca-ephemeral-vm-service-repo-') + const resumePath = join(repoPath, 'resume.js') + writeFileSync( + resumePath, + [ + 'console.log(JSON.stringify({', + ' schemaVersion: 2,', + ' checkoutMode: "provisioned-root",', + ' connection: {', + ' type: "ssh",', + ' projectRoot: "/workspace/moved",', + ' target: { label: "VM", host: "host", port: 22, username: "orca" }', + ' }', + '}))' + ].join('\n') + ) + const recipe: OrcaVmRecipe = { + id: 'cloud-sandbox', + name: 'Cloud Sandbox', + checkoutMode: 'provisioned-root', + create: 'unused', + resume: nodeCommand(resumePath), + destroyDisabled: true + } + upsertEphemeralVmRuntime(userDataPath, { + id: 'runtime-1', + recipeId: recipe.id, + recipe, + status: 'suspended', + connectionMode: 'ssh', + cleanupStatus: 'disabled', + cleanupDisabled: true, + createdAt: 1, + updatedAt: 1, + recipeResult: { + schemaVersion: 2, + checkoutMode: 'provisioned-root', + connection: { + type: 'ssh', + projectRoot: '/workspace/original', + target: { label: 'VM', host: 'host', port: 22, username: 'orca' } + } + } + }) + + const resumed = await resumeEphemeralVmRuntime({ + userDataPath, + repoPath, + recipe, + runtimeId: 'runtime-1' + }) + + expect(resumed).toMatchObject({ + ok: false, + error: 'The provisioned workspace root changed while the runtime was suspended.', + runtime: { + status: 'resume_failed', + recipeResult: { connection: { projectRoot: '/workspace/original' } } + } + }) + }) }) diff --git a/src/main/ephemeral-vm-runtime-service.ts b/src/main/ephemeral-vm-runtime-service.ts index f53e534d7f2..ddbecb7cf6b 100644 --- a/src/main/ephemeral-vm-runtime-service.ts +++ b/src/main/ephemeral-vm-runtime-service.ts @@ -15,6 +15,8 @@ import { type EphemeralVmRecipeStartFailure, type EphemeralVmRecipeStartSuccess } from './ephemeral-vm-recipe-runner' +import { cleanupFailedEphemeralVmStart } from './ephemeral-vm-failed-start-cleanup' +import { provisionedRootChangedDuringResume } from './ephemeral-vm-resume-integrity' export type ProvisionEphemeralVmRuntimeArgs = { userDataPath: string @@ -114,6 +116,12 @@ export async function provisionEphemeralVmRuntime( onStderr: args.onStderr }) if (!start.ok) { + if (start.recipeResult) { + await cleanupFailedEphemeralVmStart(args, { + context: start.context, + recipeResult: start.recipeResult + }) + } return { ok: false, start } } @@ -274,6 +282,15 @@ export async function resumeEphemeralVmRuntime( return { ok: false, runtime: failed, error: resume.error } } + if (!resume.skipped && provisionedRootChangedDuringResume(existing.recipeResult, resume.result)) { + const error = 'The provisioned workspace root changed while the runtime was suspended.' + const failed = updateEphemeralVmRuntimeStatus(args.userDataPath, existing.id, { + status: 'resume_failed', + updatedAt: Date.now() + }) + return { ok: false, runtime: failed, error } + } + const runtime = updateEphemeralVmRuntimeStatus(args.userDataPath, existing.id, { status: 'running', ...(!resume.skipped ? { recipeResult: resume.result } : {}), diff --git a/src/main/hooks.test.ts b/src/main/hooks.test.ts index c39ea86cf2f..6cb9878f79c 100644 --- a/src/main/hooks.test.ts +++ b/src/main/hooks.test.ts @@ -201,6 +201,7 @@ describe('parseOrcaYaml', () => { 'environmentRecipes:', ' - id: cloud-sandbox', ' name: Cloud Sandbox', + ' checkoutMode: provisioned-root', ' description: Starts a per-workspace VM.', ' create: ./scripts/orca-vm/start-cloud-sandbox.sh', ' suspend: ./scripts/orca-vm/suspend-cloud-sandbox.sh', @@ -214,6 +215,7 @@ describe('parseOrcaYaml', () => { { id: 'cloud-sandbox', name: 'Cloud Sandbox', + checkoutMode: 'provisioned-root', description: 'Starts a per-workspace VM.', create: './scripts/orca-vm/start-cloud-sandbox.sh', suspend: './scripts/orca-vm/suspend-cloud-sandbox.sh', @@ -224,6 +226,28 @@ describe('parseOrcaYaml', () => { }) }) + it('rejects environment recipes with an unknown checkout mode', () => { + const yaml = [ + 'environmentRecipes:', + ' - id: cloud-sandbox', + ' name: Cloud Sandbox', + ' checkoutMode: magic', + ' create: ./scripts/create.sh' + ].join('\n') + + expect(parseOrcaYaml(yaml)).toEqual({ + scripts: {}, + environmentRecipeDiagnostics: [ + { + index: 0, + field: 'checkoutMode', + message: + 'Recipe "cloud-sandbox" checkoutMode must be "orca-worktree" or "provisioned-root".' + } + ] + }) + }) + it('parses legacy environmentRecipes command and cleanup aliases', () => { const yaml = [ 'environmentRecipes:', diff --git a/src/shared/ephemeral-vm-recipe-checkout-mode.test.ts b/src/shared/ephemeral-vm-recipe-checkout-mode.test.ts new file mode 100644 index 00000000000..ea77181ada0 --- /dev/null +++ b/src/shared/ephemeral-vm-recipe-checkout-mode.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { + getEphemeralVmRecipeCheckoutModeError, + getEphemeralVmRecipeResultSchemaVersion +} from './ephemeral-vm-recipe-checkout-mode' +import type { OrcaVmRecipe } from './types' + +const defaultRecipe: OrcaVmRecipe = { + id: 'cloud-sandbox', + name: 'Cloud Sandbox', + create: './create.sh' +} + +describe('ephemeral VM recipe checkout mode', () => { + it('keeps existing recipes on schema version 1', () => { + expect(getEphemeralVmRecipeResultSchemaVersion(defaultRecipe)).toBe(1) + expect( + getEphemeralVmRecipeCheckoutModeError(defaultRecipe, { + schemaVersion: 1, + pairingCode: 'orca://pair?code=test', + projectRoot: '/workspace/repo' + }) + ).toBeNull() + }) + + it('requires both sides to opt in to provisioned-root', () => { + const provisionedRootResult = { + schemaVersion: 2 as const, + checkoutMode: 'provisioned-root' as const, + pairingCode: 'orca://pair?code=test', + projectRoot: '/workspace/repo' + } + expect(getEphemeralVmRecipeCheckoutModeError(defaultRecipe, provisionedRootResult)).toBe( + 'Recipe result requests provisioned-root checkout, but the recipe is not configured for it.' + ) + expect( + getEphemeralVmRecipeCheckoutModeError( + { ...defaultRecipe, checkoutMode: 'provisioned-root' }, + provisionedRootResult + ) + ).toBeNull() + }) +}) diff --git a/src/shared/ephemeral-vm-recipe-checkout-mode.ts b/src/shared/ephemeral-vm-recipe-checkout-mode.ts new file mode 100644 index 00000000000..1b50da88312 --- /dev/null +++ b/src/shared/ephemeral-vm-recipe-checkout-mode.ts @@ -0,0 +1,21 @@ +import { getEphemeralVmRecipeResultCheckoutMode } from './ephemeral-vm-recipes' +import type { EphemeralVmRecipeResult } from './ephemeral-vm-recipes' +import type { OrcaVmRecipe } from './types' + +export function getEphemeralVmRecipeResultSchemaVersion(recipe: OrcaVmRecipe): 1 | 2 { + return recipe.checkoutMode === 'provisioned-root' ? 2 : 1 +} + +export function getEphemeralVmRecipeCheckoutModeError( + recipe: OrcaVmRecipe, + result: EphemeralVmRecipeResult +): string | null { + const configuredMode = recipe.checkoutMode ?? 'orca-worktree' + const resultMode = getEphemeralVmRecipeResultCheckoutMode(result) + if (configuredMode === resultMode) { + return null + } + return configuredMode === 'provisioned-root' + ? 'Provisioned-root recipes must return schemaVersion 2 with checkoutMode "provisioned-root".' + : 'Recipe result requests provisioned-root checkout, but the recipe is not configured for it.' +} diff --git a/src/shared/ephemeral-vm-recipe-process.test.ts b/src/shared/ephemeral-vm-recipe-process.test.ts index 0933305f689..118847ab251 100644 --- a/src/shared/ephemeral-vm-recipe-process.test.ts +++ b/src/shared/ephemeral-vm-recipe-process.test.ts @@ -38,6 +38,7 @@ describe('runRecipeCommand', () => { command: nodeCommand(scriptPath), repoPath, mode: 'create', + resultSchemaVersion: 1, context: { recipeId: 'cloud-sandbox', repoPath @@ -71,6 +72,7 @@ describe('runRecipeCommand', () => { command: nodeCommand(scriptPath), repoPath, mode: 'create', + resultSchemaVersion: 1, context: { recipeId: 'cloud-sandbox', repoPath diff --git a/src/shared/ephemeral-vm-recipe-process.ts b/src/shared/ephemeral-vm-recipe-process.ts index 5c0c24c82b9..d2ecc5b83b8 100644 --- a/src/shared/ephemeral-vm-recipe-process.ts +++ b/src/shared/ephemeral-vm-recipe-process.ts @@ -25,6 +25,7 @@ export async function runRecipeCommand(args: { repoPath: string context: EphemeralVmRecipeContext mode: 'create' | 'suspend' | 'resume' | 'destroy' + resultSchemaVersion: 1 | 2 stdin?: string env?: NodeJS.ProcessEnv maxCaptureBytes?: number @@ -42,7 +43,7 @@ export async function runRecipeCommand(args: { child = spawnCommand(args.command, { cwd: args.repoPath, detached: process.platform !== 'win32', - env: buildRecipeEnv(args.env, args.mode, args.context), + env: buildRecipeEnv(args.env, args.mode, args.context, args.resultSchemaVersion), shell: true, windowsHide: true }) as ChildProcessWithoutNullStreams @@ -123,7 +124,8 @@ function killRecipeProcess(child: ChildProcessWithoutNullStreams): void { function buildRecipeEnv( env: NodeJS.ProcessEnv | undefined, mode: 'create' | 'suspend' | 'resume' | 'destroy', - context: EphemeralVmRecipeContext + context: EphemeralVmRecipeContext, + resultSchemaVersion: 1 | 2 ): NodeJS.ProcessEnv { return { ...process.env, @@ -138,6 +140,7 @@ function buildRecipeEnv( ORCA_REPO_URL: context.repoUrl ?? '', ORCA_REPO_BRANCH: context.branch ?? '', ORCA_REPO_REF: context.ref ?? '', + ORCA_RECIPE_RESULT_SCHEMA_VERSION: String(resultSchemaVersion), ORCA_VERSION: context.orcaVersion ?? '' } } diff --git a/src/shared/ephemeral-vm-recipe-runner.ts b/src/shared/ephemeral-vm-recipe-runner.ts index 3652bc628d8..496e9573fdb 100644 --- a/src/shared/ephemeral-vm-recipe-runner.ts +++ b/src/shared/ephemeral-vm-recipe-runner.ts @@ -3,6 +3,10 @@ import { randomUUID } from 'node:crypto' import { statSync } from 'node:fs' import type { OrcaVmRecipe } from './types' import { parseEphemeralVmRecipeResult, type EphemeralVmRecipeResult } from './ephemeral-vm-recipes' +import { + getEphemeralVmRecipeCheckoutModeError, + getEphemeralVmRecipeResultSchemaVersion +} from './ephemeral-vm-recipe-checkout-mode' import { runRecipeCommand } from './ephemeral-vm-recipe-process' import { buildEphemeralVmRecipeCleanupPayload, @@ -56,6 +60,7 @@ export type EphemeralVmRecipeStartFailure = { stderr: string exitCode: number | null signal: NodeJS.Signals | null + recipeResult?: EphemeralVmRecipeResult } export type EphemeralVmRecipeStartResult = @@ -108,6 +113,7 @@ export async function runEphemeralVmRecipeStart( repoPath: args.repoPath, context, mode: 'create', + resultSchemaVersion: getEphemeralVmRecipeResultSchemaVersion(args.recipe), env: args.env, maxCaptureBytes: args.maxCaptureBytes, signal: args.signal, @@ -134,6 +140,16 @@ export async function runEphemeralVmRecipeStart( ...processResult } } + const checkoutModeError = getEphemeralVmRecipeCheckoutModeError(args.recipe, parsed.result) + if (checkoutModeError) { + return { + ok: false, + context, + error: checkoutModeError, + recipeResult: parsed.result, + ...processResult + } + } return { ok: true, @@ -158,6 +174,7 @@ export async function runEphemeralVmRecipeCleanup( repoPath: args.repoPath, context: args.context, mode: 'destroy', + resultSchemaVersion: getEphemeralVmRecipeResultSchemaVersion(args.recipe), stdin: `${JSON.stringify(payload)}\n`, env: args.env, maxCaptureBytes: args.maxCaptureBytes, @@ -193,6 +210,7 @@ export async function runEphemeralVmRecipeSuspend( repoPath: args.repoPath, context: args.context, mode: 'suspend', + resultSchemaVersion: getEphemeralVmRecipeResultSchemaVersion(args.recipe), stdin: `${JSON.stringify(payload)}\n`, env: args.env, maxCaptureBytes: args.maxCaptureBytes, @@ -234,6 +252,7 @@ export async function runEphemeralVmRecipeResume( repoPath: args.repoPath, context: args.context, mode: 'resume', + resultSchemaVersion: getEphemeralVmRecipeResultSchemaVersion(args.recipe), stdin: `${JSON.stringify(payload)}\n`, env: args.env, maxCaptureBytes: args.maxCaptureBytes, @@ -263,6 +282,17 @@ export async function runEphemeralVmRecipeResume( ...processResult } } + const checkoutModeError = getEphemeralVmRecipeCheckoutModeError(args.recipe, parsed.result) + if (checkoutModeError) { + return { + ok: false, + skipped: false, + context: args.context, + error: checkoutModeError, + recipeResult: parsed.result, + ...processResult + } + } return { ok: true, diff --git a/src/shared/ephemeral-vm-recipes.test.ts b/src/shared/ephemeral-vm-recipes.test.ts index d83ad44bb68..cadf43597b0 100644 --- a/src/shared/ephemeral-vm-recipes.test.ts +++ b/src/shared/ephemeral-vm-recipes.test.ts @@ -127,6 +127,43 @@ describe('parseEphemeralVmRecipeResult', () => { }) }) + it('parses the provisioned-root version handshake', () => { + const result = parseEphemeralVmRecipeResult( + JSON.stringify({ + schemaVersion: 2, + checkoutMode: 'provisioned-root', + connection: { + type: 'ssh', + projectRoot: 'C:\\workspace\\repo', + target: { + label: 'Sandbox', + host: 'sandbox.example.com', + port: 22, + username: 'root' + } + } + }) + ) + + expect(result).toEqual({ + ok: true, + result: { + schemaVersion: 2, + checkoutMode: 'provisioned-root', + connection: { + type: 'ssh', + projectRoot: 'C:\\workspace\\repo', + target: { + label: 'Sandbox', + host: 'sandbox.example.com', + port: 22, + username: 'root' + } + } + } + }) + }) + it('rejects ssh results with relative project roots', () => { expect( parseEphemeralVmRecipeResult( diff --git a/src/shared/ephemeral-vm-recipes.ts b/src/shared/ephemeral-vm-recipes.ts index 9cb4349d928..108bd6ca4ff 100644 --- a/src/shared/ephemeral-vm-recipes.ts +++ b/src/shared/ephemeral-vm-recipes.ts @@ -103,9 +103,30 @@ export const EphemeralVmRecipeConnectionResultSchema = z }) .strict() +export const EphemeralVmRecipeProvisionedRootLegacyResultSchema = z + .object({ + schemaVersion: z.literal(2), + checkoutMode: z.literal('provisioned-root'), + pairingCode: z.string().min(1), + projectRoot: z.string().min(1), + userData: z.record(z.string(), JsonValueSchema).optional() + }) + .strict() + +export const EphemeralVmRecipeProvisionedRootConnectionResultSchema = z + .object({ + schemaVersion: z.literal(2), + checkoutMode: z.literal('provisioned-root'), + connection: EphemeralVmRecipeConnectionSchema, + userData: z.record(z.string(), JsonValueSchema).optional() + }) + .strict() + export const EphemeralVmRecipeResultSchema = z.union([ EphemeralVmRecipeLegacyResultSchema, - EphemeralVmRecipeConnectionResultSchema + EphemeralVmRecipeConnectionResultSchema, + EphemeralVmRecipeProvisionedRootLegacyResultSchema, + EphemeralVmRecipeProvisionedRootConnectionResultSchema ]) export type EphemeralVmRecipeResult = z.infer @@ -173,6 +194,12 @@ export function getEphemeralVmRecipeResultProjectRoot(result: EphemeralVmRecipeR return getEphemeralVmRecipeResultConnection(result).projectRoot } +export function getEphemeralVmRecipeResultCheckoutMode( + result: EphemeralVmRecipeResult +): 'orca-worktree' | 'provisioned-root' { + return result.schemaVersion === 2 ? 'provisioned-root' : 'orca-worktree' +} + export function getEphemeralVmRecipeResultPairingCode( result: EphemeralVmRecipeResult ): string | null { diff --git a/src/shared/ephemeral-vm-runtimes.ts b/src/shared/ephemeral-vm-runtimes.ts index 196c3e08184..c8bb859f06e 100644 --- a/src/shared/ephemeral-vm-runtimes.ts +++ b/src/shared/ephemeral-vm-runtimes.ts @@ -32,6 +32,7 @@ const EphemeralVmRuntimeRecipeSchema = z id: z.string().min(1), name: z.string().min(1), create: z.string().min(1), + checkoutMode: z.enum(['orca-worktree', 'provisioned-root']).optional(), description: z.string().min(1).optional(), suspend: z.string().min(1).optional(), resume: z.string().min(1).optional(), diff --git a/src/shared/orca-yaml.ts b/src/shared/orca-yaml.ts index f5abd79c1eb..35b49b20a7c 100644 --- a/src/shared/orca-yaml.ts +++ b/src/shared/orca-yaml.ts @@ -164,6 +164,15 @@ function normalizeVmRecipes(value: unknown): VmRecipeParseResult { } seenIds.add(id) const description = asTrimmedString(record.description) + const checkoutMode = asTrimmedString(record.checkoutMode) + if (checkoutMode && checkoutMode !== 'orca-worktree' && checkoutMode !== 'provisioned-root') { + diagnostics.push({ + index, + field: 'checkoutMode', + message: `Recipe "${id}" checkoutMode must be "orca-worktree" or "provisioned-root".` + }) + return null + } const suspend = asTrimmedString(record.suspend) const resume = asTrimmedString(record.resume) const destroyValue = asTrimmedString(record.destroy) ?? asTrimmedString(record.cleanup) @@ -172,6 +181,7 @@ function normalizeVmRecipes(value: unknown): VmRecipeParseResult { id, name, create, + ...(checkoutMode ? { checkoutMode } : {}), ...(description ? { description } : {}), ...(suspend ? { suspend } : {}), ...(resume ? { resume } : {}), diff --git a/src/shared/plugins/plugin-vm-recipe-artifact.test.ts b/src/shared/plugins/plugin-vm-recipe-artifact.test.ts index 89ad197a705..684ffa7341d 100644 --- a/src/shared/plugins/plugin-vm-recipe-artifact.test.ts +++ b/src/shared/plugins/plugin-vm-recipe-artifact.test.ts @@ -11,6 +11,7 @@ describe('plugin VM recipe artifacts', () => { schemaVersion: 1, id: 'cloud-sandbox', name: 'Cloud Sandbox', + checkoutMode: 'provisioned-root', create: './scripts/create.sh', suspend: './scripts/suspend.sh', resume: './scripts/resume.sh', @@ -18,7 +19,11 @@ describe('plugin VM recipe artifacts', () => { }) ) - expect(recipe).toMatchObject({ id: 'cloud-sandbox', name: 'Cloud Sandbox' }) + expect(recipe).toMatchObject({ + id: 'cloud-sandbox', + name: 'Cloud Sandbox', + checkoutMode: 'provisioned-root' + }) expect(listPluginVmRecipeCommands(recipe)).toEqual([ { phase: 'create', command: './scripts/create.sh' }, { phase: 'suspend', command: './scripts/suspend.sh' }, diff --git a/src/shared/plugins/plugin-vm-recipe-artifact.ts b/src/shared/plugins/plugin-vm-recipe-artifact.ts index 8d1441ba786..df99b40178c 100644 --- a/src/shared/plugins/plugin-vm-recipe-artifact.ts +++ b/src/shared/plugins/plugin-vm-recipe-artifact.ts @@ -15,6 +15,7 @@ const pluginVmRecipeArtifactSchema = z id: z.string().regex(ORCA_VM_RECIPE_ID_PATTERN, ORCA_VM_RECIPE_ID_RULE), name: z.string().trim().min(1).max(128), description: z.string().trim().min(1).max(1024).optional(), + checkoutMode: z.enum(['orca-worktree', 'provisioned-root']).optional(), create: recipeCommandSchema, suspend: recipeCommandSchema.optional(), resume: recipeCommandSchema.optional(), @@ -43,6 +44,7 @@ export function parsePluginVmRecipeArtifact(raw: string): OrcaVmRecipe { id: parsed.id, name: parsed.name, create: parsed.create, + ...(parsed.checkoutMode ? { checkoutMode: parsed.checkoutMode } : {}), ...(parsed.description ? { description: parsed.description } : {}), ...(parsed.suspend ? { suspend: parsed.suspend } : {}), ...(parsed.resume ? { resume: parsed.resume } : {}), diff --git a/src/shared/types.ts b/src/shared/types.ts index a573c161d2b..30f6b897b31 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2218,10 +2218,13 @@ export type OrcaDefaultTabTemplate = { command?: string } +export type EphemeralVmCheckoutMode = 'orca-worktree' | 'provisioned-root' + export type OrcaVmRecipe = { id: string name: string create: string + checkoutMode?: EphemeralVmCheckoutMode description?: string suspend?: string resume?: string