mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
refactor(plugins): name shared catalog and manifest schema parts (#30612)
* plugins: name the shared catalog and manifest schema parts Pure refactor. Marketplace entry/header fields and the duplicate-category and duplicate-id checks become named pieces, the bounded catalog JSON read is split from schema parsing, and the v1 manifest object is named apart from its refinement, so a second catalog file and manifest version can reuse them. * plugins: share the plugin tree-identity check Pure refactor. Install integrity and instructional-content checks each inlined the full-hash-or-legacy-prefix comparison; both now call pluginTreeHashMatches. The tree limits are exported for reuse.
This commit is contained in:
@@ -12,8 +12,8 @@ import { pluginPathSegmentError } from '../../shared/plugins/plugin-path-safety'
|
||||
* are refused outright (installed trees must be self-contained).
|
||||
*/
|
||||
|
||||
const MAX_PLUGIN_FILES = 2_000
|
||||
const MAX_PLUGIN_TOTAL_BYTES = 50 * 1024 * 1024
|
||||
export const MAX_PLUGIN_FILES = 2_000
|
||||
export const MAX_PLUGIN_TOTAL_BYTES = 50 * 1024 * 1024
|
||||
|
||||
type PluginFile = { path: string; size: number }
|
||||
|
||||
|
||||
@@ -7,6 +7,12 @@ export type HashAddressedPluginContent = {
|
||||
|
||||
export type PluginContentIntegrityResult = { ok: true } | { ok: false; error: string }
|
||||
|
||||
/** Whether a computed tree hash is the recorded identity. Early P0 installs used a
|
||||
* 128-bit SHA-256 prefix as the directory name; new installs use the full digest. */
|
||||
export function pluginTreeHashMatches(actual: string, expected: string): boolean {
|
||||
return actual === expected || (expected.length === 32 && actual.startsWith(expected))
|
||||
}
|
||||
|
||||
/** Dev trees are intentionally mutable; installed hash-addressed trees are not. */
|
||||
export async function verifyHashAddressedPluginContent(
|
||||
plugin: HashAddressedPluginContent
|
||||
@@ -18,12 +24,7 @@ export async function verifyHashAddressedPluginContent(
|
||||
if (!actual.ok) {
|
||||
return { ok: false, error: actual.error }
|
||||
}
|
||||
const matchesCurrentHash = actual.hash === plugin.contentHash
|
||||
// Early P0 installs used a 128-bit SHA-256 prefix as the directory name.
|
||||
// Honor that existing address while all new installs use the full digest.
|
||||
const matchesLegacyPrefix =
|
||||
plugin.contentHash.length === 32 && actual.hash.startsWith(plugin.contentHash)
|
||||
if (!matchesCurrentHash && !matchesLegacyPrefix) {
|
||||
if (!pluginTreeHashMatches(actual.hash, plugin.contentHash)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `content hash mismatch (expected ${plugin.contentHash}, got ${actual.hash})`
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { hasInstructionalPluginContributions } from '../../shared/plugins/plugin-consent-fingerprint'
|
||||
import { hashPluginTree } from './plugin-content-hash'
|
||||
import { pluginTreeHashMatches } from './plugin-content-integrity'
|
||||
import type { ValidDiscoveredPlugin } from './plugin-discovery'
|
||||
|
||||
/** Instructional bytes execute later, so every read must still match the tree
|
||||
@@ -19,10 +20,7 @@ export async function verifyInstructionalPluginContent(
|
||||
`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) {
|
||||
if (!pluginTreeHashMatches(actual.hash, plugin.consentContentHash)) {
|
||||
throw new Error(
|
||||
`plugin ${plugin.pluginKey} instructional content changed since it was reviewed`
|
||||
)
|
||||
|
||||
@@ -40,24 +40,32 @@ export async function fetchPluginMarketplace(
|
||||
export async function readPluginMarketplaceIndex(
|
||||
rootDirectory: string
|
||||
): Promise<PluginMarketplace> {
|
||||
const path = join(rootDirectory, PLUGIN_MARKETPLACE_FILENAME)
|
||||
const raw = await readMarketplaceJson(rootDirectory, PLUGIN_MARKETPLACE_FILENAME)
|
||||
return parseMarketplaceFile(PLUGIN_MARKETPLACE_FILENAME, () => pluginMarketplaceSchema.parse(raw))
|
||||
}
|
||||
|
||||
async function readMarketplaceJson(rootDirectory: string, filename: string): Promise<unknown> {
|
||||
const chunks: Buffer[] = []
|
||||
let totalBytes = 0
|
||||
for await (const chunk of createReadStream(path)) {
|
||||
for await (const chunk of createReadStream(join(rootDirectory, filename))) {
|
||||
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
||||
totalBytes += bytes.byteLength
|
||||
if (totalBytes > MARKETPLACE_INDEX_MAX_BYTES) {
|
||||
throw new Error(`${PLUGIN_MARKETPLACE_FILENAME} exceeds its size limit`)
|
||||
throw new Error(`${filename} exceeds its size limit`)
|
||||
}
|
||||
chunks.push(bytes)
|
||||
}
|
||||
return parseMarketplaceFile(filename, () =>
|
||||
JSON.parse(Buffer.concat(chunks, totalBytes).toString('utf8'))
|
||||
)
|
||||
}
|
||||
|
||||
function parseMarketplaceFile<T>(filename: string, parse: () => T): T {
|
||||
try {
|
||||
return pluginMarketplaceSchema.parse(
|
||||
JSON.parse(Buffer.concat(chunks, totalBytes).toString('utf8'))
|
||||
)
|
||||
return parse()
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`invalid ${PLUGIN_MARKETPLACE_FILENAME}: ${error instanceof Error ? error.message : String(error)}`
|
||||
`invalid ${filename}: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,62 +73,61 @@ const eventContributionSchema = z.object({
|
||||
on: z.enum(PLUGIN_EVENT_NAMES)
|
||||
})
|
||||
|
||||
export const pluginManifestSchema = z
|
||||
.object({
|
||||
manifestVersion: z.literal(1),
|
||||
id: pluginIdSchema,
|
||||
/** Publisher slug; canonical identity is `<publisher>.<id>` — bare-id
|
||||
* global uniqueness is unverifiable without a registry. */
|
||||
publisher: pluginIdSchema,
|
||||
name: z.string().min(1).max(256),
|
||||
version: z.string().regex(SEMVER_RE, 'must be semver'),
|
||||
description: z.string().max(4096).optional(),
|
||||
author: z
|
||||
.object({ name: z.string().min(1).max(256), url: z.string().max(2048).optional() })
|
||||
.optional(),
|
||||
repository: z.string().max(2048).optional(),
|
||||
icon: pluginRelativePathSchema.optional(),
|
||||
/** Minimum host version gate; the host refuses to load below it. */
|
||||
engines: z.object({ orca: orcaEngineRangeSchema }),
|
||||
/** Host-API major version this plugin targets. */
|
||||
pluginApi: z.literal(1),
|
||||
/** Node entry executed inside the out-of-process plugin worker. */
|
||||
main: pluginRelativePathSchema.optional(),
|
||||
contributes: z
|
||||
.object({
|
||||
panels: z.array(panelContributionSchema).max(PLUGIN_PANEL_LIMIT).default([]),
|
||||
commands: z.array(commandContributionSchema).max(PLUGIN_COMMAND_LIMIT).default([]),
|
||||
events: z.array(eventContributionSchema).max(PLUGIN_EVENT_SUBSCRIPTION_LIMIT).default([]),
|
||||
languagePacks: z
|
||||
.array(pluginLanguagePackContributionSchema)
|
||||
.max(PLUGIN_LANGUAGE_PACK_LIMIT)
|
||||
.default([]),
|
||||
keybindings: z
|
||||
.array(pluginKeybindingContributionSchema)
|
||||
.max(PLUGIN_KEYBINDING_LIMIT)
|
||||
.default([]),
|
||||
vmRecipes: z
|
||||
.array(pluginVmRecipeContributionSchema)
|
||||
.max(PLUGIN_VM_RECIPE_LIMIT)
|
||||
.default([]),
|
||||
agents: z
|
||||
.array(pluginAgentProfileContributionSchema)
|
||||
.max(PLUGIN_AGENT_PROFILE_LIMIT)
|
||||
.default([])
|
||||
})
|
||||
.strict()
|
||||
.default(() => ({
|
||||
panels: [],
|
||||
commands: [],
|
||||
events: [],
|
||||
languagePacks: [],
|
||||
keybindings: [],
|
||||
vmRecipes: [],
|
||||
agents: []
|
||||
})),
|
||||
capabilities: z.array(pluginCapabilitySchema).max(32).default([])
|
||||
})
|
||||
.superRefine(validatePluginManifestContributions)
|
||||
const pluginManifestV1Object = z.object({
|
||||
manifestVersion: z.literal(1),
|
||||
id: pluginIdSchema,
|
||||
/** Publisher slug; canonical identity is `<publisher>.<id>` — bare-id
|
||||
* global uniqueness is unverifiable without a registry. */
|
||||
publisher: pluginIdSchema,
|
||||
name: z.string().min(1).max(256),
|
||||
version: z.string().regex(SEMVER_RE, 'must be semver'),
|
||||
description: z.string().max(4096).optional(),
|
||||
author: z
|
||||
.object({ name: z.string().min(1).max(256), url: z.string().max(2048).optional() })
|
||||
.optional(),
|
||||
repository: z.string().max(2048).optional(),
|
||||
icon: pluginRelativePathSchema.optional(),
|
||||
/** Minimum host version gate; the host refuses to load below it. */
|
||||
engines: z.object({ orca: orcaEngineRangeSchema }),
|
||||
/** Host-API major version this plugin targets. */
|
||||
pluginApi: z.literal(1),
|
||||
/** Node entry executed inside the out-of-process plugin worker. */
|
||||
main: pluginRelativePathSchema.optional(),
|
||||
contributes: z
|
||||
.object({
|
||||
panels: z.array(panelContributionSchema).max(PLUGIN_PANEL_LIMIT).default([]),
|
||||
commands: z.array(commandContributionSchema).max(PLUGIN_COMMAND_LIMIT).default([]),
|
||||
events: z.array(eventContributionSchema).max(PLUGIN_EVENT_SUBSCRIPTION_LIMIT).default([]),
|
||||
languagePacks: z
|
||||
.array(pluginLanguagePackContributionSchema)
|
||||
.max(PLUGIN_LANGUAGE_PACK_LIMIT)
|
||||
.default([]),
|
||||
keybindings: z
|
||||
.array(pluginKeybindingContributionSchema)
|
||||
.max(PLUGIN_KEYBINDING_LIMIT)
|
||||
.default([]),
|
||||
vmRecipes: z.array(pluginVmRecipeContributionSchema).max(PLUGIN_VM_RECIPE_LIMIT).default([]),
|
||||
agents: z
|
||||
.array(pluginAgentProfileContributionSchema)
|
||||
.max(PLUGIN_AGENT_PROFILE_LIMIT)
|
||||
.default([])
|
||||
})
|
||||
.strict()
|
||||
.default(() => ({
|
||||
panels: [],
|
||||
commands: [],
|
||||
events: [],
|
||||
languagePacks: [],
|
||||
keybindings: [],
|
||||
vmRecipes: [],
|
||||
agents: []
|
||||
})),
|
||||
capabilities: z.array(pluginCapabilitySchema).max(32).default([])
|
||||
})
|
||||
|
||||
export const pluginManifestSchema = pluginManifestV1Object.superRefine(
|
||||
validatePluginManifestContributions
|
||||
)
|
||||
|
||||
export type PluginManifest = z.infer<typeof pluginManifestSchema>
|
||||
export type PluginPanelContribution = z.infer<typeof panelContributionSchema>
|
||||
|
||||
@@ -53,50 +53,63 @@ export const pluginMarketplaceGitSourceSchema = z.strictObject({
|
||||
ref: z.string().trim().min(1).max(4_096)
|
||||
})
|
||||
|
||||
export const pluginMarketplaceEntrySchema = z
|
||||
.strictObject({
|
||||
/** Canonical `<publisher>.<id>` identity expected in the source manifest. */
|
||||
id: z.string().refine(isQualifiedPluginKey, 'invalid qualified plugin key'),
|
||||
source: pluginMarketplaceGitSourceSchema,
|
||||
description: z.string().min(1).max(4_096).optional(),
|
||||
categories: z
|
||||
.array(marketplaceCategorySchema)
|
||||
.max(PLUGIN_MARKETPLACE_CATEGORY_LIMIT)
|
||||
.default([])
|
||||
})
|
||||
.superRefine((entry, context) => {
|
||||
const seen = new Set<string>()
|
||||
for (const [index, category] of entry.categories.entries()) {
|
||||
if (seen.has(category)) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['categories', index],
|
||||
message: `duplicate category: ${category}`
|
||||
})
|
||||
}
|
||||
seen.add(category)
|
||||
const marketplaceEntryFields = {
|
||||
/** Canonical `<publisher>.<id>` identity expected in the source manifest. */
|
||||
id: z.string().refine(isQualifiedPluginKey, 'invalid qualified plugin key'),
|
||||
source: pluginMarketplaceGitSourceSchema,
|
||||
description: z.string().min(1).max(4_096).optional(),
|
||||
categories: z.array(marketplaceCategorySchema).max(PLUGIN_MARKETPLACE_CATEGORY_LIMIT).default([])
|
||||
}
|
||||
|
||||
function rejectDuplicateCategories(
|
||||
entry: { categories: string[] },
|
||||
context: z.RefinementCtx
|
||||
): void {
|
||||
const seen = new Set<string>()
|
||||
for (const [index, category] of entry.categories.entries()) {
|
||||
if (seen.has(category)) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['categories', index],
|
||||
message: `duplicate category: ${category}`
|
||||
})
|
||||
}
|
||||
})
|
||||
seen.add(category)
|
||||
}
|
||||
}
|
||||
|
||||
function rejectDuplicatePluginIds(
|
||||
marketplace: { plugins: { id: string }[] },
|
||||
context: z.RefinementCtx
|
||||
): void {
|
||||
const seen = new Set<string>()
|
||||
for (const [index, plugin] of marketplace.plugins.entries()) {
|
||||
if (seen.has(plugin.id)) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['plugins', index, 'id'],
|
||||
message: `duplicate plugin id: ${plugin.id}`
|
||||
})
|
||||
}
|
||||
seen.add(plugin.id)
|
||||
}
|
||||
}
|
||||
|
||||
export const pluginMarketplaceEntrySchema = z
|
||||
.strictObject(marketplaceEntryFields)
|
||||
.superRefine(rejectDuplicateCategories)
|
||||
|
||||
const marketplaceHeaderFields = {
|
||||
name: z.string().min(1).max(256),
|
||||
owner: marketplaceOwnerSchema
|
||||
}
|
||||
|
||||
export const pluginMarketplaceSchema = z
|
||||
.strictObject({
|
||||
name: z.string().min(1).max(256),
|
||||
owner: marketplaceOwnerSchema,
|
||||
...marketplaceHeaderFields,
|
||||
plugins: z.array(pluginMarketplaceEntrySchema).max(PLUGIN_MARKETPLACE_ENTRY_LIMIT)
|
||||
})
|
||||
.superRefine((marketplace, context) => {
|
||||
const seen = new Set<string>()
|
||||
for (const [index, plugin] of marketplace.plugins.entries()) {
|
||||
if (seen.has(plugin.id)) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['plugins', index, 'id'],
|
||||
message: `duplicate plugin id: ${plugin.id}`
|
||||
})
|
||||
}
|
||||
seen.add(plugin.id)
|
||||
}
|
||||
})
|
||||
.superRefine(rejectDuplicatePluginIds)
|
||||
|
||||
/** Host-derived trust metadata. Marketplace JSON cannot self-award either bit. */
|
||||
export const pluginMarketplaceTrustMetadataSchema = z
|
||||
|
||||
Reference in New Issue
Block a user