mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 08:03:14 +00:00
feat(plugins): add VM recipe packs
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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-')
|
||||
|
||||
@@ -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: '' }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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' }]
|
||||
: [])
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user