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:
Neil
2026-10-29 00:15:36 -07:00
committed by GitHub
parent 57c6fcf7b2
commit 016cbe3cc0
27 changed files with 269 additions and 54 deletions
+1 -1
View File
@@ -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
+54
View File
@@ -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'
+3 -1
View File
@@ -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 }
+41 -28
View File
@@ -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
+1
View File
@@ -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."
},
+2
View File
@@ -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…",
+2
View File
@@ -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…",
+2
View File
@@ -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…",
+2
View File
@@ -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": "削除…",
+2
View File
@@ -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": "제거…",
+2
View File
@@ -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",
+1
View File
@@ -12197,6 +12197,7 @@
"installGit": "Orca не удалось загрузить закреплённую ревизию Git. Проверьте URL, #ref, доступ и системную настройку Git.",
"invalidManifestMissing": "В корне плагина нет orca-plugin.json. Добавьте его и обновите плагины.",
"invalidManifest": "Некорректный orca-plugin.json. Исправьте его и обновите плагины.",
"installedManifestUnloadable": "Эта версия не загружается в этой версии Orca. Обновите или переустановите её.",
"invalidArtifact": "Заявленный файл фонового процесса или панели отсутствует либо небезопасен. Исправьте файлы плагина и обновите список.",
"consentChanged": "Плагин изменился во время проверки. Закройте диалог и проверьте обновлённые разрешения."
},
+1
View File
@@ -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": "您在審查時外掛發生了變更。請關閉此對話方塊並審查更新後的權限。"
},
+2
View File
@@ -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({
+35 -14
View File
@@ -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.',