From ca38781c92011e5a413e816d3a08aaa0ba8df849 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 12 Jul 2026 23:14:56 -0700 Subject: [PATCH] feat(plugins): add VM recipe packs --- src/main/ephemeral-vm-runtime-service.test.ts | 4 + src/main/ephemeral-vm-runtime-service.ts | 1 + src/main/ipc/ephemeral-vm-recipe-context.ts | 47 ++++- src/main/ipc/ephemeral-vm-runtime-handlers.ts | 28 +-- src/main/ipc/ephemeral-vm.test.ts | 136 ++++++++++++++ src/main/ipc/ephemeral-vm.ts | 35 ++-- src/main/ipc/register-core-handlers.test.ts | 2 +- src/main/ipc/register-core-handlers.ts | 2 +- .../plugins/plugin-approved-vm-recipes.ts | 12 ++ .../plugins/plugin-artifact-validation.ts | 26 ++- .../plugin-content-pack-registry.test.ts | 58 ++++++ .../plugins/plugin-content-pack-registry.ts | 31 +++- .../plugins/plugin-content-safety.test.ts | 51 ++++++ src/main/plugins/plugin-discovery.test.ts | 54 ++++++ src/main/plugins/plugin-discovery.ts | 27 ++- src/main/plugins/plugin-install-staging.ts | 67 ++++--- src/main/plugins/plugin-install.test.ts | 36 +++- .../plugin-instructional-content-integrity.ts | 30 ++++ .../plugins/plugin-list-projection.test.ts | 50 +++++- src/main/plugins/plugin-list-projection.ts | 14 ++ .../plugins/plugin-vm-recipe-registry.test.ts | 169 ++++++++++++++++++ src/main/plugins/plugin-vm-recipe-registry.ts | 127 +++++++++++++ src/preload/api-types.ts | 9 + .../settings/EphemeralVmsPane.test.tsx | 75 ++++++++ .../components/settings/EphemeralVmsPane.tsx | 23 ++- .../settings/PluginConsentDialog.test.tsx | 44 ++++- .../settings/PluginConsentDialog.tsx | 107 +++++++---- .../settings/PluginVmRecipeConsentPreview.tsx | 83 +++++++++ ...poserState-host-context-boundaries.test.ts | 12 +- src/renderer/src/hooks/useComposerState.ts | 79 ++------ .../useEphemeralVmRecipeOptions.test.tsx | 148 +++++++++++++++ .../src/hooks/useEphemeralVmRecipeOptions.ts | 100 +++++++++++ src/renderer/src/i18n/locales/en.json | 21 ++- src/renderer/src/i18n/locales/es.json | 21 ++- src/renderer/src/i18n/locales/ja.json | 21 ++- src/renderer/src/i18n/locales/ko.json | 21 ++- src/renderer/src/i18n/locales/zh.json | 21 ++- src/shared/ephemeral-vm-runtimes.ts | 16 ++ .../plugin-consent-fingerprint.test.ts | 40 +++++ .../plugins/plugin-consent-fingerprint.ts | 37 +++- .../plugin-language-pack-artifact.test.ts | 27 +-- .../plugins/plugin-language-pack-artifact.ts | 1 + .../plugins/plugin-vm-recipe-artifact.test.ts | 79 ++++++++ .../plugins/plugin-vm-recipe-artifact.ts | 65 +++++++ 44 files changed, 1838 insertions(+), 219 deletions(-) create mode 100644 src/main/plugins/plugin-approved-vm-recipes.ts create mode 100644 src/main/plugins/plugin-instructional-content-integrity.ts create mode 100644 src/main/plugins/plugin-vm-recipe-registry.test.ts create mode 100644 src/main/plugins/plugin-vm-recipe-registry.ts create mode 100644 src/renderer/src/components/settings/PluginVmRecipeConsentPreview.tsx create mode 100644 src/renderer/src/hooks/useEphemeralVmRecipeOptions.test.tsx create mode 100644 src/renderer/src/hooks/useEphemeralVmRecipeOptions.ts create mode 100644 src/shared/plugins/plugin-vm-recipe-artifact.test.ts create mode 100644 src/shared/plugins/plugin-vm-recipe-artifact.ts diff --git a/src/main/ephemeral-vm-runtime-service.test.ts b/src/main/ephemeral-vm-runtime-service.test.ts index 6f31e7a755d..61fc991ad83 100644 --- a/src/main/ephemeral-vm-runtime-service.test.ts +++ b/src/main/ephemeral-vm-runtime-service.test.ts @@ -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', diff --git a/src/main/ephemeral-vm-runtime-service.ts b/src/main/ephemeral-vm-runtime-service.ts index 58ad107a346..90eb216c440 100644 --- a/src/main/ephemeral-vm-runtime-service.ts +++ b/src/main/ephemeral-vm-runtime-service.ts @@ -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 } : {}), diff --git a/src/main/ipc/ephemeral-vm-recipe-context.ts b/src/main/ipc/ephemeral-vm-recipe-context.ts index aba6c730755..c922d6f13b1 100644 --- a/src/main/ipc/ephemeral-vm-recipe-context.ts +++ b/src/main/ipc/ephemeral-vm-recipe-context.ts @@ -26,7 +26,11 @@ export type RecipeRepoResult = | { ok: true; repo: Exclude, 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, diff --git a/src/main/ipc/ephemeral-vm-runtime-handlers.ts b/src/main/ipc/ephemeral-vm-runtime-handlers.ts index f46dbb22e5d..02696a40483 100644 --- a/src/main/ipc/ephemeral-vm-runtime-handlers.ts +++ b/src/main/ipc/ephemeral-vm-runtime-handlers.ts @@ -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 + 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 => { const userDataPath = app.getPath('userData') const resolved = getRuntimeRecipeContext(store, userDataPath, args.runtimeId) const payload = buildEphemeralVmRecipeCleanupPayload({ diff --git a/src/main/ipc/ephemeral-vm.test.ts b/src/main/ipc/ephemeral-vm.test.ts index 071155271d3..ffe2fee195f 100644 --- a/src/main/ipc/ephemeral-vm.test.ts +++ b/src/main/ipc/ephemeral-vm.test.ts @@ -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 Promise | unknown>() const { @@ -86,6 +87,15 @@ function nodeCommand(scriptPath: string): string { return `"${process.execPath}" "${scriptPath}"` } +function pluginServiceWithRecipes( + recipes: { pluginKey: string; recipe: Record }[] +) { + 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-') diff --git a/src/main/ipc/ephemeral-vm.ts b/src/main/ipc/ephemeral-vm.ts index 9d071989c85..f75c9c77d39 100644 --- a/src/main/ipc/ephemeral-vm.ts +++ b/src/main/ipc/ephemeral-vm.ts @@ -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() @@ -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 => { + 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 => { 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: '' } diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 08af6977efa..52045c8afa5 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -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, diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 863e451785c..4b380b9e283 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -186,7 +186,7 @@ export function registerCoreHandlers( registerFilesystemWatcherHandlers() registerRuntimeHandlers(runtime) registerRuntimeEnvironmentHandlers(store) - registerEphemeralVmHandlers(store) + registerEphemeralVmHandlers(store, pluginService) registerAiVaultHandlers({ getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths, getActiveRuntimeAiVaultHostInfos: () => diff --git a/src/main/plugins/plugin-approved-vm-recipes.ts b/src/main/plugins/plugin-approved-vm-recipes.ts new file mode 100644 index 00000000000..ab193a5280f --- /dev/null +++ b/src/main/plugins/plugin-approved-vm-recipes.ts @@ -0,0 +1,12 @@ +import type { OrcaVmRecipe } from '../../shared/types' +import type { PluginService } from './plugin-service' + +export async function getApprovedPluginVmRecipes( + pluginService?: PluginService +): Promise { + if (!pluginService) { + return [] + } + await pluginService.whenReady() + return pluginService.contentPacks.vmRecipes.list().map(({ recipe }) => recipe) +} diff --git a/src/main/plugins/plugin-artifact-validation.ts b/src/main/plugins/plugin-artifact-validation.ts index 8c8f8595308..b2935c3c9b8 100644 --- a/src/main/plugins/plugin-artifact-validation.ts +++ b/src/main/plugins/plugin-artifact-validation.ts @@ -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() + 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 } } diff --git a/src/main/plugins/plugin-content-pack-registry.test.ts b/src/main/plugins/plugin-content-pack-registry.test.ts index b4ae53b65b5..5f6fe16fc65 100644 --- a/src/main/plugins/plugin-content-pack-registry.test.ts +++ b/src/main/plugins/plugin-content-pack-registry.test.ts @@ -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([]) + }) }) diff --git a/src/main/plugins/plugin-content-pack-registry.ts b/src/main/plugins/plugin-content-pack-registry.ts index efe357e245d..80dd1395d03 100644 --- a/src/main/plugins/plugin-content-pack-registry.ts +++ b/src/main/plugins/plugin-content-pack-registry.ts @@ -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() 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() 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) ) } } diff --git a/src/main/plugins/plugin-content-safety.test.ts b/src/main/plugins/plugin-content-safety.test.ts index eeeb7a6adba..356258399ea 100644 --- a/src/main/plugins/plugin-content-safety.test.ts +++ b/src/main/plugins/plugin-content-safety.test.ts @@ -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', () => { diff --git a/src/main/plugins/plugin-discovery.test.ts b/src/main/plugins/plugin-discovery.test.ts index 8aa2e456b83..685a622e90b 100644 --- a/src/main/plugins/plugin-discovery.test.ts +++ b/src/main/plugins/plugin-discovery.test.ts @@ -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) + }) +}) diff --git a/src/main/plugins/plugin-discovery.ts b/src/main/plugins/plugin-discovery.ts index 2e1ac624cb9..589fa40c612 100644 --- a/src/main/plugins/plugin-discovery.ts +++ b/src/main/plugins/plugin-discovery.ts @@ -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 { 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 } } diff --git a/src/main/plugins/plugin-install-staging.ts b/src/main/plugins/plugin-install-staging.ts index 558d6bd7c7d..8052ebe1e20 100644 --- a/src/main/plugins/plugin-install-staging.ts +++ b/src/main/plugins/plugin-install-staging.ts @@ -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 { - 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, diff --git a/src/main/plugins/plugin-install.test.ts b/src/main/plugins/plugin-install.test.ts index ac1c1fe4dc4..ab9591eba3d 100644 --- a/src/main/plugins/plugin-install.test.ts +++ b/src/main/plugins/plugin-install.test.ts @@ -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), + 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-') diff --git a/src/main/plugins/plugin-instructional-content-integrity.ts b/src/main/plugins/plugin-instructional-content-integrity.ts new file mode 100644 index 00000000000..fe2c9a3dc01 --- /dev/null +++ b/src/main/plugins/plugin-instructional-content-integrity.ts @@ -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 { + 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` + ) + } +} diff --git a/src/main/plugins/plugin-list-projection.test.ts b/src/main/plugins/plugin-list-projection.test.ts index 1c8a9bee63e..94cc0ad3ce9 100644 --- a/src/main/plugins/plugin-list-projection.test.ts +++ b/src/main/plugins/plugin-list-projection.test.ts @@ -24,6 +24,7 @@ function serviceWith( options: { activation?: ReturnType worker?: ReturnType + vmRecipes?: ReturnType } = {} ): 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' } + ] + } + ]) + }) }) diff --git a/src/main/plugins/plugin-list-projection.ts b/src/main/plugins/plugin-list-projection.ts index bad8a680615..7a6b6508b5e 100644 --- a/src/main/plugins/plugin-list-projection.ts +++ b/src/main/plugins/plugin-list-projection.ts @@ -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 ? { diff --git a/src/main/plugins/plugin-vm-recipe-registry.test.ts b/src/main/plugins/plugin-vm-recipe-registry.test.ts new file mode 100644 index 00000000000..fd931d79f02 --- /dev/null +++ b/src/main/plugins/plugin-vm-recipe-registry.test.ts @@ -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 { + 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') + }) +}) diff --git a/src/main/plugins/plugin-vm-recipe-registry.ts b/src/main/plugins/plugin-vm-recipe-registry.ts new file mode 100644 index 00000000000..8f5db069b8f --- /dev/null +++ b/src/main/plugins/plugin-vm-recipe-registry.ts @@ -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() + private readonly errors = new Map() + + 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 { + 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 => { + try { + const approved = isApproved(plugin) + const registrations: PluginVmRecipeRegistration[] = [] + const seen = new Set() + 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 => + 'approved' in result && result.approved + ) + const owners = new Map>() + for (const result of approved) { + for (const registration of result.registrations) { + const recipeOwners = owners.get(registration.recipe.id) ?? new Set() + recipeOwners.add(result.pluginKey) + owners.set(registration.recipe.id, recipeOwners) + } + } + const conflicted = new Set() + 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) + } +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 6b64a280aba..80ad5b25e02 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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' diff --git a/src/renderer/src/components/settings/EphemeralVmsPane.test.tsx b/src/renderer/src/components/settings/EphemeralVmsPane.test.tsx index 9d38a052bb1..a72b2f4550b 100644 --- a/src/renderer/src/components/settings/EphemeralVmsPane.test.tsx +++ b/src/renderer/src/components/settings/EphemeralVmsPane.test.tsx @@ -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 + 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 + 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') + }) }) diff --git a/src/renderer/src/components/settings/EphemeralVmsPane.tsx b/src/renderer/src/components/settings/EphemeralVmsPane.tsx index 85b27f12ed7..22d7f031d09 100644 --- a/src/renderer/src/components/settings/EphemeralVmsPane.tsx +++ b/src/renderer/src/components/settings/EphemeralVmsPane.tsx @@ -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 => { + 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 {

{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.' )}

diff --git a/src/renderer/src/components/settings/PluginConsentDialog.test.tsx b/src/renderer/src/components/settings/PluginConsentDialog.test.tsx index 4155ea25f8c..83fae19bc9b 100644 --- a/src/renderer/src/components/settings/PluginConsentDialog.test.tsx +++ b/src/renderer/src/components/settings/PluginConsentDialog.test.tsx @@ -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' ) diff --git a/src/renderer/src/components/settings/PluginConsentDialog.tsx b/src/renderer/src/components/settings/PluginConsentDialog.tsx index 6ed8cf6d3bd..1a8b7f70ccd 100644 --- a/src/renderer/src/components/settings/PluginConsentDialog.tsx +++ b/src/renderer/src/components/settings/PluginConsentDialog.tsx @@ -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 ? ( <> - - {translate( - 'auto.components.settings.PluginConsentDialog.title', - 'Review permissions' - )} - + {consentTitle(plugin)} {translate( 'auto.components.settings.PluginConsentDialog.subtitle', @@ -141,7 +164,7 @@ export function PluginConsentDialog({

{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.' )}

) : null} @@ -174,27 +197,29 @@ export function PluginConsentDialog({
{trustTier(plugin)}
-
-

- {translate( - 'auto.components.settings.PluginConsentDialog.capabilities', - 'This plugin can' - )} -

+ {plugin.capabilities.length > 0 ? (
- {plugin.capabilities.map((capability) => ( -
- - - {capabilityDescription(capability.kind, capability.description)}{' '} - - ({capability.kind}) +

+ {translate( + 'auto.components.settings.PluginConsentDialog.capabilities', + 'This plugin can' + )} +

+
+ {plugin.capabilities.map((capability) => ( +
+ + + {capabilityDescription(capability.kind, capability.description)}{' '} + + ({capability.kind}) + - -
- ))} +
+ ))} +
-
+ ) : null}
@@ -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." + )}
{plugin.hasSkills ? ( @@ -220,6 +250,7 @@ export function PluginConsentDialog({ ) : null} + {error ?

{error}

: null}