diff --git a/backend/internal/handler/admin/plugin_handler.go b/backend/internal/handler/admin/plugin_handler.go index cdbbec60a5..a0967c6efe 100644 --- a/backend/internal/handler/admin/plugin_handler.go +++ b/backend/internal/handler/admin/plugin_handler.go @@ -77,7 +77,7 @@ func (h *PluginHandler) Upload(c *gin.Context) { response.BadRequest(c, err.Error()) return } - c.JSON(http.StatusCreated, plugin) + response.Created(c, plugin) } type pluginEnableRequest struct { @@ -235,7 +235,7 @@ func (h *PluginHandler) ServeUIAsset(c *gin.Context) { // sandbox iframe 没有 allow-same-origin,会以不透明来源加载自己的 CSS/JS。 // 资源 URL 由短时随机能力 Token 保护,Bridge Token 只存在于 fragment 中。 c.Header("Cross-Origin-Resource-Policy", "cross-origin") - c.Header("Content-Security-Policy", "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'") + c.Header("Content-Security-Policy", "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'; navigate-to 'none'") c.Data(http.StatusOK, contentType, data) } diff --git a/backend/internal/service/plugin_manager.go b/backend/internal/service/plugin_manager.go index a9d8b5a9e9..53c0f94be3 100644 --- a/backend/internal/service/plugin_manager.go +++ b/backend/internal/service/plugin_manager.go @@ -28,6 +28,7 @@ const ( pluginConfigMaxBytes = 4 * 1024 * 1024 pluginUIAssetMaxBytes = 32 * 1024 * 1024 pluginReconcilePeriod = time.Second + pluginHealthTimeout = 5 * time.Second pluginUITokenPrefix = "sub2api:plugin-ui:v1:" ) @@ -267,6 +268,7 @@ func (m *PluginManager) reconcileOnce(ctx context.Context) error { m.publishUnavailableRoute(0, 100, "插件启用状态暂时无法读取") return fmt.Errorf("读取插件启用状态: %w", err) } + m.cleanupStaleLocalInstallations(installations) var enabled *PluginInstallation for _, installation := range installations { if !hasEnabledOpenAIBinding(installation.Bindings) { @@ -304,6 +306,13 @@ func (m *PluginManager) reconcileOnce(ctx context.Context) error { !current.runtime.client.Exited() && current.rolloutPercent == rollout && current.runtime.installation.BinarySHA256 == enabled.BinarySHA256 && current.runtime.installation.ConfigEncrypted == enabled.ConfigEncrypted { + healthCtx, cancel := context.WithTimeout(ctx, pluginHealthTimeout) + healthErr := current.runtime.checkHealth(healthCtx) + cancel() + if healthErr != nil { + m.markRuntimeUnavailable(current, healthErr.Error()) + return healthErr + } if enabled.State == PluginStateError || (enabled.State == PluginStateStarting && m.startingStateExpired(enabled)) { return m.repo.MarkRuntimeHealthy(ctx, enabled.ID, enabled.BinarySHA256, enabled.ConfigEncrypted) } @@ -455,6 +464,32 @@ func (m *PluginManager) ensureLocalInstallation(ctx context.Context, installatio return local, nil } +// cleanupStaleLocalInstallations 回收本实例缓存中已从数据库删除或已被新包替换的文件。 +// 数据库是跨实例的权威状态,本地目录不能因其他实例的卸载/升级永久残留。 +func (m *PluginManager) cleanupStaleLocalInstallations(installations []*PluginInstallation) { + persisted := make(map[int64]*PluginInstallation, len(installations)) + for _, installation := range installations { + if installation != nil { + persisted[installation.ID] = installation + } + } + m.mu.Lock() + stale := make([]*PluginInstallation, 0) + for id, local := range m.localInstallations { + current := persisted[id] + if current == nil || current.BinarySHA256 != local.BinarySHA256 || current.Version != local.Version { + stale = append(stale, local) + delete(m.localInstallations, id) + } + } + m.mu.Unlock() + for _, local := range stale { + if err := m.cleanupInstallationFiles(local); err != nil { + slog.Warn("plugin_stale_local_install_cleanup_failed", "plugin_id", local.ID, "error", err) + } + } +} + func mergeLocalInstallation(local, persisted *PluginInstallation) *PluginInstallation { merged := *persisted merged.ArtifactData = nil @@ -669,6 +704,12 @@ func (m *PluginManager) SaveConfig(ctx context.Context, id int64, raw json.RawMe if err := json.Unmarshal(raw, &normalized); err != nil { return nil, err } + if normalized == nil { + return nil, errors.New("插件配置 JSON 根节点必须是对象") + } + if _, ok := normalized.(map[string]any); !ok { + return nil, errors.New("插件配置 JSON 根节点必须是对象") + } canonical, err := json.Marshal(normalized) if err != nil { return nil, err diff --git a/backend/internal/service/plugin_runtime.go b/backend/internal/service/plugin_runtime.go index f088f7f039..c742569443 100644 --- a/backend/internal/service/plugin_runtime.go +++ b/backend/internal/service/plugin_runtime.go @@ -123,6 +123,12 @@ func (r *pluginRuntime) validateAndApplyNormalizedConfig(ctx context.Context, co if err := json.Unmarshal(configJSON, &normalized); err != nil { return nil, fmt.Errorf("解析插件规范化配置: %w", err) } + if normalized == nil { + return nil, errors.New("插件返回的规范化配置根节点必须是对象") + } + if _, ok := normalized.(map[string]any); !ok { + return nil, errors.New("插件返回的规范化配置根节点必须是对象") + } configJSON, err = json.Marshal(normalized) if err != nil { return nil, fmt.Errorf("序列化插件规范化配置: %w", err) @@ -137,6 +143,24 @@ func (r *pluginRuntime) validateAndApplyNormalizedConfig(ctx context.Context, co return configJSON, nil } +func (r *pluginRuntime) checkHealth(ctx context.Context) error { + if r == nil || r.api == nil || r.client == nil || r.client.Exited() { + return errors.New("插件进程已退出") + } + health, err := r.api.Health(ctx, &pluginv1.HealthRequest{}) + if err != nil { + return fmt.Errorf("插件健康检查失败: %w", err) + } + if health == nil || !health.Healthy { + message := "插件报告不健康" + if health != nil && strings.TrimSpace(health.Message) != "" { + message = "插件不健康: " + health.Message + } + return errors.New(message) + } + return nil +} + func (r *pluginRuntime) beginRequest() bool { if r == nil || r.draining.Load() { return false diff --git a/frontend/src/views/admin/PluginsView.vue b/frontend/src/views/admin/PluginsView.vue index fbfcfa4b76..73feebe266 100644 --- a/frontend/src/views/admin/PluginsView.vue +++ b/frontend/src/views/admin/PluginsView.vue @@ -322,7 +322,7 @@ :title=" t('admin.plugins.configTitle', { name: configPlugin?.name || '' }) " - @load="uiLoading = false" + @load="handlePluginFrameLoad" /> @@ -378,6 +378,7 @@ const pluginFrame = ref(null); const uiLoading = ref(false); const uiError = ref(""); const iframeHeight = ref(640); +const pendingBridgeRequests = new Map(); function errorMessage(error: unknown): string { if (typeof error === "object" && error !== null && "message" in error) { @@ -535,25 +536,52 @@ async function openConfiguration(plugin: PluginInstallation): Promise { } function closeConfiguration(): void { + clearPendingBridgeRequests(); configPlugin.value = null; uiSession.value = null; uiLoading.value = false; uiError.value = ""; } +function clearPendingBridgeRequests(): void { + for (const timeout of pendingBridgeRequests.values()) window.clearTimeout(timeout); + pendingBridgeRequests.clear(); +} + +function handlePluginFrameLoad(): void { + // A load can also be caused by a plugin navigating its iframe. Drop all + // outstanding responses so a late config response is never sent to the new document. + clearPendingBridgeRequests(); + uiLoading.value = false; +} + +function registerBridgeRequest(requestID: string): void { + const timeout = window.setTimeout(() => { + pendingBridgeRequests.delete(requestID); + }, 30_000); + pendingBridgeRequests.set(requestID, timeout); +} + function postBridgeResult( request: PluginBridgeMessage, payload: Record, ): void { if (!pluginFrame.value?.contentWindow || !uiSession.value) return; + const requestID = typeof request.request_id === "string" ? request.request_id.trim() : ""; + const timeout = pendingBridgeRequests.get(requestID); + if (!requestID || timeout === undefined) return; + window.clearTimeout(timeout); + pendingBridgeRequests.delete(requestID); pluginFrame.value.contentWindow.postMessage( { source: "sub2api-plugin-host", bridge_token: uiSession.value.bridge_token, type: `${request.type}.result`, - request_id: request.request_id, + request_id: requestID, ...payload, }, + // The sandboxed iframe has an opaque origin, so no fixed target origin exists. + // Pending request tracking plus load invalidation prevents cross-navigation leaks. "*", ); } @@ -562,7 +590,8 @@ async function handleBridgeMessage(event: MessageEvent): Promise { if ( !uiSession.value || !configPlugin.value || - event.source !== pluginFrame.value?.contentWindow + event.source !== pluginFrame.value?.contentWindow || + event.origin !== "null" ) return; const message = event.data as PluginBridgeMessage; @@ -573,6 +602,16 @@ async function handleBridgeMessage(event: MessageEvent): Promise { ) return; + const requestID = typeof message.request_id === "string" ? message.request_id.trim() : ""; + const expectsResponse = + message.type === "config.load" || + message.type === "config.save" || + message.type === "config.test"; + if (expectsResponse) { + if (!requestID || pendingBridgeRequests.has(requestID)) return; + registerBridgeRequest(requestID); + } + try { switch (message.type) { case "sub2api.plugin.ready": @@ -667,5 +706,6 @@ onMounted(() => { onBeforeUnmount(() => { window.removeEventListener("message", handleBridgeMessage); + clearPendingBridgeRequests(); });