feat(plugins): add VM recipe packs

This commit is contained in:
Neil
2026-07-13 03:33:43 -07:00
parent 3bc87fedcf
commit ca38781c92
44 changed files with 1838 additions and 219 deletions
@@ -83,6 +83,9 @@ describe('ephemeral VM runtime service', () => {
const recipe: OrcaVmRecipe = {
id: 'cloud-sandbox',
name: 'Cloud Sandbox',
// Repo-owned recipes predate plugin bounds; snapshotting must not fail
// after create has already provisioned external resources.
description: 'x'.repeat(2_048),
create: nodeCommand(startPath),
destroy: nodeCommand(cleanupPath)
}
@@ -104,6 +107,7 @@ describe('ephemeral VM runtime service', () => {
expect(provisioned.runtime).toMatchObject({
id: provisioned.start.context.instanceId,
recipeId: 'cloud-sandbox',
recipe,
repoId: 'repo-1',
projectId: 'project-1',
workspaceName: 'Fix Login Race',
+1
View File
@@ -120,6 +120,7 @@ export async function provisionEphemeralVmRuntime(
const runtime = 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 } : {}),
+40 -7
View File
@@ -26,7 +26,11 @@ export type RecipeRepoResult =
| { ok: true; repo: Exclude<ReturnType<Store['getRepo']>, null | undefined> }
| { ok: false; message: string; doctor: (recipeId: string) => EphemeralVmRecipeDoctorResult }
export function listRecipes(store: Store, repoId: string): EphemeralVmRecipeListResult {
export function listRecipes(
store: Store,
repoId: string,
pluginRecipes: readonly OrcaVmRecipe[] = []
): EphemeralVmRecipeListResult {
const repo = store.getRepo(repoId)
if (!repo || isFolderRepo(repo)) {
return {
@@ -50,12 +54,15 @@ export function listRecipes(store: Store, repoId: string): EphemeralVmRecipeList
return {
status: 'ok',
repoPath: repo.path,
recipes: hooks?.environmentRecipes ?? [],
recipes: combineEphemeralVmRecipes(hooks?.environmentRecipes ?? [], pluginRecipes),
diagnostics: hooks?.environmentRecipeDiagnostics ?? []
}
}
export function listRecipeCatalog(store: Store): EphemeralVmRecipeCatalogEntry[] {
export function listRecipeCatalog(
store: Store,
pluginRecipes: readonly OrcaVmRecipe[] = []
): EphemeralVmRecipeCatalogEntry[] {
return store
.getRepos()
.filter((repo) => isGitRepoKind(repo) && !isFolderRepo(repo) && !repo.connectionId)
@@ -65,7 +72,7 @@ export function listRecipeCatalog(store: Store): EphemeralVmRecipeCatalogEntry[]
repoId: repo.id,
repoName: repo.displayName,
repoPath: repo.path,
recipes: hooks?.environmentRecipes ?? [],
recipes: combineEphemeralVmRecipes(hooks?.environmentRecipes ?? [], pluginRecipes),
diagnostics: hooks?.environmentRecipeDiagnostics ?? []
}
})
@@ -103,15 +110,41 @@ export function getRuntimeRecipeContext(
if (!repo.ok) {
throw new Error(repo.message)
}
const recipe = (loadHooks(repo.repo.path)?.environmentRecipes ?? []).find(
(entry) => entry.id === runtime.recipeId
)
// Pre-snapshot runtimes can only be attributed to repo-owned recipes. Never
// substitute a later same-id plugin recipe for an older runtime lifecycle.
const recipe =
runtime.recipe ??
(loadHooks(repo.repo.path)?.environmentRecipes ?? []).find(
(entry) => entry.id === runtime.recipeId
)
if (!recipe) {
throw new Error(`Recipe not found: ${runtime.recipeId}`)
}
return { runtime, repo, recipe }
}
export function resolveRecipeForRepo(
repoPath: string,
recipeId: string,
pluginRecipes: readonly OrcaVmRecipe[] = []
): OrcaVmRecipe | null {
return (
combineEphemeralVmRecipes(loadHooks(repoPath)?.environmentRecipes ?? [], pluginRecipes).find(
(recipe) => recipe.id === recipeId
) ?? null
)
}
/** Project-owned recipes are authoritative for their repository and shadow
* same-id global plugin recipes without disabling the rest of the pack. */
export function combineEphemeralVmRecipes(
repoRecipes: readonly OrcaVmRecipe[],
pluginRecipes: readonly OrcaVmRecipe[]
): OrcaVmRecipe[] {
const repoIds = new Set(repoRecipes.map((recipe) => recipe.id))
return [...repoRecipes, ...pluginRecipes.filter((recipe) => !repoIds.has(recipe.id))]
}
function failedRecipeRepo(repoPath: string | null, message: string): RecipeRepoResult {
return {
ok: false,
+9 -19
View File
@@ -1,6 +1,5 @@
import { app, ipcMain } from 'electron'
import type { Store } from '../persistence'
import { loadHooks } from '../hooks'
import {
listEphemeralVmRuntimes,
updateEphemeralVmRuntimeStatus
@@ -29,7 +28,7 @@ import {
disconnectRuntimeOwnedSshTarget,
removeRuntimeOwnedSshTarget
} from '../ephemeral-vm-runtime-ssh'
import { getRecipeRepo, getRuntimeRecipeContext } from './ephemeral-vm-recipe-context'
import { getRuntimeRecipeContext } from './ephemeral-vm-recipe-context'
export type EphemeralVmCleanupCommandResult = {
runtimeId: string
@@ -74,30 +73,21 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void {
if (!runtime.repoId) {
throw new Error(`Ephemeral VM runtime has no repo id: ${args.runtimeId}`)
}
const repo = getRecipeRepo(store, runtime.repoId)
if (!repo.ok) {
let resolved: ReturnType<typeof getRuntimeRecipeContext>
try {
resolved = getRuntimeRecipeContext(store, userDataPath, runtime.id)
} catch (error) {
return updateEphemeralVmRuntimeStatus(userDataPath, runtime.id, {
status: 'cleanup_failed',
cleanupStatus: 'failed',
cleanupLastAttemptAt: Date.now(),
cleanupLastError: repo.message
})
}
const recipe = (loadHooks(repo.repo.path)?.environmentRecipes ?? []).find(
(entry) => entry.id === runtime.recipeId
)
if (!recipe) {
return updateEphemeralVmRuntimeStatus(userDataPath, runtime.id, {
status: 'cleanup_failed',
cleanupStatus: 'failed',
cleanupLastAttemptAt: Date.now(),
cleanupLastError: `Recipe not found: ${runtime.recipeId}`
cleanupLastError: error instanceof Error ? error.message : String(error)
})
}
const result = await cleanupEphemeralVmRuntime({
userDataPath,
repoPath: repo.repo.path,
recipe,
repoPath: resolved.repo.repo.path,
recipe: resolved.recipe,
runtimeId: runtime.id
})
if (result.ok && runtime.runtimeEnvironmentId) {
@@ -217,7 +207,7 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void {
ipcMain.handle(
'ephemeralVm:getCleanupCommand',
(_event, args: { runtimeId: string }): EphemeralVmCleanupCommandResult => {
async (_event, args: { runtimeId: string }): Promise<EphemeralVmCleanupCommandResult> => {
const userDataPath = app.getPath('userData')
const resolved = getRuntimeRecipeContext(store, userDataPath, args.runtimeId)
const payload = buildEphemeralVmRecipeCleanupPayload({
+136
View File
@@ -4,6 +4,7 @@ import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing'
import { listEnvironments } from '../../shared/runtime-environment-store'
import { upsertEphemeralVmRuntime } from '../../shared/ephemeral-vm-runtime-store'
const handlers = new Map<string, (_event: unknown, args: never) => Promise<unknown> | unknown>()
const {
@@ -86,6 +87,15 @@ function nodeCommand(scriptPath: string): string {
return `"${process.execPath}" "${scriptPath}"`
}
function pluginServiceWithRecipes(
recipes: { pluginKey: string; recipe: Record<string, unknown> }[]
) {
return {
whenReady: vi.fn().mockResolvedValue(undefined),
contentPacks: { vmRecipes: { list: vi.fn(() => recipes) } }
}
}
describe('registerEphemeralVmHandlers', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
@@ -191,6 +201,132 @@ describe('registerEphemeralVmHandlers', () => {
])
})
it('merges approved plugin recipes while repository recipes shadow matching ids', async () => {
const repoPath = makeDir('orca-ephemeral-vm-ipc-repo-')
writeFileSync(
join(repoPath, 'orca.yaml'),
[
'environmentRecipes:',
' - id: shared',
' name: Repository Recipe',
' create: repo-create'
].join('\n')
)
const pluginService = pluginServiceWithRecipes([
{
pluginKey: 'orca-samples.recipes',
recipe: { id: 'shared', name: 'Plugin Shared', create: 'plugin-shared' }
},
{
pluginKey: 'orca-samples.recipes',
recipe: { id: 'global', name: 'Plugin Global', create: 'plugin-global' }
}
])
registerEphemeralVmHandlers(makeStore(repoPath) as never, pluginService as never)
const result = (await handlers.get('ephemeralVm:listRecipes')?.(null, {
repoId: 'repo-1'
} as never)) as { recipes: { id: string; name: string }[] }
expect(pluginService.whenReady).toHaveBeenCalled()
expect(result.recipes).toMatchObject([
{ id: 'shared', name: 'Repository Recipe' },
{ id: 'global', name: 'Plugin Global' }
])
})
it('uses an immutable plugin recipe snapshot after the plugin is removed', async () => {
const userDataPath = makeDir('orca-ephemeral-vm-ipc-user-data-')
const repoPath = makeDir('orca-ephemeral-vm-ipc-repo-')
getPathMock.mockReturnValue(userDataPath)
const startPath = join(repoPath, 'start.js')
const destroyPath = join(repoPath, 'destroy.js')
writeFileSync(
startPath,
`console.log(${JSON.stringify(
JSON.stringify({
schemaVersion: 1,
pairingCode: makePairingCode(),
projectRoot: '/workspace/repo'
})
)})`
)
writeFileSync(destroyPath, "require('fs').writeFileSync('plugin-cleaned.txt', 'yes')")
const registrations = [
{
pluginKey: 'orca-samples.recipes',
recipe: {
id: 'plugin-cloud',
name: 'Plugin Cloud',
create: nodeCommand(startPath),
destroy: nodeCommand(destroyPath)
}
}
]
const pluginService = pluginServiceWithRecipes(registrations)
registerEphemeralVmHandlers(makeStore(repoPath) as never, pluginService as never)
const provisioned = (await handlers.get('ephemeralVm:provision')?.(null, {
repoId: 'repo-1',
recipeId: 'plugin-cloud'
} as never)) as { ok: true; runtime: { id: string; recipe?: { id: string } } }
registrations.splice(0)
const cleaned = await handlers.get('ephemeralVm:cleanup')?.(null, {
runtimeId: provisioned.runtime.id
} as never)
expect(provisioned.runtime.recipe).toMatchObject({ id: 'plugin-cloud' })
expect(cleaned).toEqual(expect.objectContaining({ status: 'cleaned' }))
expect(readFileSync(join(repoPath, 'plugin-cleaned.txt'), 'utf8')).toBe('yes')
})
it('never substitutes a later same-id plugin recipe for a legacy runtime', async () => {
const userDataPath = makeDir('orca-ephemeral-vm-ipc-user-data-')
const repoPath = makeDir('orca-ephemeral-vm-ipc-repo-')
getPathMock.mockReturnValue(userDataPath)
const pluginDestroyPath = join(repoPath, 'plugin-destroy.js')
writeFileSync(
pluginDestroyPath,
"require('fs').writeFileSync('plugin-destroy-ran.txt', 'unsafe')"
)
upsertEphemeralVmRuntime(userDataPath, {
id: 'legacy-runtime',
recipeId: 'shared-id',
repoId: 'repo-1',
status: 'running',
cleanupStatus: 'not_started',
createdAt: 1,
updatedAt: 1,
recipeResult: {
schemaVersion: 1,
pairingCode: makePairingCode(),
projectRoot: '/workspace/repo'
}
})
const pluginService = pluginServiceWithRecipes([
{
pluginKey: 'orca-samples.recipes',
recipe: {
id: 'shared-id',
name: 'Later Plugin Recipe',
create: 'create',
destroy: nodeCommand(pluginDestroyPath)
}
}
])
registerEphemeralVmHandlers(makeStore(repoPath) as never, pluginService as never)
const cleaned = await handlers.get('ephemeralVm:cleanup')?.(null, {
runtimeId: 'legacy-runtime'
} as never)
expect(cleaned).toMatchObject({
status: 'cleanup_failed',
cleanupLastError: 'Recipe not found: shared-id'
})
expect(existsSync(join(repoPath, 'plugin-destroy-ran.txt'))).toBe(false)
})
it('provisions a recipe and persists the ephemeral runtime', async () => {
const userDataPath = makeDir('orca-ephemeral-vm-ipc-user-data-')
const repoPath = makeDir('orca-ephemeral-vm-ipc-repo-')
+23 -12
View File
@@ -1,6 +1,5 @@
import { app, ipcMain } from 'electron'
import type { Store } from '../persistence'
import { loadHooks } from '../hooks'
import {
getEphemeralVmRecipeResultConnection,
getEphemeralVmRecipeResultWarnings,
@@ -27,9 +26,12 @@ import {
getRecipeRepo,
listRecipeCatalog,
listRecipes,
resolveRecipeForRepo,
type EphemeralVmRecipeCatalogEntry
} from './ephemeral-vm-recipe-context'
import { registerEphemeralVmRuntimeHandlers } from './ephemeral-vm-runtime-handlers'
import type { PluginService } from '../plugins/plugin-service'
import { getApprovedPluginVmRecipes } from '../plugins/plugin-approved-vm-recipes'
const activeProvisionControllers = new Map<string, AbortController>()
@@ -57,7 +59,7 @@ export type EphemeralVmProvisionIpcResult =
stdout: string
}
export function registerEphemeralVmHandlers(store: Store): void {
export function registerEphemeralVmHandlers(store: Store, pluginService?: PluginService): void {
ipcMain.removeHandler('ephemeralVm:listRecipes')
ipcMain.removeHandler('ephemeralVm:listRecipeCatalog')
ipcMain.removeHandler('ephemeralVm:doctor')
@@ -65,25 +67,32 @@ export function registerEphemeralVmHandlers(store: Store): void {
ipcMain.removeHandler('ephemeralVm:cancelProvision')
registerEphemeralVmRuntimeHandlers(store)
ipcMain.handle('ephemeralVm:listRecipes', (_event, args: { repoId: string }) => {
return listRecipes(store, args.repoId)
})
ipcMain.handle('ephemeralVm:listRecipeCatalog', (): EphemeralVmRecipeCatalogEntry[] => {
return listRecipeCatalog(store)
ipcMain.handle('ephemeralVm:listRecipes', async (_event, args: { repoId: string }) => {
return listRecipes(store, args.repoId, await getApprovedPluginVmRecipes(pluginService))
})
ipcMain.handle(
'ephemeralVm:listRecipeCatalog',
async (): Promise<EphemeralVmRecipeCatalogEntry[]> => {
return listRecipeCatalog(store, await getApprovedPluginVmRecipes(pluginService))
}
)
ipcMain.handle(
'ephemeralVm:doctor',
(_event, args: { repoId: string; recipeId: string }): EphemeralVmRecipeDoctorResult => {
async (
_event,
args: { repoId: string; recipeId: string }
): Promise<EphemeralVmRecipeDoctorResult> => {
const repo = getRecipeRepo(store, args.repoId)
if (!repo.ok) {
return repo.doctor(args.recipeId)
}
const pluginRecipes = await getApprovedPluginVmRecipes(pluginService)
return doctorEphemeralVmRecipe({
repoPath: repo.repo.path,
recipeId: args.recipeId,
recipes: loadHooks(repo.repo.path)?.environmentRecipes ?? [],
recipes: listRecipes(store, args.repoId, pluginRecipes).recipes,
localExecutionSupported: true
})
}
@@ -106,8 +115,10 @@ export function registerEphemeralVmHandlers(store: Store): void {
if (!repo.ok) {
return { ok: false, error: repo.message, stdout: '', stderr: '' }
}
const recipe = (loadHooks(repo.repo.path)?.environmentRecipes ?? []).find(
(entry) => entry.id === args.recipeId
const recipe = resolveRecipeForRepo(
repo.repo.path,
args.recipeId,
await getApprovedPluginVmRecipes(pluginService)
)
if (!recipe) {
return { ok: false, error: `Recipe not found: ${args.recipeId}`, stdout: '', stderr: '' }
+1 -1
View File
@@ -498,7 +498,7 @@ describe('registerCoreHandlers', () => {
expect(registerFilesystemHandlersMock).toHaveBeenCalledWith(store)
expect(registerRuntimeHandlersMock).toHaveBeenCalledWith(runtime)
expect(registerRuntimeEnvironmentHandlersMock).toHaveBeenCalledWith(store)
expect(registerEphemeralVmHandlersMock).toHaveBeenCalledWith(store)
expect(registerEphemeralVmHandlersMock).toHaveBeenCalledWith(store, undefined)
expect(registerAiVaultHandlersMock).toHaveBeenCalledWith(
expect.objectContaining({
getAdditionalCodexHomePaths: getAdditionalAiVaultCodexHomePaths,
+1 -1
View File
@@ -186,7 +186,7 @@ export function registerCoreHandlers(
registerFilesystemWatcherHandlers()
registerRuntimeHandlers(runtime)
registerRuntimeEnvironmentHandlers(store)
registerEphemeralVmHandlers(store)
registerEphemeralVmHandlers(store, pluginService)
registerAiVaultHandlers({
getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths,
getActiveRuntimeAiVaultHostInfos: () =>
@@ -0,0 +1,12 @@
import type { OrcaVmRecipe } from '../../shared/types'
import type { PluginService } from './plugin-service'
export async function getApprovedPluginVmRecipes(
pluginService?: PluginService
): Promise<OrcaVmRecipe[]> {
if (!pluginService) {
return []
}
await pluginService.whenReady()
return pluginService.contentPacks.vmRecipes.list().map(({ recipe }) => recipe)
}
+24 -2
View File
@@ -6,6 +6,7 @@ import {
parsePluginIconThemeArtifact,
sanitizePluginIconSvg
} from '../../shared/plugins/plugin-icon-theme-artifact'
import { parsePluginVmRecipeArtifact } from '../../shared/plugins/plugin-vm-recipe-artifact'
export type PluginArtifactValidationResult = { ok: true } | { ok: false; error: string }
@@ -18,7 +19,7 @@ export const PLUGIN_ICON_SVG_MAX_BYTES = 64 * 1024
export const PLUGIN_ICON_TOTAL_MAX_BYTES = 8 * 1024 * 1024
export const PLUGIN_TERMINAL_THEME_MAX_BYTES = 256 * 1024
export const PLUGIN_LANGUAGE_PACK_MAX_BYTES = 5 * 1024 * 1024
const PLUGIN_RECIPE_MAX_BYTES = 1024 * 1024
export const PLUGIN_VM_RECIPE_MAX_BYTES = 256 * 1024
const PLUGIN_AGENT_PROFILE_MAX_BYTES = 1024 * 1024
type DeclaredArtifact =
@@ -86,7 +87,7 @@ function declaredArtifactPaths(manifest: PluginManifest): DeclaredArtifact[] {
label: 'VM recipe',
path: recipe.path,
kind: 'file' as const,
maxBytes: PLUGIN_RECIPE_MAX_BYTES
maxBytes: PLUGIN_VM_RECIPE_MAX_BYTES
})),
...manifest.contributes.agents.map((agent) => ({
label: 'agent profile',
@@ -237,5 +238,26 @@ export async function validatePluginInstallContent(
}
}
}
const vmRecipeIds = new Set<string>()
for (const contribution of manifest.contributes.vmRecipes) {
try {
const recipe = parsePluginVmRecipeArtifact(
await readContainedPluginArtifactText(
rootDir,
contribution.path,
PLUGIN_VM_RECIPE_MAX_BYTES
)
)
if (vmRecipeIds.has(recipe.id)) {
throw new Error(`duplicate VM recipe id "${recipe.id}"`)
}
vmRecipeIds.add(recipe.id)
} catch (error) {
return {
ok: false,
error: `VM recipe ${contribution.path}: ${error instanceof Error ? error.message : String(error)}`
}
}
}
return { ok: true }
}
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest'
import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint'
import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest'
import { PluginContentVerifier } from './plugin-content-integrity'
import { hashPluginTree } from './plugin-content-hash'
import { PluginContentPackRegistry } from './plugin-content-pack-registry'
import type { ValidDiscoveredPlugin } from './plugin-discovery'
@@ -70,4 +71,61 @@ describe('PluginContentPackRegistry', () => {
expect(registry.iconThemes.list()).toEqual([])
expect(registry.terminalThemes.list()).toEqual([])
})
it('rolls back valid packs when a VM recipe from the same plugin is invalid', async () => {
const rootDir = await mkdtemp(join(tmpdir(), 'orca-plugin-content-pack-vm-'))
roots.push(rootDir)
await Promise.all([mkdir(join(rootDir, 'terminal')), mkdir(join(rootDir, 'recipes'))])
await Promise.all([
writeFile(
join(rootDir, 'terminal', 'valid.json'),
JSON.stringify({
schemaVersion: 1,
mode: 'dark',
terminal: { background: '#101010', foreground: '#f0f0f0', black: '#000000' }
})
),
writeFile(
join(rootDir, 'recipes', 'invalid.json'),
JSON.stringify({ schemaVersion: 1, id: 'bad', name: 'Bad', create: 'create', resume: 'up' })
)
])
const manifest = pluginManifestSchema.parse({
manifestVersion: 1,
id: 'mixed-recipes',
publisher: 'orca-samples',
name: 'Mixed Recipes',
version: '1.0.0',
engines: { orca: '>=1.0.0' },
pluginApi: 1,
contributes: {
terminalThemes: [{ id: 'valid', label: 'Valid', path: 'terminal/valid.json' }],
vmRecipes: [{ path: 'recipes/invalid.json' }]
},
capabilities: []
})
const content = await hashPluginTree(rootDir)
if (!content.ok) {
throw new Error(content.error)
}
const plugin: ValidDiscoveredPlugin = {
pluginKey: 'orca-samples.mixed-recipes',
rootDir,
manifest,
consentFingerprint: fingerprintPluginConsent(manifest, content.hash),
consentContentHash: content.hash,
contentHash: null,
isDev: true
}
const registry = new PluginContentPackRegistry(new PluginContentVerifier(), {
pluginsDataDir: join(rootDir, 'plugin-data'),
homeDirectory: join(rootDir, 'home')
})
await registry.reconcile([plugin], () => true)
expect(registry.error(plugin.pluginKey)).toContain('suspend and resume')
expect(registry.terminalThemes.list()).toEqual([])
expect(registry.vmRecipes.list()).toEqual([])
})
})
@@ -9,6 +9,8 @@ import { PluginIconThemeRegistry } from './plugin-icon-theme-registry'
import { PluginSkillRegistry } from './plugin-skill-registry'
import { PluginThemeRegistry } from './plugin-theme-registry'
import { PluginTerminalThemeRegistry } from './plugin-terminal-theme-registry'
import { PluginVmRecipeRegistry } from './plugin-vm-recipe-registry'
import { verifyInstructionalPluginContent } from './plugin-instructional-content-integrity'
export class PluginContentPackRegistry {
readonly themes: PluginThemeRegistry
@@ -16,6 +18,7 @@ export class PluginContentPackRegistry {
readonly iconThemes: PluginIconThemeRegistry
readonly terminalThemes: PluginTerminalThemeRegistry
readonly skills: PluginSkillRegistry
readonly vmRecipes: PluginVmRecipeRegistry
private readonly activationErrors = new Map<string, string>()
constructor(
@@ -31,6 +34,7 @@ export class PluginContentPackRegistry {
options.pluginsDataDir,
options.homeDirectory
)
this.vmRecipes = new PluginVmRecipeRegistry()
}
async reconcile(
@@ -46,6 +50,27 @@ export class PluginContentPackRegistry {
const excluded = new Set<string>()
this.activationErrors.clear()
await Promise.all(
discovered.map(async (plugin) => {
if (
isInvalidDiscoveredPlugin(plugin) ||
!approvedKeys.has(plugin.pluginKey) ||
plugin.manifest.contributes.vmRecipes.length > 0
) {
return
}
try {
await verifyInstructionalPluginContent(plugin)
} catch (error) {
excluded.add(plugin.pluginKey)
this.activationErrors.set(
plugin.pluginKey,
error instanceof Error ? error.message : String(error)
)
}
})
)
while (true) {
const approveAtomically = (plugin: ValidDiscoveredPlugin): boolean =>
approvedKeys.has(plugin.pluginKey) && !excluded.has(plugin.pluginKey)
@@ -54,7 +79,8 @@ export class PluginContentPackRegistry {
this.languagePacks.reconcile(discovered, approveAtomically),
this.iconThemes.reconcile(discovered, approveAtomically),
this.terminalThemes.reconcile(discovered, approveAtomically),
this.skills.reconcile(discovered, approveAtomically)
this.skills.reconcile(discovered, approveAtomically),
this.vmRecipes.reconcile(discovered, approveAtomically)
])
let foundNewError = false
@@ -82,7 +108,8 @@ export class PluginContentPackRegistry {
this.languagePacks.error(pluginKey) ??
this.iconThemes.error(pluginKey) ??
this.terminalThemes.error(pluginKey) ??
this.skills.error(pluginKey)
this.skills.error(pluginKey) ??
this.vmRecipes.error(pluginKey)
)
}
}
@@ -191,6 +191,57 @@ describe('declared plugin artifacts', () => {
error: expect.stringContaining('active or external content')
})
})
it('parses VM recipes at the immutable install boundary', async () => {
const root = await tempRoot()
await mkdir(join(root, 'recipes'))
await writeFile(
join(root, 'recipes', 'invalid.json'),
JSON.stringify({
schemaVersion: 1,
id: 'cloud',
name: 'Cloud',
create: 'create',
suspend: 'suspend'
})
)
const pluginManifest = manifest({
contributes: { vmRecipes: [{ path: 'recipes/invalid.json' }] }
})
await expect(validateDeclaredPluginArtifacts(root, pluginManifest)).resolves.toEqual({
ok: true
})
await expect(validatePluginInstallContent(root, pluginManifest)).resolves.toMatchObject({
ok: false,
error: expect.stringContaining('suspend and resume')
})
})
it('rejects duplicate VM recipe ids at the immutable install boundary', async () => {
const root = await tempRoot()
await mkdir(join(root, 'recipes'))
const recipe = JSON.stringify({
schemaVersion: 1,
id: 'cloud',
name: 'Cloud',
create: 'create'
})
await Promise.all([
writeFile(join(root, 'recipes', 'one.json'), recipe),
writeFile(join(root, 'recipes', 'two.json'), recipe)
])
const pluginManifest = manifest({
contributes: {
vmRecipes: [{ path: 'recipes/one.json' }, { path: 'recipes/two.json' }]
}
})
await expect(validatePluginInstallContent(root, pluginManifest)).resolves.toMatchObject({
ok: false,
error: expect.stringContaining('duplicate VM recipe id "cloud"')
})
})
})
describe('hash-addressed plugin content', () => {
+54
View File
@@ -73,3 +73,57 @@ describe('installed plugin discovery identity', () => {
expect(plugin?.pluginKey).toBe(installedKey)
})
})
describe('instructional plugin discovery identity', () => {
it('changes dev consent when a VM recipe command changes', async () => {
const pluginsDir = await tempPluginsDir()
const devRoot = await tempPluginsDir()
await mkdir(join(devRoot, 'recipes'))
await writeFile(
join(devRoot, 'orca-plugin.json'),
JSON.stringify({
manifestVersion: 1,
id: 'recipes',
publisher: 'orca-samples',
name: 'Recipes',
version: '1.0.0',
engines: { orca: '>=1.0.0' },
pluginApi: 1,
contributes: { vmRecipes: [{ path: 'recipes/cloud.json' }] },
capabilities: []
})
)
const recipePath = join(devRoot, 'recipes', 'cloud.json')
await writeFile(
recipePath,
JSON.stringify({ schemaVersion: 1, id: 'cloud', name: 'Cloud', create: 'create-v1' })
)
const [first] = await discoverPlugins({
pluginsDir,
devPluginPaths: [devRoot],
hostVersion: '1.4.0'
})
await writeFile(
recipePath,
JSON.stringify({ schemaVersion: 1, id: 'cloud', name: 'Cloud', create: 'create-v2' })
)
const [second] = await discoverPlugins({
pluginsDir,
devPluginPaths: [devRoot],
hostVersion: '1.4.0'
})
expect(first && !isInvalidDiscoveredPlugin(first)).toBe(true)
expect(second && !isInvalidDiscoveredPlugin(second)).toBe(true)
if (
!first ||
!second ||
isInvalidDiscoveredPlugin(first) ||
isInvalidDiscoveredPlugin(second)
) {
return
}
expect(second.consentContentHash).not.toBe(first.consentContentHash)
expect(second.consentFingerprint).not.toBe(first.consentFingerprint)
})
})
+23 -4
View File
@@ -9,10 +9,14 @@ import {
satisfiesOrcaEngineRange,
type PluginManifest
} from '../../shared/plugins/plugin-manifest'
import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint'
import {
fingerprintPluginConsent,
hasInstructionalPluginContributions
} from '../../shared/plugins/plugin-consent-fingerprint'
import { validateDeclaredPluginArtifacts } from './plugin-artifact-validation'
import { readPluginManifestText } from './plugin-manifest-file'
import { readPluginCurrentPointer } from './plugin-current-pointer'
import { hashPluginTree } from './plugin-content-hash'
export { PLUGIN_CURRENT_POINTER_FILENAME } from './plugin-current-pointer'
@@ -37,6 +41,8 @@ export type ValidDiscoveredPlugin = {
manifest: PluginManifest
/** Fingerprint of the capabilities and trusted-worker execution tier. */
consentFingerprint: string
/** Immutable tree identity included in consent for instructional packs. */
consentContentHash?: string | null
/** Content hash the install dir is named by; null for dev plugins. */
contentHash: string | null
isDev: boolean
@@ -68,7 +74,8 @@ export function getPluginsDataDir(userDataPath: string): string {
async function readManifestDir(
rootDir: string,
hostVersion: string,
isDev: boolean
isDev: boolean,
installedContentHash?: string
): Promise<DiscoveredPlugin> {
let rawText: string
try {
@@ -116,11 +123,23 @@ async function readManifestDir(
isDev
}
}
let consentContentIdentity: string | undefined
if (installedContentHash && hasInstructionalPluginContributions(manifest)) {
consentContentIdentity = installedContentHash
}
if (isDev && hasInstructionalPluginContributions(manifest)) {
const treeHash = await hashPluginTree(rootDir)
if (!treeHash.ok) {
return { pluginKey, rootDir, error: treeHash.error, isDev }
}
consentContentIdentity = treeHash.hash
}
return {
pluginKey,
rootDir,
manifest,
consentFingerprint: fingerprintPluginConsent(manifest),
consentFingerprint: fingerprintPluginConsent(manifest, consentContentIdentity),
consentContentHash: consentContentIdentity ?? null,
contentHash: null,
isDev
}
@@ -153,7 +172,7 @@ async function readInstalledPlugin(
}
}
const versionDir = join(pluginDir, contentHash)
const discovered = await readManifestDir(versionDir, hostVersion, false)
const discovered = await readManifestDir(versionDir, hostVersion, false, contentHash)
if (isInvalidDiscoveredPlugin(discovered)) {
return { ...discovered, pluginKey: dirName }
}
+47 -20
View File
@@ -41,6 +41,32 @@ async function validatePluginInstallTree(
return declared.ok ? validatePluginInstallContent(rootDir, manifest) : declared
}
async function readInstallManifest(
rootDir: string,
hostVersion: string
): Promise<{ ok: true; manifest: PluginManifest } | { ok: false; error: string }> {
let raw: unknown
try {
raw = JSON.parse(await readPluginManifestText(rootDir))
} catch (error) {
return {
ok: false,
error: `unreadable ${PLUGIN_MANIFEST_FILENAME}: ${error instanceof Error ? error.message : String(error)}`
}
}
const parsed = parsePluginManifest(raw)
if (!parsed.ok) {
return { ok: false, error: `invalid manifest: ${parsed.error}` }
}
if (!satisfiesOrcaEngineRange(hostVersion, parsed.manifest.engines.orca)) {
return {
ok: false,
error: `plugin requires Orca ${parsed.manifest.engines.orca} (this is ${hostVersion})`
}
}
return { ok: true, manifest: parsed.manifest }
}
/** Installs a validated staging tree into the hash-addressed layout. */
export async function installStagedPluginTree(input: {
pluginsDir: string
@@ -49,26 +75,11 @@ export async function installStagedPluginTree(input: {
source: PluginInstallSource
resolvedCommit: string | null
}): Promise<PluginInstallResult> {
let manifestRaw: unknown
try {
manifestRaw = JSON.parse(await readPluginManifestText(input.stagingDir))
} catch (error) {
return {
ok: false,
error: `unreadable ${PLUGIN_MANIFEST_FILENAME}: ${error instanceof Error ? error.message : String(error)}`
}
}
const parsed = parsePluginManifest(manifestRaw)
if (!parsed.ok) {
return { ok: false, error: `invalid manifest: ${parsed.error}` }
}
const manifest = parsed.manifest
if (!satisfiesOrcaEngineRange(input.hostVersion, manifest.engines.orca)) {
return {
ok: false,
error: `plugin requires Orca ${manifest.engines.orca} (this is ${input.hostVersion})`
}
const sourceManifest = await readInstallManifest(input.stagingDir, input.hostVersion)
if (!sourceManifest.ok) {
return sourceManifest
}
let manifest = sourceManifest.manifest
const declaredArtifacts = await validatePluginInstallTree(input.stagingDir, manifest)
if (!declaredArtifacts.ok) {
return { ok: false, error: `invalid declared artifact: ${declaredArtifacts.error}` }
@@ -108,6 +119,14 @@ export async function installStagedPluginTree(input: {
: copiedHash.error
}
}
const copiedManifest = await readInstallManifest(stagedVersionDir, input.hostVersion)
if (!copiedManifest.ok) {
return { ok: false, error: `copied ${copiedManifest.error}` }
}
if (qualifiedPluginKey(copiedManifest.manifest) !== pluginKey) {
return { ok: false, error: 'plugin manifest identity changed while it was being staged' }
}
manifest = copiedManifest.manifest
const copiedArtifacts = await validatePluginInstallTree(stagedVersionDir, manifest)
if (!copiedArtifacts.ok) {
return { ok: false, error: `copied artifact validation failed: ${copiedArtifacts.error}` }
@@ -130,6 +149,14 @@ export async function installStagedPluginTree(input: {
: existingHash.error
}
}
const existingManifest = await readInstallManifest(versionDir, input.hostVersion)
if (!existingManifest.ok) {
return { ok: false, error: `installed ${existingManifest.error}` }
}
if (qualifiedPluginKey(existingManifest.manifest) !== pluginKey) {
return { ok: false, error: 'installed plugin manifest identity does not match its directory' }
}
manifest = existingManifest.manifest
const existingArtifacts = await validatePluginInstallTree(versionDir, manifest)
if (!existingArtifacts.ok) {
return {
@@ -138,7 +165,7 @@ export async function installStagedPluginTree(input: {
}
}
}
const consentFingerprint = fingerprintPluginConsent(manifest)
const consentFingerprint = fingerprintPluginConsent(manifest, treeHash.hash)
const entry: PluginLockEntry = {
pluginKey,
version: manifest.version,
+35 -1
View File
@@ -13,7 +13,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { promisify } from 'node:util'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
installPluginFromLocalPath,
installPluginFromGit,
@@ -24,6 +24,8 @@ import {
import { PLUGIN_MANIFEST_MAX_BYTES } from './plugin-manifest-file'
import { readPluginCurrentPointer } from './plugin-current-pointer'
import { writePluginLockfile } from './plugin-install-lockfile-store'
import { installStagedPluginTree } from './plugin-install-staging'
import * as manifestFile from './plugin-manifest-file'
const roots: string[] = []
const execFileAsync = promisify(execFile)
@@ -63,6 +65,7 @@ async function writePluginSource(
}
afterEach(async () => {
vi.restoreAllMocks()
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
@@ -103,6 +106,37 @@ describe('installPluginFromLocalPath', () => {
expect(lock.plugins[result.pluginKey]).not.toHaveProperty('consentFingerprint')
})
it('publishes metadata from the copied immutable manifest', async () => {
const sourcePath = await tempRoot('orca-plugin-source-')
const pluginsDir = await tempRoot('orca-plugin-installs-')
await writePluginSource(sourcePath)
const firstManifest = await readFile(join(sourcePath, 'orca-plugin.json'), 'utf8')
const changedManifest = {
...(JSON.parse(firstManifest) as Record<string, unknown>),
name: 'Changed During Staging',
version: '2.0.0'
}
await writeFile(join(sourcePath, 'orca-plugin.json'), JSON.stringify(changedManifest))
const manifestRead = vi
.spyOn(manifestFile, 'readPluginManifestText')
.mockResolvedValueOnce(firstManifest)
const result = await installStagedPluginTree({
pluginsDir,
stagingDir: sourcePath,
hostVersion: '1.4.0',
source: { kind: 'local-path', path: sourcePath },
resolvedCommit: null
})
expect(manifestRead).toHaveBeenCalledTimes(2)
expect(result).toMatchObject({ ok: true, version: '2.0.0' })
if (result.ok) {
const lock = await readPluginLockfile(pluginsDir)
expect(lock.plugins[result.pluginKey]?.version).toBe('2.0.0')
}
})
it('skips root Git metadata before copying while still enforcing plugin limits', async () => {
const sourcePath = await tempRoot('orca-plugin-source-')
const pluginsDir = await tempRoot('orca-plugin-installs-')
@@ -0,0 +1,30 @@
import { hasInstructionalPluginContributions } from '../../shared/plugins/plugin-consent-fingerprint'
import { hashPluginTree } from './plugin-content-hash'
import type { ValidDiscoveredPlugin } from './plugin-discovery'
/** Instructional bytes execute later, so every read must still match the tree
* identity the user reviewed rather than a cached discovery-time snapshot. */
export async function verifyInstructionalPluginContent(
plugin: ValidDiscoveredPlugin
): Promise<void> {
if (!hasInstructionalPluginContributions(plugin.manifest)) {
return
}
if (!plugin.consentContentHash) {
throw new Error(`plugin ${plugin.pluginKey} has no instructional consent content identity`)
}
const actual = await hashPluginTree(plugin.rootDir)
if (!actual.ok) {
throw new Error(
`plugin ${plugin.pluginKey} instructional content is unreadable: ${actual.error}`
)
}
const matches =
actual.hash === plugin.consentContentHash ||
(plugin.consentContentHash.length === 32 && actual.hash.startsWith(plugin.consentContentHash))
if (!matches) {
throw new Error(
`plugin ${plugin.pluginKey} instructional content changed since it was reviewed`
)
}
}
@@ -24,6 +24,7 @@ function serviceWith(
options: {
activation?: ReturnType<PluginService['activationState']>
worker?: ReturnType<PluginService['workerState']>
vmRecipes?: ReturnType<PluginService['contentPacks']['vmRecipes']['preview']>
} = {}
): PluginService {
return {
@@ -34,7 +35,10 @@ function serviceWith(
getDiscovered: () => [discovered],
activationState: () => options.activation ?? 'pending',
workerState: () => options.worker ?? { state: 'inactive', restarts: 0 },
activationError: () => null
activationError: () => null,
contentPacks: {
vmRecipes: { preview: () => options.vmRecipes ?? [] }
}
} as unknown as PluginService
}
@@ -120,4 +124,48 @@ describe('buildPluginList consent identity', () => {
expect(projected.name).toBe('invalid-development-plugin-1')
expect(JSON.stringify(projected)).not.toContain(invalid.rootDir)
})
it('projects exact VM lifecycle commands for instructional consent', () => {
const recipeManifest = pluginManifestSchema.parse({
...manifest,
contributes: { vmRecipes: [{ path: 'recipes/cloud.json' }] }
})
const plugin: ValidDiscoveredPlugin = {
pluginKey: 'orca-samples.demo',
rootDir: join(tmpdir(), 'plugins', 'demo'),
manifest: recipeManifest,
consentFingerprint: 'sha256-current',
consentContentHash: 'a'.repeat(64),
contentHash: null,
isDev: true
}
expect(
buildPluginList(
serviceWith(plugin, {
vmRecipes: [
{
pluginKey: plugin.pluginKey,
recipe: {
id: 'cloud',
name: 'Cloud',
create: './create.sh',
destroyDisabled: true
}
}
]
}),
emptyPluginLockfile()
)[0]?.vmRecipes
).toEqual([
{
id: 'cloud',
name: 'Cloud',
commands: [
{ phase: 'create', command: './create.sh' },
{ phase: 'destroy', command: 'none' }
]
}
])
})
})
@@ -7,6 +7,7 @@ import { pluginPanelTabKey } from '../../shared/plugins/plugin-manifest'
import type { PluginLockfile } from '../../shared/plugins/plugin-install-lockfile'
import { isInvalidDiscoveredPlugin } from './plugin-discovery'
import type { PluginService } from './plugin-service'
import { listPluginVmRecipeCommands } from '../../shared/plugins/plugin-vm-recipe-artifact'
/**
* Wire projection of installed plugins for the renderer and serve RPC.
@@ -48,6 +49,12 @@ export type PluginListEntry = {
commands: { id: string; title: string }[]
hasWorker: boolean
hasSkills: boolean
vmRecipes: {
id: string
name: string
description?: string
commands: { phase: 'create' | 'suspend' | 'resume' | 'destroy'; command: string }[]
}[]
restarts: number
source?: {
kind: 'local-path' | 'git'
@@ -82,6 +89,7 @@ export function buildPluginList(service: PluginService, lock: PluginLockfile): P
commands: [],
hasWorker: false,
hasSkills: false,
vmRecipes: [],
restarts: 0
}
}
@@ -140,6 +148,12 @@ export function buildPluginList(service: PluginService, lock: PluginLockfile): P
})),
hasWorker: Boolean(plugin.manifest.main),
hasSkills: plugin.manifest.contributes.skills.length > 0,
vmRecipes: service.contentPacks.vmRecipes.preview(plugin.pluginKey).map(({ recipe }) => ({
id: recipe.id,
name: recipe.name,
...(recipe.description ? { description: recipe.description } : {}),
commands: listPluginVmRecipeCommands(recipe)
})),
restarts: worker.restarts,
...(lockEntry
? {
@@ -0,0 +1,169 @@
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint'
import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest'
import { hashPluginTree } from './plugin-content-hash'
import type { ValidDiscoveredPlugin } from './plugin-discovery'
import { PluginVmRecipeRegistry } from './plugin-vm-recipe-registry'
const roots: string[] = []
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
async function recipePlugin(
id: string,
artifacts: { path: string; recipe: unknown }[]
): Promise<ValidDiscoveredPlugin> {
const rootDir = await mkdtemp(join(tmpdir(), 'orca-plugin-vm-recipe-'))
roots.push(rootDir)
await mkdir(join(rootDir, 'recipes'))
await Promise.all(
artifacts.map((artifact) =>
writeFile(join(rootDir, artifact.path), JSON.stringify(artifact.recipe), 'utf8')
)
)
const manifest = pluginManifestSchema.parse({
manifestVersion: 1,
id,
publisher: 'orca-samples',
name: id,
version: '1.0.0',
engines: { orca: '>=1.0.0' },
pluginApi: 1,
contributes: { vmRecipes: artifacts.map((artifact) => ({ path: artifact.path })) },
capabilities: []
})
const content = await hashPluginTree(rootDir)
if (!content.ok) {
throw new Error(content.error)
}
return {
pluginKey: `orca-samples.${id}`,
rootDir,
manifest,
consentFingerprint: fingerprintPluginConsent(manifest, content.hash),
consentContentHash: content.hash,
contentHash: null,
isDev: true
}
}
function artifact(id: string): {
schemaVersion: 1
id: string
name: string
create: string
suspend: string
resume: string
destroy: string
} {
return {
schemaVersion: 1,
id,
name: `Recipe ${id}`,
create: `create-${id}`,
suspend: `suspend-${id}`,
resume: `resume-${id}`,
destroy: `destroy-${id}`
}
}
describe('PluginVmRecipeRegistry', () => {
it('retains pending previews and exposes only approved recipes', async () => {
const plugin = await recipePlugin('recipes', [
{ path: 'recipes/cloud.json', recipe: artifact('cloud') }
])
const registry = new PluginVmRecipeRegistry()
await registry.reconcile([plugin], () => false)
expect(registry.list()).toEqual([])
expect(registry.preview(plugin.pluginKey)).toMatchObject([
{ pluginKey: plugin.pluginKey, recipe: { id: 'cloud', create: 'create-cloud' } }
])
await registry.reconcile([plugin], () => true)
expect(registry.list()).toMatchObject([{ recipe: { id: 'cloud' } }])
})
it('rejects malformed artifacts and duplicate ids within one plugin', async () => {
const malformed = await recipePlugin('malformed', [
{
path: 'recipes/bad.json',
recipe: { schemaVersion: 1, id: 'bad', name: 'Bad', create: 'create', suspend: 'stop' }
}
])
const duplicate = await recipePlugin('duplicate', [
{ path: 'recipes/one.json', recipe: artifact('same') },
{ path: 'recipes/two.json', recipe: artifact('same') }
])
const registry = new PluginVmRecipeRegistry()
await registry.reconcile([malformed, duplicate], () => true)
expect(registry.list()).toEqual([])
expect(registry.error(malformed.pluginKey)).toContain('suspend and resume')
expect(registry.error(duplicate.pluginKey)).toContain('duplicate VM recipe id')
})
it('errors every approved plugin that contributes the same global id', async () => {
const first = await recipePlugin('first', [
{ path: 'recipes/shared.json', recipe: artifact('shared') }
])
const second = await recipePlugin('second', [
{ path: 'recipes/shared.json', recipe: artifact('shared') }
])
const registry = new PluginVmRecipeRegistry()
await registry.reconcile([first, second], () => true)
expect(registry.list()).toEqual([])
expect(registry.error(first.pluginKey)).toContain('multiple plugins')
expect(registry.error(second.pluginKey)).toContain('multiple plugins')
})
it('deactivates disabled recipes and removes previews after uninstall', async () => {
const plugin = await recipePlugin('lifecycle', [
{ path: 'recipes/cloud.json', recipe: artifact('cloud') }
])
const registry = new PluginVmRecipeRegistry()
await registry.reconcile([plugin], () => true)
expect(registry.list()).toHaveLength(1)
await registry.reconcile([plugin], () => false)
expect(registry.list()).toEqual([])
expect(registry.preview(plugin.pluginKey)).toHaveLength(1)
await registry.reconcile([], () => false)
expect(registry.preview(plugin.pluginKey)).toEqual([])
expect(registry.error(plugin.pluginKey)).toBeNull()
})
it('refuses recipe bytes changed after the reviewed content identity', async () => {
const plugin = await recipePlugin('mutable', [
{ path: 'recipes/cloud.json', recipe: artifact('cloud') }
])
// Exercise the installed-tree identity as well as mutable dev previews.
plugin.contentHash = plugin.consentContentHash ?? null
const registry = new PluginVmRecipeRegistry()
await registry.reconcile([plugin], () => true)
expect(registry.list()).toHaveLength(1)
await writeFile(
join(plugin.rootDir, 'recipes', 'cloud.json'),
JSON.stringify({ ...artifact('cloud'), create: 'changed-after-review' }),
'utf8'
)
await registry.reconcile([plugin], () => true)
expect(registry.list()).toEqual([])
expect(registry.preview(plugin.pluginKey)).toEqual([])
expect(registry.error(plugin.pluginKey)).toContain('changed since it was reviewed')
})
})
@@ -0,0 +1,127 @@
import type { OrcaVmRecipe } from '../../shared/types'
import { parsePluginVmRecipeArtifact } from '../../shared/plugins/plugin-vm-recipe-artifact'
import {
PLUGIN_VM_RECIPE_MAX_BYTES,
readContainedPluginArtifactText
} from './plugin-artifact-validation'
import { mapPluginContentWithConcurrency } from './plugin-content-load-pool'
import {
isInvalidDiscoveredPlugin,
type DiscoveredPlugin,
type ValidDiscoveredPlugin
} from './plugin-discovery'
import { verifyInstructionalPluginContent } from './plugin-instructional-content-integrity'
const VM_RECIPE_LOAD_CONCURRENCY = 4
export type PluginVmRecipeRegistration = {
pluginKey: string
recipe: OrcaVmRecipe
}
type VmRecipeLoadResult =
| {
pluginKey: string
approved: boolean
registrations: PluginVmRecipeRegistration[]
}
| { pluginKey: string; error: string }
export class PluginVmRecipeRegistry {
private active: PluginVmRecipeRegistration[] = []
private readonly previews = new Map<string, PluginVmRecipeRegistration[]>()
private readonly errors = new Map<string, string>()
list(): readonly PluginVmRecipeRegistration[] {
return this.active
}
preview(pluginKey: string): readonly PluginVmRecipeRegistration[] {
return this.previews.get(pluginKey) ?? []
}
error(pluginKey: string): string | null {
return this.errors.get(pluginKey) ?? null
}
async reconcile(
discovered: readonly DiscoveredPlugin[],
isApproved: (plugin: ValidDiscoveredPlugin) => boolean
): Promise<void> {
const candidates = discovered.filter(
(plugin): plugin is ValidDiscoveredPlugin =>
!isInvalidDiscoveredPlugin(plugin) && plugin.manifest.contributes.vmRecipes.length > 0
)
const results = await mapPluginContentWithConcurrency(
candidates,
VM_RECIPE_LOAD_CONCURRENCY,
async (plugin): Promise<VmRecipeLoadResult> => {
try {
const approved = isApproved(plugin)
const registrations: PluginVmRecipeRegistration[] = []
const seen = new Set<string>()
for (const contribution of plugin.manifest.contributes.vmRecipes) {
const recipe = parsePluginVmRecipeArtifact(
await readContainedPluginArtifactText(
plugin.rootDir,
contribution.path,
PLUGIN_VM_RECIPE_MAX_BYTES
)
)
if (seen.has(recipe.id)) {
throw new Error(`duplicate VM recipe id "${recipe.id}"`)
}
seen.add(recipe.id)
registrations.push({ pluginKey: plugin.pluginKey, recipe })
}
// Verify after reading so the in-memory commands shown/activated are
// bound to the exact tree identity the user reviewed.
await verifyInstructionalPluginContent(plugin)
return { pluginKey: plugin.pluginKey, approved, registrations }
} catch (error) {
return {
pluginKey: plugin.pluginKey,
error: error instanceof Error ? error.message : String(error)
}
}
}
)
this.previews.clear()
this.errors.clear()
for (const result of results) {
if ('error' in result) {
this.errors.set(result.pluginKey, result.error)
} else {
this.previews.set(result.pluginKey, result.registrations)
}
}
const approved = results.filter(
(result): result is Extract<VmRecipeLoadResult, { approved: boolean }> =>
'approved' in result && result.approved
)
const owners = new Map<string, Set<string>>()
for (const result of approved) {
for (const registration of result.registrations) {
const recipeOwners = owners.get(registration.recipe.id) ?? new Set<string>()
recipeOwners.add(result.pluginKey)
owners.set(registration.recipe.id, recipeOwners)
}
}
const conflicted = new Set<string>()
for (const [recipeId, recipeOwners] of owners) {
if (recipeOwners.size > 1) {
for (const pluginKey of recipeOwners) {
conflicted.add(pluginKey)
this.errors.set(
pluginKey,
`VM recipe id "${recipeId}" is contributed by multiple plugins`
)
}
}
}
this.active = approved
.filter((result) => !conflicted.has(result.pluginKey))
.flatMap((result) => result.registrations)
}
}
+9
View File
@@ -952,6 +952,15 @@ export type PluginHostListEntry = {
commands: { id: string; title: string }[]
hasWorker: boolean
hasSkills?: boolean
vmRecipes?: {
id: string
name: string
description?: string
commands: {
phase: 'create' | 'suspend' | 'resume' | 'destroy'
command: string
}[]
}[]
restarts: number
source?: {
kind: 'local-path' | 'git'
@@ -54,6 +54,7 @@ describe('EphemeralVmsPane', () => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
toastMocks.error.mockClear()
storeMocks.openModal.mockClear()
let pluginChangeListener: ((event: { contentPacksChanged: boolean }) => void) | null = null
Object.assign(globalThis.window, {
api: {
ephemeralVm: {
@@ -94,6 +95,15 @@ describe('EphemeralVmsPane', () => {
},
ui: {
writeClipboardText: vi.fn().mockResolvedValue(undefined)
},
plugins: {
onChanged: vi.fn((listener) => {
pluginChangeListener = listener
return () => {
pluginChangeListener = null
}
}),
emitContentChanged: () => pluginChangeListener?.({ contentPacksChanged: true })
}
}
})
@@ -129,4 +139,69 @@ describe('EphemeralVmsPane', () => {
telemetrySource: 'settings'
})
})
it('refreshes the catalog when plugin content changes', async () => {
const listRecipeCatalog = window.api.ephemeralVm.listRecipeCatalog as ReturnType<typeof vi.fn>
const container = await renderPane()
await vi.waitFor(() => expect(container.textContent).toContain('Cloud Sandbox'))
listRecipeCatalog.mockResolvedValueOnce([
{
repoId: 'repo-1',
repoName: 'Repo',
repoPath: '/repo',
diagnostics: [],
recipes: [{ id: 'plugin-recipe', name: 'Plugin Recipe', create: 'create' }]
}
])
await act(async () => {
;(window.api.plugins as never as { emitContentChanged: () => void }).emitContentChanged()
})
await vi.waitFor(() => expect(container.textContent).toContain('Plugin Recipe'))
expect(listRecipeCatalog).toHaveBeenCalledTimes(2)
})
it('ignores an older refresh that finishes after a plugin-content refresh', async () => {
const listRecipeCatalog = window.api.ephemeralVm.listRecipeCatalog as ReturnType<typeof vi.fn>
let resolveInitial!: (catalog: unknown[]) => void
listRecipeCatalog
.mockReset()
.mockReturnValueOnce(
new Promise((resolve) => {
resolveInitial = resolve
})
)
.mockResolvedValueOnce([
{
repoId: 'repo-1',
repoName: 'Repo',
repoPath: '/repo',
diagnostics: [],
recipes: [{ id: 'new', name: 'Newest Recipe', create: 'create' }]
}
])
const container = await renderPane()
await act(async () => {
;(window.api.plugins as never as { emitContentChanged: () => void }).emitContentChanged()
})
await vi.waitFor(() => expect(container.textContent).toContain('Newest Recipe'))
await act(async () => {
resolveInitial([
{
repoId: 'repo-1',
repoName: 'Repo',
repoPath: '/repo',
diagnostics: [],
recipes: [{ id: 'stale', name: 'Stale Recipe', create: 'create' }]
}
])
await Promise.resolve()
})
expect(container.textContent).toContain('Newest Recipe')
expect(container.textContent).not.toContain('Stale Recipe')
})
})
@@ -1,6 +1,6 @@
import { ArrowRight, Check, Copy, Loader2, RefreshCw, Server } from 'lucide-react'
import type React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { useMountedRef } from '@/hooks/useMountedRef'
import { useAppStore } from '@/store'
@@ -44,6 +44,7 @@ export function EphemeralVmsPane(): React.JSX.Element {
const [isLoading, setIsLoading] = useState(true)
const [promptCopied, setPromptCopied] = useState(false)
const mountedRef = useMountedRef()
const refreshGenerationRef = useRef(0)
const installCommand =
activeSkillRuntime.agentRuntime && !activeSkillRuntime.installDisabledReason
@@ -71,16 +72,17 @@ export function EphemeralVmsPane(): React.JSX.Element {
})
const refresh = useCallback(async (): Promise<void> => {
const generation = ++refreshGenerationRef.current
if (mountedRef.current) {
setIsLoading(true)
}
try {
const nextCatalog = await window.api.ephemeralVm.listRecipeCatalog()
if (mountedRef.current) {
if (mountedRef.current && generation === refreshGenerationRef.current) {
setCatalog(nextCatalog)
}
} catch (error) {
if (mountedRef.current) {
if (mountedRef.current && generation === refreshGenerationRef.current) {
toast.error(
error instanceof Error
? error.message
@@ -91,7 +93,7 @@ export function EphemeralVmsPane(): React.JSX.Element {
)
}
} finally {
if (mountedRef.current) {
if (mountedRef.current && generation === refreshGenerationRef.current) {
setIsLoading(false)
}
}
@@ -101,6 +103,17 @@ export function EphemeralVmsPane(): React.JSX.Element {
void refresh()
}, [refresh])
useEffect(() => {
if (!window.api.plugins?.onChanged) {
return
}
return window.api.plugins.onChanged((event) => {
if (event?.contentPacksChanged ?? true) {
void refresh()
}
})
}, [refresh])
const openWorkspaceComposerForRecipe = (repoId: string, recipeId: string): void => {
openModal('new-workspace-composer', {
initialRepoId: repoId,
@@ -233,7 +246,7 @@ export function EphemeralVmsPane(): React.JSX.Element {
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.EphemeralVmsPane.recipesHelp',
'Recipes your agent adds to orca.yaml show up here, ready to launch a workspace on.'
'Recipes from orca.yaml and enabled plugins show up here, ready to launch a workspace on.'
)}
</p>
</div>
@@ -127,6 +127,46 @@ describe('PluginConsentDialog', () => {
)
})
it('shows every VM recipe lifecycle command verbatim', async () => {
await renderConsent(
{
...plugin,
hasWorker: false,
capabilities: [],
vmRecipes: [
{
id: 'cloud',
name: 'Cloud Sandbox',
description: 'Creates a disposable VM.',
commands: [
{ phase: 'create', command: './scripts/create.sh --exact "$VALUE"' },
{ phase: 'suspend', command: './scripts/suspend.sh' },
{ phase: 'resume', command: './scripts/resume.sh' },
{ phase: 'destroy', command: 'none' }
]
}
]
},
vi.fn().mockResolvedValue(undefined)
)
expect(document.body.textContent).toContain(
'Instructional content — runs later under user or agent authority'
)
expect(document.body.textContent).toContain('Review plugin content')
expect(document.body.textContent).toContain(
'Its instructional content can still cause actions when you or an agent use it.'
)
expect(document.body.textContent).toContain('./scripts/create.sh --exact "$VALUE"')
expect(document.body.textContent).toContain('./scripts/suspend.sh')
expect(document.body.textContent).toContain('./scripts/resume.sh')
expect(document.body.textContent).toContain('Destroynone')
const commands = Array.from(document.querySelectorAll('pre'))
expect(commands).toHaveLength(4)
expect(commands[0]?.tabIndex).toBe(0)
expect(commands[0]?.getAttribute('aria-label')).toBe('Cloud Sandbox · Create command')
})
it('records Keep Disabled when Escape dismisses the dialog', async () => {
const onDecision = vi.fn().mockResolvedValue(undefined)
await renderConsent(plugin, onDecision)
@@ -145,7 +185,9 @@ describe('PluginConsentDialog', () => {
it('labels re-consent generically and enables only after an explicit action', async () => {
const onDecision = vi.fn().mockResolvedValue(undefined)
await renderConsent({ ...plugin, needsReconsent: true }, onDecision)
expect(document.body.textContent).toContain('Permissions or the worker trust tier changed')
expect(document.body.textContent).toContain(
'Permissions, the worker trust tier, or instructional content changed'
)
const enable = Array.from(document.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === 'Enable plugin'
)
@@ -4,6 +4,7 @@ import type { PluginHostListEntry } from '../../../../preload/api-types'
import { translate } from '@/i18n/i18n'
import { pluginConsentErrorMessage } from './plugin-error-presentation'
import { Button } from '../ui/button'
import { PluginVmRecipeConsentPreview } from './PluginVmRecipeConsentPreview'
import {
Dialog,
DialogContent,
@@ -23,15 +24,22 @@ type PluginConsentDialogProps = {
}
function trustTier(plugin: PluginHostListEntry): string {
return plugin.hasWorker
? translate(
'auto.components.settings.PluginConsentDialog.workerTrust',
'Background worker — runs its own process'
)
: translate(
'auto.components.settings.PluginConsentDialog.panelTrust',
'Panel only — no worker process'
)
if (plugin.hasWorker) {
return translate(
'auto.components.settings.PluginConsentDialog.workerTrust',
'Background worker — runs its own process'
)
}
if (plugin.hasSkills || (plugin.vmRecipes?.length ?? 0) > 0) {
return translate(
'auto.components.settings.PluginConsentDialog.instructionalTrust',
'Instructional content — runs later under user or agent authority'
)
}
return translate(
'auto.components.settings.PluginConsentDialog.panelTrust',
'Panel or inert content — no worker process'
)
}
function capabilityDescription(kind: string, fallback: string): string {
@@ -76,6 +84,26 @@ function capabilityDescription(kind: string, fallback: string): string {
}
}
function hasInstructionalContent(plugin: PluginHostListEntry): boolean {
return plugin.hasSkills || (plugin.vmRecipes?.length ?? 0) > 0
}
function consentTitle(plugin: PluginHostListEntry): string {
if (!hasInstructionalContent(plugin)) {
return translate('auto.components.settings.PluginConsentDialog.title', 'Review permissions')
}
if (plugin.hasWorker || plugin.capabilities.length > 0) {
return translate(
'auto.components.settings.PluginConsentDialog.mixedTitle',
'Review access and content'
)
}
return translate(
'auto.components.settings.PluginConsentDialog.instructionalTitle',
'Review plugin content'
)
}
export function PluginConsentDialog({
plugin: currentPlugin,
onDecision
@@ -123,12 +151,7 @@ export function PluginConsentDialog({
{plugin ? (
<>
<DialogHeader>
<DialogTitle>
{translate(
'auto.components.settings.PluginConsentDialog.title',
'Review permissions'
)}
</DialogTitle>
<DialogTitle>{consentTitle(plugin)}</DialogTitle>
<DialogDescription>
{translate(
'auto.components.settings.PluginConsentDialog.subtitle',
@@ -141,7 +164,7 @@ export function PluginConsentDialog({
<p className="border-l-2 border-foreground/25 py-0.5 pl-3 text-sm leading-6">
{translate(
'auto.components.settings.PluginConsentDialog.reconsent',
'Permissions or the worker trust tier changed since you last reviewed this plugin. Review them again before it can run.'
'Permissions, the worker trust tier, or instructional content changed since you last reviewed this plugin. Review it again before it can run.'
)}
</p>
) : null}
@@ -174,27 +197,29 @@ export function PluginConsentDialog({
</dt>
<dd>{trustTier(plugin)}</dd>
</dl>
<div className="space-y-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
{translate(
'auto.components.settings.PluginConsentDialog.capabilities',
'This plugin can'
)}
</p>
{plugin.capabilities.length > 0 ? (
<div className="space-y-2">
{plugin.capabilities.map((capability) => (
<div key={capability.kind} className="flex items-start gap-2 text-sm leading-6">
<Check className="mt-1 size-3.5 shrink-0 text-muted-foreground" />
<span>
{capabilityDescription(capability.kind, capability.description)}{' '}
<span className="font-mono text-[11px] text-muted-foreground">
({capability.kind})
<p className="text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
{translate(
'auto.components.settings.PluginConsentDialog.capabilities',
'This plugin can'
)}
</p>
<div className="space-y-2">
{plugin.capabilities.map((capability) => (
<div key={capability.kind} className="flex items-start gap-2 text-sm leading-6">
<Check className="mt-1 size-3.5 shrink-0 text-muted-foreground" />
<span>
{capabilityDescription(capability.kind, capability.description)}{' '}
<span className="font-mono text-[11px] text-muted-foreground">
({capability.kind})
</span>
</span>
</span>
</div>
))}
</div>
))}
</div>
</div>
</div>
) : null}
<div className="flex items-start gap-2 rounded-md border border-border bg-muted/50 px-3.5 py-3 text-sm leading-6">
<AlertTriangle className="mt-1 size-4 shrink-0" />
<span>
@@ -203,10 +228,15 @@ export function PluginConsentDialog({
'auto.components.settings.PluginConsentDialog.warning',
"These permissions limit how the plugin uses Orca's API. Its worker still runs as a normal process on your computer with full access to your files, network, and other processes."
)
: translate(
'auto.components.settings.PluginConsentDialog.panelWarning',
"These permissions limit how the plugin uses Orca's API. This plugin has no worker process."
)}
: hasInstructionalContent(plugin)
? translate(
'auto.components.settings.PluginConsentDialog.instructionalWarning',
'This plugin has no worker process. Its instructional content can still cause actions when you or an agent use it. Review the instructions and commands below before enabling it.'
)
: translate(
'auto.components.settings.PluginConsentDialog.panelWarning',
"These permissions limit how the plugin uses Orca's API. This plugin has no worker process."
)}
</span>
</div>
{plugin.hasSkills ? (
@@ -220,6 +250,7 @@ export function PluginConsentDialog({
</span>
</div>
) : null}
<PluginVmRecipeConsentPreview recipes={plugin.vmRecipes ?? []} />
{error ? <p className="text-xs text-destructive">{error}</p> : null}
<DialogFooter>
<Button
@@ -0,0 +1,83 @@
import { AlertTriangle } from 'lucide-react'
import type { PluginHostListEntry } from '../../../../preload/api-types'
import { translate } from '@/i18n/i18n'
type VmRecipePreview = NonNullable<PluginHostListEntry['vmRecipes']>[number]
function lifecycleLabel(phase: VmRecipePreview['commands'][number]['phase']): string {
switch (phase) {
case 'create':
return translate('auto.components.settings.PluginVmRecipeConsentPreview.create', 'Create')
case 'suspend':
return translate('auto.components.settings.PluginVmRecipeConsentPreview.suspend', 'Suspend')
case 'resume':
return translate('auto.components.settings.PluginVmRecipeConsentPreview.resume', 'Resume')
case 'destroy':
return translate('auto.components.settings.PluginVmRecipeConsentPreview.destroy', 'Destroy')
}
}
export function PluginVmRecipeConsentPreview({
recipes
}: {
recipes: readonly VmRecipePreview[]
}): React.JSX.Element | null {
if (recipes.length === 0) {
return null
}
return (
<section className="space-y-3" aria-labelledby="plugin-vm-recipe-consent-heading">
<div className="flex items-start gap-2 rounded-md border border-border bg-muted/50 px-3.5 py-3 text-sm leading-6">
<AlertTriangle className="mt-1 size-4 shrink-0" />
<span>
{translate(
'auto.components.settings.PluginVmRecipeConsentPreview.warning',
'This plugin installs VM recipes. Orca runs the commands below on your local desktop host when you use the matching lifecycle action.'
)}
</span>
</div>
<h3
id="plugin-vm-recipe-consent-heading"
className="text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground"
>
{translate(
'auto.components.settings.PluginVmRecipeConsentPreview.heading',
'VM recipe commands'
)}
</h3>
{recipes.map((recipe) => (
<div key={recipe.id} className="space-y-2 rounded-md border border-border p-3">
<div>
<p className="text-sm font-medium">{recipe.name}</p>
{recipe.description ? (
<p className="text-xs leading-5 text-muted-foreground">{recipe.description}</p>
) : null}
</div>
<dl className="space-y-2">
{recipe.commands.map(({ phase, command }) => {
const phaseLabel = lifecycleLabel(phase)
return (
<div key={phase}>
<dt className="mb-1 text-xs text-muted-foreground">{phaseLabel}</dt>
<dd>
<pre
tabIndex={0}
aria-label={translate(
'auto.components.settings.PluginVmRecipeConsentPreview.commandLabel',
'{{value0}} · {{value1}} command',
{ value0: recipe.name, value1: phaseLabel }
)}
className="max-h-40 overflow-auto scrollbar-sleek whitespace-pre-wrap break-all rounded-md bg-muted px-2.5 py-2 font-mono text-xs leading-5 text-foreground"
>
{command}
</pre>
</dd>
</div>
)
})}
</dl>
</div>
))}
</section>
)
}
@@ -10,6 +10,10 @@ import {
} from './useComposerState'
const HOOK_SOURCE = readFileSync(join(__dirname, 'useComposerState.ts'), 'utf8')
const RECIPE_OPTIONS_SOURCE = readFileSync(
join(__dirname, 'useEphemeralVmRecipeOptions.ts'),
'utf8'
)
function sourceBetween(source: string, startPattern: string, endPattern: string): string {
const start = source.indexOf(startPattern)
@@ -632,8 +636,12 @@ describe('useComposerState host-context boundaries', () => {
'const selectedRepoConnectionId'
)
expect(recipeLoadSection).toContain('settings?.experimentalEphemeralVms === true')
expect(recipeLoadSection).toContain('!ephemeralVmsEnabled')
expect(recipeLoadSection).toContain('window.api.ephemeralVm')
expect(recipeLoadSection).toContain('useEphemeralVmRecipeOptions')
expect(recipeLoadSection).toContain('enabled: ephemeralVmsEnabled')
expect(RECIPE_OPTIONS_SOURCE).toContain('args.enabled &&')
expect(RECIPE_OPTIONS_SOURCE).toContain('window.api.ephemeralVm')
expect(RECIPE_OPTIONS_SOURCE).toContain('window.api.plugins.onChanged')
expect(RECIPE_OPTIONS_SOURCE).toContain('requestGeneration')
const submitSection = sourceBetween(
HOOK_SOURCE,
+14 -65
View File
@@ -124,6 +124,7 @@ import {
type NewWorkspaceProjectOption
} from '@/lib/new-workspace-project-options'
import { useDetectedAgents } from '@/hooks/useDetectedAgents'
import { useEphemeralVmRecipeOptions } from '@/hooks/useEphemeralVmRecipeOptions'
import {
getFolderSourceRepos,
getLinkedItemDisplayName,
@@ -751,13 +752,6 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
)
const selectedRepo = eligibleRepos.find((repo) => repo.id === repoId)
const selectedRepoIsGit = selectedRepo ? isGitRepoKind(selectedRepo) : false
const [ephemeralVmRecipes, setEphemeralVmRecipes] = useState<
NonNullable<OrcaHooks['environmentRecipes']>
>([])
const [selectedEphemeralVmRecipeId, setSelectedEphemeralVmRecipeId] = useState<string | null>(
null
)
const [ephemeralVmRecipeError, setEphemeralVmRecipeError] = useState<string | null>(null)
const selectedRepoAgentLaunchPlatform = useMemo(() => {
if (!selectedRepo) {
return CLIENT_PLATFORM
@@ -857,64 +851,19 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
// Why: the experimental toggle must hide the composer target and avoid probing
// repo recipes, since recipe discovery can surface setup errors for a hidden feature.
const ephemeralVmsEnabled = settings?.experimentalEphemeralVms === true
useEffect(() => {
let cancelled = false
setEphemeralVmRecipes([])
setSelectedEphemeralVmRecipeId(null)
setEphemeralVmRecipeError(null)
if (
!ephemeralVmsEnabled ||
!selectedRecipeRepoId ||
!selectedRepoIsGit ||
selectedRecipeRepoConnectionId ||
isProjectGroupTarget
) {
return () => {
cancelled = true
}
}
void window.api.ephemeralVm
.listRecipes({ repoId: selectedRecipeRepoId })
.then((result) => {
if (cancelled) {
return
}
setEphemeralVmRecipes(result.recipes ?? [])
setSelectedEphemeralVmRecipeId(
initialEphemeralVmRecipeId &&
result.recipes?.some((recipe) => recipe.id === initialEphemeralVmRecipeId)
? initialEphemeralVmRecipeId
: null
)
const diagnosticMessages = (result.diagnostics ?? []).map((diagnostic) => {
const recipeLabel = `environmentRecipes[${diagnostic.index}]`
const fieldLabel = diagnostic.field ? `.${diagnostic.field}` : ''
return `${recipeLabel}${fieldLabel}: ${diagnostic.message}`
})
setEphemeralVmRecipeError(
[result.status === 'error' ? result.message : null, ...diagnosticMessages]
.filter((message): message is string => Boolean(message))
.join('\n') || null
)
})
.catch((error) => {
if (cancelled) {
return
}
setEphemeralVmRecipes([])
setEphemeralVmRecipeError(error instanceof Error ? error.message : String(error))
})
return () => {
cancelled = true
}
}, [
ephemeralVmsEnabled,
initialEphemeralVmRecipeId,
isProjectGroupTarget,
selectedRecipeRepoConnectionId,
selectedRecipeRepoId,
selectedRepoIsGit
])
const {
recipes: ephemeralVmRecipes,
selectedRecipeId: selectedEphemeralVmRecipeId,
setSelectedRecipeId: setSelectedEphemeralVmRecipeId,
error: ephemeralVmRecipeError
} = useEphemeralVmRecipeOptions({
enabled: ephemeralVmsEnabled,
repoId: selectedRecipeRepoId,
repoIsGit: selectedRepoIsGit,
repoConnectionId: selectedRecipeRepoConnectionId,
projectGroupTarget: isProjectGroupTarget,
initialRecipeId: initialEphemeralVmRecipeId
})
const selectedRepoConnectionId = selectedRepo?.connectionId ?? null
const selectedRepoSshState = selectedRepoConnectionId
? (sshConnectionStates.get(selectedRepoConnectionId) ?? null)
@@ -0,0 +1,148 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useEphemeralVmRecipeOptions } from './useEphemeralVmRecipeOptions'
globalThis.IS_REACT_ACT_ENVIRONMENT = true
type RecipeListResult = Awaited<ReturnType<typeof window.api.ephemeralVm.listRecipes>>
const roots: Root[] = []
afterEach(async () => {
await act(async () => {
roots.splice(0).forEach((root) => root.unmount())
})
document.body.innerHTML = ''
vi.restoreAllMocks()
})
function result(ids: string[]): RecipeListResult {
return {
status: 'ok',
repoPath: '/repo',
diagnostics: [],
recipes: ids.map((id) => ({ id, name: id, create: `create-${id}` }))
}
}
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve!: (value: T) => void
const promise = new Promise<T>((settle) => {
resolve = settle
})
return { promise, resolve }
}
function installApi(listRecipes: ReturnType<typeof vi.fn>): {
emit: (contentPacksChanged?: boolean) => void
} {
let listener: ((event: { contentPacksChanged: boolean }) => void) | null = null
window.api = {
ephemeralVm: { listRecipes },
plugins: {
onChanged: vi.fn((nextListener) => {
listener = nextListener
return () => {
listener = null
}
})
}
} as never
return {
emit: (contentPacksChanged = true) => listener?.({ contentPacksChanged })
}
}
function Harness({
repoId,
initialRecipeId,
enabled = true
}: {
repoId: string
initialRecipeId?: string
enabled?: boolean
}): React.JSX.Element {
const state = useEphemeralVmRecipeOptions({
enabled,
repoId,
repoIsGit: true,
repoConnectionId: null,
projectGroupTarget: false,
initialRecipeId
})
return (
<div>
<span data-testid="repo">{repoId}</span>
<span data-testid="recipes">{state.recipes.map((recipe) => recipe.id).join(',')}</span>
<span data-testid="selected">{state.selectedRecipeId ?? 'none'}</span>
</div>
)
}
async function render(
element: React.ReactNode
): Promise<{ root: Root; container: HTMLDivElement }> {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
roots.push(root)
await act(async () => root.render(element))
return { root, container }
}
describe('useEphemeralVmRecipeOptions', () => {
it('refetches on plugin lifecycle changes and clears only a removed selection', async () => {
const listRecipes = vi
.fn()
.mockResolvedValueOnce(result(['repo', 'plugin']))
.mockResolvedValueOnce(result(['repo', 'plugin', 'new-plugin']))
.mockResolvedValueOnce(result(['repo']))
const changes = installApi(listRecipes)
const { container } = await render(<Harness repoId="repo-1" initialRecipeId="plugin" />)
await vi.waitFor(() =>
expect(container.querySelector('[data-testid="selected"]')?.textContent).toBe('plugin')
)
await act(async () => changes.emit())
await vi.waitFor(() =>
expect(container.querySelector('[data-testid="recipes"]')?.textContent).toContain(
'new-plugin'
)
)
expect(container.querySelector('[data-testid="selected"]')?.textContent).toBe('plugin')
await act(async () => changes.emit())
await vi.waitFor(() =>
expect(container.querySelector('[data-testid="selected"]')?.textContent).toBe('none')
)
expect(container.querySelector('[data-testid="repo"]')?.textContent).toBe('repo-1')
})
it('ignores a stale recipe response after switching projects', async () => {
const first = deferred<RecipeListResult>()
const second = deferred<RecipeListResult>()
installApi(vi.fn().mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise))
const { root, container } = await render(<Harness repoId="repo-1" />)
await act(async () => root.render(<Harness repoId="repo-2" />))
await act(async () => second.resolve(result(['new-project'])))
await act(async () => first.resolve(result(['stale-project'])))
await vi.waitFor(() =>
expect(container.querySelector('[data-testid="recipes"]')?.textContent).toBe('new-project')
)
expect(container.textContent).not.toContain('stale-project')
})
it('does not probe recipes while the feature is disabled', async () => {
const listRecipes = vi.fn().mockResolvedValue(result(['hidden']))
installApi(listRecipes)
await render(<Harness repoId="repo-1" enabled={false} />)
expect(listRecipes).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,100 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { OrcaVmRecipe } from '../../../shared/types'
type EphemeralVmRecipeOptionsArgs = {
enabled: boolean
repoId: string | null
repoIsGit: boolean
repoConnectionId: string | null
projectGroupTarget: boolean
initialRecipeId?: string
}
export function useEphemeralVmRecipeOptions(args: EphemeralVmRecipeOptionsArgs): {
recipes: OrcaVmRecipe[]
selectedRecipeId: string | null
setSelectedRecipeId: (recipeId: string | null) => void
error: string | null
} {
const [recipes, setRecipes] = useState<OrcaVmRecipe[]>([])
const [selectedRecipeId, setSelectedRecipeId] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const requestGeneration = useRef(0)
const canLoad =
args.enabled &&
Boolean(args.repoId) &&
args.repoIsGit &&
!args.repoConnectionId &&
!args.projectGroupTarget
const load = useCallback(
(resetSelection: boolean): void => {
const generation = ++requestGeneration.current
if (resetSelection) {
setRecipes([])
setSelectedRecipeId(null)
setError(null)
}
if (!canLoad || !args.repoId) {
return
}
void window.api.ephemeralVm
.listRecipes({ repoId: args.repoId })
.then((result) => {
if (generation !== requestGeneration.current) {
return
}
const nextRecipes = result.recipes ?? []
setRecipes(nextRecipes)
setSelectedRecipeId((current) => {
if (resetSelection) {
return args.initialRecipeId &&
nextRecipes.some((recipe) => recipe.id === args.initialRecipeId)
? args.initialRecipeId
: null
}
return current && nextRecipes.some((recipe) => recipe.id === current) ? current : null
})
const diagnosticMessages = (result.diagnostics ?? []).map((diagnostic) => {
const recipeLabel = `environmentRecipes[${diagnostic.index}]`
const fieldLabel = diagnostic.field ? `.${diagnostic.field}` : ''
return `${recipeLabel}${fieldLabel}: ${diagnostic.message}`
})
setError(
[result.status === 'error' ? result.message : null, ...diagnosticMessages]
.filter((message): message is string => Boolean(message))
.join('\n') || null
)
})
.catch((cause) => {
if (generation !== requestGeneration.current) {
return
}
setRecipes([])
setSelectedRecipeId(null)
setError(cause instanceof Error ? cause.message : String(cause))
})
},
[args.initialRecipeId, args.repoId, canLoad]
)
useEffect(() => {
load(true)
return () => {
requestGeneration.current += 1
}
}, [load])
useEffect(() => {
if (!window.api.plugins?.onChanged) {
return
}
return window.api.plugins.onChanged((event) => {
if (event?.contentPacksChanged ?? true) {
load(false)
}
})
}, [load])
return { recipes, selectedRecipeId, setSelectedRecipeId, error }
}
+17 -4
View File
@@ -8973,7 +8973,7 @@
"copy": "Copy",
"copied": "Copied",
"recipes": "Recipes",
"recipesHelp": "Recipes your agent adds to orca.yaml show up here, ready to launch a workspace on.",
"recipesHelp": "Recipes from orca.yaml and enabled plugins show up here, ready to launch a workspace on.",
"refresh": "Refresh ephemeral VM recipes",
"checking": "Checking recipes...",
"none": "No recipes found yet."
@@ -9039,11 +9039,13 @@
},
"PluginConsentDialog": {
"workerTrust": "Background worker — runs its own process",
"panelTrust": "Panel only — no worker process",
"panelTrust": "Panel or inert content — no worker process",
"decisionFailed": "Could not save the permission decision. Try again.",
"title": "Review permissions",
"mixedTitle": "Review access and content",
"instructionalTitle": "Review plugin content",
"subtitle": "{{value0}} v{{value1}} · {{value2}}",
"reconsent": "Permissions or the worker trust tier changed since you last reviewed this plugin. Review them again before it can run.",
"reconsent": "Permissions, the worker trust tier, or instructional content changed since you last reviewed this plugin. Review it again before it can run.",
"source": "Source",
"unknownSource": "Not available",
"commit": "Commit",
@@ -9054,6 +9056,7 @@
"enable": "Enable plugin",
"keepDisabled": "Keep Disabled",
"panelWarning": "These permissions limit how the plugin uses Orca's API. This plugin has no worker process.",
"instructionalWarning": "This plugin has no worker process. Its instructional content can still cause actions when you or an agent use it. Review the instructions and commands below before enabling it.",
"capability": {
"workspaceRead": "Read the name, branch, and terminal list of your focused worktree",
"terminalSend": "Type text into a terminal you can see (always a specific terminal)",
@@ -9063,7 +9066,8 @@
"eventsSubscribe": "Get notified when worktrees are created or removed and when agent status changes",
"settingsOwn": "Read and change the plugin's own settings"
},
"skillWarning": "This plugin installs instructional skills. Agents read those instructions and may act on them with the full authority you give the agent."
"skillWarning": "This plugin installs instructional skills. Agents read those instructions and may act on them with the full authority you give the agent.",
"instructionalTrust": "Instructional content — runs later under user or agent authority"
},
"PluginDevelopmentSection": {
"pathRequired": "Enter a plugin folder path.",
@@ -9191,6 +9195,15 @@
"disabled": "Do not install",
"cancel": "Cancel",
"save": "Save"
},
"PluginVmRecipeConsentPreview": {
"create": "Create",
"suspend": "Suspend",
"resume": "Resume",
"destroy": "Destroy",
"warning": "This plugin installs VM recipes. Orca runs the commands below on your local desktop host when you use the matching lifecycle action.",
"commandLabel": "{{value0}} · {{value1}} command",
"heading": "VM recipe commands"
}
},
"right": {
+17 -4
View File
@@ -8973,7 +8973,7 @@
"copy": "Copiar",
"copied": "Copiado",
"recipes": "Recetas",
"recipesHelp": "Las recetas que tu agente agrega a orca.yaml aparecen aquí, listas para iniciar un espacio de trabajo.",
"recipesHelp": "Las recetas de orca.yaml y de los plugins activados aparecen aquí, listas para iniciar un espacio de trabajo.",
"refresh": "Actualizar recetas de VMs efímeras",
"checking": "Comprobando recetas…",
"none": "Aún no se han encontrado recetas."
@@ -9039,11 +9039,13 @@
},
"PluginConsentDialog": {
"workerTrust": "Proceso en segundo plano — se ejecuta en su propio proceso",
"panelTrust": "Solo panel — sin proceso en segundo plano",
"panelTrust": "Panel o contenido inerte — sin proceso en segundo plano",
"decisionFailed": "No se pudo guardar la decisión sobre los permisos. Inténtalo de nuevo.",
"title": "Revisar permisos",
"mixedTitle": "Revisar acceso y contenido",
"instructionalTitle": "Revisar el contenido del plugin",
"subtitle": "{{value0}} v{{value1}} · {{value2}}",
"reconsent": "Los permisos o el nivel de confianza del proceso cambiaron desde la última revisión de este plugin. Revísalos de nuevo antes de ejecutarlo.",
"reconsent": "Los permisos, el nivel de confianza del proceso o el contenido instructivo cambiaron desde la última revisión de este plugin. Revísalo de nuevo antes de ejecutarlo.",
"source": "Origen",
"unknownSource": "No disponible",
"commit": "Commit",
@@ -9054,6 +9056,7 @@
"enable": "Activar plugin",
"keepDisabled": "Mantener desactivado",
"panelWarning": "Estos permisos limitan cómo usa el plugin la API de Orca. Este plugin no tiene ningún proceso en segundo plano.",
"instructionalWarning": "Este plugin no tiene ningún proceso en segundo plano. Aun así, su contenido instructivo puede provocar acciones cuando tú o un agente lo usen. Revisa las instrucciones y los comandos siguientes antes de habilitarlo.",
"capability": {
"workspaceRead": "Leer el nombre, la rama y la lista de terminales del worktree seleccionado",
"terminalSend": "Escribir texto en una terminal visible (siempre en una terminal específica)",
@@ -9063,7 +9066,8 @@
"eventsSubscribe": "Recibir avisos cuando se creen o eliminen worktrees y cuando cambie el estado de un agente",
"settingsOwn": "Leer y cambiar la configuración propia del plugin"
},
"skillWarning": "Este plugin instala habilidades con instrucciones. Los agentes leen esas instrucciones y pueden actuar con toda la autoridad que les concedas."
"skillWarning": "Este plugin instala habilidades con instrucciones. Los agentes leen esas instrucciones y pueden actuar con toda la autoridad que les concedas.",
"instructionalTrust": "Contenido instructivo — se ejecuta más tarde con la autoridad del usuario o del agente"
},
"PluginDevelopmentSection": {
"pathRequired": "Introduce la ruta de una carpeta de plugin.",
@@ -9191,6 +9195,15 @@
"disabled": "No instalar",
"cancel": "Cancelar",
"save": "Guardar"
},
"PluginVmRecipeConsentPreview": {
"create": "Crear",
"suspend": "Suspender",
"resume": "Reanudar",
"destroy": "Destruir",
"warning": "Este plugin instala recetas de VM. Orca ejecuta los comandos siguientes en el equipo local cuando usas la acción de ciclo de vida correspondiente.",
"commandLabel": "{{value0}} · comando de {{value1}}",
"heading": "Comandos de recetas de VM"
}
},
"right": {
+17 -4
View File
@@ -8973,7 +8973,7 @@
"copy": "コピー",
"copied": "コピーされました",
"recipes": "レシピ",
"recipesHelp": "agent が orca.yaml に追加したレシピがここに表示され、ワー​​クスペースを起動できるようになります。",
"recipesHelp": "orca.yaml と有効なプラグインのレシピがここに表示され、ワークスペースを起動できます。",
"refresh": "一時的な VM レシピを更新する",
"checking": "レシピを確認中...",
"none": "レシピはまだ見つかりません。"
@@ -9039,11 +9039,13 @@
},
"PluginConsentDialog": {
"workerTrust": "バックグラウンドワーカー — 独自のプロセスで実行",
"panelTrust": "パネルのみ — ワーカープロセスなし",
"panelTrust": "パネルまたは非実行コンテンツ — ワーカープロセスなし",
"decisionFailed": "権限の選択を保存できませんでした。再試行してください。",
"title": "権限を確認",
"mixedTitle": "アクセスとコンテンツを確認",
"instructionalTitle": "プラグインのコンテンツを確認",
"subtitle": "{{value0}} v{{value1}} · {{value2}}",
"reconsent": "前回このプラグインを確認してから、権限またはワーカーの信頼レベルが変更されました。実行する前にもう一度確認してください。",
"reconsent": "前回このプラグインを確認してから、権限、ワーカーの信頼レベル、または指示コンテンツが変更されました。実行する前にもう一度確認してください。",
"source": "ソース",
"unknownSource": "利用不可",
"commit": "コミット",
@@ -9054,6 +9056,7 @@
"enable": "プラグインを有効化",
"keepDisabled": "無効のままにする",
"panelWarning": "これらの権限は、プラグインによる Orca API の使用を制限します。このプラグインにはワーカープロセスがありません。",
"instructionalWarning": "このプラグインにはワーカープロセスがありません。ただし、その指示コンテンツは、あなたやエージェントが使用したときに操作を実行させる可能性があります。有効にする前に、以下の指示とコマンドを確認してください。",
"capability": {
"workspaceRead": "選択中のワークツリーの名前、ブランチ、ターミナル一覧を読み取る",
"terminalSend": "表示中の特定のターミナルにテキストを入力する",
@@ -9063,7 +9066,8 @@
"eventsSubscribe": "ワークツリーの作成・削除やエージェント状態の変更時に通知を受け取る",
"settingsOwn": "プラグイン自身の設定を読み取り、変更する"
},
"skillWarning": "このプラグインは指示を含むスキルをインストールします。エージェントはその指示を読み、付与されたすべての権限で操作する場合があります。"
"skillWarning": "このプラグインは指示を含むスキルをインストールします。エージェントはその指示を読み、付与されたすべての権限で操作する場合があります。",
"instructionalTrust": "指示コンテンツ — 後でユーザーまたはエージェントの権限で実行"
},
"PluginDevelopmentSection": {
"pathRequired": "プラグインフォルダーのパスを入力してください。",
@@ -9191,6 +9195,15 @@
"disabled": "インストールしない",
"cancel": "キャンセル",
"save": "保存"
},
"PluginVmRecipeConsentPreview": {
"create": "作成",
"suspend": "一時停止",
"resume": "再開",
"destroy": "破棄",
"warning": "このプラグインは VM レシピをインストールします。対応するライフサイクル操作を使用すると、Orca は以下のコマンドをローカルのデスクトップホストで実行します。",
"commandLabel": "{{value0}} · {{value1}}コマンド",
"heading": "VM レシピのコマンド"
}
},
"right": {
+17 -4
View File
@@ -8973,7 +8973,7 @@
"copy": "복사",
"copied": "복사됨",
"recipes": "조리법",
"recipesHelp": "agent가 orca.yaml에 추가하는 레시피가 여기에 표시되어 워크스페이스를 시작할 준비가 됩니다.",
"recipesHelp": "orca.yaml과 활성화된 플러그인의 레시피가 여기에 표시되어 워크스페이스를 시작할 수 있습니다.",
"refresh": "임시 VM 레시피 새로 고침",
"checking": "레시피 확인 중...",
"none": "아직 레시피를 찾을 수 없습니다."
@@ -9039,11 +9039,13 @@
},
"PluginConsentDialog": {
"workerTrust": "백그라운드 워커 — 별도 프로세스에서 실행",
"panelTrust": "패널 전용 — 워커 프로세스 없음",
"panelTrust": "패널 또는 비실행 콘텐츠 — 워커 프로세스 없음",
"decisionFailed": "권한 결정을 저장할 수 없습니다. 다시 시도하세요.",
"title": "권한 검토",
"mixedTitle": "액세스 및 콘텐츠 검토",
"instructionalTitle": "플러그인 콘텐츠 검토",
"subtitle": "{{value0}} v{{value1}} · {{value2}}",
"reconsent": "이 플러그인을 마지막으로 검토한 후 권한 또는 워커 신뢰 수준이 변경되었습니다. 실행하기 전에 다시 검토하세요.",
"reconsent": "이 플러그인을 마지막으로 검토한 후 권한, 워커 신뢰 수준 또는 지침 콘텐츠가 변경되었습니다. 실행하기 전에 다시 검토하세요.",
"source": "소스",
"unknownSource": "사용할 수 없음",
"commit": "커밋",
@@ -9054,6 +9056,7 @@
"enable": "플러그인 활성화",
"keepDisabled": "비활성화 유지",
"panelWarning": "이 권한은 플러그인이 Orca API를 사용하는 방식을 제한합니다. 이 플러그인에는 워커 프로세스가 없습니다.",
"instructionalWarning": "이 플러그인에는 워커 프로세스가 없습니다. 하지만 사용자나 에이전트가 사용할 때 지침 콘텐츠로 인해 작업이 실행될 수 있습니다. 활성화하기 전에 아래 지침과 명령을 검토하세요.",
"capability": {
"workspaceRead": "선택한 워크트리의 이름, 브랜치 및 터미널 목록 읽기",
"terminalSend": "표시된 특정 터미널에 텍스트 입력",
@@ -9063,7 +9066,8 @@
"eventsSubscribe": "워크트리가 생성 또는 제거되거나 에이전트 상태가 변경될 때 알림 받기",
"settingsOwn": "플러그인 자체 설정 읽기 및 변경"
},
"skillWarning": "이 플러그인은 지침이 포함된 스킬을 설치합니다. 에이전트는 해당 지침을 읽고 사용자가 부여한 모든 권한으로 작업할 수 있습니다."
"skillWarning": "이 플러그인은 지침이 포함된 스킬을 설치합니다. 에이전트는 해당 지침을 읽고 사용자가 부여한 모든 권한으로 작업할 수 있습니다.",
"instructionalTrust": "지침 콘텐츠 — 나중에 사용자 또는 에이전트 권한으로 실행"
},
"PluginDevelopmentSection": {
"pathRequired": "플러그인 폴더 경로를 입력하세요.",
@@ -9191,6 +9195,15 @@
"disabled": "설치하지 않음",
"cancel": "취소",
"save": "저장"
},
"PluginVmRecipeConsentPreview": {
"create": "생성",
"suspend": "일시 중지",
"resume": "재개",
"destroy": "삭제",
"warning": "이 플러그인은 VM 레시피를 설치합니다. 해당 수명 주기 작업을 사용하면 Orca가 아래 명령을 로컬 데스크톱 호스트에서 실행합니다.",
"commandLabel": "{{value0}} · {{value1}} 명령",
"heading": "VM 레시피 명령"
}
},
"right": {
+17 -4
View File
@@ -8973,7 +8973,7 @@
"copy": "复制",
"copied": "已复制",
"recipes": "环境模板",
"recipesHelp": "智能体添加到 orca.yaml 的环境模板会显示在这里,可用于启动工作区。",
"recipesHelp": "orca.yaml 和已启用插件中的配方会显示在这里,可用于启动工作区。",
"refresh": "刷新临时 VM 环境模板",
"checking": "正在检查环境模板...",
"none": "还没有找到环境模板。"
@@ -9039,11 +9039,13 @@
},
"PluginConsentDialog": {
"workerTrust": "后台工作进程 — 在独立进程中运行",
"panelTrust": "仅面板 — 无工作进程",
"panelTrust": "面板或惰性内容 — 无工作进程",
"decisionFailed": "无法保存权限决定。请重试。",
"title": "检查权限",
"mixedTitle": "检查访问权限和内容",
"instructionalTitle": "检查插件内容",
"subtitle": "{{value0}} v{{value1}} · {{value2}}",
"reconsent": "自你上次检查此插件以来,权限或工作进程信任级别已更改。请在运行前重新检查。",
"reconsent": "自你上次检查此插件以来,权限、工作进程信任级别或指令内容已更改。请在运行前重新检查。",
"source": "来源",
"unknownSource": "不可用",
"commit": "提交",
@@ -9054,6 +9056,7 @@
"enable": "启用插件",
"keepDisabled": "保持禁用",
"panelWarning": "这些权限限制插件使用 Orca API 的方式。此插件没有工作进程。",
"instructionalWarning": "此插件没有工作进程。但当你或智能体使用其指令内容时,仍可能触发操作。启用前请检查以下指令和命令。",
"capability": {
"workspaceRead": "读取当前工作树的名称、分支和终端列表",
"terminalSend": "在可见的指定终端中输入文本",
@@ -9063,7 +9066,8 @@
"eventsSubscribe": "在创建或移除工作树以及智能体状态变化时接收通知",
"settingsOwn": "读取和更改插件自己的设置"
},
"skillWarning": "此插件会安装包含指令的技能。智能体会读取这些指令,并可能使用你授予智能体的全部权限执行操作。"
"skillWarning": "此插件会安装包含指令的技能。智能体会读取这些指令,并可能使用你授予智能体的全部权限执行操作。",
"instructionalTrust": "指令内容 — 稍后以用户或智能体权限运行"
},
"PluginDevelopmentSection": {
"pathRequired": "请输入插件文件夹路径。",
@@ -9191,6 +9195,15 @@
"disabled": "不安装",
"cancel": "取消",
"save": "保存"
},
"PluginVmRecipeConsentPreview": {
"create": "创建",
"suspend": "暂停",
"resume": "恢复",
"destroy": "销毁",
"warning": "此插件会安装 VM 配方。当你使用对应的生命周期操作时,Orca 会在本地桌面主机上运行以下命令。",
"commandLabel": "{{value0}} · {{value1}}命令",
"heading": "VM 配方命令"
}
},
"right": {
+16
View File
@@ -31,9 +31,25 @@ export type EphemeralVmRuntimeConnectionMode = z.infer<
typeof EphemeralVmRuntimeConnectionModeSchema
>
const EphemeralVmRuntimeRecipeSchema = z
.object({
id: z.string().min(1),
name: z.string().min(1),
create: z.string().min(1),
description: z.string().min(1).optional(),
suspend: z.string().min(1).optional(),
resume: z.string().min(1).optional(),
destroy: z.string().min(1).optional(),
destroyDisabled: z.boolean().optional()
})
.strict()
export const EphemeralVmRuntimeRecordSchema = z.object({
id: z.string().min(1),
recipeId: z.string().min(1),
/** Immutable lifecycle commands used for this runtime even if its source
* pack is updated, disabled, or removed later. */
recipe: EphemeralVmRuntimeRecipeSchema.optional(),
repoId: z.string().min(1).optional(),
projectId: z.string().min(1).optional(),
workspaceId: z.string().min(1).optional(),
@@ -63,6 +63,46 @@ describe('fingerprintPluginConsent', () => {
expect(fingerprintPluginConsent({ main: undefined, capabilities })).toBe(legacy)
})
it('requires re-consent when instructional content bytes change', () => {
const subject = {
main: undefined,
capabilities: [],
contributes: {
skills: [],
keybindings: [],
vmRecipes: [{ path: 'recipes/cloud.json' }],
agents: []
}
}
const first = fingerprintPluginConsent(subject, 'a'.repeat(64))
const second = fingerprintPluginConsent(subject, 'b'.repeat(64))
expect(second).not.toBe(first)
expect(
getPluginActivationState('orca-samples.recipes', second, {
pluginConsents: { 'orca-samples.recipes': first },
disabledPlugins: []
})
).toBe('pending')
})
it('does not invalidate inert content consent when only its content hash changes', () => {
const subject = {
main: undefined,
capabilities: [],
contributes: {
skills: [],
keybindings: [],
vmRecipes: [],
agents: []
}
}
expect(fingerprintPluginConsent(subject, 'a'.repeat(64))).toBe(
fingerprintPluginConsent(subject, 'b'.repeat(64))
)
})
})
describe('plugin install lockfile consent fingerprints', () => {
@@ -2,22 +2,47 @@ import { createHash } from 'node:crypto'
import { canonicalizeCapabilitySet } from './plugin-capabilities'
import type { PluginManifest } from './plugin-manifest'
type PluginConsentSubject = Pick<PluginManifest, 'capabilities' | 'main'> & {
contributes?: Partial<
Pick<PluginManifest['contributes'], 'skills' | 'keybindings' | 'vmRecipes' | 'agents'>
>
}
export function hasInstructionalPluginContributions(manifest: PluginConsentSubject): boolean {
const contributions = manifest.contributes
return Boolean(
contributions &&
((contributions.skills?.length ?? 0) > 0 ||
(contributions.keybindings?.length ?? 0) > 0 ||
(contributions.vmRecipes?.length ?? 0) > 0 ||
(contributions.agents?.length ?? 0) > 0)
)
}
/**
* Consent covers both the declared host capabilities and whether the plugin
* executes trusted Node code. A panel-only update that adds `main` crosses a
* trust boundary even when its capability list is unchanged.
*/
export function canonicalizePluginConsent(
manifest: Pick<PluginManifest, 'capabilities' | 'main'>
manifest: PluginConsentSubject,
contentIdentity?: string
): string {
const capabilities = canonicalizeCapabilitySet(manifest.capabilities)
// Preserve the original capability-only digest for panel/content plugins,
// while making the trusted Node tier a domain-separated fingerprint.
return manifest.main === undefined ? capabilities : `${capabilities}\0trusted-node-worker`
const workerIdentity = manifest.main === undefined ? '' : '\0trusted-node-worker'
// Instructional bytes execute later under user or agent authority, so
// approval is bound to their immutable install/dev-tree identity.
const instructionalIdentity = hasInstructionalPluginContributions(manifest)
? `\0instructional-content:${contentIdentity ?? 'unresolved'}`
: ''
return `${capabilities}${workerIdentity}${instructionalIdentity}`
}
export function fingerprintPluginConsent(
manifest: Pick<PluginManifest, 'capabilities' | 'main'>
manifest: PluginConsentSubject,
contentIdentity?: string
): string {
return `sha256-${createHash('sha256').update(canonicalizePluginConsent(manifest)).digest('base64')}`
return `sha256-${createHash('sha256')
.update(canonicalizePluginConsent(manifest, contentIdentity))
.digest('base64')}`
}
@@ -33,19 +33,22 @@ describe('plugin language-pack artifacts', () => {
})
})
it('prevents language packs from rewriting plugin consent and trust copy', () => {
expect(
parsePluginLanguagePackArtifact(
JSON.stringify({
auto: {
components: {
settings: { PluginConsentDialog: { disclaimer: 'This plugin is perfectly safe.' } }
it.each(['PluginConsentDialog', 'PluginVmRecipeConsentPreview'])(
'prevents language packs from rewriting %s security copy',
(component) => {
expect(
parsePluginLanguagePackArtifact(
JSON.stringify({
auto: {
components: {
settings: { [component]: { disclaimer: 'This plugin is perfectly safe.' } }
}
}
}
})
)
).toMatchObject({ ok: false, error: expect.stringContaining('protected security copy') })
})
})
)
).toMatchObject({ ok: false, error: expect.stringContaining('protected security copy') })
}
)
it.each([
['array leaf', { settings: { choices: ['one'] } }],
@@ -7,6 +7,7 @@ const PROTECTED_TRANSLATION_PREFIXES = [
'auto.components.settings.PluginInstallDialog',
'auto.components.settings.PluginRemoveDialog',
'auto.components.settings.PluginSettingsRow',
'auto.components.settings.PluginVmRecipeConsentPreview',
'auto.components.settings.PluginsSettingsSection'
]
@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest'
import {
listPluginVmRecipeCommands,
parsePluginVmRecipeArtifact
} from './plugin-vm-recipe-artifact'
describe('plugin VM recipe artifacts', () => {
it('parses bounded lifecycle commands for verbatim consent', () => {
const recipe = parsePluginVmRecipeArtifact(
JSON.stringify({
schemaVersion: 1,
id: 'cloud-sandbox',
name: 'Cloud Sandbox',
create: './scripts/create.sh',
suspend: './scripts/suspend.sh',
resume: './scripts/resume.sh',
destroy: './scripts/destroy.sh'
})
)
expect(recipe).toMatchObject({ id: 'cloud-sandbox', name: 'Cloud Sandbox' })
expect(listPluginVmRecipeCommands(recipe)).toEqual([
{ phase: 'create', command: './scripts/create.sh' },
{ phase: 'suspend', command: './scripts/suspend.sh' },
{ phase: 'resume', command: './scripts/resume.sh' },
{ phase: 'destroy', command: './scripts/destroy.sh' }
])
})
it('requires paired suspend/resume commands and rejects unknown fields', () => {
expect(() =>
parsePluginVmRecipeArtifact(
JSON.stringify({
schemaVersion: 1,
id: 'broken',
name: 'Broken',
create: 'create',
suspend: 'suspend'
})
)
).toThrow('suspend and resume')
expect(() =>
parsePluginVmRecipeArtifact(
JSON.stringify({
schemaVersion: 1,
id: 'broken',
name: 'Broken',
create: 'create',
environment: { SECRET: 'value' }
})
)
).toThrow()
})
it('represents an explicitly disabled destroy action without executing text', () => {
const recipe = parsePluginVmRecipeArtifact(
JSON.stringify({
schemaVersion: 1,
id: 'managed',
name: 'Managed',
create: 'create',
destroy: 'none'
})
)
expect(recipe).toMatchObject({ destroyDisabled: true })
expect(recipe.destroy).toBeUndefined()
})
it('rejects NUL bytes and commands beyond the bounded artifact contract', () => {
for (const create of ['bad\0command', 'x'.repeat(32 * 1024 + 1)]) {
expect(() =>
parsePluginVmRecipeArtifact(
JSON.stringify({ schemaVersion: 1, id: 'bounded', name: 'Bounded', create })
)
).toThrow()
}
})
})
@@ -0,0 +1,65 @@
import { z } from 'zod'
import { ORCA_VM_RECIPE_ID_PATTERN, ORCA_VM_RECIPE_ID_RULE } from '../orca-yaml'
import type { OrcaVmRecipe } from '../types'
const recipeCommandSchema = z
.string()
.trim()
.min(1)
.max(32 * 1024)
.refine((value) => !value.includes('\0'), 'must not contain NUL bytes')
const pluginVmRecipeArtifactSchema = z
.object({
schemaVersion: z.literal(1),
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(),
create: recipeCommandSchema,
suspend: recipeCommandSchema.optional(),
resume: recipeCommandSchema.optional(),
destroy: z.union([recipeCommandSchema, z.literal('none')]).optional()
})
.strict()
.superRefine((recipe, ctx) => {
if (Boolean(recipe.suspend) !== Boolean(recipe.resume)) {
ctx.addIssue({
code: 'custom',
path: recipe.suspend ? ['resume'] : ['suspend'],
message: 'suspend and resume must be declared together'
})
}
})
export type PluginVmRecipeCommand = {
phase: 'create' | 'suspend' | 'resume' | 'destroy'
command: string
}
export function parsePluginVmRecipeArtifact(raw: string): OrcaVmRecipe {
const parsed = pluginVmRecipeArtifactSchema.parse(JSON.parse(raw))
const destroyDisabled = parsed.destroy === 'none'
return {
id: parsed.id,
name: parsed.name,
create: parsed.create,
...(parsed.description ? { description: parsed.description } : {}),
...(parsed.suspend ? { suspend: parsed.suspend } : {}),
...(parsed.resume ? { resume: parsed.resume } : {}),
...(parsed.destroy && !destroyDisabled ? { destroy: parsed.destroy } : {}),
...(destroyDisabled ? { destroyDisabled: true } : {})
}
}
export function listPluginVmRecipeCommands(recipe: OrcaVmRecipe): PluginVmRecipeCommand[] {
return [
{ phase: 'create', command: recipe.create },
...(recipe.suspend ? [{ phase: 'suspend' as const, command: recipe.suspend }] : []),
...(recipe.resume ? [{ phase: 'resume' as const, command: recipe.resume }] : []),
...(recipe.destroy
? [{ phase: 'destroy' as const, command: recipe.destroy }]
: recipe.destroyDisabled
? [{ phase: 'destroy' as const, command: 'none' }]
: [])
]
}