mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 00:03:20 +00:00
feat(plugins): add VM recipe packs
This commit is contained in:
@@ -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