feat(web): factory task plugins update only with the system

Marketplace install/upgrade on a factory-served plugin actually created
a permanent override shadowing every future built-in release. The card
now shows an informational "Updates with the system" badge instead of
the action, while keeping the built-in vs marketplace version line and
the upgradable state badge visible. Deliberate overrides are untouched:
upload and marketplace actions on overridden or third-party plugins
behave as before, and the plugins table now hints when an override
lags behind the shipped built-in version so operators know deleting it
restores the newer factory plugin.
This commit is contained in:
CaIon
2026-08-30 20:37:06 +08:00
parent 0bee5d4410
commit dc4732cfed
11 changed files with 216 additions and 20 deletions
@@ -26,7 +26,10 @@ import {
GITHUB_MARKETPLACE_INDEX_URL,
indexHasIntegrityHashes,
isDefaultMarketplaceSource,
isStaleFactoryOverride,
marketplaceBuiltInVersion,
parseMarketplaceIndex,
resolveMarketplaceActionPolicy,
resolvePluginSourceUrl,
} from '../lib/marketplace'
import type {
@@ -52,7 +55,23 @@ function marketplacePlugin(
}
}
function installedPlugin(key: string, version: string): TaskPluginListItem {
function factoryMeta(key: string, version: string) {
return {
apiVersion: 1,
key,
name: key,
version,
author: { name: 'test' as const },
models: null,
fetchMode: 'poll',
}
}
function installedPlugin(
key: string,
version: string,
overrides: Partial<TaskPluginListItem> = {}
): TaskPluginListItem {
return {
meta: {
apiVersion: 1,
@@ -71,6 +90,7 @@ function installedPlugin(key: string, version: string): TaskPluginListItem {
runtime_status: 'registered',
channel_count: 0,
in_flight_count: 0,
...overrides,
}
}
@@ -399,6 +419,102 @@ describe('install state derivation', () => {
})
})
describe('marketplace action policy', () => {
test('factory-served plugin returns the informational system-update state', () => {
assert.deepEqual(
resolveMarketplaceActionPolicy(
installedPlugin('doubao', '1.0.0', { source: 'factory' })
),
{ kind: 'system_update' }
)
})
test('overridden factory plugin still allows marketplace install', () => {
assert.deepEqual(
resolveMarketplaceActionPolicy(
installedPlugin('doubao', '1.2.0', {
source: 'override_over_factory',
factory_meta: factoryMeta('doubao', '1.0.0'),
})
),
{ kind: 'install' }
)
})
test('third-party plugin still allows marketplace install', () => {
assert.deepEqual(
resolveMarketplaceActionPolicy(installedPlugin('doubao', '1.0.0')),
{ kind: 'install' }
)
})
test('uninstalled plugin still allows marketplace install', () => {
assert.deepEqual(resolveMarketplaceActionPolicy(undefined), {
kind: 'install',
})
})
test('factory-served built-in version is the installed meta version', () => {
assert.equal(
marketplaceBuiltInVersion(
installedPlugin('doubao', '1.0.0', { source: 'factory' })
),
'1.0.0'
)
})
test('overridden factory built-in version comes from factory_meta', () => {
assert.equal(
marketplaceBuiltInVersion(
installedPlugin('doubao', '1.2.0', {
source: 'override_over_factory',
factory_meta: factoryMeta('doubao', '1.0.0'),
})
),
'1.0.0'
)
})
})
describe('stale factory override', () => {
test('is stale when override version differs from built-in', () => {
assert.equal(
isStaleFactoryOverride(
installedPlugin('doubao', '1.2.0', {
source: 'override_over_factory',
factory_meta: factoryMeta('doubao', '1.0.0'),
})
),
true
)
})
test('is not stale when override version matches built-in', () => {
assert.equal(
isStaleFactoryOverride(
installedPlugin('doubao', '1.0.0', {
source: 'override_over_factory',
factory_meta: factoryMeta('doubao', '1.0.0'),
})
),
false
)
})
test('is not stale for factory-served or third-party plugins', () => {
assert.equal(
isStaleFactoryOverride(
installedPlugin('doubao', '1.0.0', { source: 'factory' })
),
false
)
assert.equal(
isStaleFactoryOverride(installedPlugin('doubao', '1.0.0')),
false
)
})
})
describe('marketplace version lookup', () => {
test('finds the entry matching a version', () => {
assert.equal(
@@ -29,7 +29,12 @@ import { Button } from '@/components/ui/button'
import { getChannelTypeLabel } from '@/features/channels/lib'
import { resolveLocalizedText } from '@/lib/localized-text'
import { findMarketplaceVersion, type InstallState } from '../lib/marketplace'
import {
findMarketplaceVersion,
marketplaceBuiltInVersion,
resolveMarketplaceActionPolicy,
type InstallState,
} from '../lib/marketplace'
import type { MarketplacePlugin, TaskPluginListItem } from '../types'
import { PluginIcon } from './plugin-icon'
@@ -47,6 +52,8 @@ export function MarketplacePluginCard(props: MarketplacePluginCardProps) {
const channelTypes = plugin.channelTypes ?? []
const latestEntry = findMarketplaceVersion(plugin, plugin.latest)
const labelClass = 'text-muted-foreground text-[11px] font-medium select-none'
const actionPolicy = resolveMarketplaceActionPolicy(props.installed)
const builtInVersion = marketplaceBuiltInVersion(props.installed)
return (
<div className='flex h-full flex-col gap-2.5 rounded-xl border p-3'>
@@ -90,12 +97,12 @@ export function MarketplacePluginCard(props: MarketplacePluginCardProps) {
</div>
</div>
{props.installed?.factory_meta && (
{builtInVersion && (
<div className='text-xs'>
<span className={labelClass}>{t('Versions')}</span>{' '}
<span className='font-mono'>
{t('Built-in v{{factory}} / marketplace v{{market}}', {
factory: props.installed.factory_meta.version,
factory: builtInVersion,
market: plugin.latest,
})}
</span>
@@ -110,17 +117,21 @@ export function MarketplacePluginCard(props: MarketplacePluginCardProps) {
)}
<div className='mt-auto border-t pt-2'>
<Button
size='sm'
variant={
props.installState.status === 'up_to_date' ? 'outline' : 'default'
}
className='w-full'
onClick={props.onInstall}
>
<Download />
{getActionLabel(props.installState, t)}
</Button>
{actionPolicy.kind === 'system_update' ? (
<Badge variant='secondary'>{t('Updates with the system')}</Badge>
) : (
<Button
size='sm'
variant={
props.installState.status === 'up_to_date' ? 'outline' : 'default'
}
className='w-full'
onClick={props.onInstall}
>
<Download />
{getActionLabel(props.installState, t)}
</Button>
)}
</div>
</div>
)
@@ -43,6 +43,7 @@ import {
setTaskPluginStatus,
TaskPluginUsageError,
} from '../api'
import { isStaleFactoryOverride } from '../lib/marketplace'
import type { TaskPluginListItem, TaskPluginUsage } from '../types'
import { PluginCard } from './plugin-card'
import { PluginIcon } from './plugin-icon'
@@ -158,12 +159,26 @@ export function PluginsTable(props: PluginsTableProps) {
return <Badge variant='secondary'>{t('Factory')}</Badge>
}
if (row.original.source === 'override_over_factory') {
const factoryVersion = row.original.factory_meta?.version
const staleHint = isStaleFactoryOverride(row.original)
? t(
'Built-in is v{{factory}}; delete the custom version to return to it',
{ factory: factoryVersion }
)
: undefined
return (
<Badge>
{t('Custom (overrides factory {{version}})', {
version: row.original.factory_meta?.version,
})}
</Badge>
<div className='flex min-w-0 flex-col gap-0.5' title={staleHint}>
<Badge>
{t('Custom (overrides factory {{version}})', {
version: factoryVersion,
})}
</Badge>
{staleHint ? (
<span className='text-muted-foreground text-xs'>
{staleHint}
</span>
) : null}
</div>
)
}
return <Badge>{t('Third-party')}</Badge>
@@ -234,6 +234,46 @@ export function deriveInstallState(
}
}
export type MarketplaceActionPolicy =
| { kind: 'install' }
| { kind: 'system_update' }
/**
* Factory-served plugins are compiled into the binary and must only update
* with a system release. Marketplace install would create a permanent override
* that shadows every future built-in update — that action is suppressed.
* Overrides and third-party plugins still install/upgrade normally.
*/
export function resolveMarketplaceActionPolicy(
installed?: TaskPluginListItem
): MarketplaceActionPolicy {
if (installed?.source === 'factory') {
return { kind: 'system_update' }
}
return { kind: 'install' }
}
/**
* Built-in version shown next to the marketplace latest. Factory-served items
* do not carry `factory_meta` (their `meta` *is* the factory meta); overridden
* factory plugins expose the shadowed built-in on `factory_meta`.
*/
export function marketplaceBuiltInVersion(
installed?: TaskPluginListItem
): string | undefined {
if (!installed) return undefined
if (installed.source === 'factory') return installed.meta.version
return installed.factory_meta?.version
}
export function isStaleFactoryOverride(item: TaskPluginListItem): boolean {
return (
item.source === 'override_over_factory' &&
item.factory_meta != null &&
item.factory_meta.version !== item.meta.version
)
}
/**
* A source is only integrity-checked when every listed version carries a
* sha256. Anything less and installs from it cannot be pinned, so the UI warns.
+2
View File
@@ -714,6 +714,8 @@
"Built-in": "Built-in",
"Built-in Device": "Built-in Device",
"Built-in v{{factory}} / marketplace v{{market}}": "Built-in v{{factory}} / marketplace v{{market}}",
"Updates with the system": "Updates with the system",
"Built-in is v{{factory}}; delete the custom version to return to it": "Built-in is v{{factory}}; delete the custom version to return to it",
"Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "Built-in: phone fingerprint/face, or Windows Hello; External: USB security key",
"by": "by",
"By category": "By category",
+2
View File
@@ -714,6 +714,8 @@
"Built-in": "Intégré",
"Built-in Device": "Appareil intégré",
"Built-in v{{factory}} / marketplace v{{market}}": "Intégré v{{factory}} / marché v{{market}}",
"Updates with the system": "Mise à jour système",
"Built-in is v{{factory}}; delete the custom version to return to it": "La version intégrée est v{{factory}} ; supprimez la version personnalisée pour y revenir",
"Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "Intégré : empreinte digitale/visage du téléphone, ou Windows Hello ; Externe : clé de sécurité USB",
"by": "par",
"By category": "Par catégorie",
+2
View File
@@ -714,6 +714,8 @@
"Built-in": "組み込み",
"Built-in Device": "内蔵デバイス",
"Built-in v{{factory}} / marketplace v{{market}}": "組み込み v{{factory}} / マーケット v{{market}}",
"Updates with the system": "システムとともに更新",
"Built-in is v{{factory}}; delete the custom version to return to it": "組み込みは v{{factory}} です。カスタム版を削除すると戻ります",
"Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "内蔵: 電話の指紋/顔認証、またはWindows Hello。外部: USBセキュリティキー",
"by": "によって",
"By category": "カテゴリ別",
+2
View File
@@ -714,6 +714,8 @@
"Built-in": "Встроенный",
"Built-in Device": "Встроенное устройство",
"Built-in v{{factory}} / marketplace v{{market}}": "Встроенный v{{factory}} / магазин v{{market}}",
"Updates with the system": "Обновляется с системой",
"Built-in is v{{factory}}; delete the custom version to return to it": "Встроенная версия — v{{factory}}; удалите свою, чтобы вернуться к ней",
"Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "Встроенное: отпечаток пальца/лицо телефона или Windows Hello; Внешнее: USB-ключ безопасности",
"by": "от",
"By category": "По категориям",
+2
View File
@@ -714,6 +714,8 @@
"Built-in": "Tích hợp sẵn",
"Built-in Device": "Thiết bị tích hợp",
"Built-in v{{factory}} / marketplace v{{market}}": "Tích hợp v{{factory}} / chợ v{{market}}",
"Updates with the system": "Cập nhật cùng hệ thống",
"Built-in is v{{factory}}; delete the custom version to return to it": "Bản tích hợp là v{{factory}}; xóa bản tùy chỉnh để trở lại",
"Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "Tích hợp sẵn: vân tay/khuôn mặt điện thoại, hoặc Windows Hello; Bên ngoài: khóa bảo mật USB",
"by": "by",
"By category": "Theo danh mục",
+2
View File
@@ -714,6 +714,8 @@
"Built-in": "內置",
"Built-in Device": "內置設備",
"Built-in v{{factory}} / marketplace v{{market}}": "內建 v{{factory}} / 市集 v{{market}}",
"Updates with the system": "隨系統更新",
"Built-in is v{{factory}}; delete the custom version to return to it": "內建版本為 v{{factory}};刪除自訂版本即可恢復",
"Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "內置:手機指紋/面部,或 Windows Hello;外部:USB 安全金鑰",
"by": "由",
"By category": "按行業",
+2
View File
@@ -714,6 +714,8 @@
"Built-in": "内置",
"Built-in Device": "内置设备",
"Built-in v{{factory}} / marketplace v{{market}}": "内置 v{{factory}} / 市场 v{{market}}",
"Updates with the system": "随系统更新",
"Built-in is v{{factory}}; delete the custom version to return to it": "内置版本为 v{{factory}};删除自定义版本即可恢复",
"Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "内置:手机指纹/面部,或 Windows Hello;外部:USB 安全密钥",
"by": "由",
"By category": "按行业",