From 03953265d2a609d3e6e2fdfb526d5613708e533f Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 5 Oct 2026 11:42:48 +0800 Subject: [PATCH 1/5] refactor(plugins): delete the retired first-party plugin panel, its data and its schema The read-only "retired first-party plugins" block of the admin Plugins page is deleted as a whole: the panel, the seven retired installation rows and the retirement receipt. Upstream's plugin management is back in its v0.2.13 form. Backend - the startup step that retired first-party installations (service and repository), the repository wrapper that hid the seven keys from the plugin manager, GET /admin/plugins/retired and their tests are deleted; - the wiring is upstream's again: repository.NewPluginRepository and admin.NewPluginHandler. ProvideSettingService loses the parameter that only ordered it after the retirement; wire_gen.go is regenerated; - plugin_handler.go is byte-identical to v0.2.13. Three response headers that served the first-party plugin UIs go with it: Cache-Control on the two configuration interfaces (4129f63e8, the versioned management view), Access-Control-Allow-Origin on plugin UI assets (387b0a71b, the prompt management UI) and worker-src blob: in their CSP (8a4d280b6, the account import UI). Migration 272 - deletes the seven first-party installations with their bindings and the receipt, and drops the schema only they used (sub2api_plugin_bootstrap, sub2api_plugin_updates, four columns, the state 'updating', the revision trigger and function, the narrowed unique index), so both plugin tables are as upstream's 229 and 230 define them; - raises, changing nothing, on a first-party installation the receipt does not record as retired, on another installation in the state 'updating' and on two enabled bindings of one scope; - has no down path: every earlier release since v0.2.8-codexrip.5 reads the dropped column runtime_generation at start and exits; - 264 is released and names the dropped objects, so its test puts 248, 249 and 250 back before each case. Frontend - RetiredPluginsPanel.vue, its API function and types, its i18n keys and its CSS rules are deleted; - api/admin/plugins.ts, both admin/plugins.ts locales and PluginsView.spec.ts are byte-identical to v0.2.13; PluginsView.vue keeps only the console theme hook data-ui="plugins"; - the rawPluginConfig bypass of the response interceptor (4129f63e8) and the "plugin unavailable" line of the re-authorization dialog (cd8c18b54), which no longer had a reason, are removed. CI and docs - test_native_domains.sh no longer selects the deleted tests or ./internal/repository, where it would select nothing; downstream-verify.yml drops the retirement selector; - .downstream/native-domains.md states what 272 removes, what it leaves and the rollback boundary. Not in the database and left for the operator: the Redis keys plugin:kv:v1:codexrip.* and the plugin files under /plugins. The deploy workflow, the release workflow and deploy/ are unchanged. Co-Authored-By: Claude Opus 5.5 --- .downstream/native-domains.md | 100 +++--- .github/scripts/test_native_domains.sh | 6 +- .github/workflows/downstream-verify.yml | 6 +- DOWNSTREAM.md | 4 +- backend/cmd/server/wire_gen.go | 19 +- .../internal/handler/admin/plugin_handler.go | 25 +- backend/internal/handler/wire.go | 8 +- ...plugin_purge_migration_integration_test.go | 79 ++--- ...plugin_purge_migration_integration_test.go | 304 +++++++++++++++++ .../repository/native_feature_bootstrap.go | 162 --------- ...tive_feature_bootstrap_integration_test.go | 90 ----- .../repository/native_plugin_boundary.go | 156 --------- .../repository/native_plugin_boundary_test.go | 93 ------ backend/internal/repository/wire.go | 3 +- backend/internal/server/routes/admin.go | 1 - .../service/native_feature_bootstrap.go | 310 ------------------ .../service/native_feature_bootstrap_test.go | 124 ------- backend/internal/service/wire.go | 3 +- .../272_purge_first_party_plugins.sql | 114 +++++++ .../src/api/__tests__/pluginRawConfig.spec.ts | 14 - frontend/src/api/admin/plugins.ts | 51 +-- frontend/src/api/client.ts | 3 - .../components/admin/RetiredPluginsPanel.vue | 123 ------- .../admin/account/ReAuthAccountModal.vue | 1 - frontend/src/i18n/locales/en/admin/plugins.ts | 37 +-- frontend/src/i18n/locales/zh/admin/plugins.ts | 37 +-- .../console/pages/settings-ops-public.css | 72 +--- frontend/src/views/admin/PluginsView.vue | 3 - .../views/admin/__tests__/PluginsView.spec.ts | 1 - 29 files changed, 542 insertions(+), 1407 deletions(-) create mode 100644 backend/internal/repository/first_party_plugin_purge_migration_integration_test.go delete mode 100644 backend/internal/repository/native_feature_bootstrap.go delete mode 100644 backend/internal/repository/native_feature_bootstrap_integration_test.go delete mode 100644 backend/internal/repository/native_plugin_boundary.go delete mode 100644 backend/internal/repository/native_plugin_boundary_test.go delete mode 100644 backend/internal/service/native_feature_bootstrap.go delete mode 100644 backend/internal/service/native_feature_bootstrap_test.go create mode 100644 backend/migrations/272_purge_first_party_plugins.sql delete mode 100644 frontend/src/api/__tests__/pluginRawConfig.spec.ts delete mode 100644 frontend/src/components/admin/RetiredPluginsPanel.vue diff --git a/.downstream/native-domains.md b/.downstream/native-domains.md index c4e3c903382d..ba7cbde4bf00 100644 --- a/.downstream/native-domains.md +++ b/.downstream/native-domains.md @@ -26,7 +26,7 @@ Ordinary account bulk editing remains HTTP 202; this change does not replace background tasks with synchronous editing. Existing API-key reveal and account-field protections remain. -## Startup and retained data +## Startup and stored data Migrations run first. Migration 264 stops startup, changing nothing, on a database whose `codexrip.codex-runtime` installation was never retired, whose @@ -35,28 +35,23 @@ saved Codex runtime configuration has no hash recorded by v0.2.11-codexrip.6, key. Starting one of those three releases once satisfies the first two conditions. -Then, before native settings load and before the plugin manager starts, one -transaction retires the other first-party plugin installations a database still -holds, except `codexrip.image-tools`, which it does not read or change: - -1. Validates the saved first-party capability scopes and decrypts configuration. -2. Imports the effective observability switches only when the native settings - key is absent. An existing key is never overwritten. The retired Cindy - provider installation is disabled without importing its switches. -3. Saves original installation/binding/bootstrap intent under - `deplugin_retired_plugins`, disables the installations and their bindings, - and marks their bootstrap records removed. - -Missing settings may use deployment defaults. Database errors, malformed saved -settings and unknown/non-equivalent capability scopes stop startup; they never -silently enable features. An installation that is disabled as a whole, in a -domain without an equivalent native switch, requires an explicit migration -decision rather than automatic activation. - -The retired installation rows remain and are listed read-only, with the -receipt, at `GET /admin/plugins/retired`; the `codexrip.codex-runtime` row holds -no saved configuration, package, manifest or bindings. The upstream plugin -manager cannot list, modify, delete or replace the retired first-party keys. +Migration 272 stops startup, changing nothing, in three cases: + +1. One of the seven first-party plugin installations exists and the receipt of + the retirement, a `settings` row, is missing, is not completed or has no + entry for it: that installation was never retired. Starting + v0.2.13-codexrip.7 once retires it. +2. Another installation is in the state `updating`. +3. Two enabled bindings share a capability, platform and account type. + +Upstream's schema admits neither 2 nor 3 and the plugin manager writes neither; +disabling or uninstalling the plugin in v0.2.13-codexrip.7 clears both. + +The host does not retire plugin installations at startup and keeps no list of +retired plugins. The plugin tables, the plugin manager and its admin interfaces +are upstream's: the manager lists and manages every installation row. Missing +settings may use deployment defaults. Database errors and malformed saved +settings stop startup; they never silently enable features. ## Release and rollback boundary @@ -68,43 +63,48 @@ backups, canaries, model calls or automatic rollback. deployer recognizes the old and native prompt/skill directory locations by the same content hashes; missing or ambiguous trees and changed schema remain errors. -Migration 264 has no down path. An image change back to v0.2.11-codexrip.6, .7 -or .8 after it starts only on a database that keeps a retired -`codexrip.codex-runtime` installation; without that row those images exit at -start. They do not read the stored compression switch, so request compression -follows `gateway.openai_codex_request_zstd`, and without `sub2api_plugin_state` -their outbound fingerprint view and quality-run endpoints fail while forwarding -continues. Images at or before v0.2.11-codexrip.5 also need -`sub2api_plugin_state` and `sub2api_plugin_leases` for their route code and -require a database dump taken before migration 264. +Migration 264 has no down path. Images at or before v0.2.11-codexrip.5 need +`sub2api_plugin_state` and `sub2api_plugin_leases`, which it drops, for their +route code. Migration 270 has no down path either. It drops the three Image Studio tables with the job history and the records of the stored image files and deletes the `image_tools_config` setting; the files under `/image-studio` are not -removed. An earlier image starts on such a database only with Image Studio off, -which the missing setting leaves to `GATEWAY_IMAGE_STUDIO_ENABLED`: with that -variable `true` it exits at start, and its Image Studio job routes fail on the -dropped tables. +removed. Migration 271 has no down path. It drops `usage_billing_dedup.account_id`, which -the billing claim no longer writes and nothing reads. An earlier image names -that column in every billing claim, so its usage billing fails on such a -database until the column is added again -(`ALTER TABLE usage_billing_dedup ADD COLUMN account_id BIGINT`). Migration 271 -is already recorded by then and does not run a second time: after returning to -this release or a later one, drop the column by hand -(`ALTER TABLE usage_billing_dedup DROP COLUMN IF EXISTS account_id`). Billing -works with the column present until that is done. - -Returning to a plugin-based host is not an image change. That host needs schema -objects and plugin data the current database no longer holds, so it requires a -database dump that still has them. Such a legacy-host rollback has not been run -and is not an automatic failure path. +the billing claim no longer writes and nothing reads. + +Migration 272 has no down path. It deletes the seven first-party plugin +installations (`codexrip.account-tools`, `codexrip.admin-observability`, +`codexrip.cindy-provider`, `codexrip.codex-runtime`, `codexrip.image-tools`, +`codexrip.model-policy` and `codexrip.prompt-skills`) with their saved +configuration, package bytes, manifests and capability bindings, and the +`settings` row that held the receipt of their retirement. It drops the schema +only those plugins used, so that `sub2api_plugin_installations` and +`sub2api_plugin_bindings` are again as upstream's migrations 229 and 230 define +them: the tables `sub2api_plugin_bootstrap` and `sub2api_plugin_updates`, the +columns `revision`, `runtime_generation`, `package_sha256` and `update_policy`, +the state `updating`, the trigger and function `sub2api_plugin_revision`, and +the limit of the unique index on enabled bindings to two capabilities. Other +installations, their bindings and the `admin_observability_config` setting are +not written. The plugin files under `/plugins` and the Redis keys +`plugin:kv:v1:*` are not in the database and are not removed. + +After migration 272 no earlier image starts on the database. Every earlier +release since v0.2.8-codexrip.5 retires first-party plugin installations at +start: finding no receipt, it selects +`sub2api_plugin_installations.runtime_generation`, the query fails on the +dropped column and the process exits with `initialize native features`. A +plugin-based host needs the dropped schema and the deleted plugin data as well. +Going back to an earlier release is therefore not an image change: it requires a +database dump taken before the migrations that release does not have. Such a +rollback has not been run and is not an automatic failure path. ## Verification Normal required PR checks validate the native domain packages, account scope, -configuration/storage boundaries and frontend behavior. The release reuses that +native configuration and frontend behavior. The release reuses that source validation. Health endpoints establish availability only. ## Console theme diff --git a/.github/scripts/test_native_domains.sh b/.github/scripts/test_native_domains.sh index d95a428931d5..992c421d5cbb 100644 --- a/.github/scripts/test_native_domains.sh +++ b/.github/scripts/test_native_domains.sh @@ -9,7 +9,7 @@ go -C backend test -p=1 -tags unit \ ./internal/codexruntime/... \ -count=1 -# Preserve configuration/storage boundaries without starting real IO. -go -C backend test -p=1 -tags unit ./internal/service ./internal/repository \ - -run '^(TestCodexRuntimeSettingIsOnePlainRowOverTheDeployDefault|TestPrepareOpenAICodexWireRequest.*|TestNativeCodexErrorDiagnosticsProjectEveryStoredAttempt|TestNativePluginBoundaryProtectsRetiredRecordsAndAllowsThirdParties|TestNativeFeatureBootstrapPreservesEffectiveConfiguration|TestNativeSettingLoadFailureNeverReenablesSavedSwitches|TestTrafficObservation.*|TestAccountTrafficOutcomeRulesKeepCancellationAboveErrorsAndRequireWSTerminals|TestDecodeSwitchSettingsRejectsUnknownAndNonBooleanValues)$' \ +# Preserve the native configuration contracts without starting real IO. +go -C backend test -p=1 -tags unit ./internal/service \ + -run '^(TestCodexRuntimeSettingIsOnePlainRowOverTheDeployDefault|TestPrepareOpenAICodexWireRequest.*|TestNativeCodexErrorDiagnosticsProjectEveryStoredAttempt|TestNativeSettingLoadFailureNeverReenablesSavedSwitches|TestTrafficObservation.*|TestAccountTrafficOutcomeRulesKeepCancellationAboveErrorsAndRequireWSTerminals|TestDecodeSwitchSettingsRejectsUnknownAndNonBooleanValues)$' \ -count=1 diff --git a/.github/workflows/downstream-verify.yml b/.github/workflows/downstream-verify.yml index 3ceab3f9c19f..3687cdfce1d3 100644 --- a/.github/workflows/downstream-verify.yml +++ b/.github/workflows/downstream-verify.yml @@ -34,7 +34,7 @@ jobs: python3 .github/scripts/test_release_preflight.py - name: Agent protocol contracts run: bash .github/scripts/test_agent_protocol.sh - - name: Native domain and retirement contracts + - name: Native domain contracts run: bash .github/scripts/test_native_domains.sh - name: Reasoning recovery contracts run: bash .github/scripts/test_reasoning_recovery.sh @@ -74,9 +74,9 @@ jobs: - name: Account taxonomy migration integration test working-directory: backend run: go test -p=1 -tags integration ./internal/repository -run '^Test(AccountTaxonomy.*|DeleteAccountFolder.*)Integration$|^TestAPIKeyRepoSuite$/^TestReleaseAcceptanceKeyIsAdminListedReadOnlyAndAuthenticates$' -count=1 - - name: Repository retirement and audit integration tests + - name: Repository audit integration tests working-directory: backend - run: go test -p=1 -tags integration ./internal/repository -run '^Test(PGXBatchInsertAuditAndSystemLogs|Migration236.*|NativeFeatureRetirementIsAtomic)$' -count=1 + run: go test -p=1 -tags integration ./internal/repository -run '^Test(PGXBatchInsertAuditAndSystemLogs|Migration236.*)$' -count=1 - name: Historical Cindy migration tests working-directory: backend run: | diff --git a/DOWNSTREAM.md b/DOWNSTREAM.md index 753bf8391770..704e1c37d955 100644 --- a/DOWNSTREAM.md +++ b/DOWNSTREAM.md @@ -12,8 +12,8 @@ This fork maintains the `codexrip` patch set over official Sub2API releases. The five first-party domains run inside the host and use native Vue pages. The official third-party plugin framework remains available. Domain settings, -startup data preservation and the legacy-host rollback boundary are described in -[native domains](.downstream/native-domains.md). +the stored data the migrations removed and the rollback boundary are described +in [native domains](.downstream/native-domains.md). Account bulk operations remain HTTP 202 jobs with progress, cancellation and failed-item retry. Both edit entries use the frozen selected IDs whenever there diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 1209a11f84b2..bf5278a921ad 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -53,16 +53,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { settingRepository := repository.NewSettingRepository(client) groupRepository := repository.NewGroupRepository(client, db) proxyRepository := repository.NewProxyRepository(client, db) - nativeFeatureBootstrapRepository := repository.NewNativeFeatureBootstrapRepository(db) - secretEncryptor, err := repository.NewAESEncryptor(configConfig) - if err != nil { - return nil, err - } - nativeFeatureBootstrap, err := service.ProvideNativeFeatureBootstrap(nativeFeatureBootstrapRepository, secretEncryptor) - if err != nil { - return nil, err - } - settingService, err := service.ProvideSettingService(settingRepository, groupRepository, proxyRepository, configConfig, nativeFeatureBootstrap) + settingService, err := service.ProvideSettingService(settingRepository, groupRepository, proxyRepository, configConfig) if err != nil { return nil, err } @@ -99,6 +90,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { userService := service.NewUserService(userRepository, settingRepository, apiKeyAuthCacheInvalidator, billingCache) redeemCache := repository.NewRedeemCache(redisClient) redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client, apiKeyAuthCacheInvalidator, affiliateService) + secretEncryptor, err := repository.NewAESEncryptor(configConfig) + if err != nil { + return nil, err + } totpCache := repository.NewTotpCache(redisClient) totpService := service.NewTotpService(userRepository, secretEncryptor, totpCache, settingService, emailService, emailQueueService) userAttributeDefinitionRepository := repository.NewUserAttributeDefinitionRepository(client) @@ -221,7 +216,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { usageCache := service.NewUsageCache() usageCommitObserver := provideUsageCommitObserver() accountUsageService := service.ProvideAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, grokQuotaFetcher, grokQuotaService, openAIQuotaService, usageCache, identityCache, tlsFingerprintProfileService, gatewayService, openAIGatewayService, usageCommitObserver) - pluginRepository := repository.ProvidePluginRepository(db, nativeFeatureBootstrap) + pluginRepository := repository.NewPluginRepository(db) pluginHostInfo := providePluginHostInfo(buildInfo) pluginKVStore := repository.NewPluginKVStore(redisClient) pluginManager := service.ProvidePluginManager(pluginRepository, secretEncryptor, configConfig, pluginHostInfo, pluginKVStore, openAIGatewayService) @@ -279,7 +274,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { errorPassthroughService := service.NewErrorPassthroughService(errorPassthroughRepository, errorPassthroughCache) errorPassthroughHandler := admin.NewErrorPassthroughHandler(errorPassthroughService) tlsFingerprintProfileHandler := admin.NewTLSFingerprintProfileHandler(tlsFingerprintProfileService) - pluginHandler := handler.ProvidePluginHandler(pluginManager, nativeFeatureBootstrap) + pluginHandler := admin.NewPluginHandler(pluginManager) adminAPIKeyHandler := admin.NewAdminAPIKeyHandler(adminService) scheduledTestPlanRepository := repository.NewScheduledTestPlanRepository(db) scheduledTestResultRepository := repository.NewScheduledTestResultRepository(db) diff --git a/backend/internal/handler/admin/plugin_handler.go b/backend/internal/handler/admin/plugin_handler.go index b3f928c177f5..efb8e4b75c2d 100644 --- a/backend/internal/handler/admin/plugin_handler.go +++ b/backend/internal/handler/admin/plugin_handler.go @@ -26,32 +26,12 @@ const pluginUISessionTTL = 30 * time.Minute // PluginHandler 提供插件安装、生命周期、配置和隔离 UI 资源接口。 type PluginHandler struct { manager *service.PluginManager - retired *service.NativeFeatureBootstrap } func NewPluginHandler(manager *service.PluginManager) *PluginHandler { return &PluginHandler{manager: manager} } -// SetRetiredPlugins attaches the read-only view of retired first-party -// installations, which the managed plugin list never contains. -func (h *PluginHandler) SetRetiredPlugins(retired *service.NativeFeatureBootstrap) { - h.retired = retired -} - -// ListRetired returns the retired first-party installations, their retirement -// receipt and decrypted saved configuration. It is read-only. -// GET /api/v1/admin/plugins/retired -func (h *PluginHandler) ListRetired(c *gin.Context) { - c.Header("Cache-Control", "private, no-store") - view, err := h.retired.RetiredPlugins(c.Request.Context()) - if err != nil { - response.ErrorFrom(c, err) - return - } - response.Success(c, view) -} - func (h *PluginHandler) List(c *gin.Context) { plugins, err := h.manager.List(c.Request.Context()) if err != nil { @@ -149,7 +129,6 @@ func (h *PluginHandler) Delete(c *gin.Context) { } func (h *PluginHandler) GetConfig(c *gin.Context) { - c.Header("Cache-Control", "private, no-store") id, ok := pluginIDParam(c) if !ok { return @@ -163,7 +142,6 @@ func (h *PluginHandler) GetConfig(c *gin.Context) { } func (h *PluginHandler) SaveConfig(c *gin.Context) { - c.Header("Cache-Control", "private, no-store") id, ok := pluginIDParam(c) if !ok { return @@ -273,8 +251,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("Access-Control-Allow-Origin", "*") - c.Header("Content-Security-Policy", "default-src 'none'; script-src 'self' 'unsafe-inline'; worker-src blob:; 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.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/handler/wire.go b/backend/internal/handler/wire.go index 45fa2b65c779..cf0cfc4a1e48 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -290,7 +290,7 @@ var ProviderSet = wire.NewSet( admin.NewUserAttributeHandler, admin.NewErrorPassthroughHandler, admin.NewTLSFingerprintProfileHandler, - ProvidePluginHandler, + admin.NewPluginHandler, admin.NewAdminAPIKeyHandler, admin.NewScheduledTestHandler, admin.NewChannelHandler, @@ -314,9 +314,3 @@ func ProvideAccountJobRuntime(jobs *service.AccountJobService, accountHandler *a accountHandler.SetAccountJobService(jobs) return service.NewAccountJobRuntime(jobs, accountHandler) } - -func ProvidePluginHandler(manager *service.PluginManager, retired *service.NativeFeatureBootstrap) *admin.PluginHandler { - handler := admin.NewPluginHandler(manager) - handler.SetRetiredPlugins(retired) - return handler -} diff --git a/backend/internal/repository/codex_runtime_plugin_purge_migration_integration_test.go b/backend/internal/repository/codex_runtime_plugin_purge_migration_integration_test.go index 0579f15ef7ca..ef485e3fad77 100644 --- a/backend/internal/repository/codex_runtime_plugin_purge_migration_integration_test.go +++ b/backend/internal/repository/codex_runtime_plugin_purge_migration_integration_test.go @@ -9,6 +9,7 @@ import ( "errors" "strings" "testing" + "time" "github.com/Wei-Shaw/sub2api/internal/service" dbmigrations "github.com/Wei-Shaw/sub2api/migrations" @@ -22,25 +23,36 @@ const ( ) // TestMain applied every migration, 264 included, to an empty database: the -// fresh-database run. Each case restores the tables 264 drops and the rows a -// database of the previous release can hold, applies 264 and checks what went -// and what stayed. +// fresh-database run. 272 has since dropped the bundle schema of 248, 249 and +// 250, which 264 names in every run, so each case runs those three again +// first. The stored cases also restore the tables 264 drops and the rows a +// database of the previous release can hold, apply 264 and check what went and +// what stayed. func TestMigration264PurgesCodexRuntimePluginData(t *testing.T) { ctx := context.Background() - content, err := dbmigrations.FS.ReadFile(codexRuntimePurgeMigration) - require.NoError(t, err) - migration := string(content) + read := func(name string) string { + t.Helper() + content, err := dbmigrations.FS.ReadFile(name) + require.NoError(t, err) + return string(content) + } + migration := read(codexRuntimePurgeMigration) + bundleSchema := read("248_plugin_bundle_bootstrap.sql") + read("249_plugin_bundle_binding_intent.sql") + read("250_plugin_independent_updates.sql") type fixture struct { tx *sql.Tx t *testing.T } - open := func(t *testing.T) fixture { return fixture{tx: testTx(t), t: t} } exec := func(f fixture, query string, args ...any) { f.t.Helper() _, err := f.tx.ExecContext(ctx, query, args...) require.NoError(f.t, err) } + open := func(t *testing.T) fixture { + f := fixture{tx: testTx(t), t: t} + exec(f, bundleSchema) + return f + } text := func(f fixture, query string, args ...any) string { f.t.Helper() var value string @@ -115,7 +127,7 @@ RETURNING id`, for key, entry := range plugins { entries = append(entries, `"`+key+`":`+entry) } - put(f, service.NativeFeatureRetirementSetting, `{"version":1,"completed":true,"retired_at":"2026-09-24T11:20:00.123456789Z","plugins":{`+strings.Join(entries, ",")+`}}`) + put(f, "deplugin_retired_plugins", `{"version":1,"completed":true,"retired_at":"2026-09-24T11:20:00.123456789Z","plugins":{`+strings.Join(entries, ",")+`}}`) } record := func(hash string) string { return `{"version":1,"config_version":2,"config_sha256":"` + hash + `"}` @@ -198,7 +210,7 @@ INSERT INTO accounts (name, platform, type, extra, deleted_at) VALUES ($1, $2, $ untouchedAccounts[id] = ctid("accounts", "id", id) } untouchedSettings := map[string]string{} - for _, key := range []string{service.NativeFeatureRetirementSetting, "image_tools_config", "openai_codex_client_version"} { + for _, key := range []string{"deplugin_retired_plugins", "image_tools_config", "openai_codex_client_version"} { untouchedSettings[key] = ctid("settings", "key", key) } imageBefore, otherBefore := installationRow(f, imageID), installationRow(f, otherID) @@ -247,34 +259,6 @@ INSERT INTO accounts (name, platform, type, extra, deleted_at) VALUES ($1, $2, $ } require.Equal(t, outboxBefore, count(f, `SELECT count(*) FROM scheduler_outbox WHERE account_id = ANY($1)`, accountIDs)) - // The retired-plugins view still loads the receipt and both retired rows. - snapshot := &service.NativeRetirementSnapshot{} - stored, _ := setting(f, service.NativeFeatureRetirementSetting) - require.NoError(t, json.Unmarshal([]byte(stored), snapshot)) - require.True(t, snapshot.Completed) - require.Len(t, snapshot.Plugins, 2) - require.Len(t, snapshot.Plugins[codex].Bindings, 1) - rows, err := f.tx.QueryContext(ctx, pluginSelectSQL+` WHERE plugin_key LIKE 'codexrip.%' ORDER BY id`) - require.NoError(t, err) - var retired migration264RetiredRows - for rows.Next() { - plugin, scanErr := scanPlugin(rows) - require.NoError(t, scanErr) - retired = append(retired, plugin) - } - require.NoError(t, rows.Err()) - require.NoError(t, rows.Close()) - bootstrap := &service.NativeFeatureBootstrap{Snapshot: snapshot} - bootstrap.SetRetiredPluginSource(retired) - view, err := bootstrap.RetiredPlugins(ctx) - require.NoError(t, err) - require.Len(t, view.Installations, 2) - require.Equal(t, codex, view.Installations[0].PluginKey) - require.Equal(t, "Codex 运行扩展", view.Installations[0].Name) - require.Empty(t, view.Installations[0].ConfigError) - require.Nil(t, view.Installations[0].Config) - require.Equal(t, "configuration decryptor is unavailable", view.Installations[1].ConfigError, "the sibling still holds its saved configuration") - // A second run changes nothing. before := map[string]string{ "installation": ctid("sub2api_plugin_installations", "id", codexID), @@ -282,7 +266,7 @@ INSERT INTO accounts (name, platform, type, extra, deleted_at) VALUES ($1, $2, $ "members": ctid("accounts", "id", membersID), "no mode": ctid("accounts", "id", noModeID), "config": ctid("settings", "key", "codex_runtime_config"), - "receipt": ctid("settings", "key", service.NativeFeatureRetirementSetting), + "receipt": ctid("settings", "key", "deplugin_retired_plugins"), } apply(f) require.Equal(t, before, map[string]string{ @@ -291,7 +275,7 @@ INSERT INTO accounts (name, platform, type, extra, deleted_at) VALUES ($1, $2, $ "members": ctid("accounts", "id", membersID), "no mode": ctid("accounts", "id", noModeID), "config": ctid("settings", "key", "codex_runtime_config"), - "receipt": ctid("settings", "key", service.NativeFeatureRetirementSetting), + "receipt": ctid("settings", "key", "deplugin_retired_plugins"), }) require.Zero(t, droppedTables(f)) }) @@ -376,9 +360,14 @@ VALUES ($1,'Native Codex state anchor','0.0.0','Persistent native runtime genera require.Zero(t, droppedTables(f)) require.Equal(t, 1, count(f, `SELECT count(*) FROM sub2api_plugin_installations WHERE id = $1 AND config_encrypted = 'fixture-image-config-cipher' AND artifact_data IS NOT NULL`, imageID)) - stored, _ := setting(f, service.NativeFeatureRetirementSetting) - snapshot := &service.NativeRetirementSnapshot{} - require.NoError(t, json.Unmarshal([]byte(stored), snapshot)) + stored, _ := setting(f, "deplugin_retired_plugins") + var snapshot struct { + Version int `json:"version"` + Completed bool `json:"completed"` + RetiredAt time.Time `json:"retired_at"` + Plugins map[string]json.RawMessage `json:"plugins"` + } + require.NoError(t, json.Unmarshal([]byte(stored), &snapshot)) require.True(t, snapshot.Completed) require.Equal(t, 1, snapshot.Version) require.False(t, snapshot.RetiredAt.IsZero()) @@ -402,9 +391,3 @@ VALUES ($1,'Native Codex state anchor','0.0.0','Persistent native runtime genera }) } } - -type migration264RetiredRows []*service.PluginInstallation - -func (rows migration264RetiredRows) RetiredPluginInstallations(context.Context) ([]*service.PluginInstallation, error) { - return rows, nil -} diff --git a/backend/internal/repository/first_party_plugin_purge_migration_integration_test.go b/backend/internal/repository/first_party_plugin_purge_migration_integration_test.go new file mode 100644 index 000000000000..58a385d6896e --- /dev/null +++ b/backend/internal/repository/first_party_plugin_purge_migration_integration_test.go @@ -0,0 +1,304 @@ +//go:build integration + +package repository + +import ( + "context" + "database/sql" + "strings" + "testing" + + dbmigrations "github.com/Wei-Shaw/sub2api/migrations" + "github.com/stretchr/testify/require" +) + +const firstPartyPluginPurgeMigration = "272_purge_first_party_plugins.sql" + +// TestMain applied every migration, 272 included, to an empty database: the +// fresh-database run. The other cases put back the schema 272 removes by +// running 248, 249, 250 and the index statements of 246 again, store what a +// database of the previous release can hold next to a third-party +// installation, apply 272 and check what went and what stayed. +func TestMigration272PurgesFirstPartyPlugins(t *testing.T) { + ctx := context.Background() + read := func(name string) string { + t.Helper() + content, err := dbmigrations.FS.ReadFile(name) + require.NoError(t, err) + return string(content) + } + migration := read(firstPartyPluginPurgeMigration) + upstreamSchema := read("229_plugins.sql") + read("230_plugin_artifacts.sql") + // 246 narrows the index before it creates the tables 264 dropped. + narrowedIndex, _, found := strings.Cut(read("246_plugin_extension_state.sql"), "CREATE TABLE") + require.True(t, found) + bundleSchema := read("248_plugin_bundle_bootstrap.sql") + read("249_plugin_bundle_binding_intent.sql") + read("250_plugin_independent_updates.sql") + narrowedIndex + + type fixture struct { + tx *sql.Tx + t *testing.T + } + exec := func(f fixture, query string, args ...any) { + f.t.Helper() + _, err := f.tx.ExecContext(ctx, query, args...) + require.NoError(f.t, err) + } + text := func(f fixture, query string, args ...any) string { + f.t.Helper() + var value string + require.NoError(f.t, f.tx.QueryRowContext(ctx, query, args...).Scan(&value)) + return value + } + count := func(f fixture, query string, args ...any) int { + f.t.Helper() + var value int + require.NoError(f.t, f.tx.QueryRowContext(ctx, query, args...).Scan(&value)) + return value + } + put := func(f fixture, key, value string) { + f.t.Helper() + exec(f, `INSERT INTO settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, key, value) + } + apply := func(f fixture) { f.t.Helper(); exec(f, migration) } + + // Every column, constraint, index and trigger of the two plugin tables in + // one schema, without the schema's name. + tables := func(f fixture, schema string) string { + f.t.Helper() + listing := text(f, ` +SELECT string_agg(line, E'\n' ORDER BY line) FROM ( + SELECT format('column %s #%s %s %s not null=%s default=%s', c.relname, row_number() OVER (PARTITION BY c.oid ORDER BY a.attnum), + a.attname, format_type(a.atttypid, a.atttypmod), a.attnotnull, COALESCE(pg_get_expr(d.adbin, d.adrelid), '')) AS line + FROM pg_class AS c + JOIN pg_attribute AS a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped + LEFT JOIN pg_attrdef AS d ON d.adrelid = c.oid AND d.adnum = a.attnum + WHERE c.relnamespace = $1::regnamespace AND c.relname = ANY($2) + UNION ALL + SELECT format('constraint %s %s %s', c.relname, k.conname, pg_get_constraintdef(k.oid)) + FROM pg_class AS c JOIN pg_constraint AS k ON k.conrelid = c.oid + WHERE c.relnamespace = $1::regnamespace AND c.relname = ANY($2) + UNION ALL + SELECT format('index %s', pg_get_indexdef(i.indexrelid)) + FROM pg_class AS c JOIN pg_index AS i ON i.indrelid = c.oid + WHERE c.relnamespace = $1::regnamespace AND c.relname = ANY($2) + UNION ALL + SELECT format('trigger %s', pg_get_triggerdef(g.oid)) + FROM pg_class AS c JOIN pg_trigger AS g ON g.tgrelid = c.oid AND NOT g.tgisinternal + WHERE c.relnamespace = $1::regnamespace AND c.relname = ANY($2) +) AS lines`, schema, []string{"sub2api_plugin_installations", "sub2api_plugin_bindings"}) + return strings.ReplaceAll(listing, schema+".", "") + } + // What only the first-party bundle used: its two tables and the function. + bundle := func(f fixture) string { + f.t.Helper() + return text(f, `SELECT concat_ws(',', to_regclass('sub2api_plugin_bootstrap'), to_regclass('sub2api_plugin_updates'), to_regprocedure('sub2api_plugin_revision()'))`) + } + // open starts a case and builds what 229 and 230 alone define, in a schema + // of its own, to compare the plugin tables with. + open := func(t *testing.T) (fixture, string) { + f := fixture{tx: testTx(t), t: t} + exec(f, `CREATE SCHEMA migration_272_upstream`) + exec(f, `SET LOCAL search_path = migration_272_upstream, public`) + exec(f, upstreamSchema) + exec(f, `SET LOCAL search_path TO DEFAULT`) + return f, tables(f, "migration_272_upstream") + } + + const other, second = "acme.transport", "acme.second" + firstParty := []string{ + "codexrip.account-tools", "codexrip.admin-observability", "codexrip.cindy-provider", "codexrip.codex-runtime", + "codexrip.image-tools", "codexrip.model-policy", "codexrip.prompt-skills", + } + // What 272 deletes: the first-party installations and the receipt. + purged := func(f fixture) int { + f.t.Helper() + return count(f, `SELECT (SELECT count(*) FROM sub2api_plugin_installations WHERE plugin_key = ANY($1)) + + (SELECT count(*) FROM settings WHERE key = 'deplugin_retired_plugins')`, firstParty) + } + clean := func(f fixture, upstream string) { + f.t.Helper() + require.Zero(f.t, purged(f), "this fixture requires an isolated test database") + require.Zero(f.t, count(f, `SELECT count(*) FROM sub2api_plugin_installations WHERE plugin_key IN ($1, $2)`, other, second)) + require.Equal(f.t, upstream, tables(f, "public"), "the migrations leave both plugin tables as 229 and 230 define them") + require.Empty(f.t, bundle(f)) + } + restoreBundleSchema := func(f fixture, upstream string) { + f.t.Helper() + exec(f, bundleSchema) + restored := tables(f, "public") + require.NotEqual(f.t, upstream, restored) + for _, added := range []string{" revision bigint ", " runtime_generation bigint ", " package_sha256 text ", " update_policy text ", "'updating'", "trigger CREATE TRIGGER sub2api_plugin_revision ", "'extensions.provider.v1'"} { + require.Contains(f.t, restored, added) + } + require.Equal(f.t, "sub2api_plugin_bootstrap,sub2api_plugin_updates,sub2api_plugin_revision()", bundle(f)) + } + + // One installation with its saved configuration and package; blank is the + // row 264 left of codexrip.codex-runtime. + install := func(f fixture, key, state string, blank bool) int64 { + f.t.Helper() + policy := "pinned" + if strings.HasPrefix(key, "codexrip.") { + policy = "bundled" + } + values := []any{key, "fixture description", `{"id":"` + key + `"}`, "data/plugins/packages/" + key + ".s2plugin", "data/plugins/installed/" + key, + "data/plugins/installed/" + key + "/plugin", strings.Repeat("b", 64), "trusted", state, "fixture-config-cipher", []byte{1, 2, 3}, strings.Repeat("c", 64), policy} + if blank { + values = []any{key, "", `{}`, "", "", "", "", "unsigned", state, "", []byte(nil), "", "pinned"} + } + var id int64 + require.NoError(f.t, f.tx.QueryRowContext(ctx, ` +INSERT INTO sub2api_plugin_installations + (plugin_key, name, version, description, author, manifest, artifact_path, install_path, binary_path, binary_sha256, + signature_status, state, config_encrypted, last_error, enabled_at, artifact_data, runtime_generation, package_sha256, update_policy) +VALUES ($1, 'fixture', '0.2.13', $2, 'fixture author', $3::jsonb, $4, $5, $6, $7, $8, $9, $10, '', NOW(), $11, 7, $12, $13) +RETURNING id`, values...).Scan(&id)) + return id + } + bind := func(f fixture, id int64, capability, platform, accountType string, enabled bool, rollout int) { + f.t.Helper() + exec(f, `INSERT INTO sub2api_plugin_bindings (plugin_id, capability, platform, account_type, enabled, rollout_percent) VALUES ($1, $2, $3, $4, $5, $6)`, + id, capability, platform, accountType, enabled, rollout) + } + // The receipt of the startup retirement: one entry per retired installation. + receipt := func(f fixture, completed string, keys ...string) { + f.t.Helper() + entries := make([]string, 0, len(keys)) + for _, key := range keys { + entries = append(entries, `"`+key+`":{"id":1,"key":"`+key+`","state":"enabled","runtime_generation":7,"bindings":[]}`) + } + put(f, "deplugin_retired_plugins", `{"version":1,"completed":`+completed+`,"retired_at":"2026-09-24T14:38:04.441369821Z","plugins":{`+strings.Join(entries, ",")+`}}`) + } + // A third-party installation that is enabled, and the settings 272 must + // leave alone. + thirdParty := func(f fixture) int64 { + f.t.Helper() + id := install(f, other, "enabled", false) + bind(f, id, "openai.oauth.outbound_transport.v1", "openai", "oauth", true, 37) + put(f, "admin_observability_config", `{"telemetry_enabled":true,"theme_enabled":false}`) + put(f, "deplugin_retired_plugins_note", "kept: not the receipt") + return id + } + // Every upstream column and the row versions of what 272 must leave alone. + kept := func(f fixture, id int64) map[string]string { + f.t.Helper() + return map[string]string{ + "installation": text(f, `SELECT row_to_json(r)::text FROM ( + SELECT ctid, id, plugin_key, name, version, description, author, manifest, artifact_path, install_path, binary_path, binary_sha256, + signature_status, state, config_encrypted, last_error, installed_by, installed_at, enabled_at, updated_at, encode(artifact_data, 'hex') AS artifact_data + FROM sub2api_plugin_installations WHERE id = $1) AS r`, id), + "bindings": text(f, `SELECT json_agg(json_build_array(ctid::text, to_jsonb(b)) ORDER BY id)::text FROM sub2api_plugin_bindings AS b WHERE plugin_id = $1`, id), + "observability": text(f, `SELECT ctid::text || '/' || value FROM settings WHERE key = 'admin_observability_config'`), + "similar key": text(f, `SELECT ctid::text || '/' || value FROM settings WHERE key = 'deplugin_retired_plugins_note'`), + "other settings": text(f, `SELECT count(*)::text FROM settings WHERE key <> 'deplugin_retired_plugins'`), + "other plugins": text(f, `SELECT count(*)::text FROM sub2api_plugin_installations WHERE plugin_key <> ALL($1)`, firstParty), + "other bindings": text(f, `SELECT count(*)::text FROM sub2api_plugin_bindings WHERE plugin_id IN (SELECT id FROM sub2api_plugin_installations WHERE plugin_key <> ALL($1))`, firstParty), + } + } + + t.Run("fresh database", func(t *testing.T) { + f, upstream := open(t) + clean(f, upstream) + apply(f) + clean(f, upstream) + }) + + t.Run("retired installations of the previous release", func(t *testing.T) { + f, upstream := open(t) + clean(f, upstream) + restoreBundleSchema(f, upstream) + + ids := make([]int64, 0, len(firstParty)) + for _, key := range firstParty { + blank := key == "codexrip.codex-runtime" + id := install(f, key, "disabled", blank) + ids = append(ids, id) + if blank { + continue + } + bind(f, id, "extensions.admin.v1", "*", "*", false, 100) + bind(f, id, "extensions.ui.v1", "*", "*", false, 100) + exec(f, `INSERT INTO sub2api_plugin_bootstrap (plugin_key, bundle_sha256, migration_profile, desired_enabled, completed, state_imported, user_removed) + VALUES ($1, 'fixture', 'fixture-v1', true, true, true, true)`, key) + } + exec(f, `INSERT INTO sub2api_plugin_updates (plugin_id, artifact_data, package_sha256, source_generation, update_policy) VALUES ($1, '\x0a0b'::bytea, $2, 7, 'bundled')`, + ids[0], strings.Repeat("d", 64)) + receipt(f, "true", firstParty...) + otherID := thirdParty(f) + require.Equal(t, 8, purged(f)) + require.Equal(t, 12, count(f, `SELECT count(*) FROM sub2api_plugin_bindings WHERE plugin_id = ANY($1)`, ids)) + + before := kept(f, otherID) + for run := 1; run <= 2; run++ { + apply(f) + require.Zero(t, purged(f), "run %d", run) + require.Zero(t, count(f, `SELECT count(*) FROM sub2api_plugin_bindings WHERE plugin_id = ANY($1)`, ids), "run %d", run) + require.Equal(t, upstream, tables(f, "public"), "run %d", run) + require.Empty(t, bundle(f), "run %d", run) + require.Equal(t, before, kept(f, otherID), "run %d changes nothing else", run) + } + }) + + const upgrade = "; start v0.2.13-codexrip.7 once, then upgrade" + for _, test := range []struct { + name string + installed []string // first-party installations the database holds + completed string // the receipt's completed value; "" for no receipt + entries []string // the receipt's entries + store func(f fixture, otherID int64) + refusal string // what 272 raises; "" when it applies + }{ + {name: "installation without a receipt", installed: []string{"codexrip.admin-observability"}, + refusal: "first-party plugin installations were never retired (codexrip.admin-observability)" + upgrade}, + {name: "receipt that is not completed", installed: []string{"codexrip.admin-observability"}, completed: "false", entries: []string{"codexrip.admin-observability"}, + refusal: "first-party plugin installations were never retired (codexrip.admin-observability)" + upgrade}, + {name: "receipt without an entry for an installation", installed: []string{"codexrip.admin-observability", "codexrip.model-policy", "codexrip.account-tools"}, + completed: "true", entries: []string{"codexrip.admin-observability"}, + refusal: "first-party plugin installations were never retired (codexrip.account-tools, codexrip.model-policy)" + upgrade}, + {name: "receipt without an installation", completed: "false", entries: []string{"codexrip.admin-observability"}}, + {name: "third-party installation in the state updating", completed: "true", + store: func(f fixture, otherID int64) { + exec(f, `UPDATE sub2api_plugin_installations SET state = 'updating' WHERE id = $1`, otherID) + }, + refusal: "plugin installations are in a state the plugin manager does not have (acme.transport is updating); disable or uninstall them in v0.2.13-codexrip.7, then upgrade"}, + {name: "two third-party bindings enabled for one scope", completed: "true", + store: func(f fixture, otherID int64) { + bind(f, otherID, "extensions.request.v1", "openai", "oauth", true, 100) + bind(f, install(f, second, "enabled", false), "extensions.request.v1", "openai", "oauth", true, 100) + }, + refusal: "more than one enabled plugin binding shares a scope (extensions.request.v1 openai/oauth); disable all but one of the plugins in v0.2.13-codexrip.7, then upgrade"}, + } { + t.Run(test.name, func(t *testing.T) { + f, upstream := open(t) + clean(f, upstream) + restoreBundleSchema(f, upstream) + for _, key := range test.installed { + bind(f, install(f, key, "enabled", false), "extensions.admin.v1", "*", "*", true, 100) + } + if test.completed != "" { + receipt(f, test.completed, test.entries...) + } + otherID := thirdParty(f) + if test.store != nil { + test.store(f, otherID) + } + before, stored, schema := kept(f, otherID), purged(f), tables(f, "public") + + if test.refusal != "" { + exec(f, `SAVEPOINT before_272`) + _, err := f.tx.ExecContext(ctx, migration) + require.ErrorContains(t, err, "migration 272: "+test.refusal) + exec(f, `ROLLBACK TO SAVEPOINT before_272`) + require.Equal(t, stored, purged(f), "a refused migration changes nothing") + require.Equal(t, schema, tables(f, "public")) + require.Equal(t, before, kept(f, otherID)) + return + } + apply(f) + require.Zero(t, purged(f)) + require.Equal(t, upstream, tables(f, "public")) + require.Empty(t, bundle(f)) + require.Equal(t, before, kept(f, otherID)) + }) + } +} diff --git a/backend/internal/repository/native_feature_bootstrap.go b/backend/internal/repository/native_feature_bootstrap.go deleted file mode 100644 index 1ec26f0fc932..000000000000 --- a/backend/internal/repository/native_feature_bootstrap.go +++ /dev/null @@ -1,162 +0,0 @@ -package repository - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "fmt" - "sort" - "time" - - "github.com/Wei-Shaw/sub2api/internal/service" -) - -type nativeFeatureBootstrapRepository struct{ db *sql.DB } - -func NewNativeFeatureBootstrapRepository(db *sql.DB) service.NativeFeatureBootstrapRepository { - return &nativeFeatureBootstrapRepository{db: db} -} - -const nativeRetirementInstallationsSQL = `SELECT id,plugin_key,state,config_encrypted,runtime_generation,manifest - FROM sub2api_plugin_installations WHERE plugin_key IN - ('codexrip.account-tools','codexrip.admin-observability','codexrip.cindy-provider', - 'codexrip.model-policy','codexrip.prompt-skills') - ORDER BY id FOR UPDATE` - -func (r *nativeFeatureBootstrapRepository) RetireNativeFeatures(ctx context.Context, convert func(service.NativeRetirementPlugin) (map[string]json.RawMessage, error)) (*service.NativeRetirementSnapshot, error) { - tx, err := r.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) - if err != nil { - return nil, err - } - defer func() { _ = tx.Rollback() }() - if _, err = tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, service.NativeFeatureRetirementSetting); err != nil { - return nil, err - } - var saved string - err = tx.QueryRowContext(ctx, `SELECT value FROM settings WHERE key=$1 FOR UPDATE`, service.NativeFeatureRetirementSetting).Scan(&saved) - if err == nil { - var snapshot service.NativeRetirementSnapshot - if json.Unmarshal([]byte(saved), &snapshot) != nil || snapshot.Version != 1 || !snapshot.Completed || snapshot.Plugins == nil { - return nil, errors.New("invalid first-party retirement receipt") - } - if err = tx.Commit(); err != nil { - return nil, err - } - return &snapshot, nil - } - if !errors.Is(err, sql.ErrNoRows) { - return nil, err - } - - rows, err := tx.QueryContext(ctx, nativeRetirementInstallationsSQL) - if err != nil { - return nil, err - } - var plugins []service.NativeRetirementPlugin - for rows.Next() { - var plugin service.NativeRetirementPlugin - if err = rows.Scan(&plugin.ID, &plugin.Key, &plugin.State, &plugin.ConfigEncrypted, &plugin.RuntimeGeneration, &plugin.Manifest); err != nil { - _ = rows.Close() - return nil, err - } - plugins = append(plugins, plugin) - } - if err = rows.Err(); err != nil { - _ = rows.Close() - return nil, err - } - if err = rows.Close(); err != nil { - return nil, err - } - - snapshot := &service.NativeRetirementSnapshot{Version: 1, Completed: true, RetiredAt: time.Now().UTC(), Plugins: make(map[string]service.NativeRetirementPlugin)} - for _, plugin := range plugins { - // This is the same lock held by running legacy processes. A live owner - // must finish before retirement can commit. - var acquired bool - if err = tx.QueryRowContext(ctx, `SELECT pg_try_advisory_xact_lock(hashtextextended($1,0))`, fmt.Sprintf("sub2api-plugin-runtime:%d", plugin.ID)).Scan(&acquired); err != nil { - return nil, err - } - if !acquired { - return nil, fmt.Errorf("%s still has a running operation", plugin.Key) - } - bindings, readErr := readNativeRetirementBindings(ctx, tx, plugin.ID) - if readErr != nil { - return nil, readErr - } - plugin.Bindings = bindings - var bootstrap []byte - readErr = tx.QueryRowContext(ctx, `SELECT to_jsonb(b) FROM sub2api_plugin_bootstrap b WHERE plugin_key=$1 FOR UPDATE`, plugin.Key).Scan(&bootstrap) - if readErr != nil && !errors.Is(readErr, sql.ErrNoRows) { - return nil, readErr - } - if readErr == nil { - plugin.Bootstrap = append(json.RawMessage(nil), bootstrap...) - } - settings, conversionErr := convert(plugin) - if conversionErr != nil { - return nil, conversionErr - } - keys := make([]string, 0, len(settings)) - for key := range settings { - keys = append(keys, key) - } - sort.Strings(keys) - for _, key := range keys { - var existing string - readErr = tx.QueryRowContext(ctx, `SELECT value FROM settings WHERE key=$1 FOR UPDATE`, key).Scan(&existing) - if readErr == nil { - if validationErr := service.ValidateNativeFeatureSetting(key, []byte(existing)); validationErr != nil { - return nil, fmt.Errorf("invalid existing native setting %s", key) - } - continue - } - if !errors.Is(readErr, sql.ErrNoRows) { - return nil, readErr - } - if _, err = tx.ExecContext(ctx, `INSERT INTO settings(key,value,updated_at) VALUES($1,$2,NOW()) ON CONFLICT(key) DO NOTHING`, key, string(settings[key])); err != nil { - return nil, err - } - } - if _, err = tx.ExecContext(ctx, `UPDATE sub2api_plugin_bindings SET enabled=false,updated_at=NOW() WHERE plugin_id=$1 AND enabled`, plugin.ID); err != nil { - return nil, err - } - if _, err = tx.ExecContext(ctx, `UPDATE sub2api_plugin_installations SET state='disabled',updated_at=NOW() WHERE id=$1`, plugin.ID); err != nil { - return nil, err - } - if _, err = tx.ExecContext(ctx, `UPDATE sub2api_plugin_bootstrap SET user_removed=true,updated_at=NOW() WHERE plugin_key=$1`, plugin.Key); err != nil { - return nil, err - } - plugin.ConfigEncrypted, plugin.Manifest = "", nil - snapshot.Plugins[plugin.Key] = plugin - } - encoded, err := json.Marshal(snapshot) - if err != nil { - return nil, err - } - if _, err = tx.ExecContext(ctx, `INSERT INTO settings(key,value,updated_at) VALUES($1,$2,NOW())`, service.NativeFeatureRetirementSetting, string(encoded)); err != nil { - return nil, err - } - if err = tx.Commit(); err != nil { - return nil, err - } - return snapshot, nil -} - -func readNativeRetirementBindings(ctx context.Context, tx *sql.Tx, id int64) ([]service.NativeRetirementBinding, error) { - rows, err := tx.QueryContext(ctx, `SELECT id,capability,platform,account_type,enabled,rollout_percent FROM sub2api_plugin_bindings WHERE plugin_id=$1 ORDER BY id FOR UPDATE`, id) - if err != nil { - return nil, err - } - defer func() { _ = rows.Close() }() - bindings := make([]service.NativeRetirementBinding, 0) - for rows.Next() { - var binding service.NativeRetirementBinding - if err := rows.Scan(&binding.ID, &binding.Capability, &binding.Platform, &binding.AccountType, &binding.Enabled, &binding.RolloutPercent); err != nil { - return nil, err - } - bindings = append(bindings, binding) - } - return bindings, rows.Err() -} diff --git a/backend/internal/repository/native_feature_bootstrap_integration_test.go b/backend/internal/repository/native_feature_bootstrap_integration_test.go deleted file mode 100644 index a53de38d0a2e..000000000000 --- a/backend/internal/repository/native_feature_bootstrap_integration_test.go +++ /dev/null @@ -1,90 +0,0 @@ -//go:build integration - -package repository - -import ( - "context" - "encoding/json" - "errors" - "testing" - - "github.com/Wei-Shaw/sub2api/internal/service" - "github.com/stretchr/testify/require" -) - -func TestNativeFeatureRetirementIsAtomic(t *testing.T) { - ctx := context.Background() - var present int - require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT count(*) FROM sub2api_plugin_installations WHERE plugin_key IN ('codexrip.model-policy','codexrip.admin-observability')`).Scan(&present)) - require.Zero(t, present, "this fixture requires an isolated test database") - t.Cleanup(func() { - _, _ = integrationDB.ExecContext(ctx, `DELETE FROM settings WHERE key IN ('deplugin_retired_plugins','admin_observability_config')`) - _, _ = integrationDB.ExecContext(ctx, `DELETE FROM sub2api_plugin_bootstrap WHERE plugin_key='codexrip.admin-observability'`) - _, _ = integrationDB.ExecContext(ctx, `DELETE FROM sub2api_plugin_installations WHERE plugin_key IN ('codexrip.model-policy','codexrip.admin-observability')`) - }) - insert := func(key string, generation int64) int64 { - t.Helper() - var id int64 - require.NoError(t, integrationDB.QueryRowContext(ctx, `INSERT INTO sub2api_plugin_installations - (plugin_key,name,version,manifest,artifact_path,install_path,binary_path,binary_sha256,state,config_encrypted,runtime_generation,artifact_data) - VALUES($1,'retirement fixture','1.0.0','{}','','','','fixture','enabled','original encrypted config',$2,decode('010203','hex')) RETURNING id`, key, generation).Scan(&id)) - return id - } - observabilityID := insert("codexrip.admin-observability", 3) - policyID := insert("codexrip.model-policy", 9) - _, err := integrationDB.ExecContext(ctx, `INSERT INTO sub2api_plugin_bindings(plugin_id,capability,platform,account_type,enabled,rollout_percent) VALUES($1,'extensions.observability.v1','*','*',true,100)`, observabilityID) - require.NoError(t, err) - _, err = integrationDB.ExecContext(ctx, `INSERT INTO sub2api_plugin_bootstrap(plugin_key,bundle_sha256,migration_profile,desired_enabled,completed,state_imported) VALUES('codexrip.admin-observability','fixture','admin-observability-v1',true,true,true)`) - require.NoError(t, err) - _, err = integrationDB.ExecContext(ctx, `INSERT INTO settings(key,value,updated_at) VALUES('admin_observability_config','{"telemetry_enabled":false,"theme_enabled":false}',NOW())`) - require.NoError(t, err) - repo := NewNativeFeatureBootstrapRepository(integrationDB) - convert := func(plugin service.NativeRetirementPlugin) (map[string]json.RawMessage, error) { - if plugin.Key == "codexrip.admin-observability" { - return map[string]json.RawMessage{service.SettingKeyAdminObservabilityConfig: json.RawMessage(`{"telemetry_enabled":true,"theme_enabled":true}`)}, nil - } - return nil, nil - } - _, err = repo.RetireNativeFeatures(ctx, func(plugin service.NativeRetirementPlugin) (map[string]json.RawMessage, error) { - if plugin.Key == "codexrip.model-policy" { - return nil, errors.New("fixture conversion failure") - } - return convert(plugin) - }) - require.Error(t, err) - var state string - require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT state FROM sub2api_plugin_installations WHERE id=$1`, observabilityID).Scan(&state)) - require.Equal(t, "enabled", state, "an earlier disable must roll back with a later failed conversion") - require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT count(*) FROM settings WHERE key='deplugin_retired_plugins'`).Scan(&present)) - require.Zero(t, present) - - first, err := repo.RetireNativeFeatures(ctx, convert) - require.NoError(t, err) - require.True(t, first.Completed) - require.Equal(t, "enabled", first.Plugins["codexrip.model-policy"].State) - var generation int64 - var cipher, artifact string - require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT state,runtime_generation,config_encrypted,encode(artifact_data,'hex') FROM sub2api_plugin_installations WHERE id=$1`, policyID).Scan(&state, &generation, &cipher, &artifact)) - require.Equal(t, "disabled", state) - require.EqualValues(t, 9, generation) - require.Equal(t, "original encrypted config", cipher) - require.Equal(t, "010203", artifact) - var setting string - require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT value FROM settings WHERE key='admin_observability_config'`).Scan(&setting)) - require.JSONEq(t, `{"telemetry_enabled":false,"theme_enabled":false}`, setting) - var bindingEnabled, removed bool - require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT enabled FROM sub2api_plugin_bindings WHERE plugin_id=$1`, observabilityID).Scan(&bindingEnabled)) - require.False(t, bindingEnabled) - require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT user_removed FROM sub2api_plugin_bootstrap WHERE plugin_key='codexrip.admin-observability'`).Scan(&removed)) - require.True(t, removed) - second, err := repo.RetireNativeFeatures(ctx, func(service.NativeRetirementPlugin) (map[string]json.RawMessage, error) { - t.Fatal("a completed retirement must not repeat decryption or import") - return nil, nil - }) - require.NoError(t, err) - firstJSON, err := json.Marshal(first.Plugins) - require.NoError(t, err) - secondJSON, err := json.Marshal(second.Plugins) - require.NoError(t, err) - require.JSONEq(t, string(firstJSON), string(secondJSON)) -} diff --git a/backend/internal/repository/native_plugin_boundary.go b/backend/internal/repository/native_plugin_boundary.go deleted file mode 100644 index 0242b7a341c9..000000000000 --- a/backend/internal/repository/native_plugin_boundary.go +++ /dev/null @@ -1,156 +0,0 @@ -package repository - -import ( - "context" - "database/sql" - "errors" - "time" - - "github.com/Wei-Shaw/sub2api/internal/service" -) - -var errRetiredNativePlugin = errors.New("this first-party plugin is now built into the host") - -// The upstream manager sees only third-party installations. Native repositories -// and the retirement transaction deliberately read the original SQL directly. -type nativePluginBoundary struct { - service.PluginRepository - keys map[string]bool - ids map[int64]bool -} - -func ProvidePluginRepository(db *sql.DB, bootstrap *service.NativeFeatureBootstrap) service.PluginRepository { - boundary := newNativePluginBoundary(NewPluginRepository(db), bootstrap) - bootstrap.SetRetiredPluginSource(boundary) - return boundary -} - -func newNativePluginBoundary(base service.PluginRepository, bootstrap *service.NativeFeatureBootstrap) *nativePluginBoundary { - r := &nativePluginBoundary{PluginRepository: base, keys: map[string]bool{}, ids: map[int64]bool{}} - for _, key := range service.FirstPartyNativePluginKeys { - r.keys[key] = true - } - if bootstrap != nil && bootstrap.Snapshot != nil && bootstrap.Snapshot.Completed { - for key, plugin := range bootstrap.Snapshot.Plugins { - if r.keys[key] && plugin.ID > 0 { - r.ids[plugin.ID] = true - } - } - } - return r -} - -func (r *nativePluginBoundary) List(ctx context.Context) ([]*service.PluginInstallation, error) { - all, err := r.PluginRepository.List(ctx) - if err != nil { - return nil, err - } - visible := make([]*service.PluginInstallation, 0, len(all)) - for _, plugin := range all { - if plugin != nil && !r.keys[plugin.PluginKey] && !r.ids[plugin.ID] { - visible = append(visible, plugin) - } - } - return visible, nil -} - -// RetiredPluginInstallations returns exactly the rows List hides: the retired -// first-party installations, for the read-only admin view. -func (r *nativePluginBoundary) RetiredPluginInstallations(ctx context.Context) ([]*service.PluginInstallation, error) { - all, err := r.PluginRepository.List(ctx) - if err != nil { - return nil, err - } - retired := make([]*service.PluginInstallation, 0, len(r.keys)) - for _, plugin := range all { - if plugin != nil && (r.keys[plugin.PluginKey] || r.ids[plugin.ID]) { - retired = append(retired, plugin) - } - } - return retired, nil -} - -func (r *nativePluginBoundary) GetByID(ctx context.Context, id int64) (*service.PluginInstallation, error) { - if r.ids[id] { - return nil, sql.ErrNoRows - } - plugin, err := r.PluginRepository.GetByID(ctx, id) - if err != nil { - return nil, err - } - if plugin != nil && r.keys[plugin.PluginKey] { - return nil, sql.ErrNoRows - } - return plugin, nil -} - -func (r *nativePluginBoundary) GetByKey(ctx context.Context, key string) (*service.PluginInstallation, error) { - if r.keys[key] { - return nil, sql.ErrNoRows - } - return r.PluginRepository.GetByKey(ctx, key) -} - -func (r *nativePluginBoundary) Install(ctx context.Context, plugin *service.PluginInstallation, bindings []service.PluginBinding) (*service.PluginInstallation, error) { - if plugin == nil || r.keys[plugin.PluginKey] || r.ids[plugin.ID] { - return nil, errRetiredNativePlugin - } - return r.PluginRepository.Install(ctx, plugin, bindings) -} - -func (r *nativePluginBoundary) checkID(ctx context.Context, id int64) error { - if r.ids[id] { - return errRetiredNativePlugin - } - plugin, err := r.PluginRepository.GetByID(ctx, id) - if err != nil { - return err - } - if plugin == nil || r.keys[plugin.PluginKey] { - return errRetiredNativePlugin - } - return nil -} - -func (r *nativePluginBoundary) GetArtifact(ctx context.Context, id int64) ([]byte, error) { - if err := r.checkID(ctx, id); err != nil { - return nil, err - } - return r.PluginRepository.GetArtifact(ctx, id) -} -func (r *nativePluginBoundary) Delete(ctx context.Context, id int64, digest string) error { - if err := r.checkID(ctx, id); err != nil { - return err - } - return r.PluginRepository.Delete(ctx, id, digest) -} -func (r *nativePluginBoundary) BeginEnable(ctx context.Context, id int64, digest, state string) error { - if err := r.checkID(ctx, id); err != nil { - return err - } - return r.PluginRepository.BeginEnable(ctx, id, digest, state) -} -func (r *nativePluginBoundary) MarkRuntimeHealthy(ctx context.Context, id int64, digest, cipher string) error { - if err := r.checkID(ctx, id); err != nil { - return err - } - return r.PluginRepository.MarkRuntimeHealthy(ctx, id, digest, cipher) -} -func (r *nativePluginBoundary) UpdateState(ctx context.Context, id int64, state, message string, enabled *time.Time, digest, expected string) error { - if err := r.checkID(ctx, id); err != nil { - return err - } - return r.PluginRepository.UpdateState(ctx, id, state, message, enabled, digest, expected) -} -func (r *nativePluginBoundary) UpdateConfig(ctx context.Context, id int64, cipher, digest string) error { - if err := r.checkID(ctx, id); err != nil { - return err - } - return r.PluginRepository.UpdateConfig(ctx, id, cipher, digest) -} -func (r *nativePluginBoundary) UpdateBindingsAndState(ctx context.Context, id int64, bindings []service.PluginBinding, state, message string, enabled *time.Time, expected, digest string) error { - if err := r.checkID(ctx, id); err != nil { - return err - } - return r.PluginRepository.UpdateBindingsAndState(ctx, id, bindings, state, message, enabled, expected, digest) -} diff --git a/backend/internal/repository/native_plugin_boundary_test.go b/backend/internal/repository/native_plugin_boundary_test.go deleted file mode 100644 index 0c65a0c76627..000000000000 --- a/backend/internal/repository/native_plugin_boundary_test.go +++ /dev/null @@ -1,93 +0,0 @@ -package repository - -import ( - "context" - "database/sql" - "testing" - "time" - - "github.com/Wei-Shaw/sub2api/internal/service" - "github.com/stretchr/testify/require" -) - -type nativeBoundaryFixture struct { - service.PluginRepository - rows map[int64]*service.PluginInstallation - writes int -} - -func (r *nativeBoundaryFixture) List(context.Context) ([]*service.PluginInstallation, error) { - out := make([]*service.PluginInstallation, 0, len(r.rows)) - for _, p := range r.rows { - out = append(out, p) - } - return out, nil -} -func (r *nativeBoundaryFixture) GetByID(_ context.Context, id int64) (*service.PluginInstallation, error) { - if p := r.rows[id]; p != nil { - return p, nil - } - return nil, sql.ErrNoRows -} -func (r *nativeBoundaryFixture) GetByKey(_ context.Context, key string) (*service.PluginInstallation, error) { - for _, p := range r.rows { - if p.PluginKey == key { - return p, nil - } - } - return nil, sql.ErrNoRows -} -func (r *nativeBoundaryFixture) Install(_ context.Context, p *service.PluginInstallation, _ []service.PluginBinding) (*service.PluginInstallation, error) { - r.writes++ - return p, nil -} -func (r *nativeBoundaryFixture) UpdateState(context.Context, int64, string, string, *time.Time, string, string) error { - r.writes++ - return nil -} - -func TestNativePluginBoundaryProtectsRetiredRecordsAndAllowsThirdParties(t *testing.T) { - ctx := context.Background() - base := &nativeBoundaryFixture{rows: map[int64]*service.PluginInstallation{}} - snapshot := &service.NativeRetirementSnapshot{Version: 1, Completed: true, Plugins: map[string]service.NativeRetirementPlugin{}} - for i, key := range service.FirstPartyNativePluginKeys { - id := int64(i + 1) - base.rows[id] = &service.PluginInstallation{ID: id, PluginKey: key, State: "disabled"} - snapshot.Plugins[key] = service.NativeRetirementPlugin{ID: id, Key: key} - } - base.rows[100] = &service.PluginInstallation{ID: 100, PluginKey: "third-party.transport", State: "disabled"} - r := newNativePluginBoundary(base, &service.NativeFeatureBootstrap{Snapshot: snapshot}) - list, err := r.List(ctx) - require.NoError(t, err) - require.Len(t, list, 1) - require.EqualValues(t, 100, list[0].ID) - retired, err := r.RetiredPluginInstallations(ctx) - require.NoError(t, err) - require.Len(t, retired, len(service.FirstPartyNativePluginKeys), "the read-only view lists exactly the rows the managed list hides") - for id, p := range base.rows { - if id == 100 { - continue - } - _, err = r.GetByID(ctx, id) - require.ErrorIs(t, err, sql.ErrNoRows) - _, err = r.GetByKey(ctx, p.PluginKey) - require.ErrorIs(t, err, sql.ErrNoRows) - _, err = r.GetArtifact(ctx, id) - require.ErrorIs(t, err, errRetiredNativePlugin) - require.ErrorIs(t, r.Delete(ctx, id, "fixture"), errRetiredNativePlugin) - require.ErrorIs(t, r.BeginEnable(ctx, id, "fixture", "disabled"), errRetiredNativePlugin) - require.ErrorIs(t, r.UpdateState(ctx, id, "incompatible", "", nil, "fixture", "disabled"), errRetiredNativePlugin) - require.ErrorIs(t, r.UpdateConfig(ctx, id, "cipher", "fixture"), errRetiredNativePlugin) - require.ErrorIs(t, r.MarkRuntimeHealthy(ctx, id, "fixture", "cipher"), errRetiredNativePlugin) - require.ErrorIs(t, r.UpdateBindingsAndState(ctx, id, nil, "enabled", "", nil, "disabled", "fixture"), errRetiredNativePlugin) - _, err = r.Install(ctx, &service.PluginInstallation{PluginKey: p.PluginKey}, nil) - require.ErrorIs(t, err, errRetiredNativePlugin) - } - require.Zero(t, base.writes) - _, err = r.GetByID(ctx, 100) - require.NoError(t, err) - _, err = r.Install(ctx, &service.PluginInstallation{PluginKey: "third-party.new"}, nil) - require.NoError(t, err) - require.NoError(t, r.UpdateState(ctx, 100, "disabled", "", nil, "fixture", "disabled")) - require.Equal(t, 2, base.writes) -} diff --git a/backend/internal/repository/wire.go b/backend/internal/repository/wire.go index c2426000e1da..f3d7a51896c9 100644 --- a/backend/internal/repository/wire.go +++ b/backend/internal/repository/wire.go @@ -87,7 +87,6 @@ var ProviderSet = wire.NewSet( NewUsageCleanupRepository, NewDashboardAggregationRepository, NewSettingRepository, - NewNativeFeatureBootstrapRepository, NewOpsRepository, NewAuditLogRepository, NewPasskeyRepository, @@ -98,7 +97,7 @@ var ProviderSet = wire.NewSet( NewUserGroupRateRepository, NewErrorPassthroughRepository, NewTLSFingerprintProfileRepository, - ProvidePluginRepository, + NewPluginRepository, NewQuotaActivityStore, NewPluginKVStore, NewChannelRepository, diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 5eb952928c01..c59bc6f66024 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -825,7 +825,6 @@ func registerPluginRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAut plugins := admin.Group("/plugins") { plugins.GET("", h.Admin.Plugin.List) - plugins.GET("/retired", h.Admin.Plugin.ListRetired) plugins.GET("/:id", h.Admin.Plugin.Get) plugins.POST("/upload", gin.HandlerFunc(stepUpAuth), h.Admin.Plugin.Upload) plugins.POST("/:id/enable", gin.HandlerFunc(stepUpAuth), h.Admin.Plugin.Enable) diff --git a/backend/internal/service/native_feature_bootstrap.go b/backend/internal/service/native_feature_bootstrap.go deleted file mode 100644 index 0af7e6a97fe7..000000000000 --- a/backend/internal/service/native_feature_bootstrap.go +++ /dev/null @@ -1,310 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "time" - - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" -) - -const NativeFeatureRetirementSetting = "deplugin_retired_plugins" - -var FirstPartyNativePluginKeys = []string{ - "codexrip.account-tools", "codexrip.admin-observability", "codexrip.cindy-provider", - "codexrip.codex-runtime", "codexrip.image-tools", "codexrip.model-policy", "codexrip.prompt-skills", -} - -// These records describe the retired installation, not an enabled native plugin. -// Ciphertext and package bytes are not copied into them. -type NativeRetirementBinding struct { - ID int64 `json:"id"` - Capability string `json:"capability"` - Platform string `json:"platform"` - AccountType string `json:"account_type"` - Enabled bool `json:"enabled"` - RolloutPercent int `json:"rollout_percent"` -} - -type NativeRetirementPlugin struct { - ID int64 `json:"id"` - Key string `json:"key"` - State string `json:"state"` - RuntimeGeneration int64 `json:"runtime_generation"` - Bindings []NativeRetirementBinding `json:"bindings"` - Bootstrap json.RawMessage `json:"bootstrap,omitempty"` - ConfigEncrypted string `json:"-"` - Manifest json.RawMessage `json:"-"` -} - -type NativeRetirementSnapshot struct { - Version int `json:"version"` - Completed bool `json:"completed"` - RetiredAt time.Time `json:"retired_at"` - Plugins map[string]NativeRetirementPlugin `json:"plugins"` -} - -type NativeFeatureBootstrapRepository interface { - RetireNativeFeatures(context.Context, func(NativeRetirementPlugin) (map[string]json.RawMessage, error)) (*NativeRetirementSnapshot, error) -} - -// NativeFeatureBootstrap is a Wire dependency of settings loading: imports and -// retirement must commit before a native feature or the plugin manager starts. -type NativeFeatureBootstrap struct { - Snapshot *NativeRetirementSnapshot - - encryptor SecretEncryptor - retired RetiredPluginSource -} - -func ProvideNativeFeatureBootstrap(repo NativeFeatureBootstrapRepository, encryptor SecretEncryptor) (*NativeFeatureBootstrap, error) { - ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) - defer cancel() - snapshot, err := repo.RetireNativeFeatures(ctx, func(plugin NativeRetirementPlugin) (map[string]json.RawMessage, error) { - return nativeFeatureSettings(plugin, encryptor) - }) - if err != nil { - return nil, fmt.Errorf("initialize native features: %w", err) - } - return &NativeFeatureBootstrap{Snapshot: snapshot, encryptor: encryptor}, nil -} - -// RetiredPluginSource reads the retired first-party installation rows. They -// stay outside the plugin manager, whose reconciliation would clean them up. -type RetiredPluginSource interface { - RetiredPluginInstallations(context.Context) ([]*PluginInstallation, error) -} - -// SetRetiredPluginSource is called by the plugin repository boundary. -func (b *NativeFeatureBootstrap) SetRetiredPluginSource(source RetiredPluginSource) { - if b != nil { - b.retired = source - } -} - -// RetiredPluginInstallation is the read-only admin view of one retired -// first-party installation row with its decrypted saved configuration. -type RetiredPluginInstallation struct { - ID int64 `json:"id"` - PluginKey string `json:"plugin_key"` - Name string `json:"name"` - Version string `json:"version"` - Description string `json:"description"` - Author string `json:"author"` - Manifest PluginManifest `json:"manifest"` - ArtifactPath string `json:"artifact_path"` - InstallPath string `json:"install_path"` - BinaryPath string `json:"binary_path"` - BinarySHA256 string `json:"binary_sha256"` - SignatureStatus string `json:"signature_status"` - State string `json:"state"` - LastError string `json:"last_error"` - InstalledBy *int64 `json:"installed_by"` - InstalledAt time.Time `json:"installed_at"` - EnabledAt *time.Time `json:"enabled_at"` - UpdatedAt time.Time `json:"updated_at"` - Bindings []PluginBinding `json:"bindings"` - Config json.RawMessage `json:"config,omitempty"` - ConfigText string `json:"config_text,omitempty"` - ConfigError string `json:"config_error,omitempty"` -} - -// RetiredPluginsView pairs the retirement receipt with the current rows. -type RetiredPluginsView struct { - Receipt *NativeRetirementSnapshot `json:"receipt"` - Installations []RetiredPluginInstallation `json:"installations"` -} - -// RetiredPlugins lists the retired first-party installations read-only, with -// their saved configuration decrypted as the plugin configuration API did. -func (b *NativeFeatureBootstrap) RetiredPlugins(ctx context.Context) (*RetiredPluginsView, error) { - view := &RetiredPluginsView{Installations: []RetiredPluginInstallation{}} - if b == nil { - return view, nil - } - view.Receipt = b.Snapshot - if b.retired == nil { - return view, nil - } - rows, err := b.retired.RetiredPluginInstallations(ctx) - if err != nil { - return nil, err - } - for _, row := range rows { - if row == nil { - continue - } - item := RetiredPluginInstallation{ - ID: row.ID, PluginKey: row.PluginKey, Name: row.Name, Version: row.Version, - Description: row.Description, Author: row.Author, Manifest: row.Manifest, - ArtifactPath: row.ArtifactPath, InstallPath: row.InstallPath, BinaryPath: row.BinaryPath, - BinarySHA256: row.BinarySHA256, SignatureStatus: row.SignatureStatus, State: row.State, - LastError: row.LastError, InstalledBy: row.InstalledBy, InstalledAt: row.InstalledAt, - EnabledAt: row.EnabledAt, UpdatedAt: row.UpdatedAt, Bindings: row.Bindings, - } - if item.Bindings == nil { - item.Bindings = []PluginBinding{} - } - switch { - case row.ConfigEncrypted == "": - case b.encryptor == nil: - item.ConfigError = "configuration decryptor is unavailable" - default: - plain, decryptErr := b.encryptor.Decrypt(row.ConfigEncrypted) - switch { - case decryptErr != nil: - item.ConfigError = decryptErr.Error() - case json.Valid([]byte(plain)): - item.Config = json.RawMessage(plain) - default: - item.ConfigText = plain - } - } - view.Installations = append(view.Installations, item) - } - return view, nil -} - -func nativeFeatureSettings(plugin NativeRetirementPlugin, encryptor SecretEncryptor) (map[string]json.RawMessage, error) { - if plugin.State == "starting" || plugin.State == "updating" { - return nil, fmt.Errorf("%s has an unfinished installation transition", plugin.Key) - } - raw := []byte(`{}`) - if plugin.ConfigEncrypted != "" { - if encryptor == nil { - return nil, errors.New("native feature configuration decryptor is unavailable") - } - plain, err := encryptor.Decrypt(plugin.ConfigEncrypted) - if err != nil { - return nil, fmt.Errorf("cannot decrypt configuration for %s", plugin.Key) - } - raw = []byte(plain) - } - settings := make(map[string]json.RawMessage) - put := func(key string, value any) error { - encoded, err := json.Marshal(value) - if err == nil { - settings[key] = encoded - } - return err - } - switch plugin.Key { - case "codexrip.admin-observability": - if err := validateNativeUnmappedBindings(plugin, "extensions.observability.v1", "extensions.ui.v1"); err != nil { - return nil, err - } - value := extensionv1.AdminObservabilityConfig{TelemetryEnabled: true, ThemeEnabled: true} - if err := DecodeSwitchSettings(raw, &value, "telemetry_enabled", "theme_enabled"); err != nil { - return nil, fmt.Errorf("invalid saved observability configuration: %w", err) - } - telemetry, err := nativeRetirementBindingEnabled(plugin, "extensions.observability.v1", "*", "*") - if err != nil { - return nil, err - } - theme, err := nativeRetirementBindingEnabled(plugin, "extensions.ui.v1", "*", "*") - if err != nil { - return nil, err - } - value.TelemetryEnabled = value.TelemetryEnabled && telemetry - value.ThemeEnabled = value.ThemeEnabled && theme - return settings, put(SettingKeyAdminObservabilityConfig, value) - case "codexrip.cindy-provider": - // The provider plugin was removed; its saved switches have no native - // equivalent and are dropped with the retired installation. - return settings, nil - default: - // Domains without a single equivalent switch cannot silently broaden a - // saved partial rollout or turn a disabled domain back on. - if plugin.State != "enabled" { - return nil, fmt.Errorf("%s is disabled; its native availability needs an explicit decision", plugin.Key) - } - if err := validateNativeUnmappedBindings(plugin); err != nil { - return nil, err - } - return settings, nil - } -} - -func validateNativeUnmappedBindings(plugin NativeRetirementPlugin, mapped ...string) error { - if plugin.State != "enabled" { - return nil - } - var manifest struct { - Capabilities []struct { - ID string `json:"id"` - Platform string `json:"platform"` - AccountType string `json:"account_type"` - } `json:"capabilities"` - } - if json.Unmarshal(plugin.Manifest, &manifest) != nil || len(manifest.Capabilities) == 0 { - return fmt.Errorf("invalid saved manifest for %s", plugin.Key) - } - for _, capability := range manifest.Capabilities { - if !nativeCapabilityScopeKnown(plugin.Key, capability.ID, capability.Platform, capability.AccountType) { - return fmt.Errorf("unsupported saved capability scope for %s", plugin.Key) - } - isMapped := false - for _, id := range mapped { - isMapped = isMapped || capability.ID == id - } - if isMapped { - continue - } - enabled := false - for _, binding := range plugin.Bindings { - if binding.Capability == capability.ID && binding.Platform == capability.Platform && binding.AccountType == capability.AccountType { - enabled = enabled || (binding.Enabled && binding.RolloutPercent == 100) - } - } - if !enabled { - return fmt.Errorf("%s has a disabled %s binding without an equivalent native switch", plugin.Key, capability.ID) - } - } - return nil -} - -func nativeCapabilityScopeKnown(key, capability, platform, accountType string) bool { - global := platform == "*" && accountType == "*" - switch key { - case "codexrip.account-tools": - return global && (capability == "extensions.admin.v1" || capability == "extensions.ui.v1") - case "codexrip.admin-observability": - return global && (capability == "extensions.admin.v1" || capability == "extensions.ui.v1" || capability == "extensions.observability.v1") - case "codexrip.model-policy": - return global && (capability == "extensions.admin.v1" || capability == "extensions.catalog.v1") - case "codexrip.prompt-skills": - return (global && capability == "extensions.admin.v1") || (capability == "extensions.request.v1" && platform == PlatformOpenAI && accountType == "*") - } - return false -} - -func nativeRetirementBindingEnabled(plugin NativeRetirementPlugin, capability, platform, accountType string) (bool, error) { - if plugin.State != "enabled" { - return false, nil - } - enabled := false - for _, binding := range plugin.Bindings { - if binding.Capability != capability || !binding.Enabled || binding.RolloutPercent == 0 { - continue - } - if binding.RolloutPercent != 100 || binding.Platform != platform || binding.AccountType != accountType { - return false, fmt.Errorf("%s has a restricted %s binding that cannot be widened during retirement", plugin.Key, capability) - } - enabled = true - } - return enabled, nil -} - -// ValidateNativeFeatureSetting distinguishes an existing invalid value from a -// missing key. Retirement must never overwrite either an existing setting or a -// database read failure with defaults. -func ValidateNativeFeatureSetting(key string, raw []byte) error { - switch key { - case SettingKeyAdminObservabilityConfig: - return DecodeSwitchSettings(raw, &extensionv1.AdminObservabilityConfig{}, "telemetry_enabled", "theme_enabled") - default: - return errors.New("unsupported native feature setting") - } -} diff --git a/backend/internal/service/native_feature_bootstrap_test.go b/backend/internal/service/native_feature_bootstrap_test.go deleted file mode 100644 index c74fa718d421..000000000000 --- a/backend/internal/service/native_feature_bootstrap_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "errors" - "testing" - - "github.com/stretchr/testify/require" -) - -type retirementTestEncryptor struct{ fail bool } - -func (e retirementTestEncryptor) Encrypt(value string) (string, error) { return value, nil } -func (e retirementTestEncryptor) Decrypt(value string) (string, error) { - if e.fail { - return "", errors.New("sensitive ciphertext must not appear in errors") - } - return value, nil -} - -func TestNativeFeatureBootstrapPreservesEffectiveConfiguration(t *testing.T) { - for _, test := range []struct { - name string - plugin NativeRetirementPlugin - key string - expected string - wantErr bool - }{ - { - name: "disabled observability installation stays off", - plugin: NativeRetirementPlugin{Key: "codexrip.admin-observability", State: "disabled", ConfigEncrypted: `{"telemetry_enabled":true,"theme_enabled":true}`}, - key: SettingKeyAdminObservabilityConfig, expected: `{"telemetry_enabled":false,"theme_enabled":false}`, - }, - { - name: "separate theme and telemetry bindings", - plugin: NativeRetirementPlugin{Key: "codexrip.admin-observability", State: "enabled", Manifest: json.RawMessage(`{"capabilities":[{"id":"extensions.observability.v1","platform":"*","account_type":"*"},{"id":"extensions.ui.v1","platform":"*","account_type":"*"}]}`), ConfigEncrypted: `{}`, Bindings: []NativeRetirementBinding{ - {Capability: "extensions.observability.v1", Platform: "*", AccountType: "*", Enabled: true, RolloutPercent: 100}, - {Capability: "extensions.ui.v1", Platform: "*", AccountType: "*", Enabled: false, RolloutPercent: 100}, - }}, key: SettingKeyAdminObservabilityConfig, expected: `{"telemetry_enabled":true,"theme_enabled":false}`, - }, - { - name: "retired Cindy provider drops its saved switches", - plugin: NativeRetirementPlugin{Key: "codexrip.cindy-provider", State: "enabled", Manifest: json.RawMessage(`{"capabilities":[{"id":"extensions.provider.v1","platform":"cindy","account_type":"apikey"}]}`), ConfigEncrypted: `{"catalog_enabled":true,"search_enabled":false}`, Bindings: []NativeRetirementBinding{ - {Capability: "extensions.provider.v1", Platform: "cindy", AccountType: AccountTypeAPIKey, Enabled: true, RolloutPercent: 100}, - }}, - }, - { - name: "partial telemetry cannot become global", - plugin: NativeRetirementPlugin{Key: "codexrip.admin-observability", State: "enabled", Manifest: json.RawMessage(`{"capabilities":[{"id":"extensions.observability.v1","platform":"*","account_type":"*"},{"id":"extensions.ui.v1","platform":"*","account_type":"*"}]}`), ConfigEncrypted: `{}`, Bindings: []NativeRetirementBinding{ - {Capability: "extensions.observability.v1", Platform: "*", AccountType: "*", Enabled: true, RolloutPercent: 25}, - }}, wantErr: true, - }, - { - name: "disabled admin action binding is not silently enabled", - plugin: NativeRetirementPlugin{Key: "codexrip.account-tools", State: "enabled", Manifest: json.RawMessage(`{"capabilities":[{"id":"extensions.admin.v1","platform":"*","account_type":"*"}]}`)}, wantErr: true, - }, - { - name: "fully bound prompt skills, platform-scoped request included, stay equivalent", - plugin: NativeRetirementPlugin{Key: "codexrip.prompt-skills", State: "enabled", Manifest: json.RawMessage(`{"capabilities":[{"id":"extensions.admin.v1","platform":"*","account_type":"*"},{"id":"extensions.request.v1","platform":"openai","account_type":"*"}]}`), Bindings: []NativeRetirementBinding{ - {Capability: "extensions.admin.v1", Platform: "*", AccountType: "*", Enabled: true, RolloutPercent: 100}, - {Capability: "extensions.request.v1", Platform: "openai", AccountType: "*", Enabled: true, RolloutPercent: 100}, - }}, - }, - { - name: "unfinished update cannot be retired", - plugin: NativeRetirementPlugin{Key: "codexrip.model-policy", State: "updating"}, wantErr: true, - }, - { - name: "disabled whole domain without a native switch needs an explicit decision", - plugin: NativeRetirementPlugin{Key: "codexrip.model-policy", State: "disabled"}, wantErr: true, - }, - { - name: "missing capability scope is rejected", - plugin: NativeRetirementPlugin{Key: "codexrip.account-tools", State: "enabled", Manifest: json.RawMessage(`{}`)}, wantErr: true, - }, - { - name: "narrow manifest cannot become a global native scope", - plugin: NativeRetirementPlugin{Key: "codexrip.account-tools", State: "enabled", Manifest: json.RawMessage(`{"capabilities":[{"id":"extensions.admin.v1","platform":"openai","account_type":"apikey"}]}`)}, wantErr: true, - }, - } { - t.Run(test.name, func(t *testing.T) { - settings, err := nativeFeatureSettings(test.plugin, retirementTestEncryptor{}) - if test.wantErr { - require.Error(t, err) - return - } - require.NoError(t, err) - if test.key == "" { - require.Empty(t, settings) - } else { - require.JSONEq(t, test.expected, string(settings[test.key])) - } - }) - } - _, err := nativeFeatureSettings(NativeRetirementPlugin{Key: "codexrip.admin-observability", State: "disabled", ConfigEncrypted: "private"}, retirementTestEncryptor{fail: true}) - require.Error(t, err) - require.NotContains(t, err.Error(), "sensitive ciphertext") - require.Error(t, ValidateNativeFeatureSetting(SettingKeyAdminObservabilityConfig, []byte(`{"telemetry_enabled":"false"}`))) - require.Error(t, ValidateNativeFeatureSetting(SettingKeyAdminObservabilityConfig, nil)) -} - -type retiredPluginSourceStub []*PluginInstallation - -func (s retiredPluginSourceStub) RetiredPluginInstallations(context.Context) ([]*PluginInstallation, error) { - return s, nil -} - -func TestNativeFeatureBootstrapListsRetiredPluginsWithDecryptedConfig(t *testing.T) { - snapshot := &NativeRetirementSnapshot{Version: 1, Completed: true, Plugins: map[string]NativeRetirementPlugin{ - "codexrip.admin-observability": {ID: 3, Key: "codexrip.admin-observability", State: "enabled"}, - }} - bootstrap := &NativeFeatureBootstrap{Snapshot: snapshot, encryptor: retirementTestEncryptor{}} - bootstrap.SetRetiredPluginSource(retiredPluginSourceStub{{ - ID: 3, PluginKey: "codexrip.admin-observability", State: "disabled", LastError: "stopped by retirement", - ConfigEncrypted: `{"telemetry_enabled":true,"api_key":"saved-secret"}`, - }}) - view, err := bootstrap.RetiredPlugins(context.Background()) - require.NoError(t, err) - require.Same(t, snapshot, view.Receipt) - require.Len(t, view.Installations, 1) - require.Equal(t, "stopped by retirement", view.Installations[0].LastError) - require.JSONEq(t, `{"telemetry_enabled":true,"api_key":"saved-secret"}`, string(view.Installations[0].Config)) -} diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 3593fd001497..85f9953ab148 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -925,7 +925,7 @@ func ProvideOpsIngressRejectAggregator(opsRepo OpsRepository, opsService *OpsSer } // ProvideSettingService wires SettingService with group reader and proxy repo. -func ProvideSettingService(settingRepo SettingRepository, groupRepo GroupRepository, proxyRepo ProxyRepository, cfg *config.Config, _ *NativeFeatureBootstrap) (*SettingService, error) { +func ProvideSettingService(settingRepo SettingRepository, groupRepo GroupRepository, proxyRepo ProxyRepository, cfg *config.Config) (*SettingService, error) { svc := NewSettingService(settingRepo, cfg) // Observability and Codex request compression switches are read by // package-level gates before the gateway starts. @@ -996,7 +996,6 @@ func ProvideAPIKeyService( // ProviderSet is the Wire provider set for all services var ProviderSet = wire.NewSet( - ProvideNativeFeatureBootstrap, // Core services ProvideAuthService, NewPasskeyService, diff --git a/backend/migrations/272_purge_first_party_plugins.sql b/backend/migrations/272_purge_first_party_plugins.sql new file mode 100644 index 000000000000..4a007d4a1222 --- /dev/null +++ b/backend/migrations/272_purge_first_party_plugins.sql @@ -0,0 +1,114 @@ +-- Remove what the seven first-party plugins left stored. Their features are +-- built in and the application reads nothing this migration removes. A new +-- database only loses the schema of 3. +-- +-- Removed: +-- 1. The installations codexrip.account-tools, codexrip.admin-observability, +-- codexrip.cindy-provider, codexrip.codex-runtime, codexrip.image-tools, +-- codexrip.model-policy and codexrip.prompt-skills, each with its saved +-- configuration, package bytes, manifest and capability bindings. +-- 2. settings.deplugin_retired_plugins, the receipt of their retirement. +-- 3. The schema only those plugins used: the tables sub2api_plugin_bootstrap +-- (248, 249) and sub2api_plugin_updates (250) with any row they hold; the +-- columns revision, runtime_generation, package_sha256 and update_policy, +-- the state 'updating' and the trigger and function sub2api_plugin_revision +-- (250); the limit of the unique index on enabled bindings to two +-- capabilities (246). sub2api_plugin_installations and +-- sub2api_plugin_bindings are then as 229 and 230 define them. +-- +-- Left: every other installation with its bindings, of which no row is +-- written; settings.admin_observability_config; and what is not in the +-- database, the plugin files under /plugins and the Redis keys +-- plugin:kv:v1:*. +-- +-- The migration raises, and then changes nothing, when +-- * one of the seven installations exists and the receipt is missing, is not +-- completed or has no entry for it: that installation was never retired. +-- Starting v0.2.13-codexrip.7 once retires it; +-- * another installation is in a state 229 does not have ('updating', which +-- 250 added), or two enabled bindings share a capability, platform and +-- account type: the check and the unique index of 229 do not admit them. +-- Disabling or uninstalling the plugin in v0.2.13-codexrip.7 clears both. +-- +-- Both plugin tables stay locked until the migration commits; lock_timeout +-- bounds the wait. There is no down path: an earlier image finds no receipt, +-- reads the dropped column runtime_generation and exits at start. A second +-- run removes nothing and rebuilds the same check and index. No explicit +-- BEGIN/COMMIT: the runner owns this file's transaction. +SET LOCAL lock_timeout = '10s'; +SET LOCAL statement_timeout = '120s'; + +LOCK TABLE sub2api_plugin_installations, sub2api_plugin_bindings IN ACCESS EXCLUSIVE MODE; + +DO $$ +DECLARE + first_party CONSTANT text[] := ARRAY[ + 'codexrip.account-tools', 'codexrip.admin-observability', 'codexrip.cindy-provider', + 'codexrip.codex-runtime', 'codexrip.image-tools', 'codexrip.model-policy', + 'codexrip.prompt-skills' + ]; + receipt jsonb; + refused text; +BEGIN + IF EXISTS (SELECT 1 FROM sub2api_plugin_installations WHERE plugin_key = ANY (first_party)) THEN + SELECT value::jsonb INTO receipt FROM settings WHERE key = 'deplugin_retired_plugins'; + SELECT string_agg(plugin_key, ', ' ORDER BY plugin_key) INTO refused + FROM sub2api_plugin_installations + WHERE plugin_key = ANY (first_party) + AND (receipt -> 'completed' IS DISTINCT FROM 'true'::jsonb + OR COALESCE(jsonb_typeof(receipt -> 'plugins' -> plugin_key), '') <> 'object'); + IF refused IS NOT NULL THEN + RAISE EXCEPTION 'migration 272: first-party plugin installations were never retired (%); start v0.2.13-codexrip.7 once, then upgrade', refused; + END IF; + END IF; + + SELECT string_agg(plugin_key || ' is ' || state, ', ' ORDER BY plugin_key) INTO refused + FROM sub2api_plugin_installations + WHERE plugin_key <> ALL (first_party) + AND state NOT IN ('disabled', 'starting', 'enabled', 'error', 'incompatible'); + IF refused IS NOT NULL THEN + RAISE EXCEPTION 'migration 272: plugin installations are in a state the plugin manager does not have (%); disable or uninstall them in v0.2.13-codexrip.7, then upgrade', refused; + END IF; + + SELECT string_agg(scope, ', ' ORDER BY scope) INTO refused + FROM ( + SELECT b.capability || ' ' || b.platform || '/' || b.account_type AS scope + FROM sub2api_plugin_bindings AS b + JOIN sub2api_plugin_installations AS p ON p.id = b.plugin_id + WHERE b.enabled AND p.plugin_key <> ALL (first_party) + GROUP BY b.capability, b.platform, b.account_type + HAVING count(*) > 1 + ORDER BY 1 + LIMIT 5 + ) AS shared; + IF refused IS NOT NULL THEN + RAISE EXCEPTION 'migration 272: more than one enabled plugin binding shares a scope (%); disable all but one of the plugins in v0.2.13-codexrip.7, then upgrade', refused; + END IF; + + DELETE FROM sub2api_plugin_installations WHERE plugin_key = ANY (first_party); +END $$; + +DELETE FROM settings +WHERE key = 'deplugin_retired_plugins'; + +DROP TABLE IF EXISTS sub2api_plugin_updates; +DROP TABLE IF EXISTS sub2api_plugin_bootstrap; + +DROP TRIGGER IF EXISTS sub2api_plugin_revision ON sub2api_plugin_installations; +DROP FUNCTION IF EXISTS sub2api_plugin_revision(); + +ALTER TABLE sub2api_plugin_installations + DROP COLUMN IF EXISTS revision, + DROP COLUMN IF EXISTS runtime_generation, + DROP COLUMN IF EXISTS package_sha256, + DROP COLUMN IF EXISTS update_policy; + +ALTER TABLE sub2api_plugin_installations + DROP CONSTRAINT IF EXISTS sub2api_plugin_installations_state_check, + ADD CONSTRAINT sub2api_plugin_installations_state_check + CHECK (state IN ('disabled', 'starting', 'enabled', 'error', 'incompatible')); + +DROP INDEX IF EXISTS idx_sub2api_plugin_bindings_one_enabled_scope; +CREATE UNIQUE INDEX idx_sub2api_plugin_bindings_one_enabled_scope + ON sub2api_plugin_bindings(capability, platform, account_type) + WHERE enabled = TRUE; diff --git a/frontend/src/api/__tests__/pluginRawConfig.spec.ts b/frontend/src/api/__tests__/pluginRawConfig.spec.ts deleted file mode 100644 index adb7647fc9d8..000000000000 --- a/frontend/src/api/__tests__/pluginRawConfig.spec.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { afterEach, expect, it } from 'vitest' -import { apiClient } from '../client' - -const originalAdapter = apiClient.defaults.adapter -afterEach(() => { apiClient.defaults.adapter = originalAdapter }) - -it('only explicitly marked plugin config bypasses ordinary success-envelope unwrapping', async () => { - apiClient.defaults.adapter = async config => ({ config, status: 200, statusText: 'OK', data: { code: 0, message: 'user config value', data: { nested: true } }, headers: { 'x-sub2api-plugin-revision': '4' } }) - const config = await apiClient.get('/admin/plugins/7/config', { rawPluginConfig: true }) - expect(config.data).toEqual({ code: 0, message: 'user config value', data: { nested: true } }) - expect(config.headers['x-sub2api-plugin-revision']).toBe('4') - const normal = await apiClient.get('/ordinary-resource') - expect(normal.data).toEqual({ nested: true }) -}) diff --git a/frontend/src/api/admin/plugins.ts b/frontend/src/api/admin/plugins.ts index 361a9673f080..a85c42ee6968 100644 --- a/frontend/src/api/admin/plugins.ts +++ b/frontend/src/api/admin/plugins.ts @@ -1,9 +1,5 @@ import { apiClient } from '../client' -declare module 'axios' { - interface AxiosRequestConfig { rawPluginConfig?: boolean } -} - export interface PluginCapability { id: string platform: string @@ -75,43 +71,6 @@ export interface PluginInstallation { runtime_message: string } -// Read-only view of a first-party installation the host retired when the -// feature became built in. It is never part of the managed plugin list. -export interface RetiredPluginInstallation { - id: number - plugin_key: string - name: string - version: string - description: string - author: string - manifest: PluginManifest - artifact_path: string - install_path: string - binary_path: string - binary_sha256: string - signature_status: string - state: string - last_error: string - installed_by?: number | null - installed_at: string - enabled_at?: string | null - updated_at: string - bindings: PluginBinding[] - config?: unknown - config_text?: string - config_error?: string -} - -export interface RetiredPluginsView { - receipt: { - version: number - completed: boolean - retired_at: string - plugins: Record - } | null - installations: RetiredPluginInstallation[] -} - export interface PluginTestResult { success: boolean message: string @@ -137,11 +96,6 @@ export async function list(): Promise { return data } -export async function listRetired(): Promise { - const { data } = await apiClient.get('/admin/plugins/retired') - return data -} - export async function upload(file: File): Promise { const form = new FormData() form.append('plugin', file) @@ -174,7 +128,7 @@ export async function remove(id: number): Promise { } export async function getConfig(id: number): Promise> { - const { data } = await apiClient.get>(`/admin/plugins/${id}/config`, { rawPluginConfig: true }) + const { data } = await apiClient.get>(`/admin/plugins/${id}/config`) return data } @@ -182,7 +136,7 @@ export async function saveConfig( id: number, config: Record ): Promise> { - const { data } = await apiClient.put>(`/admin/plugins/${id}/config`, config, { rawPluginConfig: true }) + const { data } = await apiClient.put>(`/admin/plugins/${id}/config`, config) return data } @@ -203,7 +157,6 @@ export async function createUISession(id: number): Promise { export default { list, - listRetired, upload, enable, disable, diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 5d90e8fed377..3f102b3bdc43 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -83,9 +83,6 @@ apiClient.interceptors.request.use( apiClient.interceptors.response.use( (response: AxiosResponse) => { settleAccountOperation(response.config) - // Plugin configuration is a raw user-defined JSON object, not our API - // envelope; a legitimate setting named "code" must stay configuration. - if (response.config.rawPluginConfig) return response // Unwrap standard API response format { code, message, data } const apiResponse = response.data as ApiResponse if (apiResponse && typeof apiResponse === 'object' && 'code' in apiResponse) { diff --git a/frontend/src/components/admin/RetiredPluginsPanel.vue b/frontend/src/components/admin/RetiredPluginsPanel.vue deleted file mode 100644 index 4c99d10fe441..000000000000 --- a/frontend/src/components/admin/RetiredPluginsPanel.vue +++ /dev/null @@ -1,123 +0,0 @@ - - - diff --git a/frontend/src/components/admin/account/ReAuthAccountModal.vue b/frontend/src/components/admin/account/ReAuthAccountModal.vue index 7696e6dbb97e..f23559cf917a 100644 --- a/frontend/src/components/admin/account/ReAuthAccountModal.vue +++ b/frontend/src/components/admin/account/ReAuthAccountModal.vue @@ -5,7 +5,6 @@ width="normal" @close="handleClose" > -

{{ t('admin.plugins.extensionUnavailable') }}

[role="status"][class~="text-red-60 color: rgb(var(--ui-danger)); } -/* ================================================================== 10 · 插件管理 (PluginsView) and retired plugins */ +/* ================================================================== 10 · 插件管理 (PluginsView) */ /* 10.1 Rhythm and head: blocks one block gap apart; the head's rule goes (the reference draws no page rule), its capability chips are the reference's neutral chips (the grouped fill, radius 4, 12px ink-soft). */ @@ -3338,28 +3338,27 @@ html.flat-theme [data-ui~="plugins"] > div[class~="border-dashed"] > p:last-of-t color: rgb(var(--ui-muted)); } -/* 10.3 Plugin cards (installed and retired): section cards; the head is the header strip (14 x 18, the line under - it) with the name 13 / 600, the version in Geist Mono 11 muted, the id and state as small chips; the facts as a - two-column definition list (12 / 18, labels muted, values ink-soft, identifiers in Geist Mono); section labels - are the mono label; JSON is the reference's snippet (the page ground, radius 6, Geist Mono 11.5 on 1.6). */ -html.flat-theme :is([data-ui~="plugins"], [data-testid="retired-plugins"]) article.card > div:first-child[class~="border-b"] { +/* 10.3 Plugin cards: section cards; the head is the header strip (14 x 18, the line under it) with the name + 13 / 600, the version in Geist Mono 11 muted, the state as a small chip; the facts as a two-column definition + list (12 / 18, labels muted, values ink-soft, identifiers in Geist Mono); section labels are the mono label. */ +html.flat-theme [data-ui~="plugins"] article.card > div:first-child[class~="border-b"] { padding: var(--set-head-py) var(--set-px); } -html.flat-theme :is([data-ui~="plugins"], [data-testid="retired-plugins"]) article.card h3 { +html.flat-theme [data-ui~="plugins"] article.card h3 { font-size: 0.8125rem; line-height: 1.21875rem; font-weight: 600; color: rgb(var(--ui-ink)); } -html.flat-theme :is([data-ui~="plugins"], [data-testid="retired-plugins"]) article.card h3 ~ span[class~="font-mono"] { +html.flat-theme [data-ui~="plugins"] article.card h3 ~ span[class~="font-mono"] { font-size: 0.6875rem; line-height: 1.03125rem; color: rgb(var(--ui-muted)); } -html.flat-theme :is([data-ui~="plugins"], [data-testid="retired-plugins"]) article.card h3 ~ span[class~="rounded"] { +html.flat-theme [data-ui~="plugins"] article.card h3 ~ span[class~="rounded"] { border-radius: var(--ui-radius-sm); padding: 0.0625rem 0.375rem; font-family: var(--ui-font-mono); @@ -3367,31 +3366,31 @@ html.flat-theme :is([data-ui~="plugins"], [data-testid="retired-plugins"]) artic line-height: 1.03125rem; } -html.flat-theme :is([data-ui~="plugins"], [data-testid="retired-plugins"]) article.card > div:first-child p { +html.flat-theme [data-ui~="plugins"] article.card > div:first-child p { font-size: 0.78125rem; line-height: 1.171875rem; } -html.flat-theme :is([data-ui~="plugins"], [data-testid="retired-plugins"]) article.card > :is(dl, div):not(:first-child)[class~="p-5"] { +html.flat-theme [data-ui~="plugins"] article.card > div:not(:first-child)[class~="p-5"] { padding: var(--set-body-py) var(--set-px); } -html.flat-theme :is([data-ui~="plugins"], [data-testid="retired-plugins"]) article.card dl { +html.flat-theme [data-ui~="plugins"] article.card dl { column-gap: 1rem; row-gap: 0.25rem; font-size: 0.75rem; line-height: 1.125rem; } -html.flat-theme :is([data-ui~="plugins"], [data-testid="retired-plugins"]) article.card dl > dt { +html.flat-theme [data-ui~="plugins"] article.card dl > dt { color: rgb(var(--ui-muted)); } -html.flat-theme :is([data-ui~="plugins"], [data-testid="retired-plugins"]) article.card dl > dd:not([class~="text-red-600"]) { +html.flat-theme [data-ui~="plugins"] article.card dl > dd { color: rgb(var(--ui-ink-soft)); } -html.flat-theme :is([data-ui~="plugins"], [data-testid="retired-plugins"]) :is(article.card, [data-testid="retired-plugins-receipt"]) p[class~="uppercase"][class~="text-xs"] { +html.flat-theme [data-ui~="plugins"] article.card p[class~="uppercase"][class~="text-xs"] { font-family: var(--ui-font-mono); font-size: var(--ui-label-size); line-height: var(--ui-label-leading); @@ -3400,49 +3399,10 @@ html.flat-theme :is([data-ui~="plugins"], [data-testid="retired-plugins"]) :is(a color: rgb(var(--ui-label)); } -html.flat-theme :is([data-ui~="plugins"], [data-testid="retired-plugins"]) :is(article.card, [data-testid="retired-plugins-receipt"]) pre { - margin-top: 0.5rem; - border-radius: var(--ui-radius-md); - background-color: rgb(var(--ui-canvas)); - padding: 0.75rem; - font-size: 0.71875rem; - line-height: 1.6; - color: rgb(var(--ui-ink-soft)); -} - -/* the retired section's title: a section title (13 / 600, as the card titles) with its description, no rule (the - cards carry lines) */ -html.flat-theme [data-testid="retired-plugins"] > div:first-child { - border-bottom: 0; - padding-bottom: 0; -} - -html.flat-theme [data-testid="retired-plugins"] > div:first-child > h2 { - font-size: 0.8125rem; - line-height: 1.21875rem; - font-weight: 600; - color: rgb(var(--ui-ink)); -} - -html.flat-theme [data-testid="retired-plugins"] > div:first-child > p { - margin-top: 0.125rem; - font-size: 0.78125rem; - line-height: 1.171875rem; - color: rgb(var(--ui-muted)); -} - -html.flat-theme :is([data-ui~="plugins"], [data-testid="retired-plugins"]) > [class~="grid"] { +html.flat-theme [data-ui~="plugins"] > [class~="grid"] { gap: var(--ui-shell-gap); } -html.flat-theme [data-testid="retired-plugins"] > :not([hidden]) ~ :not([hidden]) { - margin-top: var(--ui-shell-gap); -} - -html.flat-theme [data-testid="retired-plugins-receipt"] { - padding: var(--set-body-py) var(--set-px); -} - /* ================================================================== 10b · the dialogs drawn inside the settings page */ /* The affiliate add / edit and batch-rate dialogs and the web search test dialog are built from utilities inside the diff --git a/frontend/src/views/admin/PluginsView.vue b/frontend/src/views/admin/PluginsView.vue index 0edb19e1340f..1774a1896428 100644 --- a/frontend/src/views/admin/PluginsView.vue +++ b/frontend/src/views/admin/PluginsView.vue @@ -284,8 +284,6 @@
- - ({ adminAPI: { plugins: { list: listPlugins, - listRetired: vi.fn().mockResolvedValue({ receipt: null, installations: [] }), upload: uploadPlugin, enable: enablePlugin, disable: vi.fn(), From 21fb0a1a087445220313911e96816d86eb8c8a09 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 5 Oct 2026 13:16:30 +0800 Subject: [PATCH 2/5] refactor(plugins): restore upstream's plugin contract and remove what first-party plugins left in live code Review of the retired-plugin removal found files of upstream's plugin system that were still in their first-party form, and plugin-era code that survived under other names. Upstream form restored, byte for byte - pkg/pluginapi/v1/manifest.schema.json and runtime.go. The schema advertised ten extensions.*.v1 capabilities and five manifest fields the host rejects; runtime.go carried a Connection field and an AdditionalServiceRegistrar hook nothing used. - pkg/proxyutil/dialer.go. internal/proxytransport, which existed to be shared with plugin processes and had this one caller, is deleted. No caller passes a transport with Proxy set, so the SOCKS branch behaves as before. - provideCleanup stops the plugin manager as a parallel step again instead of after all of them; wire_gen.go is regenerated. The account job runtime keeps its own step. - modelsListCacheKey and InvalidateAvailableModelsCache. Cache keys have had two parts since the provider namespace went, so the three-part matching selected nothing more than upstream's does. - the /plugins/ comments of .dockerignore and .gitignore. Deleted - service/testdata/plugin_contribution_buckets.json, which no test read; - the .dockerignore exception for deploy/plugin-bundle/README.md, which no Dockerfile copies. Comments and DOWNSTREAM.md no longer explain current code by the plugin configuration or packaging that preceded it. deploy/plugin-bundle/README.md is left: deploy/ is outside this change. Co-Authored-By: Claude Opus 5.5 --- .dockerignore | 3 +- .gitignore | 2 +- DOWNSTREAM.md | 3 +- backend/cmd/server/wire.go | 10 +- backend/cmd/server/wire_gen.go | 10 +- .../internal/handler/admin/setting_handler.go | 2 +- .../handler/openai_gateway_handler.go | 2 +- backend/internal/pkg/proxyutil/dialer.go | 42 +- backend/internal/proxytransport/transport.go | 48 - backend/internal/service/gateway_service.go | 15 +- .../service/setting_admin_observability.go | 3 +- backend/internal/service/setting_json.go | 4 +- .../testdata/plugin_contribution_buckets.json | 26 - backend/pkg/pluginapi/v1/manifest.schema.json | 1357 +---------------- backend/pkg/pluginapi/v1/runtime.go | 14 +- frontend/vitest.config.ts | 2 +- 16 files changed, 96 insertions(+), 1447 deletions(-) delete mode 100644 backend/internal/proxytransport/transport.go delete mode 100644 backend/internal/service/testdata/plugin_contribution_buckets.json diff --git a/.dockerignore b/.dockerignore index 7077eba7722e..006dcbf4259b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,7 +10,6 @@ # Documentation *.md !deploy/DOCKER.md -!deploy/plugin-bundle/README.md !THIRD_PARTY_NOTICES.md docs/ # admin-compliance gate (LegalDocumentView.vue) build-time imports @@ -51,7 +50,7 @@ coverage/ .env.* !.env.example -# Local third-party packages and credentials do not enter the host image. +# 本地闭源插件目录可能包含发布签名私钥,绝不能进入 Docker 构建上下文。 /plugins/ # Local config diff --git a/.gitignore b/.gitignore index 9dac6630cf03..0fb7a07a0915 100644 --- a/.gitignore +++ b/.gitignore @@ -165,5 +165,5 @@ issues/ # Vitest / Vite cache at repo root .vite/ -# Installed packages and local third-party projects are not versioned. +# 本地闭源插件开发目录及构建产物不进入 Sub2API 仓库 /plugins/ diff --git a/DOWNSTREAM.md b/DOWNSTREAM.md index 704e1c37d955..de3175c88c13 100644 --- a/DOWNSTREAM.md +++ b/DOWNSTREAM.md @@ -60,8 +60,7 @@ The three `238_*` migrations retain separate filenames and checksums. - New releases use immutable `vX.Y.Z-codexrip.N` tags on `main`. - Images use the tag without `v`: `ghcr.io/htexplicit/sub2api:X.Y.Z-codexrip.N`. - Downstream Release authenticates to GHCR, verifies source/build materials and - publishes the native host image digest and provenance. It reuses PR validation; - first-party package signing and separate plugin release assets are retired. + publishes the native host image digest and provenance. It reuses PR validation. - Production Deploy resolves the fixed digest through the existing restricted SSH updater. Ordinary updates use `operation=deploy-preserve`, preserving runtime settings and resources, naturally draining requests, and rebuilding only Sub2API. diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go index 15caf8197481..5128eb110bf1 100644 --- a/backend/cmd/server/wire.go +++ b/backend/cmd/server/wire.go @@ -149,6 +149,7 @@ func provideCleanup( return func() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() + type cleanupStep struct { name string fn func() error @@ -156,6 +157,12 @@ func provideCleanup( // 应用层清理步骤可并行执行,基础设施资源(Redis/Ent)最后按顺序关闭。 parallelSteps := []cleanupStep{ + {"PluginManager", func() error { + if pluginManager != nil { + pluginManager.Stop() + } + return nil + }}, {"AccountJobRuntime", func() error { if accountJobs != nil { accountJobs.Stop() @@ -469,9 +476,6 @@ func provideCleanup( } runParallel(parallelSteps) - if pluginManager != nil { - pluginManager.Stop() - } runSequential(infraSteps) // Check if context timed out diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index bf5278a921ad..fddf3dbca8cf 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -480,12 +480,19 @@ func provideCleanup( return func() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() + type cleanupStep struct { name string fn func() error } parallelSteps := []cleanupStep{ + {"PluginManager", func() error { + if pluginManager != nil { + pluginManager.Stop() + } + return nil + }}, {"AccountJobRuntime", func() error { if accountJobs != nil { accountJobs.Stop() @@ -798,9 +805,6 @@ func provideCleanup( } runParallel(parallelSteps) - if pluginManager != nil { - pluginManager.Stop() - } runSequential(infraSteps) select { diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 35918b5702ba..870e7d7ad1ae 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -117,7 +117,7 @@ func (h *SettingHandler) GetObservabilitySettings(c *gin.Context) { } // UpdateObservabilitySettings saves the account traffic telemetry and flat theme -// switches. Omitted switches stay on, as in the former plugin configuration. +// switches. Omitted switches stay on. // PUT /api/v1/admin/settings/observability func (h *SettingHandler) UpdateObservabilitySettings(c *gin.Context) { req := extensionv1.AdminObservabilityConfig{TelemetryEnabled: true, ThemeEnabled: true} diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 4a87de6d2432..0cfb0a87b226 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -553,7 +553,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { } } // Only authenticated middleware identity may own the response registered - // by the existing HTTP response handler; body/plugin fields are not authority. + // by the existing HTTP response handler; request body fields are not authority. service.SetOpenAIHTTPResponseOwner(c, subject.UserID, apiKey.ID) ensureCompositeTargetPlatform(c, apiKey, reqModel) diff --git a/backend/internal/pkg/proxyutil/dialer.go b/backend/internal/pkg/proxyutil/dialer.go index 3c9bbea29abb..3e135e3eb9a8 100644 --- a/backend/internal/pkg/proxyutil/dialer.go +++ b/backend/internal/pkg/proxyutil/dialer.go @@ -10,17 +10,22 @@ package proxyutil import ( - proxytransport "github.com/Wei-Shaw/sub2api/internal/proxytransport" + "context" + "fmt" "net" "net/http" "net/url" + "strings" + "time" + + "golang.org/x/net/proxy" ) const ( // socks5DialTimeout 限制到 SOCKS5 代理自身的 TCP 建连耗时。 - socks5DialTimeout = proxytransport.SOCKSDialTimeout + socks5DialTimeout = 10 * time.Second // socks5DialKeepAlive 与 Go 默认 keepalive 探测间隔保持一致。 - socks5DialKeepAlive = proxytransport.SOCKSKeepAlive + socks5DialKeepAlive = 30 * time.Second ) // socks5ForwardDialer 是 SOCKS5 dialer 的底层拨号器。 @@ -48,5 +53,34 @@ var socks5ForwardDialer = &net.Dialer{ // 返回: // - error: 代理配置错误(协议不支持或 dialer 创建失败) func ConfigureTransportProxy(transport *http.Transport, proxyURL *url.URL) error { - return proxytransport.Configure(transport, proxyURL, socks5ForwardDialer) + if proxyURL == nil { + return nil + } + + scheme := strings.ToLower(proxyURL.Scheme) + switch scheme { + case "http", "https": + transport.Proxy = http.ProxyURL(proxyURL) + return nil + + case "socks5", "socks5h": + dialer, err := proxy.FromURL(proxyURL, socks5ForwardDialer) + if err != nil { + return fmt.Errorf("create socks5 dialer: %w", err) + } + // 优先使用支持 context 的 DialContext,以支持请求取消和超时 + if contextDialer, ok := dialer.(proxy.ContextDialer); ok { + transport.DialContext = contextDialer.DialContext + } else { + // 回退路径:如果 dialer 不支持 ContextDialer,则包装为简单的 DialContext + // 注意:此回退不支持请求取消和超时控制 + transport.DialContext = func(_ context.Context, network, addr string) (net.Conn, error) { + return dialer.Dial(network, addr) + } + } + return nil + + default: + return fmt.Errorf("unsupported proxy scheme: %s", scheme) + } } diff --git a/backend/internal/proxytransport/transport.go b/backend/internal/proxytransport/transport.go deleted file mode 100644 index fe08cbac4887..000000000000 --- a/backend/internal/proxytransport/transport.go +++ /dev/null @@ -1,48 +0,0 @@ -// Package proxytransport shares bounded, cancellable proxy transport primitives -// between the host and independent plugins, without provider-specific policy. -package proxytransport - -import ( - "fmt" - "net" - "net/http" - "net/url" - "strings" - "time" - - "golang.org/x/net/proxy" -) - -const SOCKSDialTimeout = 10 * time.Second -const SOCKSKeepAlive = 30 * time.Second - -func Configure(transport *http.Transport, address *url.URL, forward proxy.Dialer) error { - if transport == nil { - return fmt.Errorf("proxy transport is nil") - } - if address == nil { - return nil - } - switch strings.ToLower(address.Scheme) { - case "http", "https": - transport.Proxy = http.ProxyURL(address) - return nil - case "socks5", "socks5h": - if forward == nil { - forward = &net.Dialer{Timeout: SOCKSDialTimeout, KeepAlive: SOCKSKeepAlive} - } - dialer, err := proxy.FromURL(address, forward) - if err != nil { - return fmt.Errorf("create SOCKS dialer: %w", err) - } - contextDialer, ok := dialer.(proxy.ContextDialer) - if !ok { - return fmt.Errorf("SOCKS dialer does not support cancellation") - } - transport.Proxy = nil - transport.DialContext = contextDialer.DialContext - return nil - default: - return fmt.Errorf("unsupported proxy scheme: %s", address.Scheme) - } -} diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 68f39ca5d5c0..31ad0947276c 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -551,8 +551,7 @@ func resolveModelsListCacheTTL(cfg *config.Config) time.Duration { } func modelsListCacheKey(groupID *int64, platform string) string { - platform = strings.TrimSpace(platform) - return fmt.Sprintf("%d|%s", derefGroupID(groupID), platform) + return fmt.Sprintf("%d|%s", derefGroupID(groupID), strings.TrimSpace(platform)) } func compositeModelOwnershipCacheKey(groupID int64, model string) string { @@ -1632,12 +1631,16 @@ func (s *GatewayService) InvalidateAvailableModelsCache(groupID *int64, platform s.invalidateCompositeModelOwnershipCache(groupID) normalizedPlatform := strings.TrimSpace(platform) - // Match the stable dimensions, not a freshly queried provider namespace. - // This also removes any older namespaced entry without consulting a plugin. + // 完整匹配时精准失效;否则按维度批量失效。 + if groupID != nil && normalizedPlatform != "" { + s.modelsListCache.Delete(modelsListCacheKey(groupID, normalizedPlatform)) + return + } + targetGroup := derefGroupID(groupID) for key := range s.modelsListCache.Items() { - parts := strings.SplitN(key, "|", 3) - if len(parts) < 2 { + parts := strings.SplitN(key, "|", 2) + if len(parts) != 2 { continue } groupPart, parseErr := strconv.ParseInt(parts[0], 10, 64) diff --git a/backend/internal/service/setting_admin_observability.go b/backend/internal/service/setting_admin_observability.go index 2e123dba2c60..78af5ac845dc 100644 --- a/backend/internal/service/setting_admin_observability.go +++ b/backend/internal/service/setting_admin_observability.go @@ -7,8 +7,7 @@ import ( ) // SettingKeyAdminObservabilityConfig stores the account traffic telemetry and -// flat theme switches formerly kept in the admin-observability plugin -// configuration. +// flat theme switches. const SettingKeyAdminObservabilityConfig = "admin_observability_config" // EffectiveAdminObservabilityConfig returns the switches this process applies. diff --git a/backend/internal/service/setting_json.go b/backend/internal/service/setting_json.go index d2ebcd03a99f..911381f004d7 100644 --- a/backend/internal/service/setting_json.go +++ b/backend/internal/service/setting_json.go @@ -43,8 +43,8 @@ func (s *SettingService) writeJSONSetting(ctx context.Context, key string, value } // DecodeSwitchSettings decodes a JSON object of boolean switches into target. -// Unknown keys and non-boolean values are rejected, as the former plugin -// configuration validators did; omitted switches keep target's values. +// Unknown keys and non-boolean values are rejected; omitted switches keep +// target's values. func DecodeSwitchSettings(raw []byte, target any, allowed ...string) error { var fields map[string]json.RawMessage if err := json.Unmarshal(raw, &fields); err != nil || fields == nil { diff --git a/backend/internal/service/testdata/plugin_contribution_buckets.json b/backend/internal/service/testdata/plugin_contribution_buckets.json deleted file mode 100644 index 9ad2c1949e2f..000000000000 --- a/backend/internal/service/testdata/plugin_contribution_buckets.json +++ /dev/null @@ -1,26 +0,0 @@ -[ - { - "id": 1, - "bucket": 34 - }, - { - "id": 2, - "bucket": 1 - }, - { - "id": 3, - "bucket": 52 - }, - { - "id": 42, - "bucket": 76 - }, - { - "id": 1000, - "bucket": 99 - }, - { - "id": 9007199254740991, - "bucket": 67 - } -] diff --git a/backend/pkg/pluginapi/v1/manifest.schema.json b/backend/pkg/pluginapi/v1/manifest.schema.json index 256e0a7ac95c..e23780ff8f30 100644 --- a/backend/pkg/pluginapi/v1/manifest.schema.json +++ b/backend/pkg/pluginapi/v1/manifest.schema.json @@ -4,82 +4,25 @@ "title": "Sub2API Plugin Manifest v1", "type": "object", "additionalProperties": false, - "required": [ - "schema_version", - "id", - "name", - "version", - "requires", - "capabilities", - "runtimes", - "ui", - "files" - ], + "required": ["schema_version", "id", "name", "version", "requires", "capabilities", "runtimes", "ui", "files"], "properties": { - "schema_version": { - "const": 1 - }, - "source_revision": { - "type": "string", - "pattern": "^[a-f0-9]{40}$" - }, - "id": { - "type": "string", - "maxLength": 160, - "pattern": "^[a-z0-9]+([._-][a-z0-9]+)+$" - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 160 - }, - "version": { - "type": "string", - "pattern": "^v?(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\\.[0-9A-Za-z-]+)*)?(\\+[0-9A-Za-z-]+(\\.[0-9A-Za-z-]+)*)?$" - }, - "description": { - "type": "string" - }, - "author": { - "type": "string", - "maxLength": 160 - }, + "schema_version": { "const": 1 }, + "id": { "type": "string", "maxLength": 160, "pattern": "^[a-z0-9]+([._-][a-z0-9]+)+$" }, + "name": { "type": "string", "minLength": 1, "maxLength": 160 }, + "version": { "type": "string", "pattern": "^v?(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\\.[0-9A-Za-z-]+)*)?(\\+[0-9A-Za-z-]+(\\.[0-9A-Za-z-]+)*)?$" }, + "description": { "type": "string" }, + "author": { "type": "string", "maxLength": 160 }, "requires": { "type": "object", "additionalProperties": false, - "required": [ - "sub2api", - "plugin_protocol", - "transport_api", - "ui_bridge" - ], + "required": ["sub2api", "plugin_protocol", "transport_api", "ui_bridge"], "properties": { - "sub2api": { - "type": "string", - "minLength": 1 - }, - "recommended_sub2api_version": { - "type": "string" - }, - "tested_sub2api_versions": { - "type": "array", - "items": { - "type": "string" - }, - "uniqueItems": true - }, - "plugin_protocol": { - "const": 1 - }, - "transport_api": { - "const": 1 - }, - "ui_bridge": { - "const": 1 - }, - "extension_api": { - "const": 1 - } + "sub2api": { "type": "string", "minLength": 1 }, + "recommended_sub2api_version": { "type": "string" }, + "tested_sub2api_versions": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }, + "plugin_protocol": { "const": 1 }, + "transport_api": { "const": 1 }, + "ui_bridge": { "const": 1 } } }, "capabilities": { @@ -88,597 +31,14 @@ "items": { "type": "object", "additionalProperties": false, - "required": [ - "id", - "platform", - "account_type" - ], + "required": ["id", "platform", "account_type"], "properties": { - "id": { - "enum": [ - "openai.oauth.outbound_transport.v1", - "extensions.provider.v1", - "extensions.catalog.v1", - "extensions.request.v1", - "extensions.scheduling.v1", - "extensions.jobs.v1", - "extensions.admin.v1", - "extensions.ui.v1", - "extensions.credentials.v1", - "extensions.observability.v1", - "extensions.recovery.v1" - ] - }, - "platform": { - "type": "string", - "minLength": 1 - }, - "account_type": { - "type": "string", - "minLength": 1 - } - } - } - }, - "operations": { - "type": "object", - "additionalProperties": { - "type": "array", - "maxItems": 64, - "uniqueItems": true, - "items": { - "type": "string", - "minLength": 1, - "maxLength": 80, - "pattern": "^[a-z0-9]+([._-][a-z0-9]+)*$" + "id": { "const": "openai.oauth.outbound_transport.v1" }, + "platform": { "const": "openai" }, + "account_type": { "const": "oauth" } } } }, - "resources": { - "type": "array", - "maxItems": 256, - "items": { - "type": "object", - "additionalProperties": false, - "required": [ - "name", - "capability", - "permission" - ], - "properties": { - "name": { - "type": "string", - "maxLength": 100, - "pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*(\\.[a-z][a-z0-9]*(-[a-z0-9]+)*)+$" - }, - "capability": { - "type": "string", - "minLength": 1 - }, - "permission": { - "enum": [ - "admin", - "user" - ] - } - } - } - }, - "dependencies": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": [ - "capability" - ], - "properties": { - "capability": { - "type": "string" - }, - "platform": { - "type": "string" - }, - "account_type": { - "type": "string" - } - } - } - }, - "contributions": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": [ - "id", - "slot", - "label", - "permission" - ], - "properties": { - "id": { - "type": "string", - "minLength": 1 - }, - "capability": { - "type": "string", - "minLength": 1 - }, - "config_flag": { - "type": "string", - "pattern": "^[a-z][a-z0-9_]{0,63}$" - }, - "retained_controls": { - "type": "boolean" - }, - "all_accounts": { - "type": "boolean" - }, - "display_fields": { - "type": "array", - "maxItems": 16, - "items": { - "type": "object", - "additionalProperties": false, - "required": [ - "key", - "kind" - ], - "properties": { - "key": { - "type": "string", - "pattern": "^[a-z][a-z0-9_]{0,63}$" - }, - "kind": { - "enum": [ - "text", - "badge", - "datetime" - ] - }, - "prefix": { - "type": "string", - "maxLength": 32 - }, - "new_line": { - "type": "boolean" - }, - "values": { - "type": "object", - "maxProperties": 64, - "additionalProperties": { - "type": "object", - "additionalProperties": false, - "required": [ - "label" - ], - "properties": { - "label": { - "type": "object", - "minProperties": 1, - "additionalProperties": { - "type": "string" - } - }, - "tone": { - "enum": [ - "neutral", - "success", - "warning", - "danger", - "info", - "" - ] - } - } - } - } - } - } - }, - "events": { - "type": "array", - "maxItems": 16, - "items": { - "type": "string", - "pattern": "^[a-z][a-z0-9_-]{0,63}$" - } - }, - "assets": { - "type": "array", - "maxItems": 32, - "uniqueItems": true, - "items": { - "type": "string", - "pattern": "^ui/.+\\.woff2$" - } - }, - "slot": { - "enum": [ - "admin.page", - "admin.settings", - "account.actions", - "account.details", - "account.columns", - "account.test", - "account.test.prompt", - "group.actions", - "group.details", - "navigation", - "theme", - "usage.details", - "surface", - "account.view.v1", - "account.create.v1", - "account.edit.v1" - ] - }, - "label": { - "type": "object", - "minProperties": 1, - "additionalProperties": { - "type": "string" - } - }, - "permission": { - "type": "string", - "minLength": 1 - }, - "action": { - "type": "string" - }, - "entrypoint": { - "type": "string", - "pattern": "^ui/.+" - }, - "order": { - "type": "integer" - }, - "fields": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": [ - "key", - "kind", - "label" - ], - "properties": { - "key": { - "type": "string", - "pattern": "^[a-z][a-z0-9_]{0,63}$" - }, - "kind": { - "enum": [ - "select", - "textarea", - "text" - ] - }, - "rows": { - "type": "integer", - "minimum": 1, - "maximum": 12 - }, - "max_length": { - "type": "integer", - "minimum": 1, - "maximum": 65536 - }, - "placeholder": { - "type": "string" - }, - "hint": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "reset_label": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "limit_message": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "label": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "options_source": { - "type": "string", - "pattern": "^[a-z][a-z0-9_]{0,63}$" - }, - "default_label": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "default_source": { - "type": "string", - "pattern": "^[a-z][a-z0-9_]{0,63}$" - } - }, - "allOf": [ - { - "if": { - "properties": { - "kind": { - "const": "select" - } - } - }, - "then": { - "required": [ - "options_source", - "default_label" - ] - } - }, - { - "if": { - "properties": { - "kind": { - "const": "textarea" - } - } - }, - "then": { - "required": [ - "rows", - "max_length" - ] - } - } - ] - } - }, - "account_filter": { - "type": "object", - "additionalProperties": false, - "properties": { - "platforms": { - "type": "array", - "items": { - "type": "string" - } - }, - "types": { - "type": "array", - "items": { - "type": "string" - } - }, - "statuses": { - "type": "array", - "items": { - "type": "string" - } - }, - "exclude_shadows": { - "type": "boolean" - } - } - }, - "account_view": { - "$ref": "#/$defs/accountView" - }, - "value_bindings": { - "type": "object", - "maxProperties": 16, - "propertyNames": { - "type": "string", - "pattern": "^[a-z][a-z0-9_]{0,63}$" - }, - "additionalProperties": { - "enum": [ - "cindy_balance_probe_job_id", - "cindy_balance_probe_outcome", - "cindy_balance_probe_checked_at" - ] - } - }, - "resource_action": { - "type": "object", - "additionalProperties": false, - "required": [ - "version", - "resource", - "account_parameter", - "account_source", - "effect" - ], - "properties": { - "version": { - "const": 1 - }, - "resource": { - "type": "string", - "minLength": 1, - "maxLength": 100, - "pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*(\\.[a-z][a-z0-9]*(-[a-z0-9]+)*)+$" - }, - "account_parameter": { - "const": "id" - }, - "account_source": { - "const": "row.id" - }, - "row_predicate": { - "$ref": "#/$defs/accountViewPredicate" - }, - "effect": { - "const": "refresh_current_account_view" - } - } - }, - "account_create": { - "$ref": "#/$defs/accountCreate" - }, - "account_edit": { - "$ref": "#/$defs/accountEdit" - } - }, - "allOf": [ - { - "if": {"properties": {"slot": {"const": "account.edit.v1"}}}, - "then": { - "required": ["account_edit", "capability"], - "properties": { - "permission": {"const": "admin"}, - "capability": {"const": "extensions.provider.v1"}, - "entrypoint": {"maxLength": 0}, "action": {"maxLength": 0}, - "all_accounts": {"const": false}, "retained_controls": {"const": false}, - "fields": {"maxItems": 0}, "display_fields": {"maxItems": 0}, - "assets": {"maxItems": 0}, "events": {"maxItems": 0}, - "value_bindings": {"maxProperties": 0} - }, - "not": {"anyOf": [{"required": ["account_filter"]}, {"required": ["resource_action"]}]} - }, - "else": {"not": {"required": ["account_edit"]}} - }, - { - "if": { - "properties": { - "slot": { - "const": "account.view.v1" - } - } - }, - "then": { - "required": [ - "account_view", - "capability" - ], - "properties": { - "permission": { - "const": "admin" - }, - "entrypoint": { - "maxLength": 0 - }, - "action": { - "maxLength": 0 - }, - "retained_controls": { - "const": false - } - } - }, - "else": { - "not": { - "required": [ - "account_view" - ] - } - } - }, - { - "if": { - "required": [ - "resource_action" - ] - }, - "then": { - "required": [ - "capability" - ], - "properties": { - "slot": { - "const": "account.actions" - }, - "permission": { - "const": "admin" - }, - "entrypoint": { - "maxLength": 0 - }, - "action": { - "maxLength": 0 - } - } - } - }, - { - "if": { - "required": [ - "value_bindings" - ] - }, - "then": { - "properties": { - "slot": { - "const": "account.columns" - }, - "permission": { - "const": "admin" - } - } - } - }, - { - "if": { - "properties": { - "slot": { - "const": "account.create.v1" - } - } - }, - "then": { - "required": [ - "account_create", - "capability" - ], - "properties": { - "permission": { - "const": "admin" - }, - "capability": { - "const": "extensions.provider.v1" - }, - "entrypoint": { - "maxLength": 0 - }, - "action": { - "maxLength": 0 - }, - "all_accounts": { - "const": false - }, - "retained_controls": { - "const": false - }, - "fields": { - "maxItems": 8 - } - }, - "not": { - "required": [ - "account_filter" - ] - } - }, - "else": { - "not": { - "required": [ - "account_create" - ] - } - } - } - ] - } - }, "runtimes": { "type": "object", "minProperties": 1, @@ -686,14 +46,8 @@ "^[a-z0-9]+-[a-z0-9]+$": { "type": "object", "additionalProperties": false, - "required": [ - "path" - ], - "properties": { - "path": { - "$ref": "#/$defs/safePath" - } - } + "required": ["path"], + "properties": { "path": { "$ref": "#/$defs/safePath" } } } }, "additionalProperties": false @@ -701,26 +55,14 @@ "ui": { "type": "object", "additionalProperties": false, - "required": [ - "entrypoint" - ], - "properties": { - "entrypoint": { - "type": "string", - "pattern": "^ui/.+" - } - } + "required": ["entrypoint"], + "properties": { "entrypoint": { "type": "string", "pattern": "^ui/.+" } } }, "files": { "type": "object", "minProperties": 2, - "propertyNames": { - "$ref": "#/$defs/safePath" - }, - "additionalProperties": { - "type": "string", - "pattern": "^[a-f0-9]{64}$" - } + "propertyNames": { "$ref": "#/$defs/safePath" }, + "additionalProperties": { "type": "string", "pattern": "^[a-f0-9]{64}$" } } }, "$defs": { @@ -728,657 +70,6 @@ "type": "string", "minLength": 1, "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))(?!.*\\\\).+$" - }, - "accountView": { - "type": "object", - "additionalProperties": false, - "required": [ - "version", - "source", - "base_query", - "default_preset", - "presets", - "layout", - "table" - ], - "properties": { - "version": { - "const": 1 - }, - "source": { - "const": "accounts.console.v1" - }, - "base_query": { - "type": "object", - "additionalProperties": false, - "properties": { - "platforms": { - "type": "array", - "maxItems": 32, - "uniqueItems": true, - "items": { - "type": "string", - "minLength": 1, - "maxLength": 64 - } - }, - "types": { - "type": "array", - "maxItems": 32, - "uniqueItems": true, - "items": { - "type": "string", - "minLength": 1, - "maxLength": 64 - } - }, - "statuses": { - "type": "array", - "maxItems": 32, - "uniqueItems": true, - "items": { - "type": "string", - "minLength": 1, - "maxLength": 64 - } - }, - "plans": { - "type": "array", - "maxItems": 32, - "uniqueItems": true, - "items": { - "type": "string", - "minLength": 1, - "maxLength": 64 - } - }, - "privacy_mode": { - "type": "string", - "minLength": 1, - "maxLength": 64 - }, - "cindy_only": { - "type": "boolean" - }, - "cindy_balance_status": { - "enum": [ - "insufficient" - ] - }, - "cindy_health_status": { - "enum": [ - "banned" - ] - } - } - }, - "default_preset": { - "type": "string", - "pattern": "^[a-z][a-z0-9._-]{0,63}$" - }, - "extends_core_filters": { - "type": "boolean" - }, - "presets": { - "type": "array", - "minItems": 1, - "maxItems": 16, - "items": { - "type": "object", - "additionalProperties": false, - "required": [ - "id", - "label", - "query", - "counter" - ], - "properties": { - "id": { - "type": "string", - "pattern": "^[a-z][a-z0-9._-]{0,63}$" - }, - "label": { - "type": "object", - "minProperties": 1, - "maxProperties": 16, - "additionalProperties": { - "type": "string", - "minLength": 1, - "maxLength": 256 - } - }, - "query": { - "type": "object", - "additionalProperties": false, - "properties": { - "platforms": { - "type": "array", - "maxItems": 32, - "uniqueItems": true, - "items": { - "type": "string", - "minLength": 1, - "maxLength": 64 - } - }, - "types": { - "type": "array", - "maxItems": 32, - "uniqueItems": true, - "items": { - "type": "string", - "minLength": 1, - "maxLength": 64 - } - }, - "statuses": { - "type": "array", - "maxItems": 32, - "uniqueItems": true, - "items": { - "type": "string", - "minLength": 1, - "maxLength": 64 - } - }, - "plans": { - "type": "array", - "maxItems": 32, - "uniqueItems": true, - "items": { - "type": "string", - "minLength": 1, - "maxLength": 64 - } - }, - "privacy_mode": { - "type": "string", - "minLength": 1, - "maxLength": 64 - }, - "cindy_only": { - "type": "boolean" - }, - "cindy_balance_status": { - "enum": [ - "insufficient" - ] - }, - "cindy_health_status": { - "enum": [ - "banned" - ] - } - } - }, - "counter": { - "enum": [ - "cindy_total", - "cindy_insufficient_count", - "cindy_banned_count" - ] - } - } - } - }, - "layout": { - "type": "array", - "minItems": 1, - "maxItems": 16, - "items": { - "oneOf": [ - { - "type": "object", - "additionalProperties": false, - "required": [ - "kind" - ], - "properties": { - "kind": { - "const": "account_table" - } - } - }, - { - "type": "object", - "additionalProperties": false, - "required": [ - "kind", - "surface_ref" - ], - "properties": { - "kind": { - "const": "surface" - }, - "surface_ref": { - "type": "string", - "pattern": "^[a-z][a-z0-9._-]{0,63}$" - } - } - } - ] - } - }, - "table": { - "type": "object", - "additionalProperties": false, - "required": [ - "layouts", - "column_refs", - "action_refs" - ], - "properties": { - "layouts": { - "type": "array", - "minItems": 1, - "maxItems": 3, - "uniqueItems": true, - "items": { - "enum": [ - "table", - "compact", - "cards" - ] - } - }, - "column_refs": { - "type": "array", - "maxItems": 32, - "uniqueItems": true, - "items": { - "type": "string", - "pattern": "^[a-z][a-z0-9._-]{0,63}$" - } - }, - "action_refs": { - "type": "array", - "maxItems": 32, - "uniqueItems": true, - "items": { - "type": "string", - "pattern": "^[a-z][a-z0-9._-]{0,63}$" - } - } - } - }, - "navigation": { - "type": "object", - "additionalProperties": false, - "required": [ - "section", - "target_view", - "icon" - ], - "properties": { - "section": { - "const": "admin.extensions" - }, - "target_view": { - "type": "string", - "pattern": "^[a-z][a-z0-9._-]{0,63}$" - }, - "icon": { - "const": "accounts" - }, - "order": { - "type": "integer", - "minimum": -10000, - "maximum": 10000 - } - } - }, - "legacy_query_aliases": { - "type": "array", - "maxItems": 8, - "items": { - "type": "object", - "additionalProperties": false, - "required": [ - "match", - "preset", - "priority" - ], - "properties": { - "match": { - "type": "object", - "additionalProperties": false, - "minProperties": 1, - "properties": { - "cindy_only": { - "const": "true" - }, - "cindy_balance_status": { - "const": "insufficient" - }, - "cindy_health_status": { - "const": "banned" - } - } - }, - "preset": { - "type": "string", - "pattern": "^[a-z][a-z0-9._-]{0,63}$" - }, - "priority": { - "type": "integer", - "minimum": 0, - "maximum": 1000 - } - } - } - }, - "core_filter_surface_refs": { - "type": "array", - "maxItems": 4, - "uniqueItems": true, - "items": { - "type": "string", - "pattern": "^[a-z][a-z0-9._-]{0,63}$" - } - } - }, - "allOf": [ - { - "if": { - "required": [ - "core_filter_surface_refs" - ], - "properties": { - "core_filter_surface_refs": { - "minItems": 1 - } - } - }, - "then": { - "required": [ - "extends_core_filters" - ], - "properties": { - "extends_core_filters": { - "const": true - } - } - } - } - ] - }, - "accountViewPredicate": { - "type": "object", - "additionalProperties": false, - "properties": { - "platforms": { - "type": "array", - "maxItems": 32, - "uniqueItems": true, - "items": { - "type": "string", - "minLength": 1, - "maxLength": 64 - } - }, - "types": { - "type": "array", - "maxItems": 32, - "uniqueItems": true, - "items": { - "type": "string", - "minLength": 1, - "maxLength": 64 - } - }, - "statuses": { - "type": "array", - "maxItems": 32, - "uniqueItems": true, - "items": { - "type": "string", - "minLength": 1, - "maxLength": 64 - } - }, - "plans": { - "type": "array", - "maxItems": 32, - "uniqueItems": true, - "items": { - "type": "string", - "minLength": 1, - "maxLength": 64 - } - }, - "privacy_mode": { - "type": "string", - "minLength": 1, - "maxLength": 64 - }, - "cindy_only": { - "type": "boolean" - }, - "cindy_balance_status": { - "enum": [ - "insufficient" - ] - }, - "cindy_health_status": { - "enum": [ - "banned" - ] - } - } - }, - "accountEditLabels": { - "type": "object", "minProperties": 1, "maxProperties": 16, - "propertyNames": {"minLength": 1, "maxLength": 32}, - "additionalProperties": {"type": "string", "minLength": 1, "maxLength": 256} - }, - "accountEdit": { - "type": "object", "additionalProperties": false, - "required": ["version", "platform", "account_type", "credential_profile", "credential_ui", "catalog_source", "mapping_policy", "compact_mapping_editable", "wire_controls", "unlisted_fields", "labels"], - "properties": { - "version": {"const": 1}, "platform": {"const": "cindy"}, - "account_type": {"const": "apikey"}, "credential_profile": {"const": "cindy_laxa_v1"}, - "credential_ui": { - "type": "object", "additionalProperties": false, "required": ["base_url", "base_url_readonly", "hint"], - "properties": { - "base_url": {"const": "https://api.laxarouter.ai"}, "base_url_readonly": {"const": true}, - "hint": {"$ref": "#/$defs/accountEditLabels"} - } - }, - "catalog_source": {"const": "provider_catalog_snapshot_v1"}, - "mapping_policy": {"const": "managed_catalog_v1"}, - "compact_mapping_editable": {"type": "boolean"}, - "unlisted_fields": {"const": "preserve"}, - "wire_controls": { - "type": "array", "maxItems": 3, - "items": { - "type": "object", "additionalProperties": false, "required": ["target", "values", "allow_clear"], - "properties": { - "target": {"enum": ["responses_mode", "compact_mode", "responses_websocket_mode"]}, - "values": {"type": "array", "minItems": 1, "maxItems": 4, "uniqueItems": true, "items": {"type": "string"}}, - "allow_clear": {"type": "boolean"} - }, - "allOf": [ - {"if": {"properties": {"target": {"const": "responses_mode"}}}, "then": {"properties": {"values": {"maxItems": 3, "items": {"enum": ["auto", "force_responses", "force_chat_completions"]}}}}}, - {"if": {"properties": {"target": {"const": "compact_mode"}}}, "then": {"properties": {"values": {"maxItems": 3, "items": {"enum": ["auto", "force_on", "force_off"]}}}}}, - {"if": {"properties": {"target": {"const": "responses_websocket_mode"}}}, "then": {"properties": {"values": {"items": {"enum": ["off", "ctx_pool", "passthrough", "http_bridge"]}}}}} - ] - } - }, - "labels": { - "type": "object", "additionalProperties": false, "required": ["managed_catalog", "managed_aliases", "custom_mappings"], - "properties": { - "managed_catalog": {"$ref": "#/$defs/accountEditLabels"}, - "managed_aliases": {"$ref": "#/$defs/accountEditLabels"}, - "custom_mappings": {"$ref": "#/$defs/accountEditLabels"} - } - } - } - }, - "accountCreate": { - "type": "object", - "additionalProperties": false, - "required": [ - "version", - "platform", - "account_type", - "credential_profile", - "credential_ui", - "defaults", - "minimum_effective_groups", - "upstream_billing_probe", - "model_editing", - "catalog_source", - "field_bindings" - ], - "properties": { - "version": { - "const": 1 - }, - "platform": { - "const": "cindy" - }, - "account_type": { - "const": "apikey" - }, - "credential_profile": { - "const": "cindy_laxa_v1" - }, - "credential_ui": { - "type": "object", - "additionalProperties": false, - "required": [ - "base_url", - "base_url_readonly", - "api_key_placeholder", - "hint" - ], - "properties": { - "base_url": { - "const": "https://api.laxarouter.ai" - }, - "base_url_readonly": { - "const": true - }, - "api_key_placeholder": { - "type": "string", - "minLength": 1, - "maxLength": 128 - }, - "hint": { - "type": "object", - "minProperties": 1, - "maxProperties": 16, - "additionalProperties": { - "type": "string", - "minLength": 1, - "maxLength": 256 - } - }, - "base_url_hint": { - "type": "object", - "minProperties": 1, - "maxProperties": 16, - "additionalProperties": { - "type": "string", - "minLength": 1, - "maxLength": 256 - } - }, - "account_type_hint": { - "type": "object", - "minProperties": 1, - "maxProperties": 16, - "additionalProperties": { - "type": "string", - "minLength": 1, - "maxLength": 256 - } - }, - "catalog_label": { - "type": "object", - "minProperties": 1, - "maxProperties": 16, - "additionalProperties": { - "type": "string", - "minLength": 1, - "maxLength": 256 - } - }, - "catalog_hint": { - "type": "object", - "minProperties": 1, - "maxProperties": 16, - "additionalProperties": { - "type": "string", - "minLength": 1, - "maxLength": 256 - } - } - } - }, - "defaults": { - "type": "object", - "additionalProperties": false, - "required": [ - "concurrency", - "priority", - "rate_multiplier", - "load_factor", - "responses_mode" - ], - "properties": { - "concurrency": { - "type": "integer", - "minimum": 1 - }, - "priority": { - "type": "integer", - "minimum": 0 - }, - "rate_multiplier": { - "type": "number", - "minimum": 0 - }, - "load_factor": { - "type": [ - "integer", - "null" - ], - "minimum": 1, - "maximum": 10000 - }, - "responses_mode": { - "enum": [ - "auto", - "force_responses", - "force_chat_completions" - ] - } - } - }, - "minimum_effective_groups": { - "const": 1 - }, - "upstream_billing_probe": { - "const": "unsupported" - }, - "model_editing": { - "const": "provider_managed" - }, - "catalog_source": { - "const": "group_model_candidates" - }, - "field_bindings": { - "type": "object", - "maxProperties": 8, - "propertyNames": { - "pattern": "^[a-z][a-z0-9_]{0,63}$" - }, - "additionalProperties": { - "const": "provider_device_identity_input" - } - } - } } } } diff --git a/backend/pkg/pluginapi/v1/runtime.go b/backend/pkg/pluginapi/v1/runtime.go index e38ea18ea247..22a138aaaac0 100644 --- a/backend/pkg/pluginapi/v1/runtime.go +++ b/backend/pkg/pluginapi/v1/runtime.go @@ -44,14 +44,7 @@ type HostBrokerReceiver interface { // 本连接的 go-plugin broker,宿主据此把 HostService 反向暴露给插件。 type TransportClient struct { TransportPluginClient - Broker *hcplugin.GRPCBroker - Connection *grpc.ClientConn -} - -// AdditionalServiceRegistrar allows a plugin to expose separately versioned -// capabilities without changing the official transport RPC contract. -type AdditionalServiceRegistrar interface { - RegisterAdditionalServices(grpc.ServiceRegistrar) + Broker *hcplugin.GRPCBroker } // GRPCPlugin 把生成的 gRPC 服务注册到 go-plugin 子进程。 @@ -62,9 +55,6 @@ type GRPCPlugin struct { func (p *GRPCPlugin) GRPCServer(broker *hcplugin.GRPCBroker, server *grpc.Server) error { RegisterTransportPluginServer(server, p.Impl) - if registrar, ok := p.Impl.(AdditionalServiceRegistrar); ok { - registrar.RegisterAdditionalServices(server) - } // 插件侧:把 broker 交给需要反向访问宿主服务的实现(可选能力)。 if receiver, ok := p.Impl.(HostBrokerReceiver); ok { receiver.SetHostBroker(broker) @@ -74,7 +64,7 @@ func (p *GRPCPlugin) GRPCServer(broker *hcplugin.GRPCBroker, server *grpc.Server func (p *GRPCPlugin) GRPCClient(_ context.Context, broker *hcplugin.GRPCBroker, conn *grpc.ClientConn) (any, error) { // 宿主侧:捆绑 broker,供 startPluginRuntime 反向暴露 HostService。 - return &TransportClient{TransportPluginClient: NewTransportPluginClient(conn), Broker: broker, Connection: conn}, nil + return &TransportClient{TransportPluginClient: NewTransportPluginClient(conn), Broker: broker}, nil } // ClientPluginMap 返回宿主侧使用的插件声明。 diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 3e00c000ee30..7df084fcbe12 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -4,7 +4,7 @@ import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], - // Match the host and public plugin builds: runtime-only i18n uses CSP-safe JIT. + // Match the production build: runtime-only i18n uses CSP-safe JIT. define: { __INTLIFY_JIT_COMPILATION__: true }, server: { fs: { allow: [resolve(__dirname, '..')] } }, resolve: { From 55ae016eeea25fe00cf20a7e1ab6bb6d239e498c Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 5 Oct 2026 13:17:01 +0800 Subject: [PATCH 3/5] fix(migrations): 272 names a remedy that works for each refusal Migration 272 gave one remedy, "start v0.2.13-codexrip.7 once", for three different states of the retirement receipt. An earlier release retires an installation only while no receipt is stored: on a receipt that is not completed it exits at start, and once a completed one is stored it retires nothing. - A stored receipt that does not cover an installed first-party plugin now raises its own message: no release repairs it and it is resolved by hand. The message for a missing receipt is unchanged in substance. Both, and the two for other installations, name v0.2.13-codexrip.8, the last release before these migrations, and point to .downstream/native-domains.md. - The document says what such a refusal leaves behind. Each migration commits on its own, so 270 and 271 are applied by then; v0.2.13-codexrip.8 needs Image Studio off and usage_billing_dedup.account_id added back on that database. The previous commit had removed both facts. - It names what the seven plugins stored outside the database (package files, unpacked trees, Redis keys with the codexrip. prefix) instead of everything under the plugin directory, and says they are deleted by hand at the release that applies 272. The files of other plugins stay. - It no longer describes the deleted startup retirement by negation. Test: the three cases of an uncovered installation expect the two messages. The assertions that a refused migration changes nothing ran after a rollback to a savepoint and could not fail; they are removed, since the runner's one transaction per file is what guarantees it. Co-Authored-By: Claude Opus 5.5 --- .downstream/native-domains.md | 65 +++++++++++++------ ...plugin_purge_migration_integration_test.go | 28 ++++---- .../272_purge_first_party_plugins.sql | 33 ++++++---- 3 files changed, 83 insertions(+), 43 deletions(-) diff --git a/.downstream/native-domains.md b/.downstream/native-domains.md index ba7cbde4bf00..c9bf9c7d746c 100644 --- a/.downstream/native-domains.md +++ b/.downstream/native-domains.md @@ -35,23 +35,42 @@ saved Codex runtime configuration has no hash recorded by v0.2.11-codexrip.6, key. Starting one of those three releases once satisfies the first two conditions. -Migration 272 stops startup, changing nothing, in three cases: - -1. One of the seven first-party plugin installations exists and the receipt of - the retirement, a `settings` row, is missing, is not completed or has no - entry for it: that installation was never retired. Starting - v0.2.13-codexrip.7 once retires it. -2. Another installation is in the state `updating`. -3. Two enabled bindings share a capability, platform and account type. - -Upstream's schema admits neither 2 nor 3 and the plugin manager writes neither; -disabling or uninstalling the plugin in v0.2.13-codexrip.7 clears both. - -The host does not retire plugin installations at startup and keeps no list of -retired plugins. The plugin tables, the plugin manager and its admin interfaces -are upstream's: the manager lists and manages every installation row. Missing -settings may use deployment defaults. Database errors and malformed saved -settings stop startup; they never silently enable features. +Migration 272 stops startup, changing nothing itself, in four cases: + +1. One of the seven first-party plugin installations exists and no receipt of + the retirement, the `settings` row `deplugin_retired_plugins`, is stored: + that installation was never retired. Starting v0.2.13-codexrip.8 once + retires it. +2. One of them exists and the stored receipt is not completed or has no entry + for it. No release writes such a receipt and none repairs it: + v0.2.13-codexrip.8 exits at start on a receipt that is not completed and + retires nothing once a completed one is stored. Whether the configuration + saved in such an installation is still needed is decided by hand; 272 + applies once the installations it names are deleted. +3. Another installation is in the state `updating`. +4. Two enabled bindings share a capability, platform and account type. + +Upstream's schema admits neither 3 nor 4 and the plugin manager writes neither; +disabling or uninstalling the plugin in v0.2.13-codexrip.8 clears both. + +Each migration commits on its own, so a database on which 272 has stopped +already has 270 and 271, which v0.2.13-codexrip.8 and every earlier release +lack. Such an image starts on that database only with Image Studio off, which +the deleted `image_tools_config` setting leaves to +`GATEWAY_IMAGE_STUDIO_ENABLED`: with that variable `true` it exits at start, +and its Image Studio job routes fail on the dropped tables. It names +`usage_billing_dedup.account_id` in every billing claim, so its usage billing +fails until the column is added again +(`ALTER TABLE usage_billing_dedup ADD COLUMN account_id BIGINT`). Migration 271 +is recorded by then and does not run a second time: after returning to this +release, drop the column by hand +(`ALTER TABLE usage_billing_dedup DROP COLUMN IF EXISTS account_id`). Billing +works with the column present until that is done. + +The plugin tables, the plugin manager and its admin interfaces are upstream's: +the manager lists and manages every installation row. Missing settings may use +deployment defaults. Database errors and malformed saved settings stop startup; +they never silently enable features. ## Release and rollback boundary @@ -88,8 +107,16 @@ columns `revision`, `runtime_generation`, `package_sha256` and `update_policy`, the state `updating`, the trigger and function `sub2api_plugin_revision`, and the limit of the unique index on enabled bindings to two capabilities. Other installations, their bindings and the `admin_observability_config` setting are -not written. The plugin files under `/plugins` and the Redis keys -`plugin:kv:v1:*` are not in the database and are not removed. +not written. + +A migration does not reach what the seven plugins stored outside the database: +their package files `/packages/codexrip.*.s2plugin`, their unpacked +trees `/installed/codexrip.*/` and any Redis key +`plugin:kv:v1:codexrip.*`, where `` is `plugins.data_dir` or, when +that is not set, `/plugins`. Nothing reads these once 272 has +applied; they are deleted by hand at the release that applies it. The files +and keys of other plugins in the same directory and under the same prefix are +in use and stay. After migration 272 no earlier image starts on the database. Every earlier release since v0.2.8-codexrip.5 retires first-party plugin installations at diff --git a/backend/internal/repository/first_party_plugin_purge_migration_integration_test.go b/backend/internal/repository/first_party_plugin_purge_migration_integration_test.go index 58a385d6896e..60c06e30011e 100644 --- a/backend/internal/repository/first_party_plugin_purge_migration_integration_test.go +++ b/backend/internal/repository/first_party_plugin_purge_migration_integration_test.go @@ -239,7 +239,13 @@ RETURNING id`, values...).Scan(&id)) } }) - const upgrade = "; start v0.2.13-codexrip.7 once, then upgrade" + // Only an installation without any receipt is retired by an earlier release; + // a receipt that is stored but does not cover an installation is not. + const ( + earlier = " in v0.2.13-codexrip.8 as .downstream/native-domains.md describes, then upgrade" + unretired = "; start v0.2.13-codexrip.8 once as .downstream/native-domains.md describes, then upgrade" + uncovered = "; no release repairs it, resolve it as .downstream/native-domains.md describes, then upgrade" + ) for _, test := range []struct { name string installed []string // first-party installations the database holds @@ -249,24 +255,24 @@ RETURNING id`, values...).Scan(&id)) refusal string // what 272 raises; "" when it applies }{ {name: "installation without a receipt", installed: []string{"codexrip.admin-observability"}, - refusal: "first-party plugin installations were never retired (codexrip.admin-observability)" + upgrade}, + refusal: "first-party plugin installations were never retired (codexrip.admin-observability)" + unretired}, {name: "receipt that is not completed", installed: []string{"codexrip.admin-observability"}, completed: "false", entries: []string{"codexrip.admin-observability"}, - refusal: "first-party plugin installations were never retired (codexrip.admin-observability)" + upgrade}, + refusal: "the retirement receipt is not completed or has no entry for first-party plugin installations (codexrip.admin-observability)" + uncovered}, {name: "receipt without an entry for an installation", installed: []string{"codexrip.admin-observability", "codexrip.model-policy", "codexrip.account-tools"}, completed: "true", entries: []string{"codexrip.admin-observability"}, - refusal: "first-party plugin installations were never retired (codexrip.account-tools, codexrip.model-policy)" + upgrade}, + refusal: "the retirement receipt is not completed or has no entry for first-party plugin installations (codexrip.account-tools, codexrip.model-policy)" + uncovered}, {name: "receipt without an installation", completed: "false", entries: []string{"codexrip.admin-observability"}}, {name: "third-party installation in the state updating", completed: "true", store: func(f fixture, otherID int64) { exec(f, `UPDATE sub2api_plugin_installations SET state = 'updating' WHERE id = $1`, otherID) }, - refusal: "plugin installations are in a state the plugin manager does not have (acme.transport is updating); disable or uninstall them in v0.2.13-codexrip.7, then upgrade"}, + refusal: "plugin installations are in a state the plugin manager does not have (acme.transport is updating); disable or uninstall them" + earlier}, {name: "two third-party bindings enabled for one scope", completed: "true", store: func(f fixture, otherID int64) { bind(f, otherID, "extensions.request.v1", "openai", "oauth", true, 100) bind(f, install(f, second, "enabled", false), "extensions.request.v1", "openai", "oauth", true, 100) }, - refusal: "more than one enabled plugin binding shares a scope (extensions.request.v1 openai/oauth); disable all but one of the plugins in v0.2.13-codexrip.7, then upgrade"}, + refusal: "more than one enabled plugin binding shares a scope (extensions.request.v1 openai/oauth); disable all but one of the plugins" + earlier}, } { t.Run(test.name, func(t *testing.T) { f, upstream := open(t) @@ -282,18 +288,14 @@ RETURNING id`, values...).Scan(&id)) if test.store != nil { test.store(f, otherID) } - before, stored, schema := kept(f, otherID), purged(f), tables(f, "public") - if test.refusal != "" { - exec(f, `SAVEPOINT before_272`) + // The runner applies a file in one transaction, so a file that + // raises changes nothing: only what it raises is checked here. _, err := f.tx.ExecContext(ctx, migration) require.ErrorContains(t, err, "migration 272: "+test.refusal) - exec(f, `ROLLBACK TO SAVEPOINT before_272`) - require.Equal(t, stored, purged(f), "a refused migration changes nothing") - require.Equal(t, schema, tables(f, "public")) - require.Equal(t, before, kept(f, otherID)) return } + before := kept(f, otherID) apply(f) require.Zero(t, purged(f)) require.Equal(t, upstream, tables(f, "public")) diff --git a/backend/migrations/272_purge_first_party_plugins.sql b/backend/migrations/272_purge_first_party_plugins.sql index 4a007d4a1222..e6256fb6f119 100644 --- a/backend/migrations/272_purge_first_party_plugins.sql +++ b/backend/migrations/272_purge_first_party_plugins.sql @@ -17,18 +17,26 @@ -- sub2api_plugin_bindings are then as 229 and 230 define them. -- -- Left: every other installation with its bindings, of which no row is --- written; settings.admin_observability_config; and what is not in the --- database, the plugin files under /plugins and the Redis keys --- plugin:kv:v1:*. +-- written, and settings.admin_observability_config. A migration does not +-- reach what the seven plugins stored outside the database: their package +-- files /packages/codexrip.*.s2plugin, their unpacked trees +-- /installed/codexrip.*/ and any Redis key +-- plugin:kv:v1:codexrip.*. Nothing reads these once the rows are gone; they +-- are deleted by hand at the release that applies this migration. -- -- The migration raises, and then changes nothing, when --- * one of the seven installations exists and the receipt is missing, is not --- completed or has no entry for it: that installation was never retired. --- Starting v0.2.13-codexrip.7 once retires it; +-- * one of the seven installations exists and no receipt is stored: that +-- installation was never retired. Starting v0.2.13-codexrip.8 once retires +-- it; +-- * one of them exists and the stored receipt is not completed or has no +-- entry for it. No release writes such a receipt and none repairs it; -- * another installation is in a state 229 does not have ('updating', which -- 250 added), or two enabled bindings share a capability, platform and -- account type: the check and the unique index of 229 do not admit them. --- Disabling or uninstalling the plugin in v0.2.13-codexrip.7 clears both. +-- Disabling or uninstalling the plugin in v0.2.13-codexrip.8 clears both. +-- Migrations 270 and 271 are committed by then. .downstream/native-domains.md +-- says what v0.2.13-codexrip.8 needs on such a database and how a receipt no +-- release repairs is resolved. -- -- Both plugin tables stay locked until the migration commits; lock_timeout -- bounds the wait. There is no down path: an earlier image finds no receipt, @@ -57,8 +65,11 @@ BEGIN WHERE plugin_key = ANY (first_party) AND (receipt -> 'completed' IS DISTINCT FROM 'true'::jsonb OR COALESCE(jsonb_typeof(receipt -> 'plugins' -> plugin_key), '') <> 'object'); - IF refused IS NOT NULL THEN - RAISE EXCEPTION 'migration 272: first-party plugin installations were never retired (%); start v0.2.13-codexrip.7 once, then upgrade', refused; + -- settings.value is NOT NULL: receipt is NULL only while no row is stored. + IF refused IS NOT NULL AND receipt IS NULL THEN + RAISE EXCEPTION 'migration 272: first-party plugin installations were never retired (%); start v0.2.13-codexrip.8 once as .downstream/native-domains.md describes, then upgrade', refused; + ELSIF refused IS NOT NULL THEN + RAISE EXCEPTION 'migration 272: the retirement receipt is not completed or has no entry for first-party plugin installations (%); no release repairs it, resolve it as .downstream/native-domains.md describes, then upgrade', refused; END IF; END IF; @@ -67,7 +78,7 @@ BEGIN WHERE plugin_key <> ALL (first_party) AND state NOT IN ('disabled', 'starting', 'enabled', 'error', 'incompatible'); IF refused IS NOT NULL THEN - RAISE EXCEPTION 'migration 272: plugin installations are in a state the plugin manager does not have (%); disable or uninstall them in v0.2.13-codexrip.7, then upgrade', refused; + RAISE EXCEPTION 'migration 272: plugin installations are in a state the plugin manager does not have (%); disable or uninstall them in v0.2.13-codexrip.8 as .downstream/native-domains.md describes, then upgrade', refused; END IF; SELECT string_agg(scope, ', ' ORDER BY scope) INTO refused @@ -82,7 +93,7 @@ BEGIN LIMIT 5 ) AS shared; IF refused IS NOT NULL THEN - RAISE EXCEPTION 'migration 272: more than one enabled plugin binding shares a scope (%); disable all but one of the plugins in v0.2.13-codexrip.7, then upgrade', refused; + RAISE EXCEPTION 'migration 272: more than one enabled plugin binding shares a scope (%); disable all but one of the plugins in v0.2.13-codexrip.8 as .downstream/native-domains.md describes, then upgrade', refused; END IF; DELETE FROM sub2api_plugin_installations WHERE plugin_key = ANY (first_party); From ba4106957930ba8eb9e179726a188553f687356b Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 5 Oct 2026 13:20:09 +0800 Subject: [PATCH 4/5] test(migrations): the refusal cases of 264 check what the migration raises The test of 264 held the pattern the test of 272 took over: after the refused migration it rolled back to a savepoint and then asserted that nothing had changed, which holds for any migration. The assertions are removed. That a file which raises changes nothing follows from the runner's one transaction per file. Co-Authored-By: Claude Opus 5.5 --- ...runtime_plugin_purge_migration_integration_test.go | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/backend/internal/repository/codex_runtime_plugin_purge_migration_integration_test.go b/backend/internal/repository/codex_runtime_plugin_purge_migration_integration_test.go index ef485e3fad77..e5186088a5d6 100644 --- a/backend/internal/repository/codex_runtime_plugin_purge_migration_integration_test.go +++ b/backend/internal/repository/codex_runtime_plugin_purge_migration_integration_test.go @@ -338,17 +338,10 @@ VALUES ($1,'Native Codex state anchor','0.0.0','Persistent native runtime genera } if test.wantErr != "" { - exec(f, `SAVEPOINT before_264`) + // The runner applies a file in one transaction, so a file that + // raises changes nothing: only what it raises is checked here. _, err := f.tx.ExecContext(ctx, migration) require.ErrorContains(t, err, test.wantErr) - exec(f, `ROLLBACK TO SAVEPOINT before_264`) - require.Equal(t, 2, droppedTables(f), "a refused migration changes nothing") - if test.otherState { - require.Equal(t, 2, count(f, `SELECT count(*) FROM sub2api_plugin_state`)) - } - require.Equal(t, 1, count(f, `SELECT count(*) FROM sub2api_plugin_installations WHERE id = $1`, codexID)) - _, found := setting(f, "codex_runtime_config") - require.False(t, found) return } apply(f) From 450d1b61a50f9b800b769c43a26670a387b9d45d Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 5 Oct 2026 15:14:26 +0800 Subject: [PATCH 5/5] fix(migrations): 272 points at DOWNSTREAM.md, a name that stays The refusal messages of 272 cannot change once the migration is released. They named .downstream/native-domains.md, a file whose name comes from the plugin era and is due to change. DOWNSTREAM.md links to the same text and keeps its name. Co-Authored-By: Claude Opus 5.5 --- ...arty_plugin_purge_migration_integration_test.go | 6 +++--- .../migrations/272_purge_first_party_plugins.sql | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/backend/internal/repository/first_party_plugin_purge_migration_integration_test.go b/backend/internal/repository/first_party_plugin_purge_migration_integration_test.go index 60c06e30011e..5ebf9522366f 100644 --- a/backend/internal/repository/first_party_plugin_purge_migration_integration_test.go +++ b/backend/internal/repository/first_party_plugin_purge_migration_integration_test.go @@ -242,9 +242,9 @@ RETURNING id`, values...).Scan(&id)) // Only an installation without any receipt is retired by an earlier release; // a receipt that is stored but does not cover an installation is not. const ( - earlier = " in v0.2.13-codexrip.8 as .downstream/native-domains.md describes, then upgrade" - unretired = "; start v0.2.13-codexrip.8 once as .downstream/native-domains.md describes, then upgrade" - uncovered = "; no release repairs it, resolve it as .downstream/native-domains.md describes, then upgrade" + earlier = " in v0.2.13-codexrip.8 as DOWNSTREAM.md describes, then upgrade" + unretired = "; start v0.2.13-codexrip.8 once as DOWNSTREAM.md describes, then upgrade" + uncovered = "; no release repairs it, resolve it as DOWNSTREAM.md describes, then upgrade" ) for _, test := range []struct { name string diff --git a/backend/migrations/272_purge_first_party_plugins.sql b/backend/migrations/272_purge_first_party_plugins.sql index e6256fb6f119..096410082751 100644 --- a/backend/migrations/272_purge_first_party_plugins.sql +++ b/backend/migrations/272_purge_first_party_plugins.sql @@ -34,9 +34,9 @@ -- 250 added), or two enabled bindings share a capability, platform and -- account type: the check and the unique index of 229 do not admit them. -- Disabling or uninstalling the plugin in v0.2.13-codexrip.8 clears both. --- Migrations 270 and 271 are committed by then. .downstream/native-domains.md --- says what v0.2.13-codexrip.8 needs on such a database and how a receipt no --- release repairs is resolved. +-- Migrations 270 and 271 are committed by then. DOWNSTREAM.md leads to what +-- v0.2.13-codexrip.8 needs on such a database and how a receipt no release +-- repairs is resolved. -- -- Both plugin tables stay locked until the migration commits; lock_timeout -- bounds the wait. There is no down path: an earlier image finds no receipt, @@ -67,9 +67,9 @@ BEGIN OR COALESCE(jsonb_typeof(receipt -> 'plugins' -> plugin_key), '') <> 'object'); -- settings.value is NOT NULL: receipt is NULL only while no row is stored. IF refused IS NOT NULL AND receipt IS NULL THEN - RAISE EXCEPTION 'migration 272: first-party plugin installations were never retired (%); start v0.2.13-codexrip.8 once as .downstream/native-domains.md describes, then upgrade', refused; + RAISE EXCEPTION 'migration 272: first-party plugin installations were never retired (%); start v0.2.13-codexrip.8 once as DOWNSTREAM.md describes, then upgrade', refused; ELSIF refused IS NOT NULL THEN - RAISE EXCEPTION 'migration 272: the retirement receipt is not completed or has no entry for first-party plugin installations (%); no release repairs it, resolve it as .downstream/native-domains.md describes, then upgrade', refused; + RAISE EXCEPTION 'migration 272: the retirement receipt is not completed or has no entry for first-party plugin installations (%); no release repairs it, resolve it as DOWNSTREAM.md describes, then upgrade', refused; END IF; END IF; @@ -78,7 +78,7 @@ BEGIN WHERE plugin_key <> ALL (first_party) AND state NOT IN ('disabled', 'starting', 'enabled', 'error', 'incompatible'); IF refused IS NOT NULL THEN - RAISE EXCEPTION 'migration 272: plugin installations are in a state the plugin manager does not have (%); disable or uninstall them in v0.2.13-codexrip.8 as .downstream/native-domains.md describes, then upgrade', refused; + RAISE EXCEPTION 'migration 272: plugin installations are in a state the plugin manager does not have (%); disable or uninstall them in v0.2.13-codexrip.8 as DOWNSTREAM.md describes, then upgrade', refused; END IF; SELECT string_agg(scope, ', ' ORDER BY scope) INTO refused @@ -93,7 +93,7 @@ BEGIN LIMIT 5 ) AS shared; IF refused IS NOT NULL THEN - RAISE EXCEPTION 'migration 272: more than one enabled plugin binding shares a scope (%); disable all but one of the plugins in v0.2.13-codexrip.8 as .downstream/native-domains.md describes, then upgrade', refused; + RAISE EXCEPTION 'migration 272: more than one enabled plugin binding shares a scope (%); disable all but one of the plugins in v0.2.13-codexrip.8 as DOWNSTREAM.md describes, then upgrade', refused; END IF; DELETE FROM sub2api_plugin_installations WHERE plugin_key = ANY (first_party);