mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-10-05 23:33:02 +08:00
feat(plugin): install declarative plugins — MCP servers, skills and model vendors (#3716)
* feat(plugin): install declarative plugin packages across nodes * feat(plugin): serve installed plugins' MCP servers in each workspace * feat(plugin): let installed plugins contribute skills and model vendors * feat(plugin): register plugin skills into the workspace skill catalog * feat(frontend): manage installed plugins and their configuration * test(weaviate): read the class state before the probe barrier * fix(plugin): keep MCP tool directories reachable behind the plugin wrapper * test(plugin): wrap a long line
This commit is contained in:
@@ -41,6 +41,10 @@ export interface MCPService {
|
||||
}
|
||||
env_vars?: Record<string, string> // Environment variables for stdio transport
|
||||
is_builtin?: boolean // Whether this is a builtin MCP service
|
||||
/** Installed plugin providing the service (read-only, never stored). */
|
||||
plugin_id?: string
|
||||
/** Why a plugin service is off, typically missing workspace configuration. */
|
||||
plugin_error?: string
|
||||
// Per-field "configured?" map embedded on the main response (server-side
|
||||
// dto.MCPServiceResponse.Credentials). Drives the CredentialResource card
|
||||
// without a follow-up GET. Absent for builtin services.
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { get, put } from '@/utils/request'
|
||||
|
||||
import type { ConfigSchema, ConfigValue } from '@/components/schema-form/schema'
|
||||
import type { LocalizedText } from '@/utils/localizedText'
|
||||
|
||||
export type { LocalizedText } from '@/utils/localizedText'
|
||||
|
||||
export type ExtensionPoint = 'modelVendors' | 'connectors' | 'imChannels' | 'webSearch' | 'tools' | 'parsers'
|
||||
export type ExtensionPoint =
|
||||
| 'modelVendors'
|
||||
| 'connectors'
|
||||
| 'imChannels'
|
||||
| 'webSearch'
|
||||
| 'tools'
|
||||
| 'parsers'
|
||||
| 'skills'
|
||||
| 'mcpServers'
|
||||
|
||||
export interface PluginContribution {
|
||||
id: string
|
||||
@@ -14,6 +23,16 @@ export interface PluginContribution {
|
||||
aliases?: string[]
|
||||
capabilities?: string[]
|
||||
order?: number
|
||||
/** Package path of a skill directory or model vendor file. */
|
||||
path?: string
|
||||
/** Remote MCP server an installed plugin contributes. */
|
||||
mcp?: { url: string; transport?: string; headers?: Record<string, string> }
|
||||
}
|
||||
|
||||
export interface PluginPermissions {
|
||||
egress?: string[]
|
||||
hostApi?: string[]
|
||||
events?: string[]
|
||||
}
|
||||
|
||||
/** A plugin's manifest (internal/plugin/manifest.Manifest). */
|
||||
@@ -25,9 +44,19 @@ export interface PluginManifest {
|
||||
description?: LocalizedText
|
||||
publisher: { id: string; name?: string; url?: string }
|
||||
icon?: string
|
||||
homepage?: string
|
||||
license?: string
|
||||
builtin?: boolean
|
||||
required?: boolean
|
||||
engines?: { weknora?: string }
|
||||
runtime: { type: string }
|
||||
permissions?: PluginPermissions
|
||||
config?: {
|
||||
system?: string
|
||||
tenant?: string
|
||||
systemSchema?: ConfigSchema
|
||||
tenantSchema?: ConfigSchema
|
||||
}
|
||||
contributes: Partial<Record<ExtensionPoint, PluginContribution[]>>
|
||||
}
|
||||
|
||||
@@ -38,11 +67,43 @@ export interface TenantPlugin {
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
/** One running instance of a plugin, for the detail view. */
|
||||
export interface PluginInstance {
|
||||
node: string
|
||||
version: string
|
||||
state: 'starting' | 'ready' | 'degraded' | 'stopped'
|
||||
error?: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
/** A plugin configuration: its schema and values, secrets redacted. */
|
||||
export interface PluginConfig {
|
||||
schema: ConfigSchema
|
||||
values: ConfigValue
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export function listPlugins() {
|
||||
return get<{ data: TenantPlugin[] }>('/api/v1/plugins')
|
||||
}
|
||||
|
||||
export function getPlugin(id: string) {
|
||||
return get<{ data: TenantPlugin & { instances: PluginInstance[]; instanceError?: string } }>(
|
||||
`/api/v1/plugins/${encodeURIComponent(id)}`,
|
||||
)
|
||||
}
|
||||
|
||||
/** Turns a plugin on or off for the current workspace (Admin+). */
|
||||
export function setPluginEnabled(id: string, enabled: boolean) {
|
||||
return put<{ data: TenantPlugin }>(`/api/v1/plugins/${encodeURIComponent(id)}/enabled`, { enabled })
|
||||
}
|
||||
|
||||
/** The workspace's configuration of a plugin (Admin+). */
|
||||
export function getPluginConfig(id: string) {
|
||||
return get<{ data: PluginConfig }>(`/api/v1/plugins/${encodeURIComponent(id)}/config`)
|
||||
}
|
||||
|
||||
/** Saves the workspace's configuration; "***" keeps a stored secret (Admin+). */
|
||||
export function updatePluginConfig(id: string, values: ConfigValue) {
|
||||
return put<{ data: PluginConfig }>(`/api/v1/plugins/${encodeURIComponent(id)}/config`, { values })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// System-admin plugin installation (/api/v1/system/admin/plugins).
|
||||
import { del, get, post, postUpload, put } from '@/utils/request'
|
||||
|
||||
import type { ConfigValue } from '@/components/schema-form/schema'
|
||||
import type { PluginConfig, PluginManifest } from '@/api/plugin'
|
||||
|
||||
const BASE = '/api/v1/system/admin/plugins'
|
||||
const PACKAGE_TIMEOUT = 5 * 60 * 1000
|
||||
|
||||
/** What installing a package would do. */
|
||||
export type InstallChange = 'install' | 'upgrade' | 'downgrade' | 'reinstall'
|
||||
|
||||
export interface PluginPreview {
|
||||
manifest: PluginManifest
|
||||
digest: string
|
||||
size: number
|
||||
change: InstallChange
|
||||
installedVersion?: string
|
||||
}
|
||||
|
||||
export interface PluginVersion {
|
||||
plugin_id: string
|
||||
version: string
|
||||
digest: string
|
||||
manifest: PluginManifest
|
||||
size: number
|
||||
created_by: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** This node's report on an installed plugin. */
|
||||
export interface PluginNodeStatus {
|
||||
version: string
|
||||
state: 'ready' | 'failed'
|
||||
error?: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface InstalledPlugin {
|
||||
id: string
|
||||
source: { kind: 'upload' | 'url'; url?: string }
|
||||
active_version: string
|
||||
desired_state: 'enabled' | 'disabled'
|
||||
runtime: string
|
||||
granted_perms: PluginManifest['permissions']
|
||||
created_by: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
manifest?: PluginManifest
|
||||
versions: PluginVersion[]
|
||||
node?: PluginNodeStatus
|
||||
}
|
||||
|
||||
/** A package to inspect or install: an uploaded file or a URL. */
|
||||
export type PackageSource = { file: File } | { url: string }
|
||||
|
||||
function packageForm(file: File, digest?: string) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
if (digest) form.append('digest', digest)
|
||||
return form
|
||||
}
|
||||
|
||||
export function listInstalledPlugins() {
|
||||
return get<{ data: InstalledPlugin[] }>(BASE)
|
||||
}
|
||||
|
||||
export function getInstalledPlugin(id: string) {
|
||||
return get<{ data: InstalledPlugin }>(`${BASE}/${encodeURIComponent(id)}`)
|
||||
}
|
||||
|
||||
/** Reads a package and reports what installing it would do, changing nothing. */
|
||||
export function inspectPluginPackage(source: PackageSource) {
|
||||
if ('file' in source) {
|
||||
return postUpload(`${BASE}/inspect`, packageForm(source.file), undefined, { timeout: PACKAGE_TIMEOUT }) as Promise<{
|
||||
data: PluginPreview
|
||||
}>
|
||||
}
|
||||
return post<{ data: PluginPreview }>(`${BASE}/inspect`, { url: source.url }, { timeout: PACKAGE_TIMEOUT })
|
||||
}
|
||||
|
||||
/** Installs the package reviewed with inspect; digest pins it to that package. */
|
||||
export function installPluginPackage(source: PackageSource, digest: string) {
|
||||
if ('file' in source) {
|
||||
return postUpload(BASE, packageForm(source.file, digest), undefined, { timeout: PACKAGE_TIMEOUT }) as Promise<{
|
||||
data: InstalledPlugin
|
||||
}>
|
||||
}
|
||||
return post<{ data: InstalledPlugin }>(BASE, { url: source.url, digest }, { timeout: PACKAGE_TIMEOUT })
|
||||
}
|
||||
|
||||
export function setInstalledPluginEnabled(id: string, enabled: boolean) {
|
||||
return put<{ data: InstalledPlugin }>(`${BASE}/${encodeURIComponent(id)}/enabled`, { enabled })
|
||||
}
|
||||
|
||||
export function activatePluginVersion(id: string, version: string) {
|
||||
return put<{ data: InstalledPlugin }>(`${BASE}/${encodeURIComponent(id)}/active-version`, { version })
|
||||
}
|
||||
|
||||
export function uninstallPlugin(id: string) {
|
||||
return del(`${BASE}/${encodeURIComponent(id)}`)
|
||||
}
|
||||
|
||||
export function getPluginSystemConfig(id: string) {
|
||||
return get<{ data: PluginConfig }>(`${BASE}/${encodeURIComponent(id)}/config`)
|
||||
}
|
||||
|
||||
export function updatePluginSystemConfig(id: string, values: ConfigValue) {
|
||||
return put<{ data: PluginConfig }>(`${BASE}/${encodeURIComponent(id)}/config`, { values })
|
||||
}
|
||||
@@ -34,6 +34,6 @@ test('personal skill environment variables are visible to every member', () => {
|
||||
test('system administration settings stay explicitly system-admin-only', () => {
|
||||
assert.deepEqual(
|
||||
[...SYSTEM_ADMIN_SETTINGS_SECTIONS],
|
||||
['system-global', 'model-catalog', 'runtime-queues', 'platform-api-keys', 'system-audit-log'],
|
||||
['system-global', 'model-catalog', 'runtime-queues', 'platform-api-keys', 'system-audit-log', 'plugin-admin'],
|
||||
)
|
||||
})
|
||||
|
||||
@@ -49,4 +49,5 @@ export const SYSTEM_ADMIN_SETTINGS_SECTIONS = new Set([
|
||||
'runtime-queues',
|
||||
'platform-api-keys',
|
||||
'system-audit-log',
|
||||
'plugin-admin',
|
||||
])
|
||||
|
||||
@@ -26,6 +26,7 @@ import WebSearchSettings from '@/views/settings/WebSearchSettings.vue'
|
||||
import WeKnoraCloudSettings from '@/views/settings/WeKnoraCloudSettings.vue'
|
||||
import ModelCatalog from '@/views/system/ModelCatalog.vue'
|
||||
import PlatformAPIKeys from '@/views/system/PlatformAPIKeys.vue'
|
||||
import PluginManagement from '@/views/system/PluginManagement.vue'
|
||||
import RuntimeQueues from '@/views/system/RuntimeQueues.vue'
|
||||
import SystemAuditLog from '@/views/system/SystemAuditLog.vue'
|
||||
import SystemSettings from '@/views/system/SystemSettings.vue'
|
||||
@@ -56,6 +57,7 @@ const COMPONENTS: Record<string, Component> = {
|
||||
'runtime-queues': RuntimeQueues,
|
||||
'platform-api-keys': PlatformAPIKeys,
|
||||
'system-audit-log': SystemAuditLog,
|
||||
'plugin-admin': PluginManagement,
|
||||
system: SystemInfo,
|
||||
}
|
||||
|
||||
|
||||
@@ -100,6 +100,7 @@ const ROWS: Record<string, Row[]> = {
|
||||
{ key: 'runtime-queues', label: 'settings.taskQueue', icon: icon('queue') },
|
||||
{ key: 'platform-api-keys', label: 'platformApiKeys.title', icon: icon('secured') },
|
||||
{ key: 'system-audit-log', label: 'system.globalSettings.audit.tabLabel', icon: icon('history') },
|
||||
{ key: 'plugin-admin', label: 'pluginAdmin.title', icon: icon('app') },
|
||||
],
|
||||
platform: [
|
||||
{ key: 'system', label: 'settings.versionInfo', icon: icon('info-circle') },
|
||||
|
||||
@@ -1879,6 +1879,8 @@ export default {
|
||||
},
|
||||
},
|
||||
skills: {
|
||||
pluginSection: 'From enabled plugins',
|
||||
pluginSectionHint: 'Pick a skill a plugin provides to add it to the library; then install it into sandboxes like any other skill.',
|
||||
title: 'Skill Management',
|
||||
description: 'Skills live in the workspace catalog. Register them first, then install onto one or more sandboxes. An agent can only enable skills that are ready on its sandbox.',
|
||||
helpTooltip: 'A catalog skill does not have to be installed anywhere. Scripts only run after the skill is installed into the sandbox image the agent uses. Docker, Cube, and E2B images are not interchangeable — install once per sandbox.',
|
||||
@@ -2729,10 +2731,117 @@ export default {
|
||||
parseCurrentKnowledgeBaseFailed: 'Failed to parse current knowledge base'
|
||||
}
|
||||
},
|
||||
pluginAdmin: {
|
||||
title: 'Plugin management',
|
||||
description: 'Install and manage plugins for the whole platform. Every workspace sees an installed plugin, and each one enables it for itself; disabling a plugin here unloads it on every node.',
|
||||
installButton: 'Install plugin',
|
||||
empty: 'No plugins installed yet',
|
||||
loadFailed: 'Failed to load installed plugins',
|
||||
saveFailed: 'Failed to save',
|
||||
enabledToast: 'Enabled platform-wide',
|
||||
disabledToast: 'Disabled platform-wide',
|
||||
platformSwitch: 'Enable or disable platform-wide',
|
||||
publisher: 'Publisher',
|
||||
contributions: 'What it adds',
|
||||
permissions: 'Permissions and remote access',
|
||||
state: {
|
||||
running: 'Running',
|
||||
failed: 'Failed to load',
|
||||
disabled: 'Disabled',
|
||||
pending: 'Loading'
|
||||
},
|
||||
nodeState: {
|
||||
ready: 'Ready',
|
||||
starting: 'Starting',
|
||||
degraded: 'Degraded',
|
||||
stopped: 'Not loaded'
|
||||
},
|
||||
source: {
|
||||
upload: 'Upload',
|
||||
url: 'URL'
|
||||
},
|
||||
change: {
|
||||
install: 'New install',
|
||||
upgrade: 'Upgrade from v{from}',
|
||||
downgrade: 'Downgrade from v{from}',
|
||||
reinstall: 'Reinstall'
|
||||
},
|
||||
permission: {
|
||||
remote: 'Remote service',
|
||||
egress: 'Network egress',
|
||||
hostApi: 'WeKnora API',
|
||||
events: 'Events'
|
||||
},
|
||||
install: {
|
||||
title: 'Install plugin',
|
||||
description: 'Upload a .wkp package or give its download URL, review it, then install.',
|
||||
sourceSection: 'Package',
|
||||
mode: {
|
||||
upload: 'Upload file',
|
||||
url: 'From URL'
|
||||
},
|
||||
fileLabel: 'Package file',
|
||||
chooseFile: 'Choose file',
|
||||
noFile: 'No file chosen',
|
||||
fileHint: 'A .wkp file (a zip with plugin.yaml), up to 64 MB.',
|
||||
urlLabel: 'Download URL',
|
||||
urlHint: 'The server downloads this URL; private network addresses are refused.',
|
||||
reviewSection: 'Review before installing',
|
||||
noPermissions: 'This plugin asks for no extra permissions.',
|
||||
configNotice: 'This plugin needs configuration: platform settings are in its details after installing, and workspace admins fill in workspace settings in Plugins.',
|
||||
tenantNotice: 'Once installed the plugin is visible to every workspace but disabled until a workspace admin enables it.',
|
||||
digest: 'Package digest',
|
||||
inspect: 'Inspect package',
|
||||
confirm: {
|
||||
install: 'Install',
|
||||
upgrade: 'Upgrade',
|
||||
downgrade: 'Install older version',
|
||||
reinstall: 'Reinstall'
|
||||
},
|
||||
inspectFailed: 'Could not read the package',
|
||||
failed: 'Install failed',
|
||||
done: 'Installed {name}'
|
||||
},
|
||||
detail: {
|
||||
overview: 'Overview',
|
||||
runtime: 'Runtime',
|
||||
source: 'Source',
|
||||
homepage: 'Homepage',
|
||||
license: 'License',
|
||||
engines: 'Compatible versions',
|
||||
nodes: 'Nodes',
|
||||
noNodes: 'No node has the plugin loaded',
|
||||
versions: 'Versions',
|
||||
active: 'Active',
|
||||
activate: 'Switch to this version',
|
||||
rollback: 'Roll back to this version',
|
||||
activateConfirm: 'Make v{version} the active version? Every node reloads the plugin.',
|
||||
activated: 'Switched to v{version}',
|
||||
activateFailed: 'Failed to switch versions',
|
||||
systemConfig: 'Platform settings',
|
||||
systemConfigHint: 'Apply to every workspace, such as a service region or a platform-wide credential.',
|
||||
configLoadFailed: 'Failed to load platform settings',
|
||||
configSaved: 'Platform settings saved',
|
||||
configSaveFailed: 'Failed to save platform settings',
|
||||
danger: 'Uninstall',
|
||||
uninstallHint: 'Removes the plugin and every stored version. Workspaces\' switches and settings are kept and come back if you reinstall.',
|
||||
uninstall: 'Uninstall plugin',
|
||||
uninstallConfirm: 'Uninstall? Every node unloads the plugin right away.',
|
||||
uninstalled: 'Plugin uninstalled',
|
||||
uninstallFailed: 'Uninstall failed'
|
||||
}
|
||||
},
|
||||
pluginCenter: {
|
||||
installed: 'Installed',
|
||||
configure: 'Configure',
|
||||
configTitle: 'Configure {name}',
|
||||
configDescription: 'This workspace\'s settings for the plugin, such as its API key. Secrets are stored encrypted and never shown again.',
|
||||
configLoadFailed: 'Failed to load the plugin configuration',
|
||||
configSaved: 'Plugin configuration saved',
|
||||
configSaveFailed: 'Failed to save the plugin configuration',
|
||||
navGroup: 'Extensions',
|
||||
title: 'Plugins',
|
||||
description: 'Every plugin this deployment provides, builtins included. Enable or disable them for this workspace: a disabled plugin\'s integrations leave the type lists and cannot be created, while existing ones keep working.',
|
||||
description: 'Every plugin this deployment provides: builtins and the ones a system administrator installed. Enable or disable them for this workspace: a disabled plugin\'s integrations leave the type lists and cannot be created, while existing ones keep working. Installed plugins start disabled until a workspace admin enables them.',
|
||||
searchPlaceholder: 'Search plugins, IDs or integrations',
|
||||
allPoints: 'All',
|
||||
empty: 'No matching plugins',
|
||||
@@ -2750,7 +2859,9 @@ export default {
|
||||
imChannels: 'IM channels',
|
||||
webSearch: 'Web search',
|
||||
tools: 'Agent tools',
|
||||
parsers: 'Document parsing'
|
||||
parsers: 'Document parsing',
|
||||
skills: 'Skills',
|
||||
mcpServers: 'MCP servers'
|
||||
}
|
||||
},
|
||||
schemaForm: {
|
||||
@@ -5388,6 +5499,8 @@ export default {
|
||||
}
|
||||
},
|
||||
mcpSettings: {
|
||||
fromPlugin: 'Plugin',
|
||||
pluginNotConfigured: 'The plugin is not configured yet; fill in its settings in Plugins to use it',
|
||||
addUsageInstructions: "Add usage instructions",
|
||||
noUsageInstructions: "No usage instructions yet",
|
||||
title: 'MCP Services',
|
||||
|
||||
@@ -1879,6 +1879,8 @@ export default {
|
||||
},
|
||||
},
|
||||
skills: {
|
||||
pluginSection: '有効なプラグインから',
|
||||
pluginSectionHint: 'プラグインが提供するスキルを選んでライブラリに追加し、他のスキルと同様にサンドボックスへインストールできます。',
|
||||
title: 'スキル管理',
|
||||
description: 'スキルはワークスペースのカタログに登録されます。まず登録し、その後1つ以上のサンドボックスにインストールしてください。エージェントは、自身のサンドボックスで準備完了しているスキルのみ有効化できます。',
|
||||
helpTooltip: 'カタログのスキルは、どこかにインストールされている必要はありません。スクリプトは、エージェントが使用するサンドボックスイメージにスキルがインストールされて初めて実行されます。Docker、Cube、E2Bのイメージには互換性がないため、サンドボックスごとにインストールしてください。',
|
||||
@@ -2729,10 +2731,117 @@ export default {
|
||||
parseCurrentKnowledgeBaseFailed: '現在のナレッジベースの解析に失敗しました'
|
||||
}
|
||||
},
|
||||
pluginAdmin: {
|
||||
title: 'プラグイン管理',
|
||||
description: 'プラットフォーム全体のプラグインをインストール・管理します。インストールしたプラグインはすべてのワークスペースに表示され、各ワークスペースで個別に有効化します。ここで無効にすると全ノードでアンロードされます。',
|
||||
installButton: 'プラグインをインストール',
|
||||
empty: 'インストール済みのプラグインはありません',
|
||||
loadFailed: 'インストール済みプラグインの読み込みに失敗しました',
|
||||
saveFailed: '保存に失敗しました',
|
||||
enabledToast: 'プラットフォーム全体で有効にしました',
|
||||
disabledToast: 'プラットフォーム全体で無効にしました',
|
||||
platformSwitch: 'プラットフォーム全体で有効/無効',
|
||||
publisher: '発行者',
|
||||
contributions: '提供する機能',
|
||||
permissions: '権限と外部アクセス',
|
||||
state: {
|
||||
running: '稼働中',
|
||||
failed: '読み込み失敗',
|
||||
disabled: '無効',
|
||||
pending: '読み込み中'
|
||||
},
|
||||
nodeState: {
|
||||
ready: '準備完了',
|
||||
starting: '起動中',
|
||||
degraded: '異常',
|
||||
stopped: '未読み込み'
|
||||
},
|
||||
source: {
|
||||
upload: 'アップロード',
|
||||
url: 'URL'
|
||||
},
|
||||
change: {
|
||||
install: '新規インストール',
|
||||
upgrade: 'v{from} からアップグレード',
|
||||
downgrade: 'v{from} からダウングレード',
|
||||
reinstall: '再インストール'
|
||||
},
|
||||
permission: {
|
||||
remote: 'リモートサービス',
|
||||
egress: 'ネットワーク送信',
|
||||
hostApi: 'WeKnora API',
|
||||
events: 'イベント'
|
||||
},
|
||||
install: {
|
||||
title: 'プラグインをインストール',
|
||||
description: '.wkp パッケージをアップロードするかダウンロード URL を指定し、確認してからインストールします。',
|
||||
sourceSection: 'パッケージ',
|
||||
mode: {
|
||||
upload: 'ファイルをアップロード',
|
||||
url: 'URL から'
|
||||
},
|
||||
fileLabel: 'パッケージファイル',
|
||||
chooseFile: 'ファイルを選択',
|
||||
noFile: 'ファイル未選択',
|
||||
fileHint: '.wkp ファイル(plugin.yaml を含む zip)、最大 64 MB。',
|
||||
urlLabel: 'ダウンロード URL',
|
||||
urlHint: 'サーバーがこの URL からダウンロードします。プライベートネットワークのアドレスは拒否されます。',
|
||||
reviewSection: 'インストール前の確認',
|
||||
noPermissions: 'このプラグインは追加の権限を要求しません。',
|
||||
configNotice: 'このプラグインには設定が必要です。プラットフォーム設定はインストール後の詳細で、ワークスペース設定は各ワークスペース管理者がプラグインセンターで入力します。',
|
||||
tenantNotice: 'インストール後、プラグインはすべてのワークスペースに表示されますが、ワークスペース管理者が有効にするまで無効です。',
|
||||
digest: 'パッケージダイジェスト',
|
||||
inspect: 'パッケージを確認',
|
||||
confirm: {
|
||||
install: 'インストール',
|
||||
upgrade: 'アップグレード',
|
||||
downgrade: '旧バージョンをインストール',
|
||||
reinstall: '再インストール'
|
||||
},
|
||||
inspectFailed: 'パッケージを読み込めませんでした',
|
||||
failed: 'インストールに失敗しました',
|
||||
done: '{name} をインストールしました'
|
||||
},
|
||||
detail: {
|
||||
overview: '概要',
|
||||
runtime: 'ランタイム',
|
||||
source: '取得元',
|
||||
homepage: 'ホームページ',
|
||||
license: 'ライセンス',
|
||||
engines: '対応バージョン',
|
||||
nodes: 'ノード',
|
||||
noNodes: 'このプラグインを読み込んでいるノードはありません',
|
||||
versions: 'バージョン',
|
||||
active: '使用中',
|
||||
activate: 'このバージョンに切り替え',
|
||||
rollback: 'このバージョンにロールバック',
|
||||
activateConfirm: 'v{version} を使用中のバージョンにしますか?すべてのノードで再読み込みされます。',
|
||||
activated: 'v{version} に切り替えました',
|
||||
activateFailed: 'バージョンの切り替えに失敗しました',
|
||||
systemConfig: 'プラットフォーム設定',
|
||||
systemConfigHint: 'すべてのワークスペースに適用されます(サービスリージョンや共通の認証情報など)。',
|
||||
configLoadFailed: 'プラットフォーム設定の読み込みに失敗しました',
|
||||
configSaved: 'プラットフォーム設定を保存しました',
|
||||
configSaveFailed: 'プラットフォーム設定の保存に失敗しました',
|
||||
danger: 'アンインストール',
|
||||
uninstallHint: 'プラグインと保存済みの全バージョンを削除します。各ワークスペースのスイッチと設定は保持され、再インストールすると復元されます。',
|
||||
uninstall: 'プラグインをアンインストール',
|
||||
uninstallConfirm: 'アンインストールしますか?すべてのノードで直ちにアンロードされます。',
|
||||
uninstalled: 'プラグインをアンインストールしました',
|
||||
uninstallFailed: 'アンインストールに失敗しました'
|
||||
}
|
||||
},
|
||||
pluginCenter: {
|
||||
installed: 'インストール済み',
|
||||
configure: '設定',
|
||||
configTitle: '{name} の設定',
|
||||
configDescription: 'このワークスペースでプラグインを使うための設定(API キーなど)。シークレットは暗号化して保存され、再表示されません。',
|
||||
configLoadFailed: 'プラグイン設定の読み込みに失敗しました',
|
||||
configSaved: 'プラグイン設定を保存しました',
|
||||
configSaveFailed: 'プラグイン設定の保存に失敗しました',
|
||||
navGroup: '拡張',
|
||||
title: 'プラグイン',
|
||||
description: 'このデプロイが提供するすべてのプラグイン(組み込みを含む)を表示し、このワークスペースで有効/無効にします。無効にしたプラグインの連携は種類一覧から消えて新規作成できなくなりますが、既存のものは引き続き動作します。',
|
||||
description: 'このデプロイが提供するすべてのプラグイン(組み込みと、システム管理者がインストールしたもの)。このワークスペースで有効化・無効化できます。無効にしたプラグインの連携は種類一覧から外れ新規作成できなくなりますが、既存のものは引き続き動作します。インストールされたプラグインは、ワークスペース管理者が有効にするまで無効です。',
|
||||
searchPlaceholder: 'プラグイン名・ID・連携を検索',
|
||||
allPoints: 'すべて',
|
||||
empty: '一致するプラグインはありません',
|
||||
@@ -2750,7 +2859,9 @@ export default {
|
||||
imChannels: 'IM チャネル',
|
||||
webSearch: 'Web 検索',
|
||||
tools: 'エージェントツール',
|
||||
parsers: 'ドキュメント解析'
|
||||
parsers: 'ドキュメント解析',
|
||||
skills: 'スキル',
|
||||
mcpServers: 'MCP サーバー'
|
||||
}
|
||||
},
|
||||
schemaForm: {
|
||||
@@ -5388,6 +5499,8 @@ export default {
|
||||
}
|
||||
},
|
||||
mcpSettings: {
|
||||
fromPlugin: 'プラグイン',
|
||||
pluginNotConfigured: 'プラグインが未設定です。プラグインセンターで設定してから使用してください',
|
||||
addUsageInstructions: "使用方法を追加",
|
||||
noUsageInstructions: "使用方法は未入力です",
|
||||
title: 'MCPサービス',
|
||||
|
||||
@@ -2663,6 +2663,8 @@ export default {
|
||||
}
|
||||
},
|
||||
mcpSettings: {
|
||||
fromPlugin: '플러그인',
|
||||
pluginNotConfigured: '플러그인이 아직 설정되지 않았습니다. 플러그인 센터에서 설정한 뒤 사용하세요',
|
||||
addUsageInstructions: "사용 안내 추가",
|
||||
noUsageInstructions: "아직 사용 안내가 없습니다",
|
||||
title: 'MCP 서비스 관리',
|
||||
@@ -5249,10 +5251,117 @@ export default {
|
||||
belongsToOrg: '스페이스: ',
|
||||
noCompatibleKbForAgent: '현재 에이전트의 도구와 범위 내 지식베이스의 기능이 일치하지 않아 참조할 수 있는 지식베이스가 없습니다.'
|
||||
},
|
||||
pluginAdmin: {
|
||||
title: '플러그인 관리',
|
||||
description: '플랫폼 전체의 플러그인을 설치하고 관리합니다. 설치된 플러그인은 모든 워크스페이스에 보이며 각 워크스페이스가 직접 활성화합니다. 여기서 비활성화하면 모든 노드에서 언로드됩니다.',
|
||||
installButton: '플러그인 설치',
|
||||
empty: '설치된 플러그인이 없습니다',
|
||||
loadFailed: '설치된 플러그인을 불러오지 못했습니다',
|
||||
saveFailed: '저장하지 못했습니다',
|
||||
enabledToast: '플랫폼 전체에서 활성화했습니다',
|
||||
disabledToast: '플랫폼 전체에서 비활성화했습니다',
|
||||
platformSwitch: '플랫폼 전체 활성화 / 비활성화',
|
||||
publisher: '게시자',
|
||||
contributions: '제공 기능',
|
||||
permissions: '권한 및 외부 접근',
|
||||
state: {
|
||||
running: '실행 중',
|
||||
failed: '로드 실패',
|
||||
disabled: '비활성',
|
||||
pending: '로드 중'
|
||||
},
|
||||
nodeState: {
|
||||
ready: '준비됨',
|
||||
starting: '시작 중',
|
||||
degraded: '이상',
|
||||
stopped: '로드되지 않음'
|
||||
},
|
||||
source: {
|
||||
upload: '업로드',
|
||||
url: 'URL'
|
||||
},
|
||||
change: {
|
||||
install: '새로 설치',
|
||||
upgrade: 'v{from}에서 업그레이드',
|
||||
downgrade: 'v{from}에서 다운그레이드',
|
||||
reinstall: '재설치'
|
||||
},
|
||||
permission: {
|
||||
remote: '원격 서비스',
|
||||
egress: '네트워크 송신',
|
||||
hostApi: 'WeKnora API',
|
||||
events: '이벤트'
|
||||
},
|
||||
install: {
|
||||
title: '플러그인 설치',
|
||||
description: '.wkp 패키지를 업로드하거나 다운로드 URL을 입력하고, 검토한 뒤 설치합니다.',
|
||||
sourceSection: '패키지',
|
||||
mode: {
|
||||
upload: '파일 업로드',
|
||||
url: 'URL에서'
|
||||
},
|
||||
fileLabel: '패키지 파일',
|
||||
chooseFile: '파일 선택',
|
||||
noFile: '선택된 파일 없음',
|
||||
fileHint: '.wkp 파일(plugin.yaml이 포함된 zip), 최대 64 MB.',
|
||||
urlLabel: '다운로드 URL',
|
||||
urlHint: '서버가 이 URL에서 다운로드합니다. 사설 네트워크 주소는 거부됩니다.',
|
||||
reviewSection: '설치 전 검토',
|
||||
noPermissions: '이 플러그인은 추가 권한을 요청하지 않습니다.',
|
||||
configNotice: '이 플러그인은 설정이 필요합니다. 플랫폼 설정은 설치 후 상세 화면에서, 워크스페이스 설정은 각 워크스페이스 관리자가 플러그인 센터에서 입력합니다.',
|
||||
tenantNotice: '설치 후 플러그인은 모든 워크스페이스에 보이지만 워크스페이스 관리자가 활성화하기 전까지 비활성 상태입니다.',
|
||||
digest: '패키지 다이제스트',
|
||||
inspect: '패키지 검사',
|
||||
confirm: {
|
||||
install: '설치',
|
||||
upgrade: '업그레이드',
|
||||
downgrade: '이전 버전 설치',
|
||||
reinstall: '재설치'
|
||||
},
|
||||
inspectFailed: '패키지를 읽을 수 없습니다',
|
||||
failed: '설치에 실패했습니다',
|
||||
done: '{name}을(를) 설치했습니다'
|
||||
},
|
||||
detail: {
|
||||
overview: '개요',
|
||||
runtime: '런타임',
|
||||
source: '출처',
|
||||
homepage: '홈페이지',
|
||||
license: '라이선스',
|
||||
engines: '호환 버전',
|
||||
nodes: '노드',
|
||||
noNodes: '이 플러그인을 로드한 노드가 없습니다',
|
||||
versions: '버전',
|
||||
active: '사용 중',
|
||||
activate: '이 버전으로 전환',
|
||||
rollback: '이 버전으로 롤백',
|
||||
activateConfirm: 'v{version}을(를) 사용 중인 버전으로 할까요? 모든 노드가 플러그인을 다시 로드합니다.',
|
||||
activated: 'v{version}(으)로 전환했습니다',
|
||||
activateFailed: '버전을 전환하지 못했습니다',
|
||||
systemConfig: '플랫폼 설정',
|
||||
systemConfigHint: '모든 워크스페이스에 적용됩니다(예: 서비스 리전, 플랫폼 공용 자격 증명).',
|
||||
configLoadFailed: '플랫폼 설정을 불러오지 못했습니다',
|
||||
configSaved: '플랫폼 설정을 저장했습니다',
|
||||
configSaveFailed: '플랫폼 설정을 저장하지 못했습니다',
|
||||
danger: '제거',
|
||||
uninstallHint: '플러그인과 저장된 모든 버전을 삭제합니다. 워크스페이스의 스위치와 설정은 유지되며 재설치하면 복원됩니다.',
|
||||
uninstall: '플러그인 제거',
|
||||
uninstallConfirm: '제거할까요? 모든 노드에서 즉시 언로드됩니다.',
|
||||
uninstalled: '플러그인을 제거했습니다',
|
||||
uninstallFailed: '제거하지 못했습니다'
|
||||
}
|
||||
},
|
||||
pluginCenter: {
|
||||
installed: '설치됨',
|
||||
configure: '설정',
|
||||
configTitle: '{name} 설정',
|
||||
configDescription: '이 워크스페이스에서 플러그인을 사용하기 위한 설정(예: API 키). 비밀 값은 암호화되어 저장되며 다시 표시되지 않습니다.',
|
||||
configLoadFailed: '플러그인 설정을 불러오지 못했습니다',
|
||||
configSaved: '플러그인 설정을 저장했습니다',
|
||||
configSaveFailed: '플러그인 설정을 저장하지 못했습니다',
|
||||
navGroup: '확장',
|
||||
title: '플러그인',
|
||||
description: '이 배포가 제공하는 모든 플러그인(내장 포함)을 보고 이 워크스페이스에서 켜거나 끕니다. 끈 플러그인의 연동은 유형 목록에서 사라지고 새로 만들 수 없지만 기존 연동은 계속 동작합니다.',
|
||||
description: '이 배포에서 제공하는 모든 플러그인(내장 플러그인과 시스템 관리자가 설치한 플러그인). 이 워크스페이스에서 활성화하거나 비활성화할 수 있습니다. 비활성화된 플러그인의 연동은 유형 목록에서 빠지고 새로 만들 수 없지만 기존 항목은 계속 동작합니다. 설치된 플러그인은 워크스페이스 관리자가 활성화하기 전까지 비활성 상태입니다.',
|
||||
searchPlaceholder: '플러그인 이름, ID 또는 연동 검색',
|
||||
allPoints: '전체',
|
||||
empty: '일치하는 플러그인이 없습니다',
|
||||
@@ -5270,7 +5379,9 @@ export default {
|
||||
imChannels: 'IM 채널',
|
||||
webSearch: '웹 검색',
|
||||
tools: '에이전트 도구',
|
||||
parsers: '문서 파싱'
|
||||
parsers: '문서 파싱',
|
||||
skills: '스킬',
|
||||
mcpServers: 'MCP 서버'
|
||||
}
|
||||
},
|
||||
schemaForm: {
|
||||
@@ -6280,6 +6391,8 @@ export default {
|
||||
},
|
||||
},
|
||||
skills: {
|
||||
pluginSection: '활성화된 플러그인에서',
|
||||
pluginSectionHint: '플러그인이 제공하는 스킬을 선택해 라이브러리에 추가한 뒤, 다른 스킬처럼 샌드박스에 설치할 수 있습니다.',
|
||||
title: '스킬 관리',
|
||||
description: '스킬은 워크스페이스 카탈로그에 속합니다. 먼저 등록한 뒤 하나 이상의 샌드박스에 설치할 수 있습니다. 에이전트는 현재 샌드박스에서 준비된 스킬만 사용할 수 있습니다.',
|
||||
helpTooltip: '카탈로그 스킬은 아무 샌드박스에도 설치하지 않아도 됩니다. 스크립트를 실행하려면 에이전트가 쓰는 샌드박스 이미지에 설치해야 합니다. Docker, Cube, E2B 이미지는 호환되지 않으므로 샌드박스마다 따로 설치합니다.',
|
||||
|
||||
@@ -2663,6 +2663,8 @@ export default {
|
||||
}
|
||||
},
|
||||
mcpSettings: {
|
||||
fromPlugin: 'Плагин',
|
||||
pluginNotConfigured: 'Плагин ещё не настроен; заполните его настройки в разделе «Плагины»',
|
||||
addUsageInstructions: "Добавить инструкции",
|
||||
noUsageInstructions: "Инструкции пока не добавлены",
|
||||
title: 'Сервисы MCP',
|
||||
@@ -5249,10 +5251,117 @@ export default {
|
||||
belongsToOrg: 'Пространство: ',
|
||||
noCompatibleKbForAgent: 'Инструменты текущего агента не соответствуют возможностям ни одной базы знаний в области видимости — нечего упомянуть.'
|
||||
},
|
||||
pluginAdmin: {
|
||||
title: 'Управление плагинами',
|
||||
description: 'Установка и управление плагинами всей платформы. Установленный плагин виден всем рабочим пространствам, и каждое включает его самостоятельно; отключение здесь выгружает плагин на всех узлах.',
|
||||
installButton: 'Установить плагин',
|
||||
empty: 'Плагины ещё не установлены',
|
||||
loadFailed: 'Не удалось загрузить установленные плагины',
|
||||
saveFailed: 'Не удалось сохранить',
|
||||
enabledToast: 'Включён для всей платформы',
|
||||
disabledToast: 'Отключён для всей платформы',
|
||||
platformSwitch: 'Включить или отключить для всей платформы',
|
||||
publisher: 'Издатель',
|
||||
contributions: 'Что добавляет',
|
||||
permissions: 'Разрешения и внешний доступ',
|
||||
state: {
|
||||
running: 'Работает',
|
||||
failed: 'Ошибка загрузки',
|
||||
disabled: 'Отключён',
|
||||
pending: 'Загружается'
|
||||
},
|
||||
nodeState: {
|
||||
ready: 'Готов',
|
||||
starting: 'Запускается',
|
||||
degraded: 'Сбой',
|
||||
stopped: 'Не загружен'
|
||||
},
|
||||
source: {
|
||||
upload: 'Загрузка',
|
||||
url: 'URL'
|
||||
},
|
||||
change: {
|
||||
install: 'Новая установка',
|
||||
upgrade: 'Обновление с v{from}',
|
||||
downgrade: 'Откат с v{from}',
|
||||
reinstall: 'Переустановка'
|
||||
},
|
||||
permission: {
|
||||
remote: 'Удалённый сервис',
|
||||
egress: 'Исходящая сеть',
|
||||
hostApi: 'API WeKnora',
|
||||
events: 'События'
|
||||
},
|
||||
install: {
|
||||
title: 'Установка плагина',
|
||||
description: 'Загрузите пакет .wkp или укажите URL для скачивания, проверьте и установите.',
|
||||
sourceSection: 'Пакет',
|
||||
mode: {
|
||||
upload: 'Загрузить файл',
|
||||
url: 'По URL'
|
||||
},
|
||||
fileLabel: 'Файл пакета',
|
||||
chooseFile: 'Выбрать файл',
|
||||
noFile: 'Файл не выбран',
|
||||
fileHint: 'Файл .wkp (zip с plugin.yaml), до 64 МБ.',
|
||||
urlLabel: 'URL для скачивания',
|
||||
urlHint: 'Сервер скачает этот URL; адреса частных сетей запрещены.',
|
||||
reviewSection: 'Проверка перед установкой',
|
||||
noPermissions: 'Плагин не запрашивает дополнительных разрешений.',
|
||||
configNotice: 'Плагину нужны настройки: настройки платформы — в его карточке после установки, настройки рабочего пространства заполняют его администраторы в разделе «Плагины».',
|
||||
tenantNotice: 'После установки плагин виден всем рабочим пространствам, но выключен, пока администратор пространства его не включит.',
|
||||
digest: 'Дайджест пакета',
|
||||
inspect: 'Проверить пакет',
|
||||
confirm: {
|
||||
install: 'Установить',
|
||||
upgrade: 'Обновить',
|
||||
downgrade: 'Установить старую версию',
|
||||
reinstall: 'Переустановить'
|
||||
},
|
||||
inspectFailed: 'Не удалось прочитать пакет',
|
||||
failed: 'Не удалось установить',
|
||||
done: 'Установлен {name}'
|
||||
},
|
||||
detail: {
|
||||
overview: 'Обзор',
|
||||
runtime: 'Среда выполнения',
|
||||
source: 'Источник',
|
||||
homepage: 'Сайт',
|
||||
license: 'Лицензия',
|
||||
engines: 'Совместимые версии',
|
||||
nodes: 'Узлы',
|
||||
noNodes: 'Ни один узел не загрузил плагин',
|
||||
versions: 'Версии',
|
||||
active: 'Активная',
|
||||
activate: 'Переключиться на эту версию',
|
||||
rollback: 'Откатиться к этой версии',
|
||||
activateConfirm: 'Сделать v{version} активной версией? Все узлы перезагрузят плагин.',
|
||||
activated: 'Переключено на v{version}',
|
||||
activateFailed: 'Не удалось переключить версию',
|
||||
systemConfig: 'Настройки платформы',
|
||||
systemConfigHint: 'Действуют во всех рабочих пространствах, например регион сервиса или общий ключ платформы.',
|
||||
configLoadFailed: 'Не удалось загрузить настройки платформы',
|
||||
configSaved: 'Настройки платформы сохранены',
|
||||
configSaveFailed: 'Не удалось сохранить настройки платформы',
|
||||
danger: 'Удаление',
|
||||
uninstallHint: 'Удаляет плагин и все сохранённые версии. Переключатели и настройки рабочих пространств сохраняются и вернутся при повторной установке.',
|
||||
uninstall: 'Удалить плагин',
|
||||
uninstallConfirm: 'Удалить? Все узлы сразу выгрузят плагин.',
|
||||
uninstalled: 'Плагин удалён',
|
||||
uninstallFailed: 'Не удалось удалить'
|
||||
}
|
||||
},
|
||||
pluginCenter: {
|
||||
installed: 'Установлен',
|
||||
configure: 'Настроить',
|
||||
configTitle: 'Настройки: {name}',
|
||||
configDescription: 'Настройки плагина для этого рабочего пространства, например API-ключ. Секреты хранятся в зашифрованном виде и больше не показываются.',
|
||||
configLoadFailed: 'Не удалось загрузить настройки плагина',
|
||||
configSaved: 'Настройки плагина сохранены',
|
||||
configSaveFailed: 'Не удалось сохранить настройки плагина',
|
||||
navGroup: 'Расширения',
|
||||
title: 'Плагины',
|
||||
description: 'Все плагины этого развертывания, включая встроенные. Включайте и отключайте их для рабочего пространства: интеграции отключенного плагина пропадают из списков типов и не создаются, а существующие продолжают работать.',
|
||||
description: 'Все плагины этого развёртывания: встроенные и установленные системным администратором. Включайте и отключайте их для этого рабочего пространства: интеграции отключённого плагина исчезают из списков типов и не создаются, а существующие продолжают работать. Установленные плагины выключены, пока администратор рабочего пространства их не включит.',
|
||||
searchPlaceholder: 'Поиск по названию, ID или интеграции',
|
||||
allPoints: 'Все',
|
||||
empty: 'Подходящих плагинов нет',
|
||||
@@ -5270,7 +5379,9 @@ export default {
|
||||
imChannels: 'IM-каналы',
|
||||
webSearch: 'Веб-поиск',
|
||||
tools: 'Инструменты агента',
|
||||
parsers: 'Разбор документов'
|
||||
parsers: 'Разбор документов',
|
||||
skills: 'Навыки',
|
||||
mcpServers: 'MCP-серверы'
|
||||
}
|
||||
},
|
||||
schemaForm: {
|
||||
@@ -6280,6 +6391,8 @@ export default {
|
||||
},
|
||||
},
|
||||
skills: {
|
||||
pluginSection: 'Из включённых плагинов',
|
||||
pluginSectionHint: 'Выберите навык, который предоставляет плагин, чтобы добавить его в библиотеку, а затем установите в песочницы, как любой другой навык.',
|
||||
title: 'Управление навыками',
|
||||
description: 'Навыки живут в каталоге пространства. Их можно только зарегистрировать или установить в одну или несколько песочниц. Агент включает только навыки, которые готовы в выбранной песочнице.',
|
||||
helpTooltip: 'Навык из каталога можно не устанавливать никуда. Скрипты запускаются только после установки в образ песочницы агента. Образы Docker, Cube и E2B несовместимы — устанавливайте отдельно в каждую песочницу.',
|
||||
|
||||
@@ -2665,6 +2665,8 @@ export default {
|
||||
}
|
||||
},
|
||||
mcpSettings: {
|
||||
fromPlugin: '插件',
|
||||
pluginNotConfigured: '插件尚未配置,请在插件中心填写配置后使用',
|
||||
addUsageInstructions: "添加使用说明",
|
||||
noUsageInstructions: "尚未填写使用说明",
|
||||
title: 'MCP 服务管理',
|
||||
@@ -5251,10 +5253,117 @@ export default {
|
||||
belongsToOrg: '所属空间:',
|
||||
noCompatibleKbForAgent: '当前智能体的工具与作用域内知识库的能力不匹配,暂无可引用的知识库。'
|
||||
},
|
||||
pluginAdmin: {
|
||||
title: '插件管理',
|
||||
description: '安装与管理本平台的插件。安装后所有空间都能看到,但每个空间需自行启用;停用会在所有节点卸载该插件。',
|
||||
installButton: '安装插件',
|
||||
empty: '还没有安装任何插件',
|
||||
loadFailed: '加载已安装插件失败',
|
||||
saveFailed: '保存失败',
|
||||
enabledToast: '已在全平台启用',
|
||||
disabledToast: '已在全平台停用',
|
||||
platformSwitch: '全平台启用 / 停用',
|
||||
publisher: '发布者',
|
||||
contributions: '提供的能力',
|
||||
permissions: '权限与外部访问',
|
||||
state: {
|
||||
running: '运行中',
|
||||
failed: '加载失败',
|
||||
disabled: '已停用',
|
||||
pending: '加载中'
|
||||
},
|
||||
nodeState: {
|
||||
ready: '就绪',
|
||||
starting: '启动中',
|
||||
degraded: '异常',
|
||||
stopped: '未加载'
|
||||
},
|
||||
source: {
|
||||
upload: '上传',
|
||||
url: 'URL'
|
||||
},
|
||||
change: {
|
||||
install: '新安装',
|
||||
upgrade: '从 v{from} 升级',
|
||||
downgrade: '从 v{from} 降级',
|
||||
reinstall: '重新安装'
|
||||
},
|
||||
permission: {
|
||||
remote: '访问远程服务',
|
||||
egress: '网络出口',
|
||||
hostApi: '调用 WeKnora API',
|
||||
events: '订阅事件'
|
||||
},
|
||||
install: {
|
||||
title: '安装插件',
|
||||
description: '上传 .wkp 插件包或填写下载地址,审阅后安装。',
|
||||
sourceSection: '插件包',
|
||||
mode: {
|
||||
upload: '上传文件',
|
||||
url: '从 URL 下载'
|
||||
},
|
||||
fileLabel: '插件包文件',
|
||||
chooseFile: '选择文件',
|
||||
noFile: '未选择文件',
|
||||
fileHint: '.wkp 文件(包含 plugin.yaml 的 zip),最大 64 MB。',
|
||||
urlLabel: '下载地址',
|
||||
urlHint: '服务端会下载该地址;不允许内网地址。',
|
||||
reviewSection: '安装前审阅',
|
||||
noPermissions: '该插件不申请任何额外权限。',
|
||||
configNotice: '该插件需要配置:平台配置在安装后的详情中填写,空间配置由各空间管理员在插件中心填写。',
|
||||
tenantNotice: '安装后插件对所有空间可见,但默认停用,由各空间管理员自行启用。',
|
||||
digest: '包摘要',
|
||||
inspect: '检查插件包',
|
||||
confirm: {
|
||||
install: '安装',
|
||||
upgrade: '升级',
|
||||
downgrade: '降级安装',
|
||||
reinstall: '重新安装'
|
||||
},
|
||||
inspectFailed: '无法读取插件包',
|
||||
failed: '安装失败',
|
||||
done: '已安装 {name}'
|
||||
},
|
||||
detail: {
|
||||
overview: '概览',
|
||||
runtime: '运行方式',
|
||||
source: '来源',
|
||||
homepage: '主页',
|
||||
license: '许可证',
|
||||
engines: '兼容版本',
|
||||
nodes: '节点状态',
|
||||
noNodes: '当前没有节点加载该插件',
|
||||
versions: '版本',
|
||||
active: '当前版本',
|
||||
activate: '切换到此版本',
|
||||
rollback: '回滚到此版本',
|
||||
activateConfirm: '将当前版本切换为 v{version}?所有节点会重新加载。',
|
||||
activated: '已切换到 v{version}',
|
||||
activateFailed: '切换版本失败',
|
||||
systemConfig: '平台配置',
|
||||
systemConfigHint: '对所有空间生效,例如服务区域或平台统一的凭证。',
|
||||
configLoadFailed: '加载平台配置失败',
|
||||
configSaved: '平台配置已保存',
|
||||
configSaveFailed: '保存平台配置失败',
|
||||
danger: '卸载',
|
||||
uninstallHint: '删除插件及其全部版本;各空间的开关和配置会保留,重新安装后恢复。',
|
||||
uninstall: '卸载插件',
|
||||
uninstallConfirm: '确定卸载?所有节点会立即卸载该插件。',
|
||||
uninstalled: '插件已卸载',
|
||||
uninstallFailed: '卸载失败'
|
||||
}
|
||||
},
|
||||
pluginCenter: {
|
||||
installed: '已安装',
|
||||
configure: '配置',
|
||||
configTitle: '配置 {name}',
|
||||
configDescription: '本空间使用该插件时的配置,例如 API 密钥。密钥加密保存,不会再次显示。',
|
||||
configLoadFailed: '加载插件配置失败',
|
||||
configSaved: '插件配置已保存',
|
||||
configSaveFailed: '保存插件配置失败',
|
||||
navGroup: '扩展',
|
||||
title: '插件',
|
||||
description: '查看当前部署提供的全部插件(含内置插件),并为本空间启用或停用。停用后,该插件的集成不再出现在类型列表中、不能新建,已有实例不受影响。',
|
||||
description: '本部署提供的所有插件,含内置插件与平台管理员安装的插件。在这里为当前空间启用或停用:停用后其集成不再出现在类型列表中、不能新建,已有的照常工作。安装的插件默认停用,需空间管理员启用。',
|
||||
searchPlaceholder: '搜索插件名称、ID 或集成',
|
||||
allPoints: '全部',
|
||||
empty: '没有匹配的插件',
|
||||
@@ -5272,7 +5381,9 @@ export default {
|
||||
imChannels: 'IM 渠道',
|
||||
webSearch: '联网搜索',
|
||||
tools: 'Agent 工具',
|
||||
parsers: '文档解析'
|
||||
parsers: '文档解析',
|
||||
skills: '技能',
|
||||
mcpServers: 'MCP 服务'
|
||||
}
|
||||
},
|
||||
schemaForm: {
|
||||
@@ -6282,6 +6393,8 @@ export default {
|
||||
},
|
||||
},
|
||||
skills: {
|
||||
pluginSection: '来自已启用的插件',
|
||||
pluginSectionHint: '选择一个插件提供的技能加入技能库,之后可像其他技能一样安装到沙箱。',
|
||||
title: '技能管理',
|
||||
description: '技能属于空间目录,可以只登记,也可以装到一份或多份沙箱。智能体只能启用当前沙箱里已就绪的技能。',
|
||||
helpTooltip: '目录里的技能可以不装任何沙箱。脚本要跑起来,必须装进智能体所用的那份沙箱镜像。Docker、Cube、E2B 互不通用,装到几份就要装几次。',
|
||||
|
||||
@@ -25,7 +25,10 @@
|
||||
<t-icon name="tools" size="14px" />
|
||||
</div>
|
||||
<h3 class="service-card__title" :title="service.name">{{ service.name }}</h3>
|
||||
<span v-if="service.is_builtin" class="service-card__builtin">{{ $t('mcpSettings.builtin') }}</span>
|
||||
<span v-if="service.plugin_id" class="service-card__builtin" :title="service.plugin_id">
|
||||
{{ $t('mcpSettings.fromPlugin') }}
|
||||
</span>
|
||||
<span v-else-if="service.is_builtin" class="service-card__builtin">{{ $t('mcpSettings.builtin') }}</span>
|
||||
<div v-if="authStore.hasRole('admin')" class="service-card__actions">
|
||||
<button type="button" class="service-card__icon-btn" :title="$t('common.edit')"
|
||||
:aria-label="`${service.name} · ${$t('common.edit')}`" @click="handleEdit(service)">
|
||||
@@ -38,6 +41,10 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="service.plugin_error" class="service-card__plugin-error" :title="service.plugin_error">
|
||||
<t-icon name="error-circle" size="14px" />
|
||||
{{ $t('mcpSettings.pluginNotConfigured') }}
|
||||
</p>
|
||||
<p v-if="serviceUsage(service)" class="service-card__desc" :title="serviceUsage(service)">
|
||||
{{ serviceUsage(service).replace(/\s+/g, ' ') }}
|
||||
</p>
|
||||
@@ -66,7 +73,7 @@
|
||||
<span class="service-card__type">{{ getTransportTypeLabel(service.transport_type) }}</span>
|
||||
</div>
|
||||
<component :is="authStore.hasRole('admin') && !service.is_builtin ? 'button' : 'span'"
|
||||
class="service-card__status" :class="{ 'is-enabled': service.enabled || service.is_builtin }"
|
||||
class="service-card__status" :class="{ 'is-enabled': isOn(service) }"
|
||||
:type="authStore.hasRole('admin') && !service.is_builtin ? 'button' : undefined"
|
||||
:role="authStore.hasRole('admin') && !service.is_builtin ? 'switch' : undefined"
|
||||
:aria-checked="authStore.hasRole('admin') && !service.is_builtin ? service.enabled : undefined"
|
||||
@@ -76,7 +83,7 @@
|
||||
@click="handleToggleEnabled(service)">
|
||||
<t-loading v-if="togglingIds.has(service.id)" size="12px" />
|
||||
<span v-else class="service-card__status-dot" aria-hidden="true" />
|
||||
{{ $t(service.enabled || service.is_builtin ? 'common.on' : 'common.off') }}
|
||||
{{ $t(isOn(service) ? 'common.on' : 'common.off') }}
|
||||
</component>
|
||||
</div>
|
||||
</div>
|
||||
@@ -136,6 +143,9 @@ const currentService = ref<MCPService | null>(null)
|
||||
const dialogInitialStep = ref<0 | 1>(0)
|
||||
const togglingIds = ref(new Set<string>())
|
||||
const serviceUsage = (service: MCPService) => service.usage_instructions?.trim() || service.description?.trim() || ''
|
||||
// Builtin services are always on; a plugin service is on once the workspace
|
||||
// has configured the plugin.
|
||||
const isOn = (service: MCPService) => service.enabled || (!!service.is_builtin && !service.plugin_id)
|
||||
|
||||
// Load MCP services
|
||||
const loadServices = async () => {
|
||||
@@ -390,6 +400,15 @@ defineExpose({ openAdd: handleAdd })
|
||||
color: var(--td-text-color-placeholder);
|
||||
}
|
||||
|
||||
.service-card__plugin-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
font-size: var(--app-text-sm);
|
||||
color: var(--td-warning-color);
|
||||
}
|
||||
|
||||
.service-card__type {
|
||||
flex-shrink: 0;
|
||||
font-size: var(--app-text-xs);
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
<div class="plugin-card__title">
|
||||
<span class="plugin-card__name">{{ nameOf(p) }}</span>
|
||||
<t-tag v-if="p.manifest.builtin" size="small" variant="light">{{ t('pluginCenter.builtin') }}</t-tag>
|
||||
<t-tag v-else size="small" variant="light" theme="warning">{{ t('pluginCenter.installed') }}</t-tag>
|
||||
<t-tag v-if="p.manifest.required" size="small" variant="light" theme="primary">
|
||||
{{ t('pluginCenter.required') }}
|
||||
</t-tag>
|
||||
@@ -49,16 +50,42 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<t-tooltip :content="switchTooltip(p)" :disabled="!switchTooltip(p)">
|
||||
<t-switch
|
||||
:model-value="p.enabled"
|
||||
:disabled="!canManage || p.manifest.required || pending.has(p.manifest.id)"
|
||||
:loading="pending.has(p.manifest.id)"
|
||||
@update:model-value="(v: boolean) => toggle(p, v)"
|
||||
/>
|
||||
</t-tooltip>
|
||||
<div class="plugin-card__actions">
|
||||
<t-tooltip :content="switchTooltip(p)" :disabled="!switchTooltip(p)">
|
||||
<t-switch
|
||||
:model-value="p.enabled"
|
||||
:disabled="!canManage || p.manifest.required || pending.has(p.manifest.id)"
|
||||
:loading="pending.has(p.manifest.id)"
|
||||
@update:model-value="(v: boolean) => toggle(p, v)"
|
||||
/>
|
||||
</t-tooltip>
|
||||
<t-button
|
||||
v-if="canManage && hasTenantConfig(p.manifest)"
|
||||
size="small"
|
||||
variant="text"
|
||||
theme="primary"
|
||||
@click="openConfig(p)"
|
||||
>
|
||||
{{ t('pluginCenter.configure') }}
|
||||
</t-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingDrawer
|
||||
v-model:visible="configOpen"
|
||||
:title="t('pluginCenter.configTitle', { name: configPlugin ? nameOf(configPlugin) : '' })"
|
||||
:description="t('pluginCenter.configDescription')"
|
||||
icon="setting"
|
||||
:confirm-loading="configSaving"
|
||||
:confirm-disabled="!configSchema || configSaving"
|
||||
@confirm="saveConfig"
|
||||
>
|
||||
<div v-if="configLoading" class="plugin-center__state"><t-loading size="small" /></div>
|
||||
<section v-else-if="configSchema" class="setting-drawer__section">
|
||||
<SchemaForm v-model="configValues" :schema="configSchema" :errors="configErrors" />
|
||||
</section>
|
||||
</SettingDrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -67,11 +94,21 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { MessagePlugin } from 'tdesign-vue-next'
|
||||
|
||||
import { listPlugins, setPluginEnabled, type ExtensionPoint, type TenantPlugin } from '@/api/plugin'
|
||||
import {
|
||||
getPluginConfig,
|
||||
listPlugins,
|
||||
setPluginEnabled,
|
||||
updatePluginConfig,
|
||||
type ExtensionPoint,
|
||||
type TenantPlugin,
|
||||
} from '@/api/plugin'
|
||||
import SchemaForm from '@/components/schema-form/SchemaForm.vue'
|
||||
import { validateConfig, type ConfigSchema, type ConfigValue, type FieldError } from '@/components/schema-form/schema'
|
||||
import SettingDrawer from '@/components/settings/SettingDrawer.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { localizedText } from '@/utils/localizedText'
|
||||
|
||||
import { EXTENSION_POINTS, contributionSummary, filterPlugins } from './pluginCenterState'
|
||||
import { EXTENSION_POINTS, contributionSummary, filterPlugins, hasTenantConfig } from './pluginCenterState'
|
||||
|
||||
// Lists the plugins this deployment knows — builtins included — and lets an
|
||||
// admin turn them off for the workspace. A disabled plugin's integrations drop
|
||||
@@ -130,6 +167,53 @@ async function toggle(p: TenantPlugin, enabled: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
// Workspace configuration of an installed plugin, such as its API key.
|
||||
const configOpen = ref(false)
|
||||
const configPlugin = ref<TenantPlugin | null>(null)
|
||||
const configSchema = ref<ConfigSchema | null>(null)
|
||||
const configValues = ref<ConfigValue>({})
|
||||
const configErrors = ref<FieldError[]>([])
|
||||
const configLoading = ref(false)
|
||||
const configSaving = ref(false)
|
||||
|
||||
async function openConfig(p: TenantPlugin) {
|
||||
configPlugin.value = p
|
||||
configSchema.value = null
|
||||
configValues.value = {}
|
||||
configErrors.value = []
|
||||
configOpen.value = true
|
||||
configLoading.value = true
|
||||
try {
|
||||
const res = await getPluginConfig(p.manifest.id)
|
||||
configSchema.value = res.data.schema
|
||||
configValues.value = res.data.values ?? {}
|
||||
} catch (e: any) {
|
||||
MessagePlugin.error(e?.message || t('pluginCenter.configLoadFailed'))
|
||||
} finally {
|
||||
configLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
const p = configPlugin.value
|
||||
if (!p || !configSchema.value) return
|
||||
configErrors.value = validateConfig(configSchema.value, configValues.value)
|
||||
if (configErrors.value.length) return
|
||||
configSaving.value = true
|
||||
try {
|
||||
const res = await updatePluginConfig(p.manifest.id, configValues.value)
|
||||
configValues.value = res.data.values ?? {}
|
||||
MessagePlugin.success(t('pluginCenter.configSaved'))
|
||||
configOpen.value = false
|
||||
} catch (e: any) {
|
||||
const details = e?.error?.details
|
||||
if (Array.isArray(details)) configErrors.value = details
|
||||
MessagePlugin.error(e?.message || t('pluginCenter.configSaveFailed'))
|
||||
} finally {
|
||||
configSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
@@ -256,6 +340,14 @@ onMounted(load)
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.plugin-card__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.plugin-card__contrib {
|
||||
font-size: var(--app-text-xs);
|
||||
color: var(--td-text-color-secondary);
|
||||
|
||||
@@ -73,6 +73,7 @@ import {
|
||||
type SettingsAccessContext,
|
||||
type SettingsSection,
|
||||
} from '@/extensions/settingsSections'
|
||||
import { SYSTEM_ADMIN_SETTINGS_SECTIONS } from '@/config/settingsAccess'
|
||||
import { isToolboxSection, toolboxLocation } from '@/config/toolbox'
|
||||
import {
|
||||
buildSettingsRouteQuery,
|
||||
@@ -195,7 +196,7 @@ const handleClose = () => {
|
||||
// 如果当前路由是设置页,返回上一页
|
||||
if (route.path === '/platform/settings') {
|
||||
const sec = route.query.section
|
||||
if (sec === 'model-catalog' || sec === 'system-global' || sec === 'runtime-queues' || sec === 'platform-api-keys' || sec === 'system-audit-log') {
|
||||
if (typeof sec === 'string' && SYSTEM_ADMIN_SETTINGS_SECTIONS.has(sec)) {
|
||||
router.push('/platform/knowledge-bases')
|
||||
} else {
|
||||
router.back()
|
||||
|
||||
@@ -188,6 +188,22 @@
|
||||
</article>
|
||||
|
||||
<template v-if="addStep === 0">
|
||||
<section v-if="pluginSkills.length" class="setting-drawer__section">
|
||||
<h4 class="setting-drawer__section-title">{{ $t('settings.skills.pluginSection') }}</h4>
|
||||
<p class="installer-model-hint">{{ $t('settings.skills.pluginSectionHint') }}</p>
|
||||
<div class="plugin-skill-list">
|
||||
<button v-for="s in pluginSkills" :key="s.source" type="button" class="plugin-skill"
|
||||
:class="{ 'is-selected': sourceInput === s.source }" :disabled="addBusy || !!registeredCatalog"
|
||||
:aria-pressed="sourceInput === s.source" @click="pickPluginSkill(s.source)">
|
||||
<span class="plugin-skill__head">
|
||||
<span class="plugin-skill__name">{{ s.name }}</span>
|
||||
<span class="plugin-skill__plugin">{{ s.plugin }}</span>
|
||||
</span>
|
||||
<span v-if="s.description" class="plugin-skill__desc">{{ s.description }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="setting-drawer__section">
|
||||
<h4 class="setting-drawer__section-title">{{ $t('settings.sandbox.skillSourceSection') }}</h4>
|
||||
<p class="installer-model-hint">{{ $t('settings.sandbox.skillSourceSectionHint', { size: maxSkillBundleMB })
|
||||
@@ -351,6 +367,8 @@ import SandboxSkillsPanel from '@/components/SandboxSkillsPanel.vue'
|
||||
import SkillFilesDrawer from '@/components/SkillFilesDrawer.vue'
|
||||
import SandboxBackendBadge from '@/components/settings/SandboxBackendBadge.vue'
|
||||
import SettingDrawer from '@/components/settings/SettingDrawer.vue'
|
||||
import { listPlugins } from '@/api/plugin'
|
||||
import { pluginSkillChoices } from './pluginCenterState'
|
||||
import ModelSelector from '@/components/ModelSelector.vue'
|
||||
import { useConfirmDelete } from '@/components/settings/useConfirmDelete'
|
||||
import { useConfigSkillInstallProgress } from '@/composables/useConfigSkillInstallProgress'
|
||||
@@ -388,7 +406,7 @@ const props = defineProps<{
|
||||
}>()
|
||||
const emit = defineEmits<{ count: [value: number] }>()
|
||||
|
||||
const { t, te } = useI18n()
|
||||
const { t, te, locale } = useI18n()
|
||||
const uiStore = useUIStore()
|
||||
const deploymentCapabilities = useDeploymentCapabilitiesStore()
|
||||
const confirmDelete = useConfirmDelete()
|
||||
@@ -934,10 +952,30 @@ function resetAddWizard() {
|
||||
|
||||
async function openAdd() {
|
||||
resetAddWizard()
|
||||
void loadPluginSkills()
|
||||
await loadInstallerModel()
|
||||
showAdd.value = true
|
||||
}
|
||||
|
||||
// Skills of the plugins this workspace enabled register through the same
|
||||
// source path as a pasted link, as "plugin:<plugin>/<skill>".
|
||||
const pluginSkills = ref<ReturnType<typeof pluginSkillChoices>>([])
|
||||
|
||||
async function loadPluginSkills() {
|
||||
try {
|
||||
const res = await listPlugins()
|
||||
pluginSkills.value = pluginSkillChoices(res.data || [], locale.value)
|
||||
} catch {
|
||||
pluginSkills.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function pickPluginSkill(source: string) {
|
||||
sourceInput.value = sourceInput.value === source ? '' : source
|
||||
pendingFile.value = null
|
||||
if (fileInputRef.value) fileInputRef.value.value = ''
|
||||
}
|
||||
|
||||
function canJumpAddStep(index: number) {
|
||||
if (index === addStep.value) return false
|
||||
return Boolean(registeredCatalog.value) || index < addStep.value
|
||||
@@ -2058,6 +2096,73 @@ onUnmounted(() => {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.plugin-skill-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.plugin-skill {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--td-component-stroke);
|
||||
border-radius: var(--app-radius-md);
|
||||
background: var(--td-bg-color-container);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color var(--app-motion-fast) ease, background var(--app-motion-fast) ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: var(--td-brand-color);
|
||||
}
|
||||
|
||||
&.is-selected {
|
||||
border-color: var(--td-brand-color);
|
||||
background: color-mix(in srgb, var(--td-brand-color) 6%, transparent);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: var(--app-text-md);
|
||||
font-weight: 500;
|
||||
color: var(--td-text-color-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__plugin {
|
||||
flex: none;
|
||||
font-size: var(--app-text-xs);
|
||||
color: var(--td-text-color-placeholder);
|
||||
}
|
||||
|
||||
&__desc {
|
||||
font-size: var(--app-text-sm);
|
||||
color: var(--td-text-color-secondary);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.file-input-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { TenantPlugin } from '../../api/plugin'
|
||||
import { contributionSummary, filterPlugins } from './pluginCenterState'
|
||||
import { contributionSummary, filterPlugins, hasTenantConfig, pluginSkillChoices } from './pluginCenterState'
|
||||
|
||||
const plugin = (id: string, name: string, contributes: TenantPlugin['manifest']['contributes'], zh?: string): TenantPlugin => ({
|
||||
enabled: true,
|
||||
@@ -37,3 +37,29 @@ test('filterPlugins matches names, IDs and contributions, and filters by point',
|
||||
assert.deepEqual(names(filterPlugins(all, { query: '', point: 'webSearch', locale: 'en-US' })), ['weknora.bing'])
|
||||
assert.deepEqual(names(filterPlugins(all, { query: 'bing', point: 'connectors', locale: 'en-US' })), [])
|
||||
})
|
||||
|
||||
test('hasTenantConfig needs a tenant schema with fields', () => {
|
||||
const m = feishu.manifest
|
||||
assert.equal(hasTenantConfig(m), false)
|
||||
assert.equal(hasTenantConfig({ ...m, config: { tenantSchema: { type: 'object', properties: {} } } }), false)
|
||||
assert.equal(hasTenantConfig({
|
||||
...m, config: { tenantSchema: { type: 'object', properties: { api_key: { type: 'string' } } } },
|
||||
}), true)
|
||||
})
|
||||
|
||||
test('pluginSkillChoices lists skills of enabled plugins with their install source', () => {
|
||||
const kit = plugin('acme.kit', 'ACME Kit', {
|
||||
skills: [
|
||||
{ id: 'triage', name: { default: 'Triage', 'zh-CN': '分诊' }, description: { default: 'Sort issues' } },
|
||||
{ id: 'audit', name: { default: 'Audit' } },
|
||||
],
|
||||
})
|
||||
const off = { ...plugin('acme.off', 'Off', { skills: [{ id: 'x', name: { default: 'X' } }] }), enabled: false }
|
||||
assert.deepEqual(pluginSkillChoices([kit, off, bing], 'en-US').map((c) => [c.source, c.name]), [
|
||||
['plugin:acme.kit/audit', 'Audit'],
|
||||
['plugin:acme.kit/triage', 'Triage'],
|
||||
])
|
||||
const zh = pluginSkillChoices([kit], 'zh-CN').find((c) => c.source === 'plugin:acme.kit/triage')
|
||||
assert.equal(zh?.name, '分诊')
|
||||
assert.equal(zh?.description, 'Sort issues')
|
||||
})
|
||||
|
||||
@@ -5,9 +5,37 @@ import { localizedText } from '../../utils/localizedText'
|
||||
|
||||
/** Extension points in the order the plugin center shows them. */
|
||||
export const EXTENSION_POINTS: ExtensionPoint[] = [
|
||||
'modelVendors', 'connectors', 'imChannels', 'webSearch', 'tools', 'parsers',
|
||||
'modelVendors', 'connectors', 'imChannels', 'webSearch', 'tools', 'parsers', 'skills', 'mcpServers',
|
||||
]
|
||||
|
||||
/** Whether the workspace can configure the plugin (it declares config.tenant). */
|
||||
export function hasTenantConfig(m: PluginManifest): boolean {
|
||||
return !!m.config?.tenantSchema?.properties && Object.keys(m.config.tenantSchema.properties).length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Skills of enabled plugins, for the skill catalog's "from plugin" picker.
|
||||
* Each carries the install source the backend understands.
|
||||
*/
|
||||
export function pluginSkillChoices(
|
||||
list: readonly TenantPlugin[],
|
||||
locale: string,
|
||||
): Array<{ source: string; name: string; plugin: string; description: string }> {
|
||||
const out: Array<{ source: string; name: string; plugin: string; description: string }> = []
|
||||
for (const p of list) {
|
||||
if (!p.enabled) continue
|
||||
for (const c of p.manifest.contributes.skills ?? []) {
|
||||
out.push({
|
||||
source: `plugin:${p.manifest.id}/${c.id}`,
|
||||
name: localizedText(c.name, locale) || c.id,
|
||||
plugin: localizedText(p.manifest.name, locale) || p.manifest.id,
|
||||
description: localizedText(c.description, locale),
|
||||
})
|
||||
}
|
||||
}
|
||||
return out.sort((a, b) => a.name.localeCompare(b.name, locale))
|
||||
}
|
||||
|
||||
/** How many contributions a plugin makes at each point, in point order. */
|
||||
export function contributionSummary(m: PluginManifest): Array<{ point: ExtensionPoint; count: number }> {
|
||||
return EXTENSION_POINTS
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
<template>
|
||||
<div class="plugin-admin">
|
||||
<header class="section-header">
|
||||
<div class="section-header__row">
|
||||
<div>
|
||||
<h2>{{ t('pluginAdmin.title') }}</h2>
|
||||
<p class="section-description">{{ t('pluginAdmin.description') }}</p>
|
||||
</div>
|
||||
<t-button theme="primary" @click="installOpen = true">
|
||||
<template #icon><t-icon name="add" /></template>
|
||||
{{ t('pluginAdmin.installButton') }}
|
||||
</t-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="plugin-admin__state"><t-loading size="small" /></div>
|
||||
<div v-else-if="plugins.length === 0" class="plugin-admin__state plugin-admin__state--empty">
|
||||
<t-empty :description="t('pluginAdmin.empty')" />
|
||||
<t-button variant="outline" @click="installOpen = true">
|
||||
<template #icon><t-icon name="add" /></template>
|
||||
{{ t('pluginAdmin.installButton') }}
|
||||
</t-button>
|
||||
</div>
|
||||
<div v-else class="plugin-list">
|
||||
<div
|
||||
v-for="p in plugins"
|
||||
:key="p.id"
|
||||
class="plugin-card"
|
||||
:class="{ 'plugin-card--off': p.desired_state === 'disabled' }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="openDetail(p)"
|
||||
@keydown.enter="openDetail(p)"
|
||||
>
|
||||
<div class="plugin-card__badge">{{ initial(p) }}</div>
|
||||
<div class="plugin-card__body">
|
||||
<div class="plugin-card__title">
|
||||
<span class="plugin-card__name">{{ nameOf(p) }}</span>
|
||||
<t-tooltip :content="p.node?.error" :disabled="!p.node?.error">
|
||||
<t-tag size="small" variant="light" :theme="stateTheme(p)">
|
||||
{{ t(`pluginAdmin.state.${installedState(p)}`) }}
|
||||
</t-tag>
|
||||
</t-tooltip>
|
||||
</div>
|
||||
<div class="plugin-card__meta">{{ p.id }} · v{{ p.active_version }} · {{ p.runtime }}</div>
|
||||
<div v-if="descriptionOf(p)" class="plugin-card__desc">{{ descriptionOf(p) }}</div>
|
||||
<div v-if="p.manifest" class="plugin-card__contribs">
|
||||
<span v-for="s in contributionSummary(p.manifest)" :key="s.point" class="plugin-card__contrib">
|
||||
{{ t(`pluginCenter.points.${s.point}`) }} × {{ s.count }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="plugin-card__actions" @click.stop @keydown.enter.stop>
|
||||
<t-tooltip :content="t('pluginAdmin.platformSwitch')">
|
||||
<t-switch
|
||||
:model-value="p.desired_state === 'enabled'"
|
||||
:loading="pending.has(p.id)"
|
||||
:disabled="pending.has(p.id)"
|
||||
@update:model-value="(v: boolean) => toggle(p, v)"
|
||||
/>
|
||||
</t-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PluginInstallDrawer v-model:visible="installOpen" @installed="upsert" />
|
||||
<PluginDetailDrawer
|
||||
v-model:visible="detailOpen"
|
||||
:plugin="selected"
|
||||
@changed="upsert"
|
||||
@removed="remove"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { MessagePlugin } from 'tdesign-vue-next'
|
||||
|
||||
import { listInstalledPlugins, setInstalledPluginEnabled, type InstalledPlugin } from '@/api/system/plugins'
|
||||
import { localizedText } from '@/utils/localizedText'
|
||||
|
||||
import { contributionSummary } from '../settings/pluginCenterState'
|
||||
import PluginDetailDrawer from './plugins/PluginDetailDrawer.vue'
|
||||
import PluginInstallDrawer from './plugins/PluginInstallDrawer.vue'
|
||||
import { installedState } from './pluginManagementState'
|
||||
|
||||
// Platform plugin management: what is installed, on which version, whether it
|
||||
// loads. Installing makes a plugin available to every workspace; each
|
||||
// workspace still turns it on in its own plugin center.
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const plugins = ref<InstalledPlugin[]>([])
|
||||
const loading = ref(false)
|
||||
const pending = ref(new Set<string>())
|
||||
const installOpen = ref(false)
|
||||
const detailOpen = ref(false)
|
||||
const selectedId = ref('')
|
||||
const selected = computed(() => plugins.value.find((p) => p.id === selectedId.value) ?? null)
|
||||
|
||||
const nameOf = (p: InstalledPlugin) => (p.manifest ? localizedText(p.manifest.name, locale.value) : p.id)
|
||||
const descriptionOf = (p: InstalledPlugin) => (p.manifest ? localizedText(p.manifest.description, locale.value) : '')
|
||||
const initial = (p: InstalledPlugin) => (nameOf(p).trim().charAt(0) || '?').toUpperCase()
|
||||
|
||||
function stateTheme(p: InstalledPlugin) {
|
||||
switch (installedState(p)) {
|
||||
case 'running':
|
||||
return 'success'
|
||||
case 'failed':
|
||||
return 'danger'
|
||||
default:
|
||||
return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listInstalledPlugins()
|
||||
plugins.value = res.data || []
|
||||
} catch (e: any) {
|
||||
MessagePlugin.error(e?.message || t('pluginAdmin.loadFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function upsert(p: InstalledPlugin) {
|
||||
const i = plugins.value.findIndex((x) => x.id === p.id)
|
||||
if (i >= 0) plugins.value.splice(i, 1, p)
|
||||
else plugins.value = [...plugins.value, p].sort((a, b) => a.id.localeCompare(b.id))
|
||||
}
|
||||
|
||||
function remove(id: string) {
|
||||
plugins.value = plugins.value.filter((p) => p.id !== id)
|
||||
}
|
||||
|
||||
function openDetail(p: InstalledPlugin) {
|
||||
selectedId.value = p.id
|
||||
detailOpen.value = true
|
||||
}
|
||||
|
||||
async function toggle(p: InstalledPlugin, enabled: boolean) {
|
||||
pending.value = new Set([...pending.value, p.id])
|
||||
try {
|
||||
const res = await setInstalledPluginEnabled(p.id, enabled)
|
||||
upsert(res.data)
|
||||
MessagePlugin.success(enabled ? t('pluginAdmin.enabledToast') : t('pluginAdmin.disabledToast'))
|
||||
} catch (e: any) {
|
||||
MessagePlugin.error(e?.message || t('pluginAdmin.saveFailed'))
|
||||
} finally {
|
||||
const next = new Set(pending.value)
|
||||
next.delete(p.id)
|
||||
pending.value = next
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
@import (reference) '@/components/css/provider-card.less';
|
||||
@import (reference) '@/components/css/settings-section.less';
|
||||
|
||||
.plugin-admin {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
.settings-section-header();
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-admin__state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 32px 0;
|
||||
|
||||
&--empty {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.plugin-card {
|
||||
.provider-card();
|
||||
align-items: flex-start;
|
||||
cursor: pointer;
|
||||
|
||||
&--off {
|
||||
.plugin-card__badge,
|
||||
.plugin-card__body {
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-card__badge {
|
||||
.provider-card-badge();
|
||||
.provider-card-badge-color(#0052d9);
|
||||
}
|
||||
|
||||
.plugin-card__body {
|
||||
.provider-card-body();
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.plugin-card__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.plugin-card__name {
|
||||
.provider-card-title();
|
||||
}
|
||||
|
||||
.plugin-card__meta {
|
||||
font-family: var(--app-font-family-mono);
|
||||
font-size: var(--app-text-xs);
|
||||
color: var(--td-text-color-placeholder);
|
||||
}
|
||||
|
||||
.plugin-card__desc {
|
||||
font-size: var(--app-text-sm);
|
||||
color: var(--td-text-color-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.plugin-card__contribs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.plugin-card__contrib {
|
||||
font-size: var(--app-text-xs);
|
||||
color: var(--td-text-color-secondary);
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
border-radius: var(--app-radius-xs);
|
||||
padding: 1px 6px;
|
||||
}
|
||||
|
||||
.plugin-card__actions {
|
||||
flex: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,78 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { PluginManifest } from '../../api/plugin'
|
||||
import type { InstalledPlugin, PluginVersion } from '../../api/system/plugins'
|
||||
import {
|
||||
compareVersions,
|
||||
contributionLines,
|
||||
formatBytes,
|
||||
hasSystemConfig,
|
||||
installedState,
|
||||
isPackageUrl,
|
||||
permissionLines,
|
||||
remoteHosts,
|
||||
shortDigest,
|
||||
sortVersions,
|
||||
} from './pluginManagementState'
|
||||
|
||||
const manifest: PluginManifest = {
|
||||
schemaVersion: 1,
|
||||
id: 'acme.search',
|
||||
version: '1.2.0',
|
||||
name: { default: 'ACME Search' },
|
||||
publisher: { id: 'acme' },
|
||||
runtime: { type: 'declarative' },
|
||||
permissions: { egress: ['*.acme.example'], events: ['knowledge.created'] },
|
||||
config: { systemSchema: { type: 'object', properties: { region: { type: 'string' } } } },
|
||||
contributes: {
|
||||
mcpServers: [{ id: 'search', name: { default: 'Search', 'zh-CN': '搜索' }, mcp: { url: 'https://mcp.acme.example/mcp' } }],
|
||||
skills: [{ id: 'triage', name: { default: 'Triage' }, path: 'skills/triage' }],
|
||||
},
|
||||
}
|
||||
|
||||
test('formatBytes and shortDigest', () => {
|
||||
assert.equal(formatBytes(512), '512 B')
|
||||
assert.equal(formatBytes(1536), '1.5 KB')
|
||||
assert.equal(formatBytes(20 * 1024 * 1024), '20 MB')
|
||||
assert.equal(shortDigest('sha256:0123456789abcdef'), '0123456789ab')
|
||||
})
|
||||
|
||||
test('contributionLines follow point order and carry the detail', () => {
|
||||
assert.deepEqual(contributionLines(manifest, 'zh-CN'), [
|
||||
{ point: 'skills', id: 'acme.search/triage', name: 'Triage', detail: 'skills/triage' },
|
||||
{ point: 'mcpServers', id: 'acme.search/search', name: '搜索', detail: 'https://mcp.acme.example/mcp' },
|
||||
])
|
||||
})
|
||||
|
||||
test('permissionLines and remoteHosts flatten what the review shows', () => {
|
||||
assert.deepEqual(permissionLines(manifest.permissions), [
|
||||
{ kind: 'egress', value: '*.acme.example' },
|
||||
{ kind: 'events', value: 'knowledge.created' },
|
||||
])
|
||||
assert.deepEqual(permissionLines(undefined), [])
|
||||
assert.deepEqual(remoteHosts(manifest), ['mcp.acme.example'])
|
||||
})
|
||||
|
||||
test('versions sort newest first by semver, not by string', () => {
|
||||
const v = (version: string) => ({ version } as PluginVersion)
|
||||
assert.deepEqual(sortVersions([v('1.9.0'), v('1.10.0'), v('1.2.3')]).map((x) => x.version), ['1.10.0', '1.9.0', '1.2.3'])
|
||||
assert.ok(compareVersions('2.0.0', '1.99.99') > 0)
|
||||
assert.equal(compareVersions('1.0.0+build', '1.0.0'), 0)
|
||||
})
|
||||
|
||||
test('installedState reads desired state and the node report', () => {
|
||||
const base = { desired_state: 'enabled' } as InstalledPlugin
|
||||
assert.equal(installedState({ ...base, desired_state: 'disabled' }), 'disabled')
|
||||
assert.equal(installedState(base), 'pending')
|
||||
assert.equal(installedState({ ...base, node: { version: '1', state: 'ready', updatedAt: '' } }), 'running')
|
||||
assert.equal(installedState({ ...base, node: { version: '1', state: 'failed', updatedAt: '' } }), 'failed')
|
||||
})
|
||||
|
||||
test('isPackageUrl and hasSystemConfig', () => {
|
||||
assert.equal(isPackageUrl('https://example.com/p.wkp'), true)
|
||||
assert.equal(isPackageUrl('ftp://example.com/p.wkp'), false)
|
||||
assert.equal(isPackageUrl('not a url'), false)
|
||||
assert.equal(hasSystemConfig(manifest), true)
|
||||
assert.equal(hasSystemConfig(undefined), false)
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
// Pure helpers behind PluginManagement.vue, kept free of Vue so they run
|
||||
// under node:test.
|
||||
import type { ExtensionPoint, PluginManifest, PluginPermissions } from '../../api/plugin'
|
||||
import type { InstalledPlugin, PluginVersion } from '../../api/system/plugins'
|
||||
import { localizedText } from '../../utils/localizedText'
|
||||
import { EXTENSION_POINTS } from '../settings/pluginCenterState'
|
||||
|
||||
/** Human-readable size: 1.2 MB. */
|
||||
export function formatBytes(n: number): string {
|
||||
if (!Number.isFinite(n) || n < 0) return '-'
|
||||
if (n < 1024) return `${n} B`
|
||||
const units = ['KB', 'MB', 'GB']
|
||||
let v = n / 1024
|
||||
let i = 0
|
||||
while (v >= 1024 && i < units.length - 1) {
|
||||
v /= 1024
|
||||
i++
|
||||
}
|
||||
return `${v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`
|
||||
}
|
||||
|
||||
/** The short form of a sha256 digest shown next to versions. */
|
||||
export function shortDigest(digest: string): string {
|
||||
const hex = digest.replace(/^sha256:/, '')
|
||||
return hex.slice(0, 12)
|
||||
}
|
||||
|
||||
/** One line of what a package would add, for the install review. */
|
||||
export interface ContributionLine {
|
||||
point: ExtensionPoint
|
||||
id: string
|
||||
name: string
|
||||
/** The MCP server URL, or the package path of a skill / vendor file. */
|
||||
detail: string
|
||||
}
|
||||
|
||||
export function contributionLines(m: PluginManifest, locale: string): ContributionLine[] {
|
||||
const out: ContributionLine[] = []
|
||||
for (const point of EXTENSION_POINTS) {
|
||||
for (const c of m.contributes[point] ?? []) {
|
||||
out.push({
|
||||
point,
|
||||
id: `${m.id}/${c.id}`,
|
||||
name: localizedText(c.name, locale) || c.id,
|
||||
detail: c.mcp?.url ?? c.path ?? '',
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Permission kinds a manifest can request, in review order. */
|
||||
export type PermissionKind = keyof PluginPermissions
|
||||
|
||||
/** The permissions a package asks for, flattened for review. */
|
||||
export function permissionLines(p: PluginPermissions | undefined): Array<{ kind: PermissionKind; value: string }> {
|
||||
const out: Array<{ kind: PermissionKind; value: string }> = []
|
||||
for (const kind of ['egress', 'hostApi', 'events'] as PermissionKind[]) {
|
||||
for (const value of p?.[kind] ?? []) out.push({ kind, value })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Hosts a package's MCP servers talk to, which the review calls out. */
|
||||
export function remoteHosts(m: PluginManifest): string[] {
|
||||
const hosts = new Set<string>()
|
||||
for (const c of m.contributes.mcpServers ?? []) {
|
||||
if (!c.mcp?.url) continue
|
||||
try {
|
||||
hosts.add(new URL(c.mcp.url).host)
|
||||
} catch {
|
||||
hosts.add(c.mcp.url)
|
||||
}
|
||||
}
|
||||
return [...hosts].sort()
|
||||
}
|
||||
|
||||
function semverParts(v: string): number[] {
|
||||
return v.split(/[-+]/)[0].split('.').map((x) => Number.parseInt(x, 10) || 0)
|
||||
}
|
||||
|
||||
/** Compares two semantic versions by major.minor.patch. */
|
||||
export function compareVersions(a: string, b: string): number {
|
||||
const pa = semverParts(a)
|
||||
const pb = semverParts(b)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) - (pb[i] ?? 0)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/** Stored versions, newest first. */
|
||||
export function sortVersions(list: readonly PluginVersion[]): PluginVersion[] {
|
||||
return [...list].sort((a, b) => compareVersions(b.version, a.version))
|
||||
}
|
||||
|
||||
/** Overall state of an installed plugin on the node that answered. */
|
||||
export type InstalledState = 'running' | 'failed' | 'disabled' | 'pending'
|
||||
|
||||
export function installedState(p: InstalledPlugin): InstalledState {
|
||||
if (p.desired_state === 'disabled') return 'disabled'
|
||||
if (!p.node) return 'pending'
|
||||
return p.node.state === 'ready' ? 'running' : 'failed'
|
||||
}
|
||||
|
||||
/** Whether a string can be sent as a package URL. */
|
||||
export function isPackageUrl(raw: string): boolean {
|
||||
try {
|
||||
const u = new URL(raw.trim())
|
||||
return (u.protocol === 'https:' || u.protocol === 'http:') && !!u.host
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the plugin declares a platform-wide configuration. */
|
||||
export function hasSystemConfig(m: PluginManifest | undefined): boolean {
|
||||
const props = m?.config?.systemSchema?.properties
|
||||
return !!props && Object.keys(props).length > 0
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
<template>
|
||||
<SettingDrawer
|
||||
:visible="visible"
|
||||
:title="title"
|
||||
:description="plugin ? `${plugin.id} · v${plugin.active_version}` : ''"
|
||||
icon="app"
|
||||
width="640px"
|
||||
:confirm-text="t('common.save')"
|
||||
:confirm-loading="saving"
|
||||
:confirm-disabled="!systemSchema || saving"
|
||||
:hide-footer="!systemSchema"
|
||||
@update:visible="(v: boolean) => emit('update:visible', v)"
|
||||
@confirm="saveConfig"
|
||||
>
|
||||
<template v-if="plugin">
|
||||
<section class="setting-drawer__section">
|
||||
<h4 class="setting-drawer__section-title">{{ t('pluginAdmin.detail.overview') }}</h4>
|
||||
<dl class="facts">
|
||||
<dt>{{ t('pluginAdmin.publisher') }}</dt>
|
||||
<dd>{{ manifest?.publisher.name || manifest?.publisher.id }}</dd>
|
||||
<dt>{{ t('pluginAdmin.detail.runtime') }}</dt>
|
||||
<dd>{{ plugin.runtime }}</dd>
|
||||
<dt>{{ t('pluginAdmin.detail.source') }}</dt>
|
||||
<dd>
|
||||
{{ t(`pluginAdmin.source.${plugin.source?.kind ?? 'upload'}`) }}
|
||||
<code v-if="plugin.source?.url" class="facts__code">{{ plugin.source.url }}</code>
|
||||
</dd>
|
||||
<template v-if="manifest?.homepage">
|
||||
<dt>{{ t('pluginAdmin.detail.homepage') }}</dt>
|
||||
<dd><a :href="manifest.homepage" target="_blank" rel="noopener noreferrer">{{ manifest.homepage }}</a></dd>
|
||||
</template>
|
||||
<template v-if="manifest?.license">
|
||||
<dt>{{ t('pluginAdmin.detail.license') }}</dt>
|
||||
<dd>{{ manifest.license }}</dd>
|
||||
</template>
|
||||
<template v-if="manifest?.engines?.weknora">
|
||||
<dt>{{ t('pluginAdmin.detail.engines') }}</dt>
|
||||
<dd><code class="facts__code">{{ manifest.engines.weknora }}</code></dd>
|
||||
</template>
|
||||
</dl>
|
||||
<ul v-if="contributions.length" class="line-list">
|
||||
<li v-for="c in contributions" :key="c.id">
|
||||
<span class="line-list__tag">{{ t(`pluginCenter.points.${c.point}`) }}</span>
|
||||
<span>{{ c.name }}</span>
|
||||
<code v-if="c.detail" class="line-list__muted">{{ c.detail }}</code>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="setting-drawer__section">
|
||||
<h4 class="setting-drawer__section-title">{{ t('pluginAdmin.detail.nodes') }}</h4>
|
||||
<p v-if="instanceError" class="form-desc">{{ instanceError }}</p>
|
||||
<p v-else-if="instances.length === 0" class="form-desc">{{ t('pluginAdmin.detail.noNodes') }}</p>
|
||||
<ul v-else class="line-list">
|
||||
<li v-for="n in instances" :key="n.node">
|
||||
<t-tag size="small" variant="light" :theme="n.state === 'ready' ? 'success' : 'danger'">
|
||||
{{ t(`pluginAdmin.nodeState.${n.state}`) }}
|
||||
</t-tag>
|
||||
<code>{{ n.node }}</code>
|
||||
<span class="line-list__muted">v{{ n.version }}</span>
|
||||
<span v-if="n.error" class="line-list__error">{{ n.error }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="setting-drawer__section">
|
||||
<h4 class="setting-drawer__section-title">{{ t('pluginAdmin.detail.versions') }}</h4>
|
||||
<ul class="line-list">
|
||||
<li v-for="v in versions" :key="v.version">
|
||||
<span class="version">v{{ v.version }}</span>
|
||||
<code class="line-list__muted">{{ shortDigest(v.digest) }}</code>
|
||||
<span class="line-list__muted">{{ formatBytes(v.size) }} · {{ formatDate(v.created_at) }}</span>
|
||||
<t-tag v-if="v.version === plugin.active_version" size="small" variant="light" theme="primary">
|
||||
{{ t('pluginAdmin.detail.active') }}
|
||||
</t-tag>
|
||||
<t-popconfirm
|
||||
v-else
|
||||
:content="t('pluginAdmin.detail.activateConfirm', { version: v.version })"
|
||||
@confirm="activate(v.version)"
|
||||
>
|
||||
<t-button size="small" variant="text" theme="primary" :loading="activating === v.version">
|
||||
{{ compareVersions(v.version, plugin.active_version) < 0 ? t('pluginAdmin.detail.rollback') : t('pluginAdmin.detail.activate') }}
|
||||
</t-button>
|
||||
</t-popconfirm>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section v-if="systemSchema" class="setting-drawer__section">
|
||||
<h4 class="setting-drawer__section-title">{{ t('pluginAdmin.detail.systemConfig') }}</h4>
|
||||
<p class="form-desc">{{ t('pluginAdmin.detail.systemConfigHint') }}</p>
|
||||
<SchemaForm v-model="configValues" :schema="systemSchema" :errors="configErrors" />
|
||||
</section>
|
||||
|
||||
<section class="setting-drawer__section">
|
||||
<h4 class="setting-drawer__section-title">{{ t('pluginAdmin.detail.danger') }}</h4>
|
||||
<div class="danger-row">
|
||||
<span class="form-desc">{{ t('pluginAdmin.detail.uninstallHint') }}</span>
|
||||
<t-popconfirm theme="danger" :content="t('pluginAdmin.detail.uninstallConfirm')" @confirm="uninstall">
|
||||
<t-button theme="danger" variant="outline" :loading="uninstalling">
|
||||
{{ t('pluginAdmin.detail.uninstall') }}
|
||||
</t-button>
|
||||
</t-popconfirm>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</SettingDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { MessagePlugin } from 'tdesign-vue-next'
|
||||
|
||||
import SettingDrawer from '@/components/settings/SettingDrawer.vue'
|
||||
import SchemaForm from '@/components/schema-form/SchemaForm.vue'
|
||||
import { validateConfig, type ConfigSchema, type ConfigValue, type FieldError } from '@/components/schema-form/schema'
|
||||
import { getPlugin, type PluginInstance } from '@/api/plugin'
|
||||
import {
|
||||
activatePluginVersion,
|
||||
getPluginSystemConfig,
|
||||
uninstallPlugin,
|
||||
updatePluginSystemConfig,
|
||||
type InstalledPlugin,
|
||||
} from '@/api/system/plugins'
|
||||
import { localizedText } from '@/utils/localizedText'
|
||||
|
||||
import {
|
||||
compareVersions,
|
||||
contributionLines,
|
||||
formatBytes,
|
||||
hasSystemConfig,
|
||||
shortDigest,
|
||||
sortVersions,
|
||||
} from '../pluginManagementState'
|
||||
|
||||
const props = defineProps<{ visible: boolean; plugin: InstalledPlugin | null }>()
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean]
|
||||
changed: [plugin: InstalledPlugin]
|
||||
removed: [id: string]
|
||||
}>()
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const manifest = computed(() => props.plugin?.manifest)
|
||||
const title = computed(() => (manifest.value ? localizedText(manifest.value.name, locale.value) : props.plugin?.id ?? ''))
|
||||
const contributions = computed(() => (manifest.value ? contributionLines(manifest.value, locale.value) : []))
|
||||
const versions = computed(() => sortVersions(props.plugin?.versions ?? []))
|
||||
|
||||
const instances = ref<PluginInstance[]>([])
|
||||
const instanceError = ref('')
|
||||
const systemSchema = ref<ConfigSchema | null>(null)
|
||||
const configValues = ref<ConfigValue>({})
|
||||
const configErrors = ref<FieldError[]>([])
|
||||
const saving = ref(false)
|
||||
const activating = ref('')
|
||||
const uninstalling = ref(false)
|
||||
|
||||
const formatDate = (s: string) => (s ? new Date(s).toLocaleString(locale.value) : '')
|
||||
|
||||
async function loadNodes(id: string) {
|
||||
instanceError.value = ''
|
||||
try {
|
||||
const res = await getPlugin(id)
|
||||
instances.value = res.data.instances ?? []
|
||||
instanceError.value = res.data.instanceError ?? ''
|
||||
} catch {
|
||||
// A plugin disabled platform-wide is not in the catalog; it runs nowhere.
|
||||
instances.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConfig(id: string) {
|
||||
systemSchema.value = null
|
||||
configErrors.value = []
|
||||
if (!hasSystemConfig(manifest.value)) return
|
||||
try {
|
||||
const res = await getPluginSystemConfig(id)
|
||||
systemSchema.value = res.data.schema
|
||||
configValues.value = res.data.values ?? {}
|
||||
} catch (e: any) {
|
||||
MessagePlugin.error(e?.message || t('pluginAdmin.detail.configLoadFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.visible, props.plugin?.id, props.plugin?.active_version, props.plugin?.desired_state] as const,
|
||||
([visible, id]) => {
|
||||
if (!visible || !id) return
|
||||
void loadNodes(id)
|
||||
void loadConfig(id)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function saveConfig() {
|
||||
if (!props.plugin || !systemSchema.value) return
|
||||
configErrors.value = validateConfig(systemSchema.value, configValues.value, { skipSecrets: true })
|
||||
if (configErrors.value.length) return
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await updatePluginSystemConfig(props.plugin.id, configValues.value)
|
||||
configValues.value = res.data.values ?? {}
|
||||
MessagePlugin.success(t('pluginAdmin.detail.configSaved'))
|
||||
} catch (e: any) {
|
||||
const details = e?.details ?? e?.error?.details
|
||||
if (Array.isArray(details)) configErrors.value = details
|
||||
MessagePlugin.error(e?.message || t('pluginAdmin.detail.configSaveFailed'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function activate(version: string) {
|
||||
if (!props.plugin) return
|
||||
activating.value = version
|
||||
try {
|
||||
const res = await activatePluginVersion(props.plugin.id, version)
|
||||
emit('changed', res.data)
|
||||
MessagePlugin.success(t('pluginAdmin.detail.activated', { version }))
|
||||
} catch (e: any) {
|
||||
MessagePlugin.error(e?.message || t('pluginAdmin.detail.activateFailed'))
|
||||
} finally {
|
||||
activating.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function uninstall() {
|
||||
if (!props.plugin) return
|
||||
const id = props.plugin.id
|
||||
uninstalling.value = true
|
||||
try {
|
||||
await uninstallPlugin(id)
|
||||
MessagePlugin.success(t('pluginAdmin.detail.uninstalled'))
|
||||
emit('removed', id)
|
||||
emit('update:visible', false)
|
||||
} catch (e: any) {
|
||||
MessagePlugin.error(e?.message || t('pluginAdmin.detail.uninstallFailed'))
|
||||
} finally {
|
||||
uninstalling.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.facts {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 6px 16px;
|
||||
margin: 0;
|
||||
font-size: var(--app-text-sm);
|
||||
|
||||
dt {
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
color: var(--td-text-color-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
&__code {
|
||||
font-size: var(--app-text-xs);
|
||||
color: var(--td-text-color-placeholder);
|
||||
margin-left: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.line-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
font-size: var(--app-text-sm);
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
|
||||
&__tag {
|
||||
font-size: var(--app-text-xs);
|
||||
color: var(--td-text-color-secondary);
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
border-radius: var(--app-radius-xs);
|
||||
padding: 1px 6px;
|
||||
}
|
||||
|
||||
&__muted {
|
||||
font-size: var(--app-text-xs);
|
||||
color: var(--td-text-color-placeholder);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
&__error {
|
||||
flex-basis: 100%;
|
||||
font-size: var(--app-text-xs);
|
||||
color: var(--td-error-color);
|
||||
}
|
||||
}
|
||||
|
||||
.version {
|
||||
font-family: var(--app-font-family-mono);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-desc {
|
||||
margin: 0;
|
||||
font-size: var(--app-text-sm);
|
||||
line-height: 1.5;
|
||||
color: var(--td-text-color-placeholder);
|
||||
}
|
||||
|
||||
.danger-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,401 @@
|
||||
<template>
|
||||
<SettingDrawer
|
||||
:visible="visible"
|
||||
:title="t('pluginAdmin.install.title')"
|
||||
:description="t('pluginAdmin.install.description')"
|
||||
icon="download"
|
||||
width="640px"
|
||||
:confirm-text="confirmText"
|
||||
:confirm-loading="busy"
|
||||
:confirm-disabled="!canConfirm"
|
||||
@update:visible="(v: boolean) => emit('update:visible', v)"
|
||||
@confirm="onConfirm"
|
||||
>
|
||||
<section class="setting-drawer__section">
|
||||
<h4 class="setting-drawer__section-title">{{ t('pluginAdmin.install.sourceSection') }}</h4>
|
||||
<div class="option-chips">
|
||||
<button
|
||||
v-for="m in (['upload', 'url'] as const)"
|
||||
:key="m"
|
||||
type="button"
|
||||
class="option-chip"
|
||||
:class="{ 'option-chip--active': mode === m }"
|
||||
:disabled="busy"
|
||||
@click="setMode(m)"
|
||||
>
|
||||
{{ t(`pluginAdmin.install.mode.${m}`) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="mode === 'upload'" class="form-item">
|
||||
<label class="form-label required">{{ t('pluginAdmin.install.fileLabel') }}</label>
|
||||
<div class="package-picker">
|
||||
<t-button variant="outline" :disabled="busy" @click="fileInput?.click()">
|
||||
<template #icon><t-icon name="upload" /></template>
|
||||
{{ t('pluginAdmin.install.chooseFile') }}
|
||||
</t-button>
|
||||
<span class="package-picker__name">{{ file?.name ?? t('pluginAdmin.install.noFile') }}</span>
|
||||
<input ref="fileInput" type="file" accept=".wkp,.zip" hidden @change="onFile" />
|
||||
</div>
|
||||
<p class="form-desc">{{ t('pluginAdmin.install.fileHint') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="form-item">
|
||||
<label class="form-label required">{{ t('pluginAdmin.install.urlLabel') }}</label>
|
||||
<t-input
|
||||
v-model="url"
|
||||
:disabled="busy"
|
||||
placeholder="https://example.com/acme-search-1.0.0.wkp"
|
||||
@change="preview = null"
|
||||
/>
|
||||
<p class="form-desc">{{ t('pluginAdmin.install.urlHint') }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="preview" class="setting-drawer__section">
|
||||
<h4 class="setting-drawer__section-title">{{ t('pluginAdmin.install.reviewSection') }}</h4>
|
||||
<div class="review-head">
|
||||
<div class="review-head__badge">{{ initial }}</div>
|
||||
<div class="review-head__text">
|
||||
<div class="review-head__name">
|
||||
{{ localizedText(preview.manifest.name, locale) }}
|
||||
<t-tag size="small" variant="light" :theme="changeTheme">
|
||||
{{ t(`pluginAdmin.change.${preview.change}`, { from: preview.installedVersion ?? '' }) }}
|
||||
</t-tag>
|
||||
</div>
|
||||
<div class="review-head__meta">
|
||||
{{ preview.manifest.id }} · v{{ preview.manifest.version }} · {{ formatBytes(preview.size) }}
|
||||
</div>
|
||||
<div class="review-head__meta">
|
||||
{{ t('pluginAdmin.publisher') }}: {{ preview.manifest.publisher.name || preview.manifest.publisher.id }}
|
||||
</div>
|
||||
<p v-if="description" class="review-head__desc">{{ description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="review-block">
|
||||
<div class="review-block__title">{{ t('pluginAdmin.contributions') }}</div>
|
||||
<ul class="review-list">
|
||||
<li v-for="c in contributions" :key="c.id">
|
||||
<span class="review-list__point">{{ t(`pluginCenter.points.${c.point}`) }}</span>
|
||||
<span class="review-list__name">{{ c.name }}</span>
|
||||
<code v-if="c.detail" class="review-list__detail">{{ c.detail }}</code>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="review-block">
|
||||
<div class="review-block__title">{{ t('pluginAdmin.permissions') }}</div>
|
||||
<p v-if="permissions.length === 0 && hosts.length === 0" class="form-desc">
|
||||
{{ t('pluginAdmin.install.noPermissions') }}
|
||||
</p>
|
||||
<ul v-else class="review-list">
|
||||
<li v-for="h in hosts" :key="`host:${h}`">
|
||||
<span class="review-list__point">{{ t('pluginAdmin.permission.remote') }}</span>
|
||||
<code class="review-list__detail">{{ h }}</code>
|
||||
</li>
|
||||
<li v-for="p in permissions" :key="`${p.kind}:${p.value}`">
|
||||
<span class="review-list__point">{{ t(`pluginAdmin.permission.${p.kind}`) }}</span>
|
||||
<code class="review-list__detail">{{ p.value }}</code>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<t-alert
|
||||
v-if="needsConfig"
|
||||
theme="info"
|
||||
class="review-alert"
|
||||
:message="t('pluginAdmin.install.configNotice')"
|
||||
/>
|
||||
<t-alert theme="warning" class="review-alert" :message="t('pluginAdmin.install.tenantNotice')" />
|
||||
<p class="form-desc">{{ t('pluginAdmin.install.digest') }}: <code>{{ preview.digest }}</code></p>
|
||||
</section>
|
||||
</SettingDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { MessagePlugin } from 'tdesign-vue-next'
|
||||
|
||||
import SettingDrawer from '@/components/settings/SettingDrawer.vue'
|
||||
import {
|
||||
inspectPluginPackage,
|
||||
installPluginPackage,
|
||||
type InstalledPlugin,
|
||||
type PackageSource,
|
||||
type PluginPreview,
|
||||
} from '@/api/system/plugins'
|
||||
import { localizedText } from '@/utils/localizedText'
|
||||
|
||||
import { hasSystemConfig, contributionLines, formatBytes, isPackageUrl, permissionLines, remoteHosts } from '../pluginManagementState'
|
||||
import { hasTenantConfig } from '../../settings/pluginCenterState'
|
||||
|
||||
// Installing is two steps on purpose: inspect shows what the package would
|
||||
// add and reach, and install sends the reviewed digest so the server refuses
|
||||
// a package that changed in between (a URL that now serves something else).
|
||||
const props = defineProps<{ visible: boolean }>()
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean]
|
||||
installed: [plugin: InstalledPlugin]
|
||||
}>()
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const mode = ref<'upload' | 'url'>('upload')
|
||||
const file = ref<File | null>(null)
|
||||
const url = ref('')
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const preview = ref<PluginPreview | null>(null)
|
||||
const busy = ref(false)
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(v) => {
|
||||
if (!v) return
|
||||
mode.value = 'upload'
|
||||
file.value = null
|
||||
url.value = ''
|
||||
preview.value = null
|
||||
},
|
||||
)
|
||||
|
||||
function setMode(m: 'upload' | 'url') {
|
||||
mode.value = m
|
||||
preview.value = null
|
||||
}
|
||||
|
||||
function onFile(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
file.value = input.files?.[0] ?? null
|
||||
preview.value = null
|
||||
input.value = ''
|
||||
if (file.value) void inspect()
|
||||
}
|
||||
|
||||
const source = computed<PackageSource | null>(() => {
|
||||
if (mode.value === 'upload') return file.value ? { file: file.value } : null
|
||||
return isPackageUrl(url.value) ? { url: url.value.trim() } : null
|
||||
})
|
||||
|
||||
const canConfirm = computed(() => !!source.value && !busy.value)
|
||||
const confirmText = computed(() =>
|
||||
preview.value ? t(`pluginAdmin.install.confirm.${preview.value.change}`) : t('pluginAdmin.install.inspect'),
|
||||
)
|
||||
|
||||
const contributions = computed(() => (preview.value ? contributionLines(preview.value.manifest, locale.value) : []))
|
||||
const permissions = computed(() => permissionLines(preview.value?.manifest.permissions))
|
||||
const hosts = computed(() => (preview.value ? remoteHosts(preview.value.manifest) : []))
|
||||
const description = computed(() => (preview.value ? localizedText(preview.value.manifest.description, locale.value) : ''))
|
||||
const initial = computed(() =>
|
||||
(preview.value ? localizedText(preview.value.manifest.name, locale.value) : '?').trim().charAt(0).toUpperCase(),
|
||||
)
|
||||
const needsConfig = computed(
|
||||
() => !!preview.value && (hasSystemConfig(preview.value.manifest) || hasTenantConfig(preview.value.manifest)),
|
||||
)
|
||||
const changeTheme = computed(() => {
|
||||
switch (preview.value?.change) {
|
||||
case 'upgrade':
|
||||
return 'success'
|
||||
case 'downgrade':
|
||||
return 'warning'
|
||||
default:
|
||||
return 'primary'
|
||||
}
|
||||
})
|
||||
|
||||
async function inspect() {
|
||||
if (!source.value) return
|
||||
busy.value = true
|
||||
try {
|
||||
const res = await inspectPluginPackage(source.value)
|
||||
preview.value = res.data
|
||||
} catch (e: any) {
|
||||
preview.value = null
|
||||
MessagePlugin.error(e?.message || t('pluginAdmin.install.inspectFailed'))
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onConfirm() {
|
||||
if (!preview.value) {
|
||||
await inspect()
|
||||
return
|
||||
}
|
||||
if (!source.value) return
|
||||
busy.value = true
|
||||
try {
|
||||
const res = await installPluginPackage(source.value, preview.value.digest)
|
||||
MessagePlugin.success(t('pluginAdmin.install.done', { name: localizedText(preview.value.manifest.name, locale.value) }))
|
||||
emit('installed', res.data)
|
||||
emit('update:visible', false)
|
||||
} catch (e: any) {
|
||||
MessagePlugin.error(e?.message || t('pluginAdmin.install.failed'))
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
@import (reference) '@/components/css/provider-card.less';
|
||||
|
||||
.option-chips {
|
||||
display: inline-flex;
|
||||
align-self: flex-start;
|
||||
gap: 4px;
|
||||
padding: 3px;
|
||||
border-radius: var(--app-radius-md);
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
}
|
||||
|
||||
.option-chip {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--td-text-color-secondary);
|
||||
font: inherit;
|
||||
font-size: var(--app-text-sm);
|
||||
padding: 5px 12px;
|
||||
border-radius: var(--app-radius-sm);
|
||||
cursor: pointer;
|
||||
|
||||
&--active {
|
||||
background: var(--td-bg-color-container);
|
||||
color: var(--td-brand-color);
|
||||
font-weight: 500;
|
||||
box-shadow: var(--td-shadow-1);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: var(--app-text-md);
|
||||
font-weight: 500;
|
||||
color: var(--td-text-color-primary);
|
||||
|
||||
&.required::before {
|
||||
content: '*';
|
||||
color: var(--td-error-color);
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-desc {
|
||||
margin: 4px 0 0;
|
||||
font-size: var(--app-text-sm);
|
||||
line-height: 1.5;
|
||||
color: var(--td-text-color-placeholder);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.package-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
|
||||
&__name {
|
||||
font-size: var(--app-text-sm);
|
||||
color: var(--td-text-color-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.review-head {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
&__badge {
|
||||
.provider-card-badge();
|
||||
.provider-card-badge-color(#0052d9);
|
||||
}
|
||||
|
||||
&__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: var(--app-text-lg);
|
||||
font-weight: 600;
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
|
||||
&__meta {
|
||||
font-size: var(--app-text-xs);
|
||||
font-family: var(--app-font-family-mono);
|
||||
color: var(--td-text-color-placeholder);
|
||||
}
|
||||
|
||||
&__desc {
|
||||
margin: 4px 0 0;
|
||||
font-size: var(--app-text-sm);
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.review-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
&__title {
|
||||
font-size: var(--app-text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.review-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
font-size: var(--app-text-sm);
|
||||
}
|
||||
|
||||
&__point {
|
||||
flex: none;
|
||||
font-size: var(--app-text-xs);
|
||||
color: var(--td-text-color-secondary);
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
border-radius: var(--app-radius-xs);
|
||||
padding: 1px 6px;
|
||||
}
|
||||
|
||||
&__name {
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
|
||||
&__detail {
|
||||
font-size: var(--app-text-xs);
|
||||
color: var(--td-text-color-placeholder);
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
.review-alert {
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// pluginRepository stores installed plugins (plugins, plugin_versions;
|
||||
// migration 000116). Both tables stay small, so nothing paginates.
|
||||
type pluginRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewPluginRepository wires the repository into the container.
|
||||
func NewPluginRepository(db *gorm.DB) interfaces.PluginRepository {
|
||||
return &pluginRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *pluginRepository) ListPlugins(ctx context.Context) ([]types.InstalledPlugin, error) {
|
||||
var rows []types.InstalledPlugin
|
||||
err := r.db.WithContext(ctx).Order("id ASC").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (r *pluginRepository) GetPlugin(ctx context.Context, id string) (*types.InstalledPlugin, error) {
|
||||
var p types.InstalledPlugin
|
||||
err := r.db.WithContext(ctx).Where("id = ?", id).First(&p).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// SavePlugin inserts or updates the row keyed by id.
|
||||
func (r *pluginRepository) SavePlugin(ctx context.Context, p *types.InstalledPlugin) error {
|
||||
return r.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"owner_tenant_id", "source", "active_version", "desired_state", "runtime",
|
||||
"granted_perms", "system_config", "updated_at",
|
||||
}),
|
||||
}).Create(p).Error
|
||||
}
|
||||
|
||||
func (r *pluginRepository) DeletePlugin(ctx context.Context, id string) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("plugin_id = ?", id).Delete(&types.PluginVersion{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Where("id = ?", id).Delete(&types.InstalledPlugin{}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (r *pluginRepository) ListVersions(ctx context.Context, pluginID string) ([]types.PluginVersion, error) {
|
||||
var rows []types.PluginVersion
|
||||
err := r.db.WithContext(ctx).Where("plugin_id = ?", pluginID).Order("created_at DESC").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (r *pluginRepository) GetVersion(ctx context.Context, pluginID, version string) (*types.PluginVersion, error) {
|
||||
var v types.PluginVersion
|
||||
err := r.db.WithContext(ctx).Where("plugin_id = ? AND version = ?", pluginID, version).First(&v).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
// SaveVersion inserts or updates the row keyed by (plugin_id, version).
|
||||
func (r *pluginRepository) SaveVersion(ctx context.Context, v *types.PluginVersion) error {
|
||||
return r.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "plugin_id"}, {Name: "version"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"digest", "manifest", "package_uri", "size", "created_by"}),
|
||||
}).Create(v).Error
|
||||
}
|
||||
@@ -29,10 +29,29 @@ func (r *pluginTenantSettingRepository) List(
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// Upsert writes the row keyed by (tenant_id, plugin_id).
|
||||
func (r *pluginTenantSettingRepository) Upsert(ctx context.Context, s *types.PluginTenantSetting) error {
|
||||
func (r *pluginTenantSettingRepository) Get(
|
||||
ctx context.Context, tenantID uint64, pluginID string,
|
||||
) (*types.PluginTenantSetting, error) {
|
||||
var rows []types.PluginTenantSetting
|
||||
err := r.db.WithContext(ctx).Where("tenant_id = ? AND plugin_id = ?", tenantID, pluginID).Limit(1).Find(&rows).Error
|
||||
if err != nil || len(rows) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
return &rows[0], nil
|
||||
}
|
||||
|
||||
// Upsert writes the row keyed by (tenant_id, plugin_id). On conflict only
|
||||
// the given columns change, so the switch and the configuration can be
|
||||
// saved independently.
|
||||
func (r *pluginTenantSettingRepository) Upsert(
|
||||
ctx context.Context, s *types.PluginTenantSetting, columns ...string,
|
||||
) error {
|
||||
if len(columns) == 0 {
|
||||
columns = []string{"enabled", "config"}
|
||||
}
|
||||
columns = append(columns, "updated_by", "updated_at")
|
||||
return r.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "tenant_id"}, {Name: "plugin_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"enabled", "config", "updated_by", "updated_at"}),
|
||||
DoUpdates: clause.AssignmentColumns(columns),
|
||||
}).Create(s).Error
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
func TestPluginRepository(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+uuid.NewString()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&types.InstalledPlugin{}, &types.PluginVersion{}))
|
||||
repo := NewPluginRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
got, err := repo.GetPlugin(ctx, "acme.kit")
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, got, "missing plugins read as nil")
|
||||
|
||||
now := time.Now()
|
||||
for _, v := range []string{"1.0.0", "1.1.0"} {
|
||||
require.NoError(t, repo.SaveVersion(ctx, &types.PluginVersion{
|
||||
PluginID: "acme.kit", Version: v, Digest: "sha256:" + v, Manifest: types.JSON(`{}`),
|
||||
PackageURI: "local://p-" + v, CreatedAt: now,
|
||||
}))
|
||||
now = now.Add(time.Second)
|
||||
}
|
||||
p := &types.InstalledPlugin{
|
||||
ID: "acme.kit", ActiveVersion: "1.0.0", DesiredState: types.PluginStateEnabled, Runtime: "declarative",
|
||||
Source: types.JSON(`{"kind":"upload"}`), GrantedPerms: types.JSON(`{}`),
|
||||
}
|
||||
require.NoError(t, repo.SavePlugin(ctx, p))
|
||||
p.ActiveVersion = "1.1.0"
|
||||
p.DesiredState = types.PluginStateDisabled
|
||||
require.NoError(t, repo.SavePlugin(ctx, p), "save is an upsert")
|
||||
|
||||
got, err = repo.GetPlugin(ctx, "acme.kit")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "1.1.0", got.ActiveVersion)
|
||||
require.Equal(t, types.PluginStateDisabled, got.DesiredState)
|
||||
|
||||
versions, err := repo.ListVersions(ctx, "acme.kit")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, versions, 2)
|
||||
require.Equal(t, "1.1.0", versions[0].Version, "newest first")
|
||||
|
||||
v, err := repo.GetVersion(ctx, "acme.kit", "1.0.0")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "local://p-1.0.0", v.PackageURI)
|
||||
|
||||
require.NoError(t, repo.DeletePlugin(ctx, "acme.kit"))
|
||||
all, err := repo.ListPlugins(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, all)
|
||||
versions, err = repo.ListVersions(ctx, "acme.kit")
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, versions, "deleting a plugin removes its versions")
|
||||
}
|
||||
|
||||
func TestPluginTenantSettingUpsertColumns(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+uuid.NewString()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&types.PluginTenantSetting{}))
|
||||
repo := NewPluginTenantSettingRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
got, err := repo.Get(ctx, 1, "acme.kit")
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, got)
|
||||
|
||||
require.NoError(t, repo.Upsert(ctx, &types.PluginTenantSetting{
|
||||
TenantID: 1, PluginID: "acme.kit", Enabled: true, UpdatedAt: time.Now(),
|
||||
}, "enabled"))
|
||||
require.NoError(t, repo.Upsert(ctx, &types.PluginTenantSetting{
|
||||
TenantID: 1, PluginID: "acme.kit", Enabled: false, Config: types.JSON(`{"region":"eu"}`), UpdatedAt: time.Now(),
|
||||
}, "config"))
|
||||
got, err = repo.Get(ctx, 1, "acme.kit")
|
||||
require.NoError(t, err)
|
||||
require.True(t, got.Enabled, "saving config must leave the switch alone")
|
||||
require.JSONEq(t, `{"region":"eu"}`, string(got.Config))
|
||||
|
||||
require.NoError(t, repo.Upsert(ctx, &types.PluginTenantSetting{
|
||||
TenantID: 1, PluginID: "acme.kit", Enabled: false, UpdatedAt: time.Now(),
|
||||
}, "enabled"))
|
||||
got, _ = repo.Get(ctx, 1, "acme.kit")
|
||||
require.False(t, got.Enabled)
|
||||
require.JSONEq(t, `{"region":"eu"}`, string(got.Config), "flipping the switch must keep the config")
|
||||
}
|
||||
@@ -39,13 +39,16 @@ func (s *schemaTestServer) repository(t *testing.T) *weaviateRepository {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/v1/schema/"):
|
||||
if s.probe != nil {
|
||||
s.probe()
|
||||
}
|
||||
// Read the state before the probe hook: a barrier in the hook
|
||||
// releases every worker at once, and one that reads after
|
||||
// another worker's create would see the class and never race.
|
||||
s.mu.Lock()
|
||||
s.probes++
|
||||
exists := s.exists
|
||||
s.mu.Unlock()
|
||||
if s.probe != nil {
|
||||
s.probe()
|
||||
}
|
||||
if !exists {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
|
||||
@@ -210,11 +210,12 @@ func (s *TenantSkillService) RegisterCatalogFromArchive(
|
||||
return s.upsertCatalogFromBundle(ctx, tenantID, bundle, archive, true)
|
||||
}
|
||||
|
||||
// RegisterCatalogFromSource fetches a public skill and records it in the catalog.
|
||||
// RegisterCatalogFromSource fetches a public skill, or takes a skill of an
|
||||
// enabled plugin, and records it in the catalog.
|
||||
func (s *TenantSkillService) RegisterCatalogFromSource(
|
||||
ctx context.Context, tenantID uint64, source string,
|
||||
) (*types.TenantSkillCatalogEntity, error) {
|
||||
bundle, archive, err := fetchNormalizedSkillBundle(ctx, source, s.sourceHTTP)
|
||||
bundle, archive, err := s.resolveSkillSource(ctx, tenantID, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -56,6 +56,19 @@ const (
|
||||
)
|
||||
|
||||
// TenantSkillService owns the skill image lifecycle for sandbox configs.
|
||||
// PluginSkillArchiver builds the bundle of a skill an installed plugin
|
||||
// provides, for a workspace that has the plugin enabled.
|
||||
type PluginSkillArchiver interface {
|
||||
Archive(ctx context.Context, tenantID uint64, source string) ([]byte, error)
|
||||
}
|
||||
|
||||
// pluginSkillSourcePrefix marks an install source naming a plugin skill.
|
||||
const pluginSkillSourcePrefix = "plugin:"
|
||||
|
||||
// SetPluginSkills lets installs name skills of installed plugins. It is
|
||||
// called once while the container is built.
|
||||
func (s *TenantSkillService) SetPluginSkills(a PluginSkillArchiver) { s.pluginSkills = a }
|
||||
|
||||
type TenantSkillService struct {
|
||||
skills repository.TenantSkillRepository
|
||||
configs repository.TenantSandboxConfigRepository
|
||||
@@ -87,6 +100,10 @@ type TenantSkillService struct {
|
||||
// default; tests inject httptest clients.
|
||||
sourceHTTP *http.Client
|
||||
|
||||
// pluginSkills turns a "plugin:<plugin>/<skill>" source into a bundle
|
||||
// from an installed plugin. Nil until the plugin runtime is wired.
|
||||
pluginSkills PluginSkillArchiver
|
||||
|
||||
// cleanupTimeout bounds one piece of compensating work. Injectable so a
|
||||
// test can let an install outlast it, which every real install does.
|
||||
cleanupTimeout time.Duration
|
||||
|
||||
@@ -106,13 +106,36 @@ func (s *TenantSkillService) InstallSkillFromSource(
|
||||
return "", err
|
||||
}
|
||||
|
||||
bundle, archive, err := fetchNormalizedSkillBundle(ctx, source, s.sourceHTTP)
|
||||
bundle, archive, err := s.resolveSkillSource(ctx, tenantID, source)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s.installParsedSkill(ctx, tenantID, configID, bundle, archive, skillArchiveUploaded)
|
||||
}
|
||||
|
||||
// resolveSkillSource turns a source into a parsed bundle: a skill of an
|
||||
// installed plugin the workspace enabled ("plugin:<plugin>/<skill>"), or a
|
||||
// public skill fetched over the network.
|
||||
func (s *TenantSkillService) resolveSkillSource(
|
||||
ctx context.Context, tenantID uint64, source string,
|
||||
) (*SkillBundle, []byte, error) {
|
||||
if !strings.HasPrefix(source, pluginSkillSourcePrefix) {
|
||||
return fetchNormalizedSkillBundle(ctx, source, s.sourceHTTP)
|
||||
}
|
||||
if s.pluginSkills == nil {
|
||||
return nil, nil, fmt.Errorf("%w: plugins are not available", ErrSkillSourceInvalid)
|
||||
}
|
||||
archive, err := s.pluginSkills.Archive(ctx, tenantID, source)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("%w: %v", ErrSkillSourceInvalid, err)
|
||||
}
|
||||
bundle, err := ParseSkillBundle(archive)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return bundle, archive, nil
|
||||
}
|
||||
|
||||
func skillSourceHTTPClient(override *http.Client) *http.Client {
|
||||
if override != nil {
|
||||
return override
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -588,3 +590,39 @@ func TestFetchSkillArchiveRejectsOversizeBody(t *testing.T) {
|
||||
require.ErrorIs(t, err, ErrSkillSourceInvalid)
|
||||
require.ErrorContains(t, err, "1 MB")
|
||||
}
|
||||
|
||||
type fakePluginSkills struct {
|
||||
archive []byte
|
||||
err error
|
||||
asked string
|
||||
}
|
||||
|
||||
func (f *fakePluginSkills) Archive(_ context.Context, _ uint64, source string) ([]byte, error) {
|
||||
f.asked = source
|
||||
return f.archive, f.err
|
||||
}
|
||||
|
||||
func TestResolveSkillSourceTakesPluginSkills(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := &TenantSkillService{}
|
||||
if _, _, err := s.resolveSkillSource(ctx, 1, "plugin:acme.kit/triage"); !errors.Is(err, ErrSkillSourceInvalid) {
|
||||
t.Fatalf("without the plugin runtime a plugin source is invalid, got %v", err)
|
||||
}
|
||||
|
||||
plugins := &fakePluginSkills{archive: zipBundle(t, map[string]string{
|
||||
"SKILL.md": "---\nname: triage\ndescription: Triage issues.\n---\nSteps.",
|
||||
})}
|
||||
s.SetPluginSkills(plugins)
|
||||
bundle, archive, err := s.resolveSkillSource(ctx, 1, "plugin:acme.kit/triage")
|
||||
if err != nil || bundle.Name != "triage" || len(archive) == 0 {
|
||||
t.Fatalf("resolve = %+v, %v", bundle, err)
|
||||
}
|
||||
if plugins.asked != "plugin:acme.kit/triage" {
|
||||
t.Fatalf("archiver asked for %q", plugins.asked)
|
||||
}
|
||||
|
||||
plugins.err = errors.New("no enabled plugin provides this skill")
|
||||
if _, _, err := s.resolveSkillSource(ctx, 1, "plugin:acme.kit/triage"); !errors.Is(err, ErrSkillSourceInvalid) {
|
||||
t.Fatalf("an unavailable plugin skill must be a bad source, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,8 +71,10 @@ import (
|
||||
"github.com/Tencent/WeKnora/internal/models/embedding"
|
||||
"github.com/Tencent/WeKnora/internal/models/limiter" // register built-in vendors
|
||||
"github.com/Tencent/WeKnora/internal/models/utils/ollama"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/activate"
|
||||
pluginbuiltin "github.com/Tencent/WeKnora/internal/plugin/builtin"
|
||||
plugindriver "github.com/Tencent/WeKnora/internal/plugin/driver"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/reconcile"
|
||||
pluginregistry "github.com/Tencent/WeKnora/internal/plugin/registry"
|
||||
plugintenancy "github.com/Tencent/WeKnora/internal/plugin/tenancy"
|
||||
"github.com/Tencent/WeKnora/internal/router"
|
||||
@@ -159,7 +161,11 @@ func BuildContainer(container *dig.Container) *dig.Container {
|
||||
must(container.Provide(repository.NewSystemSettingRepository))
|
||||
must(container.Provide(repository.NewModelCatalogRepository))
|
||||
must(container.Provide(neo4jRepo.NewNeo4jRepository))
|
||||
must(container.Provide(repository.NewMCPServiceRepository))
|
||||
// Installed plugins' MCP servers are listed alongside stored services.
|
||||
must(container.Provide(activate.NewMCPServers))
|
||||
must(container.Provide(activate.NewSkills))
|
||||
must(container.Provide(activate.NewModelVendors))
|
||||
must(container.Provide(newMCPServiceRepository))
|
||||
must(container.Provide(repository.NewMCPToolApprovalRepository))
|
||||
must(container.Provide(repository.NewMCPOAuthRepository))
|
||||
must(container.Provide(repository.NewTenantSandboxConfigRepository))
|
||||
@@ -584,8 +590,15 @@ func BuildContainer(container *dig.Container) *dig.Container {
|
||||
must(container.Provide(plugintenancy.NewService))
|
||||
must(container.Provide(func(s *plugintenancy.Service) interfaces.PluginGate { return s }))
|
||||
must(container.Invoke(installPluginGate))
|
||||
must(container.Provide(repository.NewPluginRepository))
|
||||
must(container.Invoke(bindPluginActivators))
|
||||
must(container.Provide(newPluginPackageStore))
|
||||
must(container.Provide(newPluginReconciler))
|
||||
must(container.Invoke(startPluginReconciler))
|
||||
must(container.Provide(newPluginInstaller))
|
||||
must(container.Provide(newPluginDrivers))
|
||||
must(container.Provide(handler.NewPluginHandler))
|
||||
must(container.Provide(handler.NewPluginAdminHandler))
|
||||
logger.Debugf(ctx, "[Container] HTTP handlers registered")
|
||||
|
||||
// Wire the chat package's local image resolver so multimodal chat can read
|
||||
@@ -1818,10 +1831,11 @@ func newPluginRegistry(
|
||||
})
|
||||
}
|
||||
|
||||
// newPluginDrivers returns the drivers that run plugin code. Only builtins
|
||||
// exist today; host, remote and kubernetes drivers join this set.
|
||||
func newPluginDrivers(reg *pluginregistry.Registry) *plugindriver.Set {
|
||||
return plugindriver.NewSet(plugindriver.NewBuiltin(reg.Plugin))
|
||||
// newPluginDrivers returns the drivers that run plugins: builtins and
|
||||
// declarative packages today; host, remote and kubernetes drivers join this
|
||||
// set.
|
||||
func newPluginDrivers(reg *pluginregistry.Registry, r *reconcile.Reconciler) *plugindriver.Set {
|
||||
return plugindriver.NewSet(plugindriver.NewBuiltin(reg.Plugin), r.Driver())
|
||||
}
|
||||
|
||||
// installPluginGate gives the integration handlers the tenant plugin switches,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/dig"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/application/repository"
|
||||
"github.com/Tencent/WeKnora/internal/application/service"
|
||||
"github.com/Tencent/WeKnora/internal/config"
|
||||
"github.com/Tencent/WeKnora/internal/handler"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/activate"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/install"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/reconcile"
|
||||
pluginregistry "github.com/Tencent/WeKnora/internal/plugin/registry"
|
||||
plugintenancy "github.com/Tencent/WeKnora/internal/plugin/tenancy"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
)
|
||||
|
||||
// newPluginPackageStore keeps plugin packages in the deployment's object
|
||||
// storage. Packages belong to the platform, not a tenant, so they skip the
|
||||
// tenant resource catalog.
|
||||
func newPluginPackageStore(cfg *config.Config) (reconcile.PackageStore, error) {
|
||||
fs, err := initRawFileService(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return reconcile.NewFileStore(fs), nil
|
||||
}
|
||||
|
||||
// pluginActivators are the domains installed plugins contribute to.
|
||||
type pluginActivators struct {
|
||||
dig.In
|
||||
|
||||
MCP *activate.MCPServers
|
||||
Skills *activate.Skills
|
||||
Vendors *activate.ModelVendors
|
||||
}
|
||||
|
||||
func (a pluginActivators) list() []reconcile.Activator {
|
||||
return []reconcile.Activator{a.Vendors, a.MCP, a.Skills}
|
||||
}
|
||||
|
||||
// bindPluginActivators hands the activators what they need once the plugin
|
||||
// services exist.
|
||||
func bindPluginActivators(
|
||||
a pluginActivators, t *plugintenancy.Service, repo interfaces.PluginRepository,
|
||||
skills *service.TenantSkillService,
|
||||
) {
|
||||
a.MCP.Bind(t, repo)
|
||||
a.Skills.Bind(t)
|
||||
skills.SetPluginSkills(a.Skills)
|
||||
}
|
||||
|
||||
func newMCPServiceRepository(db *gorm.DB, plugins *activate.MCPServers) interfaces.MCPServiceRepository {
|
||||
return plugins.Repository(repository.NewMCPServiceRepository(db))
|
||||
}
|
||||
|
||||
func newPluginReconciler(
|
||||
repo interfaces.PluginRepository,
|
||||
store reconcile.PackageStore,
|
||||
reg *pluginregistry.Registry,
|
||||
rdb *redis.Client,
|
||||
activators pluginActivators,
|
||||
) *reconcile.Reconciler {
|
||||
return reconcile.New(reconcile.Options{
|
||||
Repo: repo, Store: store, Registry: reg, Redis: rdb, Activators: activators.list(),
|
||||
})
|
||||
}
|
||||
|
||||
func newPluginInstaller(
|
||||
repo interfaces.PluginRepository,
|
||||
store reconcile.PackageStore,
|
||||
r *reconcile.Reconciler,
|
||||
) *install.Service {
|
||||
return install.NewService(repo, store, r, handler.Version).WithChecks(activate.CheckModelVendors)
|
||||
}
|
||||
|
||||
// startPluginReconciler loads installed plugins before the server takes
|
||||
// traffic, then keeps this node in step with the others.
|
||||
func startPluginReconciler(r *reconcile.Reconciler, cleaner interfaces.ResourceCleaner) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
r.Start(ctx)
|
||||
cleaner.RegisterWithName("PluginReconciler", func() error { cancel(); return nil })
|
||||
}
|
||||
@@ -36,6 +36,8 @@ var versionedSQLiteTables = []string{
|
||||
"tenant_skill_catalog",
|
||||
"tenant_user_env_vars",
|
||||
"plugin_tenant_settings",
|
||||
"plugins",
|
||||
"plugin_versions",
|
||||
}
|
||||
|
||||
// versionedSQLiteColumns maps each existing table to the columns that the
|
||||
@@ -70,7 +72,7 @@ var versionedSQLiteColumns = map[string][]string{
|
||||
}, // 000028
|
||||
}
|
||||
|
||||
const expectedSQLiteMigrationVersion = 34
|
||||
const expectedSQLiteMigrationVersion = 35
|
||||
|
||||
func TestSQLiteMigrationsCreateVersionedSchema(t *testing.T) {
|
||||
repoRoot := sqliteRepoRoot(t)
|
||||
|
||||
@@ -36,8 +36,12 @@ type MCPServiceResponse struct {
|
||||
StdioConfig *types.MCPStdioConfig `json:"stdio_config,omitempty"`
|
||||
EnvVars types.MCPEnvVars `json:"env_vars,omitempty"`
|
||||
IsBuiltin bool `json:"is_builtin"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
// PluginID and PluginError describe a service an installed plugin
|
||||
// provides; see types.MCPService.
|
||||
PluginID string `json:"plugin_id,omitempty"`
|
||||
PluginError string `json:"plugin_error,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
// Credentials is the per-field "configured?" map. Embedded on the main
|
||||
// response so the credential UI doesn't need a follow-up GET. The
|
||||
// frontend never sees the actual secret value — only whether one is
|
||||
@@ -99,6 +103,8 @@ func NewMCPServiceResponse(ctx context.Context, svc *types.MCPService) *MCPServi
|
||||
StdioConfig: svc.StdioConfig,
|
||||
EnvVars: svc.EnvVars,
|
||||
IsBuiltin: svc.IsBuiltin,
|
||||
PluginID: svc.PluginID,
|
||||
PluginError: svc.PluginError,
|
||||
CreatedAt: svc.CreatedAt,
|
||||
UpdatedAt: svc.UpdatedAt,
|
||||
}
|
||||
@@ -173,6 +179,8 @@ func NewSharedAgentMCPServiceResponses(svcs []*types.MCPService) []*MCPServiceRe
|
||||
Enabled: s.Enabled,
|
||||
TransportType: s.TransportType,
|
||||
IsBuiltin: s.IsBuiltin,
|
||||
PluginID: s.PluginID,
|
||||
PluginError: s.PluginError,
|
||||
})
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -7,7 +7,10 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/errors"
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/configschema"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/driver"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/install"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/manifest"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/registry"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/tenancy"
|
||||
@@ -216,3 +219,81 @@ func (h *PluginHandler) ListContributions(c *gin.Context) {
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
|
||||
}
|
||||
|
||||
// configError maps a plugin configuration failure to an HTTP error.
|
||||
func configError(c *gin.Context, err error) {
|
||||
var fields configschema.FieldErrors
|
||||
switch {
|
||||
case stderrors.As(err, &fields):
|
||||
_ = c.Error(errors.NewBadRequestError(err.Error()).WithDetails(fields))
|
||||
case stderrors.Is(err, tenancy.ErrUnknownPlugin), stderrors.Is(err, install.ErrNotInstalled):
|
||||
_ = c.Error(errors.NewNotFoundError("plugin not found"))
|
||||
case stderrors.Is(err, tenancy.ErrNoTenantConfig), stderrors.Is(err, install.ErrNoSystemConfig):
|
||||
_ = c.Error(errors.NewBadRequestError(err.Error()))
|
||||
default:
|
||||
logger.Errorf(c.Request.Context(), "[plugin] configuration request failed: %v", err)
|
||||
_ = c.Error(errors.NewInternalServerError("failed to handle plugin configuration"))
|
||||
}
|
||||
}
|
||||
|
||||
// PluginConfigRequest carries configuration values; secrets may be sent back
|
||||
// as "***" to keep them.
|
||||
type PluginConfigRequest struct {
|
||||
Values map[string]any `json:"values"`
|
||||
}
|
||||
|
||||
// GetPluginConfig godoc
|
||||
// @Summary 获取插件的空间配置
|
||||
// @Description 返回插件的空间级配置 Schema 和当前值(密钥脱敏为 ***)
|
||||
// @Tags Plugin
|
||||
// @Produce json
|
||||
// @Param id path string true "插件 ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Security Bearer
|
||||
// @Security ApiKeyAuth
|
||||
// @Router /plugins/{id}/config [get]
|
||||
func (h *PluginHandler) GetPluginConfig(c *gin.Context) {
|
||||
tenantID := c.GetUint64(types.TenantIDContextKey.String())
|
||||
if h.tenancy == nil || tenantID == 0 {
|
||||
_ = c.Error(errors.NewBadRequestError("workspace context missing"))
|
||||
return
|
||||
}
|
||||
cfg, err := h.tenancy.Config(c.Request.Context(), tenantID, c.Param("id"))
|
||||
if err != nil {
|
||||
configError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": cfg})
|
||||
}
|
||||
|
||||
// UpdatePluginConfig godoc
|
||||
// @Summary 保存插件的空间配置
|
||||
// @Description 按插件声明的 Schema 校验并保存空间级配置;密钥加密存储,回传 *** 表示保持不变
|
||||
// @Tags Plugin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "插件 ID"
|
||||
// @Param request body PluginConfigRequest true "配置"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Security Bearer
|
||||
// @Security ApiKeyAuth
|
||||
// @Router /plugins/{id}/config [put]
|
||||
func (h *PluginHandler) UpdatePluginConfig(c *gin.Context) {
|
||||
var req PluginConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
_ = c.Error(errors.NewBadRequestError("values are required"))
|
||||
return
|
||||
}
|
||||
tenantID := c.GetUint64(types.TenantIDContextKey.String())
|
||||
if h.tenancy == nil || tenantID == 0 {
|
||||
_ = c.Error(errors.NewBadRequestError("workspace context missing"))
|
||||
return
|
||||
}
|
||||
userID, _ := c.Request.Context().Value(types.UserIDContextKey).(string)
|
||||
cfg, err := h.tenancy.SetConfig(c.Request.Context(), tenantID, c.Param("id"), req.Values, userID)
|
||||
if err != nil {
|
||||
configError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": cfg})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
stderrors "errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/errors"
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/install"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/pkg"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// PluginAdminHandler lets system administrators install and manage plugins
|
||||
// for the whole platform.
|
||||
type PluginAdminHandler struct {
|
||||
service *install.Service
|
||||
}
|
||||
|
||||
// NewPluginAdminHandler creates a PluginAdminHandler.
|
||||
func NewPluginAdminHandler(service *install.Service) *PluginAdminHandler {
|
||||
return &PluginAdminHandler{service: service}
|
||||
}
|
||||
|
||||
// PluginPackageRequest locates a package by URL; uploads send the archive as
|
||||
// the multipart "file" field and the digest as a form field instead.
|
||||
type PluginPackageRequest struct {
|
||||
URL string `json:"url"`
|
||||
// Digest pins an install to the package reviewed with inspect.
|
||||
Digest string `json:"digest"`
|
||||
}
|
||||
|
||||
// readPackage returns the package archive from an upload or a URL.
|
||||
func (h *PluginAdminHandler) readPackage(c *gin.Context) ([]byte, install.Source, string, bool) {
|
||||
if strings.HasPrefix(c.ContentType(), "application/json") {
|
||||
limitJSONBody(c, skillSourceJSONMaxBytes)
|
||||
var req PluginPackageRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.URL) == "" {
|
||||
_ = c.Error(errors.NewBadRequestError("url or an uploaded file is required"))
|
||||
return nil, install.Source{}, "", false
|
||||
}
|
||||
data, err := h.service.FetchURL(c.Request.Context(), strings.TrimSpace(req.URL))
|
||||
if err != nil {
|
||||
h.fail(c, err)
|
||||
return nil, install.Source{}, "", false
|
||||
}
|
||||
return data, install.Source{Kind: "url", URL: strings.TrimSpace(req.URL)}, req.Digest, true
|
||||
}
|
||||
|
||||
limitUploadBody(c, pkg.MaxArchiveBytes)
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
if isRequestBodyTooLarge(err) {
|
||||
_ = c.Error(errors.NewBadRequestError("plugin package is too large"))
|
||||
} else {
|
||||
_ = c.Error(errors.NewBadRequestError("file is required"))
|
||||
}
|
||||
return nil, install.Source{}, "", false
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
data, err := io.ReadAll(io.LimitReader(file, pkg.MaxArchiveBytes+1))
|
||||
if err != nil {
|
||||
_ = c.Error(errors.NewBadRequestError("failed to read the uploaded package"))
|
||||
return nil, install.Source{}, "", false
|
||||
}
|
||||
if len(data) > pkg.MaxArchiveBytes {
|
||||
_ = c.Error(errors.NewBadRequestError("plugin package is too large"))
|
||||
return nil, install.Source{}, "", false
|
||||
}
|
||||
return data, install.Source{Kind: "upload", URL: header.Filename}, c.PostForm("digest"), true
|
||||
}
|
||||
|
||||
func (h *PluginAdminHandler) fail(c *gin.Context, err error) {
|
||||
var invalid *install.InvalidError
|
||||
switch {
|
||||
case stderrors.As(err, &invalid):
|
||||
_ = c.Error(errors.NewBadRequestError(err.Error()))
|
||||
case stderrors.Is(err, install.ErrNotInstalled):
|
||||
_ = c.Error(errors.NewNotFoundError("plugin is not installed"))
|
||||
default:
|
||||
logger.Errorf(c.Request.Context(), "[plugin] admin request failed: %v", err)
|
||||
_ = c.Error(errors.NewInternalServerError("plugin operation failed"))
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PluginAdminHandler) ok(c *gin.Context, data any) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": data})
|
||||
}
|
||||
|
||||
// InspectPlugin godoc
|
||||
// @Summary 检查插件包
|
||||
// @Description 解析上传的插件包(multipart file)或 URL(JSON {url}),返回清单、摘要和安装后的变化,不做任何修改
|
||||
// @Tags System
|
||||
// @Accept multipart/form-data,json
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Security Bearer
|
||||
// @Router /system/admin/plugins/inspect [post]
|
||||
func (h *PluginAdminHandler) InspectPlugin(c *gin.Context) {
|
||||
data, _, _, ok := h.readPackage(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
preview, err := h.service.Inspect(c.Request.Context(), data)
|
||||
if err != nil {
|
||||
h.fail(c, err)
|
||||
return
|
||||
}
|
||||
h.ok(c, preview)
|
||||
}
|
||||
|
||||
// InstallPlugin godoc
|
||||
// @Summary 安装或升级插件
|
||||
// @Description 安装插件包(multipart file + digest,或 JSON {url, digest})。digest 取自 inspect,保证安装的就是审阅过的包。安装后全平台可用,各空间需自行启用
|
||||
// @Tags System
|
||||
// @Accept multipart/form-data,json
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Security Bearer
|
||||
// @Router /system/admin/plugins [post]
|
||||
func (h *PluginAdminHandler) InstallPlugin(c *gin.Context) {
|
||||
data, source, digest, ok := h.readPackage(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID, _ := c.Request.Context().Value(types.UserIDContextKey).(string)
|
||||
view, err := h.service.Install(c.Request.Context(), install.Request{
|
||||
Data: data, Source: source, ExpectedDigest: digest, UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
h.fail(c, err)
|
||||
return
|
||||
}
|
||||
h.ok(c, view)
|
||||
}
|
||||
|
||||
// ListInstalledPlugins godoc
|
||||
// @Summary 列出已安装插件
|
||||
// @Description 列出系统管理员安装的插件(不含内置插件),含版本和各节点加载状态
|
||||
// @Tags System
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Security Bearer
|
||||
// @Router /system/admin/plugins [get]
|
||||
func (h *PluginAdminHandler) ListInstalledPlugins(c *gin.Context) {
|
||||
views, err := h.service.List(c.Request.Context())
|
||||
if err != nil {
|
||||
h.fail(c, err)
|
||||
return
|
||||
}
|
||||
h.ok(c, views)
|
||||
}
|
||||
|
||||
// GetInstalledPlugin godoc
|
||||
// @Summary 获取已安装插件
|
||||
// @Tags System
|
||||
// @Produce json
|
||||
// @Param id path string true "插件 ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Security Bearer
|
||||
// @Router /system/admin/plugins/{id} [get]
|
||||
func (h *PluginAdminHandler) GetInstalledPlugin(c *gin.Context) {
|
||||
view, err := h.service.Get(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
h.fail(c, err)
|
||||
return
|
||||
}
|
||||
h.ok(c, view)
|
||||
}
|
||||
|
||||
// SetInstalledPluginEnabled godoc
|
||||
// @Summary 全平台启用或停用插件
|
||||
// @Description 停用后所有节点卸载该插件;各空间的开关和配置保留
|
||||
// @Tags System
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "插件 ID"
|
||||
// @Param request body SetPluginEnabledRequest true "开关"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Security Bearer
|
||||
// @Router /system/admin/plugins/{id}/enabled [put]
|
||||
func (h *PluginAdminHandler) SetInstalledPluginEnabled(c *gin.Context) {
|
||||
var req SetPluginEnabledRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
_ = c.Error(errors.NewBadRequestError("enabled is required"))
|
||||
return
|
||||
}
|
||||
view, err := h.service.SetEnabled(c.Request.Context(), c.Param("id"), *req.Enabled)
|
||||
if err != nil {
|
||||
h.fail(c, err)
|
||||
return
|
||||
}
|
||||
h.ok(c, view)
|
||||
}
|
||||
|
||||
// ActivatePluginVersionRequest picks a stored version.
|
||||
type ActivatePluginVersionRequest struct {
|
||||
Version string `json:"version" binding:"required"`
|
||||
}
|
||||
|
||||
// ActivatePluginVersion godoc
|
||||
// @Summary 切换插件版本
|
||||
// @Description 把已存储的某个版本设为当前版本(回滚或重新升级)
|
||||
// @Tags System
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "插件 ID"
|
||||
// @Param request body ActivatePluginVersionRequest true "版本"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Security Bearer
|
||||
// @Router /system/admin/plugins/{id}/active-version [put]
|
||||
func (h *PluginAdminHandler) ActivatePluginVersion(c *gin.Context) {
|
||||
var req ActivatePluginVersionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
_ = c.Error(errors.NewBadRequestError("version is required"))
|
||||
return
|
||||
}
|
||||
view, err := h.service.Activate(c.Request.Context(), c.Param("id"), req.Version)
|
||||
if err != nil {
|
||||
h.fail(c, err)
|
||||
return
|
||||
}
|
||||
h.ok(c, view)
|
||||
}
|
||||
|
||||
// UninstallPlugin godoc
|
||||
// @Summary 卸载插件
|
||||
// @Description 删除插件及其全部版本;各空间的开关和配置保留,重新安装后恢复
|
||||
// @Tags System
|
||||
// @Produce json
|
||||
// @Param id path string true "插件 ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Security Bearer
|
||||
// @Router /system/admin/plugins/{id} [delete]
|
||||
func (h *PluginAdminHandler) UninstallPlugin(c *gin.Context) {
|
||||
if err := h.service.Uninstall(c.Request.Context(), c.Param("id")); err != nil {
|
||||
h.fail(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// GetPluginSystemConfig godoc
|
||||
// @Summary 获取插件的平台配置
|
||||
// @Description 返回插件的平台级配置 Schema 和当前值(密钥脱敏为 ***)
|
||||
// @Tags System
|
||||
// @Produce json
|
||||
// @Param id path string true "插件 ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Security Bearer
|
||||
// @Router /system/admin/plugins/{id}/config [get]
|
||||
func (h *PluginAdminHandler) GetPluginSystemConfig(c *gin.Context) {
|
||||
cfg, err := h.service.GetSystemConfig(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
configError(c, err)
|
||||
return
|
||||
}
|
||||
h.ok(c, cfg)
|
||||
}
|
||||
|
||||
// UpdatePluginSystemConfig godoc
|
||||
// @Summary 保存插件的平台配置
|
||||
// @Description 按插件声明的 Schema 校验并保存平台级配置,对所有空间生效
|
||||
// @Tags System
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "插件 ID"
|
||||
// @Param request body PluginConfigRequest true "配置"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Security Bearer
|
||||
// @Router /system/admin/plugins/{id}/config [put]
|
||||
func (h *PluginAdminHandler) UpdatePluginSystemConfig(c *gin.Context) {
|
||||
var req PluginConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
_ = c.Error(errors.NewBadRequestError("values are required"))
|
||||
return
|
||||
}
|
||||
cfg, err := h.service.SetSystemConfig(c.Request.Context(), c.Param("id"), req.Values)
|
||||
if err != nil {
|
||||
configError(c, err)
|
||||
return
|
||||
}
|
||||
h.ok(c, cfg)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -13,6 +12,7 @@ import (
|
||||
"github.com/Tencent/WeKnora/internal/middleware"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/driver"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/manifest"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/plugintest"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/registry"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/tenancy"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
@@ -115,23 +115,6 @@ func TestPluginHandlerErrors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type memPluginSettings struct {
|
||||
rows map[string]types.PluginTenantSetting
|
||||
}
|
||||
|
||||
func (m *memPluginSettings) List(context.Context, uint64) ([]types.PluginTenantSetting, error) {
|
||||
out := make([]types.PluginTenantSetting, 0, len(m.rows))
|
||||
for _, r := range m.rows {
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *memPluginSettings) Upsert(_ context.Context, s *types.PluginTenantSetting) error {
|
||||
m.rows[s.PluginID] = *s
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestPluginHandlerTogglesPluginsPerTenant(t *testing.T) {
|
||||
reg := registry.New()
|
||||
builtinPlugin := func(id string, required bool, point manifest.Point, localID string) *manifest.Manifest {
|
||||
@@ -152,7 +135,7 @@ func TestPluginHandlerTogglesPluginsPerTenant(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
settings := &memPluginSettings{rows: map[string]types.PluginTenantSetting{}}
|
||||
settings := &plugintest.MemTenantSettings{}
|
||||
h := NewPluginHandler(reg, tenancy.NewService(reg, settings), nil)
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
|
||||
@@ -358,8 +358,9 @@ type skillSourceRequest struct {
|
||||
// Source is exactly one of: "@owner/slug" or a slash-free slug (ClawHub),
|
||||
// a github.com / gitlab.com / skills.sh / clawhub / skillhub page URL, a
|
||||
// ClawHub skills-sh catalog page or "skills-sh:owner/repo/slug" locator, or
|
||||
// a direct zip/SKILL.md URL. Bare "owner/slug" is rejected: it is both a
|
||||
// ClawHub id and a GitHub repo. The fetch carries no credential.
|
||||
// a direct zip/SKILL.md URL, or "plugin:<plugin id>/<skill id>" for a skill
|
||||
// an installed plugin provides. Bare "owner/slug" is rejected: it is both
|
||||
// a ClawHub id and a GitHub repo. The fetch carries no credential.
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
|
||||
@@ -151,20 +151,9 @@ func (rt *Runtime) applyOverlayValidated(data []byte, baseDir string, validate f
|
||||
}
|
||||
vendor, exists := next[id]
|
||||
if !exists {
|
||||
vendor = &Provider{Definition: &providers.Definition{
|
||||
ID: id,
|
||||
Name: id,
|
||||
API: api.APIOpenAICompletions,
|
||||
DefaultBaseURLs: map[types.ModelType]string{},
|
||||
ModelTypes: []types.ModelType{
|
||||
types.ModelTypeKnowledgeQA,
|
||||
},
|
||||
RequiresAuth: true,
|
||||
Auth: providers.AuthBearer,
|
||||
Order: 1000,
|
||||
}, catalog: catalog.New(nil)}
|
||||
vendor = newOverlayVendor(id)
|
||||
}
|
||||
if err := applyOverlayProvider(vendor, p, baseDir); err != nil {
|
||||
if err := applyOverlayProvider(vendor, p, baseDir, interpolateEnv); err != nil {
|
||||
return fmt.Errorf("provider %s: %w", id, err)
|
||||
}
|
||||
normalizeProvider(vendor)
|
||||
@@ -180,7 +169,26 @@ func (rt *Runtime) applyOverlayValidated(data []byte, baseDir string, validate f
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyOverlayProvider(v *Provider, p OverlayProvider, baseDir string) error {
|
||||
// newOverlayVendor is the starting point of a vendor an overlay declares.
|
||||
func newOverlayVendor(id string) *Provider {
|
||||
return &Provider{Definition: &providers.Definition{
|
||||
ID: id,
|
||||
Name: id,
|
||||
API: api.APIOpenAICompletions,
|
||||
DefaultBaseURLs: map[types.ModelType]string{},
|
||||
ModelTypes: []types.ModelType{
|
||||
types.ModelTypeKnowledgeQA,
|
||||
},
|
||||
RequiresAuth: true,
|
||||
Auth: providers.AuthBearer,
|
||||
Order: 1000,
|
||||
}, catalog: catalog.New(nil)}
|
||||
}
|
||||
|
||||
// applyOverlayProvider patches v. expand rewrites URLs, the API key and
|
||||
// header values: the deployment overlay expands environment variables, a
|
||||
// plugin's definition is taken literally.
|
||||
func applyOverlayProvider(v *Provider, p OverlayProvider, baseDir string, expand func(string) string) error {
|
||||
entries := v.Models()
|
||||
if p.Name != "" {
|
||||
v.Name = p.Name
|
||||
@@ -204,22 +212,22 @@ func applyOverlayProvider(v *Provider, p OverlayProvider, baseDir string) error
|
||||
v.API = p.API
|
||||
}
|
||||
if p.BaseURL != "" {
|
||||
v.DefaultBaseURLs[types.ModelTypeKnowledgeQA] = interpolateEnv(p.BaseURL)
|
||||
v.DefaultBaseURLs[types.ModelTypeKnowledgeQA] = expand(p.BaseURL)
|
||||
}
|
||||
for k, u := range p.BaseURLs {
|
||||
t, ok := models.ParseModelType(k)
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown model type %q in base_urls", k)
|
||||
}
|
||||
v.DefaultBaseURLs[t] = interpolateEnv(u)
|
||||
v.DefaultBaseURLs[t] = expand(u)
|
||||
}
|
||||
if p.APIKey != "" {
|
||||
v.DefaultAPIKey = interpolateEnv(p.APIKey)
|
||||
v.DefaultAPIKey = expand(p.APIKey)
|
||||
}
|
||||
if len(p.Headers) > 0 {
|
||||
headers := make(map[string]string, len(p.Headers))
|
||||
for k, val := range p.Headers {
|
||||
headers[k] = interpolateEnv(val)
|
||||
headers[k] = expand(val)
|
||||
}
|
||||
v.Headers = headers
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RegisterPlugin adds a vendor an installed plugin declares. The definition
|
||||
// uses the deployment overlay format for one provider, with two limits: it
|
||||
// is taken literally (no ${ENV} expansion, which would hand a plugin the
|
||||
// server's environment) and it carries no deployment API key, since every
|
||||
// workspace brings its own. It cannot replace a built-in or operator
|
||||
// vendor; registering the same plugin vendor again replaces it. baseDir
|
||||
// resolves the icon path inside the plugin package.
|
||||
//
|
||||
// The vendor joins the baseline, so later deployment overlay reloads keep
|
||||
// it and may patch it like a built-in.
|
||||
func (rt *Runtime) RegisterPlugin(id string, definition []byte, baseDir string) error {
|
||||
id = strings.ToLower(strings.TrimSpace(id))
|
||||
if id == "" {
|
||||
return errors.New("vendor id is empty")
|
||||
}
|
||||
var p OverlayProvider
|
||||
dec := json.NewDecoder(bytesReader(definition))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&p); err != nil {
|
||||
return fmt.Errorf("parse vendor definition: %w", err)
|
||||
}
|
||||
if err := dec.Decode(new(any)); err != io.EOF {
|
||||
return errors.New("parse vendor definition: expected a single JSON document")
|
||||
}
|
||||
if p.APIKey != "" {
|
||||
return errors.New("a plugin vendor cannot set api_key; workspaces supply their own keys")
|
||||
}
|
||||
vendor := newOverlayVendor(id)
|
||||
if err := applyOverlayProvider(vendor, p, baseDir, func(s string) string { return s }); err != nil {
|
||||
return err
|
||||
}
|
||||
normalizeProvider(vendor)
|
||||
if err := validateDefinition(vendor); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rt.mu.Lock()
|
||||
defer rt.mu.Unlock()
|
||||
if _, taken := rt.providers[id]; taken && !rt.plugins[id] {
|
||||
return fmt.Errorf("vendor %s already exists", id)
|
||||
}
|
||||
if rt.plugins == nil {
|
||||
rt.plugins = map[string]bool{}
|
||||
}
|
||||
rt.plugins[id] = true
|
||||
rt.providers[id] = vendor
|
||||
rt.builtins[id] = vendor.clone()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unregister removes a vendor RegisterPlugin added. Other vendors stay.
|
||||
func (rt *Runtime) Unregister(id string) {
|
||||
id = strings.ToLower(strings.TrimSpace(id))
|
||||
rt.mu.Lock()
|
||||
defer rt.mu.Unlock()
|
||||
if !rt.plugins[id] {
|
||||
return
|
||||
}
|
||||
delete(rt.plugins, id)
|
||||
delete(rt.providers, id)
|
||||
delete(rt.builtins, id)
|
||||
}
|
||||
|
||||
// RegisterPlugin adds a plugin vendor to the default runtime.
|
||||
func RegisterPlugin(id string, definition []byte, baseDir string) error {
|
||||
return Default().RegisterPlugin(id, definition, baseDir)
|
||||
}
|
||||
|
||||
// Unregister removes a plugin vendor from the default runtime.
|
||||
func Unregister(id string) { Default().Unregister(id) }
|
||||
@@ -0,0 +1,57 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
func TestRegisterPluginVendor(t *testing.T) {
|
||||
t.Setenv("SECRET_FOR_TEST", "leaked")
|
||||
rt := New()
|
||||
def := `{"name":"ACME AI","base_url":"https://api.acme.example/${SECRET_FOR_TEST}/v1",
|
||||
"headers":{"X-Leak":"$SECRET_FOR_TEST"},"model_types":["chat","embedding"],
|
||||
"models":[{"id":"acme-large","context_window":128000}]}`
|
||||
if err := rt.RegisterPlugin("acme.ai/acme", []byte(def), t.TempDir()); err != nil {
|
||||
t.Fatalf("RegisterPlugin: %v", err)
|
||||
}
|
||||
v, ok := rt.Get("acme.ai/acme")
|
||||
if !ok {
|
||||
t.Fatal("vendor not registered")
|
||||
}
|
||||
if url := v.DefaultBaseURLs[types.ModelTypeKnowledgeQA]; strings.Contains(url, "leaked") ||
|
||||
strings.Contains(v.Headers["X-Leak"], "leaked") {
|
||||
t.Fatalf("a plugin must not read the server environment: %s %v", url, v.Headers)
|
||||
}
|
||||
if _, ok := v.FindModel("acme-large", types.ModelTypeKnowledgeQA); !ok {
|
||||
t.Fatal("catalog entry missing")
|
||||
}
|
||||
|
||||
// A deployment overlay reload keeps plugin vendors.
|
||||
if err := rt.Reload([]byte(`{"providers":{}}`), ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := rt.Get("acme.ai/acme"); !ok {
|
||||
t.Fatal("reload dropped the plugin vendor")
|
||||
}
|
||||
|
||||
if err := rt.RegisterPlugin("openai", []byte(`{"name":"Fake"}`), ""); err == nil {
|
||||
t.Fatal("a plugin must not replace a built-in vendor")
|
||||
}
|
||||
if err := rt.RegisterPlugin("acme.ai/keyed", []byte(`{"api_key":"sk-1"}`), ""); err == nil {
|
||||
t.Fatal("a plugin vendor must not carry a deployment key")
|
||||
}
|
||||
if err := rt.RegisterPlugin("acme.ai/typo", []byte(`{"nmae":"x"}`), ""); err == nil {
|
||||
t.Fatal("unknown keys must be rejected")
|
||||
}
|
||||
|
||||
rt.Unregister("openai")
|
||||
if _, ok := rt.Get("openai"); !ok {
|
||||
t.Fatal("Unregister must not remove built-ins")
|
||||
}
|
||||
rt.Unregister("acme.ai/acme")
|
||||
if _, ok := rt.Get("acme.ai/acme"); ok {
|
||||
t.Fatal("Unregister left the vendor")
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,8 @@ type Runtime struct {
|
||||
mu sync.RWMutex
|
||||
providers map[string]*Provider
|
||||
builtins map[string]*Provider
|
||||
// plugins marks the vendors installed plugins added (RegisterPlugin).
|
||||
plugins map[string]bool
|
||||
}
|
||||
|
||||
// New explicitly composes fresh built-in definitions and independent catalogs.
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
// Package activate connects installed plugins' declarative contributions to
|
||||
// the domains that use them: MCP servers, skills and model vendors.
|
||||
package activate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/install"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/manifest"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/reconcile"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/tenancy"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
)
|
||||
|
||||
// ErrPluginService is returned when a caller tries to change an MCP service
|
||||
// a plugin provides.
|
||||
var ErrPluginService = errors.New("this MCP service is provided by a plugin and cannot be changed")
|
||||
|
||||
// mcpNamespace seeds the IDs of plugin MCP services.
|
||||
var mcpNamespace = uuid.MustParse("5b0c8e5e-4f7c-4a55-9d0e-6f1d0f4c7a21")
|
||||
|
||||
type mcpServer struct {
|
||||
manifest *manifest.Manifest
|
||||
contrib manifest.Contribution
|
||||
qualifiedID string
|
||||
activatedAt time.Time
|
||||
}
|
||||
|
||||
// MCPServers makes the MCP servers of installed plugins appear as read-only
|
||||
// MCP services in every workspace that enabled the plugin. Each workspace
|
||||
// gets its own service ID, because headers carry its own configuration.
|
||||
type MCPServers struct {
|
||||
mu sync.RWMutex
|
||||
tenancy *tenancy.Service
|
||||
plugins interfaces.PluginRepository
|
||||
servers map[string][]mcpServer // plugin ID → servers
|
||||
// directories are the tool directory snapshots of plugin services, by
|
||||
// plugin ID. They cannot be stored (mcp_metadata references stored
|
||||
// services) and are cheap to list again, so each node keeps its own and
|
||||
// drops a plugin's when it is loaded again or unloaded.
|
||||
directories map[string]map[string]*types.MCPMetadata
|
||||
}
|
||||
|
||||
// NewMCPServers creates the MCP server activator. It lists nothing until
|
||||
// Bind gives it the tenant switches: the MCP repository it decorates is
|
||||
// needed long before the plugin services exist.
|
||||
func NewMCPServers() *MCPServers {
|
||||
return &MCPServers{servers: map[string][]mcpServer{}, directories: map[string]map[string]*types.MCPMetadata{}}
|
||||
}
|
||||
|
||||
// Bind supplies the tenant switches and configuration and the installed
|
||||
// plugin rows (for platform configuration).
|
||||
func (a *MCPServers) Bind(t *tenancy.Service, plugins interfaces.PluginRepository) {
|
||||
a.mu.Lock()
|
||||
a.tenancy, a.plugins = t, plugins
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
// Name implements reconcile.Activator.
|
||||
func (a *MCPServers) Name() string { return "mcpServers" }
|
||||
|
||||
// Activate implements reconcile.Activator.
|
||||
func (a *MCPServers) Activate(_ context.Context, l *reconcile.Loaded) error {
|
||||
var list []mcpServer
|
||||
now := time.Now()
|
||||
for _, c := range l.Manifest.Contributes[manifest.PointMCPServers] {
|
||||
if c.MCP == nil {
|
||||
continue
|
||||
}
|
||||
list = append(list, mcpServer{
|
||||
manifest: l.Manifest, contrib: c, qualifiedID: l.Manifest.ID + "/" + c.ID, activatedAt: now,
|
||||
})
|
||||
}
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
delete(a.directories, l.Manifest.ID)
|
||||
if len(list) == 0 {
|
||||
delete(a.servers, l.Manifest.ID)
|
||||
} else {
|
||||
a.servers[l.Manifest.ID] = list
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deactivate implements reconcile.Activator.
|
||||
func (a *MCPServers) Deactivate(_ context.Context, pluginID string) error {
|
||||
a.mu.Lock()
|
||||
delete(a.servers, pluginID)
|
||||
delete(a.directories, pluginID)
|
||||
a.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// serviceID is a plugin MCP service's ID in one workspace. It is stable
|
||||
// across nodes and restarts, so agents can keep it in their configuration.
|
||||
func serviceID(tenantID uint64, qualifiedID string) string {
|
||||
return uuid.NewSHA1(mcpNamespace, fmt.Appendf(nil, "%d/%s", tenantID, qualifiedID)).String()
|
||||
}
|
||||
|
||||
func (a *MCPServers) snapshot() []mcpServer {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
if a.tenancy == nil {
|
||||
return nil
|
||||
}
|
||||
var out []mcpServer
|
||||
for _, list := range a.servers {
|
||||
out = append(out, list...)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].qualifiedID < out[j].qualifiedID })
|
||||
return out
|
||||
}
|
||||
|
||||
// Services returns the plugin MCP services a workspace sees.
|
||||
func (a *MCPServers) Services(ctx context.Context, tenantID uint64) []*types.MCPService {
|
||||
servers := a.snapshot()
|
||||
if len(servers) == 0 {
|
||||
return nil
|
||||
}
|
||||
enabled := a.tenancy.EnabledFilter(ctx, tenantID)
|
||||
var out []*types.MCPService
|
||||
for _, s := range servers {
|
||||
if !enabled(manifest.PointMCPServers, s.qualifiedID) {
|
||||
continue
|
||||
}
|
||||
out = append(out, a.service(ctx, tenantID, s))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// service builds one workspace's view of a plugin MCP server. A server whose
|
||||
// headers cannot be filled (the workspace has not configured the plugin yet)
|
||||
// is listed disabled, so agents skip it and the UI can say why.
|
||||
func (a *MCPServers) service(ctx context.Context, tenantID uint64, s mcpServer) *types.MCPService {
|
||||
url := s.contrib.MCP.URL
|
||||
transport := types.MCPTransportType(s.contrib.MCP.Transport)
|
||||
if transport == "" {
|
||||
transport = types.MCPTransportHTTPStreamable
|
||||
}
|
||||
svc := &types.MCPService{
|
||||
ID: serviceID(tenantID, s.qualifiedID),
|
||||
TenantID: tenantID,
|
||||
Name: s.contrib.Name.Default,
|
||||
Description: s.contrib.Description.Default,
|
||||
Enabled: true,
|
||||
TransportType: transport,
|
||||
URL: &url,
|
||||
IsBuiltin: true,
|
||||
PluginID: s.manifest.ID,
|
||||
CreatedAt: s.activatedAt,
|
||||
UpdatedAt: s.activatedAt,
|
||||
}
|
||||
headers, updated, err := a.headers(ctx, tenantID, s)
|
||||
if err != nil {
|
||||
logger.Debugf(ctx, "[plugin] MCP server %s in tenant %d: %v", s.qualifiedID, tenantID, err)
|
||||
svc.Enabled = false
|
||||
svc.PluginError = err.Error()
|
||||
return svc
|
||||
}
|
||||
svc.Headers = headers
|
||||
if updated.After(svc.UpdatedAt) {
|
||||
// The MCP manager reconnects when UpdatedAt moves, so a changed
|
||||
// credential takes effect on the next call.
|
||||
svc.UpdatedAt = updated
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
func (a *MCPServers) headers(ctx context.Context, tenantID uint64, s mcpServer) (types.MCPHeaders, time.Time, error) {
|
||||
var (
|
||||
tenantCfg, systemCfg map[string]any
|
||||
updated time.Time
|
||||
loadErr error
|
||||
)
|
||||
lookup := func(scope, key string) (string, bool) {
|
||||
var cfg map[string]any
|
||||
switch scope {
|
||||
case manifest.ScopeConfig:
|
||||
if tenantCfg == nil && loadErr == nil {
|
||||
var at time.Time
|
||||
tenantCfg, at, loadErr = a.tenancy.OpenConfig(ctx, tenantID, s.manifest.ID)
|
||||
updated = later(updated, at)
|
||||
}
|
||||
cfg = tenantCfg
|
||||
case manifest.ScopeSystem:
|
||||
if systemCfg == nil && loadErr == nil {
|
||||
var at time.Time
|
||||
systemCfg, at, loadErr = install.OpenSystemConfig(ctx, a.plugins, s.manifest)
|
||||
updated = later(updated, at)
|
||||
}
|
||||
cfg = systemCfg
|
||||
}
|
||||
v, ok := cfg[key]
|
||||
if !ok || v == nil {
|
||||
return "", false
|
||||
}
|
||||
return fmt.Sprint(v), true
|
||||
}
|
||||
out := types.MCPHeaders{}
|
||||
for name, value := range s.contrib.MCP.Headers {
|
||||
expanded, err := manifest.ExpandTemplate(value, lookup)
|
||||
if loadErr != nil {
|
||||
return nil, updated, loadErr
|
||||
}
|
||||
if err != nil {
|
||||
return nil, updated, err
|
||||
}
|
||||
out[name] = expanded
|
||||
}
|
||||
return out, updated, nil
|
||||
}
|
||||
|
||||
func later(a, b time.Time) time.Time {
|
||||
if b.After(a) {
|
||||
return b
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// find returns the plugin MCP service with an ID in a workspace.
|
||||
func (a *MCPServers) find(ctx context.Context, tenantID uint64, id string) *types.MCPService {
|
||||
for _, svc := range a.Services(ctx, tenantID) {
|
||||
if svc.ID == id {
|
||||
return svc
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// owns reports whether an ID belongs to a plugin MCP server in a workspace,
|
||||
// enabled or not.
|
||||
func (a *MCPServers) owns(tenantID uint64, id string) bool {
|
||||
for _, s := range a.snapshot() {
|
||||
if serviceID(tenantID, s.qualifiedID) == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Repository wraps the MCP service repository so plugin MCP services are
|
||||
// listed and resolved alongside stored ones and cannot be modified.
|
||||
func (a *MCPServers) Repository(inner interfaces.MCPServiceRepository) interfaces.MCPServiceRepository {
|
||||
return &mcpRepository{MCPServiceRepository: inner, plugins: a}
|
||||
}
|
||||
|
||||
type mcpRepository struct {
|
||||
interfaces.MCPServiceRepository
|
||||
plugins *MCPServers
|
||||
}
|
||||
|
||||
func (r *mcpRepository) GetByID(ctx context.Context, tenantID uint64, id string) (*types.MCPService, error) {
|
||||
if svc := r.plugins.find(ctx, tenantID, id); svc != nil {
|
||||
return svc, nil
|
||||
}
|
||||
return r.MCPServiceRepository.GetByID(ctx, tenantID, id)
|
||||
}
|
||||
|
||||
func (r *mcpRepository) List(ctx context.Context, tenantID uint64) ([]*types.MCPService, error) {
|
||||
stored, err := r.MCPServiceRepository.List(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(stored, r.plugins.Services(ctx, tenantID)...), nil
|
||||
}
|
||||
|
||||
func (r *mcpRepository) ListEnabled(ctx context.Context, tenantID uint64) ([]*types.MCPService, error) {
|
||||
stored, err := r.MCPServiceRepository.ListEnabled(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, svc := range r.plugins.Services(ctx, tenantID) {
|
||||
if svc.Enabled {
|
||||
stored = append(stored, svc)
|
||||
}
|
||||
}
|
||||
return stored, nil
|
||||
}
|
||||
|
||||
func (r *mcpRepository) ListByIDs(ctx context.Context, tenantID uint64, ids []string) ([]*types.MCPService, error) {
|
||||
stored, err := r.MCPServiceRepository.ListByIDs(ctx, tenantID, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
want := make(map[string]bool, len(ids))
|
||||
for _, id := range ids {
|
||||
want[id] = true
|
||||
}
|
||||
for _, svc := range r.plugins.Services(ctx, tenantID) {
|
||||
if want[svc.ID] {
|
||||
stored = append(stored, svc)
|
||||
}
|
||||
}
|
||||
return stored, nil
|
||||
}
|
||||
|
||||
func (r *mcpRepository) Update(ctx context.Context, svc *types.MCPService) error {
|
||||
if r.plugins.owns(svc.TenantID, svc.ID) {
|
||||
return ErrPluginService
|
||||
}
|
||||
return r.MCPServiceRepository.Update(ctx, svc)
|
||||
}
|
||||
|
||||
func (r *mcpRepository) Delete(ctx context.Context, tenantID uint64, id string) error {
|
||||
if r.plugins.owns(tenantID, id) {
|
||||
return ErrPluginService
|
||||
}
|
||||
return r.MCPServiceRepository.Delete(ctx, tenantID, id)
|
||||
}
|
||||
|
||||
// ownerOf is the plugin providing a service ID in a workspace, or "".
|
||||
func (a *MCPServers) ownerOf(tenantID uint64, id string) string {
|
||||
for _, s := range a.snapshot() {
|
||||
if serviceID(tenantID, s.qualifiedID) == id {
|
||||
return s.manifest.ID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func directoryKey(tenantID uint64, serviceID, principal string) string {
|
||||
return fmt.Sprintf("%d/%s/%s", tenantID, serviceID, principal)
|
||||
}
|
||||
|
||||
// The repository also stores tool directory snapshots
|
||||
// (interfaces.MCPMetadataRepository): plugin services' in memory, the rest
|
||||
// in the wrapped repository. Agents discover MCP tools through them.
|
||||
var _ interfaces.MCPMetadataRepository = (*mcpRepository)(nil)
|
||||
|
||||
func (r *mcpRepository) inner() (interfaces.MCPMetadataRepository, bool) {
|
||||
m, ok := r.MCPServiceRepository.(interfaces.MCPMetadataRepository)
|
||||
return m, ok
|
||||
}
|
||||
|
||||
// GetMetadata implements interfaces.MCPMetadataRepository.
|
||||
func (r *mcpRepository) GetMetadata(
|
||||
ctx context.Context, tenantID uint64, serviceID, principal string,
|
||||
) (*types.MCPMetadata, error) {
|
||||
if owner := r.plugins.ownerOf(tenantID, serviceID); owner != "" {
|
||||
a := r.plugins
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
if m := a.directories[owner][directoryKey(tenantID, serviceID, principal)]; m != nil {
|
||||
cp := *m
|
||||
return &cp, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
if inner, ok := r.inner(); ok {
|
||||
return inner.GetMetadata(ctx, tenantID, serviceID, principal)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ListMetadataSummaries implements interfaces.MCPMetadataRepository.
|
||||
func (r *mcpRepository) ListMetadataSummaries(
|
||||
ctx context.Context, tenantID uint64, principals []string,
|
||||
) ([]*types.MCPMetadataSummary, error) {
|
||||
var out []*types.MCPMetadataSummary
|
||||
if inner, ok := r.inner(); ok {
|
||||
rows, err := inner.ListMetadataSummaries(ctx, tenantID, principals)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = rows
|
||||
}
|
||||
wanted := map[string]bool{}
|
||||
for _, p := range principals {
|
||||
wanted[p] = true
|
||||
}
|
||||
a := r.plugins
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
for _, dirs := range a.directories {
|
||||
for _, m := range dirs {
|
||||
if m.TenantID == tenantID && wanted[m.Principal] {
|
||||
out = append(out, &types.MCPMetadataSummary{
|
||||
ServiceID: m.ServiceID, Principal: m.Principal, ConfigFingerprint: m.ConfigFingerprint,
|
||||
ToolCount: len(m.Tools), SyncedAt: m.SyncedAt, ServerName: m.ServerName,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SaveMetadata implements interfaces.MCPMetadataRepository.
|
||||
func (r *mcpRepository) SaveMetadata(ctx context.Context, snapshot *types.MCPMetadata) error {
|
||||
owner := r.plugins.ownerOf(snapshot.TenantID, snapshot.ServiceID)
|
||||
if owner == "" {
|
||||
inner, ok := r.inner()
|
||||
if !ok {
|
||||
return types.ErrMCPMetadataStorage
|
||||
}
|
||||
return inner.SaveMetadata(ctx, snapshot)
|
||||
}
|
||||
a := r.plugins
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
dirs := a.directories[owner]
|
||||
if dirs == nil {
|
||||
dirs = map[string]*types.MCPMetadata{}
|
||||
a.directories[owner] = dirs
|
||||
}
|
||||
key := directoryKey(snapshot.TenantID, snapshot.ServiceID, snapshot.Principal)
|
||||
// Like the stored table: an older refresh must not overwrite a newer one.
|
||||
if prev := dirs[key]; prev != nil && prev.SyncedAt.After(snapshot.SyncedAt) {
|
||||
return nil
|
||||
}
|
||||
cp := *snapshot
|
||||
dirs[key] = &cp
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package activate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/plugin/install"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/plugintest"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/reconcile"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/registry"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/tenancy"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
)
|
||||
|
||||
const searchManifest = `schemaVersion: 1
|
||||
id: acme.search
|
||||
version: 1.0.0
|
||||
name: { en-US: ACME Search }
|
||||
publisher: { id: acme }
|
||||
runtime: { type: declarative }
|
||||
config: { tenant: tenant.yaml, system: system.yaml }
|
||||
contributes:
|
||||
mcpServers:
|
||||
- id: search
|
||||
name: ACME Search
|
||||
mcp:
|
||||
url: https://mcp.acme.example/mcp
|
||||
headers:
|
||||
Authorization: Bearer ${config.api_key}
|
||||
X-Region: ${system.region}
|
||||
`
|
||||
|
||||
type env struct {
|
||||
ctx context.Context
|
||||
mcp *MCPServers
|
||||
tenancy *tenancy.Service
|
||||
installer *install.Service
|
||||
stored *fakeMCPRepo
|
||||
repo *mcpRepository
|
||||
}
|
||||
|
||||
// fakeMCPRepo stores one ordinary service per tenant.
|
||||
type fakeMCPRepo struct {
|
||||
updates int
|
||||
saved *types.MCPMetadata
|
||||
}
|
||||
|
||||
func (f *fakeMCPRepo) own(tenantID uint64) *types.MCPService {
|
||||
return &types.MCPService{ID: "stored", TenantID: tenantID, Name: "Mine", Enabled: true}
|
||||
}
|
||||
|
||||
func (f *fakeMCPRepo) Create(context.Context, *types.MCPService) error { return nil }
|
||||
func (f *fakeMCPRepo) GetByID(_ context.Context, t uint64, id string) (*types.MCPService, error) {
|
||||
if id == "stored" {
|
||||
return f.own(t), nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeMCPRepo) List(_ context.Context, t uint64) ([]*types.MCPService, error) {
|
||||
return []*types.MCPService{f.own(t)}, nil
|
||||
}
|
||||
|
||||
func (f *fakeMCPRepo) ListEnabled(_ context.Context, t uint64) ([]*types.MCPService, error) {
|
||||
return []*types.MCPService{f.own(t)}, nil
|
||||
}
|
||||
|
||||
func (f *fakeMCPRepo) ListByIDs(_ context.Context, t uint64, ids []string) ([]*types.MCPService, error) {
|
||||
for _, id := range ids {
|
||||
if id == "stored" {
|
||||
return []*types.MCPService{f.own(t)}, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeMCPRepo) Update(context.Context, *types.MCPService) error { f.updates++; return nil }
|
||||
func (f *fakeMCPRepo) Delete(context.Context, uint64, string) error { return nil }
|
||||
|
||||
func setup(t *testing.T) *env {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
plugins, store, reg := plugintest.NewMemRepo(), &plugintest.MemStore{}, registry.New()
|
||||
ten := tenancy.NewService(reg, &plugintest.MemTenantSettings{})
|
||||
mcp := NewMCPServers()
|
||||
mcp.Bind(ten, plugins)
|
||||
r := reconcile.New(reconcile.Options{
|
||||
Repo: plugins, Store: store, Registry: reg, CacheDir: t.TempDir(), Activators: []reconcile.Activator{mcp},
|
||||
})
|
||||
installer := install.NewService(plugins, store, r, "")
|
||||
pkg := plugintest.Zip(t, map[string]string{
|
||||
"plugin.yaml": searchManifest,
|
||||
"tenant.yaml": "type: object\nproperties: { api_key: { type: string, x-secret: true } }\nrequired: [api_key]\n",
|
||||
"system.yaml": "type: object\nproperties: { region: { type: string } }\n",
|
||||
})
|
||||
if _, err := installer.Install(ctx, install.Request{Data: pkg}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored := &fakeMCPRepo{}
|
||||
return &env{
|
||||
ctx: ctx, mcp: mcp, tenancy: ten, installer: installer, stored: stored,
|
||||
repo: mcp.Repository(stored).(*mcpRepository),
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginMCPServiceFollowsTenantSwitchAndConfig(t *testing.T) {
|
||||
e := setup(t)
|
||||
list, _ := e.repo.List(e.ctx, 1)
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("a plugin the tenant has not enabled must stay hidden, got %d services", len(list))
|
||||
}
|
||||
if err := e.tenancy.SetEnabled(e.ctx, 1, "acme.search", true, "u"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
list, _ = e.repo.List(e.ctx, 1)
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("want stored + plugin service, got %d", len(list))
|
||||
}
|
||||
svc := list[1]
|
||||
if svc.PluginID != "acme.search" || !svc.IsBuiltin || svc.Enabled || svc.PluginError == "" {
|
||||
t.Fatalf("an unconfigured plugin service must be listed disabled: %+v", svc)
|
||||
}
|
||||
enabled, _ := e.repo.ListEnabled(e.ctx, 1)
|
||||
if len(enabled) != 1 {
|
||||
t.Fatal("agents must not get an unconfigured plugin service")
|
||||
}
|
||||
|
||||
if _, err := e.installer.SetSystemConfig(e.ctx, "acme.search", map[string]any{"region": "eu"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := e.tenancy.SetConfig(e.ctx, 1, "acme.search", map[string]any{"api_key": "k-1"}, "u"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := e.repo.GetByID(e.ctx, 1, svc.ID)
|
||||
if err != nil || got == nil || !got.Enabled {
|
||||
t.Fatalf("GetByID = %+v, %v", got, err)
|
||||
}
|
||||
if got.Headers["Authorization"] != "Bearer k-1" || got.Headers["X-Region"] != "eu" {
|
||||
t.Fatalf("headers = %v", got.Headers)
|
||||
}
|
||||
if !got.UpdatedAt.After(svc.UpdatedAt) {
|
||||
t.Fatal("changing configuration must move UpdatedAt so the MCP client reconnects")
|
||||
}
|
||||
byIDs, _ := e.repo.ListByIDs(e.ctx, 1, []string{"stored", svc.ID})
|
||||
if len(byIDs) != 2 {
|
||||
t.Fatalf("ListByIDs = %d", len(byIDs))
|
||||
}
|
||||
|
||||
// Each tenant has its own service ID and configuration.
|
||||
if err := e.tenancy.SetEnabled(e.ctx, 2, "acme.search", true, "u"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other, _ := e.repo.List(e.ctx, 2)
|
||||
if other[1].ID == svc.ID || other[1].Enabled {
|
||||
t.Fatalf("tenant 2 = %+v", other[1])
|
||||
}
|
||||
|
||||
if err := e.repo.Update(e.ctx, got); !errors.Is(err, ErrPluginService) {
|
||||
t.Fatalf("Update = %v", err)
|
||||
}
|
||||
if err := e.repo.Delete(e.ctx, 1, svc.ID); !errors.Is(err, ErrPluginService) {
|
||||
t.Fatalf("Delete = %v", err)
|
||||
}
|
||||
|
||||
if _, err := e.installer.SetEnabled(e.ctx, "acme.search", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if list, _ := e.repo.List(e.ctx, 1); len(list) != 1 {
|
||||
t.Fatal("disabling the plugin platform-wide must remove its services")
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeMCPRepo) GetMetadata(_ context.Context, _ uint64, id, _ string) (*types.MCPMetadata, error) {
|
||||
if f.saved != nil && f.saved.ServiceID == id {
|
||||
return f.saved, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeMCPRepo) ListMetadataSummaries(context.Context, uint64, []string) ([]*types.MCPMetadataSummary, error) {
|
||||
if f.saved == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return []*types.MCPMetadataSummary{{ServiceID: f.saved.ServiceID}}, nil
|
||||
}
|
||||
|
||||
func (f *fakeMCPRepo) SaveMetadata(_ context.Context, m *types.MCPMetadata) error {
|
||||
f.saved = m
|
||||
return nil
|
||||
}
|
||||
|
||||
// Agents discover MCP tools through stored directory snapshots. Plugin
|
||||
// services have no row to reference, so the wrapper keeps theirs.
|
||||
func TestPluginMCPDirectories(t *testing.T) {
|
||||
e := setup(t)
|
||||
if err := e.tenancy.SetEnabled(e.ctx, 1, "acme.search", true, "u"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta, ok := e.mcp.Repository(e.stored).(interfaces.MCPMetadataRepository)
|
||||
if !ok {
|
||||
t.Fatal("the wrapped repository hides directory storage")
|
||||
}
|
||||
id := serviceID(1, "acme.search/search")
|
||||
older := time.Now().Add(-time.Minute)
|
||||
first := &types.MCPMetadata{
|
||||
TenantID: 1, ServiceID: id, Tools: []*types.MCPTool{{Name: "search"}}, SyncedAt: time.Now(),
|
||||
}
|
||||
if err := meta.SaveMetadata(e.ctx, first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// An older refresh arriving late does not win.
|
||||
_ = meta.SaveMetadata(e.ctx, &types.MCPMetadata{TenantID: 1, ServiceID: id, SyncedAt: older})
|
||||
got, err := meta.GetMetadata(e.ctx, 1, id, "")
|
||||
if err != nil || got == nil || len(got.Tools) != 1 {
|
||||
t.Fatalf("plugin directory = %+v, %v", got, err)
|
||||
}
|
||||
if other, _ := meta.GetMetadata(e.ctx, 2, id, ""); other != nil {
|
||||
t.Fatal("another workspace reads the directory")
|
||||
}
|
||||
if err := meta.SaveMetadata(e.ctx, &types.MCPMetadata{TenantID: 1, ServiceID: "stored"}); err != nil ||
|
||||
e.stored.saved == nil {
|
||||
t.Fatalf("stored services' directories go to the database: %v", err)
|
||||
}
|
||||
sums, _ := meta.ListMetadataSummaries(e.ctx, 1, []string{""})
|
||||
if len(sums) != 2 {
|
||||
t.Fatalf("summaries = %d", len(sums))
|
||||
}
|
||||
if err := e.mcp.Deactivate(e.ctx, "acme.search"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(e.mcp.directories) != 0 {
|
||||
t.Fatal("an unloaded plugin keeps its directories")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package activate
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/plugin/manifest"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/reconcile"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/tenancy"
|
||||
)
|
||||
|
||||
// SkillSourcePrefix marks a skill install source that names a plugin skill:
|
||||
// "plugin:<plugin id>/<skill id>".
|
||||
const SkillSourcePrefix = "plugin:"
|
||||
|
||||
// ErrUnknownSkill is returned for a plugin skill no loaded, enabled plugin
|
||||
// provides.
|
||||
var ErrUnknownSkill = errors.New("no enabled plugin provides this skill")
|
||||
|
||||
// Skills offers the skills of installed plugins for installation into a
|
||||
// workspace's sandbox, through the same pipeline as an uploaded skill: the
|
||||
// sandbox is where skills run, so that is where a plugin skill has to go.
|
||||
type Skills struct {
|
||||
mu sync.RWMutex
|
||||
tenancy *tenancy.Service
|
||||
loaded map[string]*reconcile.Loaded
|
||||
}
|
||||
|
||||
// NewSkills creates the skills activator; Bind completes it.
|
||||
func NewSkills() *Skills { return &Skills{loaded: map[string]*reconcile.Loaded{}} }
|
||||
|
||||
// Bind supplies the tenant switches.
|
||||
func (a *Skills) Bind(t *tenancy.Service) {
|
||||
a.mu.Lock()
|
||||
a.tenancy = t
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
// Name implements reconcile.Activator.
|
||||
func (a *Skills) Name() string { return "skills" }
|
||||
|
||||
// Activate implements reconcile.Activator.
|
||||
func (a *Skills) Activate(_ context.Context, l *reconcile.Loaded) error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if len(l.Manifest.Contributes[manifest.PointSkills]) == 0 {
|
||||
delete(a.loaded, l.Manifest.ID)
|
||||
return nil
|
||||
}
|
||||
a.loaded[l.Manifest.ID] = l
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deactivate implements reconcile.Activator.
|
||||
func (a *Skills) Deactivate(_ context.Context, pluginID string) error {
|
||||
a.mu.Lock()
|
||||
delete(a.loaded, pluginID)
|
||||
a.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Archive returns a plugin skill as a skill bundle (a zip with SKILL.md at
|
||||
// its root) for a workspace that has the plugin enabled. source is the
|
||||
// qualified skill ID, with or without SkillSourcePrefix.
|
||||
func (a *Skills) Archive(ctx context.Context, tenantID uint64, source string) ([]byte, error) {
|
||||
qualified := strings.TrimPrefix(source, SkillSourcePrefix)
|
||||
pluginID, localID, ok := strings.Cut(qualified, "/")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %q is not <plugin>/<skill>", ErrUnknownSkill, qualified)
|
||||
}
|
||||
a.mu.RLock()
|
||||
l, loaded := a.loaded[pluginID]
|
||||
t := a.tenancy
|
||||
a.mu.RUnlock()
|
||||
if !loaded || t == nil || !t.ContributionEnabled(ctx, tenantID, manifest.PointSkills, qualified) {
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownSkill, qualified)
|
||||
}
|
||||
for _, c := range l.Manifest.Contributes[manifest.PointSkills] {
|
||||
if c.ID == localID {
|
||||
return zipDir(l, c.Path)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownSkill, qualified)
|
||||
}
|
||||
|
||||
func zipDir(l *reconcile.Loaded, dir string) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
prefix := strings.TrimSuffix(path.Clean(dir), "/") + "/"
|
||||
for _, name := range l.Package.Files(dir) {
|
||||
data, _ := l.Package.ReadFile(name)
|
||||
w, err := zw.Create(strings.TrimPrefix(name, prefix))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := w.Write(data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package activate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/application/service"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/plugintest"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/reconcile"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/registry"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/tenancy"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
func TestPluginSkillArchiveInstallsLikeAnUpload(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo, store, reg := plugintest.NewMemRepo(), &plugintest.MemStore{}, registry.New()
|
||||
ten := tenancy.NewService(reg, &plugintest.MemTenantSettings{})
|
||||
skills := NewSkills()
|
||||
skills.Bind(ten)
|
||||
r := reconcile.New(reconcile.Options{
|
||||
Repo: repo, Store: store, Registry: reg, CacheDir: t.TempDir(), Activators: []reconcile.Activator{skills},
|
||||
})
|
||||
plugintest.Install(t, repo, store, plugintest.KitPackageWith(t, "1.0.0",
|
||||
"Triage incoming issues by severity."), types.PluginStateEnabled)
|
||||
if err := r.Reconcile(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := skills.Archive(ctx, 1, "plugin:acme.kit/triage"); !errors.Is(err, ErrUnknownSkill) {
|
||||
t.Fatalf("a workspace without the plugin enabled must be refused, got %v", err)
|
||||
}
|
||||
if err := ten.SetEnabled(ctx, 1, "acme.kit", true, "u"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
archive, err := skills.Archive(ctx, 1, "plugin:acme.kit/triage")
|
||||
if err != nil {
|
||||
t.Fatalf("Archive: %v", err)
|
||||
}
|
||||
bundle, err := service.ParseSkillBundle(archive)
|
||||
if err != nil {
|
||||
t.Fatalf("the archive must be a valid skill bundle: %v", err)
|
||||
}
|
||||
if bundle.Name != "triage" {
|
||||
t.Fatalf("bundle = %+v", bundle)
|
||||
}
|
||||
for _, bad := range []string{"plugin:acme.kit/nope", "plugin:acme.kit", "plugin:other.kit/triage"} {
|
||||
if _, err := skills.Archive(ctx, 1, bad); !errors.Is(err, ErrUnknownSkill) {
|
||||
t.Errorf("Archive(%s) = %v", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package activate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
modelruntime "github.com/Tencent/WeKnora/internal/models/runtime"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/manifest"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/pkg"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/reconcile"
|
||||
)
|
||||
|
||||
// ModelVendors registers the model vendors of installed plugins with the
|
||||
// model runtime. A plugin vendor's ID is its qualified contribution ID
|
||||
// ("acme.ai/acme"), so it can never shadow a built-in vendor and the tenant
|
||||
// plugin switch resolves it directly.
|
||||
type ModelVendors struct {
|
||||
rt *modelruntime.Runtime
|
||||
|
||||
mu sync.Mutex
|
||||
registered map[string][]string // plugin ID → vendor IDs
|
||||
}
|
||||
|
||||
// NewModelVendors creates the model vendor activator on the default runtime.
|
||||
func NewModelVendors() *ModelVendors {
|
||||
return &ModelVendors{rt: modelruntime.Default(), registered: map[string][]string{}}
|
||||
}
|
||||
|
||||
// Name implements reconcile.Activator.
|
||||
func (a *ModelVendors) Name() string { return "modelVendors" }
|
||||
|
||||
// Activate implements reconcile.Activator. It registers all of a plugin's
|
||||
// vendors or none.
|
||||
func (a *ModelVendors) Activate(_ context.Context, l *reconcile.Loaded) error {
|
||||
var ids []string
|
||||
for _, c := range l.Manifest.Contributes[manifest.PointModelVendors] {
|
||||
id := manifest.QualifiedID(l.Manifest.ID, c.ID)
|
||||
def, err := vendorDefinition(l.Package, c)
|
||||
if err == nil {
|
||||
err = a.rt.RegisterPlugin(id, def, filepath.Join(l.Dir, filepath.FromSlash(path.Dir(c.Path))))
|
||||
}
|
||||
if err != nil {
|
||||
for _, done := range ids {
|
||||
a.rt.Unregister(done)
|
||||
}
|
||||
return fmt.Errorf("model vendor %s: %w", c.ID, err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
a.mu.Lock()
|
||||
a.registered[l.Manifest.ID] = ids
|
||||
a.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deactivate implements reconcile.Activator.
|
||||
func (a *ModelVendors) Deactivate(_ context.Context, pluginID string) error {
|
||||
a.mu.Lock()
|
||||
ids := a.registered[pluginID]
|
||||
delete(a.registered, pluginID)
|
||||
a.mu.Unlock()
|
||||
for _, id := range ids {
|
||||
a.rt.Unregister(id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// vendorDefinition reads one vendor file (YAML or JSON) as overlay JSON,
|
||||
// taking the name and description from the manifest when the file has none.
|
||||
func vendorDefinition(p *pkg.Package, c manifest.Contribution) ([]byte, error) {
|
||||
raw, ok := p.ReadFile(c.Path)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s is not in the package", c.Path)
|
||||
}
|
||||
var def map[string]any
|
||||
if err := yaml.Unmarshal(raw, &def); err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", c.Path, err)
|
||||
}
|
||||
if def == nil {
|
||||
return nil, fmt.Errorf("%s is empty", c.Path)
|
||||
}
|
||||
if _, ok := def["name"]; !ok && c.Name.Default != "" {
|
||||
def["name"] = c.Name.Default
|
||||
if len(c.Name.Locales) > 0 {
|
||||
def["names"] = c.Name.Locales
|
||||
}
|
||||
}
|
||||
if _, ok := def["description"]; !ok && c.Description.Default != "" {
|
||||
def["description"] = c.Description.Default
|
||||
if len(c.Description.Locales) > 0 {
|
||||
def["descriptions"] = c.Description.Locales
|
||||
}
|
||||
}
|
||||
return json.Marshal(def)
|
||||
}
|
||||
|
||||
// CheckModelVendors verifies at install time that every vendor a package
|
||||
// declares would register, against a scratch runtime.
|
||||
func CheckModelVendors(p *pkg.Package) error {
|
||||
contribs := p.Manifest.Contributes[manifest.PointModelVendors]
|
||||
if len(contribs) == 0 {
|
||||
return nil
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "weknora-vendor-check-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(dir) }()
|
||||
// Icons are read from disk, so the files they may name are written out.
|
||||
for _, name := range p.Files("") {
|
||||
data, _ := p.ReadFile(name)
|
||||
target := filepath.Join(dir, filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(target, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
scratch := modelruntime.New()
|
||||
var errs []error
|
||||
for _, c := range contribs {
|
||||
def, err := vendorDefinition(p, c)
|
||||
if err == nil {
|
||||
err = scratch.RegisterPlugin(manifest.QualifiedID(p.Manifest.ID, c.ID), def,
|
||||
filepath.Join(dir, filepath.FromSlash(path.Dir(c.Path))))
|
||||
}
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("model vendor %s: %w", c.ID, err))
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package activate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
modelruntime "github.com/Tencent/WeKnora/internal/models/runtime"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/pkg"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/plugintest"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/reconcile"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/registry"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
const vendorManifest = `schemaVersion: 1
|
||||
id: acme.ai
|
||||
version: 1.0.0
|
||||
name: { en-US: ACME AI }
|
||||
publisher: { id: acme }
|
||||
runtime: { type: declarative }
|
||||
contributes:
|
||||
modelVendors:
|
||||
- id: acme
|
||||
name: { en-US: ACME AI, zh-CN: ACME 智能 }
|
||||
path: vendors/acme.yaml
|
||||
`
|
||||
|
||||
func vendorPackage(t *testing.T, vendorYAML string) []byte {
|
||||
return plugintest.Zip(t, map[string]string{
|
||||
"plugin.yaml": vendorManifest,
|
||||
"vendors/acme.yaml": vendorYAML,
|
||||
"vendors/acme.svg": `<svg xmlns="http://www.w3.org/2000/svg"></svg>`,
|
||||
})
|
||||
}
|
||||
|
||||
func TestModelVendorsFollowThePlugin(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
rt := modelruntime.New()
|
||||
vendors := &ModelVendors{rt: rt, registered: map[string][]string{}}
|
||||
repo, store := plugintest.NewMemRepo(), &plugintest.MemStore{}
|
||||
r := reconcile.New(reconcile.Options{
|
||||
Repo: repo, Store: store, Registry: registry.New(), CacheDir: t.TempDir(),
|
||||
Activators: []reconcile.Activator{vendors},
|
||||
})
|
||||
plugintest.Install(t, repo, store, vendorPackage(t, `
|
||||
base_url: https://api.acme.example/v1
|
||||
icon: acme.svg
|
||||
model_types: [chat, embedding]
|
||||
models:
|
||||
- { id: acme-large, context_window: 128000 }
|
||||
`), types.PluginStateEnabled)
|
||||
if err := r.Reconcile(ctx); err != nil {
|
||||
t.Fatalf("Reconcile: %v", err)
|
||||
}
|
||||
v, ok := rt.Get("acme.ai/acme")
|
||||
if !ok {
|
||||
t.Fatal("vendor not registered")
|
||||
}
|
||||
if v.Name != "ACME AI" || v.Names["zh-CN"] != "ACME 智能" || len(v.Icon) == 0 {
|
||||
t.Fatalf("vendor = name %q names %v icon %d bytes", v.Name, v.Names, len(v.Icon))
|
||||
}
|
||||
|
||||
row, _ := repo.GetPlugin(ctx, "acme.ai")
|
||||
row.DesiredState = types.PluginStateDisabled
|
||||
_ = repo.SavePlugin(ctx, row)
|
||||
_ = r.Reconcile(ctx)
|
||||
if _, ok := rt.Get("acme.ai/acme"); ok {
|
||||
t.Fatal("disabling the plugin must remove its vendor")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckModelVendorsRejectsBrokenDefinitions(t *testing.T) {
|
||||
for name, def := range map[string]string{
|
||||
"unknown key": "base_url: https://x\nnmae: typo\n",
|
||||
"deploy key": "api_key: sk-123\n",
|
||||
"bad api": "api: carrier-pigeon\n",
|
||||
"bad icon": "icon: ../../etc/passwd\n",
|
||||
} {
|
||||
p, err := pkg.Open(vendorPackage(t, def))
|
||||
if err != nil {
|
||||
t.Fatalf("%s: Open: %v", name, err)
|
||||
}
|
||||
if err := CheckModelVendors(p); err == nil || !strings.Contains(err.Error(), "model vendor acme") {
|
||||
t.Errorf("%s: want a vendor error, got %v", name, err)
|
||||
}
|
||||
}
|
||||
p, _ := pkg.Open(vendorPackage(t, "base_url: https://api.acme.example/v1\nicon: acme.svg\n"))
|
||||
if err := CheckModelVendors(p); err != nil {
|
||||
t.Fatalf("valid vendor rejected: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -175,3 +175,15 @@ func deepCopyValue(v any) any {
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// Update applies a client's edit to a stored (sealed) configuration: it keeps
|
||||
// secrets the client sent back redacted, validates the result and seals it
|
||||
// for storage. A validation failure is returned as FieldErrors.
|
||||
func Update(s *Schema, stored, incoming map[string]any) (map[string]any, error) {
|
||||
plain, _ := OpenLenient(s, stored)
|
||||
merged := Merge(s, plain, incoming)
|
||||
if errs := Validate(s, merged); len(errs) > 0 {
|
||||
return nil, errs
|
||||
}
|
||||
return Seal(s, merged)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package configschema
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUpdateKeepsRedactedSecretsAndValidates(t *testing.T) {
|
||||
s := Object().
|
||||
Set("api_key", &Schema{Type: TypeString, Secret: true, MinLength: intPtr(4)}, true).
|
||||
Set("region", &Schema{Type: TypeString}, false)
|
||||
stored, err := Update(s, nil, map[string]any{"api_key": "secret-1", "region": "eu"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
next, err := Update(s, stored, map[string]any{"api_key": RedactedPlaceholder, "region": "us"})
|
||||
if err != nil {
|
||||
t.Fatalf("a redacted secret must count as set: %v", err)
|
||||
}
|
||||
opened, err := Open(s, next)
|
||||
if err != nil || opened["api_key"] != "secret-1" || opened["region"] != "us" {
|
||||
t.Fatalf("opened = %v, %v", opened, err)
|
||||
}
|
||||
var fe FieldErrors
|
||||
if _, err := Update(s, nil, map[string]any{"region": "eu"}); !errors.As(err, &fe) {
|
||||
t.Fatalf("a missing required secret must fail validation, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package install
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/plugin/configschema"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/manifest"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
)
|
||||
|
||||
// ErrNoSystemConfig is returned for a plugin without a config.system schema.
|
||||
var ErrNoSystemConfig = errors.New("this plugin has no platform configuration")
|
||||
|
||||
// SystemConfig is a plugin's platform configuration as the UI sees it.
|
||||
type SystemConfig struct {
|
||||
Schema json.RawMessage `json:"schema"`
|
||||
// Values has secrets redacted.
|
||||
Values map[string]any `json:"values"`
|
||||
}
|
||||
|
||||
func (s *Service) systemSchema(ctx context.Context, id string) (*types.InstalledPlugin, *configschema.Schema,
|
||||
json.RawMessage, error,
|
||||
) {
|
||||
view, err := s.Get(ctx, id)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if view.Manifest == nil || len(view.Manifest.Config.SystemSchema) == 0 {
|
||||
return nil, nil, nil, ErrNoSystemConfig
|
||||
}
|
||||
raw := view.Manifest.Config.SystemSchema
|
||||
schema, err := configschema.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("plugin %s system schema: %w", id, err)
|
||||
}
|
||||
row := view.InstalledPlugin
|
||||
return &row, schema, raw, nil
|
||||
}
|
||||
|
||||
func systemValues(row *types.InstalledPlugin) map[string]any {
|
||||
var v map[string]any
|
||||
if len(row.SystemConfig) == 0 || json.Unmarshal(row.SystemConfig, &v) != nil || v == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// GetSystemConfig returns a plugin's platform configuration, secrets
|
||||
// redacted.
|
||||
func (s *Service) GetSystemConfig(ctx context.Context, id string) (*SystemConfig, error) {
|
||||
row, schema, raw, err := s.systemSchema(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SystemConfig{Schema: raw, Values: configschema.Redact(schema, systemValues(row))}, nil
|
||||
}
|
||||
|
||||
// SetSystemConfig validates and stores a plugin's platform configuration.
|
||||
// Validation failures come back as configschema.FieldErrors.
|
||||
func (s *Service) SetSystemConfig(ctx context.Context, id string, values map[string]any) (*SystemConfig, error) {
|
||||
row, schema, _, err := s.systemSchema(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sealed, err := configschema.Update(schema, systemValues(row), values)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := json.Marshal(sealed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row.SystemConfig = types.JSON(b)
|
||||
if err := s.repo.SavePlugin(ctx, row); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetSystemConfig(ctx, id)
|
||||
}
|
||||
|
||||
// OpenSystemConfig returns the platform configuration of a loaded plugin with
|
||||
// secrets decrypted, for code about to use it, and when the row last
|
||||
// changed. m is the manifest the node runs, which carries the schema.
|
||||
func OpenSystemConfig(
|
||||
ctx context.Context, repo interfaces.PluginRepository, m *manifest.Manifest,
|
||||
) (map[string]any, time.Time, error) {
|
||||
if len(m.Config.SystemSchema) == 0 {
|
||||
return map[string]any{}, time.Time{}, nil
|
||||
}
|
||||
schema, err := configschema.Parse(m.Config.SystemSchema)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
row, err := repo.GetPlugin(ctx, m.ID)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
if row == nil {
|
||||
return nil, time.Time{}, ErrNotInstalled
|
||||
}
|
||||
values, err := configschema.Open(schema, systemValues(row))
|
||||
if err != nil {
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
return values, row.UpdatedAt, nil
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
// Package install is how a system administrator manages installed plugins:
|
||||
// review a package, install or upgrade it, switch it on or off platform-wide,
|
||||
// roll back to a stored version, uninstall. It only writes rows and package
|
||||
// blobs; the reconciler on every node does the loading.
|
||||
package install
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"golang.org/x/mod/semver"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/manifest"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/pkg"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/reconcile"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
"github.com/Tencent/WeKnora/internal/utils"
|
||||
)
|
||||
|
||||
// ErrNotInstalled is returned for a plugin ID with no installed row.
|
||||
var ErrNotInstalled = errors.New("plugin is not installed")
|
||||
|
||||
// InvalidError is a request the administrator has to change: a bad package,
|
||||
// an unsupported runtime, a digest that no longer matches what was reviewed.
|
||||
type InvalidError struct{ Err error }
|
||||
|
||||
func (e *InvalidError) Error() string { return e.Err.Error() }
|
||||
func (e *InvalidError) Unwrap() error { return e.Err }
|
||||
|
||||
func invalid(format string, args ...any) error {
|
||||
return &InvalidError{Err: fmt.Errorf(format, args...)}
|
||||
}
|
||||
|
||||
// supportedRuntimes are the runtimes an installed package may use today.
|
||||
// Code plugins arrive with the plugin host.
|
||||
var supportedRuntimes = map[manifest.RuntimeType]bool{manifest.RuntimeDeclarative: true}
|
||||
|
||||
// Syncer is the node's reconciler as the installer uses it.
|
||||
type Syncer interface {
|
||||
Reconcile(ctx context.Context) error
|
||||
Notify(ctx context.Context)
|
||||
Status(pluginID string) (reconcile.Status, bool)
|
||||
}
|
||||
|
||||
// Service manages installed plugins.
|
||||
type Service struct {
|
||||
repo interfaces.PluginRepository
|
||||
store reconcile.PackageStore
|
||||
sync Syncer
|
||||
hostVersion string
|
||||
client *http.Client
|
||||
checks []PackageCheck
|
||||
}
|
||||
|
||||
// PackageCheck is a domain's install-time verdict on a package, such as
|
||||
// whether its model vendor definitions would load.
|
||||
type PackageCheck func(*pkg.Package) error
|
||||
|
||||
// WithChecks adds install-time package checks.
|
||||
func (s *Service) WithChecks(checks ...PackageCheck) *Service {
|
||||
s.checks = append(s.checks, checks...)
|
||||
return s
|
||||
}
|
||||
|
||||
// NewService creates a Service. hostVersion is the running WeKnora version
|
||||
// that engines ranges are checked against.
|
||||
func NewService(
|
||||
repo interfaces.PluginRepository, store reconcile.PackageStore, sync Syncer, hostVersion string,
|
||||
) *Service {
|
||||
cfg := utils.DefaultSSRFSafeHTTPClientConfig()
|
||||
cfg.Timeout = 2 * time.Minute
|
||||
return &Service{
|
||||
repo: repo, store: store, sync: sync, hostVersion: hostVersion,
|
||||
client: utils.NewSSRFSafeHTTPClient(cfg),
|
||||
}
|
||||
}
|
||||
|
||||
// Change is what installing a package would do.
|
||||
type Change string
|
||||
|
||||
// Changes a Preview reports.
|
||||
const (
|
||||
ChangeInstall Change = "install"
|
||||
ChangeUpgrade Change = "upgrade"
|
||||
ChangeDowngrade Change = "downgrade"
|
||||
ChangeReinstall Change = "reinstall"
|
||||
)
|
||||
|
||||
// Preview is a package as the administrator reviews it before installing.
|
||||
type Preview struct {
|
||||
Manifest *manifest.Manifest `json:"manifest"`
|
||||
Digest string `json:"digest"`
|
||||
Size int64 `json:"size"`
|
||||
Change Change `json:"change"`
|
||||
// InstalledVersion is the active version when the plugin is installed.
|
||||
InstalledVersion string `json:"installedVersion,omitempty"`
|
||||
}
|
||||
|
||||
// Source says where a package came from.
|
||||
type Source struct {
|
||||
Kind string `json:"kind"` // "upload" or "url"
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
// View is an installed plugin with its versions and how this node runs it.
|
||||
type View struct {
|
||||
types.InstalledPlugin
|
||||
Manifest *manifest.Manifest `json:"manifest"`
|
||||
Versions []types.PluginVersion `json:"versions"`
|
||||
// Node is the plugin's state on the node that served the request.
|
||||
Node *reconcile.Status `json:"node,omitempty"`
|
||||
}
|
||||
|
||||
// Inspect opens a package and says what installing it would do.
|
||||
func (s *Service) Inspect(ctx context.Context, data []byte) (*Preview, error) {
|
||||
p, err := s.open(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := &Preview{Manifest: p.Manifest, Digest: p.Digest, Size: p.Size, Change: ChangeInstall}
|
||||
row, err := s.repo.GetPlugin(ctx, p.Manifest.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if row != nil {
|
||||
out.InstalledVersion = row.ActiveVersion
|
||||
switch c := semver.Compare("v"+p.Manifest.Version, "v"+row.ActiveVersion); {
|
||||
case c > 0:
|
||||
out.Change = ChangeUpgrade
|
||||
case c < 0:
|
||||
out.Change = ChangeDowngrade
|
||||
default:
|
||||
out.Change = ChangeReinstall
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// open validates a package for installation on this platform.
|
||||
func (s *Service) open(data []byte) (*pkg.Package, error) {
|
||||
p, err := pkg.Open(data)
|
||||
if err != nil {
|
||||
return nil, &InvalidError{Err: err}
|
||||
}
|
||||
if !supportedRuntimes[p.Manifest.Runtime.Type] {
|
||||
return nil, invalid("runtime %q is not supported yet; only declarative plugins can be installed",
|
||||
p.Manifest.Runtime.Type)
|
||||
}
|
||||
if err := p.Manifest.CheckEngines(s.hostVersion); err != nil {
|
||||
return nil, &InvalidError{Err: err}
|
||||
}
|
||||
for _, check := range s.checks {
|
||||
if err := check(p); err != nil {
|
||||
return nil, &InvalidError{Err: err}
|
||||
}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Request installs or upgrades a plugin from a package.
|
||||
type Request struct {
|
||||
Data []byte
|
||||
Source Source
|
||||
// ExpectedDigest, when set, must match the package: it pins the install
|
||||
// to the package the administrator reviewed.
|
||||
ExpectedDigest string
|
||||
UserID string
|
||||
}
|
||||
|
||||
// Install stores the package as a version of its plugin and makes it the
|
||||
// active one. Installing makes the plugin available platform-wide; each
|
||||
// tenant still opts in. The permissions the manifest asks for are recorded
|
||||
// as granted.
|
||||
func (s *Service) Install(ctx context.Context, req Request) (*View, error) {
|
||||
p, err := s.open(req.Data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.ExpectedDigest != "" && req.ExpectedDigest != p.Digest {
|
||||
return nil, invalid("the package changed since it was reviewed (digest %s, reviewed %s)",
|
||||
p.Digest, req.ExpectedDigest)
|
||||
}
|
||||
m := p.Manifest
|
||||
existing, err := s.repo.GetVersion(ctx, m.ID, m.Version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil && existing.Digest != p.Digest {
|
||||
return nil, invalid("version %s of %s is already installed with different contents; "+
|
||||
"publish the change under a new version", m.Version, m.ID)
|
||||
}
|
||||
if existing == nil {
|
||||
uri, err := s.store.Put(ctx, p.Digest, req.Data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
manifestJSON, _ := json.Marshal(m)
|
||||
if err := s.repo.SaveVersion(ctx, &types.PluginVersion{
|
||||
PluginID: m.ID, Version: m.Version, Digest: p.Digest, Manifest: types.JSON(manifestJSON),
|
||||
PackageURI: uri, Size: p.Size, CreatedBy: req.UserID, CreatedAt: time.Now(),
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
row, err := s.repo.GetPlugin(ctx, m.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if row == nil {
|
||||
row = &types.InstalledPlugin{ID: m.ID, DesiredState: types.PluginStateEnabled, CreatedBy: req.UserID}
|
||||
}
|
||||
source, _ := json.Marshal(req.Source)
|
||||
perms, _ := json.Marshal(m.Permissions)
|
||||
row.Source = types.JSON(source)
|
||||
row.ActiveVersion = m.Version
|
||||
row.Runtime = string(m.Runtime.Type)
|
||||
row.GrantedPerms = types.JSON(perms)
|
||||
if err := s.repo.SavePlugin(ctx, row); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logger.Infof(ctx, "[plugin] %s installed %s %s (%s)", req.UserID, m.ID, m.Version, p.Digest)
|
||||
return s.apply(ctx, m.ID)
|
||||
}
|
||||
|
||||
// FetchURL downloads a package over HTTP(S), refusing private addresses.
|
||||
func (s *Service) FetchURL(ctx context.Context, rawURL string) ([]byte, error) {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" {
|
||||
return nil, invalid("package URL must be an http(s) URL")
|
||||
}
|
||||
if err := utils.ValidateURLForSSRF(rawURL); err != nil {
|
||||
return nil, invalid("package URL is not allowed: %v", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, invalid("package URL: %v", err)
|
||||
}
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, invalid("download package: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, invalid("download package: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, pkg.MaxArchiveBytes+1))
|
||||
if err != nil {
|
||||
return nil, invalid("download package: %v", err)
|
||||
}
|
||||
if len(data) > pkg.MaxArchiveBytes {
|
||||
return nil, invalid("package is over the %d byte limit", pkg.MaxArchiveBytes)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// List returns every installed plugin.
|
||||
func (s *Service) List(ctx context.Context) ([]View, error) {
|
||||
rows, err := s.repo.ListPlugins(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Slice(rows, func(i, j int) bool { return rows[i].ID < rows[j].ID })
|
||||
out := make([]View, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
v, err := s.view(ctx, row)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *v)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Get returns one installed plugin.
|
||||
func (s *Service) Get(ctx context.Context, id string) (*View, error) {
|
||||
row, err := s.repo.GetPlugin(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if row == nil {
|
||||
return nil, ErrNotInstalled
|
||||
}
|
||||
return s.view(ctx, *row)
|
||||
}
|
||||
|
||||
func (s *Service) view(ctx context.Context, row types.InstalledPlugin) (*View, error) {
|
||||
versions, err := s.repo.ListVersions(ctx, row.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v := &View{InstalledPlugin: row, Versions: versions}
|
||||
for _, pv := range versions {
|
||||
if pv.Version == row.ActiveVersion {
|
||||
var m manifest.Manifest
|
||||
if json.Unmarshal(pv.Manifest, &m) == nil {
|
||||
v.Manifest = &m
|
||||
}
|
||||
}
|
||||
}
|
||||
if st, ok := s.sync.Status(row.ID); ok {
|
||||
v.Node = &st
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// SetEnabled switches an installed plugin on or off for the whole platform.
|
||||
// Off unloads it everywhere; tenant switches are kept for when it returns.
|
||||
func (s *Service) SetEnabled(ctx context.Context, id string, enabled bool) (*View, error) {
|
||||
row, err := s.repo.GetPlugin(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if row == nil {
|
||||
return nil, ErrNotInstalled
|
||||
}
|
||||
row.DesiredState = types.PluginStateDisabled
|
||||
if enabled {
|
||||
row.DesiredState = types.PluginStateEnabled
|
||||
}
|
||||
if err := s.repo.SavePlugin(ctx, row); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.apply(ctx, id)
|
||||
}
|
||||
|
||||
// Activate makes a stored version the active one: a rollback, or a return to
|
||||
// a newer version after one.
|
||||
func (s *Service) Activate(ctx context.Context, id, version string) (*View, error) {
|
||||
row, err := s.repo.GetPlugin(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if row == nil {
|
||||
return nil, ErrNotInstalled
|
||||
}
|
||||
v, err := s.repo.GetVersion(ctx, id, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if v == nil {
|
||||
return nil, invalid("version %s of %s is not stored", version, id)
|
||||
}
|
||||
var m manifest.Manifest
|
||||
if err := json.Unmarshal(v.Manifest, &m); err == nil {
|
||||
if err := m.CheckEngines(s.hostVersion); err != nil {
|
||||
return nil, &InvalidError{Err: err}
|
||||
}
|
||||
perms, _ := json.Marshal(m.Permissions)
|
||||
row.GrantedPerms = types.JSON(perms)
|
||||
}
|
||||
row.ActiveVersion = version
|
||||
if err := s.repo.SavePlugin(ctx, row); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.apply(ctx, id)
|
||||
}
|
||||
|
||||
// Uninstall removes a plugin, its versions and their packages. Tenant
|
||||
// switches and configuration are kept, so reinstalling restores them.
|
||||
func (s *Service) Uninstall(ctx context.Context, id string) error {
|
||||
row, err := s.repo.GetPlugin(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if row == nil {
|
||||
return ErrNotInstalled
|
||||
}
|
||||
versions, err := s.repo.ListVersions(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.repo.DeletePlugin(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.sync.Reconcile(ctx); err != nil {
|
||||
logger.Warnf(ctx, "[plugin] reconcile after uninstalling %s: %v", id, err)
|
||||
}
|
||||
s.sync.Notify(ctx)
|
||||
for _, v := range versions {
|
||||
if err := s.store.Delete(ctx, v.PackageURI); err != nil {
|
||||
logger.Warnf(ctx, "[plugin] delete package %s: %v", v.PackageURI, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// apply reconciles this node now, tells the others, and returns the result.
|
||||
// A plugin that fails to load is still installed; its View says why.
|
||||
func (s *Service) apply(ctx context.Context, id string) (*View, error) {
|
||||
if err := s.sync.Reconcile(ctx); err != nil {
|
||||
logger.Warnf(ctx, "[plugin] reconcile after changing %s: %v", id, err)
|
||||
}
|
||||
s.sync.Notify(ctx)
|
||||
return s.Get(ctx, id)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package install
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/plugin/plugintest"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/reconcile"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/registry"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
func newService(t *testing.T) (*Service, *plugintest.MemRepo, *plugintest.MemStore, *registry.Registry) {
|
||||
t.Helper()
|
||||
repo, store, reg := plugintest.NewMemRepo(), &plugintest.MemStore{}, registry.New()
|
||||
r := reconcile.New(reconcile.Options{Repo: repo, Store: store, Registry: reg, CacheDir: t.TempDir()})
|
||||
return NewService(repo, store, r, "0.5.0"), repo, store, reg
|
||||
}
|
||||
|
||||
func TestInstallUpgradeRollbackUninstall(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, repo, store, reg := newService(t)
|
||||
|
||||
v1 := plugintest.KitPackage(t, "1.0.0")
|
||||
preview, err := s.Inspect(ctx, v1)
|
||||
if err != nil || preview.Change != ChangeInstall || preview.Manifest.ID != "acme.kit" {
|
||||
t.Fatalf("Inspect = %+v, %v", preview, err)
|
||||
}
|
||||
view, err := s.Install(ctx, Request{
|
||||
Data: v1, Source: Source{Kind: "upload"},
|
||||
ExpectedDigest: preview.Digest, UserID: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Install: %v", err)
|
||||
}
|
||||
if view.ActiveVersion != "1.0.0" || view.DesiredState != types.PluginStateEnabled ||
|
||||
view.Node == nil || view.Node.State != reconcile.StateReady {
|
||||
t.Fatalf("view = %+v node=%+v", view.InstalledPlugin, view.Node)
|
||||
}
|
||||
if _, ok := reg.Plugin("acme.kit"); !ok {
|
||||
t.Fatal("install must load the plugin on this node")
|
||||
}
|
||||
|
||||
v2 := plugintest.KitPackage(t, "1.1.0")
|
||||
if p, _ := s.Inspect(ctx, v2); p.Change != ChangeUpgrade || p.InstalledVersion != "1.0.0" {
|
||||
t.Fatalf("upgrade preview = %+v", p)
|
||||
}
|
||||
if _, err := s.Install(ctx, Request{Data: v2, ExpectedDigest: preview.Digest}); !isInvalid(err) {
|
||||
t.Fatalf("a digest other than the reviewed one must be refused, got %v", err)
|
||||
}
|
||||
if _, err := s.Install(ctx, Request{Data: v2}); err != nil {
|
||||
t.Fatalf("upgrade: %v", err)
|
||||
}
|
||||
view, err = s.Activate(ctx, "acme.kit", "1.0.0")
|
||||
if err != nil || view.ActiveVersion != "1.0.0" || len(view.Versions) != 2 {
|
||||
t.Fatalf("rollback = %+v, %v", view, err)
|
||||
}
|
||||
if m, _ := reg.Plugin("acme.kit"); m.Version != "1.0.0" {
|
||||
t.Fatalf("registry runs %s after rollback", m.Version)
|
||||
}
|
||||
|
||||
if view, err = s.SetEnabled(ctx, "acme.kit", false); err != nil || view.Node != nil {
|
||||
t.Fatalf("disable = %+v, %v", view, err)
|
||||
}
|
||||
if _, ok := reg.Plugin("acme.kit"); ok {
|
||||
t.Fatal("a disabled plugin must be unloaded")
|
||||
}
|
||||
|
||||
if err := s.Uninstall(ctx, "acme.kit"); err != nil {
|
||||
t.Fatalf("Uninstall: %v", err)
|
||||
}
|
||||
if row, _ := repo.GetPlugin(ctx, "acme.kit"); row != nil {
|
||||
t.Fatal("row survived uninstall")
|
||||
}
|
||||
if len(store.Blobs) != 0 {
|
||||
t.Fatalf("packages survived uninstall: %d", len(store.Blobs))
|
||||
}
|
||||
if _, err := s.Get(ctx, "acme.kit"); !errors.Is(err, ErrNotInstalled) {
|
||||
t.Fatalf("Get after uninstall = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallRejects(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, _, _, _ := newService(t)
|
||||
if _, err := s.Install(ctx, Request{Data: []byte("nope")}); !isInvalid(err) {
|
||||
t.Fatalf("garbage must be invalid, got %v", err)
|
||||
}
|
||||
|
||||
pkg := plugintest.KitPackage(t, "1.0.0")
|
||||
if _, err := s.Install(ctx, Request{Data: pkg}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Same version, different bytes.
|
||||
other := plugintest.KitPackageWith(t, "1.0.0", "changed")
|
||||
if _, err := s.Install(ctx, Request{Data: other}); !isInvalid(err) ||
|
||||
!strings.Contains(err.Error(), "new version") {
|
||||
t.Fatalf("want same-version conflict, got %v", err)
|
||||
}
|
||||
// Reinstalling the identical package is fine.
|
||||
if p, _ := s.Inspect(ctx, pkg); p.Change != ChangeReinstall {
|
||||
t.Fatalf("change = %s", p.Change)
|
||||
}
|
||||
if _, err := s.Install(ctx, Request{Data: pkg}); err != nil {
|
||||
t.Fatalf("reinstall: %v", err)
|
||||
}
|
||||
|
||||
old := NewService(plugintest.NewMemRepo(), &plugintest.MemStore{}, nil, "0.1.0")
|
||||
engines := plugintest.KitPackageWith(t, "2.0.0", "", "engines: { weknora: \">=0.2.0\" }")
|
||||
if _, err := old.Inspect(ctx, engines); !isInvalid(err) || !strings.Contains(err.Error(), "needs WeKnora") {
|
||||
t.Fatalf("want engines error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchURLRefusesPrivateAddresses(t *testing.T) {
|
||||
s, _, _, _ := newService(t)
|
||||
for _, u := range []string{"http://127.0.0.1:8080/p.wkp", "file:///etc/passwd", "http://169.254.169.254/x"} {
|
||||
if _, err := s.FetchURL(context.Background(), u); !isInvalid(err) {
|
||||
t.Errorf("FetchURL(%s) = %v, want refusal", u, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isInvalid(err error) bool {
|
||||
var ie *InvalidError
|
||||
return errors.As(err, &ie)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package manifest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/mod/semver"
|
||||
)
|
||||
|
||||
// comparator is one term of an engines range, such as ">=0.10.0".
|
||||
type comparator struct {
|
||||
op string
|
||||
version string // canonical, with the "v" prefix semver wants
|
||||
}
|
||||
|
||||
// parseRange reads a space-separated list of comparators, all of which must
|
||||
// hold: ">=0.10.0 <1.0.0". A bare version means "=".
|
||||
func parseRange(r string) ([]comparator, error) {
|
||||
var out []comparator
|
||||
for _, term := range strings.Fields(r) {
|
||||
op := "="
|
||||
for _, candidate := range []string{">=", "<=", ">", "<", "="} {
|
||||
if strings.HasPrefix(term, candidate) {
|
||||
op, term = candidate, strings.TrimPrefix(term, candidate)
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isStrictSemver(term) {
|
||||
return nil, fmt.Errorf("%q is not a semantic version", term)
|
||||
}
|
||||
out = append(out, comparator{op: op, version: "v" + term})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("range is empty")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CheckEngines reports whether host (the running WeKnora version) satisfies
|
||||
// the manifest's engines.weknora range. Development builds without a release
|
||||
// version pass, as does a manifest without a range.
|
||||
func (m *Manifest) CheckEngines(host string) error {
|
||||
if m.Engines.WeKnora == "" {
|
||||
return nil
|
||||
}
|
||||
v := "v" + strings.TrimPrefix(strings.TrimSpace(host), "v")
|
||||
if !semver.IsValid(v) {
|
||||
return nil
|
||||
}
|
||||
terms, err := parseRange(m.Engines.WeKnora)
|
||||
if err != nil {
|
||||
return fmt.Errorf("engines.weknora: %w", err)
|
||||
}
|
||||
for _, c := range terms {
|
||||
cmp := semver.Compare(v, c.version)
|
||||
ok := map[string]bool{">=": cmp >= 0, "<=": cmp <= 0, ">": cmp > 0, "<": cmp < 0, "=": cmp == 0}[c.op]
|
||||
if !ok {
|
||||
return fmt.Errorf("plugin needs WeKnora %s, this is %s", m.Engines.WeKnora, strings.TrimPrefix(v, "v"))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package manifest
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCheckEngines(t *testing.T) {
|
||||
cases := []struct {
|
||||
rng, host string
|
||||
ok bool
|
||||
}{
|
||||
{"", "0.3.0", true},
|
||||
{">=0.10.0 <1.0.0", "0.10.2", true},
|
||||
{">=0.10.0 <1.0.0", "v0.9.9", false},
|
||||
{">=0.10.0 <1.0.0", "1.0.0", false},
|
||||
{"0.10.0", "0.10.0", true},
|
||||
{">=9.0.0", "unknown", true}, // development build
|
||||
}
|
||||
for _, c := range cases {
|
||||
m := &Manifest{Engines: Engines{WeKnora: c.rng}}
|
||||
if err := m.CheckEngines(c.host); (err == nil) != c.ok {
|
||||
t.Errorf("CheckEngines(%q, %q) = %v, want ok=%v", c.rng, c.host, err, c.ok)
|
||||
}
|
||||
}
|
||||
if _, err := parseRange(">=1.0 <2"); err == nil {
|
||||
t.Error("partial versions must be rejected")
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ package manifest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
@@ -116,6 +117,10 @@ type Permissions struct {
|
||||
type ConfigSchemas struct {
|
||||
System string `json:"system,omitempty" yaml:"system"`
|
||||
Tenant string `json:"tenant,omitempty" yaml:"tenant"`
|
||||
// SystemSchema and TenantSchema are the schema files' contents as JSON,
|
||||
// filled in when a package is opened.
|
||||
SystemSchema json.RawMessage `json:"systemSchema,omitempty" yaml:"-"`
|
||||
TenantSchema json.RawMessage `json:"tenantSchema,omitempty" yaml:"-"`
|
||||
}
|
||||
|
||||
// Contributions maps each extension point to what the plugin provides there.
|
||||
@@ -140,10 +145,33 @@ type Contribution struct {
|
||||
// InstanceSchema points at the JSON Schema of one instance's
|
||||
// configuration (one data source, one IM channel).
|
||||
InstanceSchema string `json:"instanceSchema,omitempty" yaml:"instanceSchema"`
|
||||
// Path is a file or directory inside the package: the skill directory
|
||||
// (skills) or the vendor definition JSON (modelVendors).
|
||||
Path string `json:"path,omitempty" yaml:"path"`
|
||||
// MCP describes a remote MCP server (mcpServers).
|
||||
MCP *MCPServer `json:"mcp,omitempty" yaml:"mcp"`
|
||||
// Extra carries point-specific metadata the generic fields do not cover.
|
||||
Extra map[string]any `json:"extra,omitempty" yaml:"extra"`
|
||||
}
|
||||
|
||||
// MCPServer is a remote MCP server a plugin contributes.
|
||||
type MCPServer struct {
|
||||
URL string `json:"url" yaml:"url"`
|
||||
// Transport is "sse" or "http-streamable" (the default).
|
||||
Transport string `json:"transport,omitempty" yaml:"transport"`
|
||||
// Headers are sent on every request. A value may reference the
|
||||
// workspace's plugin configuration as ${config.<key>} (how a plugin asks
|
||||
// each workspace for its own API key) or the platform's as
|
||||
// ${system.<key>}. The URL is fixed: configuration cannot redirect it.
|
||||
Headers map[string]string `json:"headers,omitempty" yaml:"headers"`
|
||||
}
|
||||
|
||||
// MCP transports a plugin may declare.
|
||||
const (
|
||||
MCPTransportSSE = "sse"
|
||||
MCPTransportHTTPStreamable = "http-streamable"
|
||||
)
|
||||
|
||||
// QualifiedID is the cluster-wide ID of a contribution: "<plugin>/<local>".
|
||||
func QualifiedID(pluginID, localID string) string {
|
||||
return pluginID + "/" + localID
|
||||
@@ -204,6 +232,11 @@ func (m *Manifest) Validate() error {
|
||||
if m.Name.IsZero() {
|
||||
add("name is required")
|
||||
}
|
||||
if m.Engines.WeKnora != "" {
|
||||
if _, err := parseRange(m.Engines.WeKnora); err != nil {
|
||||
add("engines.weknora: %v", err)
|
||||
}
|
||||
}
|
||||
m.validateRuntime(add)
|
||||
m.validateContributions(add)
|
||||
return errors.Join(errs...)
|
||||
@@ -248,6 +281,9 @@ func (m *Manifest) validateContributions(add func(string, ...any)) {
|
||||
if !info.ThirdParty && !m.Builtin {
|
||||
add("contributes.%s is not open to third-party plugins yet", point)
|
||||
}
|
||||
if m.Runtime.Type == RuntimeDeclarative && !info.Declarative {
|
||||
add("contributes.%s needs code; declarative plugins cannot contribute to it", point)
|
||||
}
|
||||
seen := make(map[string]bool, len(list))
|
||||
for i, c := range list {
|
||||
where := fmt.Sprintf("contributes.%s[%d]", point, i)
|
||||
@@ -264,6 +300,7 @@ func (m *Manifest) validateContributions(add func(string, ...any)) {
|
||||
if len(c.Aliases) > 0 && !m.Builtin {
|
||||
add("%s.aliases may only be declared by builtin plugins", where)
|
||||
}
|
||||
validateDeclarative(point, c, m.Builtin, where, add)
|
||||
for _, alias := range c.Aliases {
|
||||
// Aliases share the ID alphabet, which has no '/', so an
|
||||
// alias can never shadow a qualified ID.
|
||||
@@ -274,3 +311,51 @@ func (m *Manifest) validateContributions(add func(string, ...any)) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// validateDeclarative checks the fields a declarative contribution needs.
|
||||
// Builtins describe their implementation in code instead.
|
||||
func validateDeclarative(point Point, c Contribution, builtin bool, where string, add func(string, ...any)) {
|
||||
if builtin {
|
||||
return
|
||||
}
|
||||
switch point {
|
||||
case PointSkills, PointModelVendors:
|
||||
if c.Path == "" {
|
||||
add("%s.path is required", where)
|
||||
} else if !isPackagePath(c.Path) {
|
||||
add("%s.path %q must be a relative path inside the package", where, c.Path)
|
||||
}
|
||||
case PointMCPServers:
|
||||
if c.MCP == nil || c.MCP.URL == "" {
|
||||
add("%s.mcp.url is required", where)
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(c.MCP.URL, "https://") && !strings.HasPrefix(c.MCP.URL, "http://") {
|
||||
add("%s.mcp.url must be an http(s) URL", where)
|
||||
}
|
||||
switch c.MCP.Transport {
|
||||
case "", MCPTransportSSE, MCPTransportHTTPStreamable:
|
||||
default:
|
||||
add("%s.mcp.transport must be sse or http-streamable", where)
|
||||
}
|
||||
if len(TemplateRefs(c.MCP.URL)) > 0 {
|
||||
add("%s.mcp.url cannot reference configuration; only headers can", where)
|
||||
}
|
||||
for name, value := range c.MCP.Headers {
|
||||
validateTemplate(value, fmt.Sprintf("%s.mcp.headers.%s", where, name), add)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isPackagePath reports whether p stays inside the package root.
|
||||
func isPackagePath(p string) bool {
|
||||
if p == "" || strings.HasPrefix(p, "/") || strings.Contains(p, "\\") {
|
||||
return false
|
||||
}
|
||||
for _, part := range strings.Split(p, "/") {
|
||||
if part == ".." {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -4,10 +4,8 @@ package manifest
|
||||
// The string is also the key under `contributes` in a manifest.
|
||||
type Point string
|
||||
|
||||
// Extension points with a builtin implementation today. Points the design
|
||||
// opens later (mcpServers, skills, events, pipelineHooks, ui, ...) are added
|
||||
// here when their first contribution lands, so a manifest can never declare a
|
||||
// point nothing consumes.
|
||||
// Extension points. A point is added here when its first contribution lands,
|
||||
// so a manifest can never declare a point nothing consumes.
|
||||
const (
|
||||
PointModelVendors Point = "modelVendors"
|
||||
PointConnectors Point = "connectors"
|
||||
@@ -15,6 +13,10 @@ const (
|
||||
PointWebSearch Point = "webSearch"
|
||||
PointTools Point = "tools"
|
||||
PointParsers Point = "parsers"
|
||||
// PointSkills contributes SKILL.md skill directories from the package.
|
||||
PointSkills Point = "skills"
|
||||
// PointMCPServers contributes MCP servers whose tools agents can use.
|
||||
PointMCPServers Point = "mcpServers"
|
||||
)
|
||||
|
||||
// PointInfo describes an extension point.
|
||||
@@ -23,18 +25,24 @@ type PointInfo struct {
|
||||
// ThirdParty reports whether plugins other than builtins may contribute
|
||||
// to this point yet. Builtins always may.
|
||||
ThirdParty bool `json:"thirdParty"`
|
||||
// Declarative reports whether a plugin without code (runtime
|
||||
// declarative) can contribute here: the manifest and package files
|
||||
// describe the contribution completely.
|
||||
Declarative bool `json:"declarative"`
|
||||
}
|
||||
|
||||
// points lists every known extension point in display order. Vector stores,
|
||||
// object storage and sandboxes are deliberately absent: they are
|
||||
// infrastructure owned by WeKnora itself, not extension points.
|
||||
var points = []PointInfo{
|
||||
{Point: PointModelVendors},
|
||||
{Point: PointModelVendors, ThirdParty: true, Declarative: true},
|
||||
{Point: PointConnectors},
|
||||
{Point: PointIMChannels},
|
||||
{Point: PointWebSearch},
|
||||
{Point: PointTools},
|
||||
{Point: PointParsers},
|
||||
{Point: PointSkills, ThirdParty: true, Declarative: true},
|
||||
{Point: PointMCPServers, ThirdParty: true, Declarative: true},
|
||||
}
|
||||
|
||||
// Points returns every known extension point in display order.
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package manifest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Config scopes a template may reference.
|
||||
const (
|
||||
// ScopeConfig is the workspace's plugin configuration (config.tenant).
|
||||
ScopeConfig = "config"
|
||||
// ScopeSystem is the platform-wide plugin configuration (config.system).
|
||||
ScopeSystem = "system"
|
||||
)
|
||||
|
||||
// templateRef matches ${scope.key}.
|
||||
var templateRef = regexp.MustCompile(`\$\{([a-zA-Z]+)\.([a-zA-Z0-9_]+)\}`)
|
||||
|
||||
// TemplateRef is one ${scope.key} in a templated value.
|
||||
type TemplateRef struct {
|
||||
Scope string
|
||||
Key string
|
||||
}
|
||||
|
||||
// TemplateRefs lists the references in a templated value.
|
||||
func TemplateRefs(s string) []TemplateRef {
|
||||
var out []TemplateRef
|
||||
for _, m := range templateRef.FindAllStringSubmatch(s, -1) {
|
||||
out = append(out, TemplateRef{Scope: m[1], Key: m[2]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ExpandTemplate replaces each ${scope.key} with lookup's answer. It fails
|
||||
// when a referenced value is missing or empty, so a request is never sent
|
||||
// with a half-filled credential.
|
||||
func ExpandTemplate(s string, lookup func(scope, key string) (string, bool)) (string, error) {
|
||||
var missing []string
|
||||
out := templateRef.ReplaceAllStringFunc(s, func(ref string) string {
|
||||
m := templateRef.FindStringSubmatch(ref)
|
||||
v, ok := lookup(m[1], m[2])
|
||||
if !ok || v == "" {
|
||||
missing = append(missing, m[1]+"."+m[2])
|
||||
}
|
||||
return v
|
||||
})
|
||||
if len(missing) > 0 {
|
||||
return "", fmt.Errorf("not configured: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validateTemplate(value, where string, add func(string, ...any)) {
|
||||
for _, ref := range TemplateRefs(value) {
|
||||
if ref.Scope != ScopeConfig && ref.Scope != ScopeSystem {
|
||||
add("%s references ${%s.%s}; only ${config.<key>} and ${system.<key>} are allowed",
|
||||
where, ref.Scope, ref.Key)
|
||||
}
|
||||
}
|
||||
if rest := templateRef.ReplaceAllString(value, ""); strings.Contains(rest, "${") {
|
||||
add("%s has a malformed ${...} reference", where)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package manifest
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExpandTemplate(t *testing.T) {
|
||||
values := map[string]string{"config.api_key": "k-1", "system.region": "eu"}
|
||||
lookup := func(scope, key string) (string, bool) { v, ok := values[scope+"."+key]; return v, ok }
|
||||
|
||||
got, err := ExpandTemplate("Bearer ${config.api_key} @${system.region}", lookup)
|
||||
if err != nil || got != "Bearer k-1 @eu" {
|
||||
t.Fatalf("got %q, %v", got, err)
|
||||
}
|
||||
if _, err := ExpandTemplate("Bearer ${config.token}", lookup); err == nil ||
|
||||
!strings.Contains(err.Error(), "config.token") {
|
||||
t.Fatalf("a missing value must fail, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTemplate(t *testing.T) {
|
||||
var errs []string
|
||||
add := func(f string, _ ...any) { errs = append(errs, f) }
|
||||
validateTemplate("Bearer ${config.api_key}", "h", add)
|
||||
if len(errs) != 0 {
|
||||
t.Fatalf("valid template rejected: %v", errs)
|
||||
}
|
||||
validateTemplate("${env.HOME}", "h", add)
|
||||
validateTemplate("${config.api-key}", "h", add)
|
||||
if len(errs) != 2 {
|
||||
t.Fatalf("want 2 errors, got %v", errs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
// Package pkg reads plugin packages: zip archives (.wkp) with plugin.yaml at
|
||||
// the root. Opening a package validates the manifest, checks that the files it
|
||||
// names exist, and fingerprints the archive, so an installer only ever stores
|
||||
// packages that will load.
|
||||
package pkg
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/plugin/configschema"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/manifest"
|
||||
)
|
||||
|
||||
// ManifestFile is the manifest's name at the package root.
|
||||
const ManifestFile = "plugin.yaml"
|
||||
|
||||
// Limits keep a hostile archive from exhausting memory or disk.
|
||||
const (
|
||||
MaxArchiveBytes = 64 << 20 // compressed upload
|
||||
MaxUncompressedBytes = 256 << 20 // all files together
|
||||
MaxFileBytes = 64 << 20 // one file
|
||||
MaxFiles = 4000
|
||||
)
|
||||
|
||||
// Package is an opened, validated plugin package.
|
||||
type Package struct {
|
||||
Manifest *manifest.Manifest
|
||||
// Digest is "sha256:<hex>" of the archive bytes; packages are stored and
|
||||
// cached under it.
|
||||
Digest string
|
||||
// Size is the archive size in bytes.
|
||||
Size int64
|
||||
files map[string][]byte
|
||||
}
|
||||
|
||||
// Open reads and validates a package archive. Archives whose content sits in
|
||||
// a single top-level directory (as GitHub source archives do) are accepted;
|
||||
// that directory becomes the package root.
|
||||
func Open(data []byte) (*Package, error) {
|
||||
if len(data) > MaxArchiveBytes {
|
||||
return nil, fmt.Errorf("package is %d bytes, over the %d byte limit", len(data), MaxArchiveBytes)
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("package is not a zip archive: %w", err)
|
||||
}
|
||||
if len(zr.File) > MaxFiles {
|
||||
return nil, fmt.Errorf("package has %d files, over the %d file limit", len(zr.File), MaxFiles)
|
||||
}
|
||||
|
||||
files := make(map[string][]byte, len(zr.File))
|
||||
var total int64
|
||||
for _, f := range zr.File {
|
||||
if f.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
name, err := cleanName(f.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if f.FileInfo().Mode().Type() != 0 {
|
||||
return nil, fmt.Errorf("package entry %s is not a regular file", name)
|
||||
}
|
||||
if f.UncompressedSize64 > MaxFileBytes {
|
||||
return nil, fmt.Errorf("package entry %s is over the %d byte limit", name, MaxFileBytes)
|
||||
}
|
||||
b, err := readEntry(f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", name, err)
|
||||
}
|
||||
total += int64(len(b))
|
||||
if total > MaxUncompressedBytes {
|
||||
return nil, fmt.Errorf("package expands to over %d bytes", MaxUncompressedBytes)
|
||||
}
|
||||
files[name] = b
|
||||
}
|
||||
files = stripSingleRoot(files)
|
||||
|
||||
raw, ok := files[ManifestFile]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("package has no %s at its root", ManifestFile)
|
||||
}
|
||||
m, err := manifest.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
p := &Package{Manifest: m, Digest: "sha256:" + hex.EncodeToString(sum[:]), Size: int64(len(data)), files: files}
|
||||
if err := p.checkReferences(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := p.loadConfigSchemas(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// loadConfigSchemas parses the config schema files (JSON or YAML) into the
|
||||
// manifest and checks that every ${scope.key} a template uses is declared.
|
||||
func (p *Package) loadConfigSchemas() error {
|
||||
m := p.Manifest
|
||||
var errs []error
|
||||
load := func(file string) (*configschema.Schema, json.RawMessage) {
|
||||
if file == "" {
|
||||
return nil, nil
|
||||
}
|
||||
raw, err := yamlToJSON(p.files[file])
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("config schema %s: %w", file, err))
|
||||
return nil, nil
|
||||
}
|
||||
s, err := configschema.Parse(raw)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("config schema %s: %w", file, err))
|
||||
return nil, nil
|
||||
}
|
||||
return s, raw
|
||||
}
|
||||
system, systemRaw := load(m.Config.System)
|
||||
tenant, tenantRaw := load(m.Config.Tenant)
|
||||
if len(errs) > 0 {
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
m.Config.SystemSchema, m.Config.TenantSchema = systemRaw, tenantRaw
|
||||
|
||||
declared := func(s *configschema.Schema, key string) bool {
|
||||
return s != nil && s.Properties[key] != nil
|
||||
}
|
||||
for _, c := range m.Contributes[manifest.PointMCPServers] {
|
||||
if c.MCP == nil {
|
||||
continue
|
||||
}
|
||||
for name, value := range c.MCP.Headers {
|
||||
for _, ref := range manifest.TemplateRefs(value) {
|
||||
ok := (ref.Scope == manifest.ScopeConfig && declared(tenant, ref.Key)) ||
|
||||
(ref.Scope == manifest.ScopeSystem && declared(system, ref.Key))
|
||||
if !ok {
|
||||
which := "config.tenant"
|
||||
if ref.Scope == manifest.ScopeSystem {
|
||||
which = "config.system"
|
||||
}
|
||||
errs = append(errs, fmt.Errorf(
|
||||
"mcpServers.%s header %s uses ${%s.%s}, which the %s schema does not declare",
|
||||
c.ID, name, ref.Scope, ref.Key, which))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// yamlToJSON accepts a YAML (or JSON) document and returns it as JSON.
|
||||
func yamlToJSON(data []byte) (json.RawMessage, error) {
|
||||
var v any
|
||||
if err := yaml.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(v)
|
||||
}
|
||||
|
||||
// ReadFile returns one file of the package.
|
||||
func (p *Package) ReadFile(name string) ([]byte, bool) {
|
||||
b, ok := p.files[name]
|
||||
return b, ok
|
||||
}
|
||||
|
||||
// Files returns the files under a directory (or the file itself), sorted,
|
||||
// with paths relative to the package root. An empty dir lists every file.
|
||||
func (p *Package) Files(dir string) []string {
|
||||
dir = strings.TrimSuffix(dir, "/")
|
||||
var out []string
|
||||
for name := range p.files {
|
||||
if dir == "" || name == dir || strings.HasPrefix(name, dir+"/") {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// checkReferences verifies that the files a manifest names are in the package.
|
||||
func (p *Package) checkReferences() error {
|
||||
var errs []error
|
||||
need := func(what, name string) {
|
||||
if _, ok := p.files[name]; !ok {
|
||||
errs = append(errs, fmt.Errorf("%s %s is not in the package", what, name))
|
||||
}
|
||||
}
|
||||
m := p.Manifest
|
||||
if m.Icon != "" {
|
||||
need("icon", m.Icon)
|
||||
}
|
||||
if m.Config.System != "" {
|
||||
need("config schema", m.Config.System)
|
||||
}
|
||||
if m.Config.Tenant != "" {
|
||||
need("config schema", m.Config.Tenant)
|
||||
}
|
||||
for _, info := range manifest.Points() {
|
||||
for _, c := range m.Contributes[info.Point] {
|
||||
switch info.Point {
|
||||
case manifest.PointSkills:
|
||||
need("skill", path.Join(c.Path, "SKILL.md"))
|
||||
case manifest.PointModelVendors:
|
||||
need("model vendor definition", c.Path)
|
||||
}
|
||||
if c.Icon != "" {
|
||||
need("icon", c.Icon)
|
||||
}
|
||||
if c.InstanceSchema != "" {
|
||||
need("config schema", c.InstanceSchema)
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func readEntry(f *zip.File) ([]byte, error) {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rc.Close() }()
|
||||
// Read one byte past the limit so a lying header cannot sneak by.
|
||||
b, err := io.ReadAll(io.LimitReader(rc, MaxFileBytes+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(b) > MaxFileBytes {
|
||||
return nil, errors.New("entry is over the size limit")
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// cleanName normalizes an archive entry name and rejects any that would
|
||||
// escape the package root.
|
||||
func cleanName(name string) (string, error) {
|
||||
n := strings.ReplaceAll(name, "\\", "/")
|
||||
if strings.HasPrefix(n, "/") || strings.Contains(n, ":") {
|
||||
return "", fmt.Errorf("package entry %q has an absolute path", name)
|
||||
}
|
||||
clean := path.Clean(n)
|
||||
if clean == ".." || strings.HasPrefix(clean, "../") || clean == "." {
|
||||
return "", fmt.Errorf("package entry %q escapes the package root", name)
|
||||
}
|
||||
return clean, nil
|
||||
}
|
||||
|
||||
// stripSingleRoot drops a single top-level directory shared by every file,
|
||||
// unless the manifest already sits at the root.
|
||||
func stripSingleRoot(files map[string][]byte) map[string][]byte {
|
||||
if _, ok := files[ManifestFile]; ok || len(files) == 0 {
|
||||
return files
|
||||
}
|
||||
var root string
|
||||
for name := range files {
|
||||
top, _, found := strings.Cut(name, "/")
|
||||
if !found || (root != "" && top != root) {
|
||||
return files
|
||||
}
|
||||
root = top
|
||||
}
|
||||
out := make(map[string][]byte, len(files))
|
||||
for name, b := range files {
|
||||
out[strings.TrimPrefix(name, root+"/")] = b
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package pkg
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/plugin/manifest"
|
||||
)
|
||||
|
||||
const tenantSchema = `type: object
|
||||
properties:
|
||||
api_key: { type: string, title: API key, x-secret: true }
|
||||
required: [api_key]
|
||||
`
|
||||
|
||||
const kitManifest = `schemaVersion: 1
|
||||
id: acme.kit
|
||||
version: 1.0.0
|
||||
name: { en-US: ACME Kit, zh-CN: ACME 工具包 }
|
||||
publisher: { id: acme }
|
||||
runtime: { type: declarative }
|
||||
config: { tenant: config/tenant.yaml }
|
||||
contributes:
|
||||
skills:
|
||||
- { id: triage, name: Triage, path: skills/triage }
|
||||
mcpServers:
|
||||
- id: search
|
||||
name: ACME Search
|
||||
mcp: { url: "https://mcp.acme.example/mcp", headers: { Authorization: "Bearer ${config.api_key}" } }
|
||||
`
|
||||
|
||||
// zipOf builds an archive from name → content; a nil content adds a directory.
|
||||
func zipOf(t *testing.T, files map[string]string) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
for name, content := range files {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write([]byte(content)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestOpenValidPackage(t *testing.T) {
|
||||
data := zipOf(t, map[string]string{
|
||||
"plugin.yaml": kitManifest,
|
||||
"config/tenant.yaml": tenantSchema,
|
||||
"skills/triage/SKILL.md": "---\nname: triage\n---\nTriage issues.",
|
||||
"skills/triage/notes.txt": "x",
|
||||
})
|
||||
p, err := Open(data)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
if p.Manifest.ID != "acme.kit" || !strings.HasPrefix(p.Digest, "sha256:") || p.Size != int64(len(data)) {
|
||||
t.Fatalf("unexpected package: %+v", p)
|
||||
}
|
||||
if got := p.Files("skills/triage"); strings.Join(got, ",") != "skills/triage/SKILL.md,skills/triage/notes.txt" {
|
||||
t.Fatalf("Files = %v", got)
|
||||
}
|
||||
if !strings.Contains(string(p.Manifest.Config.TenantSchema), `"x-secret":true`) {
|
||||
t.Fatalf("tenant schema = %s", p.Manifest.Config.TenantSchema)
|
||||
}
|
||||
mcp := p.Manifest.Contributes[manifest.PointMCPServers][0].MCP
|
||||
if mcp.Headers["Authorization"] != "Bearer ${config.api_key}" {
|
||||
t.Fatalf("mcp = %+v", mcp)
|
||||
}
|
||||
again, _ := Open(data)
|
||||
if again.Digest != p.Digest {
|
||||
t.Fatal("digest must be stable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenStripsSingleRootDirectory(t *testing.T) {
|
||||
p, err := Open(zipOf(t, map[string]string{
|
||||
"acme-kit-main/plugin.yaml": kitManifest,
|
||||
"acme-kit-main/config/tenant.yaml": tenantSchema,
|
||||
"acme-kit-main/skills/triage/SKILL.md": "x",
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
if _, ok := p.ReadFile("skills/triage/SKILL.md"); !ok {
|
||||
t.Fatal("the shared root directory should become the package root")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRejectsBadPackages(t *testing.T) {
|
||||
cases := map[string]struct {
|
||||
files map[string]string
|
||||
raw []byte
|
||||
want string
|
||||
}{
|
||||
"not a zip": {raw: []byte("hello"), want: "not a zip"},
|
||||
"no manifest": {files: map[string]string{"README.md": "x"}, want: "no plugin.yaml"},
|
||||
"zip slip": {files: map[string]string{"plugin.yaml": kitManifest, "../evil": "x"}, want: "escapes"},
|
||||
"absolute": {files: map[string]string{"plugin.yaml": kitManifest, "/etc/passwd": "x"}, want: "absolute"},
|
||||
"missing skill": {
|
||||
files: map[string]string{"plugin.yaml": kitManifest, "config/tenant.yaml": tenantSchema},
|
||||
want: "skill skills/triage/SKILL.md is not in the package",
|
||||
},
|
||||
"invalid manifest": {files: map[string]string{"plugin.yaml": "schemaVersion: 1\nid: Bad\n"}, want: "id"},
|
||||
"declarative with code point": {
|
||||
files: map[string]string{
|
||||
"plugin.yaml": strings.Replace(kitManifest,
|
||||
"contributes:\n", "contributes:\n connectors:\n - { id: jira, name: Jira }\n", 1),
|
||||
"config/tenant.yaml": tenantSchema,
|
||||
"skills/triage/SKILL.md": "x",
|
||||
},
|
||||
want: "not open to third-party plugins",
|
||||
},
|
||||
}
|
||||
for name, c := range cases {
|
||||
data := c.raw
|
||||
if data == nil {
|
||||
data = zipOf(t, c.files)
|
||||
}
|
||||
_, err := Open(data)
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s: got %v, want error containing %q", name, err, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestRejectsEscapingPaths(t *testing.T) {
|
||||
bad := strings.Replace(kitManifest, "path: skills/triage", "path: ../skills", 1)
|
||||
if _, err := manifest.Parse([]byte(bad)); err == nil || !strings.Contains(err.Error(), "inside the package") {
|
||||
t.Fatalf("want path error, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
// Package plugintest has in-memory fakes and sample packages for plugin tests.
|
||||
package plugintest
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/plugin/pkg"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// MemRepo is an in-memory interfaces.PluginRepository.
|
||||
type MemRepo struct {
|
||||
mu sync.Mutex
|
||||
plugins map[string]types.InstalledPlugin
|
||||
versions map[string]types.PluginVersion
|
||||
}
|
||||
|
||||
// NewMemRepo returns an empty in-memory PluginRepository.
|
||||
func NewMemRepo() *MemRepo {
|
||||
return &MemRepo{plugins: map[string]types.InstalledPlugin{}, versions: map[string]types.PluginVersion{}}
|
||||
}
|
||||
|
||||
// ListPlugins returns every row.
|
||||
func (m *MemRepo) ListPlugins(context.Context) ([]types.InstalledPlugin, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
var out []types.InstalledPlugin
|
||||
for _, p := range m.plugins {
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetPlugin returns (nil, nil) for an unknown ID.
|
||||
func (m *MemRepo) GetPlugin(_ context.Context, id string) (*types.InstalledPlugin, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if p, ok := m.plugins[id]; ok {
|
||||
return &p, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// SavePlugin upserts a row.
|
||||
func (m *MemRepo) SavePlugin(_ context.Context, p *types.InstalledPlugin) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.plugins[p.ID] = *p
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeletePlugin removes a row and its versions.
|
||||
func (m *MemRepo) DeletePlugin(_ context.Context, id string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.plugins, id)
|
||||
for k, v := range m.versions {
|
||||
if v.PluginID == id {
|
||||
delete(m.versions, k)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListVersions returns a plugin's versions.
|
||||
func (m *MemRepo) ListVersions(_ context.Context, id string) ([]types.PluginVersion, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
var out []types.PluginVersion
|
||||
for _, v := range m.versions {
|
||||
if v.PluginID == id {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetVersion returns (nil, nil) for an unknown version.
|
||||
func (m *MemRepo) GetVersion(_ context.Context, id, version string) (*types.PluginVersion, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if v, ok := m.versions[id+"@"+version]; ok {
|
||||
return &v, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// SaveVersion upserts a version.
|
||||
func (m *MemRepo) SaveVersion(_ context.Context, v *types.PluginVersion) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.versions[v.PluginID+"@"+v.Version] = *v
|
||||
return nil
|
||||
}
|
||||
|
||||
// MemStore is an in-memory package store.
|
||||
type MemStore struct {
|
||||
mu sync.Mutex
|
||||
Blobs map[string][]byte
|
||||
}
|
||||
|
||||
// Put stores a blob under mem://<digest>.
|
||||
func (s *MemStore) Put(_ context.Context, digest string, data []byte) (string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.Blobs == nil {
|
||||
s.Blobs = map[string][]byte{}
|
||||
}
|
||||
uri := "mem://" + digest
|
||||
s.Blobs[uri] = data
|
||||
return uri, nil
|
||||
}
|
||||
|
||||
// Get returns a stored blob.
|
||||
func (s *MemStore) Get(_ context.Context, uri string) ([]byte, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
b, ok := s.Blobs[uri]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s not found", uri)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// Delete drops a blob.
|
||||
func (s *MemStore) Delete(_ context.Context, uri string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.Blobs, uri)
|
||||
return nil
|
||||
}
|
||||
|
||||
// KitPackage builds the acme.kit declarative package at a version: one
|
||||
// skill, skills/triage. The same version always yields the same bytes.
|
||||
func KitPackage(t testing.TB, version string) []byte {
|
||||
return KitPackageWith(t, version, "")
|
||||
}
|
||||
|
||||
// KitPackageWith is KitPackage with a different skill body and extra
|
||||
// top-level manifest lines.
|
||||
func KitPackageWith(t testing.TB, version, skillBody string, manifestLines ...string) []byte {
|
||||
t.Helper()
|
||||
if skillBody == "" {
|
||||
skillBody = version
|
||||
}
|
||||
manifest := "schemaVersion: 1\nid: acme.kit\nversion: " + version + "\n" +
|
||||
"name: { en-US: ACME Kit }\npublisher: { id: acme }\nruntime: { type: declarative }\n"
|
||||
for _, line := range manifestLines {
|
||||
manifest += line + "\n"
|
||||
}
|
||||
manifest += "contributes:\n skills:\n - { id: triage, name: Triage, path: skills/triage }\n"
|
||||
return Zip(t, map[string]string{
|
||||
"plugin.yaml": manifest,
|
||||
"skills/triage/SKILL.md": "---\nname: triage\ndescription: Triage issues by severity.\n---\n" + skillBody,
|
||||
})
|
||||
}
|
||||
|
||||
// Zip builds an archive from name → content, entries in name order.
|
||||
func Zip(t testing.TB, files map[string]string) []byte {
|
||||
t.Helper()
|
||||
names := make([]string, 0, len(files))
|
||||
for name := range files {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
for _, name := range names {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write([]byte(files[name])); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// Install stores a package version and points the plugin row at it.
|
||||
func Install(t testing.TB, repo *MemRepo, store *MemStore, data []byte, state string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
p, err := pkg.Open(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
uri, _ := store.Put(ctx, p.Digest, data)
|
||||
_ = repo.SaveVersion(ctx, &types.PluginVersion{
|
||||
PluginID: p.Manifest.ID, Version: p.Manifest.Version, Digest: p.Digest, PackageURI: uri,
|
||||
})
|
||||
_ = repo.SavePlugin(ctx, &types.InstalledPlugin{
|
||||
ID: p.Manifest.ID, ActiveVersion: p.Manifest.Version, DesiredState: state, Runtime: "declarative",
|
||||
})
|
||||
}
|
||||
|
||||
// MemTenantSettings is an in-memory interfaces.PluginTenantSettingRepository.
|
||||
type MemTenantSettings struct {
|
||||
mu sync.Mutex
|
||||
rows map[string]types.PluginTenantSetting
|
||||
}
|
||||
|
||||
func tenantKey(tenantID uint64, pluginID string) string {
|
||||
return fmt.Sprintf("%d/%s", tenantID, pluginID)
|
||||
}
|
||||
|
||||
// List returns a tenant's rows.
|
||||
func (m *MemTenantSettings) List(_ context.Context, tenantID uint64) ([]types.PluginTenantSetting, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
var out []types.PluginTenantSetting
|
||||
for _, r := range m.rows {
|
||||
if r.TenantID == tenantID {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Get returns (nil, nil) for a row never written.
|
||||
func (m *MemTenantSettings) Get(
|
||||
_ context.Context, tenantID uint64, pluginID string,
|
||||
) (*types.PluginTenantSetting, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if r, ok := m.rows[tenantKey(tenantID, pluginID)]; ok {
|
||||
return &r, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Upsert inserts a row or updates the given columns.
|
||||
func (m *MemTenantSettings) Upsert(_ context.Context, s *types.PluginTenantSetting, columns ...string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.rows == nil {
|
||||
m.rows = map[string]types.PluginTenantSetting{}
|
||||
}
|
||||
key := tenantKey(s.TenantID, s.PluginID)
|
||||
row, ok := m.rows[key]
|
||||
if !ok || len(columns) == 0 {
|
||||
m.rows[key] = *s
|
||||
return nil
|
||||
}
|
||||
for _, c := range columns {
|
||||
switch c {
|
||||
case "enabled":
|
||||
row.Enabled = s.Enabled
|
||||
case "config":
|
||||
row.Config = s.Config
|
||||
}
|
||||
}
|
||||
row.UpdatedBy, row.UpdatedAt = s.UpdatedBy, s.UpdatedAt
|
||||
m.rows[key] = row
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package reconcile
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/driver"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/manifest"
|
||||
)
|
||||
|
||||
// NodeStatus is one node's report on a plugin.
|
||||
type NodeStatus struct {
|
||||
Node string `json:"node"`
|
||||
Status
|
||||
// SeenAt is when the node last confirmed the report.
|
||||
SeenAt time.Time `json:"seenAt"`
|
||||
}
|
||||
|
||||
const statusKeyBase = "weknora:plugins:status"
|
||||
|
||||
func statusKey(pluginID string) string {
|
||||
if ns := strings.TrimSpace(os.Getenv("WEKNORA_REDIS_NAMESPACE")); ns != "" {
|
||||
return statusKeyBase + ":" + ns + ":" + pluginID
|
||||
}
|
||||
return statusKeyBase + ":" + pluginID
|
||||
}
|
||||
|
||||
// NodeName identifies this node in status reports.
|
||||
func (r *Reconciler) NodeName() string {
|
||||
host, _ := os.Hostname()
|
||||
if host == "" {
|
||||
host = "node"
|
||||
}
|
||||
return host + "/" + r.instanceID[:8]
|
||||
}
|
||||
|
||||
// publishStatuses reports every plugin this node knows about to Redis, so
|
||||
// any node can show the whole cluster. Reports refresh on every pass; a node
|
||||
// that stops reporting ages out.
|
||||
func (r *Reconciler) publishStatuses(ctx context.Context) {
|
||||
if r.rdb == nil {
|
||||
return
|
||||
}
|
||||
r.statusMu.RLock()
|
||||
snapshot := make(map[string]Status, len(r.status))
|
||||
for id, s := range r.status {
|
||||
snapshot[id] = s
|
||||
}
|
||||
r.statusMu.RUnlock()
|
||||
node, now := r.NodeName(), time.Now()
|
||||
pipe := r.rdb.Pipeline()
|
||||
for id, s := range snapshot {
|
||||
b, _ := json.Marshal(NodeStatus{Node: node, Status: s, SeenAt: now})
|
||||
pipe.HSet(ctx, statusKey(id), node, b)
|
||||
pipe.Expire(ctx, statusKey(id), r.staleAfter()*2)
|
||||
}
|
||||
if _, err := pipe.Exec(ctx); err != nil {
|
||||
logger.Warnf(ctx, "[plugin] publish node status: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Reconciler) forgetStatus(ctx context.Context, pluginID string) {
|
||||
if r.rdb == nil {
|
||||
return
|
||||
}
|
||||
if err := r.rdb.HDel(ctx, statusKey(pluginID), r.NodeName()).Err(); err != nil {
|
||||
logger.Warnf(ctx, "[plugin] clear node status of %s: %v", pluginID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Reconciler) staleAfter() time.Duration { return 3 * r.interval }
|
||||
|
||||
// NodeStatuses reports a plugin on every live node; without Redis, on this
|
||||
// node only.
|
||||
func (r *Reconciler) NodeStatuses(ctx context.Context, pluginID string) ([]NodeStatus, error) {
|
||||
local, hasLocal := r.Status(pluginID)
|
||||
if r.rdb == nil {
|
||||
if !hasLocal {
|
||||
return nil, nil
|
||||
}
|
||||
return []NodeStatus{{Node: r.NodeName(), Status: local, SeenAt: time.Now()}}, nil
|
||||
}
|
||||
fields, err := r.rdb.HGetAll(ctx, statusKey(pluginID)).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read node status: %w", err)
|
||||
}
|
||||
var out []NodeStatus
|
||||
self := r.NodeName()
|
||||
for node, raw := range fields {
|
||||
var ns NodeStatus
|
||||
if json.Unmarshal([]byte(raw), &ns) != nil || node == self || time.Since(ns.SeenAt) > r.staleAfter() {
|
||||
continue
|
||||
}
|
||||
out = append(out, ns)
|
||||
}
|
||||
if hasLocal {
|
||||
out = append(out, NodeStatus{Node: self, Status: local, SeenAt: time.Now()})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Node < out[j].Node })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Driver returns the driver for declarative plugins. They have no code to
|
||||
// run, so an instance is a node that loaded the plugin.
|
||||
func (r *Reconciler) Driver() driver.Driver { return declarativeDriver{r} }
|
||||
|
||||
type declarativeDriver struct{ r *Reconciler }
|
||||
|
||||
func (declarativeDriver) Type() manifest.RuntimeType { return manifest.RuntimeDeclarative }
|
||||
|
||||
// Ensure and Remove are the reconciler's job for declarative plugins.
|
||||
func (declarativeDriver) Ensure(context.Context, *manifest.Manifest) error { return nil }
|
||||
func (declarativeDriver) Remove(context.Context, string, string) error { return nil }
|
||||
|
||||
func (declarativeDriver) Resolve(_ context.Context, pluginID string, _ uint64) (driver.Endpoint, error) {
|
||||
return driver.Endpoint{}, fmt.Errorf("declarative plugin %s has no endpoint", pluginID)
|
||||
}
|
||||
|
||||
func (d declarativeDriver) Status(ctx context.Context, pluginID string) ([]driver.InstanceStatus, error) {
|
||||
nodes, err := d.r.NodeStatuses(ctx, pluginID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]driver.InstanceStatus, 0, len(nodes))
|
||||
for _, n := range nodes {
|
||||
state := driver.StateReady
|
||||
if n.State != StateReady {
|
||||
state = driver.StateStopped
|
||||
}
|
||||
out = append(out, driver.InstanceStatus{
|
||||
Node: n.Node, Version: n.Version, State: state, Error: n.Error, UpdatedAt: n.UpdatedAt,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package reconcile
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/plugin/plugintest"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/registry"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// Two nodes share a database and Redis: installing on one reaches the other
|
||||
// through the broadcast, and each sees the other's status.
|
||||
func TestNodesConvergeThroughRedis(t *testing.T) {
|
||||
mr := miniredis.RunT(t)
|
||||
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
repo, store := plugintest.NewMemRepo(), &plugintest.MemStore{}
|
||||
newNode := func() (*Reconciler, *registry.Registry) {
|
||||
reg := registry.New()
|
||||
return New(Options{
|
||||
Repo: repo, Store: store, Registry: reg, CacheDir: t.TempDir(), Redis: rdb,
|
||||
Interval: time.Hour,
|
||||
}), reg
|
||||
}
|
||||
a, _ := newNode()
|
||||
b, regB := newNode()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
a.Start(ctx)
|
||||
b.Start(ctx)
|
||||
waitFor(t, func() bool { return mr.PubSubNumSub(channel())[channel()] == 2 })
|
||||
|
||||
plugintest.Install(t, repo, store, plugintest.KitPackage(t, "1.0.0"), types.PluginStateEnabled)
|
||||
if err := a.Reconcile(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.Notify(ctx)
|
||||
waitFor(t, func() bool { _, ok := regB.Plugin("acme.kit"); return ok })
|
||||
|
||||
waitFor(t, func() bool {
|
||||
nodes, err := a.NodeStatuses(ctx, "acme.kit")
|
||||
return err == nil && len(nodes) == 2
|
||||
})
|
||||
instances, err := b.Driver().Status(ctx, "acme.kit")
|
||||
if err != nil || len(instances) != 2 || instances[0].State != "ready" {
|
||||
t.Fatalf("instances = %+v, %v", instances, err)
|
||||
}
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for !cond() {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("condition not reached")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
// Package reconcile makes every node run the plugins the database says should
|
||||
// run. Installing, upgrading, disabling or removing a plugin only writes rows;
|
||||
// each node's Reconciler then loads the active version of every enabled plugin
|
||||
// into the registry, hands its contributions to the domain activators, and
|
||||
// unloads everything else. A Redis broadcast makes peers reconcile at once; a
|
||||
// periodic pass catches anything a broadcast missed.
|
||||
package reconcile
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/manifest"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/pkg"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/registry"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
"github.com/Tencent/WeKnora/internal/utils"
|
||||
)
|
||||
|
||||
// Loaded is a plugin version loaded on this node.
|
||||
type Loaded struct {
|
||||
Manifest *manifest.Manifest
|
||||
Package *pkg.Package
|
||||
// Dir is the package extracted on local disk, for consumers that read
|
||||
// files by path (skills).
|
||||
Dir string
|
||||
}
|
||||
|
||||
// Activator wires one domain to plugin contributions: it registers what a
|
||||
// loaded plugin contributes (skills, MCP servers, model vendors) and removes
|
||||
// it again. Activate is called again for each new version, after Deactivate
|
||||
// for the old one.
|
||||
type Activator interface {
|
||||
Name() string
|
||||
Activate(ctx context.Context, l *Loaded) error
|
||||
Deactivate(ctx context.Context, pluginID string) error
|
||||
}
|
||||
|
||||
// Status is how a plugin fares on this node.
|
||||
type Status struct {
|
||||
Version string `json:"version"`
|
||||
State string `json:"state"`
|
||||
Error string `json:"error,omitempty"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// Node states reported in Status.
|
||||
const (
|
||||
StateReady = "ready"
|
||||
StateFailed = "failed"
|
||||
)
|
||||
|
||||
// DefaultInterval is how often a node reconciles without being told to.
|
||||
const DefaultInterval = 30 * time.Second
|
||||
|
||||
const channelBase = "weknora:plugins:changed"
|
||||
|
||||
func channel() string {
|
||||
if ns := strings.TrimSpace(os.Getenv("WEKNORA_REDIS_NAMESPACE")); ns != "" {
|
||||
return channelBase + ":" + ns
|
||||
}
|
||||
return channelBase
|
||||
}
|
||||
|
||||
type changeMessage struct {
|
||||
OriginID string `json:"origin_id"`
|
||||
}
|
||||
|
||||
// Reconciler converges this node to the installed-plugin rows.
|
||||
type Reconciler struct {
|
||||
repo interfaces.PluginRepository
|
||||
store PackageStore
|
||||
registry *registry.Registry
|
||||
cacheDir string
|
||||
rdb *redis.Client
|
||||
activators []Activator
|
||||
instanceID string
|
||||
interval time.Duration
|
||||
|
||||
mu sync.Mutex // serializes passes
|
||||
loaded map[string]*Loaded
|
||||
digests map[string]string // plugin ID → loaded digest
|
||||
statusMu sync.RWMutex
|
||||
status map[string]Status
|
||||
runOnce sync.Once
|
||||
}
|
||||
|
||||
// Options configures a Reconciler.
|
||||
type Options struct {
|
||||
Repo interfaces.PluginRepository
|
||||
Store PackageStore
|
||||
Registry *registry.Registry
|
||||
CacheDir string
|
||||
Redis *redis.Client // nil on single-node deployments
|
||||
Activators []Activator
|
||||
Interval time.Duration
|
||||
}
|
||||
|
||||
// New creates a Reconciler.
|
||||
func New(o Options) *Reconciler {
|
||||
if o.Interval <= 0 {
|
||||
o.Interval = DefaultInterval
|
||||
}
|
||||
if o.CacheDir == "" {
|
||||
o.CacheDir = DefaultCacheDir()
|
||||
}
|
||||
return &Reconciler{
|
||||
repo: o.Repo, store: o.Store, registry: o.Registry, cacheDir: o.CacheDir, rdb: o.Redis,
|
||||
activators: o.Activators, instanceID: uuid.NewString(), interval: o.Interval,
|
||||
loaded: map[string]*Loaded{}, digests: map[string]string{}, status: map[string]Status{},
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultCacheDir is where packages are extracted unless
|
||||
// WEKNORA_PLUGIN_CACHE_DIR says otherwise.
|
||||
func DefaultCacheDir() string {
|
||||
if dir := strings.TrimSpace(os.Getenv("WEKNORA_PLUGIN_CACHE_DIR")); dir != "" {
|
||||
return dir
|
||||
}
|
||||
return filepath.Join(os.TempDir(), "weknora-plugins")
|
||||
}
|
||||
|
||||
// Reconcile runs one pass. It keeps going past a plugin that fails to load,
|
||||
// records the failure in Status, and returns every failure joined.
|
||||
func (r *Reconciler) Reconcile(ctx context.Context) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
rows, err := r.repo.ListPlugins(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list installed plugins: %w", err)
|
||||
}
|
||||
var errs []error
|
||||
desired := map[string]bool{}
|
||||
for _, row := range rows {
|
||||
if row.DesiredState != types.PluginStateEnabled {
|
||||
continue
|
||||
}
|
||||
desired[row.ID] = true
|
||||
if err := r.ensure(ctx, row); err != nil {
|
||||
errs = append(errs, fmt.Errorf("plugin %s: %w", row.ID, err))
|
||||
r.setStatus(row.ID, Status{Version: row.ActiveVersion, State: StateFailed, Error: err.Error()})
|
||||
}
|
||||
}
|
||||
for id := range r.digests {
|
||||
if !desired[id] {
|
||||
r.unload(ctx, id)
|
||||
}
|
||||
}
|
||||
// A plugin that never loaded has a status but no digest.
|
||||
r.statusMu.Lock()
|
||||
for id := range r.status {
|
||||
if !desired[id] {
|
||||
delete(r.status, id)
|
||||
r.forgetStatus(ctx, id)
|
||||
}
|
||||
}
|
||||
r.statusMu.Unlock()
|
||||
r.publishStatuses(ctx)
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// ensure loads the active version of one plugin unless it already is.
|
||||
func (r *Reconciler) ensure(ctx context.Context, row types.InstalledPlugin) error {
|
||||
v, err := r.repo.GetVersion(ctx, row.ID, row.ActiveVersion)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if v == nil {
|
||||
return fmt.Errorf("version %s is not stored", row.ActiveVersion)
|
||||
}
|
||||
if r.digests[row.ID] == v.Digest {
|
||||
return nil
|
||||
}
|
||||
data, err := r.store.Get(ctx, v.PackageURI)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := pkg.Open(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p.Digest != v.Digest {
|
||||
return fmt.Errorf("stored package digest %s does not match %s", p.Digest, v.Digest)
|
||||
}
|
||||
if p.Manifest.ID != row.ID {
|
||||
return fmt.Errorf("package is plugin %s, not %s", p.Manifest.ID, row.ID)
|
||||
}
|
||||
dir, err := r.extract(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.registry.Replace(p.Manifest); err != nil {
|
||||
return err
|
||||
}
|
||||
l := &Loaded{Manifest: p.Manifest, Package: p, Dir: dir}
|
||||
var errs []error
|
||||
for _, a := range r.activators {
|
||||
if _, had := r.loaded[row.ID]; had {
|
||||
if err := a.Deactivate(ctx, row.ID); err != nil {
|
||||
errs = append(errs, fmt.Errorf("%s: deactivate previous version: %w", a.Name(), err))
|
||||
}
|
||||
}
|
||||
if err := a.Activate(ctx, l); err != nil {
|
||||
errs = append(errs, fmt.Errorf("%s: %w", a.Name(), err))
|
||||
}
|
||||
}
|
||||
r.loaded[row.ID] = l
|
||||
r.digests[row.ID] = v.Digest
|
||||
if err := errors.Join(errs...); err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Infof(ctx, "[plugin] loaded %s %s", row.ID, row.ActiveVersion)
|
||||
r.setStatus(row.ID, Status{Version: row.ActiveVersion, State: StateReady})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Reconciler) unload(ctx context.Context, id string) {
|
||||
for _, a := range r.activators {
|
||||
if err := a.Deactivate(ctx, id); err != nil {
|
||||
logger.Warnf(ctx, "[plugin] %s: deactivate %s: %v", a.Name(), id, err)
|
||||
}
|
||||
}
|
||||
if err := r.registry.Unregister(id); err != nil {
|
||||
logger.Warnf(ctx, "[plugin] unregister %s: %v", id, err)
|
||||
}
|
||||
delete(r.loaded, id)
|
||||
delete(r.digests, id)
|
||||
r.statusMu.Lock()
|
||||
delete(r.status, id)
|
||||
r.statusMu.Unlock()
|
||||
r.forgetStatus(ctx, id)
|
||||
logger.Infof(ctx, "[plugin] unloaded %s", id)
|
||||
}
|
||||
|
||||
// extract writes the package under cacheDir/<digest>, once per digest.
|
||||
func (r *Reconciler) extract(p *pkg.Package) (string, error) {
|
||||
dir := filepath.Join(r.cacheDir, strings.TrimPrefix(p.Digest, "sha256:"))
|
||||
if _, err := os.Stat(filepath.Join(dir, pkg.ManifestFile)); err == nil {
|
||||
return dir, nil
|
||||
}
|
||||
tmp := dir + ".tmp-" + uuid.NewString()[:8]
|
||||
for _, name := range p.Files("") {
|
||||
data, _ := p.ReadFile(name)
|
||||
target, err := utils.SafeJoinUnderBase(tmp, name)
|
||||
if err != nil {
|
||||
_ = os.RemoveAll(tmp)
|
||||
return "", err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
_ = os.RemoveAll(tmp)
|
||||
return "", err
|
||||
}
|
||||
if err := os.WriteFile(target, data, 0o644); err != nil {
|
||||
_ = os.RemoveAll(tmp)
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if err := os.Rename(tmp, dir); err != nil {
|
||||
_ = os.RemoveAll(tmp)
|
||||
// Another pass extracted the same digest first.
|
||||
if _, statErr := os.Stat(filepath.Join(dir, pkg.ManifestFile)); statErr == nil {
|
||||
return dir, nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
func (r *Reconciler) setStatus(id string, s Status) {
|
||||
s.UpdatedAt = time.Now()
|
||||
r.statusMu.Lock()
|
||||
r.status[id] = s
|
||||
r.statusMu.Unlock()
|
||||
}
|
||||
|
||||
// Status reports how one plugin fares on this node.
|
||||
func (r *Reconciler) Status(pluginID string) (Status, bool) {
|
||||
r.statusMu.RLock()
|
||||
defer r.statusMu.RUnlock()
|
||||
s, ok := r.status[pluginID]
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// Loaded returns the plugins loaded on this node, sorted by ID.
|
||||
func (r *Reconciler) Loaded() []*Loaded {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]*Loaded, 0, len(r.loaded))
|
||||
for _, l := range r.loaded {
|
||||
out = append(out, l)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Manifest.ID < out[j].Manifest.ID })
|
||||
return out
|
||||
}
|
||||
|
||||
// Notify tells peer nodes to reconcile. Best effort: the periodic pass
|
||||
// catches up when Redis is down or absent.
|
||||
func (r *Reconciler) Notify(ctx context.Context) {
|
||||
if r.rdb == nil {
|
||||
return
|
||||
}
|
||||
payload, _ := json.Marshal(changeMessage{OriginID: r.instanceID})
|
||||
if err := r.rdb.Publish(ctx, channel(), payload).Err(); err != nil {
|
||||
logger.Warnf(ctx, "[plugin] publish change: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Start reconciles once, then keeps reconciling on broadcasts and on a timer
|
||||
// until ctx ends. Calling it twice has no effect.
|
||||
func (r *Reconciler) Start(ctx context.Context) {
|
||||
r.runOnce.Do(func() {
|
||||
if err := r.Reconcile(ctx); err != nil {
|
||||
logger.Warnf(ctx, "[plugin] initial reconcile: %v", err)
|
||||
}
|
||||
wake := make(chan struct{}, 1)
|
||||
if r.rdb != nil {
|
||||
go r.subscribe(ctx, wake)
|
||||
}
|
||||
go r.loop(ctx, wake)
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Reconciler) loop(ctx context.Context, wake <-chan struct{}) {
|
||||
t := time.NewTicker(r.interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
case <-wake:
|
||||
}
|
||||
if err := r.Reconcile(ctx); err != nil {
|
||||
logger.Warnf(ctx, "[plugin] reconcile: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// subscribe forwards peer broadcasts to wake, reconnecting with backoff.
|
||||
func (r *Reconciler) subscribe(ctx context.Context, wake chan<- struct{}) {
|
||||
const maxBackoff = 30 * time.Second
|
||||
backoff := time.Second
|
||||
for ctx.Err() == nil {
|
||||
sub := r.rdb.Subscribe(ctx, channel())
|
||||
if _, err := sub.Receive(ctx); err != nil {
|
||||
_ = sub.Close()
|
||||
logger.Warnf(ctx, "[plugin] subscribe: %v (retry in %s)", err, backoff)
|
||||
select {
|
||||
case <-time.After(backoff):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
backoff = min(backoff*2, maxBackoff)
|
||||
continue
|
||||
}
|
||||
backoff = time.Second
|
||||
for msg := range sub.Channel() {
|
||||
var m changeMessage
|
||||
if json.Unmarshal([]byte(msg.Payload), &m) == nil && m.OriginID == r.instanceID {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case wake <- struct{}{}:
|
||||
default: // a pass is already pending
|
||||
}
|
||||
}
|
||||
_ = sub.Close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package reconcile
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/plugin/manifest"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/plugintest"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/registry"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// recorder is an Activator that logs its calls.
|
||||
type recorder struct {
|
||||
calls []string
|
||||
fail bool
|
||||
}
|
||||
|
||||
func (a *recorder) Name() string { return "recorder" }
|
||||
|
||||
func (a *recorder) Activate(_ context.Context, l *Loaded) error {
|
||||
a.calls = append(a.calls, "activate "+l.Manifest.ID+"@"+l.Manifest.Version)
|
||||
if a.fail {
|
||||
return fmt.Errorf("boom")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *recorder) Deactivate(_ context.Context, id string) error {
|
||||
a.calls = append(a.calls, "deactivate "+id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestReconcileLoadsUpgradesAndUnloads(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo, store, reg, act := plugintest.NewMemRepo(), &plugintest.MemStore{}, registry.New(), &recorder{}
|
||||
r := New(Options{Repo: repo, Store: store, Registry: reg, CacheDir: t.TempDir(), Activators: []Activator{act}})
|
||||
|
||||
plugintest.Install(t, repo, store, plugintest.KitPackage(t, "1.0.0"), types.PluginStateEnabled)
|
||||
if err := r.Reconcile(ctx); err != nil {
|
||||
t.Fatalf("Reconcile: %v", err)
|
||||
}
|
||||
if _, ok := reg.Resolve(manifest.PointSkills, "acme.kit/triage"); !ok {
|
||||
t.Fatal("skill contribution should be registered")
|
||||
}
|
||||
loaded := r.Loaded()
|
||||
if len(loaded) != 1 {
|
||||
t.Fatalf("loaded = %d", len(loaded))
|
||||
}
|
||||
skill, err := os.ReadFile(filepath.Join(loaded[0].Dir, "skills/triage/SKILL.md"))
|
||||
if err != nil || !strings.HasSuffix(string(skill), "1.0.0") {
|
||||
t.Fatalf("extracted skill = %q, %v", skill, err)
|
||||
}
|
||||
if s, _ := r.Status("acme.kit"); s.State != StateReady || s.Version != "1.0.0" {
|
||||
t.Fatalf("status = %+v", s)
|
||||
}
|
||||
|
||||
// A second pass with nothing changed does nothing.
|
||||
_ = r.Reconcile(ctx)
|
||||
if len(act.calls) != 1 {
|
||||
t.Fatalf("calls = %v", act.calls)
|
||||
}
|
||||
|
||||
plugintest.Install(t, repo, store, plugintest.KitPackage(t, "1.1.0"), types.PluginStateEnabled)
|
||||
if err := r.Reconcile(ctx); err != nil {
|
||||
t.Fatalf("Reconcile upgrade: %v", err)
|
||||
}
|
||||
if m, _ := reg.Plugin("acme.kit"); m.Version != "1.1.0" {
|
||||
t.Fatalf("registry has %s", m.Version)
|
||||
}
|
||||
|
||||
row, _ := repo.GetPlugin(ctx, "acme.kit")
|
||||
row.DesiredState = types.PluginStateDisabled
|
||||
_ = repo.SavePlugin(ctx, row)
|
||||
if err := r.Reconcile(ctx); err != nil {
|
||||
t.Fatalf("Reconcile disable: %v", err)
|
||||
}
|
||||
if _, ok := reg.Plugin("acme.kit"); ok {
|
||||
t.Fatal("disabled plugin should be unregistered")
|
||||
}
|
||||
if _, ok := r.Status("acme.kit"); ok {
|
||||
t.Fatal("unloaded plugin should have no status")
|
||||
}
|
||||
want := "activate acme.kit@1.0.0,deactivate acme.kit,activate acme.kit@1.1.0,deactivate acme.kit"
|
||||
if got := strings.Join(act.calls, ","); got != want {
|
||||
t.Fatalf("calls = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileReportsFailures(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo, store, reg := plugintest.NewMemRepo(), &plugintest.MemStore{}, registry.New()
|
||||
r := New(Options{Repo: repo, Store: store, Registry: reg, CacheDir: t.TempDir()})
|
||||
|
||||
plugintest.Install(t, repo, store, plugintest.KitPackage(t, "1.0.0"), types.PluginStateEnabled)
|
||||
// Tamper with the stored package: the digest check must refuse it.
|
||||
for uri := range store.Blobs {
|
||||
store.Blobs[uri] = plugintest.KitPackage(t, "6.6.6")
|
||||
}
|
||||
err := r.Reconcile(ctx)
|
||||
if err == nil || !strings.Contains(err.Error(), "does not match") {
|
||||
t.Fatalf("want digest error, got %v", err)
|
||||
}
|
||||
if s, _ := r.Status("acme.kit"); s.State != StateFailed {
|
||||
t.Fatalf("status = %+v", s)
|
||||
}
|
||||
if _, ok := reg.Plugin("acme.kit"); ok {
|
||||
t.Fatal("a package that failed verification must not be registered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivatorFailureMarksPluginFailed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo, store := plugintest.NewMemRepo(), &plugintest.MemStore{}
|
||||
r := New(Options{
|
||||
Repo: repo, Store: store, Registry: registry.New(), CacheDir: t.TempDir(),
|
||||
Activators: []Activator{&recorder{fail: true}},
|
||||
})
|
||||
plugintest.Install(t, repo, store, plugintest.KitPackage(t, "1.0.0"), types.PluginStateEnabled)
|
||||
if err := r.Reconcile(ctx); err == nil || !strings.Contains(err.Error(), "recorder: boom") {
|
||||
t.Fatalf("want activator error, got %v", err)
|
||||
}
|
||||
if s, _ := r.Status("acme.kit"); s.State != StateFailed || !strings.Contains(s.Error, "boom") {
|
||||
t.Fatalf("status = %+v", s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package reconcile
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
)
|
||||
|
||||
// PackageStore keeps plugin package archives where every node can read them.
|
||||
type PackageStore interface {
|
||||
// Put stores an archive and returns the URI to read it back.
|
||||
Put(ctx context.Context, digest string, data []byte) (string, error)
|
||||
Get(ctx context.Context, uri string) ([]byte, error)
|
||||
Delete(ctx context.Context, uri string) error
|
||||
}
|
||||
|
||||
// fileStore stores packages through a raw FileService (the deployment's
|
||||
// object storage, or local disk on single-node setups). Packages are not
|
||||
// tenant data, so they bypass the tenant resource catalog.
|
||||
type fileStore struct {
|
||||
fs interfaces.FileService
|
||||
}
|
||||
|
||||
// NewFileStore returns a PackageStore on top of a raw FileService.
|
||||
func NewFileStore(fs interfaces.FileService) PackageStore {
|
||||
return &fileStore{fs: fs}
|
||||
}
|
||||
|
||||
func (s *fileStore) Put(ctx context.Context, digest string, data []byte) (string, error) {
|
||||
name := "plugin-" + strings.TrimPrefix(digest, "sha256:") + ".wkp"
|
||||
uri, err := s.fs.SaveBytes(ctx, data, 0, name, false)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("store plugin package: %w", err)
|
||||
}
|
||||
return uri, nil
|
||||
}
|
||||
|
||||
func (s *fileStore) Get(ctx context.Context, uri string) ([]byte, error) {
|
||||
rc, err := s.fs.GetFile(ctx, uri)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read plugin package: %w", err)
|
||||
}
|
||||
defer func() { _ = rc.Close() }()
|
||||
return io.ReadAll(rc)
|
||||
}
|
||||
|
||||
func (s *fileStore) Delete(ctx context.Context, uri string) error {
|
||||
return s.fs.DeleteFile(ctx, uri)
|
||||
}
|
||||
@@ -51,17 +51,56 @@ func (r *Registry) Register(m *manifest.Manifest) error {
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, ok := r.plugins[m.ID]; ok {
|
||||
return fmt.Errorf("plugin %s is already registered", m.ID)
|
||||
}
|
||||
// Check every key before touching the index so a failure leaves nothing
|
||||
// half registered.
|
||||
if err := r.checkKeysLocked(m, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
r.addLocked(m)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Replace swaps the registered plugin with the same ID for m in one step, or
|
||||
// registers m if the ID is new: an upgrade never leaves a window where the
|
||||
// plugin's contributions are missing. Builtins cannot be replaced.
|
||||
func (r *Registry) Replace(m *manifest.Manifest) error {
|
||||
if err := m.Validate(); err != nil {
|
||||
return fmt.Errorf("plugin %s: %w", m.ID, err)
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if old, ok := r.plugins[m.ID]; ok && old.Builtin {
|
||||
return fmt.Errorf("builtin plugin %s cannot be replaced", m.ID)
|
||||
}
|
||||
if err := r.checkKeysLocked(m, m.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
r.removeLocked(m.ID)
|
||||
r.addLocked(m)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unregister removes a plugin and its contributions. Builtins cannot be
|
||||
// removed; removing an unknown ID is a no-op.
|
||||
func (r *Registry) Unregister(id string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if m, ok := r.plugins[id]; ok && m.Builtin {
|
||||
return fmt.Errorf("builtin plugin %s cannot be removed", id)
|
||||
}
|
||||
r.removeLocked(id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkKeysLocked verifies that m's IDs and aliases are free, ignoring the
|
||||
// entries of the plugin being replaced.
|
||||
func (r *Registry) checkKeysLocked(m *manifest.Manifest, replacing string) error {
|
||||
pending := make(map[manifest.Point]map[string]string)
|
||||
for _, info := range manifest.Points() {
|
||||
for _, c := range m.Contributes[info.Point] {
|
||||
for _, key := range lookupKeys(m.ID, c) {
|
||||
if owner, ok := r.index[info.Point][key]; ok {
|
||||
if owner, ok := r.index[info.Point][key]; ok && owner.PluginID != replacing {
|
||||
return fmt.Errorf("%s %q of plugin %s collides with %s",
|
||||
info.Point, key, m.ID, owner.QualifiedID)
|
||||
}
|
||||
@@ -75,7 +114,10 @@ func (r *Registry) Register(m *manifest.Manifest) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Registry) addLocked(m *manifest.Manifest) {
|
||||
r.plugins[m.ID] = m
|
||||
for _, info := range manifest.Points() {
|
||||
for _, c := range m.Contributes[info.Point] {
|
||||
@@ -96,7 +138,29 @@ func (r *Registry) Register(m *manifest.Manifest) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Registry) removeLocked(id string) {
|
||||
if _, ok := r.plugins[id]; !ok {
|
||||
return
|
||||
}
|
||||
delete(r.plugins, id)
|
||||
for point, list := range r.entries {
|
||||
kept := list[:0]
|
||||
for _, e := range list {
|
||||
if e.PluginID != id {
|
||||
kept = append(kept, e)
|
||||
}
|
||||
}
|
||||
r.entries[point] = kept
|
||||
}
|
||||
for _, idx := range r.index {
|
||||
for key, e := range idx {
|
||||
if e.PluginID == id {
|
||||
delete(idx, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func lookupKeys(pluginID string, c manifest.Contribution) []string {
|
||||
|
||||
@@ -116,3 +116,55 @@ func TestRegisterValidates(t *testing.T) {
|
||||
t.Fatal("invalid manifest must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func thirdParty(id, version string, contributes manifest.Contributions) *manifest.Manifest {
|
||||
publisher, _, _ := strings.Cut(id, ".")
|
||||
return &manifest.Manifest{
|
||||
SchemaVersion: manifest.SchemaVersion, ID: id, Version: version, Name: manifest.Text(id, nil),
|
||||
Publisher: manifest.Publisher{ID: publisher}, Runtime: manifest.Runtime{Type: manifest.RuntimeDeclarative},
|
||||
Contributes: contributes,
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceAndUnregister(t *testing.T) {
|
||||
r := New()
|
||||
skill := func(id string) manifest.Contribution {
|
||||
return manifest.Contribution{ID: id, Name: manifest.Text(id, nil), Path: "skills/" + id}
|
||||
}
|
||||
if err := r.Register(builtin("weknora.core", manifest.Contributions{
|
||||
manifest.PointTools: {contribution("thinking", 0, "thinking")},
|
||||
})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v1 := thirdParty("acme.kit", "1.0.0", manifest.Contributions{manifest.PointSkills: {skill("triage"), skill("old")}})
|
||||
if err := r.Replace(v1); err != nil {
|
||||
t.Fatalf("Replace as first install: %v", err)
|
||||
}
|
||||
v2 := thirdParty("acme.kit", "2.0.0", manifest.Contributions{manifest.PointSkills: {skill("triage"), skill("new")}})
|
||||
if err := r.Replace(v2); err != nil {
|
||||
t.Fatalf("upgrade: %v", err)
|
||||
}
|
||||
if m, _ := r.Plugin("acme.kit"); m.Version != "2.0.0" {
|
||||
t.Fatalf("version = %s", m.Version)
|
||||
}
|
||||
if _, ok := r.Resolve(manifest.PointSkills, "acme.kit/old"); ok {
|
||||
t.Fatal("contributions dropped by the upgrade must be gone")
|
||||
}
|
||||
if got := len(r.Contributions(manifest.PointSkills)); got != 2 {
|
||||
t.Fatalf("skills after upgrade = %d, want 2", got)
|
||||
}
|
||||
if err := r.Unregister("acme.kit"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := r.Plugin("acme.kit"); ok || len(r.Contributions(manifest.PointSkills)) != 0 {
|
||||
t.Fatal("unregister must remove the plugin and its contributions")
|
||||
}
|
||||
if err := r.Unregister("weknora.core"); err == nil {
|
||||
t.Fatal("builtins cannot be removed")
|
||||
}
|
||||
if err := r.Replace(builtin("weknora.core", manifest.Contributions{
|
||||
manifest.PointTools: {contribution("thinking", 0)},
|
||||
})); err == nil {
|
||||
t.Fatal("builtins cannot be replaced")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package tenancy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/plugin/configschema"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// ErrNoTenantConfig is returned for a plugin without a config.tenant schema.
|
||||
var ErrNoTenantConfig = errors.New("this plugin has no workspace configuration")
|
||||
|
||||
// TenantConfig is a workspace's plugin configuration as the UI sees it.
|
||||
type TenantConfig struct {
|
||||
Schema json.RawMessage `json:"schema"`
|
||||
// Values has secrets redacted.
|
||||
Values map[string]any `json:"values"`
|
||||
UpdatedAt time.Time `json:"updatedAt,omitzero"`
|
||||
}
|
||||
|
||||
func (s *Service) tenantSchema(pluginID string) (*configschema.Schema, json.RawMessage, error) {
|
||||
m, ok := s.registry.Plugin(pluginID)
|
||||
if !ok {
|
||||
return nil, nil, ErrUnknownPlugin
|
||||
}
|
||||
raw := m.Config.TenantSchema
|
||||
if len(raw) == 0 {
|
||||
return nil, nil, ErrNoTenantConfig
|
||||
}
|
||||
schema, err := configschema.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("plugin %s tenant schema: %w", pluginID, err)
|
||||
}
|
||||
return schema, raw, nil
|
||||
}
|
||||
|
||||
func storedValues(row *types.PluginTenantSetting) map[string]any {
|
||||
if row == nil || len(row.Config) == 0 {
|
||||
return map[string]any{}
|
||||
}
|
||||
var v map[string]any
|
||||
if json.Unmarshal(row.Config, &v) != nil || v == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Config returns the tenant's configuration of a plugin, secrets redacted.
|
||||
func (s *Service) Config(ctx context.Context, tenantID uint64, pluginID string) (*TenantConfig, error) {
|
||||
schema, raw, err := s.tenantSchema(pluginID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row, err := s.repo.Get(ctx, tenantID, pluginID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := &TenantConfig{Schema: raw, Values: configschema.Redact(schema, storedValues(row))}
|
||||
if row != nil {
|
||||
out.UpdatedAt = row.UpdatedAt
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetConfig validates and stores the tenant's configuration of a plugin.
|
||||
// Secrets sent back redacted keep their stored value. Validation failures
|
||||
// come back as configschema.FieldErrors.
|
||||
func (s *Service) SetConfig(
|
||||
ctx context.Context, tenantID uint64, pluginID string, values map[string]any, updatedBy string,
|
||||
) (*TenantConfig, error) {
|
||||
schema, _, err := s.tenantSchema(pluginID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row, err := s.repo.Get(ctx, tenantID, pluginID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sealed, err := configschema.Update(schema, storedValues(row), values)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := json.Marshal(sealed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
enabled := row != nil && row.Enabled
|
||||
if row == nil {
|
||||
m, _ := s.registry.Plugin(pluginID)
|
||||
enabled = enabledByDefault(m)
|
||||
}
|
||||
if err := s.repo.Upsert(ctx, &types.PluginTenantSetting{
|
||||
TenantID: tenantID, PluginID: pluginID, Enabled: enabled, Config: types.JSON(b),
|
||||
UpdatedBy: updatedBy, UpdatedAt: time.Now(),
|
||||
}, "config"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.Config(ctx, tenantID, pluginID)
|
||||
}
|
||||
|
||||
// OpenConfig returns the tenant's configuration with secrets decrypted, for
|
||||
// code about to use it, and when it last changed.
|
||||
func (s *Service) OpenConfig(ctx context.Context, tenantID uint64, pluginID string) (map[string]any, time.Time, error) {
|
||||
schema, _, err := s.tenantSchema(pluginID)
|
||||
if errors.Is(err, ErrNoTenantConfig) {
|
||||
return map[string]any{}, time.Time{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
row, err := s.repo.Get(ctx, tenantID, pluginID)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
values, err := configschema.Open(schema, storedValues(row))
|
||||
if err != nil {
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
var updated time.Time
|
||||
if row != nil {
|
||||
updated = row.UpdatedAt
|
||||
}
|
||||
return values, updated, nil
|
||||
}
|
||||
@@ -54,7 +54,7 @@ func (s *Service) List(ctx context.Context, tenantID uint64) ([]TenantPlugin, er
|
||||
plugins := s.registry.Plugins()
|
||||
out := make([]TenantPlugin, 0, len(plugins))
|
||||
for _, m := range plugins {
|
||||
tp := TenantPlugin{Manifest: m, Enabled: true}
|
||||
tp := TenantPlugin{Manifest: m, Enabled: enabledByDefault(m)}
|
||||
if r, ok := byID[m.ID]; ok {
|
||||
tp.Enabled = r.Enabled || m.Required
|
||||
tp.UpdatedAt = r.UpdatedAt
|
||||
@@ -81,35 +81,39 @@ func (s *Service) SetEnabled(
|
||||
Enabled: enabled,
|
||||
UpdatedBy: updatedBy,
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
}, "enabled")
|
||||
}
|
||||
|
||||
// enabledByDefault is a plugin's switch in a tenant that never set it:
|
||||
// builtins are on, installed plugins wait for a tenant admin to opt in.
|
||||
func enabledByDefault(m *manifest.Manifest) bool { return m.Builtin }
|
||||
|
||||
// EnabledFilter implements interfaces.PluginGate. If the switches cannot be
|
||||
// read it fails open: offering a disabled integration is recoverable,
|
||||
// hiding every integration on a database hiccup is not.
|
||||
func (s *Service) EnabledFilter(ctx context.Context, tenantID uint64) func(manifest.Point, string) bool {
|
||||
disabled := map[string]bool{}
|
||||
set := map[string]bool{}
|
||||
rows, err := s.repo.List(ctx, tenantID)
|
||||
if err != nil {
|
||||
logger.Warnf(ctx, "[plugin] read tenant %d plugin switches: %v; treating all as enabled", tenantID, err)
|
||||
}
|
||||
for _, r := range rows {
|
||||
if !r.Enabled {
|
||||
disabled[r.PluginID] = true
|
||||
}
|
||||
set[r.PluginID] = r.Enabled
|
||||
}
|
||||
failOpen := err != nil
|
||||
return func(point manifest.Point, id string) bool {
|
||||
if len(disabled) == 0 {
|
||||
return true
|
||||
}
|
||||
e, ok := s.registry.Resolve(point, id)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if m, ok := s.registry.Plugin(e.PluginID); ok && m.Required {
|
||||
m, ok := s.registry.Plugin(e.PluginID)
|
||||
if !ok || m.Required || failOpen {
|
||||
return true
|
||||
}
|
||||
return !disabled[e.PluginID]
|
||||
if enabled, ok := set[e.PluginID]; ok {
|
||||
return enabled
|
||||
}
|
||||
return enabledByDefault(m)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,14 +26,38 @@ func (m *memRepo) List(_ context.Context, tenantID uint64) ([]types.PluginTenant
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *memRepo) Upsert(_ context.Context, s *types.PluginTenantSetting) error {
|
||||
func (m *memRepo) Get(_ context.Context, tenantID uint64, pluginID string) (*types.PluginTenantSetting, error) {
|
||||
if m.err != nil {
|
||||
return nil, m.err
|
||||
}
|
||||
if r, ok := m.rows[tenantID][pluginID]; ok {
|
||||
return &r, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *memRepo) Upsert(_ context.Context, s *types.PluginTenantSetting, columns ...string) error {
|
||||
if m.rows == nil {
|
||||
m.rows = map[uint64]map[string]types.PluginTenantSetting{}
|
||||
}
|
||||
if m.rows[s.TenantID] == nil {
|
||||
m.rows[s.TenantID] = map[string]types.PluginTenantSetting{}
|
||||
}
|
||||
m.rows[s.TenantID][s.PluginID] = *s
|
||||
row, exists := m.rows[s.TenantID][s.PluginID]
|
||||
if !exists || len(columns) == 0 {
|
||||
m.rows[s.TenantID][s.PluginID] = *s
|
||||
return nil
|
||||
}
|
||||
for _, c := range columns {
|
||||
switch c {
|
||||
case "enabled":
|
||||
row.Enabled = s.Enabled
|
||||
case "config":
|
||||
row.Config = s.Config
|
||||
}
|
||||
}
|
||||
row.UpdatedBy, row.UpdatedAt = s.UpdatedBy, s.UpdatedAt
|
||||
m.rows[s.TenantID][s.PluginID] = row
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -130,3 +154,90 @@ func TestEnabledFilterFailsOpen(t *testing.T) {
|
||||
t.Fatal("unreadable switches must not hide integrations")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstalledPluginsWaitForTenantOptIn(t *testing.T) {
|
||||
s, _ := newService(t)
|
||||
ctx := context.Background()
|
||||
kit := &manifest.Manifest{
|
||||
SchemaVersion: manifest.SchemaVersion, ID: "acme.kit", Version: "1.0.0", Name: manifest.Text("Kit", nil),
|
||||
Publisher: manifest.Publisher{ID: "acme"}, Runtime: manifest.Runtime{Type: manifest.RuntimeDeclarative},
|
||||
Contributes: manifest.Contributions{
|
||||
manifest.PointMCPServers: {{
|
||||
ID: "search", Name: manifest.Text("Search", nil),
|
||||
MCP: &manifest.MCPServer{URL: "https://mcp.acme.example/mcp"},
|
||||
}},
|
||||
},
|
||||
}
|
||||
if err := s.registry.Register(kit); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.ContributionEnabled(ctx, 1, manifest.PointMCPServers, "acme.kit/search") {
|
||||
t.Fatal("an installed plugin must start disabled in every tenant")
|
||||
}
|
||||
if !s.ContributionEnabled(ctx, 1, manifest.PointConnectors, "feishu") {
|
||||
t.Fatal("builtins start enabled")
|
||||
}
|
||||
list, _ := s.List(ctx, 1)
|
||||
for _, p := range list {
|
||||
if p.Manifest.ID == "acme.kit" && p.Enabled {
|
||||
t.Fatal("List must report the installed plugin as disabled")
|
||||
}
|
||||
}
|
||||
if err := s.SetEnabled(ctx, 1, "acme.kit", true, "u1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !s.ContributionEnabled(ctx, 1, manifest.PointMCPServers, "acme.kit/search") ||
|
||||
s.ContributionEnabled(ctx, 2, manifest.PointMCPServers, "acme.kit/search") {
|
||||
t.Fatal("opting in applies to that tenant only")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenantConfigRoundTrip(t *testing.T) {
|
||||
s, _ := newService(t)
|
||||
ctx := context.Background()
|
||||
kit := &manifest.Manifest{
|
||||
SchemaVersion: manifest.SchemaVersion, ID: "acme.kit", Version: "1.0.0", Name: manifest.Text("Kit", nil),
|
||||
Publisher: manifest.Publisher{ID: "acme"}, Runtime: manifest.Runtime{Type: manifest.RuntimeDeclarative},
|
||||
Config: manifest.ConfigSchemas{
|
||||
TenantSchema: []byte(`{"type":"object","required":["api_key"],` +
|
||||
`"properties":{"api_key":{"type":"string","x-secret":true},"region":{"type":"string"}}}`),
|
||||
},
|
||||
Contributes: manifest.Contributions{
|
||||
manifest.PointMCPServers: {{
|
||||
ID: "search", Name: manifest.Text("Search", nil),
|
||||
MCP: &manifest.MCPServer{URL: "https://mcp.acme.example/mcp"},
|
||||
}},
|
||||
},
|
||||
}
|
||||
if err := s.registry.Register(kit); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Config(ctx, 1, "weknora.feishu"); !errors.Is(err, ErrNoTenantConfig) {
|
||||
t.Fatalf("want ErrNoTenantConfig, got %v", err)
|
||||
}
|
||||
if _, err := s.SetConfig(ctx, 1, "acme.kit", map[string]any{"region": "eu"}, "u1"); err == nil {
|
||||
t.Fatal("a missing required secret must be rejected")
|
||||
}
|
||||
got, err := s.SetConfig(ctx, 1, "acme.kit", map[string]any{"api_key": "k-1", "region": "eu"}, "u1")
|
||||
if err != nil || got.Values["api_key"] != "***" || got.Values["region"] != "eu" {
|
||||
t.Fatalf("SetConfig = %+v, %v", got, err)
|
||||
}
|
||||
// Saving config must not flip the switch, and flipping the switch must
|
||||
// keep the config.
|
||||
if s.ContributionEnabled(ctx, 1, manifest.PointMCPServers, "acme.kit/search") {
|
||||
t.Fatal("configuring must not enable the plugin")
|
||||
}
|
||||
if err := s.SetEnabled(ctx, 1, "acme.kit", true, "u1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.SetConfig(ctx, 1, "acme.kit", map[string]any{"api_key": "***", "region": "us"}, "u1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
values, _, err := s.OpenConfig(ctx, 1, "acme.kit")
|
||||
if err != nil || values["api_key"] != "k-1" || values["region"] != "us" {
|
||||
t.Fatalf("OpenConfig = %v, %v", values, err)
|
||||
}
|
||||
if !s.ContributionEnabled(ctx, 1, manifest.PointMCPServers, "acme.kit/search") {
|
||||
t.Fatal("saving config must keep the plugin enabled")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ type RouterParams struct {
|
||||
DataSourceHandler *handler.DataSourceHandler
|
||||
DataSourceCredentialsHandler *handler.DataSourceCredentialsHandler
|
||||
PluginHandler *handler.PluginHandler
|
||||
PluginAdminHandler *handler.PluginAdminHandler
|
||||
WeKnoraCloudHandler *handler.WeKnoraCloudHandler
|
||||
WikiPageHandler *handler.WikiPageHandler
|
||||
MemoryHandler *handler.MemoryHandler
|
||||
@@ -325,6 +326,7 @@ func NewRouter(params RouterParams) *gin.Engine {
|
||||
RegisterMCPEndpointRoutes(v1, params.MCPEndpointHandler, rbacGuards)
|
||||
RegisterDataSourceRoutes(v1, params.DataSourceHandler, params.DataSourceCredentialsHandler, rbacGuards)
|
||||
RegisterPluginRoutes(v1, params.PluginHandler, rbacGuards)
|
||||
RegisterPluginAdminRoutes(v1, params.PluginAdminHandler, rbacGuards)
|
||||
RegisterWeKnoraCloudRoutes(v1, params.WeKnoraCloudHandler, rbacGuards)
|
||||
RegisterWikiPageRoutes(v1, params.WikiPageHandler, rbacGuards)
|
||||
RegisterMemoryRoutes(v1, params.MemoryHandler, rbacGuards)
|
||||
|
||||
@@ -19,5 +19,27 @@ func RegisterPluginRoutes(r *gin.RouterGroup, h *handler.PluginHandler, g *rbacG
|
||||
plugins.GET("/:id", g.Viewer(), h.GetPlugin)
|
||||
// Turning a plugin off hides its integrations workspace-wide — Admin+.
|
||||
plugins.PUT("/:id/enabled", g.Admin(), h.SetPluginEnabled)
|
||||
// Workspace configuration carries credentials — Admin+ to read too.
|
||||
plugins.GET("/:id/config", g.Admin(), h.GetPluginConfig)
|
||||
plugins.PUT("/:id/config", g.Admin(), h.UpdatePluginConfig)
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterPluginAdminRoutes registers plugin installation for system
|
||||
// administrators. Installing changes every tenant's catalog, so API keys
|
||||
// stay default-denied like the rest of /system/admin.
|
||||
func RegisterPluginAdminRoutes(r *gin.RouterGroup, h *handler.PluginAdminHandler, g *rbacGuards) {
|
||||
plugins := r.Group("/system/admin/plugins", g.SystemAdmin())
|
||||
{
|
||||
plugins.GET("", h.ListInstalledPlugins)
|
||||
plugins.POST("", h.InstallPlugin)
|
||||
// Registered before /:id so the static segment wins.
|
||||
plugins.POST("/inspect", h.InspectPlugin)
|
||||
plugins.GET("/:id", h.GetInstalledPlugin)
|
||||
plugins.DELETE("/:id", h.UninstallPlugin)
|
||||
plugins.PUT("/:id/enabled", h.SetInstalledPluginEnabled)
|
||||
plugins.PUT("/:id/active-version", h.ActivatePluginVersion)
|
||||
plugins.GET("/:id/config", h.GetPluginSystemConfig)
|
||||
plugins.PUT("/:id/config", h.UpdatePluginSystemConfig)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/config"
|
||||
"github.com/Tencent/WeKnora/internal/handler"
|
||||
)
|
||||
|
||||
func TestPluginInstallationRequiresSystemAdmin(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
g := &rbacGuards{cfg: &config.Config{}}
|
||||
r := gin.New()
|
||||
RegisterPluginAdminRoutes(r.Group("/api/v1"), &handler.PluginAdminHandler{}, g)
|
||||
for _, tc := range []struct{ method, path string }{
|
||||
{http.MethodGet, "/api/v1/system/admin/plugins"},
|
||||
{http.MethodPost, "/api/v1/system/admin/plugins"},
|
||||
{http.MethodPost, "/api/v1/system/admin/plugins/inspect"},
|
||||
{http.MethodGet, "/api/v1/system/admin/plugins/acme.kit"},
|
||||
{http.MethodDelete, "/api/v1/system/admin/plugins/acme.kit"},
|
||||
{http.MethodPut, "/api/v1/system/admin/plugins/acme.kit/enabled"},
|
||||
{http.MethodPut, "/api/v1/system/admin/plugins/acme.kit/active-version"},
|
||||
{http.MethodGet, "/api/v1/system/admin/plugins/acme.kit/config"},
|
||||
{http.MethodPut, "/api/v1/system/admin/plugins/acme.kit/config"},
|
||||
} {
|
||||
t.Run(tc.method+tc.path, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(tc.method, tc.path, nil))
|
||||
require.Equal(t, http.StatusForbidden, w.Code)
|
||||
if g.apiKeyAuthorizer != nil {
|
||||
_, declared := g.apiKeyAuthorizer.Lookup(tc.method, tc.path)
|
||||
require.False(t, declared, "API keys must remain default-denied")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,11 @@ import (
|
||||
// PluginTenantSettingRepository stores per-tenant plugin switches.
|
||||
type PluginTenantSettingRepository interface {
|
||||
List(ctx context.Context, tenantID uint64) ([]types.PluginTenantSetting, error)
|
||||
Upsert(ctx context.Context, setting *types.PluginTenantSetting) error
|
||||
// Get returns (nil, nil) when the tenant never changed the plugin.
|
||||
Get(ctx context.Context, tenantID uint64, pluginID string) (*types.PluginTenantSetting, error)
|
||||
// Upsert inserts the row, or updates only the given columns of an
|
||||
// existing one (every column when none are given).
|
||||
Upsert(ctx context.Context, setting *types.PluginTenantSetting, columns ...string) error
|
||||
}
|
||||
|
||||
// PluginGate tells integrations which contributions a tenant has enabled.
|
||||
@@ -23,3 +27,17 @@ type PluginGate interface {
|
||||
// does not know count as enabled, leaving existence to the domain.
|
||||
EnabledFilter(ctx context.Context, tenantID uint64) func(point manifest.Point, id string) bool
|
||||
}
|
||||
|
||||
// PluginRepository stores installed (non-builtin) plugins and their versions.
|
||||
type PluginRepository interface {
|
||||
ListPlugins(ctx context.Context) ([]types.InstalledPlugin, error)
|
||||
// GetPlugin returns (nil, nil) when the plugin is not installed.
|
||||
GetPlugin(ctx context.Context, id string) (*types.InstalledPlugin, error)
|
||||
SavePlugin(ctx context.Context, p *types.InstalledPlugin) error
|
||||
// DeletePlugin removes the plugin and every stored version.
|
||||
DeletePlugin(ctx context.Context, id string) error
|
||||
ListVersions(ctx context.Context, pluginID string) ([]types.PluginVersion, error)
|
||||
// GetVersion returns (nil, nil) when the version is not stored.
|
||||
GetVersion(ctx context.Context, pluginID, version string) (*types.PluginVersion, error)
|
||||
SaveVersion(ctx context.Context, v *types.PluginVersion) error
|
||||
}
|
||||
|
||||
@@ -39,9 +39,15 @@ type MCPService struct {
|
||||
StdioConfig *MCPStdioConfig `json:"stdio_config,omitempty" gorm:"type:json"` // Required for stdio transport
|
||||
EnvVars MCPEnvVars `json:"env_vars,omitempty" gorm:"type:json"` // Environment variables for stdio
|
||||
IsBuiltin bool `json:"is_builtin" gorm:"default:false"` // Whether this is a builtin MCP service (visible to all workspaces)
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
|
||||
// PluginID names the installed plugin providing the service; such
|
||||
// services are never stored and read as builtin (read-only).
|
||||
PluginID string `json:"plugin_id,omitempty" gorm:"-"`
|
||||
// PluginError says why a plugin service is disabled, typically that the
|
||||
// workspace has not configured the plugin yet.
|
||||
PluginError string `json:"plugin_error,omitempty" gorm:"-"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
|
||||
}
|
||||
|
||||
// EffectiveUsageInstructions preserves documentation on legacy services until
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package types
|
||||
|
||||
import "time"
|
||||
|
||||
// Desired states of an installed plugin.
|
||||
const (
|
||||
PluginStateEnabled = "enabled"
|
||||
PluginStateDisabled = "disabled"
|
||||
)
|
||||
|
||||
// InstalledPlugin is a non-builtin plugin installed on the platform. Every
|
||||
// node converges to these rows: an enabled plugin's active version is loaded,
|
||||
// anything else is unloaded.
|
||||
type InstalledPlugin struct {
|
||||
ID string `json:"id" gorm:"type:varchar(128);primaryKey"`
|
||||
// OwnerTenantID is nil for platform plugins a system admin installed.
|
||||
OwnerTenantID *uint64 `json:"owner_tenant_id,omitempty"`
|
||||
// Source records where the package came from: {"kind":"upload"} or
|
||||
// {"kind":"url","url":"..."}.
|
||||
Source JSON `json:"source" gorm:"type:json"`
|
||||
ActiveVersion string `json:"active_version" gorm:"type:varchar(64)"`
|
||||
DesiredState string `json:"desired_state" gorm:"type:varchar(16)"`
|
||||
Runtime string `json:"runtime" gorm:"type:varchar(32)"`
|
||||
// GrantedPerms is the manifest permissions an administrator accepted.
|
||||
GrantedPerms JSON `json:"granted_perms" gorm:"type:json"`
|
||||
SystemConfig JSON `json:"system_config,omitempty" gorm:"type:json"`
|
||||
CreatedBy string `json:"created_by" gorm:"type:varchar(36)"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName pins the table so GORM's pluralizer cannot drift.
|
||||
func (InstalledPlugin) TableName() string { return "plugins" }
|
||||
|
||||
// PluginVersion is one stored package version of an installed plugin.
|
||||
type PluginVersion struct {
|
||||
PluginID string `json:"plugin_id" gorm:"type:varchar(128);primaryKey"`
|
||||
Version string `json:"version" gorm:"type:varchar(64);primaryKey"`
|
||||
// Digest is "sha256:<hex>" of the package archive.
|
||||
Digest string `json:"digest" gorm:"type:varchar(80)"`
|
||||
Manifest JSON `json:"manifest" gorm:"type:json"`
|
||||
// PackageURI is where the archive is stored (FileService path).
|
||||
PackageURI string `json:"-" gorm:"type:varchar(1024)"`
|
||||
Size int64 `json:"size"`
|
||||
CreatedBy string `json:"created_by" gorm:"type:varchar(36)"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// TableName pins the table so GORM's pluralizer cannot drift.
|
||||
func (PluginVersion) TableName() string { return "plugin_versions" }
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS plugin_versions;
|
||||
DROP TABLE IF EXISTS plugins;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Installed plugins and their versions (versioned 000116).
|
||||
CREATE TABLE IF NOT EXISTS plugins (
|
||||
id VARCHAR(128) PRIMARY KEY,
|
||||
owner_tenant_id BIGINT,
|
||||
source TEXT NOT NULL DEFAULT '{}',
|
||||
active_version VARCHAR(64) NOT NULL,
|
||||
desired_state VARCHAR(16) NOT NULL DEFAULT 'enabled',
|
||||
runtime VARCHAR(32) NOT NULL,
|
||||
granted_perms TEXT NOT NULL DEFAULT '{}',
|
||||
system_config TEXT,
|
||||
created_by VARCHAR(36) NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plugin_versions (
|
||||
plugin_id VARCHAR(128) NOT NULL,
|
||||
version VARCHAR(64) NOT NULL,
|
||||
digest VARCHAR(80) NOT NULL,
|
||||
manifest TEXT NOT NULL,
|
||||
package_uri VARCHAR(1024) NOT NULL,
|
||||
size BIGINT NOT NULL DEFAULT 0,
|
||||
created_by VARCHAR(36) NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (plugin_id, version)
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS plugin_versions;
|
||||
DROP TABLE IF EXISTS plugins;
|
||||
@@ -0,0 +1,35 @@
|
||||
-- Migration 000116: installed plugins and their versions. Builtin plugins
|
||||
-- are compiled in and have no rows here.
|
||||
DO $$ BEGIN RAISE NOTICE '[Migration 000116] Creating plugins and plugin_versions'; END $$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plugins (
|
||||
id VARCHAR(128) PRIMARY KEY,
|
||||
-- NULL for platform plugins installed by a system admin.
|
||||
owner_tenant_id BIGINT,
|
||||
source JSONB NOT NULL DEFAULT '{}',
|
||||
active_version VARCHAR(64) NOT NULL,
|
||||
-- enabled | disabled: whether the plugin loads on any node.
|
||||
desired_state VARCHAR(16) NOT NULL DEFAULT 'enabled',
|
||||
runtime VARCHAR(32) NOT NULL,
|
||||
granted_perms JSONB NOT NULL DEFAULT '{}',
|
||||
system_config JSONB,
|
||||
created_by VARCHAR(36) NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plugin_versions (
|
||||
plugin_id VARCHAR(128) NOT NULL,
|
||||
version VARCHAR(64) NOT NULL,
|
||||
digest VARCHAR(80) NOT NULL,
|
||||
manifest JSONB NOT NULL,
|
||||
-- Where the package archive is stored (FileService path).
|
||||
package_uri VARCHAR(1024) NOT NULL,
|
||||
size BIGINT NOT NULL DEFAULT 0,
|
||||
created_by VARCHAR(36) NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (plugin_id, version)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE plugins IS 'Installed (non-builtin) plugins and their desired state; every node converges to it.';
|
||||
COMMENT ON TABLE plugin_versions IS 'Stored package versions of installed plugins, kept for rollback.';
|
||||
Reference in New Issue
Block a user