mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(plugins): load installed plugins whose engines floor predates their fields (#35756)
* fix(plugins): load installed plugins whose engines floor predates their fields The engines.orca floor rule is an authoring check. At runtime (install, discovery, update, rollback) a manifest now loads when this host parses every field and meets engines.orca; dev folders, validate:plugin and catalog CI stay strict. Installed copies that can't load show user-facing copy and offer Reinstall when the catalog has a loadable release with different bytes. * fix(plugins): give installed copies user-facing copy for every load failure
This commit is contained in:
@@ -189,7 +189,7 @@ One screenshot of the plugin in use.
|
||||
## Versions and compatibility
|
||||
|
||||
- **What runs is strict.** Orca refuses a manifest with any key it does not know, at every level, so an older Orca never silently ignores something a plugin relies on.
|
||||
- **`engines.orca` is the only version gate.** It must be at least the newest `Since` of every field and every value (capability, event, built-in action) the manifest uses. Orca checks this when it validates, lists or installs a plugin, so an older Orca can always say "requires Orca >=X (this is Y)" instead of listing unknown fields.
|
||||
- **`engines.orca` is the only version gate.** It must be at least the newest `Since` of every field and every value (capability, event, built-in action) the manifest uses. `pnpm validate:plugin` and catalog CI enforce it, so an older Orca can always say "requires Orca >=X (this is Y)" instead of listing unknown fields.
|
||||
- **`pluginApi` is optional.** It names the major version of the host API ([reference](plugin-host-api.md)), which only grows within major 1; Orca refuses a major it does not implement. Only `sandboxed-ui` and `trusted-worker` plugins call that API, so `data` and `declarative` plugins must leave it out.
|
||||
- **`terminal:send` needs `trusted-worker`.** Panels get no host method that types into a terminal, so Orca refuses the capability on any other profile instead of asking the user to grant something the plugin can never use.
|
||||
- **New fields, contribution points and values are additive.** They arrive with a `Since` version; nothing else changes. `manifestVersion` rises only if an existing field changes meaning.
|
||||
|
||||
@@ -3,7 +3,7 @@ import { cp, mkdir, rm } from 'node:fs/promises'
|
||||
import { join, relative, resolve, sep } from 'node:path'
|
||||
import {
|
||||
PLUGIN_MANIFEST_FILENAME,
|
||||
parsePluginManifest,
|
||||
parseHostPluginManifest,
|
||||
qualifiedPluginKey,
|
||||
satisfiesOrcaEngineRange,
|
||||
type PluginManifest
|
||||
@@ -71,7 +71,7 @@ async function readInstallManifest(
|
||||
error: `unreadable ${PLUGIN_MANIFEST_FILENAME}: ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
}
|
||||
const parsed = parsePluginManifest(raw)
|
||||
const parsed = parseHostPluginManifest(raw)
|
||||
if (!parsed.ok) {
|
||||
const incompatible = pluginManifestCompatibilityError(raw, hostVersion)
|
||||
return { ok: false, error: incompatible ?? `invalid manifest: ${parsed.error}` }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { readdir } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { parsePluginManifest } from '../../../shared/plugins/manifest/plugin-manifest'
|
||||
import { parseHostPluginManifest } from '../../../shared/plugins/manifest/plugin-manifest'
|
||||
import { readPluginCurrentPointer } from './plugin-current-pointer'
|
||||
import { readPluginManifestText } from '../plugin-manifest-file'
|
||||
import { PLUGIN_CONTENT_HASH_PATTERN } from '../../../shared/plugins/installation/plugin-install-lockfile'
|
||||
@@ -43,7 +43,7 @@ export async function readPluginRollbackVersion(pluginDir: string): Promise<stri
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed = parsePluginManifest(
|
||||
const parsed = parseHostPluginManifest(
|
||||
JSON.parse(await readPluginManifestText(join(pluginDir, candidate.contentHash)))
|
||||
)
|
||||
return parsed.ok ? parsed.manifest.version : null
|
||||
|
||||
@@ -161,6 +161,60 @@ describe('installed plugin discovery identity', () => {
|
||||
)
|
||||
})
|
||||
|
||||
// Regression: Excalidraw 1.0.0 as installed from the catalog before the format 2 floor moved.
|
||||
it('loads an installed manifest whose engines floor predates its fields, but not a dev copy', async () => {
|
||||
const pluginsDir = await tempPluginsDir()
|
||||
const devDir = await tempPluginsDir()
|
||||
const hash = 'c'.repeat(64)
|
||||
const versionDir = join(pluginsDir, 'stablyai.orca-excalidraw', hash)
|
||||
const manifest = JSON.stringify({
|
||||
manifestVersion: 2,
|
||||
id: 'orca-excalidraw',
|
||||
publisher: 'stablyai',
|
||||
name: 'Excalidraw',
|
||||
version: '1.0.0',
|
||||
execution: 'sandboxed-ui',
|
||||
engines: { orca: '>=1.4.244' },
|
||||
contributes: {
|
||||
customEditors: [
|
||||
{
|
||||
id: 'excalidraw',
|
||||
title: 'Excalidraw',
|
||||
extensions: ['.excalidraw', '.excalidraw.json'],
|
||||
entry: 'editor/index.html',
|
||||
priority: 'default'
|
||||
}
|
||||
]
|
||||
},
|
||||
capabilities: []
|
||||
})
|
||||
for (const dir of [versionDir, devDir]) {
|
||||
await mkdir(join(dir, 'editor'), { recursive: true })
|
||||
await writeFile(join(dir, 'orca-plugin.json'), manifest)
|
||||
await writeFile(join(dir, 'editor', 'index.html'), '<p>editor</p>')
|
||||
}
|
||||
await writeFile(join(pluginsDir, 'stablyai.orca-excalidraw', 'current'), hash)
|
||||
|
||||
const [installed] = await discoverPlugins({
|
||||
pluginsDir,
|
||||
devPluginPaths: [],
|
||||
hostVersion: '1.4.245'
|
||||
})
|
||||
expect(installed && 'error' in installed ? installed.error : null).toBeNull()
|
||||
expect(installed?.pluginKey).toBe('stablyai.orca-excalidraw')
|
||||
|
||||
const [dev] = await discoverPlugins({
|
||||
pluginsDir: await tempPluginsDir(),
|
||||
devPluginPaths: [devDir],
|
||||
hostVersion: '1.4.245'
|
||||
})
|
||||
expect(dev && 'error' in dev ? dev.error : '').toContain('engines.orca: must be at least')
|
||||
|
||||
// An older host still names the version gap rather than a schema error.
|
||||
const [old] = await discoverPlugins({ pluginsDir, devPluginPaths: [], hostVersion: '1.4.200' })
|
||||
expect(old && 'error' in old ? old.error : '').toBe('requires Orca >=1.4.244 (this is 1.4.200)')
|
||||
})
|
||||
|
||||
it('keeps a removable qualified identity when the current pointer is missing', async () => {
|
||||
const pluginsDir = await tempPluginsDir()
|
||||
const installedKey = 'samples.broken'
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PLUGIN_CONTENT_HASH_PATTERN } from '../../shared/plugins/installation/p
|
||||
import {
|
||||
PLUGIN_MANIFEST_FILENAME,
|
||||
isQualifiedPluginKey,
|
||||
parseHostPluginManifest,
|
||||
parsePluginManifest,
|
||||
qualifiedPluginKey,
|
||||
satisfiesOrcaEngineRange,
|
||||
@@ -92,7 +93,8 @@ async function readManifestDir(
|
||||
isDev
|
||||
}
|
||||
}
|
||||
const parsed = parsePluginManifest(raw)
|
||||
// Why strict for dev trees: they are the author's working copy, so authoring errors belong there.
|
||||
const parsed = isDev ? parsePluginManifest(raw) : parseHostPluginManifest(raw)
|
||||
if (!parsed.ok) {
|
||||
const incompatible = pluginManifestCompatibilityError(raw, hostVersion)
|
||||
return { rootDir, error: incompatible ?? `invalid manifest: ${parsed.error}`, isDev }
|
||||
|
||||
@@ -23,6 +23,40 @@ import { pluginExecutionProfile } from '../../shared/plugins/contributions/plugi
|
||||
|
||||
const PLUGIN_LIST_PROJECTION_CONCURRENCY = 4
|
||||
|
||||
type PluginLockEntry = PluginLockfile['plugins'][string]
|
||||
|
||||
function lockEntrySource(
|
||||
lockEntry: PluginLockEntry | undefined
|
||||
): Pick<PluginHostListEntry, 'source'> {
|
||||
if (!lockEntry) {
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
source: {
|
||||
kind: lockEntry.source.kind,
|
||||
reference:
|
||||
lockEntry.source.kind === 'local-path'
|
||||
? lockEntry.source.path
|
||||
: lockEntry.source.kind === 'git'
|
||||
? lockEntry.source.url
|
||||
: lockEntry.source.kind === 'marketplace'
|
||||
? pluginSourceLabel(lockEntry.source.plugin)
|
||||
: `bundled:${lockEntry.source.bundleId}`,
|
||||
resolvedCommit: lockEntry.resolvedCommit,
|
||||
contentHash: lockEntry.contentHash,
|
||||
...(lockEntry.source.kind === 'marketplace'
|
||||
? {
|
||||
marketplace: {
|
||||
reference: lockEntry.source.marketplace.url,
|
||||
ref: lockEntry.source.marketplace.ref,
|
||||
resolvedCommit: lockEntry.source.marketplace.resolvedCommit
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const withImages = (images: PluginHostImages | undefined) => (images ? { images } : {})
|
||||
|
||||
export async function buildPluginList(
|
||||
@@ -41,11 +75,14 @@ export async function buildPluginList(
|
||||
if (isInvalidDiscoveredPlugin(plugin)) {
|
||||
// Invalid dev paths may expose private absolute paths; use opaque identities for clients.
|
||||
const fallbackKey = plugin.pluginKey ?? `invalid-development-plugin-${index + 1}`
|
||||
// Why the lock entry: it says which catalog installed it, so the list can offer an update.
|
||||
const lockEntry =
|
||||
!plugin.isDev && plugin.pluginKey ? lock.plugins[plugin.pluginKey] : undefined
|
||||
return {
|
||||
pluginKey: fallbackKey,
|
||||
consentFingerprint: null,
|
||||
name: fallbackKey,
|
||||
version: '0.0.0',
|
||||
version: lockEntry?.version ?? '0.0.0',
|
||||
publisher: '',
|
||||
status: 'invalid' as const,
|
||||
needsReconsent: false,
|
||||
@@ -60,7 +97,8 @@ export async function buildPluginList(
|
||||
commands: [],
|
||||
hasWorker: false,
|
||||
vmRecipes: [],
|
||||
restarts: 0
|
||||
restarts: 0,
|
||||
...lockEntrySource(lockEntry)
|
||||
}
|
||||
}
|
||||
const activation = service.activationState(plugin)
|
||||
@@ -170,32 +208,7 @@ export async function buildPluginList(
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
...(lockEntry
|
||||
? {
|
||||
source: {
|
||||
kind: lockEntry.source.kind,
|
||||
reference:
|
||||
lockEntry.source.kind === 'local-path'
|
||||
? lockEntry.source.path
|
||||
: lockEntry.source.kind === 'git'
|
||||
? lockEntry.source.url
|
||||
: lockEntry.source.kind === 'marketplace'
|
||||
? pluginSourceLabel(lockEntry.source.plugin)
|
||||
: `bundled:${lockEntry.source.bundleId}`,
|
||||
resolvedCommit: lockEntry.resolvedCommit,
|
||||
contentHash: lockEntry.contentHash,
|
||||
...(lockEntry.source.kind === 'marketplace'
|
||||
? {
|
||||
marketplace: {
|
||||
reference: lockEntry.source.marketplace.url,
|
||||
ref: lockEntry.source.marketplace.ref,
|
||||
resolvedCommit: lockEntry.source.marketplace.resolvedCommit
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
: {})
|
||||
...lockEntrySource(lockEntry)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -61,7 +61,8 @@ export function installedPluginControls(
|
||||
plugin,
|
||||
entry.updateVersion,
|
||||
busy,
|
||||
entry.autoUpdate
|
||||
entry.autoUpdate,
|
||||
entry.reinstallVersion ?? null
|
||||
)
|
||||
const needsReview = pluginNeedsReview(plugin)
|
||||
|
||||
@@ -76,7 +77,7 @@ export function installedPluginControls(
|
||||
)
|
||||
} else if (kind === 'invalid' && plugin.error) {
|
||||
tone = 'destructive'
|
||||
description = invalidPluginErrorMessage(plugin.error)
|
||||
description = invalidPluginErrorMessage(plugin.error, !plugin.isDev)
|
||||
} else if (kind === 'error' && plugin.execution === 'data') {
|
||||
// Why: content-only plugins have no code or logs; only their files can fail.
|
||||
tone = 'destructive'
|
||||
@@ -147,6 +148,13 @@ export function installedPluginControls(
|
||||
})}
|
||||
</Button>
|
||||
)
|
||||
} else if (primary === 'reinstall') {
|
||||
action = (
|
||||
<Button variant="outline" size="xs" onClick={() => handlers.onCheckUpdate(entry)}>
|
||||
<RotateCcw />
|
||||
{translate('auto.components.settings.PluginManagerRows.reinstall', 'Reinstall')}
|
||||
</Button>
|
||||
)
|
||||
} else if (primary === 'error') {
|
||||
action = (
|
||||
<Button variant="outline" size="xs" onClick={() => handlers.onOpen(plugin.pluginKey)}>
|
||||
|
||||
@@ -211,6 +211,35 @@ describe('plugin list entries', () => {
|
||||
expect(build('2.1.0')?.updateVersion).toBe('2.0.0')
|
||||
})
|
||||
|
||||
it('offers a reinstall for an installed copy that cannot load only when the catalog has other bytes', () => {
|
||||
const plugin: PluginHostListEntry = {
|
||||
...fromCatalog(installed('acme.notes', 'Notes'), 'https://official.invalid/c.git'),
|
||||
status: 'invalid'
|
||||
}
|
||||
const sources = [catalog('official', 'https://official.invalid/c.git')]
|
||||
const release = (contentHash: string, minimumOrca = '1.0.0') =>
|
||||
listing('acme.notes', {
|
||||
version: '1.0.0',
|
||||
release: {
|
||||
version: '1.0.0',
|
||||
commit: 'd'.repeat(40),
|
||||
contentHash,
|
||||
execution: 'data',
|
||||
minimumOrca,
|
||||
types: []
|
||||
}
|
||||
})
|
||||
const build = (offered: PluginMarketplaceHostListing, target = plugin) =>
|
||||
buildPluginListEntries([target], [offered], { sources, orcaVersion: '1.5.0' }).installed[0]
|
||||
expect(build(release('e'.repeat(64)))?.reinstallVersion).toBe('1.0.0')
|
||||
// Same bytes would fail the same way; a release this Orca can't run is no fix either.
|
||||
expect(build(release('b'.repeat(64)))?.reinstallVersion).toBeNull()
|
||||
expect(build(release('e'.repeat(64), '2.0.0'))?.reinstallVersion).toBeNull()
|
||||
expect(
|
||||
build(release('e'.repeat(64)), { ...plugin, status: 'running' })?.reinstallVersion
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('lists installed plugins once, joined to their listing, and the rest as available', () => {
|
||||
const result = buildPluginListEntries(
|
||||
[installed('stablyai.orca-midnight-theme', 'Orca Midnight')],
|
||||
|
||||
@@ -28,6 +28,8 @@ export type PluginListEntry = {
|
||||
minimumOrca: string | null
|
||||
/** A newer catalog version than the installed one that this Orca can run, if any. */
|
||||
updateVersion: string | null
|
||||
/** Catalog release to reinstall over an installed copy that can't load: same version, other bytes. */
|
||||
reinstallVersion?: string | null
|
||||
/** Every catalog that lists this plugin, for filtering by catalog. */
|
||||
catalogIds: readonly string[]
|
||||
/** The listing comes from the catalog this plugin was installed from, so it can update it. */
|
||||
@@ -116,6 +118,22 @@ function newerCatalogVersion(
|
||||
return compareAppVersions(version, plugin.version) > 0 ? version : null
|
||||
}
|
||||
|
||||
function reinstallCatalogVersion(
|
||||
plugin: PluginHostListEntry,
|
||||
listing: PluginMarketplaceHostListing | null,
|
||||
orcaVersion: string | null
|
||||
): string | null {
|
||||
const release = listing?.release
|
||||
if (plugin.status !== 'invalid' || plugin.source?.kind !== 'marketplace' || !release) {
|
||||
return null
|
||||
}
|
||||
// Why bytes, not version: reinstalling the same bytes would fail the same way.
|
||||
return needsNewerOrca(release.minimumOrca, orcaVersion) ||
|
||||
release.contentHash === plugin.source.contentHash
|
||||
? null
|
||||
: release.version
|
||||
}
|
||||
|
||||
export function pluginListEntryFromInstalled(
|
||||
plugin: PluginHostListEntry,
|
||||
listing: PluginMarketplaceHostListing | null = null,
|
||||
@@ -141,6 +159,7 @@ export function pluginListEntryFromInstalled(
|
||||
types: listing?.release ? pluginCatalogReleaseTypes(listing.release) : (plugin.types ?? []),
|
||||
minimumOrca: listing?.release?.minimumOrca ?? null,
|
||||
updateVersion,
|
||||
reinstallVersion: updateVersion ? null : reinstallCatalogVersion(plugin, listing, orcaVersion),
|
||||
catalogIds,
|
||||
updatable: plugin.source?.kind === 'marketplace' && listing !== null,
|
||||
autoUpdate: entryAutoUpdate(plugin, updateVersion, autoUpdates),
|
||||
@@ -263,6 +282,7 @@ export function buildPluginListEntries(
|
||||
: {
|
||||
...entry,
|
||||
updateVersion: null,
|
||||
reinstallVersion: null,
|
||||
updatable: false,
|
||||
autoUpdate: { ...entry.autoUpdate, needsReview: false }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { invalidPluginErrorMessage } from './plugin-error-presentation'
|
||||
|
||||
describe('invalid plugin error copy', () => {
|
||||
const schemaError = 'invalid manifest: engines.orca: must be at least >=1.4.245'
|
||||
|
||||
it('asks the author to fix a dev folder, but never the user of an installed copy', () => {
|
||||
expect(invalidPluginErrorMessage(schemaError)).toBe(
|
||||
'orca-plugin.json is invalid. Fix it, then refresh plugins.'
|
||||
)
|
||||
expect(invalidPluginErrorMessage(schemaError, true)).toBe(
|
||||
"This version can't load in this Orca. Update or reinstall it."
|
||||
)
|
||||
expect(invalidPluginErrorMessage('invalid JSON in orca-plugin.json: x', true)).toBe(
|
||||
"This version can't load in this Orca. Update or reinstall it."
|
||||
)
|
||||
expect(invalidPluginErrorMessage('requires Orca >=9.0.0 (this is 1.4.0)', true)).toBe(
|
||||
'This plugin requires a different Orca version.'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -48,8 +48,16 @@ export function pluginInstallErrorMessage(cause: unknown): string {
|
||||
)
|
||||
}
|
||||
|
||||
export function invalidPluginErrorMessage(detailValue: string): string {
|
||||
/** `installed`: a copy Orca installed, so its user can't edit the manifest; dev folders can. */
|
||||
export function invalidPluginErrorMessage(detailValue: string, installed = false): string {
|
||||
const detail = detailValue.toLowerCase()
|
||||
// Why everything but a version gap: the user of an installed copy can't fix its files.
|
||||
if (installed && !detail.includes('requires orca')) {
|
||||
return translate(
|
||||
'auto.components.settings.pluginError.installedManifestUnloadable',
|
||||
"This version can't load in this Orca. Update or reinstall it."
|
||||
)
|
||||
}
|
||||
if (detail.includes('missing orca-plugin.json')) {
|
||||
return translate(
|
||||
'auto.components.settings.pluginError.invalidManifestMissing',
|
||||
|
||||
@@ -50,6 +50,14 @@ describe('plugin status presentation', () => {
|
||||
).toMatchObject({ primary: 'restart', statusText: 'Stopped' })
|
||||
})
|
||||
|
||||
it('offers Reinstall for a plugin that cannot load, and View error without one', () => {
|
||||
const broken = host('a', { status: 'invalid' })
|
||||
expect(installedPluginPresentation(broken, null, false, idle, '1.0.0').primary).toBe(
|
||||
'reinstall'
|
||||
)
|
||||
expect(installedPluginPresentation(broken, null, false, idle).primary).toBe('error')
|
||||
})
|
||||
|
||||
it('counts a crashed plugin as switched on, and a pending one as needing review', () => {
|
||||
expect(isPluginEnabled({ status: 'errored' })).toBe(true)
|
||||
expect(isPluginEnabled({ status: 'disabled' })).toBe(false)
|
||||
|
||||
@@ -98,7 +98,8 @@ export function installedPluginPresentation(
|
||||
plugin: PluginHostListEntry,
|
||||
updateVersion: string | null,
|
||||
busy: boolean,
|
||||
autoUpdate: { updatedTo: string | null; needsReview: boolean }
|
||||
autoUpdate: { updatedTo: string | null; needsReview: boolean },
|
||||
reinstallVersion: string | null = null
|
||||
) {
|
||||
const kind = pluginStatusKind(plugin)
|
||||
const removable = !plugin.bundled && !plugin.isDev
|
||||
@@ -109,6 +110,7 @@ export function installedPluginPresentation(
|
||||
[pluginNeedsReview(plugin), 'review'],
|
||||
[kind === 'error', 'restart'],
|
||||
[Boolean(updateVersion), 'update'],
|
||||
[kind === 'invalid' && Boolean(reinstallVersion), 'reinstall'],
|
||||
[kind === 'invalid', 'error']
|
||||
] as const
|
||||
const primary = primaryRules.find(([applies]) => applies)?.[1] ?? null
|
||||
|
||||
@@ -11903,6 +11903,7 @@
|
||||
"installGit": "Orca konnte die fixierte Git-Revision nicht abrufen. URL, #ref, Zugriffsrechte und die Git-Konfiguration des Systems prüfen.",
|
||||
"invalidManifestMissing": "Im Stammordner des Plugins fehlt orca-plugin.json. Die Datei hinzufügen und anschließend die Plugins aktualisieren.",
|
||||
"invalidManifest": "orca-plugin.json ist ungültig. Die Datei korrigieren und anschließend die Plugins aktualisieren.",
|
||||
"installedManifestUnloadable": "Diese Version kann in dieser Orca-Version nicht geladen werden. Aktualisieren oder neu installieren.",
|
||||
"invalidArtifact": "Eine deklarierte Datei für einen Hintergrundprozess oder ein Panel fehlt oder ist unsicher. Die Plugin-Dateien korrigieren und anschließend aktualisieren.",
|
||||
"consentChanged": "Das Plugin wurde während der Prüfung geändert. Diesen Dialog schließen und die aktualisierten Berechtigungen prüfen."
|
||||
},
|
||||
|
||||
@@ -11966,6 +11966,7 @@
|
||||
"installGit": "Couldn't fetch this Git revision. Check the URL, #ref, and access.",
|
||||
"invalidManifestMissing": "The plugin root is missing orca-plugin.json. Add it, then refresh plugins.",
|
||||
"invalidManifest": "orca-plugin.json is invalid. Fix it, then refresh plugins.",
|
||||
"installedManifestUnloadable": "This version can't load in this Orca. Update or reinstall it.",
|
||||
"invalidArtifact": "A worker or panel file is missing or unsafe. Fix it, then refresh.",
|
||||
"consentChanged": "This plugin changed while you were reviewing it. Review it again."
|
||||
},
|
||||
@@ -13000,6 +13001,7 @@
|
||||
"customEditor": "Editor"
|
||||
},
|
||||
"PluginManagerRows": {
|
||||
"reinstall": "Reinstall",
|
||||
"crashedCount": "Crashed {{value0}} times in a row",
|
||||
"crashed": "Crashed while starting",
|
||||
"remove": "Remove…",
|
||||
|
||||
@@ -11718,6 +11718,7 @@
|
||||
"installGit": "No se pudo obtener esta revisión de Git. Revisa la URL, el #ref y el acceso.",
|
||||
"invalidManifestMissing": "A la raíz del plugin le falta orca-plugin.json. Añádelo y actualiza los plugins.",
|
||||
"invalidManifest": "orca-plugin.json no es válido. Corrígelo y actualiza los plugins.",
|
||||
"installedManifestUnloadable": "Esta versión no se puede cargar en este Orca. Actualízala o reinstálala.",
|
||||
"invalidArtifact": "Falta un archivo de proceso o de panel, o no es seguro. Corrígelo y actualiza.",
|
||||
"consentChanged": "Este plugin cambió mientras lo revisabas. Revísalo de nuevo."
|
||||
},
|
||||
@@ -12715,6 +12716,7 @@
|
||||
"noAccessReach": "Nada fuera de sus propios archivos"
|
||||
},
|
||||
"PluginManagerRows": {
|
||||
"reinstall": "Reinstalar",
|
||||
"crashedCount": "Falló {{value0}} veces seguidas",
|
||||
"crashed": "Falló al iniciar",
|
||||
"remove": "Quitar…",
|
||||
|
||||
@@ -11895,6 +11895,7 @@
|
||||
"installGit": "Impossible de récupérer cette révision Git. Vérifiez l'URL, le #ref et l'accès.",
|
||||
"invalidManifestMissing": "orca-plugin.json manque à la racine du plugin. Ajoutez-le, puis actualisez les plugins.",
|
||||
"invalidManifest": "orca-plugin.json est non valide. Corrigez-le, puis actualisez les plugins.",
|
||||
"installedManifestUnloadable": "Cette version ne peut pas se charger dans cet Orca. Mettez-la à jour ou réinstallez-la.",
|
||||
"invalidArtifact": "Un fichier de worker ou de panneau est manquant ou dangereux. Corrigez-le, puis actualisez.",
|
||||
"consentChanged": "Ce plugin a changé pendant votre examen. Examinez-le à nouveau."
|
||||
},
|
||||
@@ -12901,6 +12902,7 @@
|
||||
"noAccessReach": "Rien en dehors de ses propres fichiers"
|
||||
},
|
||||
"PluginManagerRows": {
|
||||
"reinstall": "Réinstaller",
|
||||
"crashedCount": "Plantage {{value0}} fois de suite",
|
||||
"crashed": "Plantage au démarrage",
|
||||
"remove": "Supprimer…",
|
||||
|
||||
@@ -11444,6 +11444,7 @@
|
||||
"installGit": "この Git リビジョンを取得できませんでした。URL、#ref、アクセス権を確認してください。",
|
||||
"invalidManifestMissing": "プラグインルートにorca-plugin.jsonがありません。追加してからプラグインを更新してください。",
|
||||
"invalidManifest": "orca-plugin.jsonが無効です。修正してからプラグインを更新してください。",
|
||||
"installedManifestUnloadable": "このバージョンはこの Orca では読み込めません。更新するか再インストールしてください。",
|
||||
"invalidArtifact": "ワーカーまたはパネルのファイルが見つからないか安全ではありません。修正してから更新してください。",
|
||||
"consentChanged": "確認中にこのプラグインが変更されました。もう一度確認してください。"
|
||||
},
|
||||
@@ -12438,6 +12439,7 @@
|
||||
"noAccessReach": "自身のファイル以外にはアクセスしません"
|
||||
},
|
||||
"PluginManagerRows": {
|
||||
"reinstall": "再インストール",
|
||||
"crashedCount": "{{value0}} 回連続でクラッシュしました",
|
||||
"crashed": "起動中にクラッシュしました",
|
||||
"remove": "削除…",
|
||||
|
||||
@@ -11444,6 +11444,7 @@
|
||||
"installGit": "이 Git 리비전을 가져올 수 없습니다. URL, #ref, 접근 권한을 확인하세요.",
|
||||
"invalidManifestMissing": "플러그인 루트에 orca-plugin.json이 없습니다. 추가한 후 플러그인을 새로고침하세요.",
|
||||
"invalidManifest": "orca-plugin.json이 유효하지 않습니다. 수정한 후 플러그인을 새로고침하세요.",
|
||||
"installedManifestUnloadable": "이 버전은 이 Orca에서 불러올 수 없습니다. 업데이트하거나 다시 설치하세요.",
|
||||
"invalidArtifact": "워커 또는 패널 파일이 없거나 안전하지 않습니다. 수정한 후 새로 고치세요.",
|
||||
"consentChanged": "검토하는 동안 이 플러그인이 변경되었습니다. 다시 검토하세요."
|
||||
},
|
||||
@@ -12438,6 +12439,7 @@
|
||||
"noAccessReach": "자체 파일 외에는 접근하지 않음"
|
||||
},
|
||||
"PluginManagerRows": {
|
||||
"reinstall": "다시 설치",
|
||||
"crashedCount": "{{value0}}번 연속 충돌함",
|
||||
"crashed": "시작 중 충돌함",
|
||||
"remove": "제거…",
|
||||
|
||||
@@ -7787,6 +7787,7 @@
|
||||
"installGit": "O Orca não conseguiu obter a revisão Git fixada. Verifique a URL, o #ref, o acesso e a configuração do Git do sistema.",
|
||||
"invalidManifestMissing": "A raiz do plugin está sem o orca-plugin.json. Adicione-o e depois atualize os plugins.",
|
||||
"invalidManifest": "orca-plugin.json é inválido. Corrija-o e depois atualize os plugins.",
|
||||
"installedManifestUnloadable": "Esta versão não pode ser carregada neste Orca. Atualize-a ou reinstale-a.",
|
||||
"invalidArtifact": "Um arquivo de worker ou painel declarado está ausente ou é inseguro. Corrija os arquivos do plugin e depois atualize.",
|
||||
"consentChanged": "O plugin mudou enquanto você o revisava. Feche esta caixa de diálogo e revise as permissões atualizadas."
|
||||
},
|
||||
@@ -8947,6 +8948,7 @@
|
||||
"updatesKeepApproval": "Atualizações só perguntam de novo por novo acesso"
|
||||
},
|
||||
"PluginManagerRows": {
|
||||
"reinstall": "Reinstalar",
|
||||
"crashed": "Falhou ao iniciar",
|
||||
"crashedCount": "Falhou {{value0}} vezes seguidas",
|
||||
"crashedOnce": "Falhou uma vez",
|
||||
|
||||
@@ -12197,6 +12197,7 @@
|
||||
"installGit": "Orca не удалось загрузить закреплённую ревизию Git. Проверьте URL, #ref, доступ и системную настройку Git.",
|
||||
"invalidManifestMissing": "В корне плагина нет orca-plugin.json. Добавьте его и обновите плагины.",
|
||||
"invalidManifest": "Некорректный orca-plugin.json. Исправьте его и обновите плагины.",
|
||||
"installedManifestUnloadable": "Эта версия не загружается в этой версии Orca. Обновите или переустановите её.",
|
||||
"invalidArtifact": "Заявленный файл фонового процесса или панели отсутствует либо небезопасен. Исправьте файлы плагина и обновите список.",
|
||||
"consentChanged": "Плагин изменился во время проверки. Закройте диалог и проверьте обновлённые разрешения."
|
||||
},
|
||||
|
||||
@@ -11889,6 +11889,7 @@
|
||||
"installGit": "Orca, sabitlenmiş Git revizyonunu getiremedi. URL'yi, #ref'i, erişimi ve sistem Git kurulumunu kontrol edin.",
|
||||
"invalidManifestMissing": "Eklenti kökünde orca-plugin.json eksik. Ekleyip eklentileri yenileyin.",
|
||||
"invalidManifest": "orca-plugin.json geçersiz. Düzeltip eklentileri yenileyin.",
|
||||
"installedManifestUnloadable": "Bu sürüm bu Orca'da yüklenemiyor. Güncelleyin veya yeniden yükleyin.",
|
||||
"invalidArtifact": "Bildirilen bir worker veya panel dosyası eksik ya da güvenli değil. Eklenti dosyalarını düzeltip yenileyin.",
|
||||
"consentChanged": "Eklenti, siz incelerken değişti. Bu iletişim kutusunu kapatıp güncellenmiş izinleri inceleyin."
|
||||
},
|
||||
|
||||
@@ -11757,6 +11757,7 @@
|
||||
"installGit": "Orca 無法取得固定的 Git 版本。請檢查 URL、#ref、存取權限和系統 Git 設定。",
|
||||
"invalidManifestMissing": "外掛根目錄缺少 orca-plugin.json。請新增它,然後重新整理外掛。",
|
||||
"invalidManifest": "orca-plugin.json 無效。請修復它,然後重新整理外掛。",
|
||||
"installedManifestUnloadable": "此版本無法在這個 Orca 中載入。請更新或重新安裝。",
|
||||
"invalidArtifact": "宣告的工作行程或面板檔案缺失或不安全。請修復外掛檔案,然後重新整理。",
|
||||
"consentChanged": "您在審查時外掛發生了變更。請關閉此對話方塊並審查更新後的權限。"
|
||||
},
|
||||
|
||||
@@ -11444,6 +11444,7 @@
|
||||
"installGit": "无法获取此 Git 版本。请检查 URL、#ref 和访问权限。",
|
||||
"invalidManifestMissing": "插件根目录缺少 orca-plugin.json。请添加它,然后刷新插件。",
|
||||
"invalidManifest": "orca-plugin.json 无效。请修复它,然后刷新插件。",
|
||||
"installedManifestUnloadable": "此版本无法在当前 Orca 中加载。请更新或重新安装。",
|
||||
"invalidArtifact": "工作进程或面板文件缺失或不安全。修复后请刷新。",
|
||||
"consentChanged": "你审查期间此插件发生了变化。请重新审查。"
|
||||
},
|
||||
@@ -12471,6 +12472,7 @@
|
||||
"noAccessReach": "仅限其自身文件"
|
||||
},
|
||||
"PluginManagerRows": {
|
||||
"reinstall": "重新安装",
|
||||
"crashedCount": "连续崩溃 {{value0}} 次",
|
||||
"crashed": "启动时崩溃",
|
||||
"remove": "移除…",
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from 'zod'
|
||||
import { pluginEngineFloor } from './plugin-engine-floor'
|
||||
import { PLUGIN_ENUM_SINCE_KEY } from './plugin-engine-floor-keys'
|
||||
import { PLUGIN_SCHEMA_NEWEST_SINCE, pluginHostVersion } from '../installation/plugin-host-version'
|
||||
import { parsePluginManifest } from './plugin-manifest'
|
||||
import { parseHostPluginManifest, parsePluginManifest } from './plugin-manifest'
|
||||
import { PLUGIN_MANIFEST_V2_SINCE } from './plugin-schema-field-docs'
|
||||
import { PLUGIN_RESERVED_CONTRIBUTION_POINTS } from '../contributions/plugin-contribution-points'
|
||||
|
||||
@@ -93,6 +93,17 @@ describe('manifest format 2 rules', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('skips only the engines floor when reading a manifest at runtime', () => {
|
||||
const underDeclared = v2({ engines: { orca: '>=1.4.160' } })
|
||||
expect(parseHostPluginManifest(underDeclared)).toMatchObject({ ok: true })
|
||||
expect(parsePluginManifest(underDeclared)).toMatchObject({ ok: false })
|
||||
// Every other rule still applies.
|
||||
expect(parseHostPluginManifest(v2({ engines: { orca: '>=1.4.160' }, pluginApi: 1 }))).toEqual({
|
||||
ok: false,
|
||||
error: 'pluginApi: data plugins never call the host API; remove pluginApi'
|
||||
})
|
||||
})
|
||||
|
||||
it('counts enum values and only fields that are present', () => {
|
||||
const schema = z.object({
|
||||
kind: z.enum(['old', 'new']).meta({
|
||||
|
||||
@@ -184,18 +184,28 @@ export const pluginManifestV2Object = z
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const pluginManifestSchema = z.preprocess(
|
||||
upgradePluginManifestV1Names,
|
||||
z.discriminatedUnion('manifestVersion', [
|
||||
pluginManifestV1Object.superRefine(validatePluginManifestContributions),
|
||||
pluginManifestV2Object.superRefine((manifest, ctx) => {
|
||||
validatePluginManifestContributions(manifest, ctx)
|
||||
validatePluginExecutionProfile(manifest, ctx)
|
||||
validatePluginManifestV2Rules(manifest, ctx)
|
||||
validatePluginEngineFloor(pluginManifestV2Object, manifest, ctx)
|
||||
})
|
||||
])
|
||||
)
|
||||
function buildPluginManifestSchema(enforceEngineFloor: boolean) {
|
||||
return z.preprocess(
|
||||
upgradePluginManifestV1Names,
|
||||
z.discriminatedUnion('manifestVersion', [
|
||||
pluginManifestV1Object.superRefine(validatePluginManifestContributions),
|
||||
pluginManifestV2Object.superRefine((manifest, ctx) => {
|
||||
validatePluginManifestContributions(manifest, ctx)
|
||||
validatePluginExecutionProfile(manifest, ctx)
|
||||
validatePluginManifestV2Rules(manifest, ctx)
|
||||
if (enforceEngineFloor) {
|
||||
validatePluginEngineFloor(pluginManifestV2Object, manifest, ctx)
|
||||
}
|
||||
})
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
/** Authoring schema: also refuses an engines.orca below the newest field it uses. */
|
||||
export const pluginManifestSchema = buildPluginManifestSchema(true)
|
||||
// Why no floor at runtime: the floor only lets older hosts say "requires Orca X"; a host that
|
||||
// parses every field and meets engines.orca runs the plugin fine, so refusing it strands installs.
|
||||
const hostPluginManifestSchema = buildPluginManifestSchema(false)
|
||||
|
||||
export type PluginManifest = z.infer<typeof pluginManifestSchema>
|
||||
export {
|
||||
@@ -230,14 +240,25 @@ export type PluginManifestParseResult =
|
||||
| { ok: true; manifest: PluginManifest }
|
||||
| { ok: false; error: string }
|
||||
|
||||
export function parsePluginManifest(raw: unknown): PluginManifestParseResult {
|
||||
const parsed = pluginManifestSchema.safeParse(raw)
|
||||
function parseWith(schema: typeof pluginManifestSchema, raw: unknown): PluginManifestParseResult {
|
||||
const parsed = schema.safeParse(raw)
|
||||
if (parsed.success) {
|
||||
return { ok: true, manifest: parsed.data }
|
||||
}
|
||||
return { ok: false, error: formatPluginSchemaIssues(parsed.error.issues, raw) }
|
||||
}
|
||||
|
||||
/** Strict authoring check (`validate:plugin`, catalog CI, dev folders). */
|
||||
export function parsePluginManifest(raw: unknown): PluginManifestParseResult {
|
||||
return parseWith(pluginManifestSchema, raw)
|
||||
}
|
||||
|
||||
/** Runtime read for install, discovery and update. Callers must still check engines.orca
|
||||
* against the host, which is what actually gates loading. */
|
||||
export function parseHostPluginManifest(raw: unknown): PluginManifestParseResult {
|
||||
return parseWith(hostPluginManifestSchema, raw)
|
||||
}
|
||||
|
||||
export {
|
||||
isPluginPanelTabKey,
|
||||
isQualifiedPluginKey,
|
||||
|
||||
@@ -16,7 +16,7 @@ export const PLUGIN_MANIFEST_POLICY_NOTES: readonly string[] = [
|
||||
'## Versions and compatibility',
|
||||
'',
|
||||
'- **What runs is strict.** Orca refuses a manifest with any key it does not know, at every level, so an older Orca never silently ignores something a plugin relies on.',
|
||||
'- **`engines.orca` is the only version gate.** It must be at least the newest `Since` of every field and every value (capability, event, built-in action) the manifest uses. Orca checks this when it validates, lists or installs a plugin, so an older Orca can always say "requires Orca >=X (this is Y)" instead of listing unknown fields.',
|
||||
'- **`engines.orca` is the only version gate.** It must be at least the newest `Since` of every field and every value (capability, event, built-in action) the manifest uses. `pnpm validate:plugin` and catalog CI enforce it, so an older Orca can always say "requires Orca >=X (this is Y)" instead of listing unknown fields.',
|
||||
'- **`pluginApi` is optional.** It names the major version of the host API ([reference](plugin-host-api.md)), which only grows within major 1; Orca refuses a major it does not implement. Only `sandboxed-ui` and `trusted-worker` plugins call that API, so `data` and `declarative` plugins must leave it out.',
|
||||
'- **`terminal:send` needs `trusted-worker`.** Panels get no host method that types into a terminal, so Orca refuses the capability on any other profile instead of asking the user to grant something the plugin can never use.',
|
||||
'- **New fields, contribution points and values are additive.** They arrive with a `Since` version; nothing else changes. `manifestVersion` rises only if an existing field changes meaning.',
|
||||
|
||||
Reference in New Issue
Block a user